test_telegram_thread_fallback.py
1 """Tests for Telegram send() thread_id fallback. 2 3 When message_thread_id points to a non-existent thread, Telegram returns 4 BadRequest('Message thread not found'). Since BadRequest is a subclass of 5 NetworkError in python-telegram-bot, the old retry loop treated this as a 6 transient error and retried 3 times before silently failing — killing all 7 tool progress messages, streaming responses, and typing indicators. 8 9 The fix detects "thread not found" BadRequest errors and retries the send 10 WITHOUT message_thread_id so the message still reaches the chat. 11 """ 12 13 import sys 14 import types 15 from types import SimpleNamespace 16 17 import pytest 18 19 from gateway.config import PlatformConfig, Platform 20 from gateway.platforms.base import SendResult 21 22 23 # ── Fake telegram.error hierarchy ────────────────────────────────────── 24 # Mirrors the real python-telegram-bot hierarchy: 25 # BadRequest → NetworkError → TelegramError → Exception 26 27 28 class FakeNetworkError(Exception): 29 pass 30 31 32 class FakeBadRequest(FakeNetworkError): 33 pass 34 35 36 class FakeTimedOut(FakeNetworkError): 37 pass 38 39 40 class FakeRetryAfter(Exception): 41 def __init__(self, seconds): 42 super().__init__(f"Retry after {seconds}") 43 self.retry_after = seconds 44 45 46 # Build a fake telegram module tree so the adapter's internal imports work 47 _fake_telegram = types.ModuleType("telegram") 48 _fake_telegram.Update = object 49 _fake_telegram.Bot = object 50 _fake_telegram.Message = object 51 _fake_telegram.InlineKeyboardButton = object 52 _fake_telegram.InlineKeyboardMarkup = object 53 _fake_telegram_error = types.ModuleType("telegram.error") 54 _fake_telegram_error.NetworkError = FakeNetworkError 55 _fake_telegram_error.BadRequest = FakeBadRequest 56 _fake_telegram_error.TimedOut = FakeTimedOut 57 _fake_telegram.error = _fake_telegram_error 58 _fake_telegram_constants = types.ModuleType("telegram.constants") 59 _fake_telegram_constants.ParseMode = SimpleNamespace(MARKDOWN_V2="MarkdownV2") 60 _fake_telegram_constants.ChatType = SimpleNamespace( 61 GROUP="group", 62 SUPERGROUP="supergroup", 63 CHANNEL="channel", 64 ) 65 _fake_telegram.constants = _fake_telegram_constants 66 _fake_telegram_ext = types.ModuleType("telegram.ext") 67 _fake_telegram_ext.Application = object 68 _fake_telegram_ext.CommandHandler = object 69 _fake_telegram_ext.CallbackQueryHandler = object 70 _fake_telegram_ext.MessageHandler = object 71 _fake_telegram_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object) 72 _fake_telegram_ext.filters = object 73 _fake_telegram_request = types.ModuleType("telegram.request") 74 _fake_telegram_request.HTTPXRequest = object 75 76 77 @pytest.fixture(autouse=True) 78 def _inject_fake_telegram(monkeypatch): 79 """Inject fake telegram modules so the adapter can import from them.""" 80 monkeypatch.setitem(sys.modules, "telegram", _fake_telegram) 81 monkeypatch.setitem(sys.modules, "telegram.error", _fake_telegram_error) 82 monkeypatch.setitem(sys.modules, "telegram.constants", _fake_telegram_constants) 83 monkeypatch.setitem(sys.modules, "telegram.ext", _fake_telegram_ext) 84 monkeypatch.setitem(sys.modules, "telegram.request", _fake_telegram_request) 85 86 87 def _make_adapter(): 88 from gateway.platforms.telegram import TelegramAdapter 89 90 config = PlatformConfig(enabled=True, token="fake-token") 91 adapter = object.__new__(TelegramAdapter) 92 adapter.config = config 93 adapter._config = config 94 adapter._platform = Platform.TELEGRAM 95 adapter._connected = True 96 adapter._dm_topics = {} 97 adapter._dm_topics_config = [] 98 adapter._reply_to_mode = "first" 99 adapter._fallback_ips = [] 100 adapter._polling_conflict_count = 0 101 adapter._polling_network_error_count = 0 102 adapter._polling_error_callback_ref = None 103 adapter.platform = Platform.TELEGRAM 104 return adapter 105 106 107 def test_forum_general_topic_without_message_thread_id_keeps_thread_context(): 108 """Forum General-topic messages should keep synthetic thread context.""" 109 from gateway.platforms import telegram as telegram_mod 110 111 adapter = _make_adapter() 112 message = SimpleNamespace( 113 text="hello from General", 114 caption=None, 115 chat=SimpleNamespace( 116 id=-100123, 117 type=telegram_mod.ChatType.SUPERGROUP, 118 is_forum=True, 119 title="Forum group", 120 ), 121 from_user=SimpleNamespace(id=456, full_name="Alice"), 122 message_thread_id=None, 123 reply_to_message=None, 124 message_id=10, 125 date=None, 126 ) 127 128 event = adapter._build_message_event(message, msg_type=SimpleNamespace(value="text")) 129 130 assert event.source.chat_id == "-100123" 131 assert event.source.chat_type == "group" 132 assert event.source.thread_id == "1" 133 134 135 @pytest.mark.asyncio 136 async def test_send_omits_general_topic_thread_id(): 137 """Telegram sends to forum General should omit message_thread_id=1.""" 138 adapter = _make_adapter() 139 call_log = [] 140 141 async def mock_send_message(**kwargs): 142 call_log.append(dict(kwargs)) 143 return SimpleNamespace(message_id=42) 144 145 adapter._bot = SimpleNamespace(send_message=mock_send_message) 146 147 result = await adapter.send( 148 chat_id="-100123", 149 content="test message", 150 metadata={"thread_id": "1"}, 151 ) 152 153 assert result.success is True 154 assert len(call_log) == 1 155 assert call_log[0]["chat_id"] == -100123 156 assert call_log[0]["text"] == "test message" 157 assert call_log[0]["reply_to_message_id"] is None 158 assert call_log[0]["message_thread_id"] is None 159 160 161 @pytest.mark.asyncio 162 async def test_send_typing_retries_without_general_thread_when_not_found(): 163 """Typing for forum General should fall back if Telegram rejects thread 1.""" 164 adapter = _make_adapter() 165 call_log = [] 166 167 async def mock_send_chat_action(**kwargs): 168 call_log.append(dict(kwargs)) 169 if kwargs.get("message_thread_id") == 1: 170 raise FakeBadRequest("Message thread not found") 171 172 adapter._bot = SimpleNamespace(send_chat_action=mock_send_chat_action) 173 174 await adapter.send_typing("-100123", metadata={"thread_id": "1"}) 175 176 assert call_log == [ 177 {"chat_id": -100123, "action": "typing", "message_thread_id": 1}, 178 {"chat_id": -100123, "action": "typing", "message_thread_id": None}, 179 ] 180 181 182 @pytest.mark.asyncio 183 async def test_send_retries_without_thread_on_thread_not_found(): 184 """When message_thread_id causes 'thread not found', retry without it.""" 185 adapter = _make_adapter() 186 187 call_log = [] 188 189 async def mock_send_message(**kwargs): 190 call_log.append(dict(kwargs)) 191 tid = kwargs.get("message_thread_id") 192 if tid is not None: 193 raise FakeBadRequest("Message thread not found") 194 return SimpleNamespace(message_id=42) 195 196 adapter._bot = SimpleNamespace(send_message=mock_send_message) 197 198 result = await adapter.send( 199 chat_id="123", 200 content="test message", 201 metadata={"thread_id": "99999"}, 202 ) 203 204 assert result.success is True 205 assert result.message_id == "42" 206 # First call has thread_id, second call retries without 207 assert len(call_log) == 2 208 assert call_log[0]["message_thread_id"] == 99999 209 assert call_log[1]["message_thread_id"] is None 210 211 212 @pytest.mark.asyncio 213 async def test_send_raises_on_other_bad_request(): 214 """Non-thread BadRequest errors should NOT be retried — they fail immediately.""" 215 adapter = _make_adapter() 216 217 async def mock_send_message(**kwargs): 218 raise FakeBadRequest("Chat not found") 219 220 adapter._bot = SimpleNamespace(send_message=mock_send_message) 221 222 result = await adapter.send( 223 chat_id="123", 224 content="test message", 225 metadata={"thread_id": "99999"}, 226 ) 227 228 assert result.success is False 229 assert "Chat not found" in result.error 230 231 232 @pytest.mark.asyncio 233 async def test_send_without_thread_id_unaffected(): 234 """Normal sends without thread_id should work as before.""" 235 adapter = _make_adapter() 236 237 call_log = [] 238 239 async def mock_send_message(**kwargs): 240 call_log.append(dict(kwargs)) 241 return SimpleNamespace(message_id=100) 242 243 adapter._bot = SimpleNamespace(send_message=mock_send_message) 244 245 result = await adapter.send( 246 chat_id="123", 247 content="test message", 248 ) 249 250 assert result.success is True 251 assert len(call_log) == 1 252 assert call_log[0]["message_thread_id"] is None 253 254 255 @pytest.mark.asyncio 256 async def test_send_retries_network_errors_normally(): 257 """Real transient network errors (not BadRequest) should still be retried.""" 258 adapter = _make_adapter() 259 260 attempt = [0] 261 262 async def mock_send_message(**kwargs): 263 attempt[0] += 1 264 if attempt[0] < 3: 265 raise FakeNetworkError("Connection reset") 266 return SimpleNamespace(message_id=200) 267 268 adapter._bot = SimpleNamespace(send_message=mock_send_message) 269 270 result = await adapter.send( 271 chat_id="123", 272 content="test message", 273 ) 274 275 assert result.success is True 276 assert attempt[0] == 3 # Two retries then success 277 278 279 @pytest.mark.asyncio 280 async def test_send_does_not_retry_timeout(): 281 """TimedOut (subclass of NetworkError) should NOT be retried in send(). 282 283 The request may have already been delivered to the user — retrying 284 would send duplicate messages. 285 """ 286 adapter = _make_adapter() 287 288 attempt = [0] 289 290 async def mock_send_message(**kwargs): 291 attempt[0] += 1 292 raise FakeTimedOut("Timed out waiting for Telegram response") 293 294 adapter._bot = SimpleNamespace(send_message=mock_send_message) 295 296 result = await adapter.send( 297 chat_id="123", 298 content="test message", 299 ) 300 301 assert result.success is False 302 assert "Timed out" in result.error 303 # CRITICAL: only 1 attempt — no retry for TimedOut 304 assert attempt[0] == 1 305 306 307 @pytest.mark.asyncio 308 async def test_thread_fallback_only_fires_once(): 309 """After clearing thread_id, subsequent chunks should also use None.""" 310 adapter = _make_adapter() 311 312 call_log = [] 313 314 async def mock_send_message(**kwargs): 315 call_log.append(dict(kwargs)) 316 tid = kwargs.get("message_thread_id") 317 if tid is not None: 318 raise FakeBadRequest("Message thread not found") 319 return SimpleNamespace(message_id=42) 320 321 adapter._bot = SimpleNamespace(send_message=mock_send_message) 322 323 # Send a long message that gets split into chunks 324 long_msg = "A" * 5000 # Exceeds Telegram's 4096 limit 325 result = await adapter.send( 326 chat_id="123", 327 content=long_msg, 328 metadata={"thread_id": "99999"}, 329 ) 330 331 assert result.success is True 332 # First chunk: attempt with thread → fail → retry without → succeed 333 # Second chunk: should use thread_id=None directly (effective_thread_id 334 # was cleared per-chunk but the metadata doesn't change between chunks) 335 # The key point: the message was delivered despite the invalid thread 336 337 338 @pytest.mark.asyncio 339 async def test_send_retries_retry_after_errors(): 340 """Telegram flood control should back off and retry instead of failing fast.""" 341 adapter = _make_adapter() 342 343 attempt = [0] 344 345 async def mock_send_message(**kwargs): 346 attempt[0] += 1 347 if attempt[0] == 1: 348 raise FakeRetryAfter(2) 349 return SimpleNamespace(message_id=300) 350 351 adapter._bot = SimpleNamespace(send_message=mock_send_message) 352 353 result = await adapter.send(chat_id="123", content="test message") 354 355 assert result.success is True 356 assert result.message_id == "300" 357 assert attempt[0] == 2