From 2564132a1f6c4cc5c452b74d07364ee086f985e3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 7 May 2026 08:39:21 -0700 Subject: [PATCH] fix(telegram): preserve thread_id=1 for forum General typing indicator (#21390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The May 5 refactor in d5357f816 made _message_thread_id_for_typing() symmetric with _message_thread_id_for_send() by mapping the General topic (thread id "1") to None upfront for both. That's correct for sendMessage — Telegram rejects message_thread_id=1 on sends and the topic must be omitted — but it's wrong for sendChatAction. Observed behavior (confirmed via before/after Telegram wire traces): Before d5357f816: thread_id=1 → message_thread_id=1 → bubble visible in General After d5357f816: thread_id=1 → message_thread_id=None → no visible typing Omitting message_thread_id on sendChatAction does NOT fall back to the General topic's view in a forum-enabled supergroup; the bubble ends up hidden from the client's General-topic pane entirely. For any user on a forum-group, the typing indicator stopped appearing. Fix: drop the symmetric "1 → None" mapping from the typing resolver. sendMessage still maps 1 → None via _message_thread_id_for_send (that side was never broken). The asymmetry is real and required by Telegram's API — document it in the resolver docstring. Partial revert of d5357f816; restores the behavior from 0cf7d570e ("fix(telegram): restore typing indicator and thread routing for forum General topic"). Does not re-introduce the retry-without-thread fallback that 41545f7ec scoped down for DM topics — with the resolver fixed, the first call already hits the right wire shape. Test updated from test_send_typing_general_topic_uses_none_thread_id (which encoded the broken contract) to test_send_typing_preserves_general_topic_thread_id, asserting the single correct call with message_thread_id=1. 10 other tests in the file untouched and passing. --- gateway/platforms/telegram.py | 12 ++++++++---- tests/gateway/test_telegram_thread_fallback.py | 17 +++++++++++------ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index ec50822673..0d0ac3866f 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -369,10 +369,14 @@ class TelegramAdapter(BasePlatformAdapter): @classmethod def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]: - # Mirrors _message_thread_id_for_send: the General forum topic (thread id - # "1") is represented as "no thread id" on the wire. User-created topics - # keep their real id so typing stays scoped to that topic. - if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID: + # Asymmetric with _message_thread_id_for_send on purpose. Telegram's + # sendMessage and sendChatAction treat thread id "1" (the forum General + # topic) differently: sends reject message_thread_id=1 and must omit it, + # but sendChatAction needs message_thread_id=1 to place the typing + # bubble in the General topic (omitting it hides the bubble entirely + # from the client's view of that topic). Preserve the real id here — + # sends still map "1" → None via _message_thread_id_for_send. + if not thread_id: return None return int(thread_id) diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index b8330822b3..7b982e9588 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -159,12 +159,17 @@ async def test_send_omits_general_topic_thread_id(): @pytest.mark.asyncio -async def test_send_typing_general_topic_uses_none_thread_id(): - """Typing for forum General should hit the API with message_thread_id=None directly. +async def test_send_typing_preserves_general_topic_thread_id(): + """Typing for forum General must send message_thread_id=1, not None. - _message_thread_id_for_typing() maps the General topic (thread id "1") to None - the same way _message_thread_id_for_send() does, so there's no retry path — the - first call is already correct. + Asymmetric with _message_thread_id_for_send: sendMessage rejects + message_thread_id=1, but sendChatAction needs it to scope the typing + bubble to the General topic. Omitting it (message_thread_id=None) hides + the bubble from the General-topic view entirely. + + Regression guard for the d5357f816 refactor that mapped "1" → None in + the typing resolver and silently killed typing indicators in every + forum-group General topic. """ adapter = _make_adapter() call_log = [] @@ -177,7 +182,7 @@ async def test_send_typing_general_topic_uses_none_thread_id(): await adapter.send_typing("-100123", metadata={"thread_id": "1"}) assert call_log == [ - {"chat_id": -100123, "action": "typing", "message_thread_id": None}, + {"chat_id": -100123, "action": "typing", "message_thread_id": 1}, ]