import asyncio import os import sqlite3 import sys import tempfile import unittest from contextlib import closing from pathlib import Path from types import ModuleType, SimpleNamespace def install_import_stubs() -> None: class DummyRouter: def message(self, *args, **kwargs): def decorator(func): return func return decorator def identity_factory(*args, **kwargs): return object() modules = { "feedparser": ModuleType("feedparser"), "httpx": ModuleType("httpx"), "yaml": ModuleType("yaml"), "uvicorn": ModuleType("uvicorn"), "apscheduler": ModuleType("apscheduler"), "apscheduler.schedulers": ModuleType("apscheduler.schedulers"), "apscheduler.schedulers.asyncio": ModuleType("apscheduler.schedulers.asyncio"), "bs4": ModuleType("bs4"), "dotenv": ModuleType("dotenv"), "aiogram": ModuleType("aiogram"), "aiogram.enums": ModuleType("aiogram.enums"), "aiogram.exceptions": ModuleType("aiogram.exceptions"), "aiogram.filters": ModuleType("aiogram.filters"), "aiogram.types": ModuleType("aiogram.types"), "aiogram.client": ModuleType("aiogram.client"), "aiogram.client.default": ModuleType("aiogram.client.default"), "fastapi": ModuleType("fastapi"), "fastapi.responses": ModuleType("fastapi.responses"), } modules["apscheduler.schedulers.asyncio"].AsyncIOScheduler = object modules["bs4"].BeautifulSoup = object modules["dotenv"].load_dotenv = lambda *args, **kwargs: None modules["yaml"].safe_load = lambda stream: {"bot": {"spam_filter": {"enabled": True, "keywords": []}}} modules["yaml"].safe_dump = lambda data, **kwargs: str(data) modules["aiogram"].Bot = object modules["aiogram"].Dispatcher = object modules["aiogram"].F = object() modules["aiogram"].Router = DummyRouter modules["aiogram.enums"].ParseMode = SimpleNamespace(HTML="HTML") modules["aiogram.exceptions"].TelegramAPIError = Exception modules["aiogram.filters"].Command = identity_factory modules["aiogram.filters"].CommandObject = object modules["aiogram.types"].Message = object modules["aiogram.client.default"].DefaultBotProperties = identity_factory modules["fastapi"].Depends = identity_factory modules["fastapi"].FastAPI = object modules["fastapi"].Form = identity_factory modules["fastapi"].HTTPException = Exception modules["fastapi"].Request = object modules["fastapi"].Response = object modules["fastapi"].status = object() modules["fastapi.responses"].HTMLResponse = object modules["fastapi.responses"].RedirectResponse = object modules["fastapi.responses"].PlainTextResponse = object modules["uvicorn"].Server = object modules["uvicorn"].Config = identity_factory sys.modules.update({name: sys.modules.get(name, module) for name, module in modules.items()}) install_import_stubs() import app class FakeBot: def __init__(self) -> None: self.deleted: list[tuple[int, int]] = [] self.sent_texts: list[str] = [] self.sent_chat_ids: list[int] = [] self.fail_chat_ids: set[int] = set() async def delete_message(self, chat_id: int, message_id: int) -> None: self.deleted.append((chat_id, message_id)) async def send_message(self, chat_id: int, text: str, disable_web_page_preview: bool = False): if chat_id in self.fail_chat_ids: raise RuntimeError("send failed") self.sent_chat_ids.append(chat_id) self.sent_texts.append(text) return SimpleNamespace(message_id=3003) class MonitorMessageCleanupTest(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_monitor_notification_send_is_recorded_for_later_deletion(self) -> None: old_bot = app.bot old_admin_chat_id = app.admin_chat_id old_admin_chat_ids = app.admin_chat_ids old_config = app.config fake_bot = FakeBot() app.bot = fake_bot app.admin_chat_id = 1001 app.admin_chat_ids = [] app.config = {"cleanup": {"monitor_message_delete_after_minutes": 1}} try: sent = asyncio.run(app.admin_send_monitor("monitor hit", "NodeSeek 新帖")) self.assertTrue(sent) self.assertEqual(["monitor hit"], fake_bot.sent_texts) with closing(sqlite3.connect(app.DB_PATH)) as conn: row = conn.execute( "SELECT chat_id, message_id, monitor_name, delete_after_seconds FROM monitor_messages" ).fetchone() self.assertEqual((1001, 3003, "NodeSeek 新帖", 60), row) finally: app.bot = old_bot app.admin_chat_id = old_admin_chat_id app.admin_chat_ids = old_admin_chat_ids app.config = old_config def test_monitor_event_history_is_recorded(self) -> None: app.record_monitor_event("NodeSeek 新帖", "title", "https://example.com", ["关键词"], False) with closing(sqlite3.connect(app.DB_PATH)) as conn: row = conn.execute("SELECT monitor_name, title, pushed FROM monitor_events").fetchone() self.assertEqual(("NodeSeek 新帖", "title", 0), row) def test_monitor_notification_is_sent_to_all_admins(self) -> None: old_bot = app.bot old_admin_chat_ids = app.admin_chat_ids old_config = app.config fake_bot = FakeBot() app.bot = fake_bot app.admin_chat_ids = [1001, 1002, 1003] app.config = {"cleanup": {"monitor_message_delete_after_minutes": 1}} try: self.assertTrue(asyncio.run(app.admin_send_monitor("monitor hit", "NodeSeek 新帖"))) self.assertEqual([1001, 1002, 1003], fake_bot.sent_chat_ids) finally: app.bot = old_bot app.admin_chat_ids = old_admin_chat_ids app.config = old_config def test_monitor_notification_continues_when_one_admin_fails(self) -> None: old_bot = app.bot old_admin_chat_ids = app.admin_chat_ids old_config = app.config fake_bot = FakeBot() fake_bot.fail_chat_ids.add(1002) app.bot = fake_bot app.admin_chat_ids = [1001, 1002, 1003] app.config = {"cleanup": {"monitor_message_delete_after_minutes": 1}} try: with self.assertLogs("tg-watchbot", level="ERROR"): self.assertTrue(asyncio.run(app.admin_send_monitor("monitor hit", "NodeSeek 新帖"))) self.assertEqual([1001, 1003], fake_bot.sent_chat_ids) finally: app.bot = old_bot app.admin_chat_ids = old_admin_chat_ids app.config = old_config def test_outbound_message_is_recorded_in_conversation_log(self) -> None: app.upsert_user(2001, "User", "user") outbox_id = app.create_outbox_message(2001, "reply text", "web:inbox", 4004) with closing(sqlite3.connect(app.DB_PATH)) as conn: conn.row_factory = sqlite3.Row row = conn.execute("SELECT direction, source, text, forwarded FROM inbox_messages WHERE id=?", (outbox_id,)).fetchone() self.assertEqual(("out", "web:inbox", "reply text", 1), (row["direction"], row["source"], row["text"], row["forwarded"])) def test_save_message_map_supports_message_id_only_payload(self) -> None: app.save_message_map(1001, 3003, 2001, 4004) with closing(sqlite3.connect(app.DB_PATH)) as conn: row = conn.execute( "SELECT admin_chat_id, admin_message_id, user_id, user_message_id FROM message_map" ).fetchone() self.assertEqual((1001, 3003, 2001, 4004), row) def test_expired_monitor_message_is_deleted_and_removed_from_queue(self) -> None: app.record_monitor_message(1001, 2002, "NodeSeek 新帖", delete_after_seconds=60, sent_at_ts=1000) fake_bot = FakeBot() deleted_count = asyncio.run(app.delete_expired_monitor_messages(fake_bot, now_ts=1061)) self.assertEqual(1, deleted_count) self.assertEqual([(1001, 2002)], fake_bot.deleted) with closing(sqlite3.connect(app.DB_PATH)) as conn: remaining = conn.execute("SELECT COUNT(*) FROM monitor_messages").fetchone()[0] self.assertEqual(0, remaining) def test_unexpired_monitor_message_is_kept(self) -> None: app.record_monitor_message(1001, 2002, "NodeSeek 新帖", delete_after_seconds=60, sent_at_ts=1000) fake_bot = FakeBot() deleted_count = asyncio.run(app.delete_expired_monitor_messages(fake_bot, now_ts=1059)) self.assertEqual(0, deleted_count) self.assertEqual([], fake_bot.deleted) with closing(sqlite3.connect(app.DB_PATH)) as conn: remaining = conn.execute("SELECT COUNT(*) FROM monitor_messages").fetchone()[0] self.assertEqual(1, remaining) class BotConfigurationTest(unittest.TestCase): def test_parse_admin_chat_ids_keeps_unique_first_three(self) -> None: self.assertEqual([1, 2, 3], app.parse_admin_chat_ids("1,2 2;3,4")) def test_bot_is_not_configured_without_token_or_admin_chat_id(self) -> None: old_token = os.environ.pop("TELEGRAM_BOT_TOKEN", None) old_admin = os.environ.pop("ADMIN_CHAT_ID", None) try: self.assertFalse(app.bot_env_configured()) finally: if old_token is not None: os.environ["TELEGRAM_BOT_TOKEN"] = old_token if old_admin is not None: os.environ["ADMIN_CHAT_ID"] = old_admin def test_bot_is_configured_with_token_and_admin_chat_id(self) -> None: old_token = os.environ.get("TELEGRAM_BOT_TOKEN") old_admin = os.environ.get("ADMIN_CHAT_ID") os.environ["TELEGRAM_BOT_TOKEN"] = "123456:test-token" os.environ["ADMIN_CHAT_ID"] = "1001" try: self.assertTrue(app.bot_env_configured()) finally: if old_token is None: os.environ.pop("TELEGRAM_BOT_TOKEN", None) else: os.environ["TELEGRAM_BOT_TOKEN"] = old_token if old_admin is None: os.environ.pop("ADMIN_CHAT_ID", None) else: os.environ["ADMIN_CHAT_ID"] = old_admin def test_write_env_values_preserves_existing_session_secret(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: old_env_path = app.ENV_PATH app.ENV_PATH = Path(temp_dir) / ".env" app.ENV_PATH.write_text("WEB_PANEL_SESSION_SECRET=keep-me\n", encoding="utf-8") try: app.write_env_values({ "TELEGRAM_BOT_TOKEN": "123456:test-token", "ADMIN_CHAT_ID": "1001", "WEB_PANEL_USER": "admin", "WEB_PANEL_PASSWORD": "change-me", }) self.assertIn( "WEB_PANEL_SESSION_SECRET=keep-me", app.ENV_PATH.read_text(encoding="utf-8"), ) finally: app.ENV_PATH = old_env_path class PanelHtmlContractTest(unittest.TestCase): def test_login_form_keeps_expected_fields(self) -> None: html = app.login_page() self.assertIn("action=/login", html) self.assertIn("name=username", html) self.assertIn("name=password", html) def test_monitor_form_keeps_backend_field_names(self) -> None: html = app.monitor_form_html() for expected in [ "action='/monitor/create'", "name=name", "name=mtype", "name=url", "name=interval_seconds", "name=keywords", "name=item_selector", "name=title_selector", "name=link_selector", "name=keyword_match", "name=new_item", "name=price_change", "name=stock_change", "name=notify_telegram", ]: self.assertIn(expected, html) def test_monitor_form_can_disable_telegram_notification(self) -> None: monitor = { "type": "rss", "interval_seconds": 60, "notify_telegram": False, "notify_on": {"keyword_match": True}, } html = app.monitor_form_html(monitor) self.assertIn("name=notify_telegram", html) self.assertNotIn("name=notify_telegram checked", html) def test_layout_groups_navigation_by_domain(self) -> None: html = app.layout("测试", "

ok

") for expected in ["消息", "监控", "配置", "系统", "私聊广告拦截", "TG 群监听"]: self.assertIn(expected, html) def test_inbox_copy_describes_two_way_conversation(self) -> None: source = Path("app.py").read_text(encoding="utf-8") self.assertIn("这里显示双向机器人对话记录", source) self.assertIn("管理员 -> 用户", 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) self.assertIn("这里和“Bot / 面板设置”共用同一份 .env", source) def test_group_monitor_form_keeps_backend_field_names(self) -> None: html = app.group_monitor_form_html() for expected in [ "action='/group-monitors/create'", "name=name", "name=chat_id", "name=keywords", "name=exclude_keywords", "name=enabled", "name=notify_telegram", "name=listen_source", "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) class SpamAndTemplateConfigTest(unittest.TestCase): def test_spam_keyword_hits_follow_config(self) -> None: old_config = app.config app.config = {"bot": {"spam_filter": {"enabled": True, "keywords": ["博彩", "投资"]}}} try: self.assertEqual(["博彩"], app.spam_keyword_hits("这里有博彩广告")) finally: app.config = old_config def test_quick_replies_are_loaded_from_config(self) -> None: old_config = app.config app.config = {"bot": {"quick_replies": [{"title": "收到", "text": "稍后处理"}]}} try: self.assertEqual("收到", app.list_quick_replies()[0]["title"]) finally: app.config = old_config def test_update_spam_keywords_writes_config(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: old_config_path = app.CONFIG_PATH old_config = app.config app.CONFIG_PATH = Path(temp_dir) / "config.yaml" app.CONFIG_PATH.write_text("bot:\n spam_filter:\n enabled: true\n keywords: []\n", encoding="utf-8") app.config = {"bot": {"spam_filter": {"enabled": True, "keywords": []}}} try: self.assertEqual(["广告"], app.update_spam_keywords("add", "广告")) self.assertEqual([], app.update_spam_keywords("delete", "广告")) finally: app.CONFIG_PATH = old_config_path app.config = old_config 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("bot", saved["listen_source"]) 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 = { "group_monitors": [ {"enabled": True, "chat_id": -10001, "keywords": ["vps"], "exclude_keywords": []}, {"enabled": False, "chat_id": -10002, "keywords": ["api"]}, ] } try: monitor = app.group_monitor_for_chat(-10001) self.assertIsNotNone(monitor) self.assertEqual(-10001, monitor["chat_id"]) self.assertIsNone(app.group_monitor_for_chat(-10002)) finally: 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: old_config = app.config old_bot = app.bot old_admin_chat_ids = app.admin_chat_ids 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, } ] } 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=777, 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("[群关键词命中]", fake_bot.sent_texts[0]) self.assertIn("命中:vps, 优惠", fake_bot.sent_texts[0]) finally: app.config = old_config app.bot = old_bot app.admin_chat_ids = old_admin_chat_ids def test_handle_group_keyword_message_respects_exclude_keywords(self) -> None: old_config = app.config old_bot = app.bot old_admin_chat_ids = app.admin_chat_ids fake_bot = FakeBot() app.bot = fake_bot app.admin_chat_ids = [9001] app.config = { "group_monitors": [ { "enabled": True, "chat_id": -100100100, "keywords": ["vps"], "exclude_keywords": ["求带"], "notify_telegram": True, } ] } 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=777, content_type="text", ) try: ok = asyncio.run(app.handle_group_keyword_message(msg)) self.assertFalse(ok) self.assertEqual([], fake_bot.sent_chat_ids) finally: app.config = old_config 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 def test_record_and_list_discovered_group_chats(self) -> None: msg = SimpleNamespace( chat=SimpleNamespace(id=-100123, type="supergroup", title="测试群A", username="group_a"), text="hello", caption=None, reply_to_message=None, message_id=1, from_user=SimpleNamespace(id=11, first_name="u", last_name="", username="u1"), content_type="text", ) app.record_discovered_group_chat(msg) rows = app.list_discovered_group_chats() self.assertTrue(rows) self.assertEqual(-100123, rows[0]["chat_id"]) self.assertEqual("测试群A", rows[0]["title"]) def test_group_monitors_page_keeps_discovered_chat_actions_markup(self) -> None: source = Path("app.py").read_text(encoding="utf-8") self.assertIn("已发现群聊", source) self.assertIn("用此群创建监听", source) self.assertIn("/group-monitors/new?chat_id=", source) if __name__ == "__main__": unittest.main()