From 16d8e44f7ae7b8d410ad0601ae99c112e046f1e2 Mon Sep 17 00:00:00 2001 From: fonhal Date: Mon, 18 May 2026 15:11:32 +0800 Subject: [PATCH] fix(telegram): add DM topic typing fallback when message_thread_id rejected When a DM topic lane's message_thread_id is rejected by Telegram (e.g. stale or deleted topic), send_typing now falls back to sending the typing indicator without thread_id so it at least appears in the main DM view, rather than being silently swallowed. Also adds test for the fallback behavior. --- gateway/platforms/telegram.py | 20 ++++++--- .../gateway/test_telegram_thread_fallback.py | 42 ++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 1570eb6973b..5aea4708951 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -3729,20 +3729,30 @@ class TelegramAdapter(BasePlatformAdapter): async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None: """Send typing indicator.""" if self._bot: + _is_dm_topic: bool = False + message_thread_id: Optional[int] = None try: _typing_thread = self._metadata_thread_id(metadata) + _is_dm_topic = bool(metadata and metadata.get("telegram_dm_topic_reply_fallback")) message_thread_id = self._message_thread_id_for_typing(_typing_thread) - # No retry-without-thread fallback here: _message_thread_id_for_typing - # already maps the forum General topic to None, so any non-None value - # reaching this call is a user-created topic. If Telegram rejects it - # (e.g. topic deleted mid-session), we swallow the failure rather than - # showing a typing indicator in the wrong chat/All Messages. await self._bot.send_chat_action( chat_id=int(chat_id), action="typing", message_thread_id=message_thread_id, ) except Exception as e: + # For DM topic lanes, Telegram may reject message_thread_id. + # Fall back to sending typing without thread_id so the typing + # indicator at least appears in the main DM view. + if _is_dm_topic and message_thread_id is not None: + try: + await self._bot.send_chat_action( + chat_id=int(chat_id), + action="typing", + ) + return + except Exception: + pass # Typing failures are non-fatal; log at debug level only. logger.debug( "[%s] Failed to send Telegram typing indicator: %s", diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index d8bfc6261be..02ff7262397 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -306,7 +306,8 @@ async def test_send_typing_attempts_api_call_for_dm_topic_reply_fallback(): Some private DM topic lanes route message sends through reply-anchor fallback, but live Telegram testing shows sendChatAction accepts the lane's message_thread_id. If Telegram rejects a stale or invalid thread later, - send_typing already swallows that failure as non-fatal. + send_typing now falls back to sending typing without thread_id so the + indicator at least appears in the main DM view. """ adapter = _make_adapter() call_log = [] @@ -330,6 +331,45 @@ async def test_send_typing_attempts_api_call_for_dm_topic_reply_fallback(): ] +@pytest.mark.asyncio +async def test_send_typing_falls_back_without_thread_on_bad_request(): + """When DM topic typing with message_thread_id fails, retry without it.""" + adapter = _make_adapter() + + call_log = [] + call_count = [0] + + async def mock_send_chat_action(**kwargs): + call_log.append(dict(kwargs)) + call_count[0] += 1 + if call_count[0] == 1 and kwargs.get("message_thread_id") is not None: + raise FakeBadRequest("Message thread not found") + + adapter._bot = SimpleNamespace(send_chat_action=mock_send_chat_action) + + await adapter.send_typing( + "12345", + metadata={ + "thread_id": "20197", + "telegram_dm_topic_reply_fallback": True, + "telegram_reply_to_message_id": "462", + }, + ) + + # First call: with message_thread_id (failed) + # Second call: fallback without message_thread_id (succeeded) + assert len(call_log) == 2 + assert call_log[0] == { + "chat_id": 12345, + "action": "typing", + "message_thread_id": 20197, + } + assert call_log[1] == { + "chat_id": 12345, + "action": "typing", + } + + @pytest.mark.asyncio async def test_send_retries_without_thread_on_thread_not_found(): """When message_thread_id causes 'thread not found', retry without it."""