rowbot/rowbot

772 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
shopt -s nullglob dotglob extglob
###
# logger
###
declare -A levels=(
[debug]=1 [info]=2
[warn]=3 [error]=4
)
log() {
if [[ -v LEVEL ]] && (( levels[$level] <= levels[$LEVEL] )); then
printf "%s: $1\n" "${LEVEL^^}" "${@:2}" >&"$log"
fi
}
debug() {
LEVEL=debug log "$@"
}
info() {
LEVEL=info log "$@"
}
warn() {
LEVEL=warn log "$@"
}
error() {
LEVEL=error log "$@"
}
###
# argument parser for parsing arguments
##
original_args=("$0" "$@")
declare -A opts
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
###
# default config
##
server=${opts[server]:-irc.libera.chat}
tls=${opts[tls]:-no}
if [[ $tls = yes ]]; then
if ! hash socat 2>/dev/null; then
printf 'please install socat to use tls with rowbot.\n' >&2
exit 1
fi
if [[ -v opts[client-cert] ]]; then
client_cert=${opts[client-cert]}
fi
port=${opts[port]:-6697}
else
port=${opts[port]:-6667}
fi
nick=${opts[nick]:-rowbot-dev}
ident=${opts[ident]:-rowbot}
realname=${opts[realname]:-rowbot}
chan=${opts[chan]:-}
trigger=${opts[trigger]:-\`}
fact_root=${opts[fact-root]:-.}
reload=${opts[reload]:-no}
dev=${opts[dev]:-no}
if [[ -v USER ]]; then
owner=${opts[owner]:-"$USER"}
else
owner=${opts[owner]:-uplime}
fi
level=${opts[log-level]:-info}
if [[ $reload = yes ]]; then
log=$LOG_FD
elif [[ ${opts[log]} ]]; then
exec {log}>"${opts[log]}"
else
log=1
fi
###
# utilities
###
cleanup() {
if [[ -v tls_pid || $tls = no ]]; then
exec {in_sock}>&- {out_sock}>&- {log}>&-
fi
if [[ -v tls_pid ]]; then
kill "$tls_pid"
rm -rf "$sock_dir"
fi
if [[ -v ping_pid ]]; then
kill "$ping_pid"
fi
}
trap cleanup EXIT
alarm-handler() {
ping "row your bot gently down the stream"
if [[ $keep_trying = yes ]]; then
info "trying for %s again" "$desired_nick"
nick "$desired_nick"
fi
}
trap alarm-handler ALRM
###
# reload code
###
if [[ $reload = yes ]]; then
in_sock=$IN_SOCK out_sock=$OUT_SOCK
trigger=$TRIGGER dev=$DEV level=$LOG_LEVEL
registered=$REGISTERED
debug "doing a reload. pid is %d" "$$"
if [[ -v KEEP_TRYING ]]; then
keep_trying=$KEEP_TRYING desired_nick=$DESIRED
fi
if [[ $tls = yes ]]; then
sock_dir=$SOCK_DIR
tls_pid=$tls_pid
fi
if [[ -v PING_PID ]]; then
ping_pid=$PING_PID
fi
nick=$NICK ident=$IDENT
if [[ -v HOST ]]; then
host=$HOST
fi
fi
###
# net code
###
if [[ $reload = no && $tls = yes ]]; then
sock_dir=$(mktemp -d)
mkfifo "$sock_dir"/rb{in,out}
if [[ -v client_cert ]]; then
if [[ ! -f $client_cert ]]; then
error "client certificate not found: %s" "$client_cert"
exit 1
fi
conn_args=OPENSSL:$server:$port,cert=$client_cert
else
conn_args=OPENSSL:$server:$port
fi
socat "$conn_args" - <"$sock_dir"/rbin >"$sock_dir"/rbout &
tls_pid=$!
debug "created tls connection (pid %d)" "$tls_pid"
exec {out_sock}>"$sock_dir"/rbin {in_sock}<"$sock_dir"/rbout
elif [[ $reload = no ]]; then
exec {sock}<>/dev/tcp/"$server"/"$port"
in_sock=$sock out_sock=$sock
debug "created plaintext connection"
fi
send() {
local fmt
printf -v fmt "$1" "${@:2}"
printf '%s\r\n' "$fmt" >&"$out_sock"
debug "sending line: %s" "$fmt"
}
recv() {
declare -n sock_line=$1
IFS= read -r "$1" <&"$in_sock"
sock_line=${sock_line%$'\r'}
debug "received line: %s" "$sock_line"
}
###
# irc recv code
###
on_ERROR() {
error "${params[0]}"
exit
}
on_JOIN() {
info "%s has joined %s" "$from" "${params[0]}"
}
on_MODE() {
if (( ${#params[@]} == 2 )); then
info "%s sets mode(s) %s on %s" "$from" "${params[1]}" "${params[0]}"
elif (( ${#params[@]} > 2 )); then
info "%s: %s sets mode(s) %s" "${params[0]}" "$from" "${params[*]:1}"
fi
}
on_NICK() {
if [[ $from = $nick ]]; then
nick=${params[0]}
fi
info "%s has changed their name to %s" "$from" "${params[0]}"
}
on_NOTICE() {
info "[%s/%s] %s" "$from" "${params[0]}" "${params[1]}"
}
on_PART() {
if (( ${#params[@]} > 1 )); then
info "%s has left %s: %s" "$from" "${params[0]}" "${params[1]}"
else
info "%s has left %s" "$from" "${params[0]}"
fi
}
on_PING() {
pong "${params[1]}"
debug "received ping: %s" "${params[0]}"
}
on_PONG() {
debug "received pong: %s" "${params[1]}"
}
on_PRIVMSG() {
info "<%s/%s> %s" "$from" "${params[0]}" "${params[1]}"
}
on_QUIT() {
info "%s has disconnected: %s" "$from" "${params[0]}"
}
on_001() {
info %s "${params[1]}"
if [[ $chan ]]; then
join "$chan"
fi
{
debug "timer pid is %d" "$BASHPID"
while true; do
kill -ALRM "$$"
sleep 10
done
} &
ping_pid=$!
nick=${params[0]}
registered=yes
who "$nick" %%uht,42
}
on_002() {
info %s "${params[1]}"
}
on_003() {
info %s "${params[1]}"
}
on_004() {
debug "%s " "${params[@]:1}"
}
declare -A isupport
on_005() {
local param key value
for param in "${params[@]:1:${#params[@]}-2}"; do
IFS== read -r key value <<< "$param"
isupport[$key]=$value
debug "isupport: %s = %s" "$key" "$value"
done
}
on_250() {
info %s "${params[1]}"
}
on_251() {
info %s "${params[1]}"
}
on_252() {
info "There are %d operators online" "${params[1]}"
}
on_253() {
info "There are %d unknown connections" "${params[1]}"
}
on_254() {
info "There are %d channels formed" "${params[1]}"
}
on_255() {
info %s "${params[1]}"
}
on_265() {
info %s "${params[3]}"
}
on_266() {
info %s "${params[3]}"
}
on_315() {
debug "end of WHO for %s" "${params[1]}"
}
on_332() {
info "topic for %s is %s" "${params[1]}" "${params[2]}"
}
on_333() {
local date
printf -v date '%(%c)T' "${params[3]}"
info "topic for %s set by %s at %s" "${params[1]}" "${params[2]}" "$date"
}
on_353() {
info "members of %s: %s" "${params[2]}" "${params[3]}"
}
on_354() {
if (( ${params[1]} == 42 )); then
debug "received the identifying who"
ident=${params[2]} host=${params[3]}
debug "ident=%s host=%s" "$ident" "$host"
fi
}
on_366() {
debug "%s: end of NAMES list" "${params[1]}"
}
on_372() {
info %s "${params[1]}"
}
on_375() {
debug %s "${params[1]}"
}
on_376() {
debug %s "${params[1]}"
}
on_433() {
info "somebody is already using %s" "${params[1]}"
if [[ $registered = no ]]; then
nick "${nick}_"
fi
}
on_473() {
error "%s: %s" "${params[1]}" "${params[2]}"
}
###
# irc send code
###
join() {
send "JOIN %s" "$1"
}
nick() {
send "NICK %s" "$1"
}
notice() {
if [[ -v host ]]; then
local msg_len msg=$2
(( msg_len = 494 - (${#nick} + ${#ident} + ${#host} + ${#1}) ))
debug "max message length is %d" "$msg_len"
while (( ${#msg} > msg_len )); do
send "NOTICE %s :"$'\xe2\x80\x8b'"%s" "$1" "${msg:0:$msg_len}"
info "[%s/%s] %s" "$nick" "$1" "${msg:0:$msg_len}"
msg=${msg:$msg_len}
done
fi
send "NOTICE %s :%s" "$1" "$2"
info "[%s/%s] %s" "$nick" "$1" "$2"
}
part() {
if (( $# )); then
if (( $# > 1 )); then
send "PART $1 :$2"
else
send "PART $1"
fi
fi
}
ping() {
send "PING :%s" "$1"
}
pong() {
send "PONG %s" "$1"
}
privmsg() {
if [[ -v host ]]; then
local msg_len msg=$2
(( msg_len = 493 - (${#nick} + ${#ident} + ${#host} + ${#1}) ))
debug "max message length is %d" "$msg_len"
while (( ${#msg} > msg_len )); do
send "PRIVMSG %s :"$'\xe2\x80\x8b'"%s" "$1" "${msg:0:$msg_len}"
info "<%s/%s> %s" "$nick" "$1" "${msg:0:$msg_len}"
msg=${msg:$msg_len}
done
fi
send "PRIVMSG %s :"$'\xe2\x80\x8b'"%s" "$1" "$msg"
info "<%s/%s> %s" "$nick" "$1" "$msg"
}
quit() {
if (( $# )); then
send "QUIT :%s" "$1"
else
send QUIT
fi
}
user() {
send "USER %s 0 * :%s" "$ident" "$realname"
}
who() {
if (( $# > 1 )); then
send "WHO %s %s" "$1" "$2"
else
send "WHO %s" "$1"
fi
}
###
# app hooks
##
hook_pre_PRIVMSG_CTCP() {
if [[ ${params[1]} != $'\x01'*$'\x01' ]]; then
return 0
fi
local cmd msg
cmd=${params[1]#$'\x01'} cmd=${cmd%% *}
msg=${params[1]#* } msg=${msg%$'\x01'}
if [[ ${cmd^^} = ACTION ]]; then
if [[ ${params[0]:0:1} = \# ]]; then
info "ctcp: %s: %s %s" "${params[0]}" "$from" "$msg"
else
info "privately, %s %s" "$from" "$msg"
fi
return 1
elif [[ ${params[0]:0:1} = \# ]]; then
info "ctcp: %s has requested %s in %s" "$from" "${cmd^^}" "${params[0]}"
return 1
fi
info "ctcp: sending %s to %s" "${cmd^^}" "$from"
case ${cmd^^} in
CLIENTINFO)
notice "$from" $'\x01'"CLIENTINFO ACTION CLIENTINFO PING SOURCE TIME VERSION"$'\x01'
;;
PING)
local msg
msg=${params[1]#* } msg=${msg%$'\x01'}
notice "$from" $'\x01'"PING $msg"$'\x01'
;;
SOURCE)
notice "$from" $'\x01'"SOURCE https://ahti.space/git/uplime/rowbot"$'\x01'
;;
TIME)
notice "$from" $'\x01'"TIME time for you to get a watch"$'\x01'
;;
VERSION)
notice "$from" $'\x01'"VERSION rowbot v2"$'\x01'
esac
return 1
}
hook_post_PRIVMSG_factoids() {
if [[ ${params[0]:0:1} != \# ]]; then
return 0
elif [[ ${words[0]} = "$trigger"* ]]; then
case ${words[0]:${#trigger}} in
is)
if (( ${#words[@]} < 3 )); then
return 0
fi
local key val
key=${params[1]#*"$trigger"is} key=${key# }
val=${key#* } key=${key%% *}
info "%s said in %s to remember %s as %s" "$from" "${params[0]}" "$key" "$val"
privmsg "${params[0]}" "I'm sure I'll remember that."
mkdir -p "$fact_root"/"${params[0]}"
printf %s "$val" > "$fact_root"/"${params[0]}"/"$key"
;;
isnt)
if (( ${#words[@]} < 2 )); then
return 0
fi
local key
key=${params[1]#*"$trigger"isnt} key=${key# }
if [[ -f $fact_root/${params[0]}/$key ]]; then
info "%s said in %s to delete %s" "$from" "${params[0]}" "$key"
privmsg "${params[0]}" "I forgot what that was anyways."
rm -f "$fact_root"/"${params[0]}"/"$key"
fi
;;
ls)
local facts=( "$fact_root"/"${params[0]}"/* )
privmsg "${params[0]}" "${facts[*]##*/}"
;;
*)
local key=${words[0]:${#trigger}}
if [[ -f $fact_root/${params[0]}/$key ]]; then
local msg=$(<"$fact_root"/"${params[0]}"/"$key")
if (( ${#words[@]} > 1 )) && [[ ${words[1]} = \> ]]; then
local target
target=${params[1]#*\>} target=${target# } target=${target% }
privmsg "${params[0]}" "$target: $msg"
else
privmsg "${params[0]}" "$from: $msg"
fi
fi
esac
fi
}
hook_post_PRIVMSG_control_panel() {
if [[ ${words[0]} = "$trigger"* ]]; then
if [[ $from != $owner && $dev != yes ]]; then
return 0
fi
local to=${params[0]}
if [[ ${params[0]:0:1} != \# ]]; then
to=$from
fi
case ${words[0]:${#trigger}} in
raw)
local cmd
cmd=${params[1]#"$trigger"raw} cmd=${cmd# }
info "%s is executing command: %s" "$from" "$cmd"
send "$cmd"
;;
join)
join "${words[1]}"
privmsg "$to" "joined ${words[1]}"
;;
reload)
export IN_SOCK=$in_sock OUT_SOCK=$out_sock LOG_FD=$log DEV=$dev
export RELOAD_TO=$to TRIGGER=$trigger LOG_LEVEL=$level
export NICK=$nick IDENT=$ident REGISTERED=$registered
if [[ $keep_trying = yes ]]; then
export KEEP_TRYING=yes DESIRED=$desired_nick
fi
if [[ -v host ]]; then
export HOST=$host
fi
if [[ $tls = yes ]]; then
export SOCK_DIR=$sock_dir
export TLS_PID=$tls_pid
fi
if [[ -v ping_pid ]]; then
export PING_pid=$ping_pid
fi
privmsg "$to" "reloading..."
exec "${original_args[@]}" --reload
;;
level)
level=${words[1]}
privmsg "$to" "log level is now set to $level"
;;
dev)
if [[ $dev = yes ]]; then
dev=no
privmsg "$to" "developer status disabled"
else
dev=yes
privmsg "$to" "developer status enabled"
fi
;;
dev\?)
if [[ $dev = yes ]]; then
privmsg "$to" "developer status is enabled"
else
privmsg "$to" "developer status is disabled"
fi
;;
trigger)
if (( ${#words[@]} > 1 )); then
trigger=${words[1]}
privmsg "$to" "trigger is now $trigger"
fi
;;
msg)
if (( ${words[@]} > 2 )); then
privmsg "${words[1]}" "${words[*]:2}"
privmsg "$to" "sent message to ${words[1]}"
fi
;;
cycle)
if [[ ${to:0:1} = \# ]]; then
privmsg "$to" "cycling channel $to"
part "$to" "be back soon!"
join "$to"
fi
esac
fi
}
hook_post_433_alternick() {
if [[ -z $desired_nick && $registered = no ]]; then
desired_nick=${params[1]}
keep_trying=yes
fi
}
hook_post_NICK_alternick() {
if [[ ${params[0]} = $desired_nick ]]; then
keep_trying=no
info "obtained nick %s" "$desired_nick"
fi
}
###
# driver
###
if [[ $reload = yes ]]; then
privmsg "$RELOAD_TO" done.
else
registered=no
info "rowbot's pid is %d" "$$"
nick "$nick"
user "$ident" "$realname"
fi
while recv line; do
params=( )
has_words=no
orig_line=$line
if [[ ${line:0:1} = : ]]; then
src=${line%% *} src=${src#:}
line=${line#:"$src"} line=${line# }
from=${src%@*} ident=${from#*!}
from=${from%!*} host=${src#*@}
fi
cmd=${line%% *}
line=${line#"$cmd"}
line=${line# }
while [[ $line ]]; do
if [[ ${line:0:1} = : ]]; then
params+=("${line:1}")
line=""
has_words=yes
else
param=${line%% *}
params+=("$param")
line=${line#"$param"} line=${line# }
fi
done
if [[ $has_words = yes ]]; then
read -ra words <<< "${params[@]:(-1)}"
else
words=( )
fi
skip_handler=0
while IFS= read -r hook; do
"$hook"
(( skip_handler |= $? ))
done < <(compgen -A function "hook_pre_${cmd^^}_")
if hash "on_${cmd^^}" 2>/dev/null; then
if (( ! skip_handler )); then
"on_${cmd^^}"
else
debug "handler for %s was skipped" "${cmd^^}"
fi
else
warn "unhandled line: %s" "$orig_line"
fi
while IFS= read -r hook; do
"$hook"
done < <(compgen -A function "hook_post_${cmd^^}_")
done