rowbot/rowbot

772 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
###
# feature switch toggling
###
shopt -s dotglob extglob nullglob
###
# 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
(( (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 0
}
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 "$chan"
fi
while true; do
read -rt 10 </dev/zero
KILL -USR1 "$$"
done &
alarm_pid=$!
trap magic_annoyatron900 USR1
log_debug "process %d is being annoying" "$alarm_pid"
}
on_sys_exit_997_magic() {
log_debug "shutting down annoyatron900"
if [[ -v alarm_pid ]] && is_running "$alarm_pid"; then
kill -STOP "$alarm_pid"
fi
}
###
# 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_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_ping() {
net_send "PING :%s" "$1"
}
irc_pong() {
net_send "PONG %s" "$1"
}
irc_user() {
net_send "USER %s 0 * :%s" "$1" "$2"
}
###
# 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"
fi
done