rowbot/rowbot

1639 lines
34 KiB
Bash
Executable File

#!/usr/bin/env bash
###
# feature switch toggling
###
shopt -s dotglob extglob nullglob
stty -echoctl
###
# utility and helper functions
###
# cerealizers
put_assoc_array() {
local key put_name=RB_AA_${1^^}
declare -n assoc_array=$1
declare -n scalar=$put_name
for key in "${!assoc_array[@]}"; do
scalar+=${#key},${#assoc_array[$key]}:$key${assoc_array[$key]}
done
log_trace "storing %s as %s=%s" "$1" "$put_name" "${scalar@Q}"
export "${put_name?}"
}
get_assoc_array() {
if [[ $1 && ! -v $1 ]]; then
declare -gA "$1"
fi
local key_len val_len key debug_str get_name=RB_AA_${1^^}
declare -n assoc_array=$1
declare -n scalar=$get_name
while [[ $scalar ]]; do
key_len=${scalar%%,*} val_len=${scalar#*,}
val_len=${val_len%%:*} scalar=${scalar#"$key_len","$val_len":}
assoc_array[${scalar:0:key_len}]=${scalar:key_len:val_len}
scalar=${scalar:key_len + val_len}
done
for key in "${!assoc_array[@]}"; do
debug_str+="[$key]=${assoc_array[$key]@Q} "
done
log_trace "retreiving %s as %s=(%s)" "$get_name" "$1" "$debug_str"
unset "$get_name"
}
put_array() {
local val put_name=RB_A_${1^^}
# The variable named array is a nameref to an array
# shellcheck disable=SC2178
declare -n array=$1
declare -n scalar=$put_name
for val in "${array[@]}"; do
scalar+=${#val}:$val
done
log_trace "storing %s as %s=%s" "$1" "$put_name" "${scalar@Q}"
export "${put_name?}"
}
get_array() {
local len val get_name=RB_A_${1^^}
declare -n array=$1
declare -n scalar=$get_name
while [[ $scalar ]]; do
len=${scalar%%:*} scalar=${scalar#"$len":}
val=${scalar:0:len} scalar=${scalar:len}
array+=( "$val" )
done
log_trace "retreiving %s as %s=(%s)" "$get_name" "$1" "${array[*]@Q}"
unset "$get_name"
}
b64_encode() {
local idx=0 numerics table_idxs table_idx encoded
local table=( {A..Z} {a..z} {0..9} + / )
for (( ; idx < ${#1}; idx+=3 )); do
read -ra numerics < <(
printf '%d %d %d\n' "'${1:idx:1}" "'${1:idx+1:1}" "'${1:idx+2:1}"
)
(( table_idxs[0] = numerics[0] >> 2 ))
(( table_idxs[1] = (( (numerics[0] & 0x03) << 6) | (numerics[1] & 0xF0) >> 2) >> 2 ))
(( table_idxs[2] = (( (numerics[1] & 0x0F) << 4) | (numerics[2] & 0xC0) >> 4) >> 2 ))
(( table_idxs[3] = numerics[2] & 0x3F ))
for table_idx in "${table_idxs[@]}"; do
encoded+=${table[$table_idx]}
done
done
if (( ${#1} % 3 == 1 )); then
encoded=${encoded::-2}==
elif (( ${#1} % 3 == 2 )); then
encoded=${encoded::-1}=
fi
prints %s "$encoded"
}
# code reloading helpers
is_reloaded() {
[[ $RELOADED = yes ]] || (( RELOAD_COUNT ))
}
# message classification
is_action() {
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local trigger=$(state_get trigger)
[[ ${msg[cmd]} = PRIVMSG && ${msg_args[-1]:0:${#trigger}} = "$trigger" ]]
}
is_chan() {
[[ ${msg_args[0]:0:1} = \# ]]
}
# cryptographically secure (almost maybe) pseudo random number utilities
random() {
local min=$1 max=$2
printf %d "$(( (RANDOM % max) + min ))"
}
any_file() {
local files=( "${1:-.}"/* ) max idx
if (( ${#files[@]} > 1 )); then
(( max = ${#files[@]} - 1 ))
idx=$(random 0 "$max")
printf %s "${files[$idx]}"
fi
}
shuffle() {
local idx=0 spot tmp
declare -n rowbot_array=$1
for (( ; idx < ${#rowbot_array[@]}; idx += 1 )); do
spot=$(random 0 "${#rowbot_array[@]}")
tmp=${rowbot_array[$idx]}
rowbot_array[$idx]=${rowbot_array[$spot]}
rowbot_array[$spot]=$tmp
done
}
# process management and friends
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}"
}
has() {
if (( $# )); then
hash "$1" 2>/dev/null
else
return 1
fi
}
is_running () {
kill -0 "$1" 2>/dev/null
}
# misc
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"
}
prints() {
# This is a wrapper around printf so the format string isn't known ahead of
# time.
# shellcheck disable=SC2059
printf "$@"
if [[ -t 1 ]]; then
printf \\n
fi
}
# 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
prints %s "$time"
}
url() {
if NS=net QUIET="" state_get tls; then
printf ircs://
else
printf irc://
fi
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local server=$(NS=net state_get server) port=$(NS=net state_get port)
prints %s:%s "$server" "$port"
}
###
# Prepare rowbot's configuration
###
# parse command line arguments
declare -A config 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
mapfile -t old_set < <(compgen -v)
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
mapfile -t new_set < <(compgen -v)
for new in "${new_set[@]}"; do
found=0
for old in "${old_set[@]}"; do
if [[ $new = "$old" ]]; then
found=1
break
fi
done
if (( !found )); then
config[${new//_/-}]=${!new}
fi
done
for setting in "${!opts[@]}"; do
config[$setting]=${opts[$setting]}
done
while IFS= read -r setting; do
config[${setting,,}]=${!setting}
done < <(compgen -e)
# cleanup
unset key opts old_set file new_set new found old setting
###
# state management
###
state_manage() {
local managed found=0
declare -gA __rowbot_state_store_"$1"
if [[ $1 != global ]]; then
for managed in "${states_managed[@]}"; do
if [[ $managed = "$1" ]]; then
found=1
break
fi
done
if (( !found )); then
states_managed+=( "$1" )
fi
fi
}
state_resolve() {
local ns=${NS-global}
declare -n ns_config=__rowbot_state_store_"$ns"
state_manage "$ns"
# This is a false positive.
# shellcheck disable=SC2102
if [[ -v config[$ns-$1] ]]; then
ns_config[$1]=${config[$ns-$1]}
elif [[ -v config[$1] ]]; then
ns_config[$1]=${config[$1]}
elif [[ -v DEFAULT ]]; then
ns_config[$1]=$DEFAULT
elif [[ -v DEFAULT_N && $DEFAULT_N ]]; then
ns_config[$1]=$DEFAULT_N
else
return 1
fi
}
state_put() {
local ns=${NS-global}
# The `ns_config` variable is a reference to an array
# shellcheck disable=SC2178
declare -n ns_config=__rowbot_state_store_"$ns"
state_manage "$ns"
ns_config[$1]=$2
}
state_get() {
# The `ns_config` variable is a reference to an array
# shellcheck disable=SC2178
declare -n ns_config=__rowbot_state_store_"${NS-global}"
if [[ -v ns_config[$1] ]]; then
if [[ ! -v QUIET || $QUIET = no ]]; then
printf %s "${ns_config[$1]}"
fi
if [[ ${ns_config[$1]} = no ]]; then
return 1
fi
elif [[ -v DEFAULT ]]; then
if [[ ! -v QUIET || $QUIET = no ]]; then
printf %s "$DEFAULT"
fi
if [[ $DEFAULT = no ]]; then
return 1
fi
else
return 1
fi
}
state_keys() {
# The `ns_config` variable is a reference to an array
# shellcheck disable=SC2178
declare -n ns_config=__rowbot_state_store_"${NS-global}"
declare -n array_keys=${1-CONFIG_KEYS}
# The `array_keys` variable initializes a variable declared by the calling
# code.
# shellcheck disable=SC2034
array_keys=( "${!ns_config[@]}" )
}
state_has() {
local ns=${NS-global} found=1 managed
# The `ns_config` variable is a reference to an array
# shellcheck disable=SC2178
declare -n ns_config=__rowbot_state_store_"$ns"
for managed in "${!ns_config[@]}"; do
if [[ $managed = "$1" ]]; then
found=0
break
fi
done
return "$found"
}
on_sys_init_001_state() {
states_managed=( global )
}
on_sys_before_999_state() {
local managed
for managed in "${states_managed[@]}"; do
put_assoc_array __rowbot_state_store_"$managed"
done
put_array states_managed
}
on_sys_after_001_state() {
local managed
get_array states_managed
for managed in "${states_managed[@]}"; do
declare -gA __rowbot_state_store_"$managed"
get_assoc_array __rowbot_state_store_"$managed"
done
}
###
# logger
###
log() {
if NS=log state_has fd; then
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local level=$(NS=log state_get level) fd=$(NS=log state_get fd)
if (( log_levels[$level] <= log_levels[$LOG_LEVEL] )); then
printf "%s: $1\n" "${LOG_LEVEL^^}" "${@:2}" >&"$fd"
fi
fi
}
log_trace() {
LOG_LEVEL=trace log "$@"
}
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_005_log() {
declare -gA log_levels=( [trace]=1 [debug]=2 [info]=3 [warn]=4 [error]=5 )
NS=log DEFAULT=info state_resolve level
NS=log state_resolve log
NS=log DEFAULT=no state_resolve overwrite
local log_fd=1
if ! log_has_level "$(NS=log state_get level)"; then
die "%s is not a valid logging level" "$(NS=log state_get level)"
fi
if NS=log state_has log; then
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local log_file=$(NS=log state_get log)
if NS=log QUIET="" state_get overwrite; then
exec {log_fd}>"$log_file"
else
exec {log_fd}>>"$log_file"
fi
fi
NS=log DEFAULT=$log_fd state_resolve fd
log_trace "rowbot is prepared for logging"
}
on_sys_before_999_log() {
if NS=log state_has fd; then
# The only possible fail condition is already checked for.
# shellcheck disable=2155
local fd=$(NS=log state_get fd)
if (( fd != 1 )); then
log_debug "shutting logger down for reload"
exec {fd}>&-
fi
fi
}
on_sys_exit_999_log() {
if NS=log state_has fd; then
# The only possible fail condition is already checked for.
# shellcheck disable=2155
local fd=$(NS=log state_get fd)
if (( fd != 1 )); then
log_debug "shutting logger down for good"
exec {fd}>&-
fi
fi
}
###
# bootup/shutdown sequence
###
on_sys_first_010_bootup() {
log_info "rowbot's pid is %d" "$$"
}
on_sys_before_995_bootup() {
log_debug "storing the config"
put_assoc_array config
}
on_sys_after_005_bootup() {
log_debug "retreiving the config"
get_assoc_array config
}
on_exit_zzz_bootup() {
log_info "There's a lot of beauty in ordinary things. Isn't that kind of the point?"
}
###
# net code
###
net_recv() {
declare -n sock_line=$1
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local in_sock=$(NS=net state_get in-sock)
IFS= read -ru "$in_sock" "$1"
sock_line=${sock_line%$'\r'}
log_trace "received line: %s" "$sock_line"
}
net_send() {
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local fmt out_sock=$(NS=net state_get out-sock)
# 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_trace "sending line: %s" "$fmt"
}
on_sys_init_015_net() {
NS=net DEFAULT=irc.libera.chat state_resolve server
NS=net DEFAULT=no state_resolve tls
if NS=net QUIET="" state_get tls; then
NS=net DEFAULT=6697 state_resolve port
NS=net state_resolve client-cert
else
NS=net DEFAULT=6667 state_resolve port
fi
}
on_sys_first_015_net() {
local conn_args irc_sock
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local server=$(NS=net state_get server) port=$(NS=net state_get port)
log_info "rowbot is connecting to %s" "$(url)"
if NS=net QUIET="" state_get tls; then
log_debug "requesting tls connection"
if ! has socat; then
die "please install socat to use tls with rowbot."
fi
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local sock_dir=$(mktemp -d)
log_debug "socket directory is %s" "$sock_dir"
NS=net state_put sock-dir "$sock_dir"
mkfifo "$sock_dir"/rowbot-{in,out}.sock
if NS=net state_has client-cert; then
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local client_cert=$(NS=net state_get client-cert)
log_debug "using a client certificate with the tls connection"
if [[ ! -f $client_cert ]]; then
die "client certificate not found: %s" "$client_cert"
elif [[ ! -r $client_cert ]]; then
die "client certificate is not readable"
fi
log_debug "client certificate file was found"
conn_args=OPENSSL:$server:$port,cert=$client_cert
else
log_debug "not using a client certificate for tls"
conn_args=OPENSSL:$server:$port
fi
log_debug "socat connection settings are %s" "$conn_args"
socat "$conn_args" - <"$sock_dir"/rowbot-in.sock >"$sock_dir"/rowbot-out.sock &
local tls_pid=$! out_sock in_sock
exec {out_sock}>"$sock_dir"/rowbot-in.sock {in_sock}<"$sock_dir"/rowbot-out.sock
NS=net state_put tls-pid "$tls_pid"
NS=net state_put out-sock "$out_sock"
NS=net state_put in-sock "$in_sock"
log_debug "process %d is handling tls" "$tls_pid"
else
log_debug "requesting plaintext connection"
exec {irc_sock}<>/dev/tcp/"$server"/"$port"
NS=net state_put in-sock "$irc_sock"
NS=net state_put out-sock "$irc_sock"
fi
log_debug "connection established"
}
on_sys_exit_998_net() {
log_info "rowbot is closing the connection to %s" "$(url)"
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
if NS=net state_has tls; then
if NS=net state_has tls-pid; then
local tls_pid=$(NS=net state_get tls-pid)
if is_running "$tls_pid"; then
log_debug "stopping the tls process"
kill -STOP "$tls_pid"
else
log_debug "tls process is not running"
fi
fi
log_debug "removing the socket directory"
rm -rf -- "$(NS=net state_get sock-dir)"
else
local irc_sock=$(NS=net state_get in-sock)
exec {irc_sock}>&-
fi
log_debug "connection closed"
}
###
# annoyatron900 - keep alive process
###
annoyatron900() {
irc_ping "row your bot gently down the stream"
run_callbacks annoyatron900_
}
on_sys_init_900_annoyatron900() {
trap annoyatron900 USR1
}
on_sys_register_999_annoyatron900() {
while true; do
read -rt 10 </dev/zero
if is_running "$$"; then
kill -USR1 "$$"
else
kill -INT "$BASHPID"
fi
done &
local alarm_pid=$!
NS=annoyatron900 state_put alarm-pid "$alarm_pid"
log_debug "process %d is being annoying" "$alarm_pid"
}
on_sys_exit_997_annoyatron900() {
log_debug "shutting down annoyatron900"
if NS=annoyatron900 state_has alarm-pid; then
# The only possible fail conditions are already checked for.
# shellcheck disable=SC2155
local alarm_pid=$(NS=annoyatron900 state_get alarm-pid)
if is_running "$alarm_pid"; then
kill -STOP "$alarm_pid"
fi
log_debug "annoyatron900 is dead"
else
log_debug "annoyatron900 was not running"
fi
}
###
# register with the server
###
on_sys_first_020_enroll() {
NS=enroll state_resolve caps
NS=enroll state_resolve pass
NS=irc DEFAULT=rowbot-dev state_resolve nick
NS=irc DEFAULT=rowbot state_resolve ident
NS=irc DEFAULT=rowbot state_resolve realname
log_debug "beginning irc registration handshake"
if NS=enroll state_has caps; then
log_debug "requesting list of available capabilities"
irc_cap ls
fi
if NS=enroll state_has pass; then
log_debug "sending account password"
irc_pass "$(NS=enroll state_get pass)"
fi
log_debug "sending registration data"
irc_nick "$(NS=irc state_get nick)"
irc_user "$(NS=irc state_get ident)" "$(NS=irc state_get realname)"
}
on_msg_CAP_enroll() {
local avail_cap{s,} desired_cap{s,} caps mechanism advertised
if NS=enroll state_has caps; then
case ${msg_args[1]^^} in
LS)
if ! NS=enroll QUIET="" state_get requested-caps; then
NS=avail_caps state_keys avail_caps
read -ra desired_caps < <(NS=enroll state_get caps)
# The `avail_caps` array gets initialized in the state_keys function.
# shellcheck disable=SC2154
for avail_cap in "${avail_caps[@]}"; do
for desired_cap in "${desired_caps[@]}"; do
if [[ $avail_cap = "$desired_cap" ]]; then
caps+=( "$avail_cap" )
NS=caps state_put "$avail_cap" ""
fi
done
done
irc_cap req "${caps[@]}"
NS=enroll state_put requested-caps yes
fi
;;
NAK)
irc_cap end
NS=enroll state_put have-caps no
;;
ACK)
NS=enroll state_put have-caps yes
if NS=caps state_has sasl; then
if NS=enroll state_has pass; then
mechanism=plain
elif NS=net QUIET="" state_get tls && NS=net state_has client-cert; then
mechanism=external
fi
NS=sasl DEFAULT_N=$mechanism state_resolve mechanism
NS=sasl DEFAULT=yes state_resolve required
if ! NS=sasl state_has mechanism; then
die "please specify an appropriate sasl mechanism"
fi
advertised=$(NS=avail_caps state_get sasl)
mechanism=$(NS=sasl state_get mechanism)
if [[ ,$advertised, = *,"${mechanism^^}",* ]]; then
irc_authenticate mechanism "$(NS=sasl state_get mechanism)"
else
if NS=sasl QUIET="" state_get required; then
die "%s is not a valid sasl mechanism" "$mechanism"
else
log_warn "connecting without sasl"
irc_cap end
fi
fi
else
irc_cap end
fi
esac
fi
}
on_msg_AUTHENTICATE_enroll() {
# Any fail scenario is already covered.
# shellcheck disable=SC2155
local mechanism=$(NS=sasl state_get mechanism)
irc_authenticate +
}
on_sys_init_010_bootup() {
DEFAULT=${USER:-uplime} state_resolve owner
DEFAULT=\` state_resolve trigger
DEFAULT=yes state_resolve dev
}
on_sys_init_020_welcome() {
NS=irc state_resolve chans
}
on_msg_005_welcome() {
local param key value
for param in "${msg_args[@]:1:${#msg_args[@]}-2}"; do
# This is a valid assignment, not a comparison.
# shellcheck disable=SC1097
IFS== read -r key value <<< "$param"
NS=isupport state_put "$key" "$value"
log_trace "isupport: %s = %s" "$key" "$value"
done
}
on_sys_register_welcome() {
if NS=irc state_has chans; then
irc_join "$(NS=irc state_get chans)"
fi
}
###
# magic required to make privmsg work
###
on_sys_register_privmagic() {
NS=irc state_put nick "${msg_args[0]}"
}
on_msg_354_privmagic() {
if (( msg_args[1] == 42 )); then
log_debug "received the identifying who"
NS=irc state_put ident "${msg_args[2]}"
NS=irc state_put host "${msg_args[3]}"
fi
}
on_msg_396_privmagic() {
NS=irc state_put host "${msg_args[1]}"
}
###
# irc receive handlers
###
irc_on_AUTHENTICATE() {
log_debug "received authentication acknowledgement"
}
irc_on_CAP() {
local cap caps
case ${msg_args[1]^^} in
LS)
caps=${msg_args[-1]}
while [[ $caps ]]; do
cap=${caps%% *} caps=${caps#"$cap"} caps=${caps# }
if [[ $cap = *=* ]]; then
NS=avail_caps state_put "${cap%%=*}" "${cap#*=}"
else
NS=avail_caps state_put "$cap" ""
fi
done
;;
NAK)
log_error "unable to request capabilities for %s" "$(NS=irc state_get nick)"
;;
ACK)
log_debug "have successfully received capabilities from server: %s" "${msg_args[-1]}"
esac
}
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_trace "received ping: %s" "${msg_args[0]}"
}
irc_on_PONG() {
log_trace "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 isupport specification: ${msg_args[*]:1}"
}
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_401() {
log_info "%s is unavailable: %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_on_718() {
log_info "%s %s" "${msg_args[1]}" "${msg_args[-1]}"
}
###
# irc send handlers
###
irc_accept() {
net_send "ACCEPT $1"
}
irc_authenticate() {
case ${1,,} in
mechanism)
net_send "AUTHENTICATE %s" "${2^^}"
;;
*)
net_send "AUTHENTICATE %s" "$*"
esac
}
irc_cap() {
case ${1,,} in
ls)
net_send "CAP LS %s" "${2-302}"
;;
req)
shift
net_send "CAP REQ :%s" "$*"
;;
end)
net_send "CAP END"
esac
}
irc_join() {
local chans
printf -v chans %s, "$@"
net_send "JOIN %s" "${chans%,}"
}
irc_mode() {
if (( $# == 1 )); then
net_send "MODE ${config[nick]} ${config[modes]}"
fi
}
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_trace "max message length for NOTICE 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_pass() {
net_send "PASS $1"
}
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_trace "max message length for PRIVMSG 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_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_is_good_variable() {
[[ $1 =~ ^[A-Za-z_][A-Za-z0-9_]+$ ]]
}
plugin_reg() {
if ! plugin_is_good_variable irc_plugin_array_"$1"; then
return 1
fi
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
if plugin_is_good_variable irc_plugin_array_"$1" && [[ -v irc_plugin_array_"$1" ]]; then
declare -n plugins=irc_plugin_array_"$1"
shift
local plugin
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_early_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_early_msg_433_alternick() {
if [[ ${config[registered]} = yes ]]; then
log_debug "somebody is already using ${config[nick]}"
return 1
fi
}
on_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_msg_NICK_alternick() {
if [[ ${msg[from]} = "${config[nick]}" ]]; then
desired_nick=
log_info "got desired nick %s!" "${msg_args[-1]}"
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")
local target idx fmt_fact
if [[ ${action_args[0]} = \> ]] && (( ${#action_args[@]} > 1 )); then
target=${action_args[-1]}
else
target=${msg[from]}
fi
for (( idx = 0; idx < ${#fact}; idx += 1 )); do
if [[ ${fact:idx:1} = % ]] && (( idx + 1 < ${#fact} )); then
(( idx += 1 ))
case ${fact:idx:1} in
t)
fmt_fact+=$target
;;
c)
fmt_fact+=${msg[from]}
;;
r)
fmt_fact+=$(random 0 100)
;;
*)
fmt_fact+=${fact:idx:1}
esac
else
fmt_fact+=${fact:idx:1}
fi
done
irc_privmsg "${msg[to]}" "$target: $fmt_fact"
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
}
# request default modes
on_init_mode_getter() {
# get_option modes wigR
:
}
on_register_mode_getter() {
if [[ ${config[modes]:0:1} = @(+|-) ]]; then
config[modes]=+${config[modes]}
fi
irc_mode "${config[modes]}"
irc_accept "${config[owner]}"
}
###
# atexit(3)-style cleanup
###
cleanup() {
run_callbacks on_sys_exit_
run_callbacks on_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_trace "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_trace "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_trace "parsed final argument %s" "${msg_args[-1]}"
else
arg=${line%% *} msg_args+=( "$arg" ) line=${line#"$arg"} line=${line# }
log_trace "parsed argument %s" "$arg"
fi
done
if [[ ${msg[cmd]} = @(PRIVMSG|NOTICE) ]]; then
msg[to]=${msg[from]}
if is_chan; then
msg[to]=${msg_args[0]}
fi
case ${msg_args[-1]} in
"["*"]")
(( msg[score] += 20 ))
;;
$'\xe2\x80\x8b'*)
(( msg[score] += 100 ))
esac
fi
log_trace "bot score is %d" "${msg[score]}"
if has irc_on_"${msg[cmd]}"; then
if run_callbacks "on_early_msg_${msg[cmd]}_"; then
irc_on_"${msg[cmd]}"
else
log_debug "handler for %s was skipped" "${msg[cmd]}"
fi
run_callbacks "on_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