feat(group-monitor): add bot/user-session source option and tg api session settings

This commit is contained in:
InfernoXuaI
2026-05-22 19:30:27 +08:00
parent 54873b757c
commit 6ea6c1cfda
6 changed files with 264 additions and 21 deletions
+5
View File
@@ -14,3 +14,8 @@ WEB_PANEL_PORT=8765
WEB_PANEL_USER=admin WEB_PANEL_USER=admin
WEB_PANEL_PASSWORD=change-me WEB_PANEL_PASSWORD=change-me
WEB_PANEL_SESSION_SECRET= WEB_PANEL_SESSION_SECRET=
# Optional: user-session listener for TG groups where bot cannot be added.
TG_API_ID=
TG_API_HASH=
TG_API_SESSION=
+10
View File
@@ -33,6 +33,8 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器
- TG 群监听功能增强:支持可视化配置监听规则、AI 总结参数与防刷屏策略。 - TG 群监听功能增强:支持可视化配置监听规则、AI 总结参数与防刷屏策略。
- TG 群监听新增“已发现群聊”:自动显示 Bot 收到过消息的群聊 `chat_id`,可一键创建监听。 - TG 群监听新增“已发现群聊”:自动显示 Bot 收到过消息的群聊 `chat_id`,可一键创建监听。
- TG 群监听新增“监听来源”选项:`Bot` / `用户会话`(可用于 Bot 无法加入的群)。
- 设置页新增 `TG_API_ID`、`TG_API_HASH`、`TG_API_SESSION` 可视化配置;用于用户会话监听。
- 新增 `/update` 安全更新流程:显示本地/远端 commit、ahead/behind、工作区状态;仅允许 `ff-only` 更新。 - 新增 `/update` 安全更新流程:显示本地/远端 commit、ahead/behind、工作区状态;仅允许 `ff-only` 更新。
- 更新前若检测到本地未提交改动,会拒绝更新;避免覆盖本地代码。 - 更新前若检测到本地未提交改动,会拒绝更新;避免覆盖本地代码。
- 新增“回滚上次更新”按钮:更新前自动记录回滚点,可一键回滚并重启。 - 新增“回滚上次更新”按钮:更新前自动记录回滚点,可一键回滚并重启。
@@ -301,6 +303,9 @@ curl http://127.0.0.1:8765/health
| `WEB_PANEL_USER` | 面板用户名 | | `WEB_PANEL_USER` | 面板用户名 |
| `WEB_PANEL_PASSWORD` | 面板密码 | | `WEB_PANEL_PASSWORD` | 面板密码 |
| `WEB_PANEL_SESSION_SECRET` | Session Secret,留空会自动生成并写回 `.env` | | `WEB_PANEL_SESSION_SECRET` | Session Secret,留空会自动生成并写回 `.env` |
| `TG_API_ID` | (可选)Telegram API ID,用于“TG 群监听=用户会话” |
| `TG_API_HASH` | (可选)Telegram API Hash,用于“TG 群监听=用户会话” |
| `TG_API_SESSION` | (可选)Telethon StringSession,用于“TG 群监听=用户会话” |
### `config.yaml` ### `config.yaml`
@@ -329,6 +334,7 @@ TG 群关键词监听(可选,默认关闭):
group_monitors: group_monitors:
- name: TG 群关键词监听 - name: TG 群关键词监听
enabled: false enabled: false
listen_source: bot
chat_id: -1001234567890 chat_id: -1001234567890
keywords: keywords:
- VPS - VPS
@@ -350,6 +356,9 @@ group_monitors:
- 命中 `keywords` 且未命中 `exclude_keywords` 时,会给管理员发送摘要。 - 命中 `keywords` 且未命中 `exclude_keywords` 时,会给管理员发送摘要。
- TG 群监听页面会展示“已发现群聊”(Bot 收到过消息的群),可直接点“用此群创建监听”自动填入 `chat_id` - TG 群监听页面会展示“已发现群聊”(Bot 收到过消息的群),可直接点“用此群创建监听”自动填入 `chat_id`
- `listen_source` 支持:
- `bot`:默认,使用 Bot 接收群消息(需把 Bot 拉进群)
- `user_session`:使用用户会话接收群消息(适合 Bot 无法入群)
- `summary_mode` 支持: - `summary_mode` 支持:
- `template`:固定模板摘要(默认) - `template`:固定模板摘要(默认)
- `ai`:调用 AI 生成摘要(在 TG 群监听页面可视化配置) - `ai`:调用 AI 生成摘要(在 TG 群监听页面可视化配置)
@@ -360,6 +369,7 @@ group_monitors:
- `ai_min_interval_seconds`:同一个群监听最小推送间隔(防刷屏) - `ai_min_interval_seconds`:同一个群监听最小推送间隔(防刷屏)
- `ai_dedupe_window_seconds`:相同内容摘要去重窗口(防重复) - `ai_dedupe_window_seconds`:相同内容摘要去重窗口(防重复)
- 机器人想收到群里普通消息,需要在 `@BotFather` 执行 `/setprivacy` 关闭隐私模式。 - 机器人想收到群里普通消息,需要在 `@BotFather` 执行 `/setprivacy` 关闭隐私模式。
- 若使用 `listen_source=user_session`,需在设置页填写 `TG_API_ID``TG_API_HASH``TG_API_SESSION` 后重启。
更新代码(`/update`)已支持安全检查: 更新代码(`/update`)已支持安全检查:
- 显示本地/远端 commit、ahead/behind、工作区是否干净 - 显示本地/远端 commit、ahead/behind、工作区是否干净
+227 -21
View File
@@ -24,6 +24,7 @@ from contextlib import closing
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import Any from typing import Any
from urllib.parse import quote_plus, urljoin from urllib.parse import quote_plus, urljoin
@@ -44,6 +45,14 @@ from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, st
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse
import uvicorn import uvicorn
try:
from telethon import TelegramClient, events
from telethon.sessions import StringSession
except Exception: # pragma: no cover - optional dependency
TelegramClient = None
events = None
StringSession = None
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "tg-watchbot.sqlite3" DB_PATH = BASE_DIR / "tg-watchbot.sqlite3"
CONFIG_PATH = BASE_DIR / "config.yaml" CONFIG_PATH = BASE_DIR / "config.yaml"
@@ -68,6 +77,8 @@ config: dict[str, Any] = {}
rate_buckets: dict[int, list[float]] = {} rate_buckets: dict[int, list[float]] = {}
pending_sendpic: dict[int, dict[str, Any]] = {} pending_sendpic: dict[int, dict[str, Any]] = {}
scheduler_ref: AsyncIOScheduler | None = None scheduler_ref: AsyncIOScheduler | None = None
user_session_listener_task: asyncio.Task | None = None
user_session_client: Any = None
GROUP_SUMMARY_MAX_CHARS = 800 GROUP_SUMMARY_MAX_CHARS = 800
@@ -442,25 +453,41 @@ def group_monitors() -> list[dict[str, Any]]:
ai_interface = str(row.get("ai_interface") or "responses").strip().lower() or "responses" ai_interface = str(row.get("ai_interface") or "responses").strip().lower() or "responses"
if ai_interface not in {"responses", "chat"}: if ai_interface not in {"responses", "chat"}:
ai_interface = "responses" ai_interface = "responses"
listen_source = str(row.get("listen_source") or "bot").strip().lower() or "bot"
if listen_source not in {"bot", "user_session"}:
listen_source = "bot"
keywords = [str(k).strip() for k in (row.get("keywords") or []) if str(k).strip()] 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()] exclude_keywords = [str(k).strip() for k in (row.get("exclude_keywords") or []) if str(k).strip()]
monitors.append( monitors.append(
{ {
"name": str(row.get("name") or str(chat_id)), "name": str(row.get("name") or str(chat_id)),
"chat_id": chat_id, "chat_id": chat_id,
"listen_source": listen_source,
"summary_mode": summary_mode, "summary_mode": summary_mode,
"keywords": keywords, "keywords": keywords,
"exclude_keywords": exclude_keywords, "exclude_keywords": exclude_keywords,
"notify_telegram": bool(row.get("notify_telegram", True)), "notify_telegram": bool(row.get("notify_telegram", True)),
"ai_base_url": str(row.get("ai_base_url") or "").strip(), "ai_base_url": str(row.get("ai_base_url") or "").strip(),
"ai_api_key": str(row.get("ai_api_key") 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_model": str(row.get("ai_model") or "gpt-4o-mini").strip(),
"ai_interface": ai_interface, "ai_interface": ai_interface,
"ai_temperature": safe_float(row.get("ai_temperature", 0.2), 0.2), "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_timeout_seconds": max(1, safe_int(row.get("ai_timeout_seconds", 30), 30)),
"ai_prompt": str(row.get("ai_prompt") or "").strip(), "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_min_interval_seconds": max(
"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)), 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 return monitors
@@ -473,6 +500,44 @@ def group_monitor_for_chat(chat_id: int) -> dict[str, Any] | None:
return None return None
def group_monitor_for_chat_and_source(chat_id: int, listen_source: str) -> dict[str, Any] | None:
source = (listen_source or "bot").strip().lower() or "bot"
for monitor in group_monitors():
if int(monitor["chat_id"]) != int(chat_id):
continue
if str(monitor.get("listen_source") or "bot") == source:
return monitor
return None
def group_monitors_need_user_session() -> bool:
for monitor in group_monitors():
if str(monitor.get("listen_source") or "bot") == "user_session":
return True
return False
def user_session_config() -> tuple[str, str, str]:
load_dotenv(ENV_PATH, override=True)
api_id = os.getenv("TG_API_ID", "").strip()
api_hash = os.getenv("TG_API_HASH", "").strip()
session = os.getenv("TG_API_SESSION", "").strip()
return api_id, api_hash, session
def user_session_ready() -> bool:
api_id, api_hash, session = user_session_config()
if not api_id or not api_hash:
return False
if not session:
return False
try:
int(api_id)
except Exception:
return False
return True
def group_message_text(message: Message) -> str: def group_message_text(message: Message) -> str:
parts = [message.text or "", message.caption or ""] parts = [message.text or "", message.caption or ""]
if getattr(message, "reply_to_message", None): if getattr(message, "reply_to_message", None):
@@ -718,8 +783,8 @@ def group_monitor_allow_send(monitor: dict[str, Any], fingerprint: str, now_ts:
return True, "" return True, ""
async def handle_group_keyword_message(message: Message) -> bool: async def handle_group_keyword_message(message: Message, listen_source: str = "bot") -> bool:
monitor = group_monitor_for_chat(int(message.chat.id)) monitor = group_monitor_for_chat_and_source(int(message.chat.id), listen_source)
if not monitor: if not monitor:
return False return False
text = group_message_text(message) text = group_message_text(message)
@@ -748,6 +813,93 @@ async def handle_group_keyword_message(message: Message) -> bool:
return True return True
def build_pseudo_message_from_user_session_event(event: Any) -> Any | None:
msg = getattr(event, "message", None)
if msg is None:
return None
try:
chat_id = int(getattr(event, "chat_id"))
except Exception:
return None
text = str(getattr(msg, "text", "") or getattr(msg, "message", "") or "").strip()
caption = str(getattr(msg, "caption", "") or "").strip()
content = text or caption
if not content:
return None
chat_title = str(getattr(getattr(event, "chat", None), "title", "") or str(chat_id))
chat_username = str(getattr(getattr(event, "chat", None), "username", "") or "")
sender_id = getattr(event, "sender_id", None)
from_user = SimpleNamespace(
id=int(sender_id) if sender_id is not None else 0,
first_name="",
last_name="",
username="",
)
return SimpleNamespace(
chat=SimpleNamespace(
id=chat_id,
type="supergroup" if str(chat_id).startswith("-100") else "group",
title=chat_title,
username=chat_username,
),
from_user=from_user,
text=text or content,
caption=caption or None,
reply_to_message=None,
message_id=int(getattr(msg, "id", 0) or 0),
content_type="text",
)
async def run_user_session_group_listener() -> None:
global user_session_client
if TelegramClient is None or StringSession is None:
logger.warning("user-session group listener skipped: telethon is not installed")
return
api_id_raw, api_hash, session = user_session_config()
if not api_id_raw or not api_hash or not session:
logger.warning("user-session group listener skipped: TG_API_ID/TG_API_HASH/TG_API_SESSION not complete")
return
try:
api_id = int(api_id_raw)
except Exception:
logger.warning("user-session group listener skipped: TG_API_ID must be integer")
return
try:
client = TelegramClient(StringSession(session), api_id, api_hash)
user_session_client = client
@client.on(events.NewMessage(incoming=True)) # type: ignore[misc]
async def on_new_group_message(event: Any) -> None:
if not (getattr(event, "is_group", False) or getattr(event, "is_channel", False)):
return
pseudo = build_pseudo_message_from_user_session_event(event)
if pseudo is None:
return
record_discovered_group_chat_data(
int(pseudo.chat.id),
str(getattr(pseudo.chat, "title", "") or pseudo.chat.id),
str(getattr(pseudo.chat, "username", "") or ""),
)
await handle_group_keyword_message(pseudo, listen_source="user_session")
await client.start()
logger.info("user-session group listener started")
await client.run_until_disconnected()
except asyncio.CancelledError:
logger.info("user-session group listener cancelled")
raise
except Exception:
logger.exception("user-session group listener crashed")
finally:
try:
if user_session_client is not None:
await user_session_client.disconnect()
except Exception:
logger.exception("user-session listener disconnect failed")
user_session_client = None
def update_spam_keywords(action: str, word: str) -> list[str]: def update_spam_keywords(action: str, word: str) -> list[str]:
cfg = cfg_load_fresh() cfg = cfg_load_fresh()
bot_cfg = cfg.setdefault("bot", {}) bot_cfg = cfg.setdefault("bot", {})
@@ -847,12 +999,7 @@ def get_monitor_status_badge(status: dict[str, Any] | None) -> str:
return "正常" return "正常"
def record_discovered_group_chat(message: Message) -> None: def record_discovered_group_chat_data(chat_id: int, title: str, username: str = "") -> None:
if message.chat.type not in {"group", "supergroup"}:
return
chat_id = int(message.chat.id)
title = str(getattr(message.chat, "title", "") or str(chat_id))
username = str(getattr(message.chat, "username", "") or "")
with closing(db()) as conn: with closing(db()) as conn:
conn.execute( conn.execute(
""" """
@@ -864,11 +1011,20 @@ def record_discovered_group_chat(message: Message) -> None:
last_seen_at=excluded.last_seen_at, last_seen_at=excluded.last_seen_at,
active=1 active=1
""", """,
(chat_id, title, username, now_iso()), (int(chat_id), str(title or chat_id), str(username or ""), now_iso()),
) )
conn.commit() conn.commit()
def record_discovered_group_chat(message: Message) -> None:
if message.chat.type not in {"group", "supergroup"}:
return
chat_id = int(message.chat.id)
title = str(getattr(message.chat, "title", "") or str(chat_id))
username = str(getattr(message.chat, "username", "") or "")
record_discovered_group_chat_data(chat_id, title, username)
def list_discovered_group_chats(limit: int = 200) -> list[dict[str, Any]]: def list_discovered_group_chats(limit: int = 200) -> list[dict[str, Any]]:
with closing(db()) as conn: with closing(db()) as conn:
rows = conn.execute( rows = conn.execute(
@@ -1237,7 +1393,7 @@ async def user_message(message: Message) -> None:
if message.chat.type in {"group", "supergroup"}: if message.chat.type in {"group", "supergroup"}:
try: try:
record_discovered_group_chat(message) record_discovered_group_chat(message)
await handle_group_keyword_message(message) await handle_group_keyword_message(message, listen_source="bot")
except Exception: except Exception:
logger.exception("group keyword handling failed chat_id=%s message_id=%s", message.chat.id, message.message_id) logger.exception("group keyword handling failed chat_id=%s message_id=%s", message.chat.id, message.message_id)
logger.info("incoming message ignored because chat_type is not private: %s", message.chat.type) logger.info("incoming message ignored because chat_type is not private: %s", message.chat.type)
@@ -1783,6 +1939,9 @@ def env_values() -> dict[str, str]:
"WEB_PANEL_USER": os.getenv("WEB_PANEL_USER", "admin"), "WEB_PANEL_USER": os.getenv("WEB_PANEL_USER", "admin"),
"WEB_PANEL_PASSWORD": os.getenv("WEB_PANEL_PASSWORD", "admin"), "WEB_PANEL_PASSWORD": os.getenv("WEB_PANEL_PASSWORD", "admin"),
"WEB_PANEL_SESSION_SECRET": os.getenv("WEB_PANEL_SESSION_SECRET", ""), "WEB_PANEL_SESSION_SECRET": os.getenv("WEB_PANEL_SESSION_SECRET", ""),
"TG_API_ID": os.getenv("TG_API_ID", ""),
"TG_API_HASH": os.getenv("TG_API_HASH", ""),
"TG_API_SESSION": os.getenv("TG_API_SESSION", ""),
} }
@@ -1807,6 +1966,9 @@ def write_env_values(values: dict[str, str]) -> None:
f"WEB_PANEL_USER={values.get('WEB_PANEL_USER','admin')}", f"WEB_PANEL_USER={values.get('WEB_PANEL_USER','admin')}",
f"WEB_PANEL_PASSWORD={values.get('WEB_PANEL_PASSWORD','admin')}", f"WEB_PANEL_PASSWORD={values.get('WEB_PANEL_PASSWORD','admin')}",
f"WEB_PANEL_SESSION_SECRET={session_value}", f"WEB_PANEL_SESSION_SECRET={session_value}",
f"TG_API_ID={values.get('TG_API_ID','')}",
f"TG_API_HASH={values.get('TG_API_HASH','')}",
f"TG_API_SESSION={values.get('TG_API_SESSION','')}",
"", "",
] ]
ENV_PATH.write_text("\n".join(lines), encoding="utf-8") ENV_PATH.write_text("\n".join(lines), encoding="utf-8")
@@ -1841,6 +2003,10 @@ def cfg_save(new_cfg: dict[str, Any]) -> None:
gm.setdefault("keywords", []) gm.setdefault("keywords", [])
gm.setdefault("exclude_keywords", []) gm.setdefault("exclude_keywords", [])
gm.setdefault("notify_telegram", True) gm.setdefault("notify_telegram", True)
listen_source = str(gm.get("listen_source") or "bot").strip().lower() or "bot"
if listen_source not in {"bot", "user_session"}:
listen_source = "bot"
gm["listen_source"] = listen_source
summary_mode = str(gm.get("summary_mode") or "template").strip().lower() or "template" summary_mode = str(gm.get("summary_mode") or "template").strip().lower() or "template"
if summary_mode not in {"template", "ai"}: if summary_mode not in {"template", "ai"}:
summary_mode = "template" summary_mode = "template"
@@ -2140,6 +2306,7 @@ def group_monitor_form_html(m: dict[str, Any] | None = None, idx: int | None = N
"ai_prompt": "", "ai_prompt": "",
"ai_min_interval_seconds": DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS, "ai_min_interval_seconds": DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS,
"ai_dedupe_window_seconds": DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS, "ai_dedupe_window_seconds": DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS,
"listen_source": "bot",
} }
action = "/group-monitors/save" if idx is not None else "/group-monitors/create" 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 "" hidden = f"<input type=hidden name=original_index value='{idx}'>" if idx is not None else ""
@@ -2150,9 +2317,11 @@ 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> <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 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><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=listen_source><option value=bot {'selected' if str(m.get('listen_source', 'bot')) == 'bot' else ''}>Bot</option><option value=user_session {'selected' if str(m.get('listen_source')) == 'user_session' else ''}>用户会话</option></select></div><div><label>来源说明</label><input value='Bot 需要被拉进群;用户会话适合 Bot 拉不进去的群' readonly></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>总结模式</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 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 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>
<p class=muted style='margin:0'>监听来源选“用户会话”时,需要在“Bot / 面板设置”填写 TG_API_ID、TG_API_HASH、TG_API_SESSION,并重启。</p>
<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>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> <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>AI 总结提示词(可选)</label><textarea name=ai_prompt placeholder='留空则使用默认总结提示词'>{html_escape(m.get('ai_prompt',''))}</textarea>
@@ -2230,10 +2399,11 @@ def create_panel_app() -> FastAPI:
continue continue
enabled = "启用" if gm.get("enabled", True) else "关闭" enabled = "启用" if gm.get("enabled", True) else "关闭"
notify = "推送 TG" if gm.get("notify_telegram", True) else "仅记录" notify = "推送 TG" if gm.get("notify_telegram", True) else "仅记录"
source = "Bot" if str(gm.get("listen_source") or "bot") == "bot" else "用户会话"
kws = ", ".join([str(x) for x in (gm.get("keywords") or [])]) or "-" kws = ", ".join([str(x) for x in (gm.get("keywords") or [])]) or "-"
exs = ", ".join([str(x) for x in (gm.get("exclude_keywords") or [])]) or "-" exs = ", ".join([str(x) for x in (gm.get("exclude_keywords") or [])]) or "-"
trs.append( trs.append(
f"""<tr><td>{i+1}</td><td><b>{html_escape(gm.get('name') or gm.get('chat_id') or '-')}</b><br><small>{html_escape(gm.get('chat_id') or '-')}</small></td><td>{enabled}<br><small>{notify}</small></td><td>{html_escape(kws)}</td><td>{html_escape(exs)}</td><td><a class=btn href='/group-monitors/{i}/edit'>编辑</a> <a class='btn danger' href='/group-monitors/{i}/delete' onclick='return confirm("确定删除?")'>删除</a></td></tr>""" f"""<tr><td>{i+1}</td><td><b>{html_escape(gm.get('name') or gm.get('chat_id') or '-')}</b><br><small>{html_escape(gm.get('chat_id') or '-')}</small></td><td>{enabled}<br><small>{notify} · {source}</small></td><td>{html_escape(kws)}</td><td>{html_escape(exs)}</td><td><a class=btn href='/group-monitors/{i}/edit'>编辑</a> <a class='btn danger' href='/group-monitors/{i}/delete' onclick='return confirm("确定删除?")'>删除</a></td></tr>"""
) )
discovered_rows = [] discovered_rows = []
for row in discovered: for row in discovered:
@@ -2244,11 +2414,23 @@ def create_panel_app() -> FastAPI:
discovered_rows.append( discovered_rows.append(
f"""<tr><td><b>{html_escape(title)}</b><br><small>{html_escape(username)}</small></td><td><code>{chat_id}</code></td><td>{html_escape(row['last_seen_at'])}</td><td><a class='btn ok' href='{create_link}'>用此群创建监听</a></td></tr>""" f"""<tr><td><b>{html_escape(title)}</b><br><small>{html_escape(username)}</small></td><td><code>{chat_id}</code></td><td>{html_escape(row['last_seen_at'])}</td><td><a class='btn ok' href='{create_link}'>用此群创建监听</a></td></tr>"""
) )
use_user_session = any(
isinstance(gm, dict) and str(gm.get("listen_source") or "bot") == "user_session"
for gm in rows
)
user_session_notice = ""
if use_user_session:
if TelegramClient is None:
user_session_notice = "<div class=msg>检测到“用户会话”监听,但未安装 telethon;该来源不会生效。</div>"
elif not user_session_ready():
user_session_notice = "<div class=msg>检测到“用户会话”监听,但 TG_API_ID / TG_API_HASH / TG_API_SESSION 未完整填写;该来源不会生效。</div>"
body = ( body = (
"<div class=card><div class=toolbar><div><h2 style='margin:0 0 6px'>TG 群关键词监听</h2>" "<div class=card><div class=toolbar><div><h2 style='margin:0 0 6px'>TG 群关键词监听</h2>"
"<p class=muted style='margin:0'>监听选定群并在命中关键词时给管理员发送摘要。" "<p class=muted style='margin:0'>监听选定群并在命中关键词时给管理员发送摘要。"
"需要在 @BotFather 关闭 /setprivacy 才能接收群普通消息。</p></div>" "需要在 @BotFather 关闭 /setprivacy 才能接收群普通消息。</p></div>"
"<div class=actions><a class='btn primary' href='/group-monitors/new'>新增监听</a></div></div>" "<div class=actions><a class='btn primary' href='/group-monitors/new'>新增监听</a></div></div>"
+ user_session_notice
+
"<table style='margin-top:16px'><tr><th>#</th><th>监听</th><th>状态</th><th>关键词</th><th>排除词</th><th>操作</th></tr>" "<table style='margin-top:16px'><tr><th>#</th><th>监听</th><th>状态</th><th>关键词</th><th>排除词</th><th>操作</th></tr>"
+ "".join(trs) + "</table></div>" + "".join(trs) + "</table></div>"
+ "<div class=card><h2>已发现群聊</h2><p class=muted>Bot 在群里收到消息后会自动记录群信息。可直接选择群聊创建监听。</p>" + "<div class=card><h2>已发现群聊</h2><p class=muted>Bot 在群里收到消息后会自动记录群信息。可直接选择群聊创建监听。</p>"
@@ -2302,6 +2484,7 @@ def create_panel_app() -> FastAPI:
exclude_keywords: str, exclude_keywords: str,
enabled: str | None, enabled: str | None,
notify_telegram: str | None, notify_telegram: str | None,
listen_source: str,
summary_mode: str, summary_mode: str,
ai_base_url: str, ai_base_url: str,
ai_api_key: str, ai_api_key: str,
@@ -2322,6 +2505,9 @@ def create_panel_app() -> FastAPI:
parsed_summary_mode = (summary_mode or "template").strip().lower() or "template" parsed_summary_mode = (summary_mode or "template").strip().lower() or "template"
if parsed_summary_mode not in {"template", "ai"}: if parsed_summary_mode not in {"template", "ai"}:
parsed_summary_mode = "template" parsed_summary_mode = "template"
parsed_listen_source = (listen_source or "bot").strip().lower() or "bot"
if parsed_listen_source not in {"bot", "user_session"}:
parsed_listen_source = "bot"
parsed_ai_interface = (ai_interface or "responses").strip().lower() or "responses" parsed_ai_interface = (ai_interface or "responses").strip().lower() or "responses"
if parsed_ai_interface not in {"responses", "chat"}: if parsed_ai_interface not in {"responses", "chat"}:
parsed_ai_interface = "responses" parsed_ai_interface = "responses"
@@ -2332,6 +2518,7 @@ def create_panel_app() -> FastAPI:
"keywords": parse_lines(keywords), "keywords": parse_lines(keywords),
"exclude_keywords": parse_lines(exclude_keywords), "exclude_keywords": parse_lines(exclude_keywords),
"notify_telegram": bool(notify_telegram), "notify_telegram": bool(notify_telegram),
"listen_source": parsed_listen_source,
"summary_mode": parsed_summary_mode, "summary_mode": parsed_summary_mode,
"ai_base_url": ai_base_url.strip(), "ai_base_url": ai_base_url.strip(),
"ai_api_key": ai_api_key.strip(), "ai_api_key": ai_api_key.strip(),
@@ -2364,6 +2551,7 @@ def create_panel_app() -> FastAPI:
enabled: str | None = Form(None), enabled: str | None = Form(None),
notify_telegram: str | None = Form(None), notify_telegram: str | None = Form(None),
summary_mode: str = Form("template"), summary_mode: str = Form("template"),
listen_source: str = Form("bot"),
ai_base_url: str = Form(""), ai_base_url: str = Form(""),
ai_api_key: str = Form(""), ai_api_key: str = Form(""),
ai_model: str = Form("gpt-4o-mini"), ai_model: str = Form("gpt-4o-mini"),
@@ -2382,6 +2570,7 @@ def create_panel_app() -> FastAPI:
exclude_keywords, exclude_keywords,
enabled, enabled,
notify_telegram, notify_telegram,
listen_source,
summary_mode, summary_mode,
ai_base_url, ai_base_url,
ai_api_key, ai_api_key,
@@ -2405,6 +2594,7 @@ def create_panel_app() -> FastAPI:
enabled: str | None = Form(None), enabled: str | None = Form(None),
notify_telegram: str | None = Form(None), notify_telegram: str | None = Form(None),
summary_mode: str = Form("template"), summary_mode: str = Form("template"),
listen_source: str = Form("bot"),
ai_base_url: str = Form(""), ai_base_url: str = Form(""),
ai_api_key: str = Form(""), ai_api_key: str = Form(""),
ai_model: str = Form("gpt-4o-mini"), ai_model: str = Form("gpt-4o-mini"),
@@ -2423,6 +2613,7 @@ def create_panel_app() -> FastAPI:
exclude_keywords, exclude_keywords,
enabled, enabled,
notify_telegram, notify_telegram,
listen_source,
summary_mode, summary_mode,
ai_base_url, ai_base_url,
ai_api_key, ai_api_key,
@@ -2613,6 +2804,9 @@ HostLoc|https://hostloc.com|VPS,补货,优惠"""
body = f"""<h2>Bot / 面板设置</h2>{status}<div class=card><form method=post> body = f"""<h2>Bot / 面板设置</h2>{status}<div class=card><form method=post>
<label>Telegram Bot Token</label><input name=TELEGRAM_BOT_TOKEN value='{html_escape(v['TELEGRAM_BOT_TOKEN'])}' placeholder='123456:ABC...'> <label>Telegram Bot Token</label><input name=TELEGRAM_BOT_TOKEN value='{html_escape(v['TELEGRAM_BOT_TOKEN'])}' placeholder='123456:ABC...'>
<label>管理员 ADMIN_CHAT_ID(最多 3 个,用逗号分隔)</label><input name=ADMIN_CHAT_ID value='{html_escape(v['ADMIN_CHAT_ID'])}'> <label>管理员 ADMIN_CHAT_ID(最多 3 个,用逗号分隔)</label><input name=ADMIN_CHAT_ID value='{html_escape(v['ADMIN_CHAT_ID'])}'>
<h3>TG 用户会话(可选)</h3><p class=muted>仅用于“TG 群监听 -> 监听来源=用户会话”,适合 Bot 无法加入的群。填写后需重启。</p>
<div class=grid><div><label>TG_API_ID</label><input name=TG_API_ID value='{html_escape(v['TG_API_ID'])}' placeholder='例如 12345678'></div><div><label>TG_API_HASH</label><input name=TG_API_HASH value='{html_escape(v['TG_API_HASH'])}' placeholder='32位哈希'></div></div>
<label>TG_API_SESSION</label><textarea name=TG_API_SESSION placeholder='Telethon StringSession'>{html_escape(v['TG_API_SESSION'])}</textarea>
<div class=grid><div><label>日志级别</label><input name=LOG_LEVEL value='{html_escape(v['LOG_LEVEL'])}'></div><div><label>面板监听地址</label><input name=WEB_PANEL_HOST value='{html_escape(v['WEB_PANEL_HOST'])}'></div><div><label>面板端口</label><input name=WEB_PANEL_PORT value='{html_escape(v['WEB_PANEL_PORT'])}'></div><div><label>面板用户</label><input name=WEB_PANEL_USER value='{html_escape(v['WEB_PANEL_USER'])}'></div><div><label>面板密码</label><input name=WEB_PANEL_PASSWORD value='{html_escape(v['WEB_PANEL_PASSWORD'])}'></div></div> <div class=grid><div><label>日志级别</label><input name=LOG_LEVEL value='{html_escape(v['LOG_LEVEL'])}'></div><div><label>面板监听地址</label><input name=WEB_PANEL_HOST value='{html_escape(v['WEB_PANEL_HOST'])}'></div><div><label>面板端口</label><input name=WEB_PANEL_PORT value='{html_escape(v['WEB_PANEL_PORT'])}'></div><div><label>面板用户</label><input name=WEB_PANEL_USER value='{html_escape(v['WEB_PANEL_USER'])}'></div><div><label>面板密码</label><input name=WEB_PANEL_PASSWORD value='{html_escape(v['WEB_PANEL_PASSWORD'])}'></div></div>
<h3>监控数据自动清理</h3><p class=muted>删除过期监控通知消息,并清理 RSS/网站监控状态和去重记录;不会删除用户、收件箱、双向对话消息。</p><div class=grid><div><label>清理间隔(分钟)</label><input name=CLEANUP_INTERVAL_MINUTES type=number min=1 value='{html_escape(cleanup.get("interval_minutes", 60))}'></div><div><label>监控通知删除时间(分钟)</label><input name=CLEANUP_MESSAGE_DELETE_AFTER_MINUTES type=number min=1 value='{html_escape(cleanup.get("monitor_message_delete_after_minutes", 60))}'></div><div><label>保留监控数据(分钟)</label><input name=CLEANUP_RETENTION_MINUTES type=number min=1 value='{html_escape(cleanup.get("monitor_retention_minutes", 1440))}'></div></div> <h3>监控数据自动清理</h3><p class=muted>删除过期监控通知消息,并清理 RSS/网站监控状态和去重记录;不会删除用户、收件箱、双向对话消息。</p><div class=grid><div><label>清理间隔(分钟)</label><input name=CLEANUP_INTERVAL_MINUTES type=number min=1 value='{html_escape(cleanup.get("interval_minutes", 60))}'></div><div><label>监控通知删除时间(分钟)</label><input name=CLEANUP_MESSAGE_DELETE_AFTER_MINUTES type=number min=1 value='{html_escape(cleanup.get("monitor_message_delete_after_minutes", 60))}'></div><div><label>保留监控数据(分钟)</label><input name=CLEANUP_RETENTION_MINUTES type=number min=1 value='{html_escape(cleanup.get("monitor_retention_minutes", 1440))}'></div></div>
<input type=hidden name=WEB_PANEL_ENABLED value='true'><div class=form-actions><button class='btn primary' type=submit>保存设置</button></div><small>改 Token、管理员 ID 或端口后需要重启。</small></form></div>""" <input type=hidden name=WEB_PANEL_ENABLED value='true'><div class=form-actions><button class='btn primary' type=submit>保存设置</button></div><small>改 Token、管理员 ID 或端口后需要重启。</small></form></div>"""
@@ -2635,7 +2829,7 @@ HostLoc|https://hostloc.com|VPS,补货,优惠"""
cfg_save(cfg) cfg_save(cfg)
@app.post("/settings", response_class=HTMLResponse) @app.post("/settings", response_class=HTMLResponse)
async def settings_save(_: str = Depends(panel_auth), TELEGRAM_BOT_TOKEN: str = Form(""), ADMIN_CHAT_ID: str = Form(""), LOG_LEVEL: str = Form("INFO"), WEB_PANEL_ENABLED: str = Form("true"), WEB_PANEL_HOST: str = Form("127.0.0.1"), WEB_PANEL_PORT: str = Form("8765"), WEB_PANEL_USER: str = Form("admin"), WEB_PANEL_PASSWORD: str = Form("admin"), CLEANUP_INTERVAL_MINUTES: int = Form(60), CLEANUP_MESSAGE_DELETE_AFTER_MINUTES: int = Form(60), CLEANUP_RETENTION_MINUTES: int = Form(1440)) -> str: async def settings_save(_: str = Depends(panel_auth), TELEGRAM_BOT_TOKEN: str = Form(""), ADMIN_CHAT_ID: str = Form(""), TG_API_ID: str = Form(""), TG_API_HASH: str = Form(""), TG_API_SESSION: str = Form(""), LOG_LEVEL: str = Form("INFO"), WEB_PANEL_ENABLED: str = Form("true"), WEB_PANEL_HOST: str = Form("127.0.0.1"), WEB_PANEL_PORT: str = Form("8765"), WEB_PANEL_USER: str = Form("admin"), WEB_PANEL_PASSWORD: str = Form("admin"), CLEANUP_INTERVAL_MINUTES: int = Form(60), CLEANUP_MESSAGE_DELETE_AFTER_MINUTES: int = Form(60), CLEANUP_RETENTION_MINUTES: int = Form(1440)) -> str:
save_panel_settings(locals() | {"WEB_PANEL_ENABLED": WEB_PANEL_ENABLED}, CLEANUP_INTERVAL_MINUTES, CLEANUP_MESSAGE_DELETE_AFTER_MINUTES, CLEANUP_RETENTION_MINUTES) save_panel_settings(locals() | {"WEB_PANEL_ENABLED": WEB_PANEL_ENABLED}, CLEANUP_INTERVAL_MINUTES, CLEANUP_MESSAGE_DELETE_AFTER_MINUTES, CLEANUP_RETENTION_MINUTES)
return layout("已保存", "<div class=msg>已保存,不会自动重启;修改 Token/管理员 ID 后请重启。</div><p><a class=btn href='/settings'>返回</a> <a class=btn href='/restart'>重启机器人</a></p>") return layout("已保存", "<div class=msg>已保存,不会自动重启;修改 Token/管理员 ID 后请重启。</div><p><a class=btn href='/settings'>返回</a> <a class=btn href='/restart'>重启机器人</a></p>")
@@ -2757,13 +2951,16 @@ HostLoc|https://hostloc.com|VPS,补货,优惠"""
settings_card = f"""<div class=card><h2>Bot / 面板配置</h2><p class=muted>这里和“Bot / 面板设置”共用同一份 .env。修改 Token、管理员 ID、端口、账号或密码后不会自动重启,需要手动重启服务。</p><form method=post action='/users/settings'> settings_card = f"""<div class=card><h2>Bot / 面板配置</h2><p class=muted>这里和“Bot / 面板设置”共用同一份 .env。修改 Token、管理员 ID、端口、账号或密码后不会自动重启,需要手动重启服务。</p><form method=post action='/users/settings'>
<label>Telegram Bot Token</label><input name=TELEGRAM_BOT_TOKEN value='{html_escape(v['TELEGRAM_BOT_TOKEN'])}' placeholder='123456:ABC...'> <label>Telegram Bot Token</label><input name=TELEGRAM_BOT_TOKEN value='{html_escape(v['TELEGRAM_BOT_TOKEN'])}' placeholder='123456:ABC...'>
<label>管理员 ADMIN_CHAT_ID(最多 3 个,用逗号分隔)</label><input name=ADMIN_CHAT_ID value='{html_escape(v['ADMIN_CHAT_ID'])}'> <label>管理员 ADMIN_CHAT_ID(最多 3 个,用逗号分隔)</label><input name=ADMIN_CHAT_ID value='{html_escape(v['ADMIN_CHAT_ID'])}'>
<h3>TG 用户会话(可选)</h3><p class=muted>仅用于 TG 群监听来源=用户会话。修改后需重启。</p>
<div class=grid><div><label>TG_API_ID</label><input name=TG_API_ID value='{html_escape(v['TG_API_ID'])}'></div><div><label>TG_API_HASH</label><input name=TG_API_HASH value='{html_escape(v['TG_API_HASH'])}'></div></div>
<label>TG_API_SESSION</label><textarea name=TG_API_SESSION>{html_escape(v['TG_API_SESSION'])}</textarea>
<div class=grid><div><label>日志级别</label><input name=LOG_LEVEL value='{html_escape(v['LOG_LEVEL'])}'></div><div><label>面板监听地址</label><input name=WEB_PANEL_HOST value='{html_escape(v['WEB_PANEL_HOST'])}'></div><div><label>面板端口</label><input name=WEB_PANEL_PORT value='{html_escape(v['WEB_PANEL_PORT'])}'></div><div><label>面板用户</label><input name=WEB_PANEL_USER value='{html_escape(v['WEB_PANEL_USER'])}'></div><div><label>面板密码</label><input name=WEB_PANEL_PASSWORD value='{html_escape(v['WEB_PANEL_PASSWORD'])}'></div></div> <div class=grid><div><label>日志级别</label><input name=LOG_LEVEL value='{html_escape(v['LOG_LEVEL'])}'></div><div><label>面板监听地址</label><input name=WEB_PANEL_HOST value='{html_escape(v['WEB_PANEL_HOST'])}'></div><div><label>面板端口</label><input name=WEB_PANEL_PORT value='{html_escape(v['WEB_PANEL_PORT'])}'></div><div><label>面板用户</label><input name=WEB_PANEL_USER value='{html_escape(v['WEB_PANEL_USER'])}'></div><div><label>面板密码</label><input name=WEB_PANEL_PASSWORD value='{html_escape(v['WEB_PANEL_PASSWORD'])}'></div></div>
<input type=hidden name=WEB_PANEL_ENABLED value='true'><div class=form-actions><button class='btn primary' type=submit>保存配置</button> <a class=btn href='/restart'>重启机器人</a></div></form></div>""" <input type=hidden name=WEB_PANEL_ENABLED value='true'><div class=form-actions><button class='btn primary' type=submit>保存配置</button> <a class=btn href='/restart'>重启机器人</a></div></form></div>"""
body = settings_card + "<div class=card><h2>用户管理</h2><table><tr><th>用户</th><th>状态</th><th>备注</th><th>操作</th></tr>" + "".join(trs) + "</table></div>" body = settings_card + "<div class=card><h2>用户管理</h2><table><tr><th>用户</th><th>状态</th><th>备注</th><th>操作</th></tr>" + "".join(trs) + "</table></div>"
return layout("用户管理", body) return layout("用户管理", body)
@app.post("/users/settings", response_class=HTMLResponse) @app.post("/users/settings", response_class=HTMLResponse)
async def users_settings_save(_: str = Depends(panel_auth), TELEGRAM_BOT_TOKEN: str = Form(""), ADMIN_CHAT_ID: str = Form(""), LOG_LEVEL: str = Form("INFO"), WEB_PANEL_ENABLED: str = Form("true"), WEB_PANEL_HOST: str = Form("127.0.0.1"), WEB_PANEL_PORT: str = Form("8765"), WEB_PANEL_USER: str = Form("admin"), WEB_PANEL_PASSWORD: str = Form("admin")) -> str: async def users_settings_save(_: str = Depends(panel_auth), TELEGRAM_BOT_TOKEN: str = Form(""), ADMIN_CHAT_ID: str = Form(""), TG_API_ID: str = Form(""), TG_API_HASH: str = Form(""), TG_API_SESSION: str = Form(""), LOG_LEVEL: str = Form("INFO"), WEB_PANEL_ENABLED: str = Form("true"), WEB_PANEL_HOST: str = Form("127.0.0.1"), WEB_PANEL_PORT: str = Form("8765"), WEB_PANEL_USER: str = Form("admin"), WEB_PANEL_PASSWORD: str = Form("admin")) -> str:
cleanup = (cfg_load_fresh().get("cleanup") or {}) cleanup = (cfg_load_fresh().get("cleanup") or {})
save_panel_settings( save_panel_settings(
locals() | {"WEB_PANEL_ENABLED": WEB_PANEL_ENABLED}, locals() | {"WEB_PANEL_ENABLED": WEB_PANEL_ENABLED},
@@ -3013,7 +3210,7 @@ def bot_env_configured() -> bool:
async def main_async(run_once: bool = False, panel_only: bool = False) -> None: async def main_async(run_once: bool = False, panel_only: bool = False) -> None:
global bot, admin_chat_id, admin_chat_ids, config, scheduler_ref global bot, admin_chat_id, admin_chat_ids, config, scheduler_ref, user_session_listener_task
load_dotenv(ENV_PATH, override=True) load_dotenv(ENV_PATH, override=True)
config = load_config() config = load_config()
setup_logging(os.getenv("LOG_LEVEL", "INFO")) setup_logging(os.getenv("LOG_LEVEL", "INFO"))
@@ -3053,6 +3250,15 @@ async def main_async(run_once: bool = False, panel_only: bool = False) -> None:
scheduler.start() scheduler.start()
asyncio.create_task(flush_pending_loop()) asyncio.create_task(flush_pending_loop())
asyncio.create_task(cleanup_monitor_loop()) asyncio.create_task(cleanup_monitor_loop())
if group_monitors_need_user_session():
if TelegramClient is None:
logger.warning("group monitor with listen_source=user_session detected, but telethon is not installed")
elif not user_session_ready():
logger.warning(
"group monitor with listen_source=user_session detected, but TG_API_ID/TG_API_HASH/TG_API_SESSION is not complete"
)
else:
user_session_listener_task = asyncio.create_task(run_user_session_group_listener())
await admin_send(f"tg-watchbot 已启动\n时间:{now_iso()}") await admin_send(f"tg-watchbot 已启动\n时间:{now_iso()}")
logger.info("bot polling start") logger.info("bot polling start")
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
+1
View File
@@ -56,6 +56,7 @@ monitors:
group_monitors: group_monitors:
- name: TG 群关键词监听示例 - name: TG 群关键词监听示例
enabled: false enabled: false
listen_source: bot
chat_id: -1001234567890 chat_id: -1001234567890
keywords: keywords:
- VPS - VPS
+1
View File
@@ -8,3 +8,4 @@ python-dotenv==1.2.1
python-multipart==0.0.20 python-multipart==0.0.20
PyYAML==6.0.3 PyYAML==6.0.3
uvicorn[standard]==0.38.0 uvicorn[standard]==0.38.0
telethon==1.42.0
+20
View File
@@ -324,6 +324,7 @@ class PanelHtmlContractTest(unittest.TestCase):
"name=exclude_keywords", "name=exclude_keywords",
"name=enabled", "name=enabled",
"name=notify_telegram", "name=notify_telegram",
"name=listen_source",
"name=summary_mode", "name=summary_mode",
"name=ai_interface", "name=ai_interface",
"name=ai_base_url", "name=ai_base_url",
@@ -419,6 +420,7 @@ class GroupMonitorTest(unittest.TestCase):
self.assertEqual(-10099, saved["chat_id"]) self.assertEqual(-10099, saved["chat_id"])
self.assertEqual("template", saved["summary_mode"]) self.assertEqual("template", saved["summary_mode"])
self.assertEqual("responses", saved["ai_interface"]) self.assertEqual("responses", saved["ai_interface"])
self.assertEqual("bot", saved["listen_source"])
self.assertEqual(0.2, saved["ai_temperature"]) self.assertEqual(0.2, saved["ai_temperature"])
self.assertEqual(1, saved["ai_timeout_seconds"]) 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_MIN_INTERVAL_SECONDS, saved["ai_min_interval_seconds"])
@@ -525,6 +527,24 @@ class MonitorRuntimeAndUpdateTest(unittest.TestCase):
finally: finally:
app.config = old_config app.config = old_config
def test_group_monitor_for_chat_and_source_returns_matched_monitor(self) -> None:
old_config = app.config
app.config = {
"group_monitors": [
{"enabled": True, "chat_id": -10001, "listen_source": "bot", "keywords": ["vps"]},
{"enabled": True, "chat_id": -10001, "listen_source": "user_session", "keywords": ["api"]},
]
}
try:
monitor_bot = app.group_monitor_for_chat_and_source(-10001, "bot")
monitor_session = app.group_monitor_for_chat_and_source(-10001, "user_session")
self.assertIsNotNone(monitor_bot)
self.assertIsNotNone(monitor_session)
self.assertEqual("bot", monitor_bot["listen_source"])
self.assertEqual("user_session", monitor_session["listen_source"])
finally:
app.config = old_config
def test_handle_group_keyword_message_sends_summary_to_admin(self) -> None: def test_handle_group_keyword_message_sends_summary_to_admin(self) -> None:
old_config = app.config old_config = app.config
old_bot = app.bot old_bot = app.bot