#!/usr/bin/env bash # # qdb-watch.sh --- the IRC capture side of qdb. # # Tails an `ii` channel `out` file, keeps a ring buffer of the last # MAXLINES lines, and on a `.qdb [N]` command from the channel writes # the preceding N lines to quotes/.txt (the format qdbviewer.lhs # reads) and announces the gopher permalink back to the channel. # # On simulacra the IRC server (ngircd) runs on the same host, so `ii` # connects to it over loopback; for a remote server an optional # stunnel sits in front (see README.txt). Either way this script only # ever touches ii's filesystem interface. # # Config via environment (defaults suit the simulacra deployment): # # QDB_IRC_BASE base dir ii writes into ($HOME/irc) # QDB_IRC_NETDIR ii's on-disk subdir: the host (= QDB_IRC_NET) # ii dialled. With the stunnel # setup ii dials 127.0.0.1, so # this is "127.0.0.1". # QDB_IRC_NET network *display* name, written (irc.someodd.zip) # into the snapshot header # QDB_IRC_CHAN channel dir / announce target (#main) # QDB_QUOTES_DIR where .txt files are kept (/var/gopher/applets/qdb/quotes) # GOPHER_HOST host used in announced URLs (gopher.someodd.zip) # # Run under systemd, after the qdb-ii service. On restart it starts # from the end of `out` (tail -n 0), so old `.qdb` lines are never # re-processed; the ring buffer refills as the channel talks. set -u IRC_BASE="${QDB_IRC_BASE:-$HOME/irc}" IRC_NET="${QDB_IRC_NET:-irc.someodd.zip}" # display name, recorded in snapshots IRC_NETDIR="${QDB_IRC_NETDIR:-$IRC_NET}" # ii's on-disk subdir (the host it dialled) IRC_CHAN="${QDB_IRC_CHAN:-#main}" QUOTES_DIR="${QDB_QUOTES_DIR:-/var/gopher/applets/qdb/quotes}" GOPHER_HOST="${GOPHER_HOST:-gopher.someodd.zip}" OUT="$IRC_BASE/$IRC_NETDIR/$IRC_CHAN/out" IN="$IRC_BASE/$IRC_NETDIR/$IRC_CHAN/in" COUNTER="$QUOTES_DIR/.counter" MAXLINES=50 # ring-buffer size and the hard cap on N DEFLINES=10 # N used for a bare `.qdb` with no number mkdir -p "$QUOTES_DIR" # announce --- send a line to the channel. Backgrounded and # timeout-bounded so a dead ii (nobody draining the FIFO) can never # block or wedge the capture loop. announce() { [ -p "$IN" ] || return 0 ( timeout 5 sh -c 'printf "%s\n" "$0" > "$1"' "$1" "$IN" ) 2>/dev/null & } # fmt_line --- rewrite ii's leading `` timestamp # as a readable `YYYY-MM-DD HH:MM` (UTC), leaving the rest untouched. # Non-timestamped lines pass through verbatim. This is what makes a # snapshot's transcript human-readable instead of epoch soup. fmt_line() { local line="$1" if [[ "$line" =~ ^([0-9]+)\ (.*)$ ]]; then local epoch="${BASH_REMATCH[1]}" rest="${BASH_REMATCH[2]}" when when=$(date -u -d "@$epoch" +'%Y-%m-%d %H:%M' 2>/dev/null) || when="$epoch" printf '%s %s\n' "$when" "$rest" else printf '%s\n' "$line" fi } # save_quote --- snapshot the last buffered lines. # `buf` is the ring buffer; it does NOT include the `.qdb` command # line itself (we snapshot before appending each line). save_quote() { local nick="$1" n="$2" local count=${#buf[@]} (( n > count )) && n=$count if (( n < 1 )); then announce "qdb: nothing buffered yet --- try again once the channel's been talking" return fi local last next id now tmp last=$(cat "$COUNTER" 2>/dev/null || echo 0) case "$last" in ''|*[!0-9]*) last=0 ;; esac next=$(( last + 1 )) id=$(printf '%04d' "$next") now=$(date -u +%Y-%m-%dT%H:%M:%SZ) # Write to a dotfile tmp then rename, so qdbviewer.lhs never reads a # half-written snapshot (rename is atomic within a filesystem). tmp="$QUOTES_DIR/.$id.tmp" { echo "qdb #$id" echo "=========" echo "saved-by: $nick" echo "channel: $IRC_CHAN" echo "network: $IRC_NET" echo "date: $now" echo "lines: $n" echo for l in "${buf[@]: -n}"; do fmt_line "$l"; done } > "$tmp" mv "$tmp" "$QUOTES_DIR/$id.txt" echo "$next" > "$COUNTER" announce "qdb #$id saved ($n lines) -> gopher://$GOPHER_HOST/1/applets/qdb/qdbviewer.lhs/q/$id" } buf=() # tail -n 0 -F: emit only lines appended after we start, and survive # `out` being rotated/recreated (ii reconnects, log pruning, etc). while IFS= read -r line; do # ii out lines are ` message` for channel messages # (and ` -!- ...` for joins/parts/notices). A `.qdb` command: # ` .qdb` optionally followed by a line count. # Anything else after `.qdb` is ignored as not-a-command. is_cmd=0 if [[ "$line" =~ ^[0-9]+\ \<([^>]+)\>\ \.qdb([[:space:]]+([0-9]+))?[[:space:]]*$ ]]; then nick="${BASH_REMATCH[1]}" n="${BASH_REMATCH[3]:-$DEFLINES}" (( n < 1 )) && n=1 (( n > MAXLINES )) && n=$MAXLINES save_quote "$nick" "$n" is_cmd=1 fi # Buffer only chat lines --- ` message`. Skip ii's # ` -!- ...` meta lines (joins/parts/quits/nick changes/ # topics) and the `.qdb` command lines themselves, so a snapshot is # conversation, not channel noise. Keep the most recent MAXLINES. chat_re='^[0-9]+ <[^>]+> ' if (( ! is_cmd )) && [[ "$line" =~ $chat_re ]]; then buf+=("$line") (( ${#buf[@]} > MAXLINES )) && buf=("${buf[@]: -MAXLINES}") fi done < <(tail -n 0 -F "$OUT")