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).
This commit is contained in:
rahimsais 2026-05-10 16:25:13 -07:00 committed by Teknium
parent 404640a2b7
commit 737314fe91
4 changed files with 210 additions and 13 deletions

View file

@ -1686,6 +1686,38 @@ class TelegramAdapter(BasePlatformAdapter):
)
return False
async def _send_message_with_thread_fallback(self, **kwargs):
"""Send a Telegram message, retrying once without message_thread_id
if Telegram returns 'Message thread not found'.
Used for control-style sends (approval prompts, model picker,
update prompts) that can carry a stale thread_id from a DM
reply chain. The streaming send loop has its own equivalent
(PR #3390) at the body of ``send``; this helper applies the
same retry pattern to the non-streaming control paths.
"""
if not self._bot:
raise RuntimeError("Not connected")
message_thread_id = kwargs.get("message_thread_id")
try:
return await self._bot.send_message(**kwargs)
except Exception as send_err:
if (
message_thread_id is not None
and self._is_bad_request_error(send_err)
and self._is_thread_not_found_error(send_err)
):
logger.warning(
"[%s] Thread %s not found for control message, retrying without message_thread_id",
self.name,
message_thread_id,
)
retry_kwargs = dict(kwargs)
retry_kwargs.pop("message_thread_id", None)
return await self._bot.send_message(**retry_kwargs)
raise
async def send_update_prompt(
self, chat_id: str, prompt: str, default: str = "",
session_key: str = "",
@ -1709,7 +1741,7 @@ class TelegramAdapter(BasePlatformAdapter):
])
thread_id = self._metadata_thread_id(metadata)
reply_to_id = self._reply_to_message_id_for_send(None, metadata)
msg = await self._bot.send_message(
msg = await self._send_message_with_thread_fallback(
chat_id=int(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN,
@ -1789,7 +1821,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
)
msg = await self._bot.send_message(**kwargs)
msg = await self._send_message_with_thread_fallback(**kwargs)
# Store session_key keyed by approval_id for the callback handler
self._approval_state[approval_id] = session_key
@ -1841,7 +1873,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
)
msg = await self._bot.send_message(**kwargs)
msg = await self._send_message_with_thread_fallback(**kwargs)
self._slash_confirm_state[confirm_id] = session_key
return SendResult(success=True, message_id=str(msg.message_id))
except Exception as e:
@ -1899,7 +1931,7 @@ class TelegramAdapter(BasePlatformAdapter):
thread_id = metadata.get("thread_id") if metadata else None
reply_to_id = self._reply_to_message_id_for_send(None, metadata)
msg = await self._bot.send_message(
msg = await self._send_message_with_thread_fallback(
chat_id=int(chat_id),
text=text,
parse_mode=ParseMode.MARKDOWN,
@ -4069,9 +4101,24 @@ class TelegramAdapter(BasePlatformAdapter):
elif chat.type == ChatType.CHANNEL:
chat_type = "channel"
# Resolve DM topic name and skill binding
# Resolve DM topic name and skill binding.
# In private chats, only preserve thread ids for real topic messages
# (is_topic_message=True). Telegram puts message_thread_id on every
# DM that is a reply, even when the user is just replying to a
# previous message in the same DM — that bogus id then routes to a
# nonexistent thread and Telegram returns 'Message thread not found'
# on send (#3206).
thread_id_raw = message.message_thread_id
thread_id_str = str(thread_id_raw) if thread_id_raw is not None else None
is_topic_message = bool(getattr(message, "is_topic_message", False))
thread_id_str = None
if thread_id_raw is not None:
if chat_type == "group":
thread_id_str = str(thread_id_raw)
elif chat_type == "dm" and is_topic_message:
thread_id_str = str(thread_id_raw)
# For forum groups without an explicit topic, default to the
# General-topic id so the gateway routes back to the General topic
# rather than dropping into the bot's main channel (#22423).
if chat_type == "group" and thread_id_str is None and getattr(chat, "is_forum", False):
thread_id_str = self._GENERAL_TOPIC_THREAD_ID
chat_topic = None