#!/bin/bash if [ "$(whoami)" = "root" ]; then echo "This bot should not be ran as root." exit 1 fi # Check if bot.cfg exists, if not create it from example if [[ ! -f "bot.cfg" ]]; then if [[ -f "bot.cfg.example" ]]; then echo "bot.cfg not found. Creating from bot.cfg.example..." cp "bot.cfg.example" "bot.cfg" echo "Please edit bot.cfg to configure your bot (server, channel, nick, etc.)." exit 0 else echo "Neither bot.cfg nor bot.cfg.example found." exit 1 fi fi # Check and initialize customizable configuration files check_config_files() { local configFiles=( "response/error.txt:response/error.txt.example" "response/exit.txt:response/exit.txt.example" "triggers/greet/greetings.txt:triggers/greet/greetings.txt.example" "triggers/keywords/keywords.cfg:triggers/keywords/keywords.cfg.example" ) for entry in "${configFiles[@]}"; do local target="${entry%:*}" local example="${entry#*:}" if [[ ! -f "$target" ]]; then if [[ -f "$example" ]]; then cp "$example" "$target" echo "Created $target from $example" else # Hardcoded fallback for critical files if example is missing case "$target" in "response/error.txt") mkdir -p "$(dirname "$target")" printf "I don't understand\nI'm not sure how to help you with that.\n" > "$target" echo "Warning: $example not found. Created $target with default content." ;; "response/exit.txt") mkdir -p "$(dirname "$target")" printf "Ta ta for now.\nbye.\n" > "$target" echo "Warning: $example not found. Created $target with default content." ;; *) echo "Warning: Missing $example and no hardcoded fallback available." ;; esac fi fi done } check_config_files # Load required files. for i in "bot.cfg" "functions.sh" ; do if [[ -f "$i" ]]; then source "$i" else echo "Could not find required file \"${i}\"." exit 1 fi done # Variables important to modules need to be exported here. export allowList export channels export input export ignoreList export nick export quitMessage export intentionalExit # Check for critical dependencies needed by the bot core coreDependencies=("socat" "tail" "shuf" "grep" "sed" "tr" "cut" "date") missingCore=() for dep in "${coreDependencies[@]}"; do if ! command -v "$dep" &> /dev/null; then missingCore+=("$dep") fi done if [[ ${#missingCore[@]} -gt 0 ]]; then echo "ERROR: Missing critical dependencies: ${missingCore[*]}" echo "Please install these packages before running the bot." exit 1 fi # Clean up any leftover temporary files from previous runs cleanup_old_tempfiles() { find . -maxdepth 1 -type f -name '.[A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9]' -mmin +5 -delete 2>/dev/null } # Function called on exit to remove the temporary input file. rm_input() { if [[ -f "$input" ]]; then rm -f "$input" fi } # Function to trim log file to prevent unbounded growth # Keeps log between 800-1000 lines by trimming oldest entries when limit reached trim_log() { local logFile="$1" local maxLines=1000 local trimTo=800 # Only check if log file exists and is readable if [[ ! -f "$logFile" ]]; then return 0 fi # Count lines efficiently local lineCount lineCount=$(wc -l < "$logFile" 2>/dev/null || echo 0) # If over limit, trim to keep most recent entries if [[ $lineCount -gt $maxLines ]]; then tail -n "$trimTo" "$logFile" > "${logFile}.tmp" && mv "${logFile}.tmp" "$logFile" fi } # Trap exiting ffrom the program to remove the temporary input file. trap rm_input EXIT # Flag to track intentional shutdown (set by exit module) intentionalExit=false # Reconnection loop - keeps bot connected even if connection drops reconnectDelay=10 while true; do # Clean up old temp files from previous runs cleanup_old_tempfiles # Set up the connection. echo -e "Session started $(date "+%I:%M%p%n %A, %B %d, %Y").\n\nTo gracefully exit, make sure you are in the allow list and send the command exit to the bot.\n\n" | tee -a "$log" echo "NICK $nick" > "$input" echo "USER $user" >> "$input" # Join all configured channels for channelName in "${channels[@]}"; do echo "JOIN #$channelName" >> "$input" done # The main loop of the program where we watch for output from irc. # Use SSL if enabled, otherwise plain TCP if [[ "${useSSL,,}" == "true" ]]; then socatAddress="SSL:${server}:${port},verify=0" else socatAddress="TCP:${server}:${port}" fi # Counter for log trimming (check every 50 messages) logTrimCounter=0 tail -f "$input" | socat -,ignoreeof "$socatAddress" | while read -r result ; do # Strip carriage return from IRC protocol (CRLF line endings) result="${result%$'\r'}" # Sanitize control characters for logging (prevent log injection) logSanitized="${result//[$'\001'-$'\037'$'\177']/}" # log the session echo "$logSanitized [$(date "+$dateFormat")]" >> "$log" # Periodically trim log to prevent unbounded growth ((logTrimCounter++)) if [[ $logTrimCounter -ge 50 ]]; then trim_log "$log" logTrimCounter=0 fi # do things when you see output case "$result" in # Handle nick changes ":"*"NICK :"*) # Get the original nick originalNick="${result#:}" originalNick="${originalNick%%!*}" # If the old nick was in the ignore list, update it. if [[ "${originalNick}" =~ ^($ignoreList)$ ]]; then export ignoreList="${ignoreList/${originalNick}/${result#:*:}}" fi ;; # respond to ping requests from the server PING*) echo "${result/I/O}" >> "$input" ;; # for pings on nick/user *"You have not"*) for channelName in "${channels[@]}"; do echo "JOIN #$channelName" | tee -a "$input" done ;; # Run on kick :*!*@*" KICK "*" $nick :"*) if [ "$autoRejoinChannel" = "true" ]; then # Extract channel name from kick message and rejoin that specific channel kickedChannel="${result##*#}" kickedChannel="#${kickedChannel%% *}" echo "JOIN $kickedChannel" | tee -a "$input" fi if [ "$curseKicker" = "true" ]; then kickerName="${result%!*}" kickerName="${kickerName:1}" kickerChannel="${result##*#}" kickerChannel="#${kickerChannel%% *}" msg "$kickerChannel" "$kickerName: $(shuf -e -n1 "fuck you" "go fuck yourself")!" fi ;; # run when someone joins *"JOIN :#"*) who="${result%%!*}" who="${who:1}" from="${result#*#}" from="#$from" if [ "$who" = "$nick" ]; then continue fi if [ "${greet^^}" = "TRUE" ]; then set -f ./triggers/greet/greet.sh "$who" "$from" set +f fi ;; # run when someone leaves *"PART #"*) who="${result%%!*}" who="${who:1}" from="${result#*#}" from="#$from" if [ "$who" = "$nick" ]; then continue fi if [ "${leave^^}" = "TRUE" ]; then set -f ./triggers/bye/bye.sh "$who" "$from" set +f fi ;; # run when a private message is seen *"PRIVMSG "[[:alnum:]-_]*) echo "$logSanitized" >> "$log" who="${result%%!*}" who="${who:1}" from="${who%!*}" command="${result#:* PRIVMSG [[:alnum:]_-]*:}" command="${command//# /}" will="${command#* }" command="${command%% *}" if [[ "$from" =~ ^($allowList)$ ]]; then if command -v "./modules/${command% *}/${command% *}.sh" ; then willSanitized="${will//[$'\001'-$'\037'$'\177']/}" echo "Calling module ./modules/${command% *}/${command% *}/${command% *}.sh \"$who\" \"$from\" $willSanitized" >> "$log" # Disable wildcards set -f # For PMs, respond directly to the user, not to a channel "./modules/${command% *}/${command% *}.sh" "$who" "$from" "$will" # Enable wildcards set +f else reply "$who" "$(shuf -n1 "response/error.txt")" fi else reply "$who" "You are not in the allowed list for this bot. If you think this is an error, please contact the bot's administrator." fi ;; # run when a message is seen *PRIVMSG*) # Only process if this is a user message (contains ! for hostmask) if [[ ! "$result" =~ :[^!]+!.*PRIVMSG ]]; then continue fi who="${result%%!*}" who="${who:1}" from="${result#*#}" from="${from%% *}" from="#${from:-${channels[0]}}" # Validate channel name format (IRC RFC 2812: must start with #, contain only valid chars) if [[ ! "$from" =~ ^#[a-zA-Z0-9_-]+$ ]]; then # Invalid channel, use default from="#${channels[0]}" fi # Trigger stuff happens here. # Call link trigger if msg contains a link: if [[ "$result" =~ .*http://|https://|www\..* ]]; then set -f echo "Calling link.sh with \"$who\" \"$from\" \"$logSanitized\"" >> "$log" ./triggers/link/link.sh "$who" "$from" "$result" set -f # Although this calls modules, it triggers on text other than the bot's nick # To make sure that modules are only called when they are supposed to be, had to combine string monipulation with regexp. elif [[ "${result#:*PRIVMSG*:}" =~ ^[${botCaller}][a-zA-Z0-9_].* ]]; then echo "DEBUG: Matched bot caller pattern" >> "$log" command="${result#*:[[:punct:]]}" command="${command//# /}" will="${command#* }" command="${command%% *}" # If will equals command, there were no arguments [[ "$will" == "$command" ]] && will="" willSanitized="${will//[$'\001'-$'\037'$'\177']/}" echo "DEBUG: command='$command' will='$willSanitized'" >> "$log" if command -v "./modules/${command% *}/${command% *}.sh" &>/dev/null ; then echo "Calling module ./modules/${command% *}/${command% *}.sh \"$who\" \"$from\" $willSanitized" >> "$log" # Disable wildcards set -f "./modules/${command% *}/${command% *}.sh" "$who" "$from" "$will" # Enable wildcards set +f else ./modules/say/say.sh "$who" "$from" "$who: $(shuf -n1 "response/error.txt")" fi else if ! [[ "$who" =~ ^($ignoreList)$ ]]; then set -f ./triggers/keywords/keywords.sh "$who" "$from" "$result" # Only call wordtrack for valid channel messages if [[ "$from" =~ ^#[a-zA-Z0-9_-]+$ ]]; then # Extract just the message text for wordtrack messageText="${result#*PRIVMSG*:}" ./triggers/wordtrack/wordtrack.sh "$who" "$from" "$messageText" fi set +f fi fi ;; *) echo "$logSanitized" >> "$log" ;; esac done # If we reach here, the connection was dropped # Check if this was an intentional exit if [[ "$intentionalExit" == "true" ]]; then echo "Bot shutdown requested. Exiting. [$(date "+$dateFormat")]" | tee -a "$log" break fi echo "Connection lost. Reconnecting in $reconnectDelay seconds... [$(date "+$dateFormat")]" | tee -a "$log" sleep "$reconnectDelay" done rm_input exit 0