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.
This commit is contained in:
@@ -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)。
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
||||||
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
||||||
<p class="text-caption text-medium-emphasis mb-3">
|
<p class="text-caption text-medium-emphasis mb-3">
|
||||||
在 Telegram 向 Bot 发送任意消息后,可点击下方按钮自动检测 Chat ID。
|
私聊:向 Bot 发送任意消息。群聊:将 Bot 拉入群后发送 <code>/start@你的Bot用户名</code>(或 @提及 Bot),再点击下方检测。
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
|
||||||
@@ -197,6 +197,9 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
|
<p class="text-caption text-medium-emphasis mb-3">
|
||||||
|
检测规则与默认 Bot 相同:群聊需拉入专用 Bot 后在群内发送 <code>/start@Bot用户名</code>。
|
||||||
|
</p>
|
||||||
<div class="d-flex flex-wrap ga-2 mb-2">
|
<div class="d-flex flex-wrap ga-2 mb-2">
|
||||||
<v-btn
|
<v-btn
|
||||||
size="small"
|
size="small"
|
||||||
@@ -450,14 +453,14 @@
|
|||||||
<v-card-title>选择 Chat ID</v-card-title>
|
<v-card-title>选择 Chat ID</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis">
|
<div v-if="!tgChats.length" class="text-body-2 text-medium-emphasis">
|
||||||
未检测到对话。请先在 Telegram 向 Bot 发送一条消息,然后重新检测。
|
未检测到对话。私聊请向 Bot 发消息;群聊请将 Bot 拉入群后在群内发送 <code>/start@Bot用户名</code>,然后重新检测。
|
||||||
</div>
|
</div>
|
||||||
<v-list v-else density="compact" lines="two">
|
<v-list v-else density="compact" lines="two">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="chat in tgChats"
|
v-for="chat in tgChats"
|
||||||
:key="chat.id"
|
:key="chat.id"
|
||||||
:title="chat.title || chat.username || `Chat ${chat.id}`"
|
:title="chat.title || chat.username || `Chat ${chat.id}`"
|
||||||
:subtitle="`${chat.type}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
|
:subtitle="`${formatTelegramChatType(chat.type)}${chat.username ? ' · @' + chat.username : ''} · ID ${chat.id}`"
|
||||||
@click="applyTelegramChat(chat)"
|
@click="applyTelegramChat(chat)"
|
||||||
/>
|
/>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -572,6 +575,16 @@ const notifyToggleGroups = computed(() => {
|
|||||||
return groups
|
return groups
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function formatTelegramChatType(type: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
private: '私聊',
|
||||||
|
group: '群组',
|
||||||
|
supergroup: '群组',
|
||||||
|
channel: '频道',
|
||||||
|
}
|
||||||
|
return labels[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
function parseNotifyEnabled(val: unknown): boolean {
|
function parseNotifyEnabled(val: unknown): boolean {
|
||||||
const s = String(val ?? 'true').trim().toLowerCase()
|
const s = String(val ?? 'true').trim().toLowerCase()
|
||||||
return !['false', '0', 'no', 'off'].includes(s)
|
return !['false', '0', 'no', 'off'].includes(s)
|
||||||
@@ -836,7 +849,7 @@ async function detectOfflineTelegramChats() {
|
|||||||
const res = await http.get<TelegramChatsResponse>('/settings/telegram/offline/chats')
|
const res = await http.get<TelegramChatsResponse>('/settings/telegram/offline/chats')
|
||||||
tgChats.value = res.chats || []
|
tgChats.value = res.chats || []
|
||||||
if (!tgChats.value.length) {
|
if (!tgChats.value.length) {
|
||||||
snackbar('未检测到对话,请先向离线专用 Bot 发送一条消息后再试', 'warning')
|
snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tgChatTarget.value = 'offline'
|
tgChatTarget.value = 'offline'
|
||||||
@@ -948,7 +961,7 @@ async function detectTelegramChats() {
|
|||||||
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
|
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
|
||||||
tgChats.value = res.chats || []
|
tgChats.value = res.chats || []
|
||||||
if (!tgChats.value.length) {
|
if (!tgChats.value.length) {
|
||||||
snackbar('未检测到对话,请先向 Bot 发送一条消息后再试', 'warning')
|
snackbar('未检测到对话:私聊发消息,或群聊拉入 Bot 后发送 /start@Bot用户名', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tgChatTarget.value = 'default'
|
tgChatTarget.value = 'default'
|
||||||
|
|||||||
+57
-21
@@ -1185,6 +1185,56 @@ async def reveal_telegram_token(
|
|||||||
return {"key": "telegram_bot_token", "value": value}
|
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:
|
async def _telegram_fetch_chats(token: str) -> dict:
|
||||||
import httpx
|
import httpx
|
||||||
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
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)
|
await _telegram_clear_webhook_if_set(client, base_url)
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{base_url}/getUpdates",
|
f"{base_url}/getUpdates",
|
||||||
params={"limit": 50},
|
params={"limit": 100},
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
||||||
|
|
||||||
seen: set[int] = set()
|
chats = _extract_chats_from_telegram_updates(data.get("result", []))[:10]
|
||||||
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
|
|
||||||
|
|
||||||
return {"chats": chats, "count": len(chats)}
|
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.
|
"""Call getUpdates to detect recent chats and extract unique chat_ids.
|
||||||
|
|
||||||
User must send at least one message to the Bot before calling this.
|
For private chats: send any message to the Bot.
|
||||||
Returns up to 5 distinct chats from recent messages.
|
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
|
from server.config import settings as _settings
|
||||||
|
|
||||||
@@ -1336,7 +1372,7 @@ async def reveal_offline_telegram_token(
|
|||||||
async def telegram_offline_get_chats(
|
async def telegram_offline_get_chats(
|
||||||
admin: Admin = Depends(get_current_admin),
|
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()
|
token = _offline_telegram_token_or_400()
|
||||||
return await _telegram_fetch_chats(token)
|
return await _telegram_fetch_chats(token)
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,54 @@ async def test_telegram_chats_deletes_webhook_before_get_updates():
|
|||||||
assert call_args[1]["params"]["drop_pending_updates"] == "false"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_notify_toggle_put_hot_reload():
|
async def test_notify_toggle_put_hot_reload():
|
||||||
from server.api.schemas import SettingUpdatePayload
|
from server.api.schemas import SettingUpdatePayload
|
||||||
|
|||||||
Reference in New Issue
Block a user