about summary refs log tree commit diff
path: root/handle.c
diff options
context:
space:
mode:
Diffstat (limited to 'handle.c')
-rw-r--r--handle.c1715
1 files changed, 1295 insertions, 420 deletions
diff --git a/handle.c b/handle.c
index fe15d9a..0cc7c04 100644
--- a/handle.c
+++ b/handle.c
@@ -1,568 +1,1443 @@
-/* Copyright (C) 2018, 2019  C. McEnroe <june@causal.agency>
+/* Copyright (C) 2020  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
+ * 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 Affero General Public License for more details.
+ * GNU 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/>.
+ * 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 <err.h>
-#include <stdarg.h>
+#include <stdbool.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <sysexits.h>
-#include <time.h>
+#include <wchar.h>
 
 #include "chat.h"
 
-static char *paramField(char **params) {
-	char *rest = *params;
-	if (rest[0] == ':') {
-		*params = NULL;
-		return &rest[1];
+uint replies[ReplyCap];
+
+static const char *CapNames[] = {
+#define X(name, id) [id##Bit] = name,
+	ENUM_CAP
+#undef X
+};
+
+static enum Cap capParse(const char *list) {
+	enum Cap caps = 0;
+	while (*list) {
+		enum Cap cap = 0;
+		size_t len = strcspn(list, " ");
+		for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
+			if (len != strlen(CapNames[i])) continue;
+			if (strncmp(list, CapNames[i], len)) continue;
+			cap = 1 << i;
+			break;
+		}
+		caps |= cap;
+		list += len;
+		if (*list) list++;
 	}
-	return strsep(params, " ");
+	return caps;
 }
 
-static void parse(
-	char *prefix, char **nick, char **user, char **host,
-	char *params, size_t req, size_t opt, /* (char **) */ ...
-) {
-	char *field;
-	if (prefix) {
-		field = strsep(&prefix, "!");
-		if (nick) *nick = field;
-		field = strsep(&prefix, "@");
-		if (user) *user = field;
-		if (host) *host = prefix;
-	}
-
-	va_list ap;
-	va_start(ap, opt);
-	for (size_t i = 0; i < req; ++i) {
-		if (!params) errx(EX_PROTOCOL, "%zu params required, found %zu", req, i);
-		field = paramField(&params);
-		char **param = va_arg(ap, char **);
-		if (param) *param = field;
-	}
-	for (size_t i = 0; i < opt; ++i) {
-		char **param = va_arg(ap, char **);
-		if (params) {
-			*param = paramField(&params);
-		} else {
-			*param = NULL;
+static void capList(char *buf, size_t cap, enum Cap caps) {
+	*buf = '\0';
+	char *ptr = buf, *end = &buf[cap];
+	for (size_t i = 0; i < ARRAY_LEN(CapNames); ++i) {
+		if (caps & (1 << i)) {
+			ptr = seprintf(
+				ptr, end, "%s%s", (ptr > buf ? " " : ""), CapNames[i]
+			);
 		}
 	}
-	va_end(ap);
 }
 
-static bool isPing(const char *mesg) {
-	size_t len = strlen(self.nick);
-	const char *match = mesg;
-	while (NULL != (match = strcasestr(match, self.nick))) {
-		char b = (match > mesg ? *(match - 1) : ' ');
-		char a = (match[len] ? match[len] : ' ');
-		match = &match[len];
-		if (!isspace(b) && !ispunct(b)) continue;
-		if (!isspace(a) && !ispunct(a)) continue;
-		return true;
+static void require(struct Message *msg, bool origin, uint len) {
+	if (origin) {
+		if (!msg->nick) msg->nick = "*.*";
+		if (!msg->user) msg->user = msg->nick;
+		if (!msg->host) msg->host = msg->user;
+	}
+	for (uint i = 0; i < len; ++i) {
+		if (msg->params[i]) continue;
+		errx(1, "%s missing parameter %u", msg->cmd, 1 + i);
 	}
-	return false;
 }
 
-static char *dequote(char *mesg) {
-	if (mesg[0] == '"') mesg = &mesg[1];
-	size_t len = strlen(mesg);
-	if (mesg[len - 1] == '"') mesg[len - 1] = '\0';
-	return mesg;
+static const time_t *tagTime(const struct Message *msg) {
+	static time_t time;
+	struct tm tm;
+	if (!msg->tags[TagTime]) return NULL;
+	if (!strptime(msg->tags[TagTime], "%Y-%m-%dT%T", &tm)) return NULL;
+	time = timegm(&tm);
+	return &time;
 }
 
-typedef void Handler(char *prefix, char *params);
+typedef void Handler(struct Message *msg);
 
-static void handlePing(char *prefix, char *params) {
-	(void)prefix;
-	ircFmt("PONG %s\r\n", params);
+static void handleStandardReply(struct Message *msg) {
+	require(msg, false, 3);
+	for (uint i = 2; i < ParamCap - 1; ++i) {
+		if (msg->params[i + 1]) continue;
+		uiFormat(
+			Network, Warm, tagTime(msg),
+			"%s", msg->params[i]
+		);
+		break;
+	}
 }
 
-static void handleError(char *prefix, char *params) {
-	char *mesg;
-	parse(prefix, NULL, NULL, NULL, params, 1, 0, &mesg);
-	if (self.quit) {
-		uiExit(EX_OK);
+static void handleErrorGeneric(struct Message *msg) {
+	require(msg, false, 2);
+	if (msg->params[2]) {
+		size_t len = strlen(msg->params[2]);
+		if (msg->params[2][len - 1] == '.') msg->params[2][len - 1] = '\0';
+		uiFormat(
+			Network, Warm, tagTime(msg),
+			"%s: %s", msg->params[2], msg->params[1]
+		);
 	} else {
-		errx(EX_PROTOCOL, "%s", mesg);
+		uiFormat(
+			Network, Warm, tagTime(msg),
+			"%s", msg->params[1]
+		);
+	}
+}
+
+static void handleReplyGeneric(struct Message *msg) {
+	uint first = 1;
+	uint id = Network;
+	if (msg->params[1] && strchr(network.chanTypes, msg->params[1][0])) {
+		id = idFor(msg->params[1]);
+		first++;
+	}
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	ptr = seprintf(ptr, end, "\3%d(%s)\3\t", Gray, msg->cmd);
+	for (uint i = first; i < ParamCap && msg->params[i]; ++i) {
+		ptr = seprintf(
+			ptr, end, "%s%s", (i > first ? " " : ""), msg->params[i]
+		);
 	}
+	uiWrite(id, Ice, tagTime(msg), buf);
 }
 
-static void handleCap(char *prefix, char *params) {
-	char *subc, *list;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &subc, &list);
-	if (!strcmp(subc, "ACK") && self.auth) {
-		size_t len = strlen(self.auth);
-		byte plain[1 + len];
-		plain[0] = 0;
-		for (size_t i = 0; i < len; ++i) {
-			plain[1 + i] = (self.auth[i] == ':' ? 0 : self.auth[i]);
+static void handleErrorNicknameInUse(struct Message *msg) {
+	require(msg, false, 2);
+	if (!strcmp(self.nick, "*")) {
+		static uint i = 1;
+		if (i < ARRAY_LEN(self.nicks) && self.nicks[i]) {
+			ircFormat("NICK %s\r\n", self.nicks[i++]);
+		} else {
+			ircFormat("NICK %s_\r\n", msg->params[1]);
 		}
-		char b64[base64Size(sizeof(plain))];
-		base64(b64, plain, sizeof(plain));
-		ircFmt("AUTHENTICATE PLAIN\r\n");
-		ircFmt("AUTHENTICATE %s\r\n", b64);
+	} else {
+		handleErrorGeneric(msg);
 	}
-	ircFmt("CAP END\r\n");
 }
 
-static void handleErrorErroneousNickname(char *prefix, char *params) {
-	char *mesg;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, NULL, &mesg);
-	uiFmt(TagStatus, UIHot, "You can't use that name here: \"%s\"", mesg);
-	uiLog(TagStatus, UICold, L"Type /nick <name> to choose a new one");
+static void handleErrorErroneousNickname(struct Message *msg) {
+	require(msg, false, 3);
+	if (!strcmp(self.nick, "*")) {
+		errx(1, "%s: %s", msg->params[1], msg->params[2]);
+	} else {
+		handleErrorGeneric(msg);
+	}
 }
 
-static void handleReplyWelcome(char *prefix, char *params) {
-	char *nick;
-	parse(prefix, NULL, NULL, NULL, params, 1, 0, &nick);
+static void handleCap(struct Message *msg) {
+	require(msg, false, 3);
+	enum Cap caps = capParse(msg->params[2]);
+	if (!strcmp(msg->params[1], "LS")) {
+		caps &= ~CapSASL;
+		if (caps & CapConsumer && self.pos) {
+			ircFormat("CAP REQ %s=%zu\r\n", CapNames[CapConsumerBit], self.pos);
+			caps &= ~CapConsumer;
+		}
+		if (caps) {
+			char buf[512];
+			capList(buf, sizeof(buf), caps);
+			ircFormat("CAP REQ :%s\r\n", buf);
+		} else {
+			if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
+		}
+	} else if (!strcmp(msg->params[1], "ACK")) {
+		self.caps |= caps;
+		if (caps & CapSASL) {
+			ircFormat(
+				"AUTHENTICATE %s\r\n", (self.plainUser ? "PLAIN" : "EXTERNAL")
+			);
+		}
+		if (!(self.caps & CapSASL)) ircFormat("CAP END\r\n");
+	} else if (!strcmp(msg->params[1], "NAK")) {
+		errx(1, "server does not support %s", msg->params[2]);
+	}
+}
 
-	if (strcmp(nick, self.nick)) {
-		free(self.nick);
-		self.nick = strdup(nick);
-		if (!self.nick) err(EX_OSERR, "strdup");
-		uiPrompt(true);
+#define BASE64_SIZE(len) (1 + ((len) + 2) / 3 * 4)
+
+static void base64(char *dst, const byte *src, size_t len) {
+	static const char Base64[64] = {
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+	};
+	size_t i = 0;
+	while (len > 2) {
+		dst[i++] = Base64[0x3F & (src[0] >> 2)];
+		dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
+		dst[i++] = Base64[0x3F & (src[1] << 2 | src[2] >> 6)];
+		dst[i++] = Base64[0x3F & src[2]];
+		src += 3;
+		len -= 3;
 	}
-	if (self.join && self.keys) {
-		ircFmt("JOIN %s %s\r\n", self.join, self.keys);
-	} else if (self.join) {
-		ircFmt("JOIN %s\r\n", self.join);
+	if (len) {
+		dst[i++] = Base64[0x3F & (src[0] >> 2)];
+		if (len > 1) {
+			dst[i++] = Base64[0x3F & (src[0] << 4 | src[1] >> 4)];
+			dst[i++] = Base64[0x3F & (src[1] << 2)];
+		} else {
+			dst[i++] = Base64[0x3F & (src[0] << 4)];
+			dst[i++] = '=';
+		}
+		dst[i++] = '=';
 	}
-	tabTouch(TagStatus, self.nick);
+	dst[i] = '\0';
+}
 
-	uiLog(TagStatus, UICold, L"You have arrived");
+static void handleAuthenticate(struct Message *msg) {
+	(void)msg;
+	if (!self.plainUser) {
+		ircFormat("AUTHENTICATE +\r\n");
+		return;
+	}
+
+	byte buf[299] = {0};
+	size_t userLen = strlen(self.plainUser);
+	size_t passLen = strlen(self.plainPass);
+	size_t len = 1 + userLen + 1 + passLen;
+	if (sizeof(buf) < len) errx(1, "SASL PLAIN is too long");
+	memcpy(&buf[1], self.plainUser, userLen);
+	memcpy(&buf[1 + userLen + 1], self.plainPass, passLen);
+
+	char b64[BASE64_SIZE(sizeof(buf))];
+	base64(b64, buf, len);
+	ircFormat("AUTHENTICATE ");
+	ircSend(b64, BASE64_SIZE(len) - 1);
+	ircFormat("\r\n");
+
+	explicit_bzero(b64, sizeof(b64));
+	explicit_bzero(buf, sizeof(buf));
+	explicit_bzero(self.plainPass, strlen(self.plainPass));
 }
 
-static void handleReplyMOTD(char *prefix, char *params) {
-	char *mesg;
-	parse(prefix, NULL, NULL, NULL, params, 2, 0, NULL, &mesg);
-	if (mesg[0] == '-' && mesg[1] == ' ') mesg = &mesg[2];
+static void handleReplyLoggedIn(struct Message *msg) {
+	(void)msg;
+	ircFormat("CAP END\r\n");
+	handleReplyGeneric(msg);
+}
 
-	urlScan(TagStatus, mesg);
-	uiFmt(TagStatus, UICold, "%s", mesg);
+static void handleErrorSASLFail(struct Message *msg) {
+	require(msg, false, 2);
+	errx(1, "%s", msg->params[1]);
 }
 
-static void handleReplyList(char *prefix, char *params) {
-	char *chan, *count, *topic;
-	parse(prefix, NULL, NULL, NULL, params, 4, 0, NULL, &chan, &count, &topic);
-	if (topic[0] == '[') {
-		char *skip = strstr(topic, "] ");
-		if (skip) topic = &skip[2];
+static void handleReplyWelcome(struct Message *msg) {
+	require(msg, false, 1);
+	set(&self.nick, msg->params[0]);
+	completePull(Network, self.nick, Default);
+	if (self.mode) ircFormat("MODE %s %s\r\n", self.nick, self.mode);
+	if (self.join) {
+		uint count = 1;
+		for (const char *ch = self.join; *ch && *ch != ' '; ++ch) {
+			if (*ch == ',') count++;
+		}
+		ircFormat("JOIN %s\r\n", self.join);
+		if (count == 1) replies[ReplyJoin]++;
+		replies[ReplyTopicAuto] += count;
+		replies[ReplyNamesAuto] += count;
 	}
-	const char *people = (strcmp(count, "1") ? "people" : "person");
-	if (topic[0]) {
-		uiFmt(
-			TagStatus, UIWarm,
-			"You see %s %s in \3%d%s\3 under the banner, \"%s\"",
-			count, people, colorGen(chan), chan, topic
-		);
+	commandCompletion();
+	handleReplyGeneric(msg);
+}
+
+static void handleReplyISupport(struct Message *msg) {
+	handleReplyGeneric(msg);
+	for (uint i = 1; i < ParamCap; ++i) {
+		if (!msg->params[i]) break;
+		char *key = strsep(&msg->params[i], "=");
+		if (!strcmp(key, "NETWORK")) {
+			if (!msg->params[i]) continue;
+			set(&network.name, msg->params[i]);
+			static bool arrived;
+			if (!arrived) {
+				uiFormat(
+					Network, Cold, tagTime(msg),
+					"You arrive in %s", msg->params[i]
+				);
+				arrived = true;
+			}
+		} else if (!strcmp(key, "USERLEN")) {
+			if (!msg->params[i]) continue;
+			network.userLen = strtoul(msg->params[i], NULL, 10);
+		} else if (!strcmp(key, "HOSTLEN")) {
+			if (!msg->params[i]) continue;
+			network.hostLen = strtoul(msg->params[i], NULL, 10);
+		} else if (!strcmp(key, "CHANTYPES")) {
+			if (!msg->params[i]) continue;
+			set(&network.chanTypes, msg->params[i]);
+		} else if (!strcmp(key, "STATUSMSG")) {
+			if (!msg->params[i]) continue;
+			set(&network.statusmsg, msg->params[i]);
+		} else if (!strcmp(key, "PREFIX")) {
+			strsep(&msg->params[i], "(");
+			char *modes = strsep(&msg->params[i], ")");
+			char *prefixes = msg->params[i];
+			if (!modes || !prefixes || strlen(modes) != strlen(prefixes)) {
+				errx(1, "invalid PREFIX value");
+			}
+			set(&network.prefixModes, modes);
+			set(&network.prefixes, prefixes);
+		} else if (!strcmp(key, "CHANMODES")) {
+			char *list = strsep(&msg->params[i], ",");
+			char *param = strsep(&msg->params[i], ",");
+			char *setParam = strsep(&msg->params[i], ",");
+			char *channel = strsep(&msg->params[i], ",");
+			if (!list || !param || !setParam || !channel) {
+				errx(1, "invalid CHANMODES value");
+			}
+			set(&network.listModes, list);
+			set(&network.paramModes, param);
+			set(&network.setParamModes, setParam);
+			set(&network.channelModes, channel);
+		} else if (!strcmp(key, "EXCEPTS")) {
+			network.excepts = (msg->params[i] ?: "e")[0];
+		} else if (!strcmp(key, "INVEX")) {
+			network.invex = (msg->params[i] ?: "I")[0];
+		}
+	}
+}
+
+static void handleReplyMOTD(struct Message *msg) {
+	require(msg, false, 2);
+	char *line = msg->params[1];
+	urlScan(Network, NULL, line);
+	if (!strncmp(line, "- ", 2)) {
+		uiFormat(Network, Cold, tagTime(msg), "\3%d-\3\t%s", Gray, &line[2]);
 	} else {
-		uiFmt(
-			TagStatus, UIWarm,
-			"You see %s %s in \3%d%s\3",
-			count, people, colorGen(chan), chan
-		);
+		uiFormat(Network, Cold, tagTime(msg), "%s", line);
 	}
 }
 
-static void handleReplyListEnd(char *prefix, char *params) {
-	(void)prefix;
-	(void)params;
-	uiLog(TagStatus, UICold, L"You don't see anyone else");
+static void handleErrorNoMOTD(struct Message *msg) {
+	(void)msg;
 }
 
-static enum IRCColor whoisColor;
-static void handleReplyWhoisUser(char *prefix, char *params) {
-	char *nick, *user, *host, *real;
-	parse(
-		prefix, NULL, NULL, NULL,
-		params, 6, 0, NULL, &nick, &user, &host, NULL, &real
-	);
-	whoisColor = colorGen(user);
-	uiFmt(
-		TagStatus, UIWarm,
-		"\3%d%s\3 is %s@%s, \"%s\"",
-		whoisColor, nick, user, host, real
+static void handleReplyHelp(struct Message *msg) {
+	require(msg, false, 3);
+	urlScan(Network, NULL, msg->params[2]);
+	uiWrite(Network, Warm, tagTime(msg), msg->params[2]);
+}
+
+static void handleJoin(struct Message *msg) {
+	require(msg, true, 1);
+	uint id = idFor(msg->params[0]);
+	if (!strcmp(msg->nick, self.nick)) {
+		if (!self.user || strcmp(self.user, msg->user)) {
+			set(&self.user, msg->user);
+			self.color = hash(msg->user);
+		}
+		if (!self.host || strcmp(self.host, msg->host)) {
+			set(&self.host, msg->host);
+		}
+		idColors[id] = hash(msg->params[0]);
+		completePull(None, msg->params[0], idColors[id]);
+		if (replies[ReplyJoin]) {
+			windowShow(windowFor(id));
+			replies[ReplyJoin]--;
+		}
+	}
+	completePull(id, msg->nick, hash(msg->user));
+	if (msg->params[2] && !strcasecmp(msg->params[2], msg->nick)) {
+		msg->params[2] = NULL;
+	}
+	uiFormat(
+		id, filterCheck(Cold, id, msg), tagTime(msg),
+		"\3%02d%s\3\t%s%s%sarrives in \3%02d%s\3",
+		hash(msg->user), msg->nick,
+		(msg->params[2] ? "(" : ""),
+		(msg->params[2] ?: ""),
+		(msg->params[2] ? "\17) " : ""),
+		hash(msg->params[0]), msg->params[0]
 	);
+	logFormat(id, tagTime(msg), "%s arrives in %s", msg->nick, msg->params[0]);
 }
 
-static void handleReplyWhoisServer(char *prefix, char *params) {
-	char *nick, *serv, *info;
-	parse(prefix, NULL, NULL, NULL, params, 4, 0, NULL, &nick, &serv, &info);
-	uiFmt(
-		TagStatus, UIWarm,
-		"\3%d%s\3 is connected to %s, \"%s\"",
-		whoisColor, nick, serv, info
+static void handleChghost(struct Message *msg) {
+	require(msg, true, 2);
+	if (strcmp(msg->nick, self.nick)) return;
+	if (!self.user || strcmp(self.user, msg->params[0])) {
+		set(&self.user, msg->params[0]);
+		self.color = hash(msg->params[0]);
+	}
+	if (!self.host || strcmp(self.host, msg->params[1])) {
+		set(&self.host, msg->params[1]);
+	}
+}
+
+static void handlePart(struct Message *msg) {
+	require(msg, true, 1);
+	uint id = idFor(msg->params[0]);
+	if (!strcmp(msg->nick, self.nick)) {
+		completeRemove(id, NULL);
+	}
+	completeRemove(id, msg->nick);
+	enum Heat heat = filterCheck(Cold, id, msg);
+	if (heat > Ice) urlScan(id, msg->nick, msg->params[1]);
+	uiFormat(
+		id, heat, tagTime(msg),
+		"\3%02d%s\3\tleaves \3%02d%s\3%s%s",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+		(msg->params[1] ? ": " : ""), (msg->params[1] ?: "")
+	);
+	logFormat(
+		id, tagTime(msg), "%s leaves %s%s%s",
+		msg->nick, msg->params[0],
+		(msg->params[1] ? ": " : ""), (msg->params[1] ?: "")
 	);
 }
 
-static void handleReplyWhoisOperator(char *prefix, char *params) {
-	char *nick, *oper;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &nick, &oper);
-	uiFmt(TagStatus, UIWarm, "\3%d%s\3 %s", whoisColor, nick, oper);
-}
-
-static void handleReplyWhoisIdle(char *prefix, char *params) {
-	char *nick, *idle, *sign;
-	parse(prefix, NULL, NULL, NULL, params, 4, 0, NULL, &nick, &idle, &sign);
-	time_t time = strtoul(sign, NULL, 10);
-	const char *at = ctime(&time);
-	unsigned long secs  = strtoul(idle, NULL, 10);
-	unsigned long mins  = secs / 60; secs %= 60;
-	unsigned long hours = mins / 60; mins %= 60;
-	uiFmt(
-		TagStatus, UIWarm,
-		"\3%d%s\3 signed on at %.24s and has been idle for %02lu:%02lu:%02lu",
-		whoisColor, nick, at, hours, mins, secs
+static void handleKick(struct Message *msg) {
+	require(msg, true, 2);
+	uint id = idFor(msg->params[0]);
+	bool kicked = !strcmp(msg->params[1], self.nick);
+	completePull(id, msg->nick, hash(msg->user));
+	urlScan(id, msg->nick, msg->params[2]);
+	uiFormat(
+		id, (kicked ? Hot : Cold), tagTime(msg),
+		"%s\3%02d%s\17\tkicks \3%02d%s\3 out of \3%02d%s\3%s%s",
+		(kicked ? "\26" : ""),
+		hash(msg->user), msg->nick,
+		completeColor(id, msg->params[1]), msg->params[1],
+		hash(msg->params[0]), msg->params[0],
+		(msg->params[2] ? ": " : ""), (msg->params[2] ?: "")
 	);
+	logFormat(
+		id, tagTime(msg), "%s kicks %s out of %s%s%s",
+		msg->nick, msg->params[1], msg->params[0],
+		(msg->params[2] ? ": " : ""), (msg->params[2] ?: "")
+	);
+	completeRemove(id, msg->params[1]);
+	if (kicked) completeRemove(id, NULL);
 }
 
-static void handleReplyWhoisChannels(char *prefix, char *params) {
-	char *nick, *chans;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &nick, &chans);
-	uiFmt(TagStatus, UIWarm, "\3%d%s\3 is in %s", whoisColor, nick, chans);
+static void handleNick(struct Message *msg) {
+	require(msg, true, 1);
+	if (!strcmp(msg->nick, self.nick)) {
+		set(&self.nick, msg->params[0]);
+		inputUpdate();
+	}
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
+		if (!strcmp(idNames[id], msg->nick)) {
+			set(&idNames[id], msg->params[0]);
+		}
+		uiFormat(
+			id, filterCheck(Cold, id, msg), tagTime(msg),
+			"\3%02d%s\3\tis now known as \3%02d%s\3",
+			hash(msg->user), msg->nick, hash(msg->user), msg->params[0]
+		);
+		if (id == Network) continue;
+		logFormat(
+			id, tagTime(msg), "%s is now known as %s",
+			msg->nick, msg->params[0]
+		);
+	}
+	completeReplace(msg->nick, msg->params[0]);
 }
 
-static void handleErrorNoSuchNick(char *prefix, char *params) {
-	char *nick, *mesg;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &nick, &mesg);
-	uiFmt(TagStatus, UIWarm, "%s, \"%s\"", mesg, nick);
+static void handleSetname(struct Message *msg) {
+	require(msg, true, 1);
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
+		uiFormat(
+			id, filterCheck(Cold, id, msg), tagTime(msg),
+			"\3%02d%s\3\tis now known as \3%02d%s\3 (%s\17)",
+			hash(msg->user), msg->nick, hash(msg->user), msg->nick,
+			msg->params[0]
+		);
+	}
 }
 
-static void handleJoin(char *prefix, char *params) {
-	char *nick, *user, *chan;
-	parse(prefix, &nick, &user, NULL, params, 1, 0, &chan);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleQuit(struct Message *msg) {
+	require(msg, true, 0);
+	struct Cursor curs = {0};
+	for (uint id; (id = completeEachID(&curs, msg->nick));) {
+		enum Heat heat = filterCheck(Cold, id, msg);
+		if (heat > Ice) urlScan(id, msg->nick, msg->params[0]);
+		uiFormat(
+			id, heat, tagTime(msg),
+			"\3%02d%s\3\tleaves%s%s",
+			hash(msg->user), msg->nick,
+			(msg->params[0] ? ": " : ""), (msg->params[0] ?: "")
+		);
+		if (id == Network) continue;
+		logFormat(
+			id, tagTime(msg), "%s leaves%s%s",
+			msg->nick,
+			(msg->params[0] ? ": " : ""), (msg->params[0] ?: "")
+		);
+	}
+	completeRemove(None, msg->nick);
+}
 
-	if (!strcmp(nick, self.nick)) {
-		tabTouch(TagNone, chan);
-		uiShowTag(tag);
-		logReplay(tag);
+static void handleInvite(struct Message *msg) {
+	require(msg, true, 2);
+	if (!strcmp(msg->params[0], self.nick)) {
+		set(&self.invited, msg->params[1]);
+		uiFormat(
+			Network, filterCheck(Hot, Network, msg), tagTime(msg),
+			"\3%02d%s\3\tinvites you to \3%02d%s\3",
+			hash(msg->user), msg->nick, hash(msg->params[1]), msg->params[1]
+		);
+	} else {
+		uint id = idFor(msg->params[1]);
+		uiFormat(
+			id, Cold, tagTime(msg),
+			"\3%02d%s\3\tinvites %s to \3%02d%s\3",
+			hash(msg->user), msg->nick,
+			msg->params[0],
+			hash(msg->params[1]), msg->params[1]
+		);
+		logFormat(
+			id, tagTime(msg), "%s invites %s to %s",
+			msg->nick, msg->params[0], msg->params[1]
+		);
 	}
-	tabTouch(tag, nick);
+}
+
+static void handleReplyInviting(struct Message *msg) {
+	require(msg, false, 3);
+	struct Message invite = {
+		.nick = self.nick,
+		.user = self.user,
+		.cmd = "INVITE",
+		.params[0] = msg->params[1],
+		.params[1] = msg->params[2],
+	};
+	handleInvite(&invite);
+}
 
-	uiFmt(
-		tag, UICold,
-		"\3%d%s\3 arrives in \3%d%s\3",
-		colorGen(user), nick, colorGen(chan), chan
+static void handleErrorUserOnChannel(struct Message *msg) {
+	require(msg, false, 3);
+	uint id = idFor(msg->params[2]);
+	uiFormat(
+		id, Warm, tagTime(msg),
+		"\3%02d%s\3 is already in \3%02d%s\3",
+		completeColor(id, msg->params[1]), msg->params[1],
+		hash(msg->params[2]), msg->params[2]
 	);
-	logFmt(tag, NULL, "%s arrives in %s", nick, chan);
 }
 
-static void handlePart(char *prefix, char *params) {
-	char *nick, *user, *chan, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 1, 1, &chan, &mesg);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleReplyNames(struct Message *msg) {
+	require(msg, false, 4);
+	uint id = idFor(msg->params[2]);
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	while (msg->params[3]) {
+		char *name = strsep(&msg->params[3], " ");
+		char *prefixes = strsep(&name, "!");
+		char *nick = &prefixes[strspn(prefixes, network.prefixes)];
+		char *user = strsep(&name, "@");
+		enum Color color = (user ? hash(user) : Default);
+		uint bits = 0;
+		for (char *p = prefixes; p < nick; ++p) {
+			bits |= prefixBit(*p);
+		}
+		completePush(id, nick, color);
+		*completeBits(id, nick) = bits;
+		if (!replies[ReplyNames] && !replies[ReplyNamesAuto]) continue;
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%s\3", (ptr > buf ? ", " : ""), color, prefixes
+		);
+	}
+	if (ptr == buf) return;
+	uiFormat(
+		id, (replies[ReplyNamesAuto] ? Cold : Warm), tagTime(msg),
+		"In \3%02d%s\3 are %s",
+		hash(msg->params[2]), msg->params[2], buf
+	);
+}
 
-	if (!strcmp(nick, self.nick)) {
-		tabClear(tag);
-	} else {
-		tabRemove(tag, nick);
+static void handleReplyEndOfNames(struct Message *msg) {
+	(void)msg;
+	if (replies[ReplyNamesAuto]) {
+		replies[ReplyNamesAuto]--;
+	} else if (replies[ReplyNames]) {
+		replies[ReplyNames]--;
 	}
+}
 
-	if (mesg) {
-		urlScan(tag, mesg);
-		uiFmt(
-			tag, UICold,
-			"\3%d%s\3 leaves \3%d%s\3, \"%s\"",
-			colorGen(user), nick, colorGen(chan), chan, dequote(mesg)
-		);
-		logFmt(tag, NULL, "%s leaves %s, \"%s\"", nick, chan, dequote(mesg));
+static void handleReplyNoTopic(struct Message *msg) {
+	require(msg, false, 2);
+	uiFormat(
+		idFor(msg->params[1]), Warm, tagTime(msg),
+		"There is no sign in \3%02d%s\3",
+		hash(msg->params[1]), msg->params[1]
+	);
+}
+
+static void topicComplete(uint id, const char *topic) {
+	char buf[512];
+	struct Cursor curs = {0};
+	const char *prev = completePrefix(&curs, id, "/topic ");
+	if (prev) {
+		snprintf(buf, sizeof(buf), "%s", prev);
+		completeRemove(id, buf);
+	}
+	if (topic) {
+		snprintf(buf, sizeof(buf), "/topic %s", topic);
+		completePush(id, buf, Default);
+	}
+}
+
+static void handleReplyTopic(struct Message *msg) {
+	require(msg, false, 3);
+	uint id = idFor(msg->params[1]);
+	topicComplete(id, msg->params[2]);
+	if (!replies[ReplyTopic] && !replies[ReplyTopicAuto]) return;
+	urlScan(id, NULL, msg->params[2]);
+	uiFormat(
+		id, (replies[ReplyTopicAuto] ? Cold : Warm), tagTime(msg),
+		"The sign in \3%02d%s\3 reads: %s",
+		hash(msg->params[1]), msg->params[1], msg->params[2]
+	);
+	logFormat(
+		id, tagTime(msg), "The sign in %s reads: %s",
+		msg->params[1], msg->params[2]
+	);
+	if (replies[ReplyTopicAuto]) {
+		replies[ReplyTopicAuto]--;
 	} else {
-		uiFmt(
-			tag, UICold,
-			"\3%d%s\3 leaves \3%d%s\3",
-			colorGen(user), nick, colorGen(chan), chan
-		);
-		logFmt(tag, NULL, "%s leaves %s", nick, chan);
+		replies[ReplyTopic]--;
 	}
 }
 
-static void handleKick(char *prefix, char *params) {
-	char *nick, *user, *chan, *kick, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 2, 1, &chan, &kick, &mesg);
-	struct Tag tag = colorTag(tagFor(chan), chan);
-	bool kicked = !strcmp(kick, self.nick);
+static void swap(wchar_t *a, wchar_t *b) {
+	wchar_t x = *a;
+	*a = *b;
+	*b = x;
+}
 
-	if (kicked) {
-		tabClear(tag);
+static char *highlightMiddle(
+	char *ptr, char *end, enum Color color,
+	wchar_t *str, size_t pre, size_t suf
+) {
+	wchar_t nul = L'\0';
+	swap(&str[pre], &nul);
+	ptr = seprintf(ptr, end, "%ls", str);
+	swap(&str[pre], &nul);
+	swap(&str[suf], &nul);
+	if (hashBound) {
+		ptr = seprintf(
+			ptr, end, "\3%02d,%02d%ls\3%02d,%02d",
+			Default, color, &str[pre], Default, Default
+		);
 	} else {
-		tabRemove(tag, kick);
-	}
-
-	if (mesg) {
-		urlScan(tag, mesg);
-		uiFmt(
-			tag, (kicked ? UIHot : UICold),
-			"\3%d%s\3 kicks \3%d%s\3 out of \3%d%s\3, \"%s\"",
-			colorGen(user), nick,
-			colorGen(kick), kick,
-			colorGen(chan), chan,
-			dequote(mesg)
+		ptr = seprintf(ptr, end, "\26%ls\26", &str[pre]);
+	}
+	swap(&str[suf], &nul);
+	ptr = seprintf(ptr, end, "%ls", &str[suf]);
+	return ptr;
+}
+
+static void handleTopic(struct Message *msg) {
+	require(msg, true, 2);
+	uint id = idFor(msg->params[0]);
+	if (!msg->params[1][0]) {
+		topicComplete(id, NULL);
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"\3%02d%s\3\tremoves the sign in \3%02d%s\3",
+			hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
 		);
-		logFmt(
-			tag, NULL,
-			"%s kicks %s out of %s, \"%s\"", nick, kick, chan, dequote(mesg)
+		logFormat(
+			id, tagTime(msg), "%s removes the sign in %s",
+			msg->nick, msg->params[0]
 		);
+		return;
+	}
+
+	struct Cursor curs = {0};
+	const char *prev = completePrefix(&curs, id, "/topic ");
+	if (prev) {
+		prev += 7;
 	} else {
-		uiFmt(
-			tag, (kicked ? UIHot : UICold),
-			"\3%d%s\3 kicks \3%d%s\3 out of \3%d%s\3",
-			colorGen(user), nick,
-			colorGen(kick), kick,
-			colorGen(chan), chan
-		);
-		logFmt(tag, NULL, "%s kicks %s out of %s", nick, kick, chan);
+		goto plain;
 	}
+
+	wchar_t old[512];
+	wchar_t new[512];
+	if (swprintf(old, ARRAY_LEN(old), L"%s", prev) < 0) goto plain;
+	if (swprintf(new, ARRAY_LEN(new), L"%s", msg->params[1]) < 0) goto plain;
+
+	size_t pre;
+	for (pre = 0; old[pre] && new[pre] && old[pre] == new[pre]; ++pre);
+	size_t osuf = wcslen(old);
+	size_t nsuf = wcslen(new);
+	while (osuf > pre && nsuf > pre && old[osuf-1] == new[nsuf-1]) {
+		osuf--;
+		nsuf--;
+	}
+
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	ptr = seprintf(
+		ptr, end, "\3%02d%s\3\ttakes down the sign in \3%02d%s\3: ",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
+	);
+	ptr = highlightMiddle(ptr, end, Brown, old, pre, osuf);
+	if (osuf != pre) uiWrite(id, Cold, tagTime(msg), buf);
+	ptr = buf;
+	ptr = seprintf(
+		ptr, end, "\3%02d%s\3\tplaces a new sign in \3%02d%s\3: ",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0]
+	);
+	ptr = highlightMiddle(ptr, end, Green, new, pre, nsuf);
+	uiWrite(id, Warm, tagTime(msg), buf);
+	goto log;
+
+plain:
+	uiFormat(
+		id, Warm, tagTime(msg),
+		"\3%02d%s\3\tplaces a new sign in \3%02d%s\3: %s",
+		hash(msg->user), msg->nick, hash(msg->params[0]), msg->params[0],
+		msg->params[1]
+	);
+log:
+	logFormat(
+		id, tagTime(msg), "%s places a new sign in %s: %s",
+		msg->nick, msg->params[0], msg->params[1]
+	);
+	topicComplete(id, msg->params[1]);
+	urlScan(id, msg->nick, msg->params[1]);
 }
 
-static void handleQuit(char *prefix, char *params) {
-	char *nick, *user, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 0, 1, &mesg);
+static const char *UserModes[256] = {
+	['O'] = "local oper",
+	['i'] = "invisible",
+	['o'] = "oper",
+	['r'] = "registered",
+	['w'] = "wallops",
+};
+
+static void handleReplyUserModeIs(struct Message *msg) {
+	require(msg, false, 2);
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	for (char *ch = msg->params[1]; *ch; ++ch) {
+		if (*ch == '+') continue;
+		const char *name = UserModes[(byte)*ch];
+		ptr = seprintf(
+			ptr, end, ", +%c%s%s", *ch, (name ? " " : ""), (name ?: "")
+		);
+	}
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis %s",
+		self.color, self.nick, (ptr > buf ? &buf[2] : "modeless")
+	);
+}
 
-	struct Tag tag;
-	while (TagNone.id != (tag = tabTag(nick)).id) {
-		tabRemove(tag, nick);
+static const char *ChanModes[256] = {
+	['a'] = "protected",
+	['h'] = "halfop",
+	['i'] = "invite-only",
+	['k'] = "key",
+	['l'] = "client limit",
+	['m'] = "moderated",
+	['n'] = "no external messages",
+	['o'] = "operator",
+	['q'] = "founder",
+	['s'] = "secret",
+	['t'] = "protected topic",
+	['v'] = "voice",
+};
 
-		if (mesg) {
-			urlScan(tag, mesg);
-			uiFmt(
-				tag, UICold,
-				"\3%d%s\3 leaves, \"%s\"",
-				colorGen(user), nick, dequote(mesg)
+static void handleReplyChannelModeIs(struct Message *msg) {
+	require(msg, false, 3);
+	uint param = 3;
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	for (char *ch = msg->params[2]; *ch; ++ch) {
+		if (*ch == '+') continue;
+		const char *name = ChanModes[(byte)*ch];
+		if (
+			strchr(network.paramModes, *ch) ||
+			strchr(network.setParamModes, *ch)
+		) {
+			assert(param < ParamCap);
+			ptr = seprintf(
+				ptr, end, ", +%c%s%s %s",
+				*ch, (name ? " " : ""), (name ?: ""),
+				msg->params[param++]
 			);
-			logFmt(tag, NULL, "%s leaves, \"%s\"", nick, dequote(mesg));
 		} else {
-			uiFmt(tag, UICold, "\3%d%s\3 leaves", colorGen(user), nick);
-			logFmt(tag, NULL, "%s leaves", nick);
+			ptr = seprintf(
+				ptr, end, ", +%c%s%s",
+				*ch, (name ? " " : ""), (name ?: "")
+			);
 		}
 	}
+	uiFormat(
+		idFor(msg->params[1]), Warm, tagTime(msg),
+		"\3%02d%s\3\tis %s",
+		hash(msg->params[1]), msg->params[1],
+		(ptr > buf ? &buf[2] : "modeless")
+	);
 }
 
-static void handleReplyTopic(char *prefix, char *params) {
-	char *chan, *topic;
-	parse(prefix, NULL, NULL, NULL, params, 3, 0, NULL, &chan, &topic);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleMode(struct Message *msg) {
+	require(msg, true, 2);
+
+	if (!strchr(network.chanTypes, msg->params[0][0])) {
+		bool set = true;
+		for (char *ch = msg->params[1]; *ch; ++ch) {
+			if (*ch == '+') { set = true; continue; }
+			if (*ch == '-') { set = false; continue; }
+			const char *name = UserModes[(byte)*ch];
+			uiFormat(
+				Network, Warm, tagTime(msg),
+				"\3%02d%s\3\t%ssets \3%02d%s\3 %c%c%s%s",
+				hash(msg->user), msg->nick,
+				(set ? "" : "un"),
+				self.color, msg->params[0],
+				set["-+"], *ch, (name ? " " : ""), (name ?: "")
+			);
+		}
+		return;
+	}
 
-	urlScan(tag, topic);
-	uiFmt(
-		tag, UICold,
-		"The sign in \3%d%s\3 reads, \"%s\"",
-		colorGen(chan), chan, topic
-	);
-	logFmt(tag, NULL, "The sign in %s reads, \"%s\"", chan, topic);
+	uint id = idFor(msg->params[0]);
+	bool set = true;
+	uint i = 2;
+	for (char *ch = msg->params[1]; *ch; ++ch) {
+		if (*ch == '+') { set = true; continue; }
+		if (*ch == '-') { set = false; continue; }
+
+		const char *verb = (set ? "sets" : "unsets");
+		const char *name = ChanModes[(byte)*ch];
+		if (*ch == network.excepts) name = "except";
+		if (*ch == network.invex) name = "invite";
+		const char *mode = (const char[]) {
+			set["-+"], *ch, (name ? ' ' : '\0'), '\0'
+		};
+		if (!name) name = "";
+
+		if (strchr(network.prefixModes, *ch)) {
+			if (i >= ParamCap || !msg->params[i]) {
+				errx(1, "MODE missing %s parameter", mode);
+			}
+			char *nick = msg->params[i++];
+			char prefix = network.prefixes[
+				strchr(network.prefixModes, *ch) - network.prefixModes
+			];
+			completePush(id, nick, Default);
+			if (set) {
+				*completeBits(id, nick) |= prefixBit(prefix);
+			} else {
+				*completeBits(id, nick) &= ~prefixBit(prefix);
+			}
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%c%s\3 %s%s in \3%02d%s\3",
+				hash(msg->user), msg->nick, verb,
+				completeColor(id, nick), prefix, nick,
+				mode, name, hash(msg->params[0]), msg->params[0]
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %c%s %s%s in %s",
+				msg->nick, verb, prefix, nick, mode, name, msg->params[0]
+			);
+		}
+
+		if (strchr(network.listModes, *ch)) {
+			if (i >= ParamCap || !msg->params[i]) {
+				errx(1, "MODE missing %s parameter", mode);
+			}
+			char *mask = msg->params[i++];
+			if (*ch == 'b') {
+				verb = (set ? "bans" : "unbans");
+				uiFormat(
+					id, Cold, tagTime(msg),
+					"\3%02d%s\3\t%s %c%c %s from \3%02d%s\3",
+					hash(msg->user), msg->nick, verb, set["-+"], *ch, mask,
+					hash(msg->params[0]), msg->params[0]
+				);
+				logFormat(
+					id, tagTime(msg), "%s %s %c%c %s from %s",
+					msg->nick, verb, set["-+"], *ch, mask, msg->params[0]
+				);
+			} else {
+				verb = (set ? "adds" : "removes");
+				const char *to = (set ? "to" : "from");
+				uiFormat(
+					id, Cold, tagTime(msg),
+					"\3%02d%s\3\t%s %s %s the \3%02d%s\3 %s%s list",
+					hash(msg->user), msg->nick, verb, mask, to,
+					hash(msg->params[0]), msg->params[0], mode, name
+				);
+				logFormat(
+					id, tagTime(msg), "%s %s %s %s the %s %s%s list",
+					msg->nick, verb, mask, to, msg->params[0], mode, name
+				);
+			}
+		}
+
+		if (strchr(network.paramModes, *ch)) {
+			if (i >= ParamCap || !msg->params[i]) {
+				errx(1, "MODE missing %s parameter", mode);
+			}
+			char *param = msg->params[i++];
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%s\3 %s%s %s",
+				hash(msg->user), msg->nick, verb,
+				hash(msg->params[0]), msg->params[0], mode, name, param
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %s %s%s %s",
+				msg->nick, verb, msg->params[0], mode, name, param
+			);
+		}
+
+		if (strchr(network.setParamModes, *ch) && set) {
+			if (i >= ParamCap || !msg->params[i]) {
+				errx(1, "MODE missing %s parameter", mode);
+			}
+			char *param = msg->params[i++];
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%s\3 %s%s %s",
+				hash(msg->user), msg->nick, verb,
+				hash(msg->params[0]), msg->params[0], mode, name, param
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %s %s%s %s",
+				msg->nick, verb, msg->params[0], mode, name, param
+			);
+		} else if (strchr(network.setParamModes, *ch)) {
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%s\3 %s%s",
+				hash(msg->user), msg->nick, verb,
+				hash(msg->params[0]), msg->params[0], mode, name
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %s %s%s",
+				msg->nick, verb, msg->params[0], mode, name
+			);
+		}
+
+		if (strchr(network.channelModes, *ch)) {
+			uiFormat(
+				id, Cold, tagTime(msg),
+				"\3%02d%s\3\t%s \3%02d%s\3 %s%s",
+				hash(msg->user), msg->nick, verb,
+				hash(msg->params[0]), msg->params[0], mode, name
+			);
+			logFormat(
+				id, tagTime(msg), "%s %s %s %s%s",
+				msg->nick, verb, msg->params[0], mode, name
+			);
+		}
+	}
 }
 
-static void handleTopic(char *prefix, char *params) {
-	char *nick, *user, *chan, *topic;
-	parse(prefix, &nick, &user, NULL, params, 2, 0, &chan, &topic);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleErrorChanopPrivsNeeded(struct Message *msg) {
+	require(msg, false, 3);
+	uiFormat(
+		idFor(msg->params[1]), Warm, tagTime(msg),
+		"%s", msg->params[2]
+	);
+}
 
-	if (strcmp(nick, self.nick)) tabTouch(tag, nick);
+static void handleErrorUserNotInChannel(struct Message *msg) {
+	require(msg, false, 4);
+	uiFormat(
+		idFor(msg->params[2]), Warm, tagTime(msg),
+		"%s\tis not in \3%02d%s\3",
+		msg->params[1], hash(msg->params[2]), msg->params[2]
+	);
+}
 
-	urlScan(tag, topic);
-	uiFmt(
-		tag, UICold,
-		"\3%d%s\3 places a new sign in \3%d%s\3, \"%s\"",
-		colorGen(user), nick, colorGen(chan), chan, topic
+static void handleErrorBanListFull(struct Message *msg) {
+	require(msg, false, 4);
+	uiFormat(
+		idFor(msg->params[1]), Warm, tagTime(msg),
+		"%s", (msg->params[4] ?: msg->params[3])
 	);
-	logFmt(tag, NULL, "%s places a new sign in %s, \"%s\"", nick, chan, topic);
 }
 
-static void handleReplyEndOfNames(char *prefix, char *params) {
-	char *chan;
-	parse(prefix, NULL, NULL, NULL, params, 2, 0, NULL, &chan);
-	ircFmt("WHO %s\r\n", chan);
+static void handleReplyBanList(struct Message *msg) {
+	require(msg, false, 3);
+	uint id = idFor(msg->params[1]);
+	if (msg->params[3] && msg->params[4]) {
+		char since[sizeof("0000-00-00 00:00:00")];
+		time_t time = strtol(msg->params[4], NULL, 10);
+		strftime(since, sizeof(since), "%F %T", localtime(&time));
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"Banned from \3%02d%s\3 since %s by \3%02d%s\3: %s",
+			hash(msg->params[1]), msg->params[1],
+			since, completeColor(id, msg->params[3]), msg->params[3],
+			msg->params[2]
+		);
+	} else {
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"Banned from \3%02d%s\3: %s",
+			hash(msg->params[1]), msg->params[1], msg->params[2]
+		);
+	}
 }
 
-static struct {
-	char buf[4096];
-	size_t len;
-} who;
+static void onList(const char *list, struct Message *msg) {
+	require(msg, false, 3);
+	uint id = idFor(msg->params[1]);
+	if (msg->params[3] && msg->params[4]) {
+		char since[sizeof("0000-00-00 00:00:00")];
+		time_t time = strtol(msg->params[4], NULL, 10);
+		strftime(since, sizeof(since), "%F %T", localtime(&time));
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"On the \3%02d%s\3 %s list since %s by \3%02d%s\3: %s",
+			hash(msg->params[1]), msg->params[1], list,
+			since, completeColor(id, msg->params[3]), msg->params[3],
+			msg->params[2]
+		);
+	} else {
+		uiFormat(
+			id, Warm, tagTime(msg),
+			"On the \3%02d%s\3 %s list: %s",
+			hash(msg->params[1]), msg->params[1], list, msg->params[2]
+		);
+	}
+}
 
-static void handleReplyWho(char *prefix, char *params) {
-	char *chan, *user, *nick;
-	parse(
-		prefix, NULL, NULL, NULL,
-		params, 6, 0, NULL, &chan, &user, NULL, NULL, &nick
-	);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleReplyExceptList(struct Message *msg) {
+	onList("except", msg);
+}
 
-	tabAdd(tag, nick);
+static void handleReplyInviteList(struct Message *msg) {
+	onList("invite", msg);
+}
 
-	size_t cap = sizeof(who.buf) - who.len;
-	int len = snprintf(
-		&who.buf[who.len], cap,
-		"%s\3%d%s\3",
-		(who.len ? ", " : ""), colorGen(user), nick
+static void handleReplyList(struct Message *msg) {
+	require(msg, false, 3);
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"In \3%02d%s\3 are %ld under the banner: %s",
+		hash(msg->params[1]), msg->params[1],
+		strtol(msg->params[2], NULL, 10),
+		(msg->params[3] ?: "")
 	);
-	if ((size_t)len < cap) who.len += len;
 }
 
-static void handleReplyEndOfWho(char *prefix, char *params) {
-	char *chan;
-	parse(prefix, NULL, NULL, NULL, params, 2, 0, NULL, &chan);
-	struct Tag tag = colorTag(tagFor(chan), chan);
+static void handleReplyWhoisUser(struct Message *msg) {
+	require(msg, false, 6);
+	completePull(Network, msg->params[1], hash(msg->params[2]));
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis %s!%s@%s (%s\17)",
+		hash(msg->params[2]), msg->params[1],
+		msg->params[1], msg->params[2], msg->params[3], msg->params[5]
+	);
+}
 
-	uiFmt(
-		tag, UICold,
-		"In \3%d%s\3 are %s",
-		colorGen(chan), chan, who.buf
+static void handleReplyWhoisServer(struct Message *msg) {
+	if (!replies[ReplyWhois] && !replies[ReplyWhowas]) return;
+	require(msg, false, 4);
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\t%s connected to %s (%s)",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		(replies[ReplyWhowas] ? "was" : "is"), msg->params[2], msg->params[3]
 	);
-	who.len = 0;
 }
 
-static void handleNick(char *prefix, char *params) {
-	char *prev, *user, *next;
-	parse(prefix, &prev, &user, NULL, params, 1, 0, &next);
+static void handleReplyWhoisIdle(struct Message *msg) {
+	require(msg, false, 3);
+	unsigned long idle = strtoul(msg->params[2], NULL, 10);
+	const char *unit = "second";
+	if (idle / 60) {
+		idle /= 60; unit = "minute";
+		if (idle / 60) {
+			idle /= 60; unit = "hour";
+			if (idle / 24) {
+				idle /= 24; unit = "day";
+			}
+		}
+	}
+	char signon[sizeof("0000-00-00 00:00:00")];
+	time_t time = strtol((msg->params[3] ?: ""), NULL, 10);
+	strftime(signon, sizeof(signon), "%F %T", localtime(&time));
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis idle for %lu %s%s%s%s",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		idle, unit, (idle != 1 ? "s" : ""),
+		(msg->params[3] ? ", signed on " : ""), (msg->params[3] ? signon : "")
+	);
+}
 
-	if (!strcmp(prev, self.nick)) {
-		free(self.nick);
-		self.nick = strdup(next);
-		if (!self.nick) err(EX_OSERR, "strdup");
-		uiPrompt(true);
+static void handleReplyWhoisChannels(struct Message *msg) {
+	require(msg, false, 3);
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	while (msg->params[2]) {
+		char *channel = strsep(&msg->params[2], " ");
+		if (!channel[0]) break;
+		char *name = &channel[strspn(channel, network.prefixes)];
+		ptr = seprintf(
+			ptr, end, "%s\3%02d%s\3",
+			(ptr > buf ? ", " : ""), hash(name), channel
+		);
 	}
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\tis in %s",
+		completeColor(Network, msg->params[1]), msg->params[1], buf
+	);
+}
 
-	struct Tag tag;
-	while (TagNone.id != (tag = tabTag(prev)).id) {
-		tabReplace(tag, prev, next);
+static void handleReplyWhoisGeneric(struct Message *msg) {
+	require(msg, false, 3);
+	if (msg->params[3]) {
+		msg->params[0] = msg->params[2];
+		msg->params[2] = msg->params[3];
+		msg->params[3] = msg->params[0];
+	}
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\t%s%s%s",
+		completeColor(Network, msg->params[1]), msg->params[1],
+		msg->params[2], (msg->params[3] ? " " : ""), (msg->params[3] ?: "")
+	);
+}
 
-		uiFmt(
-			tag, UICold,
-			"\3%d%s\3 is now known as \3%d%s\3",
-			colorGen(user), prev, colorGen(user), next
-		);
-		logFmt(tag, NULL, "%s is now known as %s", prev, next);
+static void handleReplyEndOfWhois(struct Message *msg) {
+	require(msg, false, 2);
+	if (strcmp(msg->params[1], self.nick)) {
+		completeRemove(Network, msg->params[1]);
 	}
 }
 
-static void handleCTCP(struct Tag tag, char *nick, char *user, char *mesg) {
-	mesg = &mesg[1];
-	char *ctcp = strsep(&mesg, " ");
-	char *params = strsep(&mesg, "\1");
-	if (strcmp(ctcp, "ACTION")) return;
+static void handleReplyWhowasUser(struct Message *msg) {
+	require(msg, false, 6);
+	completePull(Network, msg->params[1], hash(msg->params[2]));
+	uiFormat(
+		Network, Warm, tagTime(msg),
+		"\3%02d%s\3\twas %s!%s@%s (%s)",
+		hash(msg->params[2]), msg->params[1],
+		msg->params[1], msg->params[2], msg->params[3], msg->params[5]
+	);
+}
 
-	if (strcmp(nick, self.nick)) tabTouch(tag, nick);
+static void handleReplyEndOfWhowas(struct Message *msg) {
+	require(msg, false, 2);
+	if (strcmp(msg->params[1], self.nick)) {
+		completeRemove(Network, msg->params[1]);
+	}
+}
 
-	urlScan(tag, params);
-	bool ping = strcmp(nick, self.nick) && isPing(params);
-	uiFmt(
-		tag, (ping ? UIHot : UIWarm),
-		"%c\3%d* %s\17 %s",
-		ping["\17\26"], colorGen(user), nick, params
+static void handleReplyAway(struct Message *msg) {
+	require(msg, false, 3);
+	// Might be part of a WHOIS response.
+	uint id = (replies[ReplyWhois] ? Network : idFor(msg->params[1]));
+	uiFormat(
+		id, (id == Network ? Warm : Cold), tagTime(msg),
+		"\3%02d%s\3\tis away: %s",
+		completeColor(id, msg->params[1]), msg->params[1], msg->params[2]
+	);
+	logFormat(
+		id, tagTime(msg), "%s is away: %s",
+		msg->params[1], msg->params[2]
 	);
-	logFmt(tag, NULL, "* %s %s", nick, params);
 }
 
-static void handlePrivmsg(char *prefix, char *params) {
-	char *nick, *user, *chan, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 2, 0, &chan, &mesg);
-	bool direct = !strcmp(chan, self.nick);
-	struct Tag tag = tagFor(direct ? nick : chan);
-	colorTag(tag, direct ? user : chan);
-	if (mesg[0] == '\1') {
-		handleCTCP(tag, nick, user, mesg);
-		return;
+static void handleReplyNowAway(struct Message *msg) {
+	require(msg, false, 2);
+	uiFormat(Network, Warm, tagTime(msg), "%s", msg->params[1]);
+}
+
+static bool isAction(struct Message *msg) {
+	if (strncmp(msg->params[1], "\1ACTION", 7)) return false;
+	if (msg->params[1][7] == ' ') {
+		msg->params[1] += 8;
+	} else if (msg->params[1][7] == '\1') {
+		msg->params[1] += 7;
+	} else {
+		return false;
+	}
+	size_t len = strlen(msg->params[1]);
+	if (msg->params[1][len - 1] == '\1') {
+		msg->params[1][len - 1] = '\0';
 	}
+	return true;
+}
 
-	bool me = !strcmp(nick, self.nick);
-	if (!me) tabTouch(tag, nick);
+static bool matchWord(const char *str, const char *word) {
+	size_t len = strlen(word);
+	const char *match = str;
+	while (NULL != (match = strstr(match, word))) {
+		char a = (match > str ? match[-1] : ' ');
+		char b = (match[len] ?: ' ');
+		if ((isspace(a) || ispunct(a)) && (isspace(b) || ispunct(b))) {
+			return true;
+		}
+		match = &match[len];
+	}
+	return false;
+}
 
-	urlScan(tag, mesg);
-	bool hot = !me && (direct || isPing(mesg));
-	bool ping = !me && isPing(mesg);
-	uiFmt(
-		tag, (hot ? UIHot : UIWarm),
-		"%c%c\3%d<%s>%c %s",
-		(me ? IRCUnderline : IRCColor), (ping ? IRCReverse : IRCColor),
-		colorGen(user), nick, IRCReset, mesg
-	);
-	logFmt(tag, NULL, "<%s> %s", nick, mesg);
+static bool isMention(const struct Message *msg) {
+	if (matchWord(msg->params[1], self.nick)) return true;
+	for (uint i = 0; i < ARRAY_LEN(self.nicks) && self.nicks[i]; ++i) {
+		if (matchWord(msg->params[1], self.nicks[i])) return true;
+	}
+	return false;
 }
 
-static void handleNotice(char *prefix, char *params) {
-	char *nick, *user, *chan, *mesg;
-	parse(prefix, &nick, &user, NULL, params, 2, 0, &chan, &mesg);
-	bool direct = !strcmp(chan, self.nick);
-	struct Tag tag = TagStatus;
-	if (user) {
-		tag = tagFor(direct ? nick : chan);
-		colorTag(tag, direct ? user : chan);
+static char *colorMentions(char *ptr, char *end, uint id, const char *msg) {
+	// Consider words before a colon, or only the first two.
+	const char *split = strstr(msg, ": ");
+	if (!split) {
+		split = strchr(msg, ' ');
+		if (split) split = strchr(&split[1], ' ');
+	}
+	if (!split) split = &msg[strlen(msg)];
+	// Bail if there is existing formatting.
+	for (const char *ch = msg; ch < split; ++ch) {
+		if (iscntrl(*ch)) goto rest;
 	}
 
-	if (strcmp(nick, self.nick)) tabTouch(tag, nick);
+	while (msg < split) {
+		size_t skip = strspn(msg, ",:<> ");
+		ptr = seprintf(ptr, end, "%.*s", (int)skip, msg);
+		msg += skip;
 
-	urlScan(tag, mesg);
-	bool ping = strcmp(nick, self.nick) && isPing(mesg);
-	uiFmt(
-		tag, (ping ? UIHot : UIWarm),
-		"%c\3%d-%s-\17 %s",
-		ping["\17\26"], colorGen(user), nick, mesg
-	);
-	logFmt(tag, NULL, "-%s- %s", nick, mesg);
+		size_t len = strcspn(msg, ",:<> ");
+		char *p = seprintf(ptr, end, "%.*s", (int)len, msg);
+		enum Color color = completeColor(id, ptr);
+		if (color != Default) {
+			ptr = seprintf(ptr, end, "\3%02d%.*s\3", color, (int)len, msg);
+		} else {
+			ptr = p;
+		}
+		msg += len;
+	}
+
+rest:
+	return seprintf(ptr, end, "%s", msg);
 }
 
-static const struct {
-	const char *command;
-	Handler *handler;
+static void handlePrivmsg(struct Message *msg) {
+	require(msg, true, 2);
+	char statusmsg = '\0';
+	if (network.statusmsg && strchr(network.statusmsg, msg->params[0][0])) {
+		statusmsg = msg->params[0][0];
+		msg->params[0]++;
+	}
+	bool query = !strchr(network.chanTypes, msg->params[0][0]);
+	bool server = strchr(msg->nick, '.');
+	bool mine = !strcmp(msg->nick, self.nick);
+	uint id;
+	if (query && server) {
+		id = Network;
+	} else if (query && !mine) {
+		id = idFor(msg->nick);
+		idColors[id] = hash(msg->user);
+	} else {
+		id = idFor(msg->params[0]);
+	}
+
+	bool notice = (msg->cmd[0] == 'N');
+	bool action = !notice && isAction(msg);
+	bool highlight = !mine && isMention(msg);
+	enum Heat heat = (!notice && (highlight || query) ? Hot : Warm);
+	heat = filterCheck(heat, id, msg);
+	if (heat > Warm && !mine && !query) highlight = true;
+	if (!notice && !mine && heat > Ice) {
+		completePull(id, msg->nick, hash(msg->user));
+	}
+	if (heat > Ice) urlScan(id, msg->nick, msg->params[1]);
+
+	char buf[1024];
+	char *ptr = buf, *end = &buf[sizeof(buf)];
+	if (statusmsg) {
+		ptr = seprintf(
+			ptr, end, "\3%d[%c]\3 ", hash(msg->params[0]), statusmsg
+		);
+	}
+	if (notice) {
+		if (id != Network) {
+			logFormat(id, tagTime(msg), "-%s- %s", msg->nick, msg->params[1]);
+		}
+		ptr = seprintf(
+			ptr, end, "\3%d-%s-\3%d\t",
+			hash(msg->user), msg->nick, LightGray
+		);
+	} else if (action) {
+		logFormat(id, tagTime(msg), "* %s %s", msg->nick, msg->params[1]);
+		ptr = seprintf(
+			ptr, end, "%s\35\3%d* %s\17\35\t",
+			(highlight ? "\26" : ""), hash(msg->user), msg->nick
+		);
+	} else {
+		logFormat(id, tagTime(msg), "<%s> %s", msg->nick, msg->params[1]);
+		ptr = seprintf(
+			ptr, end, "%s\3%d<%s>\17\t",
+			(highlight ? "\26" : ""), hash(msg->user), msg->nick
+		);
+	}
+	if (notice) {
+		ptr = seprintf(ptr, end, "%s", msg->params[1]);
+	} else {
+		ptr = colorMentions(ptr, end, id, msg->params[1]);
+	}
+	uiWrite(id, heat, tagTime(msg), buf);
+}
+
+static void handlePing(struct Message *msg) {
+	require(msg, false, 1);
+	ircFormat("PONG :%s\r\n", msg->params[0]);
+}
+
+static void handleError(struct Message *msg) {
+	require(msg, false, 1);
+	errx(69, "%s", msg->params[0]);
+}
+
+static const struct Handler {
+	const char *cmd;
+	int reply;
+	Handler *fn;
 } Handlers[] = {
-	{ "001", handleReplyWelcome },
-	{ "311", handleReplyWhoisUser },
-	{ "312", handleReplyWhoisServer },
-	{ "313", handleReplyWhoisOperator },
-	{ "315", handleReplyEndOfWho },
-	{ "317", handleReplyWhoisIdle },
-	{ "319", handleReplyWhoisChannels },
-	{ "322", handleReplyList },
-	{ "323", handleReplyListEnd },
-	{ "332", handleReplyTopic },
-	{ "352", handleReplyWho },
-	{ "366", handleReplyEndOfNames },
-	{ "372", handleReplyMOTD },
-	{ "375", handleReplyMOTD },
-	{ "401", handleErrorNoSuchNick },
-	{ "432", handleErrorErroneousNickname },
-	{ "433", handleErrorErroneousNickname },
-	{ "CAP", handleCap },
-	{ "ERROR", handleError },
-	{ "JOIN", handleJoin },
-	{ "KICK", handleKick },
-	{ "NICK", handleNick },
-	{ "NOTICE", handleNotice },
-	{ "PART", handlePart },
-	{ "PING", handlePing },
-	{ "PRIVMSG", handlePrivmsg },
-	{ "QUIT", handleQuit },
-	{ "TOPIC", handleTopic },
+	{ "001", 0, handleReplyWelcome },
+	{ "005", 0, handleReplyISupport },
+	{ "221", -ReplyMode, handleReplyUserModeIs },
+	{ "276", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "301", 0, handleReplyAway },
+	{ "305", -ReplyAway, handleReplyNowAway },
+	{ "306", -ReplyAway, handleReplyNowAway },
+	{ "307", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "311", +ReplyWhois, handleReplyWhoisUser },
+	{ "312", 0, handleReplyWhoisServer },
+	{ "313", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "314", +ReplyWhowas, handleReplyWhowasUser },
+	{ "317", +ReplyWhois, handleReplyWhoisIdle },
+	{ "318", -ReplyWhois, handleReplyEndOfWhois },
+	{ "319", +ReplyWhois, handleReplyWhoisChannels },
+	{ "320", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "322", +ReplyList, handleReplyList },
+	{ "323", -ReplyList, NULL },
+	{ "324", -ReplyMode, handleReplyChannelModeIs },
+	{ "330", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "331", -ReplyTopic, handleReplyNoTopic },
+	{ "332", 0, handleReplyTopic },
+	{ "335", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "338", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "341", 0, handleReplyInviting },
+	{ "346", +ReplyInvex, handleReplyInviteList },
+	{ "347", -ReplyInvex, NULL },
+	{ "348", +ReplyExcepts, handleReplyExceptList },
+	{ "349", -ReplyExcepts, NULL },
+	{ "353", 0, handleReplyNames },
+	{ "366", 0, handleReplyEndOfNames },
+	{ "367", +ReplyBan, handleReplyBanList },
+	{ "368", -ReplyBan, NULL },
+	{ "369", -ReplyWhowas, handleReplyEndOfWhowas },
+	{ "372", 0, handleReplyMOTD },
+	{ "378", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "379", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "422", 0, handleErrorNoMOTD },
+	{ "432", 0, handleErrorErroneousNickname },
+	{ "433", 0, handleErrorNicknameInUse },
+	{ "437", 0, handleErrorNicknameInUse },
+	{ "441", 0, handleErrorUserNotInChannel },
+	{ "443", 0, handleErrorUserOnChannel },
+	{ "478", 0, handleErrorBanListFull },
+	{ "482", 0, handleErrorChanopPrivsNeeded },
+	{ "671", +ReplyWhois, handleReplyWhoisGeneric },
+	{ "704", +ReplyHelp, handleReplyHelp },
+	{ "705", +ReplyHelp, handleReplyHelp },
+	{ "706", -ReplyHelp, NULL },
+	{ "900", 0, handleReplyLoggedIn },
+	{ "904", 0, handleErrorSASLFail },
+	{ "905", 0, handleErrorSASLFail },
+	{ "906", 0, handleErrorSASLFail },
+	{ "AUTHENTICATE", 0, handleAuthenticate },
+	{ "CAP", 0, handleCap },
+	{ "CHGHOST", 0, handleChghost },
+	{ "ERROR", 0, handleError },
+	{ "FAIL", 0, handleStandardReply },
+	{ "INVITE", 0, handleInvite },
+	{ "JOIN", 0, handleJoin },
+	{ "KICK", 0, handleKick },
+	{ "MODE", 0, handleMode },
+	{ "NICK", 0, handleNick },
+	{ "NOTE", 0, handleStandardReply },
+	{ "NOTICE", 0, handlePrivmsg },
+	{ "PART", 0, handlePart },
+	{ "PING", 0, handlePing },
+	{ "PRIVMSG", 0, handlePrivmsg },
+	{ "QUIT", 0, handleQuit },
+	{ "SETNAME", 0, handleSetname },
+	{ "TOPIC", 0, handleTopic },
+	{ "WARN", 0, handleStandardReply },
 };
-static const size_t HandlersLen = sizeof(Handlers) / sizeof(Handlers[0]);
-
-void handle(char *line) {
-	char *prefix = NULL;
-	if (line[0] == ':') {
-		prefix = strsep(&line, " ") + 1;
-		if (!line) errx(EX_PROTOCOL, "unexpected eol");
-	}
-	char *command = strsep(&line, " ");
-	for (size_t i = 0; i < HandlersLen; ++i) {
-		if (strcmp(command, Handlers[i].command)) continue;
-		Handlers[i].handler(prefix, line);
-		break;
+
+static int compar(const void *cmd, const void *_handler) {
+	const struct Handler *handler = _handler;
+	return strcmp(cmd, handler->cmd);
+}
+
+void handle(struct Message *msg) {
+	if (!msg->cmd) return;
+	if (msg->tags[TagPos]) {
+		self.pos = strtoull(msg->tags[TagPos], NULL, 10);
+	}
+	const struct Handler *handler = bsearch(
+		msg->cmd, Handlers, ARRAY_LEN(Handlers), sizeof(*handler), compar
+	);
+	if (handler) {
+		if (handler->reply && !replies[abs(handler->reply)]) return;
+		if (handler->fn) handler->fn(msg);
+		if (handler->reply < 0) replies[abs(handler->reply)]--;
+	} else if (strcmp(msg->cmd, "400") >= 0 && strcmp(msg->cmd, "599") <= 0) {
+		handleErrorGeneric(msg);
+	} else if (isdigit(msg->cmd[0])) {
+		handleReplyGeneric(msg);
 	}
 }