From 78f85a3f82e5e489873d3c7e5fd1d7121f5e8b37 Mon Sep 17 00:00:00 2001 From: InfernoXuaI <1391197588@qq.com> Date: Tue, 23 Jun 2026 02:15:40 +0800 Subject: [PATCH] feat: improve panel theme and monitor interval defaults --- .dockerignore | 2 + .env.example | 3 +- README.md | 29 ++++-- app.py | 145 ++++++++++++++++++++++---- config.example.yaml | 4 +- docker-compose.yml | 2 +- tests/test_monitor_message_cleanup.py | 52 ++++++++- 7 files changed, 200 insertions(+), 37 deletions(-) diff --git a/.dockerignore b/.dockerignore index a2973f4..7d03e6d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,6 @@ .git +.env +config.yaml .venv __pycache__ *.pyc diff --git a/.env.example b/.env.example index 5bc666b..1ebf968 100644 --- a/.env.example +++ b/.env.example @@ -9,11 +9,12 @@ LOG_LEVEL=INFO # Web admin panel. Bind to localhost by default; use a reverse proxy / Cloudflare Tunnel if exposing it. WEB_PANEL_ENABLED=true -WEB_PANEL_HOST=0.0.0.0 +WEB_PANEL_HOST=127.0.0.1 WEB_PANEL_PORT=8765 WEB_PANEL_USER=admin WEB_PANEL_PASSWORD=change-me WEB_PANEL_SESSION_SECRET= +WEB_PANEL_COOKIE_SECURE= # Optional: user-session listener for TG groups where bot cannot be added. TG_API_ID= diff --git a/README.md b/README.md index 60b2f94..5d9af6e 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 - 支持去重,避免同一条反复推送。 - 支持屏蔽词、作者、分类过滤(YAML 高级配置)。 - 单个监控可关闭 Telegram 推送,只记录到 Web 推送历史。 -- 默认最低监控间隔为 60 秒。 +- 默认监控间隔为 30 秒,最低可设为 1 秒;频率越高越容易被目标站限流。 ![示例图片](https://pic.gongyichuren.de/file/1779287170665_17b7c8b4040d6334ea62a108d08db644.png) @@ -133,6 +133,8 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 - 批量新增监控。 - YAML 高级编辑。 - Bot Token / 管理员 ID / 面板账号配置页。 +- 明亮 / 暗黑主题切换,主题选择保存在当前浏览器。 +- 设置页可修改面板监听地址和端口,并会提示公网监听风险。 - 收件箱页面,可查看完整双向对话记录、重试转发、直接回复。 - 用户管理页,可备注、封禁、解封、主动发消息,并可编辑 Bot / 面板配置。 - 私聊广告拦截规则和快捷回复模板可在 Web 面板编辑。 @@ -170,7 +172,8 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 ## 安全说明 -- 如果要把面板暴露到公网,建议使用 Cloudflare Access / 反代鉴权,并使用强密码。 +- 默认建议只在本机访问面板;如果要公网访问,建议使用 Cloudflare Tunnel、Nginx/Caddy 反代鉴权,并使用强密码。 +- 监听地址填 `0.0.0.0` 会监听所有网卡;Docker 是否真正暴露公网还取决于端口映射和服务器防火墙。 - Bot 只能给“已经主动私聊过 Bot 的用户”发消息,这是 Telegram Bot API 的限制。 ## 快速开始 @@ -183,16 +186,18 @@ git clone https://github.com/GongyiChuren/tg-watchbot.git tg-watchbot cd tg-watchbot cp .env.example .env cp config.example.yaml config.yaml +chmod 600 .env touch tg-watchbot.sqlite3 tg-watchbot.log docker compose up -d --build ``` -Docker 会在容器内监听 `0.0.0.0:8765`,宿主机打开 `http://127.0.0.1:8765` 即可访问面板。 +Docker 容器内会监听 `0.0.0.0:8765`,但 `docker-compose.yml` 默认只把端口绑定到宿主机 `127.0.0.1:8765`。宿主机打开 `http://127.0.0.1:8765` 即可访问面板。 **⚠️ 注意事项:** -- `.env.example` 里的 `WEB_PANEL_HOST` 默认为 `0.0.0.0`,Docker 容器内面板会监听所有网口。如果需要从外部访问(如公网 IP),请确保服务器防火墙/安全组已放行 `8765` 端口。 +- `.env.example` 里的 `WEB_PANEL_HOST` 默认为 `127.0.0.1`;Docker Compose 会覆盖为容器内可访问的 `0.0.0.0`。 +- 不建议直接把 `8765` 裸露到公网;如需公网访问,优先使用 Cloudflare Tunnel、Nginx/Caddy 反代鉴权或 SSH 隧道。 - `.env` 文件会被容器挂载并写入(如 session secret),请勿设置为只读(`:ro`)。 -- 阿里云、腾讯云等云服务器需要在控制台的安全组/防火墙中手动放行端口,否则外部无法访问。 +- 如果你明确要直接公网访问,需要同时修改 `docker-compose.yml` 端口映射、服务器防火墙/安全组,并在面板里改强密码。 查看状态与日志: @@ -225,6 +230,7 @@ python3 -m venv .venv ./.venv/bin/pip install -r requirements.txt cp .env.example .env cp config.example.yaml config.yaml +chmod 600 .env ``` 启动: @@ -248,6 +254,8 @@ http://127.0.0.1:8765 登录后进入“设置”,填写 Bot Token、管理员 Telegram 数字 chat id、面板账号和密码。保存后重启服务,Bot 才会开始收发 Telegram 消息和发送监控通知。 +如需修改面板端口,在“设置”里调整 `WEB_PANEL_PORT` 并重启服务。手动部署默认只监听 `127.0.0.1`;如果改成 `0.0.0.0`,面板会监听所有网卡,请先确认反代鉴权或防火墙策略。 + 手动跑一次监控: ```bash @@ -341,11 +349,12 @@ curl http://127.0.0.1:8765/health | `ADMIN_CHAT_ID` | 管理员 Telegram 数字 chat id;最多 3 个,用逗号分隔 | | `LOG_LEVEL` | 日志级别,默认 `INFO` | | `WEB_PANEL_ENABLED` | 是否启用 Web 面板,默认 `true` | -| `WEB_PANEL_HOST` | 面板监听地址,默认 `127.0.0.1` | -| `WEB_PANEL_PORT` | 面板端口,默认 `8765` | +| `WEB_PANEL_HOST` | 面板监听地址,默认 `127.0.0.1`;Docker Compose 会在容器内覆盖为 `0.0.0.0` | +| `WEB_PANEL_PORT` | 面板端口,默认 `8765`;修改后需重启 | | `WEB_PANEL_USER` | 面板用户名 | | `WEB_PANEL_PASSWORD` | 面板密码 | | `WEB_PANEL_SESSION_SECRET` | Session Secret,留空会自动生成并写回 `.env` | +| `WEB_PANEL_COOKIE_SECURE` | 留空时按 HTTPS 自动判断;特殊反代场景可手动设为 `true` / `false` | | `TG_API_ID` | (可选)Telegram API ID,用于“TG 群监听=用户会话” | | `TG_API_HASH` | (可选)Telegram API Hash,用于“TG 群监听=用户会话” | | `TG_API_SESSION` | (可选)Telethon StringSession,用于“TG 群监听=用户会话” | @@ -449,7 +458,7 @@ monitors: - name: NodeSeek 新帖 type: rss url: https://rss.nodeseek.com/ - interval_seconds: 60 + interval_seconds: 30 keywords: - VPS - 优惠 @@ -540,8 +549,8 @@ monitors: ## 注意事项 - Telegram Bot 不能主动私聊陌生人;对方必须先给 Bot 发过 `/start` 或任意消息。 -- 对公网暴露 Web 面板前,务必改默认密码。 -- RSS 监控建议 60 秒起步;网页监控建议更保守,避免对目标站造成压力。 +- 对公网暴露 Web 面板前,务必改默认密码,并优先使用反代鉴权或 Tunnel。 +- RSS/Web 监控最低可设 1 秒,默认 30 秒;实际部署建议按目标站承受能力调高,避免被限流。 - 媒体消息当前只保证记录文本/说明和转发状态;转发失败后的媒体补发需要额外做本地附件存储。 ## License diff --git a/app.py b/app.py index 0119d1e..85e35f3 100644 --- a/app.py +++ b/app.py @@ -62,7 +62,8 @@ DB_PATH = BASE_DIR / "tg-watchbot.sqlite3" CONFIG_PATH = BASE_DIR / "config.yaml" ENV_PATH = BASE_DIR / ".env" LOG_PATH = BASE_DIR / "tg-watchbot.log" -MIN_INTERVAL_SECONDS = 60 +MIN_INTERVAL_SECONDS = 1 +DEFAULT_MONITOR_INTERVAL_SECONDS = 30 DEFAULT_MONITOR_MESSAGE_DELETE_AFTER_MINUTES = 60 DEFAULT_GROUP_AI_MIN_INTERVAL_SECONDS = 30 DEFAULT_GROUP_AI_DEDUPE_WINDOW_SECONDS = 300 @@ -2499,7 +2500,7 @@ async def run_all_monitors_once() -> None: def schedule_monitors(scheduler: AsyncIOScheduler) -> None: for idx, m in enumerate(config.get("monitors") or []): name = m.get("name", "unnamed") - requested = int(m.get("interval_seconds", MIN_INTERVAL_SECONDS)) + requested = int(m.get("interval_seconds", DEFAULT_MONITOR_INTERVAL_SECONDS)) interval = max(requested, MIN_INTERVAL_SECONDS) if requested < MIN_INTERVAL_SECONDS: logger.warning("monitor %s interval %s raised to %s", name, requested, interval) @@ -2539,18 +2540,72 @@ def is_logged_in(request: Request) -> bool: return bool(token) and secrets.compare_digest(token, session_token(username)) +def panel_cookie_secure(request: Request) -> bool: + forced = os.getenv("WEB_PANEL_COOKIE_SECURE", "").strip().lower() + if forced in {"1", "true", "yes", "on"}: + return True + if forced in {"0", "false", "no", "off"}: + return False + return str(request.url.scheme).lower() == "https" + + def panel_auth(request: Request) -> str: # Actual redirect is handled by middleware; dependencies cannot reliably return redirects. return os.getenv("WEB_PANEL_USER", "admin") +def theme_boot_script() -> str: + return """""" + + +def theme_interaction_script() -> str: + return """""" + + def login_page(error: str = "") -> str: err = f"
{html_escape(error)}
" if error else "" return f""" 登录 · tg-watchbot +{theme_boot_script()}

tg-watchbot

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

{err}
localhost panel
""" +

tg-watchbot

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

{err}
localhost panel
{theme_interaction_script()}""" def env_values() -> dict[str, str]: @@ -2593,6 +2660,7 @@ def env_values() -> dict[str, str]: "WEB_PANEL_USER": os.getenv("WEB_PANEL_USER", "admin"), "WEB_PANEL_PASSWORD": os.getenv("WEB_PANEL_PASSWORD", "admin"), "WEB_PANEL_SESSION_SECRET": os.getenv("WEB_PANEL_SESSION_SECRET", ""), + "WEB_PANEL_COOKIE_SECURE": os.getenv("WEB_PANEL_COOKIE_SECURE", ""), "TG_API_ID": os.getenv("TG_API_ID", ""), "TG_API_HASH": os.getenv("TG_API_HASH", ""), "TG_API_SESSION": os.getenv("TG_API_SESSION", ""), @@ -2608,6 +2676,9 @@ def write_env_values(values: dict[str, str]) -> None: key, value = line.split("=", 1) existing[key.strip()] = value.strip() session_value = values.get("WEB_PANEL_SESSION_SECRET") or existing.get("WEB_PANEL_SESSION_SECRET", "") + cookie_secure_value = values.get("WEB_PANEL_COOKIE_SECURE") + if cookie_secure_value is None: + cookie_secure_value = existing.get("WEB_PANEL_COOKIE_SECURE", "") lines = [ "# tg-watchbot environment", f"TELEGRAM_BOT_TOKEN={values.get('TELEGRAM_BOT_TOKEN','')}", @@ -2621,6 +2692,7 @@ def write_env_values(values: dict[str, str]) -> None: f"WEB_PANEL_USER={values.get('WEB_PANEL_USER','admin')}", f"WEB_PANEL_PASSWORD={values.get('WEB_PANEL_PASSWORD','admin')}", f"WEB_PANEL_SESSION_SECRET={session_value}", + f"WEB_PANEL_COOKIE_SECURE={cookie_secure_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','')}", @@ -2645,7 +2717,7 @@ def cfg_save(new_cfg: dict[str, Any]) -> None: for m in monitors: if not isinstance(m, dict): raise ValueError("每个 monitor 必须是对象") - if int(m.get("interval_seconds", MIN_INTERVAL_SECONDS)) < MIN_INTERVAL_SECONDS: + if int(m.get("interval_seconds", DEFAULT_MONITOR_INTERVAL_SECONDS)) < MIN_INTERVAL_SECONDS: m["interval_seconds"] = MIN_INTERVAL_SECONDS group_monitor_rows = new_cfg.get("group_monitors") or [] if group_monitor_rows is not None and not isinstance(group_monitor_rows, list): @@ -2801,7 +2873,10 @@ def monitor_from_form( "name": name.strip(), "type": mtype, "url": url.strip(), - "interval_seconds": max(int(interval_seconds or MIN_INTERVAL_SECONDS), MIN_INTERVAL_SECONDS), + "interval_seconds": max( + int(DEFAULT_MONITOR_INTERVAL_SECONDS if interval_seconds is None else interval_seconds), + MIN_INTERVAL_SECONDS, + ), "keywords": parse_lines(keywords), "notify_telegram": notify_telegram, "notify_on": { @@ -2833,8 +2908,10 @@ def layout(title: str, body: str) -> str: return f""" {html_escape(title)} · tg-watchbot +{theme_boot_script()}

{html_escape(title)}

WatchBot Panel
-{body}
""" +

{html_escape(title)}

WatchBot Panel
+{body}
{theme_interaction_script()}""" def monitor_form_html(m: dict[str, Any] | None = None, idx: int | None = None) -> str: - m = m or {"type": "web", "interval_seconds": 60, "notify_on": {"keyword_match": True, "new_item": True, "price_change": True, "stock_change": True}, "selectors": {}} + m = m or {"type": "web", "interval_seconds": DEFAULT_MONITOR_INTERVAL_SECONDS, "notify_on": {"keyword_match": True, "new_item": True, "price_change": True, "stock_change": True}, "selectors": {}} selectors = m.get("selectors") or {} no = m.get("notify_on") or {} keywords = "\n".join(m.get("keywords") or []) @@ -2935,7 +3034,7 @@ def monitor_form_html(m: dict[str, Any] | None = None, idx: int | None = None) -
-
+

Web 选择器(RSS 可忽略)

@@ -3009,12 +3108,12 @@ def create_panel_app() -> FastAPI: return HTMLResponse(login_page()) @app.post("/login") - async def login_post(username: str = Form(""), password: str = Form("")): + async def login_post(request: Request, username: str = Form(""), password: str = Form("")): expected_user = os.getenv("WEB_PANEL_USER", "admin") expected_pass = os.getenv("WEB_PANEL_PASSWORD", "admin") if secrets.compare_digest(username, expected_user) and secrets.compare_digest(password, expected_pass): resp = RedirectResponse("/", status_code=303) - resp.set_cookie("tg_watchbot_session", session_token(expected_user), httponly=True, secure=True, samesite="lax", max_age=60 * 60 * 24 * 14) + resp.set_cookie("tg_watchbot_session", session_token(expected_user), httponly=True, secure=panel_cookie_secure(request), samesite="lax", max_age=60 * 60 * 24 * 14) return resp return HTMLResponse(login_page("用户名或密码错误"), status_code=401) @@ -3042,7 +3141,7 @@ def create_panel_app() -> FastAPI: ) if st.get("last_error"): st_line += f"
{html_escape(str(st.get('last_error'))[:100])}" - rows.append(f"""{html_escape(m.get('type','web'))}{html_escape(name)}
{html_escape(m.get('url',''))}{html_escape(m.get('interval_seconds',60))}s
{tg}{html_escape(', '.join(m.get('keywords') or []))}{st_line}编辑 预览 检查 删除""") + rows.append(f"""{html_escape(m.get('type','web'))}{html_escape(name)}
{html_escape(m.get('url',''))}{html_escape(m.get('interval_seconds', DEFAULT_MONITOR_INTERVAL_SECONDS))}s
{tg}{html_escape(', '.join(m.get('keywords') or []))}{st_line}编辑 预览 检查 删除""") body = f"""

监控目标

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

""" + "".join(rows) + "
类型目标间隔/通知关键词运行状态操作
" return layout("监控", body) @@ -3305,9 +3404,9 @@ def create_panel_app() -> FastAPI: @app.get("/monitor/template/{kind}", response_class=HTMLResponse) async def monitor_template(kind: str, _: str = Depends(panel_auth)) -> str: templates = { - "nodeseek": {"name": "NodeSeek 新帖", "type": "rss", "url": "https://rss.nodeseek.com/", "interval_seconds": 60, "keywords": ["NAT", "优惠", "补货", "VPS", "免费"], "forum": True, "notify_on": {"keyword_match": True, "new_item": True, "price_change": False, "stock_change": False}}, - "linuxdo": {"name": "Linux.do 最新", "type": "rss", "url": "https://linux.do/latest.rss", "interval_seconds": 60, "keywords": ["Claude", "Codex", "API", "VPS", "NAT"], "forum": True, "notify_on": {"keyword_match": True, "new_item": True, "price_change": False, "stock_change": False}}, - "linuxdo-resource": {"name": "Linux.do 资源荟萃", "type": "rss", "url": "https://linux.do/c/resource/14.rss", "interval_seconds": 60, "keywords": ["免费", "开源", "API", "Claude"], "forum": True, "notify_on": {"keyword_match": True, "new_item": True, "price_change": False, "stock_change": False}}, + "nodeseek": {"name": "NodeSeek 新帖", "type": "rss", "url": "https://rss.nodeseek.com/", "interval_seconds": DEFAULT_MONITOR_INTERVAL_SECONDS, "keywords": ["NAT", "优惠", "补货", "VPS", "免费"], "forum": True, "notify_on": {"keyword_match": True, "new_item": True, "price_change": False, "stock_change": False}}, + "linuxdo": {"name": "Linux.do 最新", "type": "rss", "url": "https://linux.do/latest.rss", "interval_seconds": DEFAULT_MONITOR_INTERVAL_SECONDS, "keywords": ["Claude", "Codex", "API", "VPS", "NAT"], "forum": True, "notify_on": {"keyword_match": True, "new_item": True, "price_change": False, "stock_change": False}}, + "linuxdo-resource": {"name": "Linux.do 资源荟萃", "type": "rss", "url": "https://linux.do/c/resource/14.rss", "interval_seconds": DEFAULT_MONITOR_INTERVAL_SECONDS, "keywords": ["免费", "开源", "API", "Claude"], "forum": True, "notify_on": {"keyword_match": True, "new_item": True, "price_change": False, "stock_change": False}}, } m = templates.get(kind) if not m: @@ -3319,11 +3418,11 @@ def create_panel_app() -> FastAPI: sample = """NodeSeek|https://www.nodeseek.com/|免费鸡,优惠码,NAT Linux.do|https://linux.do|公益,codex,claude HostLoc|https://hostloc.com|VPS,补货,优惠""" - body = f"""

批量新增监控

一行一个网站,格式:名称|URL|关键词1,关键词2,关键词3。

默认提醒条件

取消
""" + body = f"""

批量新增监控

一行一个网站,格式:名称|URL|关键词1,关键词2,关键词3。

默认提醒条件

取消
""" return layout("批量新增", body) @app.post("/monitor/bulk") - async def bulk_monitor_save(_: str = Depends(panel_auth), items: str = Form(""), mtype: str = Form("web"), interval_seconds: int = Form(300), keyword_match: str | None = Form(None), new_item: str | None = Form(None), price_change: str | None = Form(None), stock_change: str | None = Form(None), notify_telegram: str | None = Form("on")): + async def bulk_monitor_save(_: str = Depends(panel_auth), items: str = Form(""), mtype: str = Form("web"), interval_seconds: int = Form(DEFAULT_MONITOR_INTERVAL_SECONDS), keyword_match: str | None = Form(None), new_item: str | None = Form(None), price_change: str | None = Form(None), stock_change: str | None = Form(None), notify_telegram: str | None = Form("on")): cfg = cfg_load_fresh() monitors = cfg.setdefault("monitors", []) added = 0 @@ -3392,11 +3491,11 @@ HostLoc|https://hostloc.com|VPS,补货,优惠""" return RedirectResponse("/", status_code=303) @app.post("/monitor/create") - async def create_monitor(_: str = Depends(panel_auth), name: str = Form(...), mtype: str = Form(...), url: str = Form(...), interval_seconds: int = Form(300), keywords: str = Form(""), item_selector: str = Form(""), title_selector: str = Form(""), link_selector: str = Form(""), price_selector: str = Form(""), stock_selector: str = Form(""), keyword_match: str | None = Form(None), new_item: str | None = Form(None), price_change: str | None = Form(None), stock_change: str | None = Form(None), notify_telegram: str | None = Form(None)) -> RedirectResponse: + async def create_monitor(_: str = Depends(panel_auth), name: str = Form(...), mtype: str = Form(...), url: str = Form(...), interval_seconds: int = Form(DEFAULT_MONITOR_INTERVAL_SECONDS), keywords: str = Form(""), item_selector: str = Form(""), title_selector: str = Form(""), link_selector: str = Form(""), price_selector: str = Form(""), stock_selector: str = Form(""), keyword_match: str | None = Form(None), new_item: str | None = Form(None), price_change: str | None = Form(None), stock_change: str | None = Form(None), notify_telegram: str | None = Form(None)) -> RedirectResponse: return await save_form_common(None, name, mtype, url, interval_seconds, keywords, item_selector, title_selector, link_selector, price_selector, stock_selector, keyword_match, new_item, price_change, stock_change, notify_telegram) @app.post("/monitor/save") - async def save_monitor(_: str = Depends(panel_auth), original_index: int = Form(...), name: str = Form(...), mtype: str = Form(...), url: str = Form(...), interval_seconds: int = Form(300), keywords: str = Form(""), item_selector: str = Form(""), title_selector: str = Form(""), link_selector: str = Form(""), price_selector: str = Form(""), stock_selector: str = Form(""), keyword_match: str | None = Form(None), new_item: str | None = Form(None), price_change: str | None = Form(None), stock_change: str | None = Form(None), notify_telegram: str | None = Form(None)) -> RedirectResponse: + async def save_monitor(_: str = Depends(panel_auth), original_index: int = Form(...), name: str = Form(...), mtype: str = Form(...), url: str = Form(...), interval_seconds: int = Form(DEFAULT_MONITOR_INTERVAL_SECONDS), keywords: str = Form(""), item_selector: str = Form(""), title_selector: str = Form(""), link_selector: str = Form(""), price_selector: str = Form(""), stock_selector: str = Form(""), keyword_match: str | None = Form(None), new_item: str | None = Form(None), price_change: str | None = Form(None), stock_change: str | None = Form(None), notify_telegram: str | None = Form(None)) -> RedirectResponse: return await save_form_common(original_index, name, mtype, url, interval_seconds, keywords, item_selector, title_selector, link_selector, price_selector, stock_selector, keyword_match, new_item, price_change, stock_change, notify_telegram) @app.get("/monitor/{idx}/delete") @@ -3491,6 +3590,7 @@ HostLoc|https://hostloc.com|VPS,补货,优惠"""
3高级设置

一般保持默认即可。

+
公网提示:监听地址填 0.0.0.0 会让面板监听所有网卡;Docker 是否暴露公网还取决于 docker-compose.yml 的端口映射和服务器防火墙。个人部署建议保持 127.0.0.1,用 SSH 隧道、反代或 Cloudflare Tunnel 访问。

自动清理

改 Token、管理员 ID、端口或 TG_API_ID / TG_API_HASH 后需要保存并重启。
@@ -3543,7 +3643,7 @@ async function logoutTgSession() {{ @app.post("/settings", response_class=HTMLResponse) 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(""), TG_PROXY: 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) - return layout("已保存", "
已保存,不会自动重启;修改 Token/管理员 ID 后请重启。

返回 重启机器人

") + return layout("已保存", "
已保存,不会自动重启;修改 Token、管理员 ID、端口或监听地址后请重启。

返回 重启机器人

") @app.get("/send", response_class=HTMLResponse) @@ -3667,6 +3767,7 @@ async function logoutTgSession() {{
+
公网提示:监听地址填 0.0.0.0 会监听所有网卡;Docker 是否暴露公网还取决于 docker-compose.yml 的端口映射和服务器防火墙。
重启机器人
""" body = settings_card + "

用户管理

" + "".join(trs) + "
用户状态备注操作
" return layout("用户管理", body) @@ -3680,7 +3781,7 @@ async function logoutTgSession() {{ int(cleanup.get("monitor_message_delete_after_minutes", 60)), int(cleanup.get("monitor_retention_minutes", 1440)), ) - return layout("已保存", "
已保存,不会自动重启;修改 Token、管理员 ID、端口、账号或密码后请重启。

返回用户管理 重启机器人

") + return layout("已保存", "
已保存,不会自动重启;修改 Token、管理员 ID、端口、监听地址、账号或密码后请重启。

返回用户管理 重启机器人

") @app.post("/api/tg-login/qr") async def api_tg_login_qr(_: str = Depends(panel_auth)) -> dict[str, Any]: diff --git a/config.example.yaml b/config.example.yaml index 73fdf5d..592f9cb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -20,7 +20,7 @@ monitors: - name: NodeSeek 新帖 type: rss url: https://rss.nodeseek.com/ - interval_seconds: 60 + interval_seconds: 30 keywords: - VPS - 优惠 @@ -38,7 +38,7 @@ monitors: - name: Linux.do 最新 type: rss url: https://linux.do/latest.rss - interval_seconds: 60 + interval_seconds: 30 keywords: - API - 教程 diff --git a/docker-compose.yml b/docker-compose.yml index bd048a0..8cc931f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: tg-watchbot restart: unless-stopped ports: - - "8765:8765" + - "127.0.0.1:8765:8765" environment: WEB_PANEL_HOST: "0.0.0.0" volumes: diff --git a/tests/test_monitor_message_cleanup.py b/tests/test_monitor_message_cleanup.py index 80cab47..acbbc43 100644 --- a/tests/test_monitor_message_cleanup.py +++ b/tests/test_monitor_message_cleanup.py @@ -39,6 +39,7 @@ def install_import_stubs() -> None: "aiogram.client.default": ModuleType("aiogram.client.default"), "fastapi": ModuleType("fastapi"), "fastapi.responses": ModuleType("fastapi.responses"), + "qrcode": ModuleType("qrcode"), } modules["apscheduler.schedulers.asyncio"].AsyncIOScheduler = object modules["bs4"].BeautifulSoup = object @@ -65,6 +66,7 @@ def install_import_stubs() -> None: modules["fastapi.responses"].HTMLResponse = object modules["fastapi.responses"].RedirectResponse = object modules["fastapi.responses"].PlainTextResponse = object + modules["qrcode"].make = lambda *args, **kwargs: SimpleNamespace(save=lambda *a, **k: None) modules["uvicorn"].Server = object modules["uvicorn"].Config = identity_factory sys.modules.update({name: sys.modules.get(name, module) for name, module in modules.items()}) @@ -267,6 +269,22 @@ class PanelHtmlContractTest(unittest.TestCase): self.assertIn("action=/login", html) self.assertIn("name=username", html) self.assertIn("name=password", html) + self.assertIn("data-theme-toggle", html) + + def test_layout_includes_theme_toggle(self) -> None: + html = app.layout("测试", "

ok

") + self.assertIn("tg_watchbot_theme", html) + self.assertIn("data-theme-toggle", html) + self.assertIn("html[data-theme='dark']", html) + + def test_panel_cookie_secure_follows_request_scheme(self) -> None: + old_value = os.environ.pop("WEB_PANEL_COOKIE_SECURE", None) + try: + self.assertFalse(app.panel_cookie_secure(SimpleNamespace(url=SimpleNamespace(scheme="http")))) + self.assertTrue(app.panel_cookie_secure(SimpleNamespace(url=SimpleNamespace(scheme="https")))) + finally: + if old_value is not None: + os.environ["WEB_PANEL_COOKIE_SECURE"] = old_value def test_monitor_form_keeps_backend_field_names(self) -> None: html = app.monitor_form_html() @@ -288,6 +306,32 @@ class PanelHtmlContractTest(unittest.TestCase): ]: self.assertIn(expected, html) + def test_monitor_form_defaults_to_30_seconds_with_1_second_minimum(self) -> None: + html = app.monitor_form_html() + self.assertIn("最低 1,默认 30", html) + self.assertIn("min=1", html) + self.assertIn("value='30'", html) + + def test_monitor_from_form_clamps_interval_to_one_second(self) -> None: + monitor = app.monitor_from_form( + None, + "测试", + "rss", + "https://example.com/feed", + 0, + "", + "", + "", + "", + "", + "", + True, + True, + False, + False, + ) + self.assertEqual(1, monitor["interval_seconds"]) + def test_monitor_form_can_disable_telegram_notification(self) -> None: monitor = { "type": "rss", @@ -301,7 +345,7 @@ class PanelHtmlContractTest(unittest.TestCase): def test_layout_groups_navigation_by_domain(self) -> None: html = app.layout("测试", "

ok

") - for expected in ["消息", "监控", "配置", "系统", "私聊广告拦截", "TG 群监听"]: + for expected in ["常用", "转发", "设置", "系统", "收件箱", "群监听"]: self.assertIn(expected, html) def test_inbox_copy_describes_two_way_conversation(self) -> None: @@ -309,6 +353,12 @@ class PanelHtmlContractTest(unittest.TestCase): self.assertIn("这里显示双向机器人对话记录", source) self.assertIn("管理员 -> 用户", source) + def test_settings_page_warns_about_public_bind(self) -> None: + source = Path("app.py").read_text(encoding="utf-8") + self.assertIn("公网提示", source) + self.assertIn("0.0.0.0", source) + self.assertIn("docker-compose.yml", source) + def test_users_page_keeps_shared_settings_form(self) -> None: source = Path("app.py").read_text(encoding="utf-8") self.assertIn("action='/users/settings'", source)