#!/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}]} = \# ]] } 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" } 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 -r "$1" <&"$in_sock" 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 -INT "$tls_pid" fi rm -rf -- "$sock_dir" fi } ### # irc magic ### magic_annoyatron900() { irc_ping "row your bot gently down the stream" } on_sys_init_999_magic() { get_option chan "" } on_sys_first_003_magic() { get_option nick rowbot-dev get_option ident rowbot get_option realname rowbot log_debug "registering with the server" irc_nick "${config[nick]}" irc_user "${config[ident]}" "${config[realname]}" } on_sys_before_999_magic() { if [[ -v alarm_pid ]]; then export ALARM_PID=$alarm_pid fi } on_sys_after_999_magic() { trap magic_annoyatron900 USR1 if [[ -v ALARM_PID ]]; then alarm_pid=$ALARM_PID fi } on_sys_register_999_magic() { if [[ ${config[chan]} ]]; then irc_join "${config[chan]}" fi 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_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_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_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 } 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 } ### # 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 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 } ### # 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= # Code using this array will be implemented later. # shellcheck disable=SC2034 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" # This variable will be used in later code. # shellcheck disable=SC2034 read -ra action_args <<< "$action_line" plugin_run "$action" fi done