From 9095229218cf0ab574792a3f000f5e3e90e3381e Mon Sep 17 00:00:00 2001 From: InfernoXuaI <1391197588@qq.com> Date: Fri, 22 May 2026 14:45:50 +0800 Subject: [PATCH] Add safer web update flow, group-monitor AI controls, and monitor observability --- README.md | 38 +- app.py | 795 +++++++++++++++++++++++++- config.example.yaml | 10 + tests/test_monitor_message_cleanup.py | 199 +++++++ 4 files changed, 1015 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index d647d9e..84eae81 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 ``` ## 更新日志 +### 2026-05-22 更新 + +- TG 群监听功能增强:支持可视化配置监听规则、AI 总结参数与防刷屏策略。 +- 新增 `/update` 安全更新流程:显示本地/远端 commit、ahead/behind、工作区状态;仅允许 `ff-only` 更新。 +- 更新前若检测到本地未提交改动,会拒绝更新;避免覆盖本地代码。 +- 新增“回滚上次更新”按钮:更新前自动记录回滚点,可一键回滚并重启。 +- TG 群监听 AI 总结新增可视化高级控制:`ai_prompt`、`ai_min_interval_seconds`、`ai_dedupe_window_seconds`。 +- TG 群监听增加限频和去重窗口,降低重复推送与 AI 调用成本;AI 失败时仍会回退模板摘要。 +- 监控面板新增可观测状态:最近成功/失败时间、最近错误、耗时、推送数、连续失败次数。 + ### 2026-05-21 第二次更新 - Web 面板新增收件箱直接回复、用户管理、快捷回复、私聊广告拦截、监控推送历史、配置导入/导出。 @@ -127,7 +137,6 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 - 如果要把面板暴露到公网,建议使用 Cloudflare Access / 反代鉴权,并使用强密码。 - Bot 只能给“已经主动私聊过 Bot 的用户”发消息,这是 Telegram Bot API 的限制。 - ## 快速开始 @@ -157,6 +166,9 @@ docker compose logs -f docker compose restart ``` + +## 手动安装(Python) + ```bash git clone https://github.com/GongyiChuren/tg-watchbot.git tg-watchbot cd tg-watchbot @@ -321,11 +333,35 @@ group_monitors: exclude_keywords: - 求带 notify_telegram: true + summary_mode: template + ai_base_url: "" + ai_api_key: "" + ai_model: gpt-4o-mini + ai_interface: responses + ai_temperature: 0.2 + ai_timeout_seconds: 30 + ai_prompt: "" + ai_min_interval_seconds: 30 + ai_dedupe_window_seconds: 300 ``` - 命中 `keywords` 且未命中 `exclude_keywords` 时,会给管理员发送摘要。 +- `summary_mode` 支持: + - `template`:固定模板摘要(默认) + - `ai`:调用 AI 生成摘要(在 TG 群监听页面可视化配置) +- `ai_prompt` 可填自定义总结提示词;留空使用内置默认提示词。 +- `ai_interface` 支持: + - `responses`:`/v1/responses` + - `chat`:`/v1/chat/completions` +- `ai_min_interval_seconds`:同一个群监听最小推送间隔(防刷屏) +- `ai_dedupe_window_seconds`:相同内容摘要去重窗口(防重复) - 机器人想收到群里普通消息,需要在 `@BotFather` 执行 `/setprivacy` 关闭隐私模式。 +更新代码(`/update`)已支持安全检查: +- 显示本地/远端 commit、ahead/behind、工作区是否干净 +- 只允许 `ff-only` 更新,工作区有未提交改动会拒绝更新 +- 自动记录上次更新前的回滚点,并支持一键回滚 + 监控数据自动清理示例: ```yaml diff --git a/app.py b/app.py index 9e320dd..f1562e6 100644 --- a/app.py +++ b/app.py @@ -17,6 +17,7 @@ import os import re import secrets import signal +import subprocess import sqlite3 import time from contextlib import closing @@ -50,6 +51,8 @@ ENV_PATH = BASE_DIR / ".env" LOG_PATH = BASE_DIR / "tg-watchbot.log" MIN_INTERVAL_SECONDS = 60 DEFAULT_MONITOR_MESSAGE_DELETE_AFTER_MINUTES = 60 +DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS = 30 +DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS = 300 DEFAULT_UA = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " @@ -183,6 +186,32 @@ def init_db() -> None: pushed INTEGER DEFAULT 0, created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS monitor_runtime_status ( + monitor_name TEXT PRIMARY KEY, + last_run_at TEXT, + last_success_at TEXT, + last_error_at TEXT, + last_error TEXT, + last_duration_ms INTEGER DEFAULT 0, + last_sent_count INTEGER DEFAULT 0, + consecutive_failures INTEGER DEFAULT 0, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS group_monitor_recent ( + monitor_name TEXT NOT NULL, + fingerprint TEXT NOT NULL, + sent_at_ts REAL NOT NULL, + PRIMARY KEY (monitor_name, fingerprint) + ); + CREATE TABLE IF NOT EXISTS group_monitor_last_send ( + monitor_name TEXT PRIMARY KEY, + sent_at_ts REAL NOT NULL + ); + CREATE TABLE IF NOT EXISTS app_meta ( + meta_key TEXT PRIMARY KEY, + meta_value TEXT, + updated_at TEXT NOT NULL + ); """ ) for sql in [ @@ -373,6 +402,17 @@ def spam_keyword_hits(text: str) -> list[str]: return keyword_hits(text, settings["keywords"]) +def ai_api_url(base_url: str, path: str) -> str: + base = base_url.strip().rstrip("/") + if not base: + return path + if base.endswith("/v1"): + return base + path + if path.startswith("/v1"): + return base + path + return base + "/v1" + path + + def group_monitors() -> list[dict[str, Any]]: if not isinstance(config, dict): return [] @@ -389,15 +429,31 @@ def group_monitors() -> list[dict[str, Any]]: chat_id = int(row.get("chat_id")) except (TypeError, ValueError): continue + summary_mode = str(row.get("summary_mode") or "template").strip().lower() + if summary_mode not in {"template", "ai"}: + summary_mode = "template" + ai_interface = str(row.get("ai_interface") or "responses").strip().lower() or "responses" + if ai_interface not in {"responses", "chat"}: + ai_interface = "responses" keywords = [str(k).strip() for k in (row.get("keywords") or []) if str(k).strip()] exclude_keywords = [str(k).strip() for k in (row.get("exclude_keywords") or []) if str(k).strip()] monitors.append( { "name": str(row.get("name") or str(chat_id)), "chat_id": chat_id, + "summary_mode": summary_mode, "keywords": keywords, "exclude_keywords": exclude_keywords, "notify_telegram": bool(row.get("notify_telegram", True)), + "ai_base_url": str(row.get("ai_base_url") or "").strip(), + "ai_api_key": str(row.get("ai_api_key") or "").strip(), + "ai_model": str(row.get("ai_model") or "gpt-4o-mini").strip(), + "ai_interface": ai_interface, + "ai_temperature": safe_float(row.get("ai_temperature", 0.2), 0.2), + "ai_timeout_seconds": max(1, safe_int(row.get("ai_timeout_seconds", 30), 30)), + "ai_prompt": str(row.get("ai_prompt") or "").strip(), + "ai_min_interval_seconds": max(0, safe_int(row.get("ai_min_interval_seconds", DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS), DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS)), + "ai_dedupe_window_seconds": max(0, safe_int(row.get("ai_dedupe_window_seconds", DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS), DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS)), } ) return monitors @@ -419,6 +475,37 @@ def group_message_text(message: Message) -> str: return merged.strip() +def group_message_context(message: Message, monitor: dict[str, Any], hits: list[str]) -> str: + chat_title = getattr(message.chat, "title", "") or str(message.chat.id) + username = getattr(message.from_user, "username", None) + user_full = " ".join( + x for x in [getattr(message.from_user, "first_name", ""), getattr(message.from_user, "last_name", "")] if x + ).strip() or str(getattr(message.from_user, "id", "unknown")) + sender = f"{user_full} (@{username})" if username else user_full + reply_text = "" + if getattr(message, "reply_to_message", None): + reply = message.reply_to_message + reply_text = group_message_text(reply)[:GROUP_SUMMARY_MAX_CHARS] + text = group_message_text(message) + if len(text) > GROUP_SUMMARY_MAX_CHARS: + text = text[:GROUP_SUMMARY_MAX_CHARS] + "..." + return ( + f"群名: {chat_title}\n" + f"群ID: {message.chat.id}\n" + f"群用户名: @{getattr(message.chat, 'username', '') or ''}\n" + f"发送者: {sender}\n" + f"发送者ID: {getattr(message.from_user, 'id', 'unknown')}\n" + f"消息ID: {message.message_id}\n" + f"时间: {now_iso()}\n" + f"命中关键词: {', '.join(hits) or '-'}\n" + f"消息类型: {message.content_type}\n" + f"正文:\n{text or '(非文本消息)'}\n" + f"回复引用:\n{reply_text or '-'}\n" + f"链接: {telegram_message_link(getattr(message.chat, 'username', None), int(message.chat.id), int(message.message_id))}\n" + f"监听名称: {monitor.get('name') or chat_title}" + ) + + def telegram_message_link(chat_username: str | None, chat_id: int, message_id: int) -> str: if chat_username: return f"https://t.me/{chat_username}/{message_id}" @@ -428,7 +515,99 @@ def telegram_message_link(chat_username: str | None, chat_id: int, message_id: i return f"chat_id={chat_id} message_id={message_id}" -def build_group_summary(message: Message, monitor: dict[str, Any], hits: list[str]) -> str: +def extract_responses_text(data: dict[str, Any]) -> str: + text = data.get("output_text") + if isinstance(text, str) and text.strip(): + return text.strip() + chunks: list[str] = [] + for item in data.get("output") or []: + if not isinstance(item, dict): + continue + for part in item.get("content") or []: + if not isinstance(part, dict): + continue + value = part.get("text") or part.get("content") + if isinstance(value, str) and value.strip(): + chunks.append(value.strip()) + return "\n".join(chunks).strip() + + +def extract_chat_text(data: dict[str, Any]) -> str: + choices = data.get("choices") or [] + if not choices: + return "" + message = choices[0].get("message") or {} + content = message.get("content") + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + chunks: list[str] = [] + for part in content: + if not isinstance(part, dict): + continue + value = part.get("text") or part.get("content") + if isinstance(value, str) and value.strip(): + chunks.append(value.strip()) + return "\n".join(chunks).strip() + return "" + + +def build_group_ai_system_prompt(custom_prompt: str) -> str: + base = ( + "你是 Telegram 群消息摘要助手。" + "请用简体中文输出一段给管理员看的消息摘要,尽量完整,保留群名、发送者、命中词、正文关键内容、时间和链接。" + "不要编造,不要添加未出现的信息。" + "如果正文很长,请压缩成可快速扫读的摘要,但不要丢掉数字、价格、链接、联系方式和结论。" + ) + custom = (custom_prompt or "").strip() + if not custom: + return base + return base + "\n补充要求:\n" + custom + + +def build_group_ai_prompt(message: Message, monitor: dict[str, Any], hits: list[str]) -> tuple[str, str]: + system = build_group_ai_system_prompt(str(monitor.get("ai_prompt") or "")) + user = group_message_context(message, monitor, hits) + return system, user + + +async def summarize_group_message_ai(message: Message, monitor: dict[str, Any], hits: list[str]) -> str | None: + ai_base_url = str(monitor.get("ai_base_url") or "").strip() + ai_api_key = str(monitor.get("ai_api_key") or "").strip() + ai_model = str(monitor.get("ai_model") or "gpt-4o-mini").strip() + ai_interface = str(monitor.get("ai_interface") or "responses").strip().lower() + ai_timeout = max(1, int(monitor.get("ai_timeout_seconds") or 30)) + ai_temperature = float(monitor.get("ai_temperature") or 0.2) + if not ai_base_url or not ai_api_key or not ai_model: + return None + system_prompt, user_prompt = build_group_ai_prompt(message, monitor, hits) + headers = {"Authorization": f"Bearer {ai_api_key}", "Content-Type": "application/json"} + async with httpx.AsyncClient(timeout=ai_timeout, headers=headers) as client: + if ai_interface == "chat": + payload = { + "model": ai_model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": ai_temperature, + } + resp = await client.post(ai_api_url(ai_base_url, "/chat/completions"), json=payload) + resp.raise_for_status() + return extract_chat_text(resp.json()) + payload = { + "model": ai_model, + "instructions": system_prompt, + "input": user_prompt, + "temperature": ai_temperature, + "max_output_tokens": 300, + } + resp = await client.post(ai_api_url(ai_base_url, "/responses"), json=payload) + resp.raise_for_status() + return extract_responses_text(resp.json()) + + +def summarize_group_message_template(message: Message, monitor: dict[str, Any], hits: list[str]) -> str: chat_title = getattr(message.chat, "title", "") or str(message.chat.id) username = getattr(message.from_user, "username", None) user_full = " ".join( @@ -438,18 +617,100 @@ def build_group_summary(message: Message, monitor: dict[str, Any], hits: list[st text = group_message_text(message) if len(text) > GROUP_SUMMARY_MAX_CHARS: text = text[:GROUP_SUMMARY_MAX_CHARS] + "..." - link = telegram_message_link(getattr(message.chat, "username", None), int(message.chat.id), int(message.message_id)) return ( f"[群关键词命中] {html_escape(str(monitor.get('name') or chat_title))}\n" f"群:{html_escape(chat_title)} ({message.chat.id})\n" f"发送者:{html_escape(sender)}\n" f"命中:{html_escape(', '.join(hits))}\n" f"时间:{html_escape(now_iso())}\n" - f"链接:{html_escape(link)}\n" + f"链接:{html_escape(telegram_message_link(getattr(message.chat, 'username', None), int(message.chat.id), int(message.message_id)))}\n" f"内容:\n{html_escape(text or '(非文本消息)')}" ) +async def summarize_group_message(message: Message, monitor: dict[str, Any], hits: list[str]) -> str: + if str(monitor.get("summary_mode") or "template").strip().lower() != "ai": + return summarize_group_message_template(message, monitor, hits) + try: + text = await summarize_group_message_ai(message, monitor, hits) + if text: + return f"[群AI总结] {html_escape(str(monitor.get('name') or message.chat.id))}\n{html_escape(text.strip())}" + logger.warning("group ai summary returned empty result chat_id=%s message_id=%s", message.chat.id, message.message_id) + except Exception: + logger.exception("group ai summary failed chat_id=%s message_id=%s", message.chat.id, message.message_id) + return "[群AI总结失败,已使用模板]\n" + summarize_group_message_template(message, monitor, hits) + + +def build_group_summary(message: Message, monitor: dict[str, Any], hits: list[str]) -> str: + return summarize_group_message_template(message, monitor, hits) + + +def group_monitor_interval_seconds(monitor: dict[str, Any]) -> int: + return max(0, safe_int(monitor.get("ai_min_interval_seconds"), DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS)) + + +def group_monitor_dedupe_window_seconds(monitor: dict[str, Any]) -> int: + return max(0, safe_int(monitor.get("ai_dedupe_window_seconds"), DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS)) + + +def group_monitor_fingerprint(message: Message, monitor: dict[str, Any], hits: list[str]) -> str: + payload = "|".join( + [ + str(monitor.get("name") or ""), + str(message.chat.id), + str(getattr(message.from_user, "id", "")), + ",".join(sorted(hits)), + group_message_text(message)[:300], + ] + ) + return hashlib.sha256(payload.encode("utf-8", errors="ignore")).hexdigest() + + +def group_monitor_allow_send(monitor: dict[str, Any], fingerprint: str, now_ts: float | None = None) -> tuple[bool, str]: + ts = time.time() if now_ts is None else float(now_ts) + name = str(monitor.get("name") or monitor.get("chat_id") or "group-monitor") + min_interval = group_monitor_interval_seconds(monitor) + dedupe_window = group_monitor_dedupe_window_seconds(monitor) + with closing(db()) as conn: + if min_interval > 0: + row = conn.execute( + "SELECT sent_at_ts FROM group_monitor_last_send WHERE monitor_name=?", + (name,), + ).fetchone() + if row and (ts - float(row["sent_at_ts"]) < min_interval): + return False, f"min-interval({min_interval}s)" + if dedupe_window > 0: + row = conn.execute( + "SELECT sent_at_ts FROM group_monitor_recent WHERE monitor_name=? AND fingerprint=?", + (name, fingerprint), + ).fetchone() + if row and (ts - float(row["sent_at_ts"]) < dedupe_window): + return False, f"dedupe({dedupe_window}s)" + if dedupe_window > 0: + conn.execute( + "DELETE FROM group_monitor_recent WHERE sent_at_ts < ?", + (ts - dedupe_window,), + ) + conn.execute( + """ + INSERT INTO group_monitor_recent(monitor_name, fingerprint, sent_at_ts) + VALUES(?,?,?) + ON CONFLICT(monitor_name, fingerprint) DO UPDATE SET sent_at_ts=excluded.sent_at_ts + """, + (name, fingerprint, ts), + ) + conn.execute( + """ + INSERT INTO group_monitor_last_send(monitor_name, sent_at_ts) + VALUES(?,?) + ON CONFLICT(monitor_name) DO UPDATE SET sent_at_ts=excluded.sent_at_ts + """, + (name, ts), + ) + conn.commit() + return True, "" + + async def handle_group_keyword_message(message: Message) -> bool: monitor = group_monitor_for_chat(int(message.chat.id)) if not monitor: @@ -465,7 +726,18 @@ async def handle_group_keyword_message(message: Message) -> bool: return False if not monitor.get("notify_telegram", True): return True - await admin_send(build_group_summary(message, monitor, hits)) + fp = group_monitor_fingerprint(message, monitor, hits) + allow, reason = group_monitor_allow_send(monitor, fp) + if not allow: + logger.info( + "group monitor skipped by limiter monitor=%s chat_id=%s message_id=%s reason=%s", + monitor.get("name"), + message.chat.id, + message.message_id, + reason, + ) + return False + await admin_send(await summarize_group_message(message, monitor, hits)) return True @@ -497,6 +769,77 @@ def record_monitor_event(monitor_name: str, title: str, link: str, reasons: list conn.commit() +def record_monitor_runtime( + monitor_name: str, + ok: bool, + duration_ms: int, + sent_count: int, + error: str = "", +) -> None: + now = now_iso() + with closing(db()) as conn: + row = conn.execute( + "SELECT consecutive_failures FROM monitor_runtime_status WHERE monitor_name=?", + (monitor_name,), + ).fetchone() + prev_failures = int(row["consecutive_failures"]) if row else 0 + failures = 0 if ok else (prev_failures + 1) + conn.execute( + """ + INSERT INTO monitor_runtime_status( + monitor_name, last_run_at, last_success_at, last_error_at, last_error, + last_duration_ms, last_sent_count, consecutive_failures, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?) + ON CONFLICT(monitor_name) DO UPDATE SET + last_run_at=excluded.last_run_at, + last_success_at=CASE WHEN excluded.last_success_at IS NOT NULL THEN excluded.last_success_at ELSE monitor_runtime_status.last_success_at END, + last_error_at=CASE WHEN excluded.last_error_at IS NOT NULL THEN excluded.last_error_at ELSE monitor_runtime_status.last_error_at END, + last_error=CASE WHEN excluded.last_error != '' THEN excluded.last_error ELSE monitor_runtime_status.last_error END, + last_duration_ms=excluded.last_duration_ms, + last_sent_count=excluded.last_sent_count, + consecutive_failures=excluded.consecutive_failures, + updated_at=excluded.updated_at + """, + ( + monitor_name, + now, + now if ok else None, + now if not ok else None, + "" if ok else error[:1000], + max(0, int(duration_ms)), + max(0, int(sent_count)), + failures, + now, + ), + ) + conn.commit() + + +def list_monitor_runtime_status() -> dict[str, dict[str, Any]]: + with closing(db()) as conn: + rows = conn.execute("SELECT * FROM monitor_runtime_status").fetchall() + output: dict[str, dict[str, Any]] = {} + for row in rows: + output[str(row["monitor_name"])] = { + "last_run_at": row["last_run_at"], + "last_success_at": row["last_success_at"], + "last_error_at": row["last_error_at"], + "last_error": row["last_error"] or "", + "last_duration_ms": int(row["last_duration_ms"] or 0), + "last_sent_count": int(row["last_sent_count"] or 0), + "consecutive_failures": int(row["consecutive_failures"] or 0), + } + return output + + +def get_monitor_status_badge(status: dict[str, Any] | None) -> str: + if not status: + return "未运行" + if int(status.get("consecutive_failures", 0)) > 0: + return f"异常 x{int(status.get('consecutive_failures', 0))}" + return "正常" + + def lookup_reply_target(admin_chat: int, admin_message_id: int) -> int | None: with closing(db()) as conn: row = conn.execute( @@ -1139,8 +1482,10 @@ async def run_monitor(monitor: dict[str, Any]) -> int: name = monitor.get("name", "unnamed") mtype = monitor.get("type", "web") url = monitor.get("url") + started = time.time() if not url: logger.error("monitor %s missing url", name) + record_monitor_runtime(name, ok=False, duration_ms=int((time.time() - started) * 1000), sent_count=0, error="missing url") return 0 keywords = monitor.get("keywords") or [] timeout = int((config.get("http") or {}).get("timeout_seconds", 20)) @@ -1198,8 +1543,10 @@ async def run_monitor(monitor: dict[str, Any]) -> int: continue if await admin_send_monitor(text, name): sent_count += 1 - except Exception: + record_monitor_runtime(name, ok=True, duration_ms=int((time.time() - started) * 1000), sent_count=sent_count) + except Exception as e: logger.exception("monitor failed: %s %s", name, url) + record_monitor_runtime(name, ok=False, duration_ms=int((time.time() - started) * 1000), sent_count=sent_count, error=str(e)) return sent_count @@ -1345,13 +1692,34 @@ def login_page(error: str = "") -> str: 登录 · tg-watchbot

tg-watchbot

登录后管理 Telegram 机器人、关键词监控和提醒。

{err}
localhost panel
""" @@ -1420,11 +1788,33 @@ def cfg_save(new_cfg: dict[str, Any]) -> None: if not isinstance(gm, dict): raise ValueError("每个 group_monitor 必须是对象") if "chat_id" in gm: - gm["chat_id"] = int(gm["chat_id"]) + gm["chat_id"] = safe_int(gm["chat_id"], 0) gm.setdefault("enabled", True) gm.setdefault("keywords", []) gm.setdefault("exclude_keywords", []) gm.setdefault("notify_telegram", True) + summary_mode = str(gm.get("summary_mode") or "template").strip().lower() or "template" + if summary_mode not in {"template", "ai"}: + summary_mode = "template" + gm["summary_mode"] = summary_mode + gm["ai_base_url"] = str(gm.get("ai_base_url") or "").strip() + gm["ai_api_key"] = str(gm.get("ai_api_key") or "").strip() + gm["ai_model"] = str(gm.get("ai_model") or "gpt-4o-mini").strip() + ai_interface = str(gm.get("ai_interface") or "responses").strip().lower() or "responses" + if ai_interface not in {"responses", "chat"}: + ai_interface = "responses" + gm["ai_interface"] = ai_interface + gm["ai_temperature"] = safe_float(gm.get("ai_temperature", 0.2), 0.2) + gm["ai_timeout_seconds"] = max(1, safe_int(gm.get("ai_timeout_seconds", 30), 30)) + gm["ai_prompt"] = str(gm.get("ai_prompt") or "").strip() + gm["ai_min_interval_seconds"] = max( + 0, + safe_int(gm.get("ai_min_interval_seconds", DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS), DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS), + ) + gm["ai_dedupe_window_seconds"] = max( + 0, + safe_int(gm.get("ai_dedupe_window_seconds", DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS), DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS), + ) CONFIG_PATH.write_text(yaml.safe_dump(new_cfg, allow_unicode=True, sort_keys=False), encoding="utf-8") global config config = new_cfg @@ -1443,6 +1833,82 @@ def parse_lines(text: str) -> list[str]: return [x.strip() for x in (text or "").splitlines() if x.strip()] +def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def safe_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def app_meta_get(key: str) -> str: + with closing(db()) as conn: + row = conn.execute("SELECT meta_value FROM app_meta WHERE meta_key=?", (key,)).fetchone() + return str(row["meta_value"]) if row and row["meta_value"] is not None else "" + + +def app_meta_set(key: str, value: str) -> None: + with closing(db()) as conn: + conn.execute( + """ + INSERT INTO app_meta(meta_key, meta_value, updated_at) + VALUES(?,?,?) + ON CONFLICT(meta_key) DO UPDATE SET + meta_value=excluded.meta_value, + updated_at=excluded.updated_at + """, + (key, value, now_iso()), + ) + conn.commit() + + +def git_run(repo_dir: Path, args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repo_dir), *args], + capture_output=True, + text=True, + check=check, + ) + + +def current_git_branch(repo_dir: Path) -> str: + try: + return git_run(repo_dir, ["rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip() or "main" + except Exception: + return "main" + + +def git_update_status(repo_dir: Path, branch: str, fetch_remote: bool = True) -> dict[str, Any]: + if fetch_remote: + git_run(repo_dir, ["fetch", "origin", branch], check=True) + head = git_run(repo_dir, ["rev-parse", "HEAD"]).stdout.strip() + remote_ref = f"origin/{branch}" + remote_head = git_run(repo_dir, ["rev-parse", remote_ref]).stdout.strip() + counts = git_run(repo_dir, ["rev-list", "--left-right", "--count", f"HEAD...{remote_ref}"]).stdout.strip().split() + ahead = safe_int(counts[0] if len(counts) > 0 else 0, 0) + behind = safe_int(counts[1] if len(counts) > 1 else 0, 0) + dirty = bool(git_run(repo_dir, ["status", "--porcelain"], check=True).stdout.strip()) + return { + "branch": branch, + "head": head, + "remote_head": remote_head, + "ahead": ahead, + "behind": behind, + "dirty": dirty, + } + + +def rollback_to_commit(repo_dir: Path, commit: str) -> None: + git_run(repo_dir, ["cat-file", "-e", f"{commit}^{{commit}}"], check=True) + git_run(repo_dir, ["reset", "--hard", commit], check=True) + + def monitor_from_form( original_index: int | None, name: str, @@ -1498,15 +1964,85 @@ def layout(title: str, body: str) -> str: {html_escape(title)} · tg-watchbot

{html_escape(title)}

WatchBot Panel
+:root{{--canvas:#f0f0f0;--ink:#121212;--muted:#5c5c5c;--red:#d02020;--blue:#1040c0;--yellow:#f0c020;--white:#fff;--gray:#e0e0e0;--ease:cubic-bezier(.2,.8,.2,1)}} +*{{box-sizing:border-box}} +body{{font-family:Outfit,Aptos,'Segoe UI',sans-serif;background:var(--canvas);color:var(--ink);margin:0;letter-spacing:0;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}} +body:before{{content:"";position:fixed;right:-70px;top:110px;width:190px;height:190px;border:4px solid var(--ink);border-radius:50%;background:var(--yellow);z-index:-1;animation:floatA 7s var(--ease) infinite alternate}} +body:after{{content:"";position:fixed;left:190px;bottom:-80px;width:190px;height:190px;border:4px solid var(--ink);background:var(--blue);transform:rotate(45deg);z-index:-1;animation:floatB 8s var(--ease) infinite alternate}} +a{{color:var(--ink);text-decoration:none}} +a:hover{{text-decoration:underline}} +.shell{{display:grid;grid-template-columns:254px minmax(0,1fr);min-height:100vh}} +aside{{border-right:4px solid var(--ink);background:var(--white);padding:18px 14px;position:sticky;top:0;height:100vh;overflow:auto;overscroll-behavior:contain}} +main{{padding:24px 30px;min-width:0;max-width:1440px;animation:mainIn .25s var(--ease)}} +.brand{{display:flex;gap:10px;align-items:center;margin-bottom:18px;padding:0 4px 16px;border-bottom:4px solid var(--ink)}} +.mark{{width:44px;height:44px;border:4px solid var(--ink);background:var(--white);position:relative;box-shadow:4px 4px 0 var(--ink);flex:0 0 auto;transition:transform .2s var(--ease);will-change:transform}} +.mark:before{{content:"";position:absolute;left:5px;top:5px;width:13px;height:13px;border:3px solid var(--ink);border-radius:50%;background:var(--red)}} +.mark:after{{content:"";position:absolute;right:4px;top:5px;width:13px;height:13px;border:3px solid var(--ink);background:var(--blue)}} +.mark i{{position:absolute;left:8px;bottom:4px;width:25px;height:18px;background:var(--yellow);border:3px solid var(--ink);clip-path:polygon(50% 0,0 100%,100% 100%)}} +.brand:hover .mark{{transform:translateY(-1px)}} +.brand b{{font-size:18px;color:var(--ink);font-weight:900;text-transform:uppercase}} +.brand small{{display:block;color:var(--muted);margin-top:2px;font-weight:700}} +nav{{display:grid;gap:13px}} +nav section{{display:grid;gap:6px;padding:9px;border:3px solid var(--ink);background:#fff;box-shadow:3px 3px 0 var(--ink);transition:transform .18s var(--ease);contain:paint}} +nav section:hover{{transform:translateY(-1px)}} +nav section>b{{display:inline-block;width:max-content;margin:-20px 0 2px -2px;padding:3px 8px;border:3px solid var(--ink);background:var(--yellow);font-size:12px;font-weight:900;text-transform:uppercase}} +nav a{{position:relative;padding:9px 10px;border:3px solid var(--ink);background:var(--white);color:var(--ink);font-weight:900;text-transform:uppercase;font-size:12px;box-shadow:2px 2px 0 var(--ink);transition:transform .14s var(--ease),box-shadow .14s var(--ease),background-color .14s var(--ease);will-change:transform}} +nav section:nth-child(2)>b{{background:var(--blue);color:white}} +nav section:nth-child(3)>b{{background:var(--red);color:white}} +nav section:nth-child(4)>b{{background:var(--gray)}} +nav a:hover{{text-decoration:none;transform:translate(-1px,-1px);box-shadow:4px 4px 0 var(--ink)}} +nav a:active{{transform:translate(1px,1px);box-shadow:1px 1px 0 var(--ink)}} +.logout{{background:var(--red)!important;color:white}} +.top{{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:20px;border-bottom:4px solid var(--ink);padding-bottom:14px}} +.top h1{{margin:0;font-size:34px;line-height:.95;color:var(--ink);font-weight:900;text-transform:uppercase}} +.top .badge{{background:var(--blue);color:white}} +.btn{{background:var(--white);color:var(--ink);padding:7px 11px;border:3px solid var(--ink);border-radius:0;display:inline-block;cursor:pointer;font-weight:900;line-height:1.35;text-transform:uppercase;font-size:12px;box-shadow:3px 3px 0 var(--ink);transition:transform .14s var(--ease),box-shadow .14s var(--ease),background-color .14s var(--ease);will-change:transform}} +.btn:hover{{text-decoration:none;transform:translate(-1px,-1px);box-shadow:5px 5px 0 var(--ink)}} +.btn:active{{transform:translate(2px,2px);box-shadow:1px 1px 0 var(--ink)}} +.btn.primary{{background:var(--red);color:white}} +.btn.danger{{background:var(--red);color:white}} +.btn.ok{{background:var(--yellow);color:var(--ink)}} +.actions{{display:flex;gap:8px;align-items:center;flex-wrap:wrap}} +.card{{position:relative;background:var(--white);border:4px solid var(--ink);border-radius:0;padding:18px;margin:16px 0;box-shadow:8px 8px 0 var(--ink);transition:transform .2s var(--ease);contain:paint}} +.card:hover{{transform:translateY(-1px)}} +.card:after{{content:"";position:absolute;top:12px;right:12px;width:14px;height:14px;background:var(--red);border:3px solid var(--ink)}} +.toolbar{{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;flex-wrap:wrap;padding-right:34px}} +.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}} +.form-actions{{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:18px 0 0}} +h2,h3{{font-weight:900;text-transform:uppercase;letter-spacing:0}} +h2{{font-size:24px}} +h3{{font-size:16px;border-bottom:3px solid var(--ink);padding-bottom:6px;margin-top:20px}} +input,select,textarea{{width:100%;box-sizing:border-box;background:#fff;color:var(--ink);border:3px solid var(--ink);border-radius:0;padding:10px 11px;outline:none;font-size:14px;font-weight:600;transition:transform .14s var(--ease),box-shadow .14s var(--ease);will-change:transform}} +input:focus,select:focus,textarea:focus{{transform:translate(-1px,-1px);box-shadow:4px 4px 0 var(--blue)}} +textarea{{min-height:116px;font-family:'Cascadia Mono',Consolas,monospace}} +label{{display:block;margin:10px 0 5px;color:var(--ink);font-weight:900;font-size:12px;text-transform:uppercase;letter-spacing:.06em}} +.check-row{{display:flex;gap:10px;align-items:center;flex-wrap:wrap}} +.check-row label{{display:flex;gap:7px;align-items:center;margin:0;padding:8px 10px;border:3px solid var(--ink);background:var(--gray);transition:transform .14s var(--ease)}} +.check-row label:hover{{transform:translateY(-1px)}} +.check-row input{{width:auto}} +small,.muted{{color:var(--muted);line-height:1.5;font-weight:600}} +table{{width:100%;border-collapse:collapse;border:3px solid var(--ink);background:white}} +td,th{{border:3px solid var(--ink);padding:10px;text-align:left;vertical-align:top}} +th{{color:var(--ink);font-size:12px;background:var(--yellow);text-transform:uppercase;letter-spacing:.06em}} +tr:nth-child(even) td{{background:#fafafa}} +.badge{{padding:4px 8px;border:3px solid var(--ink);border-radius:999px;background:var(--blue);color:white;font-size:12px;font-weight:900;text-transform:uppercase}} +.msg{{padding:11px 12px;border:3px solid var(--ink);background:var(--yellow);color:var(--ink);margin:10px 0;font-weight:900;box-shadow:4px 4px 0 var(--ink)}} +pre{{white-space:pre-wrap;background:#121212;color:#fff;padding:13px;border:4px solid var(--ink);max-height:420px;overflow:auto;box-shadow:5px 5px 0 var(--yellow)}} +@keyframes mainIn{{from{{opacity:.0;transform:translateY(8px)}}to{{opacity:1;transform:none}}}} +@keyframes floatA{{from{{transform:translateY(0)}}to{{transform:translateY(-8px)}}}} +@keyframes floatB{{from{{transform:rotate(45deg) translateY(0)}}to{{transform:rotate(45deg) translateY(-10px)}}}} +@media(max-width:860px){{ + .shell{{grid-template-columns:1fr}} + aside{{position:relative;height:auto}} + main{{padding:18px}} + nav{{grid-template-columns:repeat(2,minmax(0,1fr))}} + .top{{align-items:flex-start;flex-direction:column}} + .card{{box-shadow:5px 5px 0 var(--ink)}} +}} +@media (prefers-reduced-motion: reduce){{ + *,*::before,*::after{{animation:none!important;transition:none!important}} +}} +""" @@ -1541,7 +2077,22 @@ def monitor_form_html(m: dict[str, Any] | None = None, idx: int | None = None) - def group_monitor_form_html(m: dict[str, Any] | None = None, idx: int | None = None) -> str: - m = m or {"enabled": True, "keywords": [], "exclude_keywords": [], "notify_telegram": True} + m = m or { + "enabled": True, + "keywords": [], + "exclude_keywords": [], + "notify_telegram": True, + "summary_mode": "template", + "ai_base_url": "", + "ai_api_key": "", + "ai_model": "gpt-4o-mini", + "ai_interface": "responses", + "ai_temperature": 0.2, + "ai_timeout_seconds": 30, + "ai_prompt": "", + "ai_min_interval_seconds": DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS, + "ai_dedupe_window_seconds": DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS, + } action = "/group-monitors/save" if idx is not None else "/group-monitors/create" hidden = f"" if idx is not None else "" keywords = "\n".join(m.get("keywords") or []) @@ -1551,6 +2102,12 @@ def group_monitor_form_html(m: dict[str, Any] | None = None, idx: int | None = N
+
+
+
+
+
+
返回列表
""" @@ -1591,11 +2148,23 @@ def create_panel_app() -> FastAPI: @app.get("/", response_class=HTMLResponse) async def index(_: str = Depends(panel_auth)) -> str: cfg = cfg_load_fresh() + statuses = list_monitor_runtime_status() rows = [] for i, m in enumerate(cfg.get("monitors") or []): tg = "TG" if m.get("notify_telegram", True) else "仅 Web" - rows.append(f"""{html_escape(m.get('type','web'))}{html_escape(m.get('name',''))}
{html_escape(m.get('url',''))}{html_escape(m.get('interval_seconds',60))}s
{tg}{html_escape(', '.join(m.get('keywords') or []))}编辑 预览 检查 删除""") - body = f"""

监控目标

当前 {len(cfg.get('monitors') or [])} 个;保存后自动重载定时任务。

""" + "".join(rows) + "
类型目标间隔/通知关键词操作
" + name = str(m.get("name", "")) + st = statuses.get(name) + st_badge = get_monitor_status_badge(st) + st_line = "-" + if st: + st_line = ( + f"{html_escape(st_badge)} · 推送 {st.get('last_sent_count', 0)} · " + f"{st.get('last_duration_ms', 0)}ms
成功: {html_escape(st.get('last_success_at') or '-')} / 失败: {html_escape(st.get('last_error_at') or '-')}" + ) + if st.get("last_error"): + st_line += f"
{html_escape(str(st.get('last_error'))[:100])}" + rows.append(f"""{html_escape(m.get('type','web'))}{html_escape(name)}
{html_escape(m.get('url',''))}{html_escape(m.get('interval_seconds',60))}s
{tg}{html_escape(', '.join(m.get('keywords') or []))}{st_line}编辑 预览 检查 删除""") + body = f"""

监控目标

当前 {len(cfg.get('monitors') or [])} 个;保存后自动重载定时任务。

""" + "".join(rows) + "
类型目标间隔/通知关键词运行状态操作
" return layout("监控", body) @app.get("/monitor/new", response_class=HTMLResponse) @@ -1647,6 +2216,16 @@ def create_panel_app() -> FastAPI: exclude_keywords: str, enabled: str | None, notify_telegram: str | None, + summary_mode: str, + ai_base_url: str, + ai_api_key: str, + ai_model: str, + ai_interface: str, + ai_temperature: str, + ai_timeout_seconds: str, + ai_prompt: str, + ai_min_interval_seconds: str, + ai_dedupe_window_seconds: str, ) -> RedirectResponse | HTMLResponse: cfg = cfg_load_fresh() rows = cfg.setdefault("group_monitors", []) @@ -1654,6 +2233,12 @@ def create_panel_app() -> FastAPI: rows = [] cfg["group_monitors"] = rows try: + parsed_summary_mode = (summary_mode or "template").strip().lower() or "template" + if parsed_summary_mode not in {"template", "ai"}: + parsed_summary_mode = "template" + parsed_ai_interface = (ai_interface or "responses").strip().lower() or "responses" + if parsed_ai_interface not in {"responses", "chat"}: + parsed_ai_interface = "responses" item = { "name": name.strip() or chat_id.strip(), "enabled": bool(enabled), @@ -1661,6 +2246,16 @@ def create_panel_app() -> FastAPI: "keywords": parse_lines(keywords), "exclude_keywords": parse_lines(exclude_keywords), "notify_telegram": bool(notify_telegram), + "summary_mode": parsed_summary_mode, + "ai_base_url": ai_base_url.strip(), + "ai_api_key": ai_api_key.strip(), + "ai_model": ai_model.strip() or "gpt-4o-mini", + "ai_interface": parsed_ai_interface, + "ai_temperature": safe_float(ai_temperature, 0.2), + "ai_timeout_seconds": max(1, safe_int(ai_timeout_seconds, 30)), + "ai_prompt": ai_prompt.strip(), + "ai_min_interval_seconds": max(0, safe_int(ai_min_interval_seconds, DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS)), + "ai_dedupe_window_seconds": max(0, safe_int(ai_dedupe_window_seconds, DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS)), } except Exception as e: return HTMLResponse(layout("保存失败", f"
{html_escape(e)}

返回

"), status_code=400) @@ -1682,8 +2277,36 @@ def create_panel_app() -> FastAPI: exclude_keywords: str = Form(""), enabled: str | None = Form(None), notify_telegram: str | None = Form(None), + summary_mode: str = Form("template"), + ai_base_url: str = Form(""), + ai_api_key: str = Form(""), + ai_model: str = Form("gpt-4o-mini"), + ai_interface: str = Form("responses"), + ai_temperature: str = Form("0.2"), + ai_timeout_seconds: str = Form("30"), + ai_prompt: str = Form(""), + ai_min_interval_seconds: str = Form(str(DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS)), + ai_dedupe_window_seconds: str = Form(str(DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS)), ) -> RedirectResponse | HTMLResponse: - return await save_group_monitor_common(None, name, chat_id, keywords, exclude_keywords, enabled, notify_telegram) + return await save_group_monitor_common( + None, + name, + chat_id, + keywords, + exclude_keywords, + enabled, + notify_telegram, + summary_mode, + ai_base_url, + ai_api_key, + ai_model, + ai_interface, + ai_temperature, + ai_timeout_seconds, + ai_prompt, + ai_min_interval_seconds, + ai_dedupe_window_seconds, + ) @app.post("/group-monitors/save") async def group_monitor_save( @@ -1695,8 +2318,36 @@ def create_panel_app() -> FastAPI: exclude_keywords: str = Form(""), enabled: str | None = Form(None), notify_telegram: str | None = Form(None), + summary_mode: str = Form("template"), + ai_base_url: str = Form(""), + ai_api_key: str = Form(""), + ai_model: str = Form("gpt-4o-mini"), + ai_interface: str = Form("responses"), + ai_temperature: str = Form("0.2"), + ai_timeout_seconds: str = Form("30"), + ai_prompt: str = Form(""), + ai_min_interval_seconds: str = Form(str(DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS)), + ai_dedupe_window_seconds: str = Form(str(DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS)), ) -> RedirectResponse | HTMLResponse: - return await save_group_monitor_common(original_index, name, chat_id, keywords, exclude_keywords, enabled, notify_telegram) + return await save_group_monitor_common( + original_index, + name, + chat_id, + keywords, + exclude_keywords, + enabled, + notify_telegram, + summary_mode, + ai_base_url, + ai_api_key, + ai_model, + ai_interface, + ai_temperature, + ai_timeout_seconds, + ai_prompt, + ai_min_interval_seconds, + ai_dedupe_window_seconds, + ) @app.get("/group-monitors/{idx}/delete") async def group_monitor_delete(idx: int, _: str = Depends(panel_auth)) -> RedirectResponse: @@ -2132,6 +2783,98 @@ HostLoc|https://hostloc.com|VPS,补货,优惠""" body = """

重启机器人

用于修改 Token、管理员 ID、面板设置等需要重启生效的配置。

""" return layout("重启机器人", body) + @app.get("/update", response_class=HTMLResponse) + async def update_page(_: str = Depends(panel_auth)) -> str: + repo_dir = BASE_DIR + branch = current_git_branch(repo_dir) + status_html = "" + try: + st = git_update_status(repo_dir, branch, fetch_remote=True) + rollback = app_meta_get("last_update_rollback") + status_html = ( + "

更新状态

" + f"

分支:{html_escape(st['branch'])}

" + f"

本地:{html_escape(st['head'][:12])}
" + f"远端:{html_escape(st['remote_head'][:12])}
" + f"ahead: {st['ahead']} / behind: {st['behind']}
" + f"工作区:{'有未提交改动' if st['dirty'] else '干净'}

" + f"

上次回滚点:{html_escape((rollback or '-')[:12])}

" + "
" + ) + except Exception as e: + status_html = f"

更新状态

{html_escape(str(e))}
" + actions = ( + "

更新操作

只允许快进更新(ff-only)。若工作区有本地改动,将拒绝更新。

" + "
" + "
" + "
" + "
" + ) + return layout("更新代码", status_html + actions) + + @app.post("/update") + async def update_post(_: str = Depends(panel_auth)) -> HTMLResponse: + repo_dir = BASE_DIR + branch = current_git_branch(repo_dir) + try: + st = git_update_status(repo_dir, branch, fetch_remote=True) + if st["dirty"]: + return HTMLResponse( + layout( + "更新被拒绝", + "

检测到本地未提交改动,已拒绝自动更新。请先提交或清理本地改动再试。

返回

", + ), + status_code=400, + ) + if int(st["behind"]) <= 0: + return HTMLResponse(layout("无需更新", "
当前已是最新版本,无需重启。

返回

")) + old_head = st["head"] + pull = git_run(repo_dir, ["pull", "--ff-only", "origin", branch], check=True) + new_head = git_run(repo_dir, ["rev-parse", "HEAD"], check=True).stdout.strip() + if new_head != old_head: + app_meta_set("last_update_rollback", old_head) + logger.info("update applied branch=%s old=%s new=%s out=%s", branch, old_head, new_head, pull.stdout.strip()) + except subprocess.CalledProcessError as e: + logger.exception("update failed") + return HTMLResponse( + layout( + "更新失败", + f"
{html_escape((e.stderr or e.stdout or str(e))[:4000])}

返回

", + ), + status_code=500, + ) + except Exception as e: + logger.exception("update failed") + return HTMLResponse(layout("更新失败", f"
{html_escape(str(e))}

返回

"), status_code=500) + async def delayed_restart(): + await asyncio.sleep(1.0) + os._exit(1) + asyncio.create_task(delayed_restart()) + return HTMLResponse(layout("更新完成", "
已拉取最新代码,正在重启。

返回首页

")) + + @app.post("/update/rollback") + async def update_rollback_post(_: str = Depends(panel_auth)) -> HTMLResponse: + repo_dir = BASE_DIR + rollback = app_meta_get("last_update_rollback") + if not rollback: + return HTMLResponse(layout("回滚失败", "

没有可用的回滚点。

返回

"), status_code=400) + try: + if git_run(repo_dir, ["status", "--porcelain"], check=True).stdout.strip(): + return HTMLResponse( + layout("回滚被拒绝", "

检测到本地未提交改动,已拒绝回滚。请先处理本地改动再试。

返回

"), + status_code=400, + ) + rollback_to_commit(repo_dir, rollback) + logger.info("rollback applied commit=%s", rollback) + except Exception as e: + logger.exception("rollback failed") + return HTMLResponse(layout("回滚失败", f"
{html_escape(str(e))}

返回

"), status_code=500) + async def delayed_restart(): + await asyncio.sleep(1.0) + os._exit(1) + asyncio.create_task(delayed_restart()) + return HTMLResponse(layout("回滚完成", "
已回滚并准备重启。

返回首页

")) + @app.post("/restart") async def restart_post(_: str = Depends(panel_auth)) -> HTMLResponse: async def delayed_restart(): diff --git a/config.example.yaml b/config.example.yaml index 460f43a..c391c68 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -63,6 +63,16 @@ group_monitors: exclude_keywords: - 求带 notify_telegram: true + summary_mode: template + ai_base_url: '' + ai_api_key: '' + ai_model: gpt-4o-mini + ai_interface: responses + ai_temperature: 0.2 + ai_timeout_seconds: 30 + ai_prompt: '' + ai_min_interval_seconds: 30 + ai_dedupe_window_seconds: 300 cleanup: enabled: true interval_minutes: 60 diff --git a/tests/test_monitor_message_cleanup.py b/tests/test_monitor_message_cleanup.py index ae8338b..5d77e1f 100644 --- a/tests/test_monitor_message_cleanup.py +++ b/tests/test_monitor_message_cleanup.py @@ -324,6 +324,16 @@ class PanelHtmlContractTest(unittest.TestCase): "name=exclude_keywords", "name=enabled", "name=notify_telegram", + "name=summary_mode", + "name=ai_interface", + "name=ai_base_url", + "name=ai_api_key", + "name=ai_model", + "name=ai_temperature", + "name=ai_timeout_seconds", + "name=ai_prompt", + "name=ai_min_interval_seconds", + "name=ai_dedupe_window_seconds", ]: self.assertIn(expected, html) @@ -361,6 +371,144 @@ class SpamAndTemplateConfigTest(unittest.TestCase): class GroupMonitorTest(unittest.TestCase): + def test_ai_api_url_supports_v1_and_plain_base(self) -> None: + self.assertEqual("https://api.example.com/v1/responses", app.ai_api_url("https://api.example.com", "/responses")) + self.assertEqual("https://api.example.com/v1/chat/completions", app.ai_api_url("https://api.example.com/v1", "/chat/completions")) + + def test_extract_responses_text_and_chat_text(self) -> None: + self.assertEqual( + "hello", + app.extract_responses_text({"output_text": "hello"}), + ) + self.assertEqual( + "a\nb", + app.extract_responses_text( + {"output": [{"content": [{"text": "a"}, {"content": "b"}]}]} + ), + ) + self.assertEqual( + "ok", + app.extract_chat_text({"choices": [{"message": {"content": "ok"}}]}), + ) + + def test_cfg_save_normalizes_group_monitor_ai_fields(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + old_config_path = app.CONFIG_PATH + old_config = app.config + old_reload = app.reload_scheduler_jobs + app.CONFIG_PATH = Path(temp_dir) / "config.yaml" + app.reload_scheduler_jobs = lambda: None + try: + cfg = { + "monitors": [], + "group_monitors": [ + { + "enabled": True, + "chat_id": "-10099", + "keywords": ["vps"], + "exclude_keywords": [], + "summary_mode": "bad-mode", + "ai_interface": "bad-iface", + "ai_temperature": "x", + "ai_timeout_seconds": "0", + } + ], + } + app.cfg_save(cfg) + saved = app.config["group_monitors"][0] + self.assertEqual(-10099, saved["chat_id"]) + self.assertEqual("template", saved["summary_mode"]) + self.assertEqual("responses", saved["ai_interface"]) + self.assertEqual(0.2, saved["ai_temperature"]) + self.assertEqual(1, saved["ai_timeout_seconds"]) + self.assertEqual(app.DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS, saved["ai_min_interval_seconds"]) + self.assertEqual(app.DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS, saved["ai_dedupe_window_seconds"]) + finally: + app.CONFIG_PATH = old_config_path + app.config = old_config + app.reload_scheduler_jobs = old_reload + + def test_build_group_ai_system_prompt_allows_custom_prompt(self) -> None: + text = app.build_group_ai_system_prompt("请按项目符号输出") + self.assertIn("Telegram 群消息摘要助手", text) + self.assertIn("请按项目符号输出", text) + + def test_group_monitor_allow_send_applies_interval_and_dedupe(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + old_db_path = app.DB_PATH + app.DB_PATH = Path(temp_dir) / "test.sqlite3" + app.init_db() + monitor = { + "name": "测试群", + "ai_min_interval_seconds": 30, + "ai_dedupe_window_seconds": 120, + } + try: + ok1, reason1 = app.group_monitor_allow_send(monitor, "fp1", now_ts=1000) + ok2, reason2 = app.group_monitor_allow_send(monitor, "fp2", now_ts=1010) + ok3, reason3 = app.group_monitor_allow_send(monitor, "fp1", now_ts=1040) + self.assertTrue(ok1) + self.assertEqual("", reason1) + self.assertFalse(ok2) + self.assertIn("min-interval", reason2) + self.assertFalse(ok3) + self.assertIn("dedupe", reason3) + finally: + app.DB_PATH = old_db_path + + +class MonitorRuntimeAndUpdateTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.old_db_path = app.DB_PATH + app.DB_PATH = Path(self.temp_dir.name) / "test.sqlite3" + app.init_db() + + def tearDown(self) -> None: + app.DB_PATH = self.old_db_path + self.temp_dir.cleanup() + + def test_record_monitor_runtime_tracks_failures(self) -> None: + app.record_monitor_runtime("m1", ok=False, duration_ms=120, sent_count=0, error="oops") + app.record_monitor_runtime("m1", ok=False, duration_ms=90, sent_count=0, error="oops2") + app.record_monitor_runtime("m1", ok=True, duration_ms=70, sent_count=2) + data = app.list_monitor_runtime_status()["m1"] + self.assertEqual(0, data["consecutive_failures"]) + self.assertEqual(2, data["last_sent_count"]) + self.assertEqual(70, data["last_duration_ms"]) + + def test_git_update_status_parses_ahead_behind_and_dirty(self) -> None: + old_git_run = app.git_run + + class FakeResult: + def __init__(self, out: str): + self.stdout = out + + def fake_git_run(repo_dir, args, check=True): + cmd = " ".join(args) + if cmd.startswith("fetch "): + return FakeResult("") + if cmd == "rev-parse HEAD": + return FakeResult("abc123\n") + if cmd == "rev-parse origin/main": + return FakeResult("def456\n") + if cmd.startswith("rev-list --left-right --count"): + return FakeResult("2 5\n") + if cmd == "status --porcelain": + return FakeResult(" M app.py\n") + raise AssertionError(f"unexpected git command: {cmd}") + + app.git_run = fake_git_run + try: + st = app.git_update_status(Path("."), "main", fetch_remote=True) + self.assertEqual("abc123", st["head"]) + self.assertEqual("def456", st["remote_head"]) + self.assertEqual(2, st["ahead"]) + self.assertEqual(5, st["behind"]) + self.assertTrue(st["dirty"]) + finally: + app.git_run = old_git_run + def test_group_monitor_for_chat_returns_enabled_target(self) -> None: old_config = app.config app.config = { @@ -452,6 +600,57 @@ class GroupMonitorTest(unittest.TestCase): app.bot = old_bot app.admin_chat_ids = old_admin_chat_ids + def test_group_ai_summary_fallback_to_template_when_ai_fails(self) -> None: + old_config = app.config + old_bot = app.bot + old_admin_chat_ids = app.admin_chat_ids + old_ai = app.summarize_group_message_ai + fake_bot = FakeBot() + app.bot = fake_bot + app.admin_chat_ids = [9001] + app.config = { + "group_monitors": [ + { + "enabled": True, + "name": "测试群", + "chat_id": -100100100, + "keywords": ["vps"], + "exclude_keywords": [], + "notify_telegram": True, + "summary_mode": "ai", + "ai_base_url": "https://api.example.com/v1", + "ai_api_key": "sk-test", + "ai_model": "gpt-4o-mini", + "ai_interface": "responses", + } + ] + } + + async def fail_ai(message, monitor, hits): + raise RuntimeError("ai failed") + + app.summarize_group_message_ai = fail_ai + msg = SimpleNamespace( + chat=SimpleNamespace(id=-100100100, username="groupdemo", title="测试群"), + from_user=SimpleNamespace(id=123, first_name="Alice", last_name="", username="alice"), + text="今晚 vps 有货", + caption=None, + reply_to_message=None, + message_id=888, + content_type="text", + ) + try: + ok = asyncio.run(app.handle_group_keyword_message(msg)) + self.assertTrue(ok) + self.assertEqual([9001], fake_bot.sent_chat_ids) + self.assertIn("[群AI总结失败,已使用模板]", fake_bot.sent_texts[0]) + self.assertIn("[群关键词命中]", fake_bot.sent_texts[0]) + finally: + app.config = old_config + app.bot = old_bot + app.admin_chat_ids = old_admin_chat_ids + app.summarize_group_message_ai = old_ai + if __name__ == "__main__": unittest.main()