Add safer web update flow, group-monitor AI controls, and monitor observability

This commit is contained in:
InfernoXuaI
2026-05-22 14:45:50 +08:00
parent 212b9bc878
commit 9095229218
4 changed files with 1015 additions and 27 deletions
+37 -1
View File
@@ -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 的限制。
<a id="manual-install"></a>
## 快速开始
<a id="docker-install"></a>
@@ -157,6 +166,9 @@ docker compose logs -f
docker compose restart
```
<a id="manual-install"></a>
## 手动安装(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
+769 -26
View File
@@ -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:
<title>登录 · tg-watchbot</title>
<link rel=icon href="{app_icon_data_uri()}">
<style>
:root{{color-scheme:light;--canvas:#f0f0f0;--ink:#121212;--muted:#5c5c5c;--red:#d02020;--blue:#1040c0;--yellow:#f0c020;--white:#fff}}
*{{box-sizing:border-box}}body{{margin:0;min-height:100vh;font-family:Outfit,Aptos,'Segoe UI',sans-serif;background:var(--canvas);color:var(--ink);display:grid;place-items:center;padding:24px;overflow:hidden}}
body:before{{content:"";position:fixed;inset:auto auto -90px -70px;width:220px;height:220px;border:4px solid var(--ink);border-radius:50%;background:var(--yellow);z-index:-1}}body:after{{content:"";position:fixed;top:54px;right:8vw;width:150px;height:150px;background:var(--blue);border:4px solid var(--ink);transform:rotate(12deg);z-index:-1}}
.login-card{{position:relative;width:min(420px,100%);padding:32px;border:4px solid var(--ink);border-radius:0;background:var(--white);box-shadow:8px 8px 0 var(--ink)}}
:root{{color-scheme:light;--canvas:#f0f0f0;--ink:#121212;--muted:#5c5c5c;--red:#d02020;--blue:#1040c0;--yellow:#f0c020;--white:#fff;--ease:cubic-bezier(.2,.8,.2,1)}}
*{{box-sizing:border-box}}
body{{margin:0;min-height:100vh;font-family:Outfit,Aptos,'Segoe UI',sans-serif;background:var(--canvas);color:var(--ink);display:grid;place-items:center;padding:24px;overflow:hidden}}
body:before{{content:"";position:fixed;inset:auto auto -90px -70px;width:220px;height:220px;border:4px solid var(--ink);border-radius:50%;background:var(--yellow);z-index:-1;animation:floatA 5.5s var(--ease) infinite alternate}}
body:after{{content:"";position:fixed;top:54px;right:8vw;width:150px;height:150px;background:var(--blue);border:4px solid var(--ink);transform:rotate(12deg);z-index:-1;animation:floatB 6.5s var(--ease) infinite alternate}}
.login-card{{position:relative;width:min(420px,100%);padding:32px;border:4px solid var(--ink);border-radius:0;background:var(--white);box-shadow:8px 8px 0 var(--ink);contain:paint;will-change:transform;animation:cardIn .28s var(--ease)}}
.login-card:after{{content:"";position:absolute;right:22px;top:22px;width:24px;height:24px;background:var(--red);clip-path:polygon(50% 0,0 100%,100% 100%)}}
.logo{{width:58px;height:58px;border:4px solid var(--ink);background:var(--white);position:relative;margin-bottom:22px;box-shadow:4px 4px 0 var(--ink)}}.logo:before{{content:"";position:absolute;left:8px;top:8px;width:18px;height:18px;border:3px solid var(--ink);border-radius:50%;background:var(--red)}}.logo:after{{content:"";position:absolute;right:7px;top:8px;width:18px;height:18px;border:3px solid var(--ink);background:var(--blue)}}.logo i{{position:absolute;left:13px;bottom:7px;width:30px;height:22px;background:var(--yellow);border:3px solid var(--ink);clip-path:polygon(50% 0,0 100%,100% 100%)}}
h1{{margin:0 0 8px;font-size:34px;line-height:.95;text-transform:uppercase;color:var(--ink);letter-spacing:0;font-weight:900}}p{{margin:0 0 24px;color:var(--muted);line-height:1.5;font-weight:500}}label{{display:block;margin:14px 0 7px;color:var(--ink);font-size:12px;font-weight:900;text-transform:uppercase;letter-spacing:.08em}}input{{width:100%;border:3px solid var(--ink);border-radius:0;background:#fff;color:var(--ink);padding:12px 13px;font-size:15px;outline:none}}input:focus{{box-shadow:4px 4px 0 var(--blue)}}button{{width:100%;margin-top:22px;border:3px solid var(--ink);border-radius:0;padding:12px 16px;background:var(--red);color:white;font-weight:900;font-size:14px;text-transform:uppercase;letter-spacing:.08em;cursor:pointer;box-shadow:4px 4px 0 var(--ink)}}button:active{{transform:translate(2px,2px);box-shadow:1px 1px 0 var(--ink)}}.login-error{{background:#fff;border:3px solid var(--ink);color:var(--red);padding:10px 12px;margin-bottom:16px;font-weight:800;box-shadow:4px 4px 0 var(--red)}}.foot{{margin-top:18px;color:var(--muted);font-size:13px;text-align:center;font-weight:700}}
.logo{{width:58px;height:58px;border:4px solid var(--ink);background:var(--white);position:relative;margin-bottom:22px;box-shadow:4px 4px 0 var(--ink);transition:transform .22s var(--ease);will-change:transform}}
.logo:before{{content:"";position:absolute;left:8px;top:8px;width:18px;height:18px;border:3px solid var(--ink);border-radius:50%;background:var(--red)}}
.logo:after{{content:"";position:absolute;right:7px;top:8px;width:18px;height:18px;border:3px solid var(--ink);background:var(--blue)}}
.logo i{{position:absolute;left:13px;bottom:7px;width:30px;height:22px;background:var(--yellow);border:3px solid var(--ink);clip-path:polygon(50% 0,0 100%,100% 100%)}}
.login-card:hover .logo{{transform:translateY(-1px)}}
h1{{margin:0 0 8px;font-size:34px;line-height:.95;text-transform:uppercase;color:var(--ink);letter-spacing:0;font-weight:900}}
p{{margin:0 0 24px;color:var(--muted);line-height:1.5;font-weight:500}}
label{{display:block;margin:14px 0 7px;color:var(--ink);font-size:12px;font-weight:900;text-transform:uppercase;letter-spacing:.08em}}
input{{width:100%;border:3px solid var(--ink);border-radius:0;background:#fff;color:var(--ink);padding:12px 13px;font-size:15px;outline:none;transition:transform .16s var(--ease),box-shadow .16s var(--ease);will-change:transform}}
input:focus{{transform:translate(-1px,-1px);box-shadow:4px 4px 0 var(--blue)}}
button{{width:100%;margin-top:22px;border:3px solid var(--ink);border-radius:0;padding:12px 16px;background:var(--red);color:white;font-weight:900;font-size:14px;text-transform:uppercase;letter-spacing:.08em;cursor:pointer;box-shadow:4px 4px 0 var(--ink);transition:transform .16s var(--ease),background-color .16s var(--ease);will-change:transform}}
button:hover{{transform:translate(-1px,-1px);background:#bc1c1c}}
button:active{{transform:translate(2px,2px)}}
.login-error{{background:#fff;border:3px solid var(--ink);color:var(--red);padding:10px 12px;margin-bottom:16px;font-weight:800;box-shadow:4px 4px 0 var(--red)}}
.foot{{margin-top:18px;color:var(--muted);font-size:13px;text-align:center;font-weight:700}}
@keyframes cardIn{{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(12deg) translateY(0)}}to{{transform:rotate(12deg) translateY(-9px)}}}}
@media (prefers-reduced-motion: reduce){{
*,*::before,*::after{{animation:none!important;transition:none!important}}
}}
</style></head><body><main class=login-card><div class=logo><i></i></div><h1>tg-watchbot</h1><p>登录后管理 Telegram 机器人、关键词监控和提醒。</p>{err}<form method=post action=/login><label>用户名</label><input name=username autocomplete=username autofocus><label>密码</label><input name=password type=password autocomplete=current-password><button type=submit>登录面板</button></form><div class=foot>localhost panel</div></main></body></html>"""
@@ -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:
<title>{html_escape(title)} · tg-watchbot</title>
<link rel=icon href="{app_icon_data_uri()}">
<style>
:root{{--canvas:#f0f0f0;--ink:#121212;--muted:#5c5c5c;--red:#d02020;--blue:#1040c0;--yellow:#f0c020;--white:#fff;--gray:#e0e0e0}}
*{{box-sizing:border-box}}body{{font-family:Outfit,Aptos,'Segoe UI',sans-serif;background:var(--canvas);color:var(--ink);margin:0;letter-spacing:0}}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}}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}}
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}}main{{padding:24px 30px;min-width:0;max-width:1440px}}.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}}.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 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)}}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)}}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)}}.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)}}.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)}}.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}}input:focus,select:focus,textarea:focus{{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)}}.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)}}
@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)}}}}
</style></head><body><div class=shell><aside><div class=brand><div class=mark><i></i></div><div><b>tg-watchbot</b><small>Telegram 自动化</small></div></div><nav><section><b>消息</b><a href='/inbox'>收件箱</a><a href='/users'>用户管理</a><a href='/send'>主动发消息</a><a href='/replies'>快捷回复</a><a href='/rules'>私聊广告拦截</a></section><section><b>监控</b><a href='/'>监控面板</a><a href='/monitor/new'>新增监控</a><a href='/group-monitors'>TG 群监听</a><a href='/monitor/events'>推送历史</a><a href='/run-once'>手动检查</a></section><section><b>配置</b><a href='/settings'>Bot / 面板设置</a><a href='/yaml'>YAML 高级编辑</a><a href='/config/export'>导出配置</a></section><section><b>系统</b><a href='/logs'>运行日志</a><a href='/restart' onclick='return confirm("确定重启机器人服务?")'>重启机器人</a><a class=logout href='/logout'>退出登录</a></section></nav></aside><main><div class=top><h1>{html_escape(title)}</h1><span class=badge>WatchBot Panel</span></div>
: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}}
}}
</style></head><body><div class=shell><aside><div class=brand><div class=mark><i></i></div><div><b>tg-watchbot</b><small>Telegram 自动化</small></div></div><nav><section><b>消息</b><a href='/inbox'>收件箱</a><a href='/users'>用户管理</a><a href='/send'>主动发消息</a><a href='/replies'>快捷回复</a><a href='/rules'>私聊广告拦截</a></section><section><b>监控</b><a href='/'>监控面板</a><a href='/monitor/new'>新增监控</a><a href='/group-monitors'>TG 群监听</a><a href='/monitor/events'>推送历史</a><a href='/run-once'>手动检查</a></section><section><b>配置</b><a href='/settings'>Bot / 面板设置</a><a href='/yaml'>YAML 高级编辑</a><a href='/config/export'>导出配置</a></section><section><b>系统</b><a href='/update'>更新代码</a><a href='/logs'>运行日志</a><a href='/restart' onclick='return confirm("确定重启机器人服务?")'>重启机器人</a><a class=logout href='/logout'>退出登录</a></section></nav></aside><main><div class=top><h1>{html_escape(title)}</h1><span class=badge>WatchBot Panel</span></div>
{body}</main></div></body></html>"""
@@ -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"<input type=hidden name=original_index value='{idx}'>" 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
<label><input type=checkbox name=notify_telegram {'checked' if m.get('notify_telegram', True) else ''}> 推送管理员</label></div>
<div class=grid><div><label>监听名称</label><input name=name value='{html_escape(m.get('name',''))}' placeholder='例如:业务群关键词'></div>
<div><label>群 chat_id</label><input name=chat_id value='{html_escape(m.get('chat_id',''))}' placeholder='例如 -1001234567890' required></div></div>
<div class=grid><div><label>总结模式</label><select name=summary_mode><option value=template {'selected' if str(m.get('summary_mode', 'template')) == 'template' else ''}>模板</option><option value=ai {'selected' if str(m.get('summary_mode')) == 'ai' else ''}>AI</option></select></div><div><label>AI 接口</label><select name=ai_interface><option value=responses {'selected' if str(m.get('ai_interface', 'responses')) == 'responses' else ''}>Responses</option><option value=chat {'selected' if str(m.get('ai_interface')) == 'chat' else ''}>Chat Completions</option></select></div></div>
<div class=grid><div><label>AI Base URL</label><input name=ai_base_url value='{html_escape(m.get('ai_base_url',''))}' placeholder='https://api.example.com/v1'></div><div><label>AI Model</label><input name=ai_model value='{html_escape(m.get('ai_model','gpt-4o-mini'))}' placeholder='gpt-4o-mini'></div></div>
<div class=grid><div><label>AI API Key</label><input name=ai_api_key value='{html_escape(m.get('ai_api_key',''))}' placeholder='sk-...'></div><div><label>AI Temperature</label><input name=ai_temperature type=number step=0.1 min=0 max=2 value='{html_escape(m.get('ai_temperature',0.2))}'></div></div>
<div class=grid><div><label>AI 超时(秒)</label><input name=ai_timeout_seconds type=number min=1 value='{html_escape(m.get('ai_timeout_seconds',30))}'></div><div><label>最小推送间隔(秒)</label><input name=ai_min_interval_seconds type=number min=0 value='{html_escape(m.get('ai_min_interval_seconds',DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS))}'></div></div>
<div class=grid><div><label>摘要去重窗口(秒)</label><input name=ai_dedupe_window_seconds type=number min=0 value='{html_escape(m.get('ai_dedupe_window_seconds',DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS))}'></div><div></div></div>
<label>AI 总结提示词(可选)</label><textarea name=ai_prompt placeholder='留空则使用默认总结提示词'>{html_escape(m.get('ai_prompt',''))}</textarea>
<label>关键词(一行一个)</label><textarea name=keywords>{html_escape(keywords)}</textarea>
<label>排除词(一行一个)</label><textarea name=exclude_keywords>{html_escape(exclude_keywords)}</textarea>
<div class=form-actions><button class='btn primary' type=submit>保存</button> <a class=btn href='/group-monitors'>返回列表</a></div></form>"""
@@ -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"""<tr><td><span class=badge>{html_escape(m.get('type','web'))}</span></td><td><b>{html_escape(m.get('name',''))}</b><br><small>{html_escape(m.get('url',''))}</small></td><td>{html_escape(m.get('interval_seconds',60))}s<br><small>{tg}</small></td><td>{html_escape(', '.join(m.get('keywords') or []))}</td><td><a class=btn href='/monitor/{i}/edit'>编辑</a> <a class='btn ok' href='/monitor/{i}/preview'>预览</a> <a class='btn ok' href='/monitor/{i}/run'>检查</a> <a class='btn danger' href='/monitor/{i}/delete' onclick='return confirm("确定删除?")'>删除</a></td></tr>""")
body = f"""<div class=card><div class=toolbar><div><h2 style='margin:0 0 6px'>监控目标</h2><p class=muted style='margin:0'>当前 {len(cfg.get('monitors') or [])} 个;保存后自动重载定时任务。</p></div><div class=actions><a class='btn' href='/monitor/templates'>论坛模板</a> <a class='btn primary' href='/monitor/new'>新增监控</a> <a class='btn ok' href='/monitor/bulk'>批量新增</a></div></div><table style='margin-top:16px'><tr><th>类型</th><th>目标</th><th>间隔/通知</th><th>关键词</th><th>操作</th></tr>""" + "".join(rows) + "</table></div>"
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<br><small>成功: {html_escape(st.get('last_success_at') or '-')} / 失败: {html_escape(st.get('last_error_at') or '-')}</small>"
)
if st.get("last_error"):
st_line += f"<br><small>{html_escape(str(st.get('last_error'))[:100])}</small>"
rows.append(f"""<tr><td><span class=badge>{html_escape(m.get('type','web'))}</span></td><td><b>{html_escape(name)}</b><br><small>{html_escape(m.get('url',''))}</small></td><td>{html_escape(m.get('interval_seconds',60))}s<br><small>{tg}</small></td><td>{html_escape(', '.join(m.get('keywords') or []))}</td><td>{st_line}</td><td><a class=btn href='/monitor/{i}/edit'>编辑</a> <a class='btn ok' href='/monitor/{i}/preview'>预览</a> <a class='btn ok' href='/monitor/{i}/run'>检查</a> <a class='btn danger' href='/monitor/{i}/delete' onclick='return confirm("确定删除?")'>删除</a></td></tr>""")
body = f"""<div class=card><div class=toolbar><div><h2 style='margin:0 0 6px'>监控目标</h2><p class=muted style='margin:0'>当前 {len(cfg.get('monitors') or [])} 个;保存后自动重载定时任务。</p></div><div class=actions><a class='btn' href='/monitor/templates'>论坛模板</a> <a class='btn primary' href='/monitor/new'>新增监控</a> <a class='btn ok' href='/monitor/bulk'>批量新增</a></div></div><table style='margin-top:16px'><tr><th>类型</th><th>目标</th><th>间隔/通知</th><th>关键词</th><th>运行状态</th><th>操作</th></tr>""" + "".join(rows) + "</table></div>"
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"<div class=card><pre>{html_escape(e)}</pre></div><p><a class=btn href='/group-monitors'>返回</a></p>"), 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 = """<div class=card><h2>重启机器人</h2><p class=muted>用于修改 Token、管理员 ID、面板设置等需要重启生效的配置。</p><form method=post action='/restart'><button class='btn danger' type=submit>确认重启 tg-watchbot</button></form></div>"""
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 = (
"<div class=card><h2>更新状态</h2>"
f"<p class=muted>分支:{html_escape(st['branch'])}</p>"
f"<p>本地:<code>{html_escape(st['head'][:12])}</code><br>"
f"远端:<code>{html_escape(st['remote_head'][:12])}</code><br>"
f"ahead: {st['ahead']} / behind: {st['behind']}<br>"
f"工作区:{'有未提交改动' if st['dirty'] else '干净'}</p>"
f"<p class=muted>上次回滚点:<code>{html_escape((rollback or '-')[:12])}</code></p>"
"</div>"
)
except Exception as e:
status_html = f"<div class=card><h2>更新状态</h2><pre>{html_escape(str(e))}</pre></div>"
actions = (
"<div class=card><h2>更新操作</h2><p class=muted>只允许快进更新(ff-only)。若工作区有本地改动,将拒绝更新。</p>"
"<div class=actions>"
"<form method=post action='/update' style='display:inline'><button class='btn primary' type=submit>更新并重启</button></form>"
"<form method=post action='/update/rollback' style='display:inline'><button class='btn danger' type=submit>回滚上次更新</button></form>"
"</div></div>"
)
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(
"更新被拒绝",
"<div class=card><p>检测到本地未提交改动,已拒绝自动更新。请先提交或清理本地改动再试。</p></div><p><a class=btn href='/update'>返回</a></p>",
),
status_code=400,
)
if int(st["behind"]) <= 0:
return HTMLResponse(layout("无需更新", "<div class=msg>当前已是最新版本,无需重启。</div><p><a class=btn href='/update'>返回</a></p>"))
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"<div class=card><pre>{html_escape((e.stderr or e.stdout or str(e))[:4000])}</pre></div><p><a class=btn href='/update'>返回</a></p>",
),
status_code=500,
)
except Exception as e:
logger.exception("update failed")
return HTMLResponse(layout("更新失败", f"<div class=card><pre>{html_escape(str(e))}</pre></div><p><a class=btn href='/update'>返回</a></p>"), status_code=500)
async def delayed_restart():
await asyncio.sleep(1.0)
os._exit(1)
asyncio.create_task(delayed_restart())
return HTMLResponse(layout("更新完成", "<div class=msg>已拉取最新代码,正在重启。</div><p><a class=btn href='/'>返回首页</a></p>"))
@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("回滚失败", "<div class=card><p>没有可用的回滚点。</p></div><p><a class=btn href='/update'>返回</a></p>"), status_code=400)
try:
if git_run(repo_dir, ["status", "--porcelain"], check=True).stdout.strip():
return HTMLResponse(
layout("回滚被拒绝", "<div class=card><p>检测到本地未提交改动,已拒绝回滚。请先处理本地改动再试。</p></div><p><a class=btn href='/update'>返回</a></p>"),
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"<div class=card><pre>{html_escape(str(e))}</pre></div><p><a class=btn href='/update'>返回</a></p>"), status_code=500)
async def delayed_restart():
await asyncio.sleep(1.0)
os._exit(1)
asyncio.create_task(delayed_restart())
return HTMLResponse(layout("回滚完成", "<div class=msg>已回滚并准备重启。</div><p><a class=btn href='/'>返回首页</a></p>"))
@app.post("/restart")
async def restart_post(_: str = Depends(panel_auth)) -> HTMLResponse:
async def delayed_restart():
+10
View File
@@ -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
+199
View File
@@ -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()