hermes-agent/tests/gateway/test_telegram_model_picker.py
rahimsais 737314fe91 fix(telegram): normalize dm threads and retry control sends
Cherry-picked from PR #10371. Two-layer defense for the spurious-thread_id
issue (#3206):

1. _build_message_event filters DM thread_ids: only preserve thread_id
   for real topic messages (is_topic_message=True). Telegram puts
   message_thread_id on every DM that is a reply, but reply-chain ids
   route to nonexistent threads on send.

2. _send_message_with_thread_fallback helper: control sends
   (send_update_prompt, send_exec_approval / send_slash_confirm,
   send_model_picker) retry once without message_thread_id when
   Telegram returns BadRequest 'Message thread not found'. Mirrors
   the pattern PR #3390 added for the streaming send path.

Salvage notes:
- Conflict 1 (line ~4099): merged the contributor's DM is_topic_message
  filter with the existing forum General-topic default from #22423,
  preserving both behaviors.
- Conflict 2 (line ~1664 / 1690): kept main's delete_message (PR #23416)
  alongside the new helper. Tightened the helper's exception catch
  from bare 'Exception' to use the existing _is_bad_request_error +
  _is_thread_not_found_error helpers (line 484-496) for consistency
  with the streaming send path.
- Widened the fix to send_update_prompt (was bare self._bot.send_message,
  same bug class).

Authored by rahimsais via PR #10371 (re-attributed from donrhmexe@
local commit author).
2026-05-10 18:09:31 -07:00

76 lines
2.6 KiB
Python

"""Tests for Telegram model picker thread fallback."""
import sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
mod = MagicMock()
mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
mod.constants.ParseMode.MARKDOWN = "Markdown"
mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
mod.constants.ParseMode.HTML = "HTML"
mod.constants.ChatType.PRIVATE = "private"
mod.constants.ChatType.GROUP = "group"
mod.constants.ChatType.SUPERGROUP = "supergroup"
mod.constants.ChatType.CHANNEL = "channel"
mod.error.NetworkError = type("NetworkError", (OSError,), {})
mod.error.TimedOut = type("TimedOut", (OSError,), {})
mod.error.BadRequest = type("BadRequest", (Exception,), {})
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, mod)
sys.modules.setdefault("telegram.error", mod.error)
_ensure_telegram_mock()
from gateway.config import PlatformConfig
from gateway.platforms.telegram import TelegramAdapter
def _make_adapter():
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="test-token"))
adapter._bot = AsyncMock()
adapter._app = MagicMock()
return adapter
class TestTelegramModelPicker:
@pytest.mark.asyncio
async def test_retries_without_thread_when_thread_not_found(self):
adapter = _make_adapter()
providers = [{"slug": "openai", "name": "OpenAI", "total_models": 2, "is_current": True}]
call_log = []
class FakeBadRequest(Exception):
pass
async def mock_send_message(**kwargs):
call_log.append(dict(kwargs))
if kwargs.get("message_thread_id") is not None:
raise FakeBadRequest("Message thread not found")
return SimpleNamespace(message_id=99)
adapter._bot.send_message = AsyncMock(side_effect=mock_send_message)
result = await adapter.send_model_picker(
chat_id="12345",
providers=providers,
current_model="gpt-5",
current_provider="openai",
session_key="s",
on_model_selected=AsyncMock(),
metadata={"thread_id": "99999"},
)
assert result.success is True
assert len(call_log) == 2
assert call_log[0]["message_thread_id"] == 99999
assert "message_thread_id" not in call_log[1] or call_log[1]["message_thread_id"] is None