DOWNGRADE(1) General Commands Manual DOWNGRADE(1)

downgradeIRC features for all

downgrade [-iv] [-c cert] [-j join] [-k priv] [-n nick] [-p port] host

The downgrade IRC bot downgrades new IRC “features” so can see them. It supports typing notifications, message reactions and message replies.

The arguments are as follows:

cert
Load the TLS client certificate from cert and authenticate using SASL EXTERNAL.
Accept invites to channels.
join
Join the channel list join.
priv
Load the TLS client private key from priv. The default is the same path as cert.
nick
Set the nickname and username to nick. The default is downgrade.
port
Connect to port. The default is 6697.
Log IRC protocol.
host
Connect to host.

-downgrade- * guest-n4 is typing...
<guest-n4> wtf
-downgrade- * june reacted to guest-n4's message ("wtf") with "👍"
-downgrade- * guest-n4 is typing...
-downgrade- * guest-n4 has given up :(
<june> ,bef
-downgrade- * tildebot is typing...
<tildebot> [Ducks] june: There was no duck!
-downgrade- * tildebot was replying to june's message (",bef")

September 14, 2021 OpenBSD 7.4

downgrade.c in git

/* Copyright (C) 2021  June 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 <tls.h>
#include <unistd.h>

#ifdef __FreeBSD__
#include <capsicum_helpers.h>
#endif

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(1, "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 = 1024 };
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(1, "strdup");
	dst->mesg = NULL;
	if (msg.mesg) {
		dst->mesg = strdup(msg.mesg);
		if (!dst->mesg) err(1, "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(1, "CAP without subcommand");
		if (!strcmp(sub, "NAK")) {
			errx(1, "server does not support %s", ptr);
		} else if (!strcmp(sub, "ACK")) {
			if (!ptr) errx(1, "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(1, "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, "005")) {
		char *self = strsep(&ptr, " ");
		if (!self) errx(1, "RPL_ISUPPORT missing nick");
		while (ptr && *ptr != ':') {
			char *tok = strsep(&ptr, " ");
			char *key = strsep(&tok, "=");
			if (!strcmp(key, "BOT") && tok) {
				format("MODE %s +%s\r\n", self, tok);
			}
		}
	} else if (!strcmp(cmd, "INVITE") && invite) {
		strsep(&ptr, " ");
		if (!ptr) errx(1, "INVITE missing channel");
		if (*ptr == ':') ptr++;
		format("JOIN %s\r\n", ptr);
	} else if (!strcmp(cmd, "PING")) {
		if (!ptr) errx(1, "PING missing parameter");
		format("PONG %s\r\n", ptr);
	} else if (!strcmp(cmd, "ERROR")) {
		if (!ptr) errx(1, "ERROR missing parameter");
		if (*ptr == ':') ptr++;
		errx(1, "%s", ptr);
	}

	if (
		strcmp(cmd, "PRIVMSG") &&
		strcmp(cmd, "NOTICE") &&
		strcmp(cmd, "TAGMSG")
	) return;
	if (!origin) errx(1, "%s missing origin", cmd);

	struct Message msg = {
		.nick = strsep(&origin, "!"),
		.chan = strsep(&ptr, " "),
	};
	if (!msg.chan) errx(1, "%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);
		format("NOTICE %s :* %s reacted to ", msg.chan, msg.nick);
		if (to && strcmp(to->chan, msg.chan)) {
			format("a message in another channel");
		} 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(
				"%s's message (\"%.*s\"%s)",
				to->nick, (int)len, to->mesg, (to->mesg[len] ? "..." : "")
			);
		} else if (to) {
			format("%s's reaction", to->nick);
		} else {
			format("an unknown message");
		}
		format(" with \"%s\"\r\n", 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);
		format("NOTICE %s :* %s was replying to ", msg.chan, msg.nick);
		if (to && strcmp(to->chan, msg.chan)) {
			format("a message in another channel!\r\n");
		} 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(
				"%s's message (\"%.*s\"%s)\r\n",
				to->nick, (int)len, to->mesg, (to->mesg[len] ? "..." : "")
			);
		} else if (to) {
			format("%s's reaction\r\n", to->nick);
		} else {
			format("an unknown message!\r\n");
		}
	}
}

static void quit(int sig) {
	(void)sig;
	format("QUIT\r\n");
	tls_close(client);
	exit(0);
}

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 1;
		}
	}
	if (optind == argc) errx(1, "host required");
	host = argv[optind];

	client = tls_client();
	if (!client) errx(1, "tls_client");

	struct tls_config *config = tls_config_new();
	if (!config) errx(1, "tls_config_new");

	if (cert) {
		if (!priv) priv = cert;
		int error = tls_config_set_keypair_file(config, cert, priv);
		if (error) errx(1, "%s: %s", cert, tls_config_error(config));
	}

	int error = tls_configure(client, config);
	if (error) errx(1, "tls_configure: %s", tls_error(client));

	error = tls_connect(client, host, port);
	if (error) errx(1, "tls_connect: %s", tls_error(client));

	do {
		error = tls_handshake(client);
	} while (error == TLS_WANT_POLLIN || error == TLS_WANT_POLLOUT);
	if (error) errx(1, "tls_handshake: %s", tls_error(client));
	tls_config_clear_keys(config);

#ifdef __OpenBSD__
	error = pledge("stdio", NULL);
	if (error) err(1, "pledge");
#endif

#ifdef __FreeBSD__
	error = caph_enter() || caph_limit_stdio();
	if (error) err(1, "caph_enter");
#endif

	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(1, "tls_read: %s", tls_error(client));
		if (!n) errx(1, "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);
	}
}