Jeff Martin: 1 support palaver push notification irc capability 7 files changed, 702 insertions(+), 1 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.sr.ht/~emersion/soju-dev/patches/34538/mbox | git am -3Learn more about email & git
This change introduces support for the palaver push notifications extension. It requires no additional user configuration, as the required data comes from the CAP negotiation and subsequent commands described in the palaver capability spec. This is an exploratory RFC patch to give a sense of what an implementation of this feature could look like. The patch enables sending push notifications that, when tapped on the device, route to the correct buffer in palaver. Nothing beyond that is implemented (ie no proper badge management, no checking palaver notification preferences). Only the sqlite database interface is implemented. The implementation is at a high level is: At the beginning of each connection to soju, Palaver sends "PALAVER IDENTIFY" along with its the client information for that connection. This changes handles this by, after registration completes, responding with "PALAVER REQ" on changes to the palaver preference version described in the spec, which causes the Palaver client to send its notification configuration. This is built up in memory on the downstream connection struct instance and then stored in the db when the response completes (with a "PALAVER END" message). After that, push notification requests can be sent. The decision whether to send a push notification ignores the palaver app preferences in this change. Instead, it piggybacks off the webpush code for that decision. Badges aren't implemented but there is a sqlite table for reference. There are TODOs in the patch to indicate additional pieces of the implementation. quick links: https://github.com/cocodelabs/palaver-irc-capability/blob/master/Specification.md https://git.causal.agency/pounce/tree/palaver.c https://github.com/cocodelabs/znc-palaver/blob/master/palaver.cpp --- Based on a discussion in IRC, looks like this wont go forward, but I'm leaving it here in case there are other poor souls who don't want to wait for palaver to implement webpush. database/database.go | 70 +++++++++ database/postgres.go | 12 ++ database/sqlite.go | 358 +++++++++++++++++++++++++++++++++++++++++++ downstream.go | 164 +++++++++++++++++++- palaver.go | 50 ++++++ upstream.go | 23 +++ user.go | 26 ++++ 7 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 palaver.go diff --git a/database/database.go b/database/database.go index eb5240e..f04f62b 100644 --- a/database/database.go +++ b/database/database.go @@ -2,6 +2,7 @@ package database import ( "context" + "errors" "fmt" "net/url" "strings" @@ -39,6 +40,10 @@ type Database interface { ListWebPushSubscriptions(ctx context.Context, userID, networkID int64) ([]WebPushSubscription, error) StoreWebPushSubscription(ctx context.Context, userID, networkID int64, sub *WebPushSubscription) error DeleteWebPushSubscription(ctx context.Context, id int64) error + + StorePalaverConfig(ctx context.Context, config *PalaverConfig) error + ListPalaverConfigs(ctx context.Context, userID, networkID int64) ([]*PalaverConfig, error) + HasPalaverPreferences(ctx context.Context, pc *PalaverClient) (bool, error) } type MetricsCollectorDatabase interface { @@ -235,3 +240,68 @@ type WebPushSubscription struct { VAPID string } } + +type PalaverClient struct { + ID int64 + User int64 + Network int64 + Suffix string + Token string + PreferenceVersion string + NetworkUUID string +} + +type PalaverClientPreference struct { + ID int64 + Client int64 + ClientToken string + ClientPreferenceVersion string + + // all possible SET preferences + PushToken string + PushEndpoint string + ShowMessagePreview bool // default true + +} + +type PalaverClientPreferenceLists struct { + // all possible ADD preferences + // TODO substitute "{nick}" for the current user's nick + ID int64 + Client int64 + MentionKeyword []string + MentionChannel []string + MentionNick []string + IgnoreKeyword []string + IgnoreChannel []string + IgnoreNick []string +} + +func (pl *PalaverClientPreferenceLists) Append(key, val string) error { + switch key { + case "MENTION-KEYWORD": + pl.MentionKeyword = append(pl.MentionKeyword, val) + case "MENTION-CHANNEL": + pl.MentionKeyword = append(pl.MentionChannel, val) + case "MENTION-NICK": + pl.MentionKeyword = append(pl.MentionNick, val) + case "IGNORE-KEYWORD": + pl.MentionKeyword = append(pl.IgnoreKeyword, val) + case "IGNORE-CHANNEL": + pl.MentionKeyword = append(pl.IgnoreChannel, val) + case "IGNORE-NICK": + pl.MentionKeyword = append(pl.IgnoreNick, val) + default: + return errors.New("unsupported palaver preference list key") + } + + return nil +} + +type PalaverConfig struct { + Foreground bool + Badge int + Client *PalaverClient + Preferences *PalaverClientPreference + Lists *PalaverClientPreferenceLists +} diff --git a/database/postgres.go b/database/postgres.go index 98030fe..f0cb76c 100644 --- a/database/postgres.go +++ b/database/postgres.go @@ -795,6 +795,18 @@ func (db *PostgresDB) DeleteWebPushSubscription(ctx context.Context, id int64) e return err } +func (db *PostgresDB) StorePalaverConfig(ctx context.Context, pc *PalaverConfig) error { + return errors.New("not implemented") +} + +func (db *PostgresDB) HasPalaverPreferences(ctx context.Context, pc *PalaverClient) (bool, error) { + return false, errors.New("not implemented") +} + +func (db *PostgresDB) ListPalaverConfigs(ctx context.Context, userID, networkID int64) ([]*PalaverConfig, error) { + return nil, errors.New("not implemented") +} + var postgresNetworksTotalDesc = prometheus.NewDesc("soju_networks_total", "Number of networks", []string{"hostname"}, nil) type postgresMetricsCollector struct { diff --git a/database/sqlite.go b/database/sqlite.go index fc70f11..1384827 100644 --- a/database/sqlite.go +++ b/database/sqlite.go @@ -3,6 +3,7 @@ package database import ( "context" "database/sql" + "errors" "fmt" "math" "strings" @@ -108,6 +109,52 @@ CREATE TABLE WebPushSubscription ( FOREIGN KEY(network) REFERENCES Network(id), UNIQUE(network, endpoint) ); + +CREATE TABLE PalaverClient ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + user INTEGER NOT NULL, + network INTEGER NOT NULL, + client_suffix TEXT NOT NULL, + token TEXT NOT NULL, + preference_version TEXT NOT NULL, + network_uuid TEXT NOT NULL, + FOREIGN KEY(user) REFERENCES User(id), + FOREIGN KEY(network) REFERENCES Network(id), + UNIQUE(user, network, client_suffix, token) +); + +-- TODO probably want indexes on client for these two tables since reading the +-- preferences will happen for every privmsg, notice, and invite +CREATE TABLE PalaverPreferences ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + client INTEGER NOT NULL, + push_token TEXT NOT NULL, + push_endpoint TEXT NOT NULL, + show_message_preview INTEGER NOT NULL, + FOREIGN KEY(client) REFERENCES PalaverClient(id) +); + +CREATE TABLE PalaverPreferenceList ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + client INTEGER NOT NULL, + key TEXT NOT NULL CHECK( key IN ('MENTION-KEYWORD', 'MENTION-CHANNEL', 'MENTION-NICK', 'IGNORE-KEYWORD', 'IGNORE-CHANNEL', 'IGNORE-NICK') ), + val TEXT NOT NULL, + FOREIGN KEY(client) REFERENCES PalaverClient(id), + UNIQUE(client, key, val) +); + +CREATE TABLE PalaverBadge ( + id INTEGER PRIMARY KEY, + client INTEGER NOT NULL, + count INTEGER NOT NULL, + FOREIGN KEY(client) REFERENCES PalaverClient(id), + UNIQUE(client) +); ` var sqliteMigrations = []string{ @@ -245,6 +292,51 @@ var sqliteMigrations = []string{ UPDATE WebPushSubscription AS wps SET user = (SELECT n.user FROM Network AS n WHERE n.id = wps.network); `, "ALTER TABLE User ADD COLUMN nick TEXT;", + ` + CREATE TABLE PalaverClient ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + user INTEGER NOT NULL, + network INTEGER NOT NULL, + client_suffix TEXT NOT NULL, + token TEXT NOT NULL, + preference_version TEXT NOT NULL, + network_uuid TEXT NOT NULL, + FOREIGN KEY(user) REFERENCES User(id), + FOREIGN KEY(network) REFERENCES Network(id), + UNIQUE(user, network, client_suffix, token) + ); + + CREATE TABLE PalaverPreferences ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + client INTEGER NOT NULL, + push_token TEXT NOT NULL, + push_endpoint TEXT NOT NULL, + show_message_preview INTEGER NOT NULL, + FOREIGN KEY(client) REFERENCES PalaverClient(id) + ); + + CREATE TABLE PalaverPreferenceList ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + client INTEGER NOT NULL, + key TEXT NOT NULL CHECK( key IN ('MENTION-KEYWORD', 'MENTION-CHANNEL', 'MENTION-NICK', 'IGNORE-KEYWORD', 'IGNORE-CHANNEL', 'IGNORE-NICK') ), + val TEXT NOT NULL, + FOREIGN KEY(client) REFERENCES PalaverClient(id), + UNIQUE(client, key, val) + ); + + CREATE TABLE PalaverBadge ( + id INTEGER PRIMARY KEY, + client INTEGER NOT NULL, + count INTEGER NOT NULL, + FOREIGN KEY(client) REFERENCES PalaverClient(id), + UNIQUE(client) + ); + `, } type SqliteDB struct { @@ -970,3 +1062,269 @@ func (db *SqliteDB) DeleteWebPushSubscription(ctx context.Context, id int64) err _, err := db.db.ExecContext(ctx, "DELETE FROM WebPushSubscription WHERE id = ?", id) return err } + +func (db *SqliteDB) StorePalaverConfig(ctx context.Context, pc *PalaverConfig) error { + if err := db.storePalaverClient(ctx, pc.Client); err != nil { + return err + } + pc.Preferences.Client = pc.Client.ID + if err := db.storePalaverPreferences(ctx, pc.Preferences); err != nil { + return err + } + pc.Lists.Client = pc.Client.ID + if err := db.storePalaverPreferenceLists(ctx, pc.Lists); err != nil { + return err + } + return nil +} + +func (db *SqliteDB) storePalaverClient(ctx context.Context, pc *PalaverClient) error { + ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) + defer cancel() + + // TODO when there is a token with a stale preference_version, cascade + // delete / replace the previous PalaverClient rows for this token + + args := []interface{}{ + sql.Named("id", pc.ID), + sql.Named("now", formatSqliteTime(time.Now())), + sql.Named("user", pc.User), + sql.Named("network", pc.Network), + sql.Named("client_suffix", pc.Suffix), + sql.Named("token", pc.Token), + sql.Named("preference_version", pc.PreferenceVersion), + sql.Named("network_uuid", pc.NetworkUUID), + } + + var err error + if pc.ID > 0 { + _, err = db.db.ExecContext(ctx, ` + UPDATE PalaverClient + SET updated_at = :now, token = :token, + preference_version = :preference_version, + network_uuid = :network_uuid + WHERE id = :id +`, + args...) + } else { + var res sql.Result + res, err = db.db.ExecContext(ctx, ` + INSERT INTO + PalaverClient(created_at, updated_at, user, network, client_suffix, token, preference_version, network_uuid) + VALUES (:now, :now, :user, :network, :client_suffix, :token, :preference_version, :network_uuid)`, + args...) + if err != nil { + return err + } + pc.ID, err = res.LastInsertId() + } + return err +} + +func (db *SqliteDB) storePalaverPreferences(ctx context.Context, pref *PalaverClientPreference) error { + ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) + defer cancel() + + args := []interface{}{ + sql.Named("id", pref.ID), + sql.Named("client", pref.Client), + sql.Named("now", formatSqliteTime(time.Now())), + sql.Named("push_token", pref.PushToken), + sql.Named("push_endpoint", pref.PushEndpoint), + sql.Named("show_message_preview", pref.ShowMessagePreview), + } + + var err error + if pref.ID > 0 { + _, err = db.db.ExecContext(ctx, ` + UPDATE PalaverPreferences + SET updated_at = :now, push_token = :push_token, + push_endpoint = :push_endpoint, + show_message_preview = :show_message_preview + WHERE id = :id +`, + args...) + } else { + var res sql.Result + res, err = db.db.ExecContext(ctx, ` + INSERT INTO + PalaverPreferences(created_at, updated_at, client, push_token, push_endpoint, show_message_preview) + VALUES (:now, :now, :client, :push_token, :push_endpoint, :show_message_preview)`, + args...) + if err != nil { + return err + } + pref.ID, err = res.LastInsertId() + } + return err +} + +func (db *SqliteDB) storePalaverPreferenceLists(ctx context.Context, pl *PalaverClientPreferenceLists) error { + // this needs to be a transaction even if the other queries arent + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, "DELETE FROM PalaverPreferenceList WHERE client = ?", pl.Client) + if err != nil { + return err + } + args := []interface{}{ + sql.Named("now", formatSqliteTime(time.Now())), + sql.Named("client", pl.Client), + } + q := "INSERT INTO PalaverPreferenceList(created_at, client, key, val) VALUES (:now, :client, :key, :val)" + for _, v := range pl.MentionKeyword { + if _, err := tx.ExecContext(ctx, q, append(args, sql.Named("key", "MENTION-KEYWORD"), sql.Named("val", v))...); err != nil { + return err + } + } + for _, v := range pl.MentionChannel { + if _, err := tx.ExecContext(ctx, q, append(args, sql.Named("key", "MENTION-CHANNEL"), sql.Named("val", v))...); err != nil { + return err + } + } + for _, v := range pl.MentionNick { + if _, err := tx.ExecContext(ctx, q, append(args, sql.Named("key", "MENTION-NICK"), sql.Named("val", v))...); err != nil { + return err + } + } + for _, v := range pl.IgnoreKeyword { + if _, err := tx.ExecContext(ctx, q, append(args, sql.Named("key", "IGNORE-KEYWORD"), sql.Named("val", v))...); err != nil { + return err + } + } + for _, v := range pl.IgnoreChannel { + if _, err := tx.ExecContext(ctx, q, append(args, sql.Named("key", "IGNORE-CHANNEL"), sql.Named("val", v))...); err != nil { + return err + } + } + for _, v := range pl.IgnoreNick { + if _, err := tx.ExecContext(ctx, q, append(args, sql.Named("key", "IGNORE-NICK"), sql.Named("val", v))...); err != nil { + return err + } + } + return tx.Commit() +} + +func (db *SqliteDB) incrementPalaverBadgeCount(ctx context.Context, pc *PalaverClient) error { + ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) + defer cancel() + + q := ` + INSERT INTO PalaverBadge (addr, count) + VALUES (:addr, 1) + ON CONFLICT (addr) DO + UPDATE SET count = count + 1 + WHERE user = :user` + _, err := db.db.ExecContext(ctx, q, pc.User) + return err +} + +func (db *SqliteDB) resetPalaverBadgeCount(ctx context.Context, pc *PalaverClient) error { + ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) + defer cancel() + + _, err := db.db.ExecContext(ctx, "DELETE FROM PalaverBadge WHERE user = ?", pc.User) + return err +} + +func (db *SqliteDB) HasPalaverPreferences(ctx context.Context, pc *PalaverClient) (bool, error) { + ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) + defer cancel() + + args := []interface{}{ + sql.Named("user", pc.User), + sql.Named("client_suffix", pc.Suffix), + sql.Named("token", pc.Token), + sql.Named("preference_version", pc.PreferenceVersion), + } + q := ` + SELECT 1 FROM PalaverClient + WHERE user = :user + AND client_suffix = :client_suffix + AND token = :token + AND preference_version = :preference_version` + row := db.db.QueryRowContext(ctx, q, args...) + var n int + if err := row.Scan(&n); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + return n == 1, nil +} + +func (db *SqliteDB) ListPalaverConfigs(ctx context.Context, userID, networkID int64) ([]*PalaverConfig, error) { + ctx, cancel := context.WithTimeout(ctx, sqliteQueryTimeout) + defer cancel() + + args := []interface{}{ + sql.Named("user", userID), + sql.Named("network", networkID), + } + q := ` + SELECT id, user, network, client_suffix, token, preference_version, network_uuid + FROM PalaverClient + WHERE user = :user + AND network = :network` + + rows, err := db.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + + // TODO don't use a pointer to be like the web push subs code + var cfgs []*PalaverConfig + for rows.Next() { + cfg := &PalaverConfig{ + Client: &PalaverClient{}, + } + if err := rows.Scan(&cfg.Client.ID, &cfg.Client.User, &cfg.Client.Network, &cfg.Client.Suffix, &cfg.Client.Token, &cfg.Client.PreferenceVersion, &cfg.Client.NetworkUUID); err != nil { + return nil, err + } + cfgs = append(cfgs, cfg) + } + + for _, cfg := range cfgs { + q := ` + SELECT id, client, push_token, push_endpoint, show_message_preview + FROM PalaverPreferences + WHERE client = ? + ` + row := db.db.QueryRowContext(ctx, q, cfg.Client.ID) + prefs := &PalaverClientPreference{} + if err := row.Scan(&prefs.ID, &prefs.Client, &prefs.PushToken, &prefs.PushEndpoint, &prefs.ShowMessagePreview); err != nil { + return nil, err + } + cfg.Preferences = prefs + + q = ` + SELECT id, client, key, val + FROM PalaverPreferenceList + WHERE client = ? + ` + rows, err := db.db.QueryContext(ctx, q, cfg.Client.ID) + if err != nil { + return nil, err + } + + lists := &PalaverClientPreferenceLists{ + Client: cfg.Client.ID, + } + for rows.Next() { + var id, client int64 + var key, val string + if err := rows.Scan(&id, &client, &key, &val); err != nil { + return nil, err + } + if err := lists.Append(key, val); err != nil { + return nil, err + } + } + cfg.Lists = lists + } + return cfgs, nil +} diff --git a/downstream.go b/downstream.go index 6336eed..713296e 100644 --- a/downstream.go +++ b/downstream.go @@ -245,6 +245,8 @@ var permanentDownstreamCaps = map[string]string{ "soju.im/read": "", "soju.im/account-required": "", "soju.im/webpush": "", + + "palaverapp.com": "", } // needAllDownstreamCaps is the list of downstream capabilities that @@ -340,6 +342,8 @@ type downstreamConn struct { lastBatchRef uint64 monitored casemapMap + + palaverClient *database.PalaverConfig } func newDownstreamConn(srv *Server, ic ircConn, id uint64) *downstreamConn { @@ -366,6 +370,7 @@ func newDownstreamConn(srv *Server, ic ircConn, id uint64) *downstreamConn { dc.caps.Available[k] = v } dc.caps.Available["sasl"] = "PLAIN" + // TODO: this is racy, we should only enable chathistory after // authentication and then check that user.msgStore implements // chatHistoryMessageStore @@ -816,12 +821,30 @@ func (dc *downstreamConn) handleMessageUnregistered(ctx context.Context, msg *ir dc.registration.networkID = id } + case "PALAVER": + if err := dc.handlePalaverCommand(ctx, msg); err != nil { + return err + } default: dc.logger.Printf("unhandled message: %v", msg) return newUnknownCommandError(msg.Command) } if dc.registration.nick != "" && dc.registration.username != "" && !dc.registration.negotiatingCaps { - return dc.register(ctx) + if err := dc.register(ctx); err != nil { + return err + } + + // now that registration is complete, finish setting up palaver client + if dc.palaverClient != nil { + dc.palaverClient.Client.User = dc.user.ID + dc.palaverClient.Client.Suffix = dc.clientName + + // check db for pref version change + if err := dc.handlePalaverPrefs(ctx, msg); err != nil { + return err + } + } + return nil } return nil } @@ -3418,6 +3441,10 @@ func (dc *downstreamConn) handleMessageRegistered(ctx context.Context, msg *irc. Params: []string{"WEBPUSH", "INVALID_PARAMS", subcommand, "Unknown command"}, }} } + case "PALAVER": + if err := dc.handlePalaverCommand(ctx, msg); err != nil { + return err + } default: dc.logger.Printf("unhandled message: %v", msg) @@ -3465,6 +3492,141 @@ func (dc *downstreamConn) findWebPushSubscription(ctx context.Context, endpoint return nil, nil } +func (dc *downstreamConn) handlePalaverCommand(ctx context.Context, msg *irc.Message) error { + var subcommand string + if err := parseMessageParams(msg, &subcommand); err != nil { + return err + } + + switch subcommand { + case "IDENTIFY": + var clientToken, clientPreferenceVersion, networkUUID string + if err := parseMessageParams(msg, nil, &clientToken, &clientPreferenceVersion, &networkUUID); err != nil { + return err + } + + cfg := &database.PalaverConfig{ + Foreground: true, + Client: &database.PalaverClient{ + Token: clientToken, + PreferenceVersion: clientPreferenceVersion, + NetworkUUID: networkUUID, + }, + Preferences: &database.PalaverClientPreference{}, + Lists: &database.PalaverClientPreferenceLists{}, + } + dc.palaverClient = cfg + + // TODO from the spec: A server should reset it's preferences for a + // client when the BEGIN command is received and then set the new + // values. This will prevent any from being left behind. + // + // the current implementation can be easily wrapped in a transaction so + // its preferable to following the spec to the letter. Just delete + // PalaverPreferences row (already deleting pref lists), rewrite the + // prefs, and update the preference version if they don't match. + + // TODO there's no MARKREAD with palaver, so this is probably the time + // to reset badges. the user has connected using palaver so we can at + // least mark all badges read for the multiclient suffix that + // connected. We can't be more granular than that because we don't know + // what buffers the user looked at. + case "BEGIN": + case "SET": + cfg := dc.palaverClient + if cfg == nil { + return ircError{&irc.Message{ + Command: "FAIL", + Params: []string{"PALAVER", "INTERNAL_ERROR", subcommand, "Internal error"}, + }} + } + + var key, val string + if err := parseMessageParams(msg, nil, &key, &val); err != nil { + return err + } + switch key { + case "PUSH-TOKEN": + cfg.Preferences.PushToken = val + case "PUSH-ENDPOINT": + cfg.Preferences.PushEndpoint = val + case "SHOW-MESSAGE-PREVIEW": + v := false + if val == "true" { + v = true + } + cfg.Preferences.ShowMessagePreview = v + default: + return ircError{&irc.Message{ + Command: "FAIL", + Params: []string{"PALAVER", "INVALID_PARAMS", subcommand, "Unknown parameter"}, + }} + } + case "ADD": + cfg := dc.palaverClient + if cfg == nil { + return ircError{&irc.Message{ + Command: "FAIL", + Params: []string{"PALAVER", "INTERNAL_ERROR", subcommand, "Internal error"}, + }} + } + + var key, val string + if err := parseMessageParams(msg, nil, &key, &val); err != nil { + return err + } + if err := cfg.Lists.Append(key, val); err != nil { + return ircError{&irc.Message{ + Command: "FAIL", + Params: []string{"PALAVER", "INVALID_PARAMS", subcommand, "Unknown parameter"}, + }} + } + + case "END": + cfg := dc.palaverClient + if cfg == nil { + return ircError{&irc.Message{ + Command: "FAIL", + Params: []string{"PALAVER", "INTERNAL_ERROR", subcommand, "Internal error"}, + }} + } + + cfg.Client.Network = dc.network.ID + if err := dc.srv.db.StorePalaverConfig(ctx, cfg); err != nil { + return err + } + + case "FOREGROUND": + dc.palaverClient.Foreground = true + case "BACKGROUND": + dc.palaverClient.Foreground = false + default: + return ircError{&irc.Message{ + Command: "FAIL", + Params: []string{"PALAVER", "INVALID_PARAMS", subcommand, "Unknown command"}, + }} + } + + return nil +} + +func (dc *downstreamConn) handlePalaverPrefs(ctx context.Context, msg *irc.Message) error { + hasPrefs, err := dc.srv.db.HasPalaverPreferences(ctx, dc.palaverClient.Client) + if err != nil { + return err + } + if !hasPrefs { + reqMsg := &irc.Message{ + Prefix: dc.srv.prefix(), + Command: "PALAVER", + Params: []string{"REQ"}, + } + dc.SendMessage(reqMsg) + } + + return nil +} + func parseNickServCredentials(text, nick string) (username, password string, ok bool) { fields := strings.Fields(text) if len(fields) < 2 { diff --git a/palaver.go b/palaver.go new file mode 100644 index 0000000..3040d00 --- /dev/null +++ b/palaver.go @@ -0,0 +1,50 @@ +package soju + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "git.sr.ht/~emersion/soju/database" +) + +type palaverPushNotificationPayload struct { + Badge int `json:"badge"` + Message string `json:"message"` + Sender string `json:"sender"` + Channel string `json:"channel"` + Network string `json:"network"` +} + +// https://github.com/cocodelabs/palaver-irc-capability/blob/master/Specification.md#sending-a-notification +func sendPalaverPushNotification(ctx context.Context, payload *palaverPushNotificationPayload, config *database.PalaverConfig) error { + b, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequest("POST", config.Preferences.PushEndpoint, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.Preferences.PushToken)) + req.Header.Set("Content-type", "application/json") + + // b, _ = httputil.DumpRequest(req, true) + // dc.logger.Printf("Would send:\n%s", string(b)) + // TODO client with timeouts + // TODO handle 504s w/ retries + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode > 300 { + b, _ = ioutil.ReadAll(res.Body) + return fmt.Errorf("palaver push notification failed (status %d): %s", res.StatusCode, string(b)) + } + return nil +} diff --git a/upstream.go b/upstream.go index dd3afce..118f5b3 100644 --- a/upstream.go +++ b/upstream.go @@ -541,6 +541,21 @@ func (uc *upstreamConn) handleMessage(ctx context.Context, msg *irc.Message) err if highlight || uc.isOurNick(target) { uc.network.broadcastWebPush(ctx, msg) uc.network.pushTargets.Add(bufferName) + + var palaverForeground bool + uc.forEachDownstream(func(dc *downstreamConn) { + // if there is a palaverclient here that means there is + // functionally a foregrounded connection. Even if it is in + // "BACKGROUND" mode, palaver will handle the push notification + // so sending here would only cause a double push notification. + if dc.palaverClient != nil { + palaverForeground = true + } + }) + + if !palaverForeground { + uc.network.sendPalaverPush(ctx, msg, bufferName) + } } uc.produce(bufferName, msg, downstreamID) @@ -1531,10 +1546,15 @@ func (uc *upstreamConn) handleMessage(ctx context.Context, msg *irc.Message) err weAreInvited := uc.isOurNick(nick) + var palaverForeground bool uc.forEachDownstream(func(dc *downstreamConn) { if !weAreInvited && !dc.caps.IsEnabled("invite-notify") { return } + if dc.palaverClient != nil { + palaverForeground = true + } + dc.SendMessage(&irc.Message{ Prefix: dc.marshalUserPrefix(uc.network, msg.Prefix), Command: "INVITE", @@ -1544,6 +1564,9 @@ func (uc *upstreamConn) handleMessage(ctx context.Context, msg *irc.Message) err if weAreInvited { uc.network.broadcastWebPush(ctx, msg) + if !palaverForeground { + uc.network.sendPalaverPush(ctx, msg, channel) + } } case irc.RPL_INVITING: var nick, channel string diff --git a/user.go b/user.go index 8c072f5..cc7f630 100644 --- a/user.go +++ b/user.go @@ -482,6 +482,32 @@ func (net *network) broadcastWebPush(ctx context.Context, msg *irc.Message) { } } +func (net *network) sendPalaverPush(ctx context.Context, msg *irc.Message, bufferName string) { + cfgs, err := net.user.srv.db.ListPalaverConfigs(ctx, net.user.ID, net.ID) + if err != nil { + net.logger.Printf("failed to get palaver config: %v", err) + return + } + + for _, cfg := range cfgs { + channel := "" + if bufferName != msg.Name { + channel = bufferName + } + payload := &palaverPushNotificationPayload{ + Message: msg.Param(1), + Sender: msg.Name, + Channel: channel, + Network: cfg.Client.NetworkUUID, + } + err := sendPalaverPushNotification(ctx, payload, cfg) + if err != nil { + net.logger.Printf("sending palaver push notification failed: %v", err) + return + } + } +} + type user struct { database.User srv *Server -- 2.34.1