#!/usr/bin/env bash ### # feature switch toggling ### shopt -s dotglob extglob nullglob stty -echoctl ### # utility and helper functions ### any_file() { local files=( "${1:-.}"/* ) max idx if (( ${#files[@]} > 1 )); then (( max = ${#files[@]} - 1 )) idx=$(random 0 "$max") printf %s "${files[$idx]}" fi } die() { local fmt=$1 shift # This is a wrapper around printf so the format string isn't known ahead of # time. # shellcheck disable=SC2059 printf "FATAL: $fmt\n" "$@" >&2 exit "${STATUS:-42}" } get_option() { if (( ! $# )); then return 1 fi local var_name=${1//-/_} local env_var=${var_name^^} if [[ -v $env_var ]]; then config[$1]=${!env_var} elif [[ -v opts[$1] ]]; then config[$1]=${opts[$1]} elif [[ -v $var_name ]]; then config[$1]=${!var_name} elif (( $# > 1 )); then config[$1]=$2 fi } has() { if (( $# )); then hash "$1" 2>/dev/null else return 1 fi } is_action() { [[ ${msg[cmd]} = PRIVMSG && ${msg_args[-1]:0:${#config[trigger]}} = "${config[trigger]}" ]] } is_chan() { [[ ${msg_args[${1:-0}]:0:1} = \# ]] } is_parent() { (( BASHPID == $$ )) } is_reloaded() { [[ $RELOADED = yes ]] || (( LORE_LIVES > 1 )) } is_running () { kill -0 "$1" 2>/dev/null } random() { local min=$1 max=$2 printf %d "$(( (RANDOM % max) + min ))" } run_callbacks() { if (( ! $# )); then return 1 fi local status=0 filter=$1 shift while IFS= read -r; do "$REPLY" "$@" (( status |= $? )) done < <(compgen -A function "$filter") return "$status" } # The variables are checked dynamically # shellcheck disable=SC2034 seconds() { local day hour minute second span time (( day = $1 / 60 / 60 / 24 )) (( hour = $1 / 60 / 60 % 24 )) (( minute = $1 / 60 % 60 )) (( second = $1 % 60 )) for span in day hour minute second; do if (( ${!span} )); then if [[ $time ]]; then time+=", " fi time+="${!span} $span" if (( ${!span} > 1 )); then time+=s fi fi done printf -- %s "$time" } url() { if [[ ${config[tls]} = no ]]; then printf irc:// else printf ircs:// fi printf %s:%s "${config[server]}" "${config[port]}" } ### # configure rowbot's environment ### # parse command line arguments declare -A opts cmd_line=( "${@:0}" ) 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 # load custom configuration files for file do if [[ -f $file ]]; then # These files (if any) are provided dynamically at run-time. # shellcheck disable=SC1090 . "$file" # ha, dot file else die "could not locate config file %s" "$file" fi done # cleanup unset key file ### # load default config ### declare -A config get_option owner "${USER:-uplime}" get_option trigger \` get_option dev yes ### # bootup/shutdown sequence ### on_sys_first_001_bootup() { log_info "rowbot's pid is %d" "$$" } on_sys_before_999_bootup() { local setting setting_name log_debug "storing the config" for setting in "${!config[@]}"; do setting_name=${setting//-/_} export "CONFIG_${setting_name^^}=${config[$setting]}" done } on_sys_after_001_bootup() { log_debug "reloading the config" local setting setting_name while IFS= read -r setting; do setting_name=${setting#CONFIG_} setting_name=${setting_name//_/-} config[${setting_name,,}]=${!setting} unset "$setting" done < <(compgen -e CONFIG_) } on_sys_exit_999_bootup() { log_info "There's a lot of beauty in ordinary things. Isn't that kind of the point?" } ### # logger ### log() { if [[ -v LOG_LEVEL ]] && (( log_levels[$log_level] <= log_levels[$LOG_LEVEL] )); then printf "%s: $1\n" "${LOG_LEVEL^^}" "${@:2}" >&"$log_fd" fi } log_debug() { LOG_LEVEL=debug log "$@" } log_info() { LOG_LEVEL=info log "$@" } log_warn() { LOG_LEVEL=warn log "$@" } log_error() { LOG_LEVEL=error log "$@" } log_has_level() { local level for level in "${!log_levels[@]}"; do if [[ ${1,,} = "$level" ]]; then return 0 fi done return 1 } on_sys_init_001_log() { declare -gA log_levels=( [debug]=1 [info]=2 [warn]=3 [error]=4 ) get_option log-level info if ! log_has_level "${config[log-level]}"; then die "%s is not a valid logging level" "${config[log-level]}" fi get_option log "" get_option overwrite no log_level=${config[log-level]} if [[ ${config[log]} ]]; then if [[ ${config[overwrite]} = yes ]]; then exec {log_fd}>"${config[log]}" else exec {log_fd}>>"${config[log]}" fi else log_fd=1 fi } on_sys_before_999_log() { if [[ -v log_fd ]] && (( log_fd != 1 )); then log_debug "shutting logger down for reload" exec {log_fd}>&- fi } on_sys_exit_999_log() { if [[ -v log_fd ]] && (( log_fd != 1 )); then log_debug "shutting logger down for good" exec {log_fd}>&- fi } ### # net code ### net_recv() { declare -n sock_line=$1 IFS= read -ru "$in_sock" "$1" sock_line=${sock_line%$'\r'} log_debug "received line: %s" "$sock_line" } net_send() { local fmt # As this is a printf wrapper, the format string is provided as an argument. # shellcheck disable=SC2059 printf -v fmt "$1" "${@:2}" printf '%s\r\n' "$fmt" >&"$out_sock" log_debug "sending line: %s" "$fmt" } on_sys_init_002_net() { get_option server irc.libera.chat get_option tls no if [[ ${config[tls]} = no ]]; then get_option port 6667 else get_option client-cert "" get_option port 6697 fi } on_sys_first_002_net() { local conn_args log_info "rowbot is connecting to %s" "$(url)" if [[ ${config[tls]} = no ]]; then exec {irc_sock}<>/dev/tcp/"${config[server]}"/"${config[port]}" in_sock=$irc_sock out_sock=$irc_sock else if ! has socat; then die "please install socat to use tls with rowbot." fi sock_dir=$(mktemp -d) log_debug "socket directory is %s" "$sock_dir" mkfifo "$sock_dir"/rowbot-{in,out}.sock # This is a false positive # shellcheck disable=SC2102 if [[ ${config[client-cert]} ]]; then if [[ ! -f ${config[client-cert]} ]]; then die "client certificate not found: %s" "${config[client-cert]}" fi conn_args=OPENSSL:${config[server]}:${config[port]},cert=${config[client-cert]} else conn_args=OPENSSL:${config[server]}:${config[port]} fi socat "$conn_args" - <"$sock_dir"/rowbot-in.sock >"$sock_dir"/rowbot-out.sock & tls_pid=$! exec {out_sock}>"$sock_dir"/rowbot-in.sock {in_sock}<"$sock_dir"/rowbot-out.sock log_debug "process %d is handling tls" "$tls_pid" fi } on_sys_before_002_net() { if [[ ${config[tls]} = no ]]; then export IRC_SOCK=$irc_sock else export SOCK_DIR=$sock_dir TLS_PID=$tls_pid export OUT_SOCK=$out_sock IN_SOCK=$in_sock fi } on_sys_after_002_net() { if [[ ${config[tls]} = no ]]; then irc_sock=$IRC_SOCK out_sock=$irc_sock in_sock=$irc_sock unset IRC_SOCK else sock_dir=$SOCK_DIR tls_pid=$TLS_PID out_sock=$OUT_SOCK in_sock=$IN_SOCK unset SOCK_DIR TLS_PID OUT_SOCK IN_SOCK fi } on_sys_exit_998_net() { if [[ ${config[tls]} = no ]]; then log_info "rowbot is closing the connection to irc://%s:%s" "${config[server]}" "${config[port]}" exec {irc_sock}>&- else log_info "rowbot is closing the connection to ircs://%s:%s" "${config[server]}" "${config[port]}" if [[ -v tls_pid ]]; then kill -STOP "$tls_pid" fi rm -rf -- "$sock_dir" fi } ### # annoyatron900 - keep alive process ### annoyatron900() { irc_ping "row your bot gently down the stream" run_callbacks annoyatron900_ } on_sys_before_999_annoyatron900() { if [[ -v alarm_pid ]]; then export ALARM_PID=$alarm_pid fi } on_sys_after_999_annoyatron900() { trap annoyatron900 USR1 if [[ -v ALARM_PID ]]; then alarm_pid=$ALARM_PID fi } on_sys_register_999_annoyatron900() { while true; do read -rt 10 2 )); then log_info "%s: %s sets mode(s) %s" "${msg_args[0]}" "${msg[from]}" "${msg_args[*]:1}" fi } irc_on_NICK() { log_info "%s has changed their name to %s" "${msg[from]}" "${msg_args[0]}" } irc_on_NOTICE() { log_info "[%s/%s] %s" "${msg[from]}" "${msg_args[0]}" "${msg_args[1]}" } irc_on_PART() { if (( ${#msg_args[@]} > 1 )); then log_info "%s has left %s: %s" "${msg[from]}" "${msg_args[0]}" "${msg_args[1]}" else log_info "%s has left %s" "${msg[from]}" "${msg_args[0]}" fi } irc_on_PING() { irc_pong "${msg_args[1]}" log_debug "received ping: %s" "${msg_args[0]}" } irc_on_PONG() { log_debug "received pong: %s" "${msg_args[1]}" } irc_on_PRIVMSG() { log_info "<%s/%s> %s" "${msg[from]}" "${msg_args[0]}" "${msg_args[1]}" } irc_on_TOPIC() { log_info "%s has changed the topic for %s: %s" "${msg[from]}" "${msg_args[0]}" "${msg_args[1]}" } irc_on_QUIT() { log_info "%s has disconnected: %s" "${msg[from]}" "${msg_args[0]}" } irc_on_001() { log_info %s "${msg_args[1]}" run_callbacks on_sys_register_ run_callbacks on_register_ } irc_on_002() { log_info %s "${msg_args[1]}" } irc_on_003() { log_info %s "${msg_args[1]}" } irc_on_004() { log_debug "%s " "${msg_args[@]:1}" } irc_on_005() { log_debug "received inotify specs" } irc_on_250() { log_info %s "${msg_args[1]}" } irc_on_251() { log_info %s "${msg_args[1]}" } irc_on_252() { log_info "There are %d operators online" "${msg_args[1]}" } irc_on_253() { log_info "There are %d unknown connections" "${msg_args[1]}" } irc_on_254() { log_info "There are %d channels formed" "${msg_args[1]}" } irc_on_255() { log_info %s "${msg_args[1]}" } irc_on_265() { log_info %s "${msg_args[3]}" } irc_on_266() { log_info %s "${msg_args[3]}" } irc_on_315() { log_debug "end of WHO for %s" "${msg_args[1]}" } irc_on_332() { log_info "topic for %s is %s" "${msg_args[1]}" "${msg_args[2]}" } irc_on_333() { local date printf -v date '%(%c)T' "${msg_args[3]}" log_info "topic for %s set by %s at %s" "${msg_args[1]}" "${msg_args[2]}" "$date" } irc_on_353() { log_info "members of %s: %s" "${msg_args[2]}" "${msg_args[3]}" } irc_on_354() { log_debug "who: %s" "${msg_args[*]}" } irc_on_366() { log_debug "%s: end of NAMES list" "${msg_args[1]}" } irc_on_372() { log_info %s "${msg_args[1]}" } irc_on_375() { log_debug %s "${msg_args[1]}" } irc_on_376() { log_debug %s "${msg_args[1]}" } irc_on_396() { log_info "%s %s" "${msg_args[1]}" "${msg_args[-1]}" } irc_on_433() { log_info "somebody is already using %s" "${msg_args[1]}" } irc_on_438() { log_error "%s couldn't change their nick to %s: %s" "${msg_args[1]}" "${msg_args[2]}" "${msg_args[-1]}" } irc_on_473() { log_error "%s: %s" "${msg_args[1]}" "${msg_args[2]}" } ### # irc send handlers ### irc_join() { local chans printf -v chans %s, "$@" net_send "JOIN %s" "${chans%,}" } irc_nick() { net_send "NICK :%s" "$1" } irc_notice() { local msg=$2 msg_len if [[ -v config[host] ]]; then (( msg_len = 494 - (${#config[nick]} + ${#config[ident]} + ${#config[host]} + ${#1}) )) log_debug "max message length is %d" "$msg_len" while (( ${#msg} > msg_len )); do net_send "NOTICE %s :"$'\xe2\x80\x8b'"%s" "$1" "${msg:0:$msg_len}" log_info "[%s/%s] %s" "${config[nick]}" "$1" "${msg:0:$msg_len}" msg=${msg:$msg_len} done fi net_send "NOTICE %s :"$'\xe2\x80\x8b'"%s" "$1" "$msg" log_info "[%s/%s] %s" "${config[nick]}" "$1" "$msg" } irc_part() { if (( $# )); then if (( $# > 1 )); then net_send "PART $1 :$2" else net_send "PART $1" fi fi } irc_ping() { net_send "PING :%s" "$1" } irc_pong() { net_send "PONG %s" "$1" } irc_privmsg() { local msg=$2 msg_len if [[ -v config[host] ]]; then (( msg_len = 493 - (${#config[nick]} + ${#config[ident]} + ${#config[host]} + ${#1}) )) log_debug "max message length is %d" "$msg_len" while (( ${#msg} > msg_len )); do net_send "PRIVMSG %s :"$'\xe2\x80\x8b'"%s" "$1" "${msg:0:$msg_len}" log_info "<%s/%s> %s" "${config[nick]}" "$1" "${msg:0:$msg_len}" msg=${msg:$msg_len} done fi net_send "PRIVMSG %s :"$'\xe2\x80\x8b'"%s" "$1" "$msg" log_info "<%s/%s> %s" "${config[nick]}" "$1" "$msg" } irc_part() { if (( $# )); then if (( $# > 1 )); then net_send "PART $1 :$2" else net_send "PART $1" fi fi } irc_quit() { if (( $# )); then net_send "QUIT :%s" "$1" else net_send QUIT fi } irc_user() { net_send "USER %s 0 * :%s" "$1" "$2" } irc_who() { if (( $# > 1 )); then net_send "WHO %s %s" "$1" "$2" else net_send "WHO %s" "$1" fi } ### # plugin api ### plugin_reg() { declare -n plugins=irc_plugin_array_"$1" local plugin if [[ -v plugins ]]; then for plugin in "${plugins[@]}"; do if [[ $plugin = "$2" ]]; then return 1 fi done else plugins=( ) fi plugins+=( "$2" ) } plugin_run() { # This is a false positive. # shellcheck disable=SC2178 declare -n plugins=irc_plugin_array_"$1" shift local plugin if [[ -v plugins ]]; then for plugin in "${plugins[@]}"; do "$plugin" "$@" done else run_callbacks plugin_not_found_ fi } ### # plugins ### # lime-o-meter limeometer() { # We don't care about failures here, for better or for worse. # shellcheck disable=SC2155 local limes=$(random 1 42) limeification if [[ ${msg[from]} = Time-Warp ]]; then limes=42 fi (( limeification = (limes * 100) / 42 )) irc_privmsg "${msg[to]}" "$limes limes (limes to $limeification%)" } on_init_limeometer() { plugin_reg uplime limeometer } # nonlogger on_msg_PRIVMSG_nolog() { if [[ ${msg_words[0]} = *nolog* ]]; then log_info "this message was redacted" return 1 else return 0 fi } # debugger debugger_toggle() { if [[ $- = *x* ]]; then irc_privmsg "${msg[to]}" "disabling debug mode" set +x else irc_privmsg "${msg[to]}" "enabling debug mode" set -x fi } on_init_debugger() { plugin_reg debug debugger_toggle } on_before_debugger() { if [[ $- = *x* ]]; then export SET_X=yes fi } on_after_debugger() { if [[ -v SET_X && $SET_X = yes ]]; then set -x unset SET_X fi } # system administration facts get_sysfact() { local idx (( idx = RANDOM % ${#sysfacts[@]} )) irc_privmsg "${msg[to]}" "sysfact #$(( idx + 1 )): ${sysfacts[$idx]}" } on_init_sysfacts() { sysfacts=( "Use the \`rm\` command to read manuals. Use \`rm -rf\` to read manuals really fast." "When in doubt, use HTTP as a transport layer." "TLS is only needed in the United States, since the NSA doesn't monitor anywhere else." Linux. "Your local computer group can teach you all of the computer tricks you see on TV." "When people talk about headless servers, they mean deleting head(1) from it." "Store your passwords in plaintext, so a user can recover one easily if they forget it." "Cron and screen make the best process manager." ) plugin_reg sysfact get_sysfact } # alternick tracking on_init_alternick() { get_option registered no } on_register_alternick() { config[registered]=yes } on_msg_433_alternick() { if [[ ${config[registered]} = yes ]]; then log_debug "somebody is already using ${config[nick/]}" return 1 fi } on_late_msg_433_alternick() { if [[ ${config[registered]} = no ]]; then log_info "using nick ${config[nick]}_" irc_nick "${config[nick]}_" desired_nick=${config[nick]} fi } annoyatron900_alternick() { if [[ $desired_nick ]]; then irc_nick "$desired_nick" fi } on_late_msg_NICK_alternick() { if [[ ${msg[from]} = "${config[nick]}" ]]; then desired_nick= log_info "got desired nick!" config[nick]=${msg_args[-1]} fi } # factoids factoids_cmd_is() { local fact_name=${action_line%% *} if [[ $fact_name = "$action_line" || $fact_name = "$action_line " ]]; then irc_privmsg "${msg[to]}" "Can you repeat that?" return 0 fi local fact_value=${action_line#"$fact_name"} fact_value=${fact_value# } irc_privmsg "${msg[to]}" "I'm sure I'll remember that." mkdir -p "${config[fact-root]}"/"${msg[to]}" printf %s "$fact_value" > "${config[fact-root]}"/"${msg[to]}"/"$fact_name" } factoids_cmd_isnt() { if [[ -f ${config[fact-root]}/${msg[to]}/$action_line ]]; then irc_privmsg "${msg[to]}" "I forgot what that was anyways." rm -f "${config[fact-root]}"/"${msg[to]}"/"$action_line" fi } factoids_cmd_ls() { local facts=( "${config[fact-root]}"/"${msg[to]}"/* ) irc_privmsg "${msg[to]}" "${facts[*]##*/}" } plugin_not_found_factoids() { if [[ ${config[fact-root]} && -f ${config[fact-root]}/${msg[to]}/$action ]]; then # The exit status isn't important here. # shellcheck disable=SC2155 local fact=$(<"${config[fact-root]}"/"${msg[to]}"/"$action") if [[ ${action_args[0]} = \> ]] && (( ${#action_args[@]} > 1 )); then irc_privmsg "${msg[to]}" "${action_args[-1]}: $fact" else irc_privmsg "${msg[to]}" "${msg[from]}: $fact" fi fi } on_init_factoids() { get_option fact-root "" if [[ ${config[fact-root]} && -d ${config[fact-root]} ]]; then plugin_reg is factoids_cmd_is plugin_reg isnt factoids_cmd_isnt plugin_reg ls factoids_cmd_ls fi } ### # cleanup ### cleanup() { run_callbacks on_exit_ run_callbacks on_sys_exit_ } trap cleanup EXIT ### # live code reloader ### reload_config() { run_callbacks on_sys_before_ run_callbacks on_before_ RELOADED=yes exec "${cmd_line[@]}" } reload_hup() { log_info "received reload signal (HUP)" reload_config } trap reload_hup HUP ### # initialization sequence ### run_callbacks on_sys_init_ run_callbacks on_init_ if is_reloaded; then run_callbacks on_sys_after_ run_callbacks on_after_ else run_callbacks on_sys_first_ run_callbacks on_first_ fi ### # driver/protocol parser ### while net_recv line; do declare -A msg=( [words]=no [original]="$line" [score]=0 ) # parse prefix in the style of nick!ident@host if [[ ${line:0:1} = :* ]]; then prefix=${line%% *} prefix=${prefix#:} line=${line#:"$prefix"} line=${line# } log_debug "parsing message prefix %s" "$prefix" msg[host]=${prefix#*@} prefix=${prefix%"${msg[host]}"} prefix=${prefix%@} msg[from]=${msg[host]} if [[ $prefix ]]; then msg[ident]=${prefix#*!} msg[from]=${msg[ident]} if [[ ${msg[ident]} != "$prefix" ]]; then msg[nick]=${prefix%!*} msg[from]=${msg[nick]} fi fi if [[ /${msg[host]}/ = */bot/* ]]; then (( msg[score] += 100 )) fi if [[ ${msg[nick]} = *-bot ]]; then (( msg[score] += 30 )) elif [[ ${msg[nick]} = *bot ]]; then (( msg[score] += 15 )) fi fi # parse command formatted as a 3 digit integer or as a word consisting of # alphabet values msg[cmd]=${line%% *} line=${line#"${msg[cmd]}"} line=${line# } msg[cmd]=${msg[cmd]^^} log_debug "parsing message command %s" "${msg[cmd]}" # parse the remaining values into white-space separated arguments msg_args=() while [[ $line ]]; do if [[ ${line:0:1} = : ]]; then msg_args+=( "${line:1}" ) msg[words]=yes line= read -ra msg_words <<< "${msg_args[-1]}" log_debug "parsed final argument %s" "${msg_args[-1]}" else arg=${line%% *} msg_args+=( "$arg" ) line=${line#"$arg"} line=${line# } log_debug "parsed argument %s" "$arg" fi done if [[ ${msg[cmd]} = @(PRIVMSG|NOTICE) ]]; then msg[to]=${msg[from]} if [[ ${msg_args[0]:0:1} = \# ]]; then msg[to]=${msg_args[0]} fi case ${msg_args[-1]} in "["*"]") (( msg[score] += 20 )) ;; $'\xe2\x80\x8b'*) (( msg[score] += 100 )) esac fi log_debug "bot score is %d" "${msg[score]}" if has irc_on_"${msg[cmd]}"; then if run_callbacks "on_msg_${msg[cmd]}_"; then irc_on_"${msg[cmd]}" else log_debug "handler for %s was skipped" "${msg[cmd]}" fi run_callbacks "on_late_msg_${msg[cmd]}_" else log_warn "unhandled line: %s" "${msg[original]}" fi if is_action; then action=${msg_args[-1]#"${config[trigger]}"} action=${action%% *} action_line=${msg_args[-1]#"$trigger$action"} read -r action_line <<< "$action_line" read -ra action_args <<< "$action_line" plugin_run "$action" fi done