From 40cba6a5673a274fcf95dd9accce7918fac18c9b Mon Sep 17 00:00:00 2001 From: Nexus Agent Date: Sat, 13 Jun 2026 02:04:59 +0800 Subject: [PATCH] fix(settings): detect Telegram group chat IDs via my_chat_member Extend getUpdates parsing for group joins and update settings UI hints so Chat ID detection works for supergroups, not only private DMs. --- ...026-06-13-telegram-chat-id-group-detect.md | 29 +++++++ frontend/src/pages/SettingsPage.vue | 23 ++++-- server/api/settings.py | 78 ++++++++++++++----- tests/test_settings_telegram.py | 48 ++++++++++++ 4 files changed, 152 insertions(+), 26 deletions(-) create mode 100644 docs/changelog/2026-06-13-telegram-chat-id-group-detect.md diff --git a/docs/changelog/2026-06-13-telegram-chat-id-group-detect.md b/docs/changelog/2026-06-13-telegram-chat-id-group-detect.md new file mode 100644 index 00000000..e543a1af --- /dev/null +++ b/docs/changelog/2026-06-13-telegram-chat-id-group-detect.md @@ -0,0 +1,29 @@ +# Telegram Chat ID 群聊检测修复 + +**日期**:2026-06-13 + +## 变更摘要 + +修复设置页「检测 Chat ID」无法识别群聊的问题:扩展 `getUpdates` 解析,覆盖 Bot 被拉入群时的 `my_chat_member` 等更新类型;更新前端操作说明。 + +## 动机 + +用户反馈群聊 Chat ID 检测不到。原实现仅从 `message` / `channel_post` 取 `chat`,而群聊常见首条可见更新为 `my_chat_member`(Bot 入群),导致列表为空。 + +## 涉及文件 + +- `server/api/settings.py` — `_telegram_chat_entry`、`_extract_chats_from_telegram_updates`、`_telegram_fetch_chats`(limit 100,最多返回 10 条,群组优先) +- `frontend/src/pages/SettingsPage.vue` — 群聊检测说明、空状态文案、类型中文标签 +- `tests/test_settings_telegram.py` — 群聊/编辑消息/callback 解析单测 + +## 迁移 / 重启 + +无需数据库迁移;部署后端 + 前端后生效。 + +## 验证方式 + +```bash +pytest tests/test_settings_telegram.py -q +``` + +生产:将 Bot 拉入测试群 → 群内发送 `/start@Bot用户名` → 设置页点击「检测 Chat ID」应出现群组项(负 ID)。 diff --git a/frontend/src/pages/SettingsPage.vue b/frontend/src/pages/SettingsPage.vue index 011ba891..fb83d275 100644 --- a/frontend/src/pages/SettingsPage.vue +++ b/frontend/src/pages/SettingsPage.vue @@ -134,7 +134,7 @@ Token 已配置

- 在 Telegram 向 Bot 发送任意消息后,可点击下方按钮自动检测 Chat ID。 + 私聊:向 Bot 发送任意消息。群聊:将 Bot 拉入群后发送 /start@你的Bot用户名(或 @提及 Bot),再点击下方检测。

检测 Chat ID @@ -197,6 +197,9 @@ density="compact" class="mb-3" /> +

+ 检测规则与默认 Bot 相同:群聊需拉入专用 Bot 后在群内发送 /start@Bot用户名。 +

选择 Chat ID
- 未检测到对话。请先在 Telegram 向 Bot 发送一条消息,然后重新检测。 + 未检测到对话。私聊请向 Bot 发消息;群聊请将 Bot 拉入群后在群内发送 /start@Bot用户名,然后重新检测。
@@ -572,6 +575,16 @@ const notifyToggleGroups = computed(() => { return groups }) +function formatTelegramChatType(type: string): string { + const labels: Record = { + private: '私聊', + group: '群组', + supergroup: '群组', + channel: '频道', + } + return labels[type] || type +} + function parseNotifyEnabled(val: unknown): boolean { const s = String(val ?? 'true').trim().toLowerCase() return !['false', '0', 'no', 'off'].includes(s) @@ -836,7 +849,7 @@ async function detectOfflineTelegramChats() { const res = await http.get('/settings/telegram/offline/chats') tgChats.value = res.chats || [] if (!tgChats.value.length) { - snackbar('未检测到对话,请先向离线专用 Bot 发送一条消息后再试', 'warning') + snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning') return } tgChatTarget.value = 'offline' @@ -948,7 +961,7 @@ async function detectTelegramChats() { const res = await http.get('/settings/telegram/chats') tgChats.value = res.chats || [] if (!tgChats.value.length) { - snackbar('未检测到对话,请先向 Bot 发送一条消息后再试', 'warning') + snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning') return } tgChatTarget.value = 'default' diff --git a/server/api/settings.py b/server/api/settings.py index b3d31a13..b92f3b33 100644 --- a/server/api/settings.py +++ b/server/api/settings.py @@ -1185,6 +1185,56 @@ async def reveal_telegram_token( return {"key": "telegram_bot_token", "value": value} +def _telegram_chat_entry(chat: dict) -> dict | None: + cid = chat.get("id") + if cid is None: + return None + return { + "id": cid, + "type": chat.get("type", ""), + "title": chat.get("title") or chat.get("first_name") or chat.get("username") or "", + "username": chat.get("username") or "", + } + + +def _extract_chats_from_telegram_updates(updates: list) -> list[dict]: + """Extract unique chats from getUpdates (DM, group join, channel, callbacks).""" + seen: set[int] = set() + chats: list[dict] = [] + + def add(chat: object) -> None: + if not isinstance(chat, dict): + return + entry = _telegram_chat_entry(chat) + if not entry or entry["id"] in seen: + return + seen.add(entry["id"]) + chats.append(entry) + + for update in updates: + if not isinstance(update, dict): + continue + for key in ("message", "edited_message", "channel_post", "edited_channel_post"): + msg = update.get(key) + if isinstance(msg, dict): + add(msg.get("chat")) + mcm = update.get("my_chat_member") + if isinstance(mcm, dict): + add(mcm.get("chat")) + cm = update.get("chat_member") + if isinstance(cm, dict): + add(cm.get("chat")) + cq = update.get("callback_query") + if isinstance(cq, dict): + msg = cq.get("message") + if isinstance(msg, dict): + add(msg.get("chat")) + + type_order = {"supergroup": 0, "group": 1, "channel": 2, "private": 3} + chats.sort(key=lambda c: (type_order.get(c.get("type", ""), 9), c.get("id", 0))) + return chats + + async def _telegram_fetch_chats(token: str) -> dict: import httpx from server.infrastructure.telegram import TELEGRAM_API_BASE @@ -1195,7 +1245,7 @@ async def _telegram_fetch_chats(token: str) -> dict: await _telegram_clear_webhook_if_set(client, base_url) resp = await client.get( f"{base_url}/getUpdates", - params={"limit": 50}, + params={"limit": 100}, ) if resp.status_code != 200: raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}") @@ -1208,23 +1258,7 @@ async def _telegram_fetch_chats(token: str) -> dict: except Exception as e: raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e - seen: set[int] = set() - chats: list[dict] = [] - for update in data.get("result", []): - msg = update.get("message") or update.get("channel_post") or {} - chat = msg.get("chat", {}) - cid = chat.get("id") - if cid and cid not in seen: - seen.add(cid) - chats.append({ - "id": cid, - "type": chat.get("type", ""), - "title": chat.get("title") or chat.get("first_name") or "", - "username": chat.get("username") or "", - }) - if len(chats) >= 5: - break - + chats = _extract_chats_from_telegram_updates(data.get("result", []))[:10] return {"chats": chats, "count": len(chats)} @@ -1234,8 +1268,10 @@ async def telegram_get_chats( ): """Call getUpdates to detect recent chats and extract unique chat_ids. - User must send at least one message to the Bot before calling this. - Returns up to 5 distinct chats from recent messages. + For private chats: send any message to the Bot. + For groups: add the Bot to the group, then send /start@BotName (privacy mode) + or any message mentioning the Bot. Bot join events (my_chat_member) are included. + Returns up to 10 distinct chats, groups listed first. """ from server.config import settings as _settings @@ -1336,7 +1372,7 @@ async def reveal_offline_telegram_token( async def telegram_offline_get_chats( admin: Admin = Depends(get_current_admin), ): - """Detect chats for offline-dedicated Bot via getUpdates.""" + """Detect chats for offline-dedicated Bot via getUpdates (same rules as /telegram/chats).""" token = _offline_telegram_token_or_400() return await _telegram_fetch_chats(token) diff --git a/tests/test_settings_telegram.py b/tests/test_settings_telegram.py index 322ad73b..03bcd482 100644 --- a/tests/test_settings_telegram.py +++ b/tests/test_settings_telegram.py @@ -134,6 +134,54 @@ async def test_telegram_chats_deletes_webhook_before_get_updates(): assert call_args[1]["params"]["drop_pending_updates"] == "false" +@pytest.mark.asyncio +async def test_telegram_chats_includes_my_chat_member_supergroup(): + from server.api.settings import _extract_chats_from_telegram_updates + + updates = [ + { + "my_chat_member": { + "chat": {"id": -1001234567890, "title": "Ops Group", "type": "supergroup"}, + }, + }, + { + "message": { + "chat": {"id": 42, "type": "private", "first_name": "Alice"}, + }, + }, + ] + chats = _extract_chats_from_telegram_updates(updates) + assert len(chats) == 2 + assert chats[0]["id"] == -1001234567890 + assert chats[0]["type"] == "supergroup" + assert chats[0]["title"] == "Ops Group" + assert chats[1]["id"] == 42 + + +@pytest.mark.asyncio +async def test_telegram_chats_includes_edited_message_and_callback(): + from server.api.settings import _extract_chats_from_telegram_updates + + updates = [ + { + "edited_message": { + "chat": {"id": -10099, "title": "Alert Room", "type": "group"}, + }, + }, + { + "callback_query": { + "message": { + "chat": {"id": -10088, "title": "Channel", "type": "channel"}, + }, + }, + }, + ] + chats = _extract_chats_from_telegram_updates(updates) + ids = {c["id"] for c in chats} + assert -10099 in ids + assert -10088 in ids + + @pytest.mark.asyncio async def test_notify_toggle_put_hot_reload(): from server.api.schemas import SettingUpdatePayload