#!/usr/bin/env bash shopt -s nullglob dotglob extglob ### # logger ### declare -A levels=( [debug]=1 [info]=2 [warn]=3 [error]=4 ) log() { if [[ -v LEVEL ]] && (( levels[$level] <= levels[$LEVEL] )); then printf "%s: $1\n" "${LEVEL^^}" "${@:2}" >&"$log" fi } debug() { LEVEL=debug log "$@" } info() { LEVEL=info log "$@" } warn() { LEVEL=warn log "$@" } error() { LEVEL=error log "$@" } ### # argument parser for parsing arguments ## original_args=("$0" "$@") declare -A opts while (( $# )); do case $1 in --*=*) key=${1#--} key=${key%%=*} opts[$key]=${1#--*=} ;; --no-*) key=${1#--no-} opts[$key]=no ;; --) shift break ;; --*) key=${1#--} opts[$key]=yes ;; *) break esac shift done ### # default config ## server=${opts[server]:-irc.libera.chat} tls=${opts[tls]:-no} if [[ $tls = yes ]]; then if ! hash socat 2>/dev/null; then printf 'please install socat to use tls with rowbot.\n' >&2 exit 1 fi if [[ -v opts[client-cert] ]]; then client_cert=${opts[client-cert]} fi port=${opts[port]:-6697} else port=${opts[port]:-6667} fi nick=${opts[nick]:-rowbot-dev} ident=${opts[ident]:-rowbot} realname=${opts[realname]:-rowbot} chan=${opts[chan]:-} trigger=${opts[trigger]:-\`} fact_root=${opts[fact-root]:-.} reload=${opts[reload]:-no} dev=${opts[dev]:-no} if [[ -v USER ]]; then owner=${opts[owner]:-"$USER"} else owner=${opts[owner]:-uplime} fi level=${opts[log-level]:-info} if [[ $reload = yes ]]; then log=$LOG_FD elif [[ ${opts[log]} ]]; then exec {log}>"${opts[log]}" else log=1 fi ### # utilities ### cleanup() { if [[ -v tls_pid || $tls = no ]]; then exec {in_sock}>&- {out_sock}>&- {log}>&- fi if [[ -v tls_pid ]]; then kill "$tls_pid" rm -rf "$sock_dir" fi if [[ -v ping_pid ]]; then kill "$ping_pid" fi } trap cleanup EXIT alarm-handler() { ping "row your bot gently down the stream" if [[ $keep_trying = yes ]]; then info "trying for %s again" "$desired_nick" nick "$desired_nick" fi } trap alarm-handler ALRM ### # reload code ### if [[ $reload = yes ]]; then in_sock=$IN_SOCK out_sock=$OUT_SOCK trigger=$TRIGGER dev=$DEV level=$LOG_LEVEL registered=$REGISTERED debug "doing a reload. pid is %d" "$$" if [[ -v KEEP_TRYING ]]; then keep_trying=$KEEP_TRYING desired_nick=$DESIRED fi if [[ $tls = yes ]]; then sock_dir=$SOCK_DIR tls_pid=$tls_pid fi if [[ -v PING_PID ]]; then ping_pid=$PING_PID fi nick=$NICK ident=$IDENT if [[ -v HOST ]]; then host=$HOST fi fi ### # net code ### if [[ $reload = no && $tls = yes ]]; then sock_dir=$(mktemp -d) mkfifo "$sock_dir"/rb{in,out} if [[ -v client_cert ]]; then if [[ ! -f $client_cert ]]; then error "client certificate not found: %s" "$client_cert" exit 1 fi conn_args=OPENSSL:$server:$port,cert=$client_cert else conn_args=OPENSSL:$server:$port fi socat "$conn_args" - <"$sock_dir"/rbin >"$sock_dir"/rbout & tls_pid=$! debug "created tls connection (pid %d)" "$tls_pid" exec {out_sock}>"$sock_dir"/rbin {in_sock}<"$sock_dir"/rbout elif [[ $reload = no ]]; then exec {sock}<>/dev/tcp/"$server"/"$port" in_sock=$sock out_sock=$sock debug "created plaintext connection" fi send() { local fmt printf -v fmt "$1" "${@:2}" printf '%s\r\n' "$fmt" >&"$out_sock" debug "sending line: %s" "$fmt" } recv() { declare -n sock_line=$1 IFS= read -r "$1" <&"$in_sock" sock_line=${sock_line%$'\r'} debug "received line: %s" "$sock_line" } ### # irc recv code ### on_ERROR() { error "${params[0]}" exit } on_JOIN() { info "%s has joined %s" "$from" "${params[0]}" } on_MODE() { if (( ${#params[@]} == 2 )); then info "%s sets mode(s) %s on %s" "$from" "${params[1]}" "${params[0]}" elif (( ${#params[@]} > 2 )); then info "%s: %s sets mode(s) %s" "${params[0]}" "$from" "${params[*]:1}" fi } on_NICK() { if [[ $from = $nick ]]; then nick=${params[0]} fi info "%s has changed their name to %s" "$from" "${params[0]}" } on_NOTICE() { info "[%s/%s] %s" "$from" "${params[0]}" "${params[1]}" } on_PART() { if (( ${#params[@]} > 1 )); then info "%s has left %s: %s" "$from" "${params[0]}" "${params[1]}" else info "%s has left %s" "$from" "${params[0]}" fi } on_PING() { pong "${params[1]}" debug "received ping: %s" "${params[0]}" } on_PONG() { debug "received pong: %s" "${params[1]}" } on_PRIVMSG() { info "<%s/%s> %s" "$from" "${params[0]}" "${params[1]}" } on_QUIT() { info "%s has disconnected: %s" "$from" "${params[0]}" } on_001() { info %s "${params[1]}" if [[ $chan ]]; then join "$chan" fi { debug "timer pid is %d" "$BASHPID" while true; do kill -ALRM "$$" sleep 10 done } & ping_pid=$! nick=${params[0]} registered=yes who "$nick" %%uht,42 } on_002() { info %s "${params[1]}" } on_003() { info %s "${params[1]}" } on_004() { debug "%s " "${params[@]:1}" } declare -A isupport on_005() { local param key value for param in "${params[@]:1:${#params[@]}-2}"; do IFS== read -r key value <<< "$param" isupport[$key]=$value debug "isupport: %s = %s" "$key" "$value" done } on_250() { info %s "${params[1]}" } on_251() { info %s "${params[1]}" } on_252() { info "There are %d operators online" "${params[1]}" } on_253() { info "There are %d unknown connections" "${params[1]}" } on_254() { info "There are %d channels formed" "${params[1]}" } on_255() { info %s "${params[1]}" } on_265() { info %s "${params[3]}" } on_266() { info %s "${params[3]}" } on_315() { debug "end of WHO for %s" "${params[1]}" } on_332() { info "topic for %s is %s" "${params[1]}" "${params[2]}" } on_333() { local date printf -v date '%(%c)T' "${params[3]}" info "topic for %s set by %s at %s" "${params[1]}" "${params[2]}" "$date" } on_353() { info "members of %s: %s" "${params[2]}" "${params[3]}" } on_354() { if (( ${params[1]} == 42 )); then debug "received the identifying who" ident=${params[2]} host=${params[3]} debug "ident=%s host=%s" "$ident" "$host" fi } on_366() { debug "%s: end of NAMES list" "${params[1]}" } on_372() { info %s "${params[1]}" } on_375() { debug %s "${params[1]}" } on_376() { debug %s "${params[1]}" } on_433() { info "somebody is already using %s" "${params[1]}" if [[ $registered = no ]]; then nick "${nick}_" fi } on_473() { error "%s: %s" "${params[1]}" "${params[2]}" } ### # irc send code ### join() { send "JOIN %s" "$1" } nick() { send "NICK %s" "$1" } notice() { if [[ -v host ]]; then local msg_len msg=$2 (( msg_len = 494 - (${#nick} + ${#ident} + ${#host} + ${#1}) )) debug "max message length is %d" "$msg_len" while (( ${#msg} > msg_len )); do send "NOTICE %s :"$'\xe2\x80\x8b'"%s" "$1" "${msg:0:$msg_len}" info "[%s/%s] %s" "$nick" "$1" "${msg:0:$msg_len}" msg=${msg:$msg_len} done fi send "NOTICE %s :%s" "$1" "$2" info "[%s/%s] %s" "$nick" "$1" "$2" } part() { if (( $# )); then if (( $# > 1 )); then send "PART $1 :$2" else send "PART $1" fi fi } ping() { send "PING :%s" "$1" } pong() { send "PONG %s" "$1" } privmsg() { if [[ -v host ]]; then local msg_len msg=$2 (( msg_len = 493 - (${#nick} + ${#ident} + ${#host} + ${#1}) )) debug "max message length is %d" "$msg_len" while (( ${#msg} > msg_len )); do send "PRIVMSG %s :"$'\xe2\x80\x8b'"%s" "$1" "${msg:0:$msg_len}" info "<%s/%s> %s" "$nick" "$1" "${msg:0:$msg_len}" msg=${msg:$msg_len} done fi send "PRIVMSG %s :"$'\xe2\x80\x8b'"%s" "$1" "$msg" info "<%s/%s> %s" "$nick" "$1" "$msg" } quit() { if (( $# )); then send "QUIT :%s" "$1" else send QUIT fi } user() { send "USER %s 0 * :%s" "$ident" "$realname" } who() { if (( $# > 1 )); then send "WHO %s %s" "$1" "$2" else send "WHO %s" "$1" fi } ### # app hooks ## hook_pre_PRIVMSG_CTCP() { if [[ ${params[1]} != $'\x01'*$'\x01' ]]; then return 0 fi local cmd msg cmd=${params[1]#$'\x01'} cmd=${cmd%% *} msg=${params[1]#* } msg=${msg%$'\x01'} if [[ ${cmd^^} = ACTION ]]; then if [[ ${params[0]:0:1} = \# ]]; then info "ctcp: %s: %s %s" "${params[0]}" "$from" "$msg" else info "privately, %s %s" "$from" "$msg" fi return 1 elif [[ ${params[0]:0:1} = \# ]]; then info "ctcp: %s has requested %s in %s" "$from" "${cmd^^}" "${params[0]}" return 1 fi info "ctcp: sending %s to %s" "${cmd^^}" "$from" case ${cmd^^} in CLIENTINFO) notice "$from" $'\x01'"CLIENTINFO ACTION CLIENTINFO PING SOURCE TIME VERSION"$'\x01' ;; PING) local msg msg=${params[1]#* } msg=${msg%$'\x01'} notice "$from" $'\x01'"PING $msg"$'\x01' ;; SOURCE) notice "$from" $'\x01'"SOURCE https://ahti.space/git/uplime/rowbot"$'\x01' ;; TIME) notice "$from" $'\x01'"TIME time for you to get a watch"$'\x01' ;; VERSION) notice "$from" $'\x01'"VERSION rowbot v2"$'\x01' esac return 1 } hook_post_PRIVMSG_factoids() { if [[ ${params[0]:0:1} != \# ]]; then return 0 elif [[ ${words[0]} = "$trigger"* ]]; then case ${words[0]:${#trigger}} in is) if (( ${#words[@]} < 3 )); then return 0 fi local key val key=${params[1]#*"$trigger"is} key=${key# } val=${key#* } key=${key%% *} info "%s said in %s to remember %s as %s" "$from" "${params[0]}" "$key" "$val" privmsg "${params[0]}" "I'm sure I'll remember that." mkdir -p "$fact_root"/"${params[0]}" printf %s "$val" > "$fact_root"/"${params[0]}"/"$key" ;; isnt) if (( ${#words[@]} < 2 )); then return 0 fi local key key=${params[1]#*"$trigger"isnt} key=${key# } if [[ -f $fact_root/${params[0]}/$key ]]; then info "%s said in %s to delete %s" "$from" "${params[0]}" "$key" privmsg "${params[0]}" "I forgot what that was anyways." rm -f "$fact_root"/"${params[0]}"/"$key" fi ;; ls) local facts=( "$fact_root"/"${params[0]}"/* ) privmsg "${params[0]}" "${facts[*]##*/}" ;; *) local key=${words[0]:${#trigger}} if [[ -f $fact_root/${params[0]}/$key ]]; then local msg=$(<"$fact_root"/"${params[0]}"/"$key") if (( ${#words[@]} > 1 )) && [[ ${words[1]} = \> ]]; then local target target=${params[1]#*\>} target=${target# } target=${target% } privmsg "${params[0]}" "$target: $msg" else privmsg "${params[0]}" "$from: $msg" fi fi esac fi } hook_post_PRIVMSG_control_panel() { if [[ ${words[0]} = "$trigger"* ]]; then if [[ $from != $owner && $dev != yes ]]; then return 0 fi local to=${params[0]} if [[ ${params[0]:0:1} != \# ]]; then to=$from fi case ${words[0]:${#trigger}} in raw) local cmd cmd=${params[1]#"$trigger"raw} cmd=${cmd# } info "%s is executing command: %s" "$from" "$cmd" send "$cmd" ;; join) join "${words[1]}" privmsg "$to" "joined ${words[1]}" ;; reload) export IN_SOCK=$in_sock OUT_SOCK=$out_sock LOG_FD=$log DEV=$dev export RELOAD_TO=$to TRIGGER=$trigger LOG_LEVEL=$level export NICK=$nick IDENT=$ident REGISTERED=$registered if [[ $keep_trying = yes ]]; then export KEEP_TRYING=yes DESIRED=$desired_nick fi if [[ -v host ]]; then export HOST=$host fi if [[ $tls = yes ]]; then export SOCK_DIR=$sock_dir export TLS_PID=$tls_pid fi if [[ -v ping_pid ]]; then export PING_pid=$ping_pid fi privmsg "$to" "reloading..." exec "${original_args[@]}" --reload ;; level) level=${words[1]} privmsg "$to" "log level is now set to $level" ;; dev) if [[ $dev = yes ]]; then dev=no privmsg "$to" "developer status disabled" else dev=yes privmsg "$to" "developer status enabled" fi ;; dev\?) if [[ $dev = yes ]]; then privmsg "$to" "developer status is enabled" else privmsg "$to" "developer status is disabled" fi ;; trigger) if (( ${#words[@]} > 1 )); then trigger=${words[1]} privmsg "$to" "trigger is now $trigger" fi ;; msg) if (( ${words[@]} > 2 )); then privmsg "${words[1]}" "${words[*]:2}" privmsg "$to" "sent message to ${words[1]}" fi ;; cycle) if [[ ${to:0:1} = \# ]]; then privmsg "$to" "cycling channel $to" part "$to" "be back soon!" join "$to" fi esac fi } hook_post_433_alternick() { if [[ -z $desired_nick && $registered = no ]]; then desired_nick=${params[1]} keep_trying=yes fi } hook_post_NICK_alternick() { if [[ ${params[0]} = $desired_nick ]]; then keep_trying=no info "obtained nick %s" "$desired_nick" fi } ### # driver ### if [[ $reload = yes ]]; then privmsg "$RELOAD_TO" done. else registered=no info "rowbot's pid is %d" "$$" nick "$nick" user "$ident" "$realname" fi while recv line; do params=( ) has_words=no orig_line=$line if [[ ${line:0:1} = : ]]; then src=${line%% *} src=${src#:} line=${line#:"$src"} line=${line# } from=${src%@*} ident=${from#*!} from=${from%!*} host=${src#*@} fi cmd=${line%% *} line=${line#"$cmd"} line=${line# } while [[ $line ]]; do if [[ ${line:0:1} = : ]]; then params+=("${line:1}") line="" has_words=yes else param=${line%% *} params+=("$param") line=${line#"$param"} line=${line# } fi done if [[ $has_words = yes ]]; then read -ra words <<< "${params[@]:(-1)}" else words=( ) fi skip_handler=0 while IFS= read -r hook; do "$hook" (( skip_handler |= $? )) done < <(compgen -A function "hook_pre_${cmd^^}_") if hash "on_${cmd^^}" 2>/dev/null; then if (( ! skip_handler )); then "on_${cmd^^}" else debug "handler for %s was skipped" "${cmd^^}" fi else warn "unhandled line: %s" "$orig_line" fi while IFS= read -r hook; do "$hook" done < <(compgen -A function "hook_post_${cmd^^}_") done