diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 026ee7bc55c..2de169ee092 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -810,6 +810,47 @@ class TelegramAdapter(BasePlatformAdapter): def _is_thread_not_found_error(error: Exception) -> bool: return "thread not found" in str(error).lower() + def _prune_stale_dm_topic_binding( + self, chat_id: Any, thread_id: Any, + ) -> None: + """Drop the stale ``telegram_dm_topic_bindings`` row for a + topic Telegram has confirmed deleted. + + Without this prune the recovery logic in + ``gateway.run._recover_telegram_topic_thread_id`` keeps + steering future inbound messages to the dead thread (the + bug behind #31501 — tool progress, approvals, replies all + end up in the wrong place even though the user has moved + on to a fresh topic). Best-effort: we never raise from a + send-fallback path — a failed cleanup must not turn into a + failed user-facing send. + """ + if chat_id is None or thread_id is None: + return + store = getattr(self, "_session_store", None) + if store is None: + return + db = getattr(store, "_db", None) + if db is None or not hasattr(db, "delete_telegram_topic_binding"): + return + try: + removed = db.delete_telegram_topic_binding( + chat_id=str(chat_id), thread_id=str(thread_id), + ) + except Exception: + logger.debug( + "[%s] delete_telegram_topic_binding failed for " + "chat=%s thread=%s — skipping prune", + self.name, chat_id, thread_id, exc_info=True, + ) + return + if removed: + logger.info( + "[%s] Pruned stale Telegram DM topic binding " + "chat=%s thread=%s (Bot API: thread not found)", + self.name, chat_id, thread_id, + ) + @staticmethod def _is_bad_request_error(error: Exception) -> bool: name = error.__class__.__name__.lower() @@ -2670,11 +2711,17 @@ class TelegramAdapter(BasePlatformAdapter): continue # Second failure: the thread is genuinely gone. # Retry without ``message_thread_id`` so the - # message still reaches the chat. + # message still reaches the chat, and prune + # the stale binding so future inbound + # messages aren't redirected back to it + # (#31501). logger.warning( "[%s] Thread %s not found, retrying without message_thread_id", self.name, effective_thread_id, ) + self._prune_stale_dm_topic_binding( + chat_id, effective_thread_id, + ) used_thread_fallback = True effective_thread_id = None thread_kwargs = {"message_thread_id": None} @@ -3355,6 +3402,13 @@ class TelegramAdapter(BasePlatformAdapter): self.name, message_thread_id, ) + # Same prune as the streaming send path — the + # control-message retry tells us the topic is gone, + # so the binding row in state.db must go too + # (#31501). + self._prune_stale_dm_topic_binding( + kwargs.get("chat_id"), message_thread_id, + ) retry_kwargs = dict(kwargs) retry_kwargs.pop("message_thread_id", None) return await self._bot.send_message(**retry_kwargs)