summary refs log tree commit diff
path: root/extra/palaver
diff options
context:
space:
mode:
Diffstat (limited to 'extra/palaver')
-rw-r--r--extra/palaver/.gitignore3
-rw-r--r--extra/palaver/Makefile26
-rwxr-xr-xextra/palaver/configure45
-rw-r--r--extra/palaver/notify.c809
-rw-r--r--extra/palaver/pounce-palaver.1107
5 files changed, 990 insertions, 0 deletions
diff --git a/extra/palaver/.gitignore b/extra/palaver/.gitignore
new file mode 100644
index 0000000..d92ce2e
--- /dev/null
+++ b/extra/palaver/.gitignore
@@ -0,0 +1,3 @@
+*.o
+config.mk
+pounce-palaver
diff --git a/extra/palaver/Makefile b/extra/palaver/Makefile
new file mode 100644
index 0000000..457e592
--- /dev/null
+++ b/extra/palaver/Makefile
@@ -0,0 +1,26 @@
+PREFIX ?= /usr/local
+MANDIR ?= ${PREFIX}/share/man
+
+CFLAGS += -std=c11 -Wall -Wextra -Wpedantic
+LDLIBS = -lcurl -lsqlite3 -ltls
+
+-include config.mk
+
+OBJS = notify.o
+
+all: pounce-palaver
+
+pounce-palaver: ${OBJS}
+	${CC} ${LDFLAGS} ${OBJS} ${LDLIBS} -o $@
+
+clean:
+	rm -f ${OBJS} pounce-palaver
+
+install: pounce-palaver pounce-palaver.1
+	install -d ${DESTDIR}${PREFIX}/bin ${DESTDIR}${MANDIR}/man1
+	install pounce-palaver ${DESTDIR}${PREFIX}/bin
+	install -m 644 pounce-palaver.1 ${DESTDIR}${MANDIR}/man1
+
+uninstall:
+	rm -f ${DESTDIR}${PREFIX}/bin/pounce-palaver
+	rm -f ${DESTDIR}${MANDIR}/man1/pounce-palaver.1
diff --git a/extra/palaver/configure b/extra/palaver/configure
new file mode 100755
index 0000000..65c82fe
--- /dev/null
+++ b/extra/palaver/configure
@@ -0,0 +1,45 @@
+#!/bin/sh
+set -eu
+
+cflags() {
+	echo "CFLAGS += $*"
+}
+ldlibs() {
+	echo "LDLIBS ${o:-}= $*"
+	o=+
+}
+config() {
+	pkg-config --print-errors "$@"
+	cflags $(pkg-config --cflags "$@")
+	ldlibs $(pkg-config --libs "$@")
+}
+defstr() {
+	cflags "-D'$1=\"$2\"'"
+}
+defvar() {
+	defstr "$1" "$(pkg-config --variable=$3 $2)${4:-}"
+}
+
+exec >config.mk
+
+for opt; do
+	case "${opt}" in
+		(--prefix=*) echo "PREFIX = ${opt#*=}" ;;
+		(--mandir=*) echo "MANDIR = ${opt#*=}" ;;
+		(*) echo "warning: unsupported option ${opt}" >&2 ;;
+	esac
+done
+
+case "$(uname)" in
+	(OpenBSD)
+		ldlibs -ltls
+		config libcurl sqlite3
+		;;
+	(Linux)
+		cflags -D_GNU_SOURCE
+		config libcurl libtls sqlite3
+		;;
+	(*)
+		config libcurl libtls sqlite3
+		;;
+esac
diff --git a/extra/palaver/notify.c b/extra/palaver/notify.c
new file mode 100644
index 0000000..67d416c
--- /dev/null
+++ b/extra/palaver/notify.c
@@ -0,0 +1,809 @@
+/* Copyright (C) 2019  C. McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ *
+ * Additional permission under GNU GPL version 3 section 7:
+ *
+ * If you modify this Program, or any covered work, by linking or
+ * combining it with OpenSSL (or a modified version of that library),
+ * containing parts covered by the terms of the OpenSSL License and the
+ * original SSLeay license, the licensors of this Program grant you
+ * additional permission to convey the resulting work. Corresponding
+ * Source for a non-source form of such a combination shall include the
+ * source code for the parts of OpenSSL used as well as that of the
+ * covered work.
+ */
+
+#include <assert.h>
+#include <ctype.h>
+#include <curl/curl.h>
+#include <err.h>
+#include <errno.h>
+#include <limits.h>
+#include <signal.h>
+#include <sqlite3.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sysexits.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+// Why must it return (const unsigned char *)?
+#define sqlite3_column_text(...) (const char *)sqlite3_column_text(__VA_ARGS__)
+
+#define DATABASE_PATH "pounce/palaver.sqlite"
+
+#define SQL(...) #__VA_ARGS__
+#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))
+
+static bool verbose;
+static char curlError[CURL_ERROR_SIZE];
+
+static CURL *curl;
+static sqlite3 *db;
+static struct tls *client;
+
+static void dbOpen(const char *path, int flags) {
+	int error = sqlite3_open_v2(path, &db, flags, NULL);
+	if (error == SQLITE_CANTOPEN) {
+		sqlite3_close(db);
+		db = NULL;
+		return;
+	}
+	if (error) errx(EX_NOINPUT, "%s: %s", path, sqlite3_errmsg(db));
+
+	sqlite3_busy_timeout(db, 10000);
+}
+
+static void dbFind(char *path) {
+	if (path) {
+		dbOpen(path, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
+		if (db) return;
+		errx(EX_NOINPUT, "%s: database not found", path);
+	}
+
+	const char *home = getenv("HOME");
+	const char *dataHome = getenv("XDG_DATA_HOME");
+	const char *dataDirs = getenv("XDG_DATA_DIRS");
+	if (!dataDirs) dataDirs = "/usr/local/share:/usr/share";
+
+	char buf[PATH_MAX];
+	if (dataHome) {
+		snprintf(buf, sizeof(buf), "%s/" DATABASE_PATH, dataHome);
+	} else {
+		if (!home) errx(EX_CONFIG, "HOME unset");
+		snprintf(buf, sizeof(buf), "%s/.local/share/" DATABASE_PATH, home);
+	}
+	dbOpen(buf, SQLITE_OPEN_READWRITE);
+	if (db) return;
+
+	char create[PATH_MAX];
+	snprintf(create, sizeof(create), "%s", buf);
+
+	while (*dataDirs) {
+		size_t len = strcspn(dataDirs, ":");
+		snprintf(buf, sizeof(buf), "%.*s/" DATABASE_PATH, (int)len, dataDirs);
+		dbOpen(buf, SQLITE_OPEN_READWRITE);
+		if (db) return;
+		dataDirs += len;
+		if (*dataDirs) dataDirs++;
+	}
+
+	char *base = strrchr(create, '/');
+	*base = '\0';
+	int error = mkdir(create, 0700);
+	if (error && errno != EEXIST) err(EX_CANTCREAT, "%s", create);
+	*base = '/';
+
+	dbOpen(create, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE);
+	if (!db) errx(EX_CANTCREAT, "%s: cannot create database", create);
+}
+
+static int dbParam(sqlite3_stmt *stmt, const char *param) {
+	int index = sqlite3_bind_parameter_index(stmt, param);
+	if (index) return index;
+	errx(EX_SOFTWARE, "no such parameter %s: %s", param, sqlite3_sql(stmt));
+}
+
+static void
+dbBindText(sqlite3_stmt *stmt, const char *param, const char *value) {
+	if (!sqlite3_bind_text(stmt, dbParam(stmt, param), value, -1, NULL)) return;
+	errx(EX_SOFTWARE, "sqlite3_bind_text: %s", sqlite3_errmsg(db));
+}
+
+static void
+dbBindCopy(sqlite3_stmt *stmt, const char *param, const char *value) {
+	int error = sqlite3_bind_text(
+		stmt, dbParam(stmt, param), value, -1, SQLITE_TRANSIENT
+	);
+	if (error) errx(EX_SOFTWARE, "sqlite3_bind_text: %s", sqlite3_errmsg(db));
+}
+
+static void dbVerbose(sqlite3_stmt *stmt) {
+	if (!verbose) return;
+	char *sql = sqlite3_expanded_sql(stmt);
+	if (sql) fprintf(stderr, "%s\n", sql);
+	sqlite3_free(sql);
+}
+
+static void dbInit(void) {
+	const char *sql = SQL(
+		CREATE TABLE IF NOT EXISTS clients (
+			host TEXT NOT NULL,
+			port INTEGER NOT NULL,
+			client TEXT NOT NULL,
+			version TEXT NOT NULL,
+			UNIQUE (host, port, client)
+		);
+		CREATE TABLE IF NOT EXISTS preferences (
+			client TEXT NOT NULL,
+			key TEXT NOT NULL,
+			value TEXT NOT NULL
+		);
+		CREATE INDEX IF NOT EXISTS preferencesIndex
+		ON preferences (client, key);
+		CREATE TABLE IF NOT EXISTS badges (
+			host TEXT NOT NULL,
+			port TEXT NOT NULL,
+			count INTEGER NOT NULL,
+			UNIQUE (host, port)
+		);
+	);
+	int error = sqlite3_exec(db, sql, NULL, NULL, NULL);
+	if (error) errx(EX_SOFTWARE, "%s: %s", sqlite3_errmsg(db), sql);
+}
+
+static void clientWrite(const char *ptr, size_t len) {
+	while (len) {
+		ssize_t ret = tls_write(client, ptr, len);
+		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
+		if (ret < 0) errx(EX_IOERR, "tls_write: %s", tls_error(client));
+		ptr += ret;
+		len -= ret;
+	}
+}
+
+static void format(const char *format, ...) {
+	char buf[1024];
+	va_list ap;
+	va_start(ap, format);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
+	va_end(ap);
+	assert((size_t)len < sizeof(buf));
+	if (verbose) fprintf(stderr, "%s", buf);
+	clientWrite(buf, len);
+}
+
+enum { ParamCap = 4 };
+struct Message {
+	char *time;
+	char *nick;
+	char *cmd;
+	char *params[ParamCap];
+};
+
+static struct Message parse(char *line) {
+	if (verbose) fprintf(stderr, "%s\n", line);
+	struct Message msg = {0};
+	if (line[0] == '@') {
+		char *tags = 1 + strsep(&line, " ");
+		while (tags) {
+			char *tag = strsep(&tags, ";");
+			char *key = strsep(&tag, "=");
+			if (!strcmp(key, "time")) msg.time = tag;
+		}
+	}
+	if (line[0] == ':') {
+		char *origin = 1 + strsep(&line, " ");
+		msg.nick = strsep(&origin, "!");
+	}
+	msg.cmd = strsep(&line, " ");
+	for (size_t i = 0; line && i < ParamCap; ++i) {
+		if (line[0] == ':') {
+			msg.params[i] = &line[1];
+			break;
+		}
+		msg.params[i] = strsep(&line, " ");
+	}
+	return msg;
+}
+
+static void require(const struct Message *msg, bool nick, size_t len) {
+	if (nick && !msg->nick) errx(EX_PROTOCOL, "%s missing origin", msg->cmd);
+	for (size_t i = 0; i < len; ++i) {
+		if (msg->params[i]) continue;
+		errx(EX_PROTOCOL, "%s missing parameter %zu", msg->cmd, 1 + i);
+	}
+}
+
+typedef void Handler(struct Message *msg);
+
+static void handleCap(struct Message *msg) {
+	require(msg, false, 3);
+	if (!strcmp(msg->params[1], "NAK")) {
+		errx(EX_CONFIG, "pounce palaver option not enabled");
+	}
+}
+
+static void handlePing(struct Message *msg) {
+	require(msg, false, 1);
+	format("PONG :%s\r\n", msg->params[0]);
+}
+
+static void handleError(struct Message *msg) {
+	require(msg, false, 1);
+	errx(EX_UNAVAILABLE, "%s", msg->params[0]);
+}
+
+static char *nick;
+static bool away;
+
+static void handleReplyWelcome(struct Message *msg) {
+	require(msg, false, 1);
+	free(nick);
+	nick = strdup(msg->params[0]);
+	if (!nick) err(EX_OSERR, "strdup");
+	format("USERHOST %s\r\n", nick);
+}
+
+static void handleNick(struct Message *msg) {
+	require(msg, true, 1);
+	if (nick && !strcmp(msg->nick, nick)) {
+		free(nick);
+		nick = strdup(msg->params[0]);
+		if (!nick) err(EX_OSERR, "strdup");
+	}
+}
+
+static void handleReplyUserHost(struct Message *msg) {
+	require(msg, false, 2);
+	while (msg->params[1]) {
+		char *reply = strsep(&msg->params[1], " ");
+		char *replyNick = strsep(&reply, "*=");
+		if (strcmp(replyNick, nick)) continue;
+		if (reply && !reply[0]) strsep(&msg->params[1], "=");
+		if (!reply) errx(EX_PROTOCOL, "invalid USERHOST reply");
+		away = (reply[0] == '-');
+		break;
+	}
+}
+
+static bool sensitive;
+
+static void keyword(sqlite3_context *ctx, int n, sqlite3_value *args[]) {
+	assert(n == 2);
+	const char *haystack = (const char *)sqlite3_value_text(args[0]);
+	const char *needle = (const char *)sqlite3_value_text(args[1]);
+	if (!nick || !haystack || !needle) {
+		sqlite3_result_null(ctx);
+		return;
+	}
+
+	char *copy = NULL;
+	const char *replace;
+	if (!strcmp(needle, "{nick}")) {
+		needle = nick;
+	} else if (NULL != (replace = strstr(needle, "{nick}"))) {
+		int n = asprintf(
+			&copy, "%.*s%s%s",
+			(int)(replace - needle), needle, nick, &replace[6]
+		);
+		if (n < 0) {
+			sqlite3_result_error_nomem(ctx);
+			return;
+		}
+		needle = copy;
+	}
+
+	size_t len = strlen(needle);
+	const char *match = haystack;
+	sqlite3_result_int(ctx, false);
+	while (NULL != (match = (sensitive ? strstr : strcasestr)(match, needle))) {
+		char a = (match > haystack ? match[-1] : ' ');
+		char b = (match[len] ? match[len] : ' ');
+		if (b == '\1') b = ' ';
+		if ((isspace(a) || ispunct(a)) && (isspace(b) || ispunct(b))) {
+			sqlite3_result_int(ctx, true);
+			break;
+		}
+		match = &match[len];
+	}
+	free(copy);
+}
+
+enum {
+	Identify,
+	Begin,
+	Set,
+	End,
+	Each,
+	Notify,
+	Increment,
+	Reset,
+	Badge,
+	QueriesLen,
+};
+
+static sqlite3_stmt *stmts[QueriesLen];
+static const char *Queries[QueriesLen] = {
+	[Identify] = SQL(
+		SELECT 1 FROM clients
+		WHERE host = :host AND port = :port
+		AND client = :client AND version = :version;
+	),
+
+	[Begin] = SQL(
+		DELETE FROM preferences WHERE client = :client;
+	),
+
+	[Set] = SQL(
+		INSERT INTO preferences (client, key, value)
+		VALUES (:client, :key, :value);
+	),
+
+	[End] = SQL(
+		INSERT INTO clients (host, port, client, version)
+		VALUES (:host, :port, :client, :version)
+		ON CONFLICT (host, port, client) DO
+		UPDATE SET version = :version
+		WHERE host = :host AND port = :port AND client = :client;
+	),
+
+	[Each] = SQL(
+		SELECT pushToken.value, pushEndpoint.value
+		FROM clients
+		JOIN preferences AS pushToken USING (client)
+		JOIN preferences AS pushEndpoint USING (client)
+		WHERE host = :host AND port = :port
+			AND pushToken.key = 'PUSH-TOKEN'
+			AND pushEndpoint.key = 'PUSH-ENDPOINT';
+	),
+
+	[Notify] = SQL(
+		WITH mentions AS (
+			SELECT DISTINCT client
+			FROM clients
+			JOIN preferences USING (client)
+			WHERE host = :host AND port = :port AND (
+				(key = 'MENTION-KEYWORD' AND keyword(:message, value)) OR
+				(key = 'MENTION-CHANNEL' AND value = :channel) OR
+				(key = 'MENTION-NICK' AND value = :nick) OR
+				:direct
+			)
+		),
+		ignores AS (
+			SELECT DISTINCT client
+			FROM clients
+			JOIN preferences USING (client)
+			WHERE host = :host AND port = :port AND (
+				(key = 'IGNORE-KEYWORD' AND keyword(:message, value)) OR
+				(key = 'IGNORE-CHANNEL' AND value = :channel) OR
+				(key = 'IGNORE-NICK' AND value = :nick)
+			)
+		),
+		matches AS (SELECT * FROM mentions EXCEPT SELECT * FROM ignores)
+		SELECT
+			pushToken.value,
+			pushEndpoint.value,
+			coalesce(showMessagePreview.value, 'true')
+		FROM clients
+		JOIN matches USING (client)
+		JOIN preferences AS pushToken USING (client)
+		JOIN preferences AS pushEndpoint USING (client)
+		LEFT JOIN preferences AS showMessagePreview
+			ON showMessagePreview.client = clients.client
+			AND showMessagePreview.key = 'SHOW-MESSAGE-PREVIEW'
+		WHERE pushToken.key = 'PUSH-TOKEN'
+			AND pushEndpoint.key = 'PUSH-ENDPOINT';
+	),
+
+	[Increment] = SQL(
+		INSERT INTO badges (host, port, count)
+		VALUES (:host, :port, 1)
+		ON CONFLICT (host, port) DO
+		UPDATE SET count = count + 1
+		WHERE host = :host AND port = :port;
+	),
+
+	[Reset] = SQL(
+		DELETE FROM badges WHERE host = :host AND port = :port;
+	),
+
+	[Badge] = SQL(
+		SELECT sum(count) FROM badges;
+	),
+};
+
+static int badgeCount(int op) {
+	dbVerbose(stmts[op]);
+	int result = sqlite3_step(stmts[op]);
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[op]);
+
+	dbVerbose(stmts[Badge]);
+	result = sqlite3_step(stmts[Badge]);
+	if (result != SQLITE_ROW) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	int badge = sqlite3_column_int(stmts[Badge], 0);
+	sqlite3_reset(stmts[Badge]);
+	return badge;
+}
+
+static void palaverIdentify(struct Message *msg) {
+	require(msg, false, 3);
+	dbBindText(stmts[Identify], ":client", msg->params[1]);
+	dbBindText(stmts[Identify], ":version", msg->params[2]);
+	dbVerbose(stmts[Identify]);
+	int result = sqlite3_step(stmts[Identify]);
+	if (result == SQLITE_DONE) {
+		format("PALAVER REQ\r\n");
+	} else if (result != SQLITE_ROW) {
+		errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	}
+	sqlite3_reset(stmts[Identify]);
+}
+
+static void palaverBegin(struct Message *msg) {
+	require(msg, false, 3);
+	dbBindText(stmts[Begin], ":client", msg->params[1]);
+	dbVerbose(stmts[Begin]);
+	int result = sqlite3_step(stmts[Begin]);
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[Begin]);
+	dbBindCopy(stmts[Set], ":client", msg->params[1]);
+	dbBindCopy(stmts[End], ":client", msg->params[1]);
+	dbBindCopy(stmts[End], ":version", msg->params[2]);
+}
+
+static void palaverSet(struct Message *msg) {
+	require(msg, false, 3);
+	dbBindText(stmts[Set], ":key", msg->params[1]);
+	dbBindText(stmts[Set], ":value", msg->params[2]);
+	dbVerbose(stmts[Set]);
+	int result = sqlite3_step(stmts[Set]);
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[Set]);
+}
+
+static void palaverEnd(struct Message *msg) {
+	(void)msg;
+	dbVerbose(stmts[End]);
+	int result = sqlite3_step(stmts[End]);
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[End]);
+}
+
+static void handlePalaver(struct Message *msg) {
+	require(msg, false, 1);
+	if (!strcmp(msg->params[0], "IDENTIFY")) {
+		palaverIdentify(msg);
+	} else if (!strcmp(msg->params[0], "BEGIN")) {
+		palaverBegin(msg);
+	} else if (!strcmp(msg->params[0], "SET")) {
+		palaverSet(msg);
+	} else if (!strcmp(msg->params[0], "ADD")) {
+		palaverSet(msg);
+	} else if (!strcmp(msg->params[0], "END")) {
+		palaverEnd(msg);
+	}
+}
+
+static void pushNotify(const char *endpoint, const char *token, char *body) {
+	CURLcode code = curl_easy_setopt(curl, CURLOPT_URL, endpoint);
+	if (code) {
+		warnx("%s: %s", endpoint, curlError);
+		return;
+	}
+
+	char auth[256];
+	struct curl_slist *list = NULL;
+	snprintf(auth, sizeof(auth), "Authorization: Bearer %s", token);
+	list = curl_slist_append(list, "Content-Type: application/json");
+	list = curl_slist_append(list, auth);
+	curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list);
+
+	size_t len = strlen(body);
+	FILE *file = fmemopen(body, len, "r");
+	if (!file) err(EX_OSERR, "fmemopen");
+
+	curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)len);
+	curl_easy_setopt(curl, CURLOPT_READDATA, file);
+
+	if (verbose) fprintf(stderr, "%s\n", body);
+	code = curl_easy_perform(curl);
+	if (code) warnx("%s: %s", endpoint, curlError);
+
+	fclose(file);
+	curl_easy_setopt(curl, CURLOPT_HTTPHEADER, NULL);
+	curl_slist_free_all(list);
+}
+
+static void handleReplyNowAway(struct Message *msg) {
+	(void)msg;
+	away = true;
+}
+
+static void handleReplyUnaway(struct Message *msg) {
+	(void)msg;
+	if (!away) return;
+	away = false;
+
+	char json[32];
+	snprintf(json, sizeof(json), "{\"badge\":%d}", badgeCount(Reset));
+
+	int result;
+	dbVerbose(stmts[Each]);
+	while (SQLITE_ROW == (result = sqlite3_step(stmts[Each]))) {
+		int i = 0;
+		const char *token = sqlite3_column_text(stmts[Each], i++);
+		const char *endpoint = sqlite3_column_text(stmts[Each], i++);
+		pushNotify(endpoint, token, json);
+	}
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[Each]);
+}
+
+static bool noPreview;
+static bool noPrivatePreview;
+
+static void jsonString(FILE *file, const char *str) {
+	fputc('"', file);
+	for (const char *ch = str; *ch; ++ch) {
+		if (iscntrl(*ch) || *ch == '"' || *ch == '\\') {
+			fprintf(file, "\\u%04x", (unsigned)*ch);
+		} else {
+			fputc(*ch, file);
+		}
+	}
+	fputc('"', file);
+}
+
+static char *jsonBody(int badge, struct Message *msg, bool preview) {
+	bool private = (msg->params[0][0] != '#');
+	if (private && noPrivatePreview) preview = false;
+	if (noPreview) preview = false;
+
+	char *buf;
+	size_t len;
+	FILE *file = open_memstream(&buf, &len);
+	if (!file) err(EX_OSERR, "open_memstream");
+
+	fprintf(file, "{\"badge\":%d", badge);
+	fprintf(file, ",\"sender\":");
+	jsonString(file, msg->nick);
+	if (!private) {
+		fprintf(file, ",\"channel\":");
+		jsonString(file, msg->params[0]);
+	}
+	if (preview) {
+		if (!strncmp(msg->params[1], "\1ACTION ", 8)) {
+			size_t len = strlen(msg->params[1]);
+			if (msg->params[1][len - 1] == '\1') msg->params[1][len - 1] = '\0';
+			fprintf(file, ",\"intent\":\"ACTION\",\"message\":");
+			jsonString(file, &msg->params[1][8]);
+		} else {
+			fprintf(file, ",\"message\":");
+			jsonString(file, msg->params[1]);
+		}
+	} else {
+		fprintf(file, ",\"private\":true");
+	}
+	fprintf(file, "}");
+
+	int error = fclose(file);
+	if (error) err(EX_IOERR, "fclose");
+
+	return buf;
+}
+
+static void handlePrivmsg(struct Message *msg) {
+	require(msg, true, 2);
+	if (!away) return;
+	if (!msg->time) return;
+	struct tm tm = {0};
+	strptime(msg->time, "%FT%T", &tm);
+	time_t then = timegm(&tm);
+	if (time(NULL) - then > 60) return;
+
+	dbBindText(stmts[Notify], ":nick", msg->nick);
+	dbBindText(stmts[Notify], ":channel", msg->params[0]);
+	dbBindText(stmts[Notify], ":message", msg->params[1]);
+	dbBindText(
+		stmts[Notify], ":direct", (!strcmp(msg->params[0], nick) ? "1" : NULL)
+	);
+	dbVerbose(stmts[Notify]);
+	int result;
+	int badge = 0;
+	while (SQLITE_ROW == (result = sqlite3_step(stmts[Notify]))) {
+		int i = 0;
+		const char *token = sqlite3_column_text(stmts[Notify], i++);
+		const char *endpoint = sqlite3_column_text(stmts[Notify], i++);
+		const char *preview = sqlite3_column_text(stmts[Notify], i++);
+
+		if (!badge) badge = badgeCount(Increment);
+		char *body = jsonBody(badge, msg, !strcmp(preview, "true"));
+		pushNotify(endpoint, token, body);
+		free(body);
+	}
+	if (result != SQLITE_DONE) errx(EX_SOFTWARE, "%s", sqlite3_errmsg(db));
+	sqlite3_reset(stmts[Notify]);
+}
+
+static const struct {
+	const char *cmd;
+	Handler *fn;
+} Handlers[] = {
+	{ "001", handleReplyWelcome },
+	{ "302", handleReplyUserHost },
+	{ "305", handleReplyUnaway },
+	{ "306", handleReplyNowAway },
+	{ "CAP", handleCap },
+	{ "ERROR", handleError },
+	{ "NICK", handleNick },
+	{ "NOTICE", handlePrivmsg },
+	{ "PALAVER", handlePalaver },
+	{ "PING", handlePing },
+	{ "PRIVMSG", handlePrivmsg },
+};
+
+static void handle(struct Message *msg) {
+	if (!msg->cmd) return;
+	for (size_t i = 0; i < ARRAY_LEN(Handlers); ++i) {
+		if (strcmp(msg->cmd, Handlers[i].cmd)) continue;
+		Handlers[i].fn(msg);
+		break;
+	}
+}
+
+static void atExit(void) {
+	if (client) tls_close(client);
+	curl_easy_cleanup(curl);
+	for (size_t i = 0; i < QueriesLen; ++i) {
+		sqlite3_finalize(stmts[i]);
+	}
+	sqlite3_close(db);
+}
+
+static void quit(int sig) {
+	(void)sig;
+	format("QUIT\r\n");
+	atExit();
+	_exit(EX_OK);
+}
+
+int main(int argc, char *argv[]) {
+	bool insecure = false;
+	char *path = NULL;
+	const char *cert = NULL;
+	const char *priv = NULL;
+	const char *host = NULL;
+	const char *port = "6697";
+	const char *pass = NULL;
+	const char *user = "pounce-palaver";
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "!NPc:d:k:p:su:vw:"));) {
+		switch (opt) {
+			break; case '!': insecure = true;
+			break; case 'N': noPreview = true;
+			break; case 'P': noPrivatePreview = true;
+			break; case 'c': cert = optarg;
+			break; case 'd': path = optarg;
+			break; case 'k': priv = optarg;
+			break; case 'p': port = optarg;
+			break; case 's': sensitive = true;
+			break; case 'u': user = optarg;
+			break; case 'v': verbose = true;
+			break; case 'w': pass = optarg;
+		}
+	}
+	if (optind == argc) errx(EX_USAGE, "host required");
+	host = argv[optind];
+
+	CURLcode code = curl_global_init(CURL_GLOBAL_ALL);
+	if (code) errx(EX_OSERR, "curl_global_init: %s", curl_easy_strerror(code));
+
+	curl = curl_easy_init();
+	if (!curl) errx(EX_SOFTWARE, "curl_easy_init");
+
+	curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, curlError);
+	curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L);
+	curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
+	curl_easy_setopt(curl, CURLOPT_VERBOSE, (verbose ? 1L : 0L));
+	curl_easy_setopt(curl, CURLOPT_POST, 1L);
+
+	dbFind(path);
+	atexit(atExit);
+
+	dbInit();
+	sqlite3_create_function(
+		db, "keyword", 2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
+		keyword, NULL, NULL
+	);
+	for (size_t i = 0; i < QueriesLen; ++i) {
+		int error = sqlite3_prepare_v3(
+			db, Queries[i], -1, SQLITE_PREPARE_PERSISTENT, &stmts[i], NULL
+		);
+		if (error) errx(EX_SOFTWARE, "%s: %s", sqlite3_errmsg(db), Queries[i]);
+		if (sqlite3_bind_parameter_index(stmts[i], ":host")) {
+			dbBindText(stmts[i], ":host", host);
+			dbBindText(stmts[i], ":port", port);
+		}
+	}
+
+	client = tls_client();
+	if (!client) errx(EX_SOFTWARE, "tls_client");
+
+	struct tls_config *config = tls_config_new();
+	if (!config) errx(EX_SOFTWARE, "tls_config_new");
+
+	if (insecure) {
+		tls_config_insecure_noverifycert(config);
+		tls_config_insecure_noverifyname(config);
+	}
+
+	int error;
+	if (cert) {
+		error = tls_config_set_keypair_file(config, cert, (priv ? priv : cert));
+		if (error) {
+			errx(
+				EX_SOFTWARE, "tls_config_set_keypair_file: %s",
+				tls_config_error(config)
+			);
+		}
+	}
+
+	error = tls_configure(client, config);
+	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+	tls_config_free(config);
+
+	error = tls_connect(client, host, port);
+	if (error) errx(EX_UNAVAILABLE, "tls_connect: %s", tls_error(client));
+
+	if (pass) format("PASS :%s\r\n", pass);
+	format(
+		"CAP REQ :server-time palaverapp.com causal.agency/passive\r\n"
+		"CAP END\r\n"
+		"NICK *\r\n"
+		"USER %s 0 * :pounce-palaver\r\n",
+		user
+	);
+
+	signal(SIGINT, quit);
+	signal(SIGTERM, quit);
+
+	char buf[8191 + 512];
+	size_t len = 0;
+	for (;;) {
+		ssize_t ret = tls_read(client, &buf[len], sizeof(buf) - len);
+		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT) continue;
+		if (ret < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
+		if (!ret) errx(EX_PROTOCOL, "server closed connection");
+		len += ret;
+
+		char *line = buf;
+		for (;;) {
+			char *crlf = memmem(line, &buf[len] - line, "\r\n", 2);
+			if (!crlf) break;
+			crlf[0] = '\0';
+			struct Message msg = parse(line);
+			handle(&msg);
+			line = crlf + 2;
+		}
+		len -= line - buf;
+		memmove(buf, line, len);
+	}
+}
diff --git a/extra/palaver/pounce-palaver.1 b/extra/palaver/pounce-palaver.1
new file mode 100644
index 0000000..37140c5
--- /dev/null
+++ b/extra/palaver/pounce-palaver.1
@@ -0,0 +1,107 @@
+.Dd April 30, 2021
+.Dt POUNCE-PALAVER 1
+.Os
+.
+.Sh NAME
+.Nm pounce-palaver
+.Nd Palaver push notifications for pounce
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl PNsv
+.Op Fl c Ar cert
+.Op Fl d Ar path
+.Op Fl k Ar priv
+.Op Fl p Ar port
+.Op Fl u Ar user
+.Op Fl w Ar pass
+.Ar host
+.
+.Sh DESCRIPTION
+The
+.Nm
+daemon provides push notifications
+for the Palaver IRC app via the
+.Xr pounce 1
+IRC bouncer.
+The
+.Cm palaver
+option must be enabled in
+.Xr pounce 1 .
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl N
+Never send message previews,
+regardless of the app preferences.
+.It Fl P
+Never send message previews
+for private messages.
+.It Fl c Ar cert
+Load the TLS client certificate from
+.Ar path .
+If the private key is in a separate file,
+it is loaded with
+.Fl k .
+.It Fl d Ar path
+Set the path to the database file
+used to store notification preferences.
+The default path is documented in
+.Sx FILES .
+.It Fl k Ar priv
+Load the TLS client private key from
+.Ar path .
+.It Fl p Ar port
+Connect to
+.Ar port .
+The default port is 6697.
+.It Fl s
+Match nick and keywords case-sensitively,
+despite the specification.
+.It Fl u Ar user
+Set the username to
+.Ar user .
+The default username is
+.Dq pounce-palaver .
+.It Fl v
+Log IRC protocol, SQL and HTTP to standard error.
+.It Fl w Ar pass
+Log in with the server password
+.Ar pass .
+.It Ar host
+Connect to
+.Ar host .
+.El
+.
+.Sh FILES
+.Bl -tag -width Ds
+.It Pa $XDG_DATA_DIRS/pounce/palaver.sqlite
+The database file is searched for first in
+.Ev $XDG_DATA_HOME ,
+usually
+.Pa ~/.local/share ,
+followed by the colon-separated list of paths
+.Ev $XDG_DATA_DIRS ,
+usually
+.Pa /usr/local/share:/usr/share .
+.It Pa ~/.local/share/pounce/palaver.sqlite
+The most likely default path to the database file.
+.El
+.
+.Sh SEE ALSO
+.Xr pounce 1
+.
+.Sh STANDARDS
+.Lk https://github.com/cocodelabs/palaver-irc-capability "Palaver IRC Capability"
+.
+.Sh AUTHORS
+.An June Bug Aq Mt june@causal.agency
+.
+.Sh BUGS
+Send mail to
+.Aq Mt list+pounce@causal.agency
+or join
+.Li #ascii.town
+on
+.Li chat.freenode.net .