Full implementation from spec: ZNC/IRC client with TLS, Ollama LLM backend, per-user SQLite conversation memory, and Flask web admin portal with 7 pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
373 lines
12 KiB
Python
373 lines
12 KiB
Python
"""
|
|
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()
|