import json import logging import os import sys from dotenv import load_dotenv from flask import Flask, abort, jsonify, redirect, render_template, request, url_for, send_file import io if os.path.isdir("/etc/irc-bot"): load_dotenv("/etc/irc-bot/.env") _LOG_PATH = "/var/log/irc-bot/bot.log" else: load_dotenv() _LOG_PATH = _LOG_PATH app = Flask(__name__, template_folder="templates", static_folder="static") app.secret_key = os.getenv("PORTAL_SECRET_KEY", "changeme") @app.template_filter("log_class") def log_class_filter(line: str) -> str: if "IRC IN:" in line: return "log-irc-in" if "IRC OUT:" in line: return "log-irc-out" if "[LLM]" in line: return "log-llm" if "[MEMORY]" in line: return "log-memory" if "[CONFIG]" in line: return "log-config" if "ERROR" in line: return "log-error" return "" logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) from portal import config_manager as cm from bot import memory as mem # ── Dashboard ────────────────────────────────────────────────────────────── @app.route("/") def index(): cfg = cm.load_config() pid_exists = os.path.exists("data/ircbot.pid") sock_exists = os.path.exists("data/ircbot.sock") return render_template("index.html", cfg=cfg, pid_exists=pid_exists, sock_exists=sock_exists) @app.route("/api/status") def api_status(): cfg = cm.load_config() pid_exists = os.path.exists("data/ircbot.pid") return jsonify({ "bot_running": pid_exists, "channels": cfg.get("channels", []), "ollama_model": cfg.get("ollama_model"), "bot_nick": cfg.get("bot_nick"), }) @app.route("/action/reload", methods=["POST"]) def action_reload(): cm.signal_bot_reload() return redirect(url_for("index")) @app.route("/action/reconnect", methods=["POST"]) def action_reconnect(): cm.signal_bot_reconnect() return redirect(url_for("index")) @app.route("/action/clear_log", methods=["POST"]) def action_clear_log(): try: open(_LOG_PATH, "w").close() except Exception: pass return redirect(url_for("logs")) # ── Channels ─────────────────────────────────────────────────────────────── @app.route("/channels") def channels(): cfg = cm.load_config() return render_template("channels.html", cfg=cfg) @app.route("/channels/add", methods=["POST"]) def channel_add(): ch = request.form.get("channel", "").strip() if not ch.startswith("#"): ch = "#" + ch cfg = cm.load_config() if ch not in cfg.get("channels", []): cfg.setdefault("channels", []).append(ch) cm.save_config(cfg) cm.signal_bot_reload() return redirect(url_for("channels")) @app.route("/channels/remove", methods=["POST"]) def channel_remove(): ch = request.form.get("channel", "").strip() cfg = cm.load_config() channels_list = cfg.get("channels", []) if ch in channels_list: channels_list.remove(ch) cfg["channels"] = channels_list cm.save_config(cfg) cm.signal_bot_reload() return redirect(url_for("channels")) # ── LLM Settings ─────────────────────────────────────────────────────────── @app.route("/llm", methods=["GET", "POST"]) def llm(): cfg = cm.load_config() errors = [] if request.method == "POST": try: cfg["ollama_host"] = request.form["ollama_host"].strip() cfg["ollama_port"] = int(request.form["ollama_port"]) cfg["ollama_model"] = request.form["ollama_model"].strip() cfg["system_prompt"] = request.form["system_prompt"] cfg["max_response_length"] = int(request.form["max_response_length"]) cfg["ollama_num_predict"] = int(request.form["ollama_num_predict"]) cfg["ollama_num_ctx"] = int(request.form["ollama_num_ctx"]) cfg["response_timeout_seconds"] = int(request.form["response_timeout_seconds"]) cfg["context_window"] = int(request.form["context_window"]) cfg["ollama_temperature"] = float(request.form["ollama_temperature"]) cfg["memory_enabled"] = "memory_enabled" in request.form cfg["memory_history_limit"] = int(request.form["memory_history_limit"]) cfg["memory_max_age_days"] = int(request.form["memory_max_age_days"]) cm.save_config(cfg) cm.signal_bot_reload() except (ValueError, KeyError) as e: errors.append(str(e)) return render_template("llm.html", cfg=cfg, errors=errors) # ── Bot Identity ──────────────────────────────────────────────────────────── @app.route("/bot", methods=["GET", "POST"]) def bot(): cfg = cm.load_config() errors = [] if request.method == "POST": cfg["bot_nick"] = request.form.get("bot_nick", "avcbot").strip() bot_realname = request.form.get("bot_realname", "").strip() if bot_realname: cfg["bot_realname"] = bot_realname cfg["trigger_on_nick"] = "trigger_on_nick" in request.form prefix = request.form.get("trigger_prefix", "").strip() cfg["trigger_prefix"] = prefix if prefix else None ignored = request.form.get("ignored_nicks", "") cfg["ignored_nicks"] = [n.strip() for n in ignored.split(",") if n.strip()] cm.save_config(cfg) cm.signal_bot_reload() return render_template("bot.html", cfg=cfg, errors=errors) # ── Logs ──────────────────────────────────────────────────────────────────── @app.route("/logs") def logs(): lines = [] log_path = _LOG_PATH if os.path.exists(log_path): with open(log_path, "r", encoding="utf-8", errors="replace") as f: all_lines = f.readlines() lines = all_lines[-200:] return render_template("logs.html", lines=lines) @app.route("/logs/download") def logs_download(): log_path = _LOG_PATH if not os.path.exists(log_path): abort(404) return send_file(log_path, as_attachment=True, download_name="bot.log") @app.route("/api/logs") def api_logs(): lines = [] log_path = _LOG_PATH if os.path.exists(log_path): with open(log_path, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines()[-200:] return jsonify({"lines": [l.rstrip() for l in lines]}) # ── Memory ───────────────────────────────────────────────────────────────── @app.route("/memory") def memory(): channels_list = mem.list_channels() selected_chan = request.args.get("channel") selected_nick = request.args.get("nick") nicks = [] exchanges = [] if selected_chan: nicks = mem.list_nicks(selected_chan) if selected_chan and selected_nick: exchanges = mem.get_all_exchanges(selected_chan, selected_nick) stats = mem.get_stats() return render_template( "memory.html", channels=channels_list, selected_chan=selected_chan, nicks=nicks, selected_nick=selected_nick, exchanges=exchanges, stats=stats, ) @app.route("/memory/clear_user", methods=["POST"]) def memory_clear_user(): chan = request.form.get("channel_dir", "") nick = request.form.get("nick", "") if chan and nick: mem.delete_user_history("#" + chan, nick) return redirect(url_for("memory", channel=chan)) @app.route("/memory/clear_channel", methods=["POST"]) def memory_clear_channel(): chan = request.form.get("channel_dir", "") if chan: mem.delete_channel_history("#" + chan) return redirect(url_for("memory")) @app.route("/memory/clear_all", methods=["POST"]) def memory_clear_all(): confirm = request.form.get("confirm", "") if confirm == "yes": mem.delete_all_history() return redirect(url_for("memory")) # ── Config editor ─────────────────────────────────────────────────────────── @app.route("/config", methods=["GET", "POST"]) def config_editor(): error = None success = None if request.method == "POST": raw = request.form.get("config_raw", "") try: parsed = json.loads(raw) cm.save_config(parsed) cm.signal_bot_reload() success = "Config saved and bot signaled to reload." except json.JSONDecodeError as e: error = f"Invalid JSON: {e}" else: parsed = cm.load_config() raw_json = json.dumps(cm.load_config(), indent=2) return render_template("config.html", raw_json=raw_json, error=error, success=success) @app.route("/config/download") def config_download(): return send_file("config/config.json", as_attachment=True, download_name="config.json") @app.route("/config/upload", methods=["POST"]) def config_upload(): f = request.files.get("config_file") if not f: return redirect(url_for("config_editor")) try: data = json.loads(f.read().decode()) cm.save_config(data) cm.signal_bot_reload() except Exception: pass return redirect(url_for("config_editor")) # ── Run ───────────────────────────────────────────────────────────────────── if __name__ == "__main__": port = int(os.getenv("PORTAL_PORT", 8080)) app.run(host="0.0.0.0", port=port, debug=False)