summary refs log tree commit diff
diff options
context:
space:
mode:
authorJune McEnroe <june@causal.agency>2021-09-14 23:30:49 -0400
committerJune McEnroe <june@causal.agency>2021-09-14 23:37:51 -0400
commit803fc57e267fc55e557f22545cf6b4fcb3fbbdee (patch)
tree6d9ce7375116d1c619ad478330dd240bfd25ecaf
parentSort by title if authors match (diff)
downloadsrc-803fc57e267fc55e557f22545cf6b4fcb3fbbdee.tar.gz
src-803fc57e267fc55e557f22545cf6b4fcb3fbbdee.zip
Add downgrade IRC bot
-rw-r--r--bin/.gitignore1
-rw-r--r--bin/Makefile2
-rw-r--r--bin/downgrade.c360
-rw-r--r--bin/man1/downgrade.1116
4 files changed, 479 insertions, 0 deletions
diff --git a/bin/.gitignore b/bin/.gitignore
index 58203f7e..b49c2b39 100644
--- a/bin/.gitignore
+++ b/bin/.gitignore
@@ -7,6 +7,7 @@ bri
 c
 config.mk
 dehtml
+downgrade
 dtch
 ever
 fbatt
diff --git a/bin/Makefile b/bin/Makefile
index 9e8d5281..c998d417 100644
--- a/bin/Makefile
+++ b/bin/Makefile
@@ -42,6 +42,7 @@ LINUX += fbatt
 LINUX += fbclock
 LINUX += psfed
 
+TLS += downgrade
 TLS += relay
 TLS += typer
 
@@ -51,6 +52,7 @@ MANS.GAMES = ${GAMES:%=man6/%.6}
 MANS.LINUX = ${LINUX:%=man1/%.1}
 MANS.TLS = ${TLS:%=man1/%.1}
 
+LDLIBS.downgrade = -ltls
 LDLIBS.dtch = -lutil
 LDLIBS.fbclock = -lz
 LDLIBS.freecell = -lcurses
diff --git a/bin/downgrade.c b/bin/downgrade.c
new file mode 100644
index 00000000..1b41df13
--- /dev/null
+++ b/bin/downgrade.c
@@ -0,0 +1,360 @@
+/* Copyright (C) 2021  C. McEnroe <june@causal.agency>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include <assert.h>
+#include <err.h>
+#include <signal.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sysexits.h>
+#include <tls.h>
+#include <unistd.h>
+
+enum { BufferCap = 8192 + 512 };
+
+static bool verbose;
+static struct tls *client;
+
+static void clientWrite(const char *ptr, size_t len) {
+	if (verbose) printf("%.*s", (int)len, ptr);
+	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[BufferCap];
+	va_list ap;
+	va_start(ap, format);
+	int len = vsnprintf(buf, sizeof(buf), format, ap);
+	va_end(ap);
+	assert((size_t)len < sizeof(buf));
+	clientWrite(buf, len);
+}
+
+static bool invite;
+static const char *join;
+
+enum { Cap = 256 };
+static struct Message {
+	char *id;
+	char *nick;
+	char *chan;
+	char *mesg;
+} msgs[Cap];
+static size_t m;
+
+static void push(struct Message msg) {
+	struct Message *dst = &msgs[m++ % Cap];
+	free(dst->id);
+	free(dst->nick);
+	free(dst->chan);
+	free(dst->mesg);
+	dst->id = strdup(msg.id);
+	dst->nick = strdup(msg.nick);
+	dst->chan = strdup(msg.chan);
+	if (!dst->id || !dst->nick || !dst->chan) err(EX_OSERR, "strdup");
+	dst->mesg = NULL;
+	if (msg.mesg) {
+		dst->mesg = strdup(msg.mesg);
+		if (!dst->mesg) err(EX_OSERR, "strdup");
+	}
+}
+
+static struct Message *find(const char *id) {
+	for (size_t i = 0; i < Cap; ++i) {
+		if (!msgs[i].id) return NULL;
+		if (!strcmp(msgs[i].id, id)) return &msgs[i];
+	}
+	return NULL;
+}
+
+static void handle(char *ptr) {
+	char *tags = NULL;
+	char *origin = NULL;
+	if (ptr && *ptr == '@') tags = 1 + strsep(&ptr, " ");
+	if (ptr && *ptr == ':') origin = 1 + strsep(&ptr, " ");
+	char *cmd = strsep(&ptr, " ");
+	if (!cmd) return;
+	if (!strcmp(cmd, "CAP")) {
+		strsep(&ptr, " ");
+		char *sub = strsep(&ptr, " ");
+		if (!sub) errx(EX_PROTOCOL, "CAP without subcommand");
+		if (!strcmp(sub, "NAK")) {
+			errx(EX_CONFIG, "server does not support %s", ptr);
+		} else if (!strcmp(sub, "ACK")) {
+			if (!ptr) errx(EX_PROTOCOL, "CAP ACK without caps");
+			if (*ptr == ':') ptr++;
+			if (!strcmp(ptr, "sasl")) format("AUTHENTICATE EXTERNAL\r\n");
+		}
+	} else if (!strcmp(cmd, "AUTHENTICATE")) {
+		format("AUTHENTICATE +\r\nCAP END\r\n");
+	} else if (!strcmp(cmd, "433")) {
+		strsep(&ptr, " ");
+		char *nick = strsep(&ptr, " ");
+		if (!nick) errx(EX_PROTOCOL, "ERR_NICKNAMEINUSE missing nick");
+		format("NICK %s_\r\n", nick);
+	} else if (!strcmp(cmd, "001")) {
+		if (join) format("JOIN %s\r\n", join);
+	} else if (!strcmp(cmd, "INVITE") && invite) {
+		strsep(&ptr, " ");
+		if (!ptr) errx(EX_PROTOCOL, "INVITE missing channel");
+		if (*ptr == ':') ptr++;
+		format("JOIN %s\r\n", ptr);
+	} else if (!strcmp(cmd, "PING")) {
+		if (!ptr) errx(EX_PROTOCOL, "PING missing parameter");
+		format("PONG %s\r\n", ptr);
+	} else if (!strcmp(cmd, "ERROR")) {
+		if (!ptr) errx(EX_PROTOCOL, "ERROR missing parameter");
+		if (*ptr == ':') ptr++;
+		errx(EX_UNAVAILABLE, "%s", ptr);
+	}
+
+	if (
+		strcmp(cmd, "PRIVMSG") &&
+		strcmp(cmd, "NOTICE") &&
+		strcmp(cmd, "TAGMSG")
+	) return;
+	if (!origin) errx(EX_PROTOCOL, "%s missing origin", cmd);
+
+	struct Message msg = {
+		.nick = strsep(&origin, "!"),
+		.chan = strsep(&ptr, " "),
+	};
+	if (!msg.chan) errx(EX_PROTOCOL, "%s missing target", cmd);
+	if (msg.chan[0] == ':') msg.chan++;
+	if (msg.chan[0] != '#') return;
+	if (strcmp(cmd, "TAGMSG")) msg.mesg = (*ptr == ':' ? &ptr[1] : ptr);
+
+	if (msg.mesg) {
+		if (!strncmp(msg.mesg, "\1ACTION ", 8)) msg.mesg += 8;
+		size_t len = strlen(msg.mesg);
+		if (msg.mesg[len-1] == '\1') msg.mesg[len-1] = '\0';
+	}
+
+	char *reply = NULL;
+	char *react = NULL;
+	char *typing = NULL;
+	if (!tags) return;
+	while (tags) {
+		char *tag = strsep(&tags, ";");
+		char *key = strsep(&tag, "=");
+		if (!strcmp(key, "msgid")) {
+			if (tag) msg.id = tag;
+		} else if (!strcmp(key, "+draft/reply")) {
+			if (tag) reply = tag;
+		} else if (!strcmp(key, "+draft/react")) {
+			if (!tag) continue;
+			for (char *ptr = tag; (ptr = strchr(ptr, '\\')); ptr += !!*ptr) {
+				switch (ptr[1]) {
+					break; case ':': ptr[1] = ';';
+					break; case 's': ptr[1] = ' ';
+					//break; case 'r': ptr[1] = '\r';
+					//break; case 'n': ptr[1] = '\n';
+				}
+				memmove(ptr, &ptr[1], strlen(&ptr[1]) + 1);
+			}
+			react = tag;
+		} else if (!strcmp(key, "+typing") || !strcmp(key, "+draft/typing")) {
+			if (tag) typing = tag;
+		}
+	}
+	if (msg.id) push(msg);
+
+	if (typing) {
+		if (!strcmp(typing, "active")) {
+			format("NOTICE %s :* %s is typing...\r\n", msg.chan, msg.nick);
+		} else if (!strcmp(typing, "paused")) {
+			format(
+				"NOTICE %s :* %s is thinking hard...\r\n", msg.chan, msg.nick
+			);
+		} else if (!strcmp(typing, "done")) {
+			format("NOTICE %s :* %s has given up :(\r\n", msg.chan, msg.nick);
+		} else {
+			format(
+				"NOTICE %s :* %s is doing some wacky %s typing!\r\n",
+				msg.chan, msg.nick, typing
+			);
+		}
+	} else if (react && reply) {
+		struct Message *to = find(reply);
+		if (to && strcmp(to->chan, msg.chan)) {
+			format(
+				"NOTICE %s :"
+				"* %s reacted to a message in another channel with \"%s\"\r\n",
+				msg.chan, msg.nick, react
+			);
+		} else if (to && to->mesg) {
+			size_t len = 0;
+			for (size_t n; to->mesg[len]; len += n) {
+				n = 1 + strcspn(&to->mesg[len+1], " ");
+				if (len + n > 50) break;
+			}
+			format(
+				"NOTICE %s :"
+				"* %s reacted to %s's message (\"%.*s\"%s) with \"%s\"\r\n",
+				msg.chan, msg.nick,
+				to->nick, (int)len, to->mesg, (to->mesg[len] ? "..." : ""),
+				react
+			);
+		} else if (to) {
+			format(
+				"NOTICE %s :* %s reacted to %s's reaction with \"%s\"\r\n",
+				msg.chan, msg.nick, to->nick, react
+			);
+		} else {
+			format(
+				"NOTICE %s :* %s reacted to an unknown message with \"%s\"\r\n",
+				msg.chan, msg.nick, react
+			);
+		}
+	} else if (react) {
+		format(
+			"NOTICE %s :* %s reacted to nothing with \"%s\"\r\n",
+			msg.chan, msg.nick, react
+		);
+	} else if (reply) {
+		struct Message *to = find(reply);
+		if (to && strcmp(to->chan, msg.chan)) {
+			format(
+				"NOTICE %s :"
+				"* %s was replying to a message in another channel!\r\n",
+				msg.chan, msg.nick
+			);
+		} else if (to && to->mesg) {
+			size_t len = 0;
+			for (size_t n; to->mesg[len]; len += n) {
+				n = 1 + strcspn(&to->mesg[len+1], " ");
+				if (len + n > 50) break;
+			}
+			format(
+				"NOTICE %s :"
+				"* %s was replying to %s's message (\"%.*s\"%s)\r\n",
+				msg.chan, msg.nick,
+				to->nick, (int)len, to->mesg, (to->mesg[len] ? "..." : "")
+			);
+		} else if (to) {
+			format(
+				"NOTICE %s :* %s was replying to %s's reaction\r\n",
+				msg.chan, msg.nick, to->nick
+			);
+		} else {
+			format(
+				"NOTICE %s :* %s was replying to an unknown message!\r\n",
+				msg.chan, msg.nick
+			);
+		}
+	}
+}
+
+static void quit(int sig) {
+	(void)sig;
+	format("QUIT\r\n");
+	tls_close(client);
+	exit(EX_OK);
+}
+
+int main(int argc, char *argv[]) {
+	const char *host = NULL;
+	const char *port = "6697";
+	const char *nick = "downgrade";
+	const char *cert = NULL;
+	const char *priv = NULL;
+
+	for (int opt; 0 < (opt = getopt(argc, argv, "c:ij:k:n:p:v"));) {
+		switch (opt) {
+			break; case 'c': cert = optarg;
+			break; case 'i': invite = true;
+			break; case 'j': join = optarg;
+			break; case 'k': priv = optarg;
+			break; case 'n': nick = optarg;
+			break; case 'p': port = optarg;
+			break; case 'v': verbose = true;
+			break; default:  return EX_USAGE;
+		}
+	}
+	if (optind == argc) errx(EX_USAGE, "host required");
+	host = argv[optind];
+
+	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 (cert) {
+		if (!priv) priv = cert;
+		int error = tls_config_set_keypair_file(config, cert, priv);
+		if (error) errx(EX_NOINPUT, "%s: %s", cert, tls_config_error(config));
+	}
+
+	int error = tls_configure(client, config);
+	if (error) errx(EX_SOFTWARE, "tls_configure: %s", tls_error(client));
+
+	error = tls_connect(client, host, port);
+	if (error) errx(EX_UNAVAILABLE, "tls_connect: %s", tls_error(client));
+
+	do {
+		error = tls_handshake(client);
+	} while (error == TLS_WANT_POLLIN || error == TLS_WANT_POLLOUT);
+	if (error) errx(EX_PROTOCOL, "tls_handshake: %s", tls_error(client));
+	tls_config_clear_keys(config);
+
+	signal(SIGHUP, quit);
+	signal(SIGINT, quit);
+	signal(SIGTERM, quit);
+	format(
+		"CAP REQ :echo-message message-tags\r\n"
+		"NICK %s\r\n"
+		"USER %s 0 * :https://causal.agency/bin/downgrade.html\r\n",
+		nick, nick
+	);
+	if (cert) {
+		format("CAP REQ sasl\r\n");
+	} else {
+		format("CAP END\r\n");
+	}
+
+	size_t len = 0;
+	char buf[BufferCap];
+	for (;;) {
+		ssize_t n = tls_read(client, &buf[len], sizeof(buf) - len);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) continue;
+		if (n < 0) errx(EX_IOERR, "tls_read: %s", tls_error(client));
+		if (!n) errx(EX_UNAVAILABLE, "disconnected");
+		len += n;
+
+		char *ptr = buf;
+		for (
+			char *crlf;
+			(crlf = memmem(ptr, &buf[len] - ptr, "\r\n", 2));
+			ptr = crlf + 2
+		) {
+			*crlf = '\0';
+			if (verbose) printf("%s\n", ptr);
+			handle(ptr);
+		}
+		len -= ptr - buf;
+		memmove(buf, ptr, len);
+	}
+}
diff --git a/bin/man1/downgrade.1 b/bin/man1/downgrade.1
new file mode 100644
index 00000000..910b42c3
--- /dev/null
+++ b/bin/man1/downgrade.1
@@ -0,0 +1,116 @@
+.Dd September 14, 2021
+.Dt DOWNGRADE 1
+.Os
+.
+.Sh NAME
+.Nm downgrade
+.Nd IRC features for all
+.
+.Sh SYNOPSIS
+.Nm
+.Op Fl iv
+.Op Fl c Ar cert
+.Op Fl j Ar join
+.Op Fl k Ar priv
+.Op Fl n Ar nick
+.Op Fl p Ar port
+.Ar host
+.
+.Sh DESCRIPTION
+The
+.Nm
+IRC bot downgrades new IRC
+.Dq features
+so
+.Em everyone
+can see them.
+It supports typing notifications,
+message reactions
+and message replies.
+.
+.Pp
+The arguments are as follows:
+.Bl -tag -width Ds
+.It Fl c Ar cert
+Load the TLS client certificate from
+.Ar cert
+and authenticate using SASL EXTERNAL.
+.It Fl i
+Accept invites to channels.
+.It Fl j Ar join
+Join the channel list
+.Ar join .
+.It Fl k Ar priv
+Load the TLS client private key from
+.Ar priv .
+The default is the same path as
+.Ar cert .
+.It Fl n Ar nick
+Set the nickname and username to
+.Ar nick .
+The default is
+.Nm .
+.It Fl p Ar port
+Connect to
+.Ar port .
+The default is 6697.
+.It Fl v
+Log IRC protocol.
+.It Ar host
+Connect to
+.Ar host .
+.El
+.
+.Sh EXAMPLES
+.Bd -literal
+-downgrade- * guest-n4 is typing...
+<guest-n4> wtf
+-downgrade- * june reacted to guest-n4's message ("wtf") with "\[u1F44D]"
+-downgrade- * guest-n4 is typing...
+-downgrade- * guest-n4 has given up :(
+.Ed
+.Bd -literal
+<june> ,bef
+-downgrade- * tildebot is typing...
+<tildebot> [Ducks] june: There was no duck!
+-downgrade- * tildebot was replying to june's message (",bef")
+.Ed
+.
+.Sh STANDARDS
+.Bl -item
+.It
+.Rs
+.%A Kiyoshi Aman
+.%A Kyle Fuller
+.%A St\('ephan Kochen
+.%A Alexey Sokolov
+.%A James Wheare
+.%T Message Tags
+.%U https://ircv3.net/specs/extensions/message-tags
+.Re
+.It
+.Rs
+.%A MuffinMedic
+.%A James Wheare
+.%T typing client tag
+.%U https://ircv3.net/specs/client-tags/typing
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T Message IDs
+.%U https://ircv3.net/specs/extensions/message-ids
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T react client tag
+.%U https://ircv3.net/specs/client-tags/react
+.Re
+.It
+.Rs
+.%A James Wheare
+.%T reply client tag
+.%U https://ircv3.net/specs/client-tags/reply
+.Re
+.El