rowbot/rowbot

1005 lines
19 KiB
Bash
Executable File

#!/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
log_debug "running callback %s" "$REPLY"
"$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 -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"
}
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 </dev/zero
KILL -USR1 "$$"
done &
alarm_pid=$!
trap annoyatron900 USR1
log_debug "process %d is being annoying" "$alarm_pid"
}
on_sys_exit_997_annoyatron900() {
log_debug "shutting down annoyatron900"
if [[ -v alarm_pid ]] && is_running "$alarm_pid"; then
kill -STOP "$alarm_pid"
fi
}
###
# register with the server
###
on_sys_first_003_welcome() {
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_init_999_welcome() {
get_option chan ""
}
on_sys_register_999_welcome() {
if [[ ${config[chan]} ]]; then
irc_join "${config[chan]}"
fi
}
###
# magic required to make privmsg work
###
on_sys_register_001_privmagic() {
config[nick]=${msg_args[0]}
irc_who "${config[nick]}" %%uht,42
}
on_msg_354_privmagic() {
if (( msg_args[1] == 42 )); then
log_debug "received the identifying who"
config[ident]=${msg_args[2]} config[host]=${msg_args[3]}
fi
}
on_msg_396_privmagic() {
config[host]=${msg_args[1]}
log_debug "config host has been changed to %s" "${msg_args[1]}"
}
###
# irc receive handlers
###
irc_on_ERROR() {
log_error "${msg_args[0]}"
exit
}
irc_on_JOIN() {
log_info "%s has joined %s" "${msg[from]}" "${msg_args[0]}"
}
irc_on_KICK() {
if (( ${#msg_args[@]} == 3 )); then
log_info "%s has kicked %s from %s: %s" "${msg[from]}" "${msg_args[1]}" "${msg_args[0]}" "${msg_args[-1]}"
else
log_info "%s has kicked %s from %s" "${msg[from]}" "${msg_args[1]}" "${msg_args[0]}"
fi
}
irc_on_MODE() {
if (( ${#msg_args[@]} == 2 )); then
log_info "%s sets mode(s) %s on %s" "${msg[from]}" "${msg_args[1]}" "${msg_args[0]}"
elif (( ${#msg_args[@]} > 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 sysfacts get_sysfact
}
###
# 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