Initial implementation of IRC LLM bot
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>
This commit is contained in:
291
portal/app.py
Normal file
291
portal/app.py
Normal file
@@ -0,0 +1,291 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user