/ tests / gateway / test_telegram_thread_fallback.py
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