~emersion/soju-dev

support palaver push notification irc capability v1 REJECTED

Jeff Martin: 1
 support palaver push notification irc capability

 7 files changed, 702 insertions(+), 1 deletions(-)
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[RFC PATCH] support palaver push notification irc capability Export this patch

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