commit 4a461dd

wf  ·  2026-08-17 22:09:49 +0000 UTC
parent 4a461dd
Initial commit
2 files changed,  +1024, -0
A README
A glub
A README
+34, -0
 1@@ -0,0 +1,34 @@
 2+glub
 3+====
 4+
 5+ Small and janky IRC client written in mksh in ~1000
 6+ LOC.
 7+
 8+ Include a screenshot here???
 9+
10+Dependencies
11+------------
12+
13+ mksh, stty, date, brssl (or any other SSL client).
14+
15+TODO
16+----
17+
18+ * Implement draft/chathistory and bouncer support
19+ * Fix scrollback not working properly in the server
20+   buffer
21+
22+Features
23+--------
24+
25+ * Quite small and quick.
26+ * Supports SASL authentication.
27+ * Has a barely-a-UI with colors.
28+ * Scrollback.
29+
30+Why the name?
31+-------------
32+
33+ I was using this nick when testing the client, and
34+ the great minds of 9larp thought it'd be cool to
35+ name the client 'glub' too.
A glub
+990, -0
  1@@ -0,0 +1,990 @@
  2+#!/bin/mksh
  3+# glub - IRC client in mksh
  4+# Usage: glub [-h] [-s server] [-p port] [-unr name] [-c channels] [-U sasl_user] [-P sasl_pass]
  5+
  6+# shellcheck shell=ksh # Well, almost.
  7+# Avoid unwanted surprises...
  8+set -o noglob
  9+
 10+typeset -a bufs bhist
 11+typeset -i port=6697 curbuf=0 scroll=0
 12+typeset srv="irc.ergo.chat" user="$USER" nick="$USER" real="$USER"
 13+typeset sasl_user sasl_pass
 14+
 15+typeset pref cmd trail ctcp input
 16+typeset -i cursor
 17+typeset -a params
 18+
 19+trap 'die' INT TERM QUIT
 20+
 21+help() {
 22+	cat << EOF
 23+Usage: ${0##*/} [-s server] [-p port] [-unr name] [-c channels] [-U sasl_user] [-P sasl_pass]
 24+
 25+Options:
 26+  -h             Print this help message
 27+  -s server      Server to connect to (irc.ergo.chat by default)
 28+  -p port        Port to use when connecting (6697 by default)
 29+  -unr name      Username, nick and realname to use (\$USER by default)
 30+  -c channels    Comma-separated list of channels to join upon connection
 31+  -U sasl_user,
 32+  -P sasl_pass   Credentials to use when authenticating with SASL
 33+EOF
 34+}
 35+
 36+termin() {
 37+	printf '\e[?1049h\e[2J\e[1;%dr\e[H\e[%d;1H' $((LINES - 1)) $LINES
 38+	stty -echo -isig -icanon -iexten
 39+}
 40+
 41+termout() {
 42+	printf '\e[;r\e[?1049l'
 43+	stty iexten icanon isig echo
 44+}
 45+
 46+die() {
 47+	exec 3>&-; exec 4>&-
 48+	kill $brpid 2>/dev/null; wait $brpid 2>/dev/null
 49+	termout
 50+}
 51+
 52+prin() {
 53+	printf '\e7\e[%d;1H\eD%b\e8' $((LINES - 1)) "$1"
 54+}
 55+
 56+prompt() {
 57+	local b
 58+	[ "${bufs[curbuf]}" = '_' ] && b="<server>" || b="${bufs[curbuf]}"
 59+	printf '\e[%d;1H\e[2K%s → %s' $LINES "$b" "$input"
 60+}
 61+
 62+nclr() {
 63+	local h="${1@#}" clr i
 64+	clr=(31 32 33 34 35 36 91 92 93 94 95 96)
 65+	i=$((0x$h % ${#clr[@]}))
 66+	((i < 0)) && ((i = -i))
 67+	printf '%d' "${clr[i]}"
 68+}
 69+
 70+parse() {
 71+	local line="$1"
 72+	pref="" cmd="" trail="" ctcp=""
 73+	params=()
 74+	line="${line%$'\r'}"
 75+
 76+	if [[ "$line" == ':'* ]]; then
 77+		pref="${line#*:}" pref="${pref%% *}"
 78+		line="${line#* }"
 79+	fi
 80+
 81+	cmd="${line%% *}"
 82+	line="${line#* }"
 83+
 84+	if [[ "$line" == *' :'* ]]; then
 85+		trail="${line#* :}"
 86+		line="${line% " $trail"}"
 87+	fi
 88+
 89+	[[ "$trail" == $'\x01'*$'\x01' ]] && \
 90+		ctcp="${trail#$'\x01'}" ctcp="${ctcp%$'\x01'}"
 91+
 92+	# shellcheck disable=2086 # Word splitting is intentional here.
 93+	set -A params -- $line
 94+}
 95+
 96+addbuf() {
 97+	local name="$1" i
 98+	# Make sure we have no buffer with the same name.
 99+	for i in "${!bufs[@]}"; do
100+		[[ "${bufs[i]}" == "$name" ]] && return
101+	done
102+	bufs+=("$name")
103+	bhist[${#bufs[@]} - 1]=""
104+}
105+
106+bmsg() {
107+	if [ -z "${bhist[$1]+x}" ]; then
108+		bhist[$1]="$2"
109+	else
110+		bhist[$1]+=$'\n'"$2"
111+	fi
112+}
113+
114+status() {
115+	# shellcheck disable=2155 # I don't need those.
116+	local s="$(printf '\e[90m%13s  %s\e[0m' '::' "$2")"
117+	bmsg "$1" "$s"
118+	((curbuf == $1)) && prin "$s"
119+}
120+
121+bprint() {
122+	local lines="${bhist[$1]}" c=0 line
123+	while IFS= read -r line; do ((c++)); done <<< "$lines"
124+
125+	local st=$((c - (LINES - 1) + 1 - scroll))
126+	((st < 1)) && st=1; ((st > c)) && ((st = c))
127+
128+	printf '\e[%d;1H\e[2J' $((LINES - 1))
129+	local i=0
130+	while IFS= read -r line; do
131+		((i++))
132+		((i < st)) && continue
133+		printf '\n%b' "$line"
134+	done <<< "$lines"
135+}
136+
137+buf() {
138+	local i
139+	for i in "${!bufs[@]}"; do
140+		if [[ "${bufs[i]}" == "$1" ]]; then
141+			((curbuf = i))
142+			bprint "$i"
143+			return
144+		fi
145+	done
146+
147+	status "$curbuf" "No such buffer: $1"
148+}
149+
150+b64() {
151+	local a='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\
152++/'
153+	local i=0
154+	while ((i < ${#1})); do
155+		# shellcheck disable=2155 # Sigh...
156+		local b1=$(printf '%d' "'${input:i:1}")
157+		# shellcheck disable=2155 # Siiiiighh.....
158+		local b2=$(printf '%d' "'${input:i+1:1}")
159+		# shellcheck disable=2155 # SIIIIIGHHHH.....
160+		local b3=$(printf '%d' "'${input:i+2:2}")
161+		b2=${b2:-0} b3=${b3:-0}
162+
163+		local bits=$(((b1 << 16) | (b2 << 8) | b3))
164+		local c1=$(((bits >> 18) & 63)) c2=$(((bits >> 12) & 63))
165+		local c3=$(((bits >> 6) & 63)) c4=$((bits & 63))
166+		local rem=$((${#1} - i))
167+
168+		case "$rem" in
169+			1) printf '%s%s==' "${a:c1:1}" "${a:c2:1}" ;;
170+			2) printf '%s%s%s=' "${a:c1:1}" "${a:c2:1}" \
171+				"${a:c3:1}" ;;
172+			*) printf '%s%s%s%s' "${a:c1:1}" "${a:c2:1}" \
173+				"${a:c3:1}" "${a:c4:1}" ;;
174+		esac
175+		((i += 3))
176+	done
177+}
178+
179+input() {
180+	local line="$1"
181+	prompt
182+	[ -z "$line" ] && return
183+
184+	[[ "$line" != '/'* ]] && {
185+		case "${bufs[curbuf]}" in ''|'_'|@*)
186+			status 0 "Cannot send messages to server"
187+			return
188+			;;
189+		esac
190+
191+		print -ru3 "PRIVMSG ${bufs[curbuf]} :${line}"
192+		# shellcheck disable=2155
193+		local hl="\e[$(nclr "$nick")m"
194+		[[ "$line" == *"$nick"* ]] && hl="\e[7m${hl}"
195+		local l=$((13 - ${#nick} - 1))
196+		# shellcheck disable=2155
197+		local s="$(printf '%*s%b %s \e[0m %s' "$l" '' "$hl" "$nick" \
198+			"$line")"
199+		bmsg "$curbuf" "$s"
200+		prin "$s"
201+		return
202+	}
203+
204+	local cmd="${line%% *}" args="${line#* }"
205+	[ "$args" = "$cmd" ] && args=""
206+
207+	case "$cmd" in
208+		/b|/buffer)
209+			local sub="${args%% *}" rest="${args#* }"
210+			[[ "$args" == "$sub" ]] && rest=""
211+
212+			case "$sub" in
213+				next)
214+					((${#bufs[@]} <= 1)) && {
215+						status "$curbuf" \
216+							"No next buffer"
217+						return
218+					}
219+					((curbuf = (curbuf + 1) % ${#bufs[@]}))
220+					bprint "$curbuf"
221+					;;
222+				prev)
223+					((${#bufs[@]} <= 1)) && {
224+						status "$curbuf" \
225+							"No previous buffer"
226+						return
227+					}
228+					((curbuf = (curbuf - 1 + ${#bufs[@]}) \
229+						 % ${#bufs[@]}))
230+					bprint "$curbuf"
231+					;;
232+				*) buf "$sub" ;;
233+			esac
234+			;;
235+		/h|/help)
236+			status "$curbuf" "List of commands:"
237+			# shellcheck disable=2155 # I don't care...
238+			local m="$(cat <<EOF
239+/h(elp), /b(uffer) prev/next/<name>, /j(oin) <#channel>, /me <action>, \
240+/nick <nickname>, /p(art) [#channel], /q(uery) <nickname>, /quit, /quote <raw>
241+EOF
242+)"
243+			status "$curbuf" "$m"
244+			;;
245+		/j|/join)
246+			local target="${args%% *}"
247+			[ -z "$target" ] && {
248+				status "$curbuf" "/join: Join what?"
249+				return
250+			}
251+			print -ru3 "JOIN $target"
252+			addbuf "$target"
253+			buf "$target"
254+			;;
255+		/me)
256+			local target="${bufs[curbuf]}"
257+			print -u3 "PRIVMSG $target :\x01ACTION $args\x01"
258+			status "$curbuf" "$nick $args"
259+			;;
260+		/nick)
261+			local new="${args%% *}"
262+			print -ru3 "NICK ${new}"
263+			nick="$new"
264+			;;
265+		/p|/part)
266+			local target="${args%% *}"
267+			target="${target:-${bufs[curbuf]}}"
268+			[ "$target" = '_' ] && {
269+				status 0 "Can't part in this buffer"
270+				return
271+			}
272+			[[ "$target" == '#'* ]] && print -ru3 "PART ${target}"
273+			local n=-1
274+			for i in "${!bufs[@]}"; do
275+				[ "${bufs[i]}" = "$target" ] && { n=$i; break; }
276+			done
277+			((n < 0)) && return
278+			unset 'bufs[n]' 'bhist[n]'
279+
280+			local -a nb nh
281+			for i in "${!bufs[@]}"; do
282+				nb+=("${bufs[i]}")
283+				nh+=("${bhist[i]}")
284+			done
285+			bufs=("${nb[@]}")
286+			bhist=("${nh[@]}")
287+
288+			((${#bufs[@]} == 0)) && {
289+				bufs=('_')
290+				bhist=('')
291+				curbuf=0
292+				bprint "$curbuf"
293+				return
294+			}
295+
296+			((curbuf >= ${#bufs[@]})) && ((curbuf = ${#bufs[@]}-1))
297+			bprint "$curbuf"
298+			;;
299+		/q|/query)
300+			local target="${args%% *}"
301+			[ -z "$target" ] && {
302+				status "$curbuf" "/query: Query who?"
303+				return
304+			}
305+			addbuf "$target"
306+			buf "$target"
307+			;;
308+		/quit)
309+			print -ru3 "QUIT :${args:-Client quit}"
310+			return 1
311+			;;
312+		/quote)
313+			print -u3 "$args"
314+			;;
315+		*)
316+			status "$curbuf" "Unknown command: $cmd, see /help"
317+			;;
318+	esac
319+}
320+
321+while getopts :hs:p:u:n:r:c:U:P: arg; do case "$arg" in
322+	'h') help; exit 0 ;;
323+	's') srv="$OPTARG" ;;
324+	'p') port=$OPTARG ;;
325+	'u') user="$OPTARG" ;;
326+	'n') nick="$OPTARG" ;;
327+	'r') real="$OPTARG" ;;
328+	'c') IFS=',' read -rA chans <<< "$OPTARG" ;;
329+	'U') sasl_user="$OPTARG" ;;
330+	'P') sasl_pass="$OPTARG" ;;
331+	'?') printf 'Unknown option -%c\n' "$arg"; exit 1 ;;
332+	':') printf 'Argument needed for -%c\n' "$arg"; exit 1 ;;
333+esac; done
334+shift $((OPTIND - 1))
335+
336+bufs=('_')
337+
338+termin
339+prompt
340+status 0 "Welcome to glub! See /help for a summary of commands."
341+status 0 "Connecting to ${srv}:${port}..."
342+
343+# Extremely shitty hack to assign 2 FDs for the read/write end of the pipe.
344+t1=$(mktemp -u) t2=$(mktemp -u)
345+mkfifo "$t1"; mkfifo "$t2"
346+
347+brssl client -q "$srv":$port <"$t1" >"$t2" & brpid=$!
348+exec 3>"$t1"; exec 4<"$t2"
349+rm -f "$t1" "$t2"
350+
351+print -rlu3 "CAP LS 302" "NICK ${nick}" \
352+	"USER ${sasl_user:-"$user"} 0 * ${real}" "CAP REQ :sasl" "CAP END"
353+for ch in "${chans[@]}"; do print -rlu3 "JOIN ${ch}"; done
354+
355+# shellcheck disable=SC2154 # False positive.
356+while :; do if IFS='' read -ru4 -t0.02 line; then
357+	[ -z "$line" ] && continue
358+	parse "$line"
359+
360+	case "$cmd" in
361+		CAP)
362+			[ "${params[1]}" = "ACK" ] && [ "$trail" = "sasl" ] && {
363+				print -lu3 "AUTHENTICATE PLAIN" \
364+					"AUTHENTICATE $(b64 "$sasl_pass")"
365+			}
366+			;;
367+		PING)
368+			s="${params[0]:-}" s="${s#:}"
369+			print -ru3 "PONG $s"
370+			;;
371+		PRIVMSG|NOTICE)
372+			src="$pref" target="${params[0]}" text="$trail"
373+			[ -n "$ctcp" ] && {
374+				msg="${ctcp%% *}" args="${ctcp#"${msg}" }"
375+				case "$msg" in
376+					"ACTION")
377+						s="${src%!*} $args"
378+						for c in "${!bufs[@]}"; do
379+							# lol
380+							[ "${bufs[c]}" = \
381+							  "$target" ] && \
382+							  status "$c" "$s"
383+						done
384+						;;
385+					"CLIENTINFO")
386+						print -u3 "NOTICE $src :\
387+CLIENTINFO ACTION CLIENTINFO TIME VERSION"
388+						;;
389+					"TIME")
390+						print -u3 "NOTICE $src :\
391+TIME $(date -uR)"
392+						;;
393+					"VERSION")
394+						print -u3 "NOTICE $src :\
395+glub v0 (see https://git.sr.ht/wf/glub)"
396+						;;
397+				esac
398+				continue
399+			}
400+			from="${src%%!*}"
401+			hl="\e[$(nclr "$from")m"
402+			[[ "$text" == *"$nick"* ]] && hl="\e[7m${hl}"
403+			l=$((13 - ${#from} - 1))
404+			s="$(printf '%*s%b %s \e[0m %s' $l '' "$hl" \
405+				"$from" "$text")"
406+			[ "$target" == "$nick" ] && {
407+				for i in "${!bufs[@]}"; do
408+					[ "${bufs[i]}" = "$from" ] && {
409+						bmsg "$i" "$s"
410+						((i == curbuf)) && prin "$s"
411+						break
412+					}
413+				done
414+				continue
415+			}
416+
417+			addbuf "$target"
418+			for i in "${!bufs[@]}"; do
419+				[ "${bufs[i]}" = "$target" ] && {
420+					bmsg "$i" "$s"
421+					[ "${bufs[curbuf]}" = "$target" ] && \
422+						prin "$s"
423+					break
424+				}
425+			done
426+			;;
427+		JOIN)
428+			src="$pref" chan="${params[0]}"
429+			s="${src%%!*} joined"
430+			addbuf "$chan"
431+			for i in "${!bufs[@]}"; do
432+				[ "${bufs[i]}" = "$chan" ] && {
433+					status "$i" "$s"
434+					break
435+				}
436+			done
437+			;;
438+		PART)
439+			src="$pref" chan="${params[0]}" reason="$trail"
440+			s="${src%%!*} left${reason:+" ($reason)"}"
441+			for i in "${!bufs[@]}"; do
442+				[ "${bufs[i]}" = "$chan" ] && {
443+					status "$i" "$s"
444+					break
445+				}
446+			done
447+			;;
448+		QUIT)
449+			# src="$pref" reason="$trail"
450+			# s="${src%%!*} quit${reason:+" ($reason)"}"
451+			# I didn't find a use case for this.
452+			;;
453+		NICK)
454+			src="$pref" new="${params[0]}"
455+			s="${src%%!*}→$new"
456+			for i in "${!bufs[@]}"; do
457+				[ "${bufs[i]}" = '_' ] && continue
458+				status "$i" "$s"
459+				break
460+			done
461+			;;
462+		[0-9][0-9][0-9])
463+			code="$cmd" msg="$trail"
464+			s=()
465+			[ -z "$msg" ] && msg="${params[*]}"
466+
467+			# status 0 "[$code] ${params[*]}"
468+
469+			target=""
470+			case "$code" in
471+				00[1-3]) # RPL_WELCOME, RPL_YOURHOST,
472+					 # RPL_CREATED
473+					s=("$msg")
474+					;;
475+				212) # RPL_STATSCOMMANDS
476+					s=("Stats for ${params[1]}: \
477+${params[2]} uses${params[3]:+", ${params[3]} bytes"}\
478+${params[4]:+", ${params[4]} uses"}")
479+					;;
480+				221) # RPL_UMODEIS
481+					s=("Your user modes are: ${params[1]}")
482+					;;
483+				242) # RPL_STATSUPTIME
484+					s=("Uptime: $msg")
485+					;;
486+				251) # RPL_LUSERCLIENT
487+					s=("Users: $msg")
488+					;;
489+				25[2-4]) # RPL_LUSEROP, RPL_USERUNKNOWN,
490+					 # RPL_LUSERCHANNELS
491+					s=("${params[1]} $msg")
492+					;;
493+				255) # RPL_LUSERME
494+					s=("Server: $msg")
495+					;;
496+				256) # RPL_ADMINME
497+					s=("Admin info for ${params[1]}:")
498+					;;
499+				257) # RPL_ADMINLOC1
500+					s=("Location: $msg")
501+					;;
502+				258) # RPL_ADMINLOC2
503+					s=("Administrated by: $msg")
504+					;;
505+				259) # RPL_ADMINEMAIL
506+					s=("Email: $msg")
507+					;;
508+				263) # RPL_TRYAGAIN
509+					s=("${params[1]}: $msg")
510+					;;
511+				26[5-6]) # RPL_LOCALUSERS, RPL_GLOBALUSERS
512+					s=("$msg")
513+					;;
514+				276) # RPL_WHOISCERTFP
515+					s=("${params[1]} $msg")
516+					;;
517+				301) # RPL_AWAY
518+					s=("${params[1]} is away ($msg)")
519+					;;
520+				302) # RPL_USERHOST
521+					# shellcheck disable=2086 # Intentional.
522+					set -A reply -- $msg
523+					for i in "${!reply[@]}"; do
524+						t="${reply[i]}"
525+						n="${t%%=*}"
526+						h="${t##*=}"
527+						isop=0 isaway=0
528+						[[ "$n" == *'*' ]] && \
529+							isop=1
530+						n="${n%\*}"
531+						[[ "$h" == '-'* ]] && \
532+							isaway=1
533+						h="${h#?}"
534+
535+						s[i]="$n"
536+						((isop==1)) && s[i]+=" (is op)"
537+						((isaway==1)) && \
538+							s[i]+=" (is away)"
539+						s[i]+=" - $h"
540+					done
541+					;;
542+				305) # RPL_UNAWAY
543+					s=("You are no longer marked as away")
544+					;;
545+				306) # RPL_NOWAWAY
546+					s=("You are now marked as away")
547+					;;
548+				307) # RPL_WHOISREGNICK
549+					s=("${params[1]} $msg")
550+					;;
551+				31[14]) # RPL_WHOISUSER, RPL_WHOWASUSER
552+					# s=("Info for ${params[1]}:"
553+					# "User: ${params[2]}"
554+					# "Host: ${params[3]}"
555+					# "Real name: $msg")
556+					s=("${params[1]} is logged in as \
557+${params[1]}, their realname is ${params[5]}" "${params[1]} is connecting from \
558+${params[3]}")
559+					;;
560+				312) # RPL_WHOISSERVER
561+					s=("${params[1]} is connected to \
562+${params[2]} ($msg)")
563+					;;
564+				313) # RPL_WHOISOPERATOR
565+					s=("${params[1]} $msg")
566+					;;
567+				315) # RPL_ENDOFWHO
568+					s=("End of WHO list.")
569+					;;
570+				317) # RPL_WHOISIDLE
571+					s=("${params[1]} has been idle for \
572+${params[2]} seconds, they signed on at $(date -ud @"${params[3]}")")
573+					;;
574+				318) # RPL_ENDOFWHOIS
575+					s=("End of WHOIS list.")
576+					;;
577+				319) # RPL_WHOISCHANNELS
578+					s=("${params[1]} is joined to: ")
579+					# shellcheck disable=2086 # Intentional.
580+					set -A reply -- $msg
581+					for i in "${!reply[@]}"; do
582+						prefix='' c="${reply[i]}"
583+						[[ "$c" == [\~\&@%+]* ]] && {
584+							prefix="${c%%"${c#?}"}"
585+							c="${c#?}"
586+						}
587+						s[0]+="$c \
588+${prefix:+"($prefix) "}"
589+					done
590+					;;
591+				320) # RPL_WHOISSPECIAL
592+					s=("${params[1]} $msg")
593+					;;
594+				322) # RPL_LIST
595+					topic="$trail"
596+					s=("${params[1]} (${params[2]} users)\
597+${topic:+" - $topic"}")
598+					;;
599+				323) # RPL_LISTEND
600+					s=("End of LIST.")
601+					;;
602+				324) # RPL_CHANNELMODEIS
603+					s=("Mode for ${params[1]}: \
604+${params[2]}")
605+					# TODO: Handle params[2..n].
606+					;;
607+				329) # RPL_CREATIONTIME
608+					s=("${params[1]} was created at \
609+$(date -d @"${params[2]}")")
610+					;;
611+				330) # RPL_WHOISACCOUNT
612+					s=("${params[1]} $msg ${params[2]}")
613+					;;
614+				331) # RPL_NOTOPIC
615+					target="${params[1]}"
616+					s=("No topic set in ${params[1]}")
617+					;;
618+				332) # RPL_TOPIC
619+					target="${params[1]}"
620+					s=("Topic in ${params[1]}: $msg")
621+					;;
622+				333) # RPL_TOPICWHOTIME
623+					target="${params[1]}"
624+					s=("Topic for ${params[1]} was set by \
625+${params[2]} at $(date -d @"${params[3]}")")
626+					;;
627+				336) # RPL_INVITELIST
628+					s=("Invited to: ${params[1]}")
629+					;;
630+				337) # RPL_ENDOFINVITELIST
631+					s=("End of INVITE list.")
632+					;;
633+				338) # RPL_WHOISACTUALLY
634+					[ -n "${params[3]}" ] && \
635+						s=("${params[1]} is actually \
636+using host ${params[2]} (IP ${params[3]})")
637+					;;
638+				341) # RPL_INVITING
639+					s=("Invited ${params[1]} to \
640+${params[2]}")
641+					;;
642+				346) # RPL_INVEXLIST
643+					s=("Invite-excepted from ${params[1]}: \
644+${params[2]}")
645+					;;
646+				347) # RPL_ENDOFINVEXLIST
647+					s=("End of invite-except list.")
648+					;;
649+				348) # RPL_EXCEPTLIST
650+					s=("Excepted from ${params[1]}: \
651+${params[2]}")
652+					;;
653+				349) # RPL_ENDOFEXCEPTLIST
654+					s=("End of channel exception list.")
655+					;;
656+				351) # RPL_VERSION
657+					s=("${params[2]} version ${params[1]} (\
658+$msg)")
659+					;;
660+				352) # RPL_WHOREPLY
661+					s=("${params[5]} is logged in as \
662+${params[2]}, their realname is ${msg#* }" "${params[5]}'s host is ${params[3]}"
663+"${params[5]} is authenticated to ${params[1]}, their flags are ${params[6]}")
664+					;;
665+				353) # RPL_NAMREPLY
666+					s=("Names in ${params[2]}: $msg")
667+					;;
668+				364) # RPL_LINKS
669+					s=("${params[1]} <-> ${params[2]} \
670+(${msg% *} hops): ${msg#* }")
671+					;;
672+				365) # RPL_ENDOFLINKS
673+					s=("End of LINKS list.")
674+					;;
675+				366) # RPL_ENDOFNAMES
676+					s=("End of NAMES list.")
677+					;;
678+				367) # RPL_BANLIST
679+					s=("Banned from ${params[1]}: \
680+${params[2]}${params[3]:+" (banned by ${params[3]})"}${params[4]:+" (banned \
681+at $(date -d @"${params[4]}")"}")
682+					;;
683+				368) # RPL_ENDOFBANLIST
684+					s=("End of channel ban list.")
685+					;;
686+				369) # RPL_ENDOFWHOWAS
687+					s=("End of WHOWAS.")
688+					;;
689+				371) # RPL_INFO
690+					s=("Info: $msg")
691+					;;
692+				372) # RPL_MOTD
693+					s=("$msg")
694+					;;
695+				374) # RPL_ENDOFINFO
696+					s=("End of INFO list.")
697+					;;
698+				375) # RPL_MOTDSTART
699+					s=("$msg")
700+					;;
701+				376) # RPL_ENDOFMOTD
702+					s=("End of MOTD.")
703+					;;
704+				37[8-9]) # RPL_WHOISHOST, RPL_WHOISMODES
705+					s=("${params[1]} $msg")
706+					;;
707+				381) # RPL_YOUREOPER
708+					s=("$msg")
709+					;;
710+				382) # RPL_REHASHING
711+					s=("$msg ${params[1]}")
712+					;;
713+				391) # RPL_TIME
714+					s=("Time for ${params[1]}: $msg")
715+					;;
716+				400) # ERR_UNKNOWNERROR
717+					s=("\"${params[*]}\": $msg")
718+					;;
719+				401) # ERR_NOSUCHNICK
720+					s=("No such nick \"${params[1]}\"")
721+					;;
722+				402) # ERR_NOSUCHSERVER
723+					s=("No such server \"${params[1]}\"")
724+					;;
725+				403) # ERR_NOSUCHCHANNEL
726+					s=("No such channel \"${params[1]}\"")
727+					;;
728+				404) # ERR_CANNOTSENDTOCHAN
729+					s=("Cannot send to channel \
730+\"${params[1]}\"")
731+					;;
732+				405) # ERR_TOOMANYCHANNELS
733+					s=("You have joined too many channels")
734+					;;
735+				406) # ERR_WASNOSUCHNICK
736+					s=("\"${params[1]}\": There was no such\
737+ nick")
738+					;;
739+				409) # ERR_NOORIGIN
740+					s=("No origin/token for PING specified")
741+					;;
742+				41[12]|417) # ERR_NORECIPIENT, ERR_NOTEXTTOSEND,
743+					    # ERR_INPUTTOOLONG
744+					s=("$msg")
745+					;;
746+				421) # ERR_UNKNOWNCOMMAND
747+					s=("\"${params[1]}\": $msg")
748+					;;
749+				422) # ERR_NOMOTD
750+					s=("No MOTD available")
751+					;;
752+				431) # ERR_NONICKNAMEGIVEN
753+					s=("No nickname given")
754+					;;
755+				432) # ERR_ERRONEUSNICKNAME (typo in spec?)
756+					s=("Erroneous nickname: \
757+\"${params[1]}\"")
758+					;;
759+				433) # ERR_NICKNAMEINUSE
760+					s=("Nickname already in use: \
761+\"${params[1]}\"")
762+					;;
763+				436) # ERR_NICKCOLLISION
764+					s=("\"${params[1]}\": $msg")
765+					;;
766+				441) # ERR_USERNOTINCHANNEL
767+					s=("${params[1]} is not on that channel\
768+ (${params[2]})")
769+					;;
770+				442) # ERR_NOTONCHANNEL
771+					s=("You're not on that channel \
772+(${params[1]})")
773+					;;
774+				443) # ERR_USERONCHANNEL
775+					s=("${params[1]} is already on channel \
776+${params[3]}")
777+					;;
778+				451) # ERR_NOTREGISTERED
779+					s=("You have not registered")
780+					;;
781+				461) # ERR_NEEDMOREPARAMS
782+					s=("\"${params[1]}\": Not enough \
783+parameters")
784+					;;
785+				462) # ERR_ALREADYREGISTERED
786+					s=("You may not reregister")
787+					;;
788+				464) # ERR_PASSWDMISMATCH
789+					s=("Password does not match")
790+					;;
791+				465) # ERR_YOUREBANNEDCREEP (lmfao)
792+					s=("$msg")
793+					;;
794+				47[1345]) # ERR_CHANNELISFULL,
795+					  # ERR_INVITEONLYCHAN,
796+					  # ERR_BANNEDFROMCHAN,
797+					  # ERR_BADCHANNELKEY
798+					s=("${params[1]}: $msg")
799+					;;
800+				472) # ERR_UNKNOWNMODE
801+					s=("'${params[1]}' $msg")
802+					;;
803+				476) # ERR_BADCHANMASK
804+					s=("\"${params[1]}\": Bad channel name")
805+					;;
806+				481) # ERR_NOPRIVILEGES
807+					s=("You're not an IRC operator")
808+					;;
809+				482) # ERR_CHANOPRIVSNEEDED
810+					s=("${params[1]}: You're not a channel \
811+operator")
812+					;;
813+				483) # ERR_CANTKILLSERVER
814+					s=("You can't kill a server")
815+					;;
816+				491) # ERR_NOOPERHOST
817+					s=("No O-lines for your host")
818+					;;
819+				501) # ERR_UMODEUNKNOWNFLAG
820+					s=("Unknown mode flag")
821+					;;
822+				502) # ERR_USERSDONTMATCH
823+					s=("$msg")
824+					;;
825+				524) # ERR_HELPNOTFOUND
826+					s=("\"${params[1]}\": No help available\
827+ on this topic")
828+					;;
829+				525) # ERR_INVALIDKEY
830+					s=("${params[1]}: \
831+Key is not well-formed")
832+					;;
833+				671) # RPL_WHOISSECURE
834+					s=("${params[1]} is using a secure \
835+connection")
836+					;;
837+				696) # ERR_INVALIDMODEPARAM
838+					s=("Failed to set ${params[2]} on \
839+${params[1]}: $msg")
840+					;;
841+				70[4-6]) # RPL_HELPSTART, RPL_HELPTXT,
842+					 # RPL_ENDOFHELP
843+					s=("$msg")
844+					;;
845+				723) # ERR_NOPRIVS
846+					s=("${params[1]}: $msg")
847+					;;
848+				90[01]) # RPL_LOGGED{IN, OUT}
849+					s=("$msg")
850+					;;
851+				902) # ERR_NICKLOCKED
852+					s=("This nick does not belong to you")
853+					;;
854+				903) # RPL_SASLSUCCESS
855+					s=("SASL authentication successful")
856+					;;
857+				904) # ERR_SASLFAIL
858+					s=("SASL authentication failed")
859+					;;
860+				905) # ERR_SASLTOOLONG
861+					s=("SASL message too long")
862+					;;
863+				906) # ERR_SASLABORTED
864+					s=("SASL authentication aborted")
865+					;;
866+				907) # ERR_SASLALREADY
867+					s=("You have already authenticated \
868+using SASL")
869+					;;
870+				908) # RPL_SASLMECHS
871+					s=("Available SASL mechanisms: \
872+${params[1]}")
873+					;;
874+				*)
875+					[[ "${params[0]:-}" == '#'* ]] && \
876+						target="${params[0]}"
877+					;;
878+			esac
879+
880+			# Omit numerics that aren't printed.
881+			((${#s[@]} == 0)) && continue 
882+
883+			if [ -n "$target" ]; then
884+				for i in "${!bufs[@]}"; do
885+					[ "${bufs[i]}" = "$target" ] && {
886+						for l in "${s[@]}"; do
887+							status "$i" "$l"
888+						done
889+						break
890+					}
891+				done
892+			else
893+				for l in "${s[@]}"; do
894+					status "$curbuf" "$l"
895+				done
896+			fi
897+			;;
898+		ERROR)
899+			status "$curbuf" "Fatal error: $trail"
900+			;;
901+		*)
902+			# What the hell???
903+			;;
904+	esac
905+else
906+	if (($? > 128)); then
907+		: # Timed out
908+	else
909+		# TODO: It would make sense here to try and reconnect.
910+		break
911+	fi
912+fi
913+
914+# Warning: this delay heavily relies on the latency of the terminal being
915+# somewhat low, so input may 'fall through' on slow terminals.
916+k=()
917+if IFS= read -rN1 -t0.004 "k[0]"; then
918+	# shellcheck disable=2034 # Weird.
919+	ki=1
920+	[ "${k[0]}" = $'\e' ] && \
921+		while IFS= read -rN1 -t0.001 "k[ki++]"; do :; done
922+	OLDIFS=$IFS
923+	IFS= t="${k[*]}"
924+	IFS=$OLDIFS
925+
926+	((cursor > ${#input})) && ((cursor = ${#input}))
927+	((cursor < 0)) && ((cursor = 0))
928+
929+	case "$t" in
930+		$'\n')
931+			input "$input" || break
932+			input="" cursor=0
933+			;;
934+		$'\b'|$'\x7f')
935+			if ((cursor == ${#input})); then
936+				input="${input%?}"
937+				((cursor--))
938+			elif ((${#input} == 0)); then
939+				:
940+			else
941+				input="${input:0:cursor-1}${input:cursor}"
942+				((cursor--))
943+			fi
944+			;;
945+		$'\e[A')
946+			c=0 lines="${bhist[curbuf]}"
947+			while IFS= read -r line; do ((c++)); done <<< "$lines"
948+			max=$((c - (LINES - 1)))
949+			((max < 0)) && max=0
950+
951+			((scroll < max)) && {
952+				((scroll++))
953+				printf '\e[H\eM'
954+				l=$((c - LINES + 2 - scroll)) i=0
955+				while IFS= read -r line; do
956+					((i++))
957+					((i == l)) && {
958+						printf '%b' "$line"
959+						break
960+					}
961+				done <<< "$lines"
962+			}
963+			;;
964+		$'\e[B')
965+			c=0 lines="${bhist[curbuf]}"
966+			while IFS= read -r line; do ((c++)); done <<< "$lines"
967+
968+			((scroll > 0)) && {
969+				((scroll--))
970+				printf '\e[%d;1H\eD' $((LINES - 1))
971+				l=$((c - scroll)) i=0
972+				((l < 1)) && l=1
973+				while IFS= read -r line; do
974+					((i++))
975+					((i == l)) && {
976+						printf '%b' "$line"
977+						break
978+					}
979+				done <<< "$lines"
980+			}
981+			;;
982+		[[:print:]])
983+			input+="$t"
984+			((cursor++))
985+			;;
986+	esac
987+
988+	prompt
989+fi; done
990+
991+die