""" IRC bot entry point — connects to ZNC via TLS, handles the message loop, reconnect backoff, config reload (SIGHUP + Unix socket), and PID file. """ import json import logging import logging.handlers import os import re import signal import socket import ssl import sys import threading import time from pathlib import Path from dotenv import load_dotenv load_dotenv() # ── Logging ──────────────────────────────────────────────────────────────── os.makedirs("logs", exist_ok=True) os.makedirs("data", exist_ok=True) os.makedirs("config", exist_ok=True) handler = logging.handlers.RotatingFileHandler( "logs/bot.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8" ) handler.setFormatter( logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") ) logging.basicConfig( level=logging.INFO, handlers=[handler, logging.StreamHandler(sys.stdout)], ) logger = logging.getLogger(__name__) from bot import memory as mem from bot.message_handler import handle_privmsg # ── Config ───────────────────────────────────────────────────────────────── CONFIG_PATH = "config/config.json" PID_PATH = "data/ircbot.pid" SOCK_PATH = "data/ircbot.sock" _config: dict = {} _config_lock = threading.Lock() # Runtime state _sock: socket.socket | None = None _connected = False _session_msg_count = 0 _status = "disconnected" # disconnected | connecting | connected | reconnecting def _load_config() -> dict: defaults = { "channels": [], "trigger_on_nick": True, "trigger_prefix": None, "ignored_nicks": ["ChanServ", "NickServ"], "bot_nick": os.getenv("BOT_NICK", "avcbot"), "system_prompt": "You are a helpful IRC assistant for Active Blue. Keep responses concise and under 3 sentences when possible.", "max_response_length": 400, "ollama_host": os.getenv("OLLAMA_HOST", "192.168.2.10"), "ollama_port": int(os.getenv("OLLAMA_PORT", 11434)), "ollama_model": os.getenv("OLLAMA_MODEL", "llama3.1"), "ollama_temperature": 0.7, "ollama_num_predict": 120, "ollama_num_ctx": 2048, "response_timeout_seconds": 30, "context_window": 5, "memory_enabled": True, "memory_history_limit": 8, "memory_max_age_days": 90, "log_level": "INFO", } if os.path.exists(CONFIG_PATH): try: with open(CONFIG_PATH, "r") as f: file_cfg = json.load(f) defaults.update(file_cfg) logger.info("[CONFIG] Loaded config.json") except Exception as e: logger.error(f"[CONFIG] Failed to load config.json: {e}") return defaults def _reload_config() -> None: global _config new_cfg = _load_config() with _config_lock: _config = new_cfg level = logging.getLevelName(_config.get("log_level", "INFO")) logging.getLogger().setLevel(level) logger.info("[CONFIG] Reloaded") def get_config() -> dict: with _config_lock: return dict(_config) # ── PID + Unix socket ────────────────────────────────────────────────────── def _write_pid() -> None: with open(PID_PATH, "w") as f: f.write(str(os.getpid())) def _remove_pid() -> None: try: os.remove(PID_PATH) except FileNotFoundError: pass def _start_sock_listener() -> None: """Listens for RELOAD command on Unix socket (used by portal in Docker).""" if sys.platform == "win32": return # Unix sockets not supported on Windows try: if os.path.exists(SOCK_PATH): os.remove(SOCK_PATH) srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) srv.bind(SOCK_PATH) srv.listen(5) srv.settimeout(1) os.chmod(SOCK_PATH, 0o660) logger.info(f"[CONFIG] Unix socket listening at {SOCK_PATH}") def _loop(): while True: try: conn, _ = srv.accept() data = conn.recv(64).decode().strip() conn.close() if data == "RELOAD": _reload_config() elif data == "RECONNECT": _trigger_reconnect() except socket.timeout: continue except Exception as e: logger.error(f"[CONFIG] Socket error: {e}") t = threading.Thread(target=_loop, daemon=True) t.start() except Exception as e: logger.warning(f"[CONFIG] Could not start Unix socket: {e}") # ── SIGHUP handler (non-Docker) ──────────────────────────────────────────── _reconnect_flag = threading.Event() def _trigger_reconnect() -> None: _reconnect_flag.set() if sys.platform != "win32": signal.signal(signal.SIGHUP, lambda s, f: _reload_config()) # ── IRC helpers ──────────────────────────────────────────────────────────── def _send(sock: socket.socket, line: str) -> None: logger.debug(f"IRC OUT: {line}") sock.sendall((line + "\r\n").encode("utf-8", errors="replace")) def _join_channels(sock: socket.socket, channels: list[str]) -> None: for ch in channels: _send(sock, f"JOIN {ch}") logger.info(f"[IRC] Joining {ch}") PLAYBACK_RE = re.compile(r"^\[\d{2}:\d{2}:\d{2}\] ") def _is_playback(text: str) -> bool: return bool(PLAYBACK_RE.match(text)) def _parse_privmsg(line: str) -> tuple[str, str, str] | None: """Returns (nick, channel, text) or None.""" m = re.match(r"^:([^!]+)![^ ]+ PRIVMSG (#\S+) :(.+)$", line) if m: return m.group(1), m.group(2), m.group(3) return None # ── Connection ───────────────────────────────────────────────────────────── def _connect() -> socket.socket: global _status host = os.getenv("ZNC_HOST", "ham.activeblue.net") port = int(os.getenv("ZNC_PORT", 6501)) use_ssl = os.getenv("ZNC_SSL", "true").lower() == "true" znc_user = os.getenv("ZNC_USER", "") znc_password = os.getenv("ZNC_PASSWORD", "") znc_network = os.getenv("ZNC_NETWORK", "activeblue") bot_nick = get_config().get("bot_nick", os.getenv("BOT_NICK", "avcbot")) bot_realname = os.getenv("BOT_REALNAME", "Active Blue IRC Bot") _status = "connecting" logger.info(f"[IRC] Connecting to {host}:{port} (SSL={use_ssl})") raw = socket.create_connection((host, port), timeout=30) if use_ssl: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE sock = ctx.wrap_socket(raw, server_hostname=host) else: sock = raw _send(sock, f"NICK {bot_nick}") _send(sock, f"USER {bot_nick} 0 * :{bot_realname}") _send(sock, f"PASS {znc_user}/{znc_network}:{znc_password}") return sock # ── Main message loop ────────────────────────────────────────────────────── def _run_loop(sock: socket.socket) -> None: global _connected, _status, _session_msg_count buf = "" _connected = True _status = "connected" while True: if _reconnect_flag.is_set(): _reconnect_flag.clear() raise ConnectionResetError("Reconnect triggered") try: sock.settimeout(1) chunk = sock.recv(4096).decode("utf-8", errors="replace") except socket.timeout: continue except Exception: raise if not chunk: raise ConnectionResetError("Remote closed connection") buf += chunk while "\r\n" in buf: line, buf = buf.split("\r\n", 1) # Strip IRCv3 server-time tag (clientbuffer playback) is_tagged_playback = False if line.startswith("@time="): is_tagged_playback = True line = re.sub(r"^@[^ ]+ ", "", line) logger.debug(f"IRC IN: {line}") if line.startswith("PING"): _send(sock, "PONG" + line[4:]) continue if " 001 " in line: logger.info("[IRC] Connected — joining channels") cfg = get_config() _join_channels(sock, cfg.get("channels", [])) continue if " 433 " in line: bot_nick = get_config().get("bot_nick", "avcbot") _send(sock, f"NICK {bot_nick}_") logger.warning("[IRC] Nick in use, trying alternate") continue parsed = _parse_privmsg(line) if not parsed: continue nick, channel, text = parsed if is_tagged_playback or _is_playback(text): # Add to context buffer but don't send to LLM from bot.message_handler import _get_context cfg = get_config() ctx = _get_context(channel, cfg.get("context_window", 5)) ctx.append(f"<{nick}> {text}") continue cfg = get_config() _session_msg_count += 1 reply = handle_privmsg(nick, channel, text, cfg) if reply: _send(sock, f"PRIVMSG {channel} :{reply}") logger.info(f"IRC OUT: PRIVMSG {channel} :{reply[:80]}") def get_status() -> dict: cfg = get_config() return { "status": _status, "nick": cfg.get("bot_nick", "avcbot"), "znc_host": os.getenv("ZNC_HOST", "ham.activeblue.net"), "znc_port": os.getenv("ZNC_PORT", "6501"), "znc_network": os.getenv("ZNC_NETWORK", "activeblue"), "ollama_host": cfg.get("ollama_host"), "ollama_port": cfg.get("ollama_port"), "ollama_model": cfg.get("ollama_model"), "channels": cfg.get("channels", []), "session_msg_count": _session_msg_count, } def send_raw(line: str) -> None: global _sock if _sock and _connected: _send(_sock, line) # ── Entry point ──────────────────────────────────────────────────────────── def main() -> None: global _sock, _connected, _status _reload_config() cfg = get_config() mem.prune_old_exchanges(cfg.get("memory_max_age_days", 90)) _write_pid() _start_sock_listener() backoff = [5, 10, 30, 60, 120, 300] attempt = 0 while True: try: _sock = _connect() attempt = 0 _run_loop(_sock) except (ConnectionResetError, ConnectionRefusedError, OSError) as e: _connected = False _status = "reconnecting" logger.warning(f"[IRC] Disconnected: {e}") except Exception as e: _connected = False _status = "reconnecting" logger.error(f"[IRC] Unexpected error: {e}", exc_info=True) finally: if _sock: try: _sock.close() except Exception: pass _sock = None delay = backoff[min(attempt, len(backoff) - 1)] attempt += 1 logger.info(f"[IRC] Reconnecting in {delay}s (attempt {attempt})") time.sleep(delay) if __name__ == "__main__": try: main() except KeyboardInterrupt: logger.info("[IRC] Shutting down") _remove_pid()