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>
292 lines
9.9 KiB
Python
292 lines
9.9 KiB
Python
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
|
|
|
|
load_dotenv()
|
|
|
|
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("logs/bot.log", "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 = "logs/bot.log"
|
|
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 = "logs/bot.log"
|
|
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 = "logs/bot.log"
|
|
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)
|