fix(tradein): startup topic-check реально валидирует message_thread_id #3513
2 changed files with 123 additions and 31 deletions
|
|
@ -67,6 +67,11 @@ _DEFAULT_MAX_RETRIES = 5
|
|||
_DEFAULT_GROUP_RATE_LIMIT_PER_MINUTE = 18
|
||||
_RATE_LIMIT_WINDOW_S = 60.0
|
||||
|
||||
# Текст зондирующего сообщения `verify_chat_and_topic` — см. докстринг там.
|
||||
# Живёт в чате доли секунды (удаляется сразу после отправки), но должен быть
|
||||
# узнаваем в логах ретранслятора/дебаге, если удаление вдруг не отработает.
|
||||
_TOPIC_PROBE_TEXT = "\U0001f50d startup topic check"
|
||||
|
||||
|
||||
class TelegramGroupRateLimiter:
|
||||
"""Общий (per-`chat_id`, НЕ per-теме) ограничитель частоты отправки в группу.
|
||||
|
|
@ -744,13 +749,13 @@ class TelegramClient:
|
|||
) -> bool:
|
||||
"""sendChatAction — статус набора текста. Возвращает `True`/`False`, JSON-объекта нет.
|
||||
|
||||
Используется НЕ по прямому назначению (индикация набора), а как способ
|
||||
проверить существование `message_thread_id` (темы форума) — см.
|
||||
`verify_chat_and_topic`. Это единственный метод Bot API, который
|
||||
принимает `message_thread_id` и не создаёт message-объект: если тема
|
||||
удалена/переименована в другую с иным id, Telegram отвечает `Bad
|
||||
Request: message thread not found` мгновенно, а в истории чата не
|
||||
остаётся ни строки (индикатор эфемерный и не персистится)."""
|
||||
⚠️ НЕ провалидировано для проверки `message_thread_id`: живой прод-замер
|
||||
(#3471) показал, что Telegram принимает и мгновенно подтверждает
|
||||
`sendChatAction` с ЗАВЕДОМО несуществующим `message_thread_id`
|
||||
(`{"ok":true,"result":true}`) — метод молча принимает любую тему,
|
||||
существующую или нет. Раньше здесь было обратное (неверное)
|
||||
утверждение; см. `verify_chat_and_topic`, которая для проверки темы
|
||||
использует `sendMessage`+`deleteMessage`."""
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
|
|
@ -759,6 +764,22 @@ class TelegramClient:
|
|||
)
|
||||
return bool(result)
|
||||
|
||||
async def delete_message(self, *, chat_id: int, message_id: int) -> bool:
|
||||
"""deleteMessage — удаляет сообщение бота.
|
||||
|
||||
Используется `verify_chat_and_topic` для зачистки зондирующего
|
||||
`sendMessage`-пробника сразу после проверки темы: тема существует
|
||||
тогда и только тогда, когда сообщение вообще удалось отправить —
|
||||
поэтому к моменту вызова `deleteMessage` id уже гарантированно
|
||||
валиден."""
|
||||
result = await self._request(
|
||||
"deleteMessage",
|
||||
{"chat_id": chat_id, "message_id": message_id},
|
||||
max_retries=1,
|
||||
max_backoff=5.0,
|
||||
)
|
||||
return bool(result)
|
||||
|
||||
|
||||
async def verify_chat_and_topic(
|
||||
client: TelegramClient,
|
||||
|
|
@ -776,18 +797,23 @@ async def verify_chat_and_topic(
|
|||
отказами: тихий отказ хуже шума). Эта проверка переносит обнаружение с
|
||||
«через сутки тишины» на «в первую секунду после старта/рестарта».
|
||||
|
||||
Способ намеренно НЕ `sendMessage`+`deleteMessage`:
|
||||
Способ: `getChat(chat_id)` + `sendMessage`/`deleteMessage` в саму тему.
|
||||
- `getChat(chat_id)` подтверждает валидность чата и то, что бот не
|
||||
выгнан/не заблокирован — чистый read, нулевой видимый след.
|
||||
- `send_chat_action` (typing-индикатор с `message_thread_id`) —
|
||||
единственный способ провалидировать САМУ тему без создания
|
||||
message-объекта: Telegram обязан знать про `message_thread_id`, чтобы
|
||||
показать «печатает...» именно в нужном треде, и явно отказывает, если
|
||||
такой темы нет. `sendMessage`+`deleteMessage` тоже сработал бы, но
|
||||
оставлял бы видимый (пусть на секунды) артефакт в истории треда при
|
||||
КАЖДОМ рестарте контейнера — на rolling-деплое это многократно в
|
||||
сутки; typing-индикатор того же результата достигает без единого
|
||||
сообщения.
|
||||
- Раньше здесь стоял `send_chat_action` (typing-индикатор с
|
||||
`message_thread_id`) в расчёте на то, что Telegram обязан знать про
|
||||
`message_thread_id`, чтобы показать «печатает...» именно в нужном
|
||||
треде. Живой прод-замер (#3471) опроверг это: `sendChatAction`
|
||||
принимает и подтверждает ЗАВЕДОМО несуществующий
|
||||
`message_thread_id` (`{"ok":true,"result":true}`) — проверка была
|
||||
ложно-зелёной ВСЕГДА, удалённая/переименованная тема проходила её
|
||||
так же, как живая, а реальная отправка потом падала на каждом
|
||||
сообщении. `sendMessage` с тем же мусорным id вместо этого мгновенно
|
||||
отвечает `400 Bad Request: message thread not found` (эмпирически
|
||||
подтверждено на том же проде), а с валидным id создаёт сообщение —
|
||||
которое здесь же стирается `deleteMessage`, так что в истории треда
|
||||
не остаётся ничего, кроме микросекундного технического сообщения на
|
||||
каждом старте/рестарте контейнера.
|
||||
|
||||
НЕ роняет процесс: любой `TelegramError` ловится здесь же и уходит в лог
|
||||
уровня error — задача явно требует шума в логе, а не падения воркера
|
||||
|
|
@ -802,9 +828,16 @@ async def verify_chat_and_topic(
|
|||
try:
|
||||
await client.get_chat(chat_id)
|
||||
if topic_id:
|
||||
await client.send_chat_action(
|
||||
chat_id=chat_id, action="typing", message_thread_id=topic_id
|
||||
probe = await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=_TOPIC_PROBE_TEXT,
|
||||
message_thread_id=topic_id,
|
||||
max_retries=1,
|
||||
max_backoff=5.0,
|
||||
)
|
||||
probe_message_id = probe.get("message_id")
|
||||
if probe_message_id:
|
||||
await client.delete_message(chat_id=chat_id, message_id=probe_message_id)
|
||||
except TelegramError as exc:
|
||||
logger.error(
|
||||
"tg topic check [%s]: чат/тема недоступны для отправки "
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ NEVER calls real Telegram API — httpx.MockTransport only, тот же патт
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
|
|
@ -51,12 +52,19 @@ def _stop_patches():
|
|||
# ── verify_chat_and_topic ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_success_calls_get_chat_and_send_chat_action() -> None:
|
||||
"""Happy path: getChat + typing-индикатор в тему, никакого видимого сообщения."""
|
||||
async def test_verify_chat_and_topic_success_uses_send_message_and_delete_message() -> None:
|
||||
"""Happy path: getChat + зондирующий sendMessage в тему + его deleteMessage.
|
||||
|
||||
`sendChatAction` тут больше не используется: живой прод-замер (#3471)
|
||||
показал, что она подтверждает даже несуществующий `message_thread_id`
|
||||
(`{"ok":true,"result":true}`) — проверка была ложно-зелёной."""
|
||||
calls: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.path.rsplit("/", 1)[-1])
|
||||
method = request.url.path.rsplit("/", 1)[-1]
|
||||
calls.append(method)
|
||||
if method == "sendMessage":
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 42}})
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
|
||||
_install_transport(handler)
|
||||
|
|
@ -65,24 +73,31 @@ async def test_verify_chat_and_topic_success_calls_get_chat_and_send_chat_action
|
|||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||
|
||||
assert ok is True
|
||||
assert calls == ["getChat", "sendChatAction"]
|
||||
assert calls == ["getChat", "sendMessage", "deleteMessage"]
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_no_visible_message_method_used() -> None:
|
||||
"""Ни один из вызовов не бьёт в sendMessage/copyMessage — не мусорим в чат."""
|
||||
methods: list[str] = []
|
||||
async def test_verify_chat_and_topic_deletes_probe_message_leaving_no_trace() -> None:
|
||||
"""Зонд `sendMessage` не остаётся видимым — `deleteMessage` бьёт по тому же
|
||||
`message_id`, что вернул `sendMessage` (не мусорим в истории треда)."""
|
||||
delete_payloads: list[dict[str, object]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
methods.append(request.url.path.rsplit("/", 1)[-1])
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
method = request.url.path.rsplit("/", 1)[-1]
|
||||
if method == "sendMessage":
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 986}})
|
||||
if method == "deleteMessage":
|
||||
delete_payloads.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
return httpx.Response(200, json={"ok": True, "result": {}})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
|
||||
await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=7, label="support")
|
||||
|
||||
assert "sendMessage" not in methods
|
||||
assert "copyMessage" not in methods
|
||||
assert ok is True
|
||||
assert len(delete_payloads) == 1
|
||||
assert delete_payloads[0]["message_id"] == 986
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_logs_error_and_does_not_raise(caplog) -> None:
|
||||
|
|
@ -135,6 +150,50 @@ async def test_verify_chat_and_topic_skips_api_call_when_chat_id_is_zero() -> No
|
|||
assert calls == []
|
||||
|
||||
|
||||
async def test_verify_chat_and_topic_is_red_when_topic_deleted_even_though_send_chat_action_lies(
|
||||
caplog,
|
||||
) -> None:
|
||||
"""Регрессия #3471: удалённая/переименованная тема ДОЛЖНА красить проверку.
|
||||
|
||||
Handler воспроизводит РЕАЛЬНО замеренное на проде поведение Bot API:
|
||||
`sendChatAction` подтверждает даже несуществующий `message_thread_id`
|
||||
(`{"ok":true,"result":true}` — живой прод-замер, #3471), а `sendMessage`
|
||||
с тем же id честно отвечает `400 Bad Request: message thread not found`.
|
||||
|
||||
На коде ДО правки (`verify_chat_and_topic` звала `send_chat_action`) этот
|
||||
тест падает: `ok` был бы `True` — проверка молчаливо считала мёртвую тему
|
||||
живой. Явно проверено при разработке (#3471): `git checkout HEAD --
|
||||
.../client.py` (до-фиксная версия) + прогон только этого теста дал
|
||||
`assert True is False`; после возврата фикса — зелёный."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
method = request.url.path.rsplit("/", 1)[-1]
|
||||
if method == "getChat":
|
||||
return httpx.Response(200, json={"ok": True, "result": {"id": -100123}})
|
||||
if method == "sendChatAction":
|
||||
# Реальный прод-баг: sendChatAction НЕ валидирует message_thread_id.
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
if method == "sendMessage":
|
||||
return httpx.Response(
|
||||
400,
|
||||
json={
|
||||
"ok": False,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: message thread not found",
|
||||
},
|
||||
)
|
||||
return httpx.Response(200, json={"ok": True, "result": True})
|
||||
|
||||
_install_transport(handler)
|
||||
client = TelegramClient(token="fake-token")
|
||||
|
||||
with caplog.at_level("ERROR", logger="app.services.tgbot.client"):
|
||||
ok = await verify_chat_and_topic(client, chat_id=-100123, topic_id=987654, label="support")
|
||||
|
||||
assert ok is False
|
||||
assert any(record.levelname == "ERROR" for record in caplog.records)
|
||||
|
||||
|
||||
# ── TelegramGroupRateLimiter ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue