Files
irc-bot/portal/app.py
tocmo0nlord ff3f6fe05b Add Debian .deb package support
- debian/control, changelog, conffiles — package metadata
- debian/postinst — creates irc-bot system user, installs Python venv,
  symlinks runtime dirs, enables systemd services
- debian/prerm/postrm — clean stop/uninstall with purge support
- debian/systemd/ — hardened systemd units for bot and portal
- build_deb.sh — assembles and builds the .deb via dpkg-deb
- Path resolver in irc_client.py, memory.py, config_manager.py, portal/app.py:
  uses /etc/irc-bot + /var/lib/irc-bot when installed as .deb, relative
  paths otherwise (Docker/venv unchanged)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 22:16:05 -04:00

297 lines
10 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
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)