feat(telegram): add Bot API 10.1 sendRichMessage support
- Try sendRichMessage for all non-blockquote messages (tables, task lists, math, collapsible, etc.) with graceful fallback to legacy HTML - Fix payload format to use top-level 'markdown' field per Bot API 10.1 spec - Add rich path in send_delta() stream end (delete preview + send rich) - Latch off permanently if server returns capability error - Removes _needs_rich_rendering() gate and 4 regex patterns
This commit is contained in:
@@ -443,6 +443,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
|
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
|
||||||
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
|
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
|
||||||
self._inbound_workers: dict[str, asyncio.Task] = {}
|
self._inbound_workers: dict[str, asyncio.Task] = {}
|
||||||
|
self._rich_send_disabled: bool = False # Latch off if Bot API < 10.1
|
||||||
|
|
||||||
def is_allowed(self, sender_id: str) -> bool:
|
def is_allowed(self, sender_id: str) -> bool:
|
||||||
"""Preserve Telegram's legacy id|username allowlist matching."""
|
"""Preserve Telegram's legacy id|username allowlist matching."""
|
||||||
@@ -632,6 +633,72 @@ class TelegramChannel(BaseChannel):
|
|||||||
def _is_remote_media_url(path: str) -> bool:
|
def _is_remote_media_url(path: str) -> bool:
|
||||||
return path.startswith(("http://", "https://"))
|
return path.startswith(("http://", "https://"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_rich_capability_error(exc: Exception) -> bool:
|
||||||
|
"""True when the error indicates sendRichMessage is unavailable."""
|
||||||
|
err = str(exc).lower()
|
||||||
|
return (
|
||||||
|
"method not found" in err
|
||||||
|
or "not found" in err
|
||||||
|
or "bad request: invalid parameter" in err
|
||||||
|
or "unknown method" in err
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _try_send_rich(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
content: str,
|
||||||
|
reply_params=None,
|
||||||
|
thread_kwargs: dict | None = None,
|
||||||
|
reply_markup=None,
|
||||||
|
) -> bool:
|
||||||
|
"""Attempt sendRichMessage (Bot API 10.1). Returns True on success."""
|
||||||
|
if not self._app:
|
||||||
|
return False
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"rich_message": {
|
||||||
|
"markdown": content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if reply_params is not None:
|
||||||
|
# sendRichMessage uses reply_parameters (object), not reply_to_message_id.
|
||||||
|
if hasattr(reply_params, "message_id"):
|
||||||
|
payload["reply_parameters"] = {
|
||||||
|
"message_id": reply_params.message_id,
|
||||||
|
"allow_sending_without_reply": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
payload["reply_parameters"] = reply_params
|
||||||
|
if thread_kwargs:
|
||||||
|
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
|
||||||
|
if reply_markup is not None:
|
||||||
|
payload["reply_markup"] = reply_markup
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._call_with_retry(
|
||||||
|
self._app.bot.do_api_request,
|
||||||
|
"sendRichMessage",
|
||||||
|
api_kwargs=payload,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except BadRequest as exc:
|
||||||
|
if self._is_rich_capability_error(exc):
|
||||||
|
self.logger.debug("sendRichMessage not available, disabling")
|
||||||
|
self._rich_send_disabled = True
|
||||||
|
else:
|
||||||
|
self.logger.debug("sendRichMessage rejected: {}", exc)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
err_str = str(exc).lower()
|
||||||
|
is_timeout = "timed out" in err_str or isinstance(exc, TimedOut)
|
||||||
|
if is_timeout:
|
||||||
|
self.logger.warning("sendRichMessage timeout, not retrying legacy")
|
||||||
|
return False
|
||||||
|
self.logger.debug("sendRichMessage failed: {}", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send a message through Telegram."""
|
"""Send a message through Telegram."""
|
||||||
if not self._app:
|
if not self._app:
|
||||||
@@ -731,6 +798,20 @@ class TelegramChannel(BaseChannel):
|
|||||||
# Fallback: no native keyboard → splice labels into the message so the choices survive.
|
# Fallback: no native keyboard → splice labels into the message so the choices survive.
|
||||||
if buttons and reply_markup is None:
|
if buttons and reply_markup is None:
|
||||||
text = f"{text}\n\n{self._buttons_as_text(buttons)}"
|
text = f"{text}\n\n{self._buttons_as_text(buttons)}"
|
||||||
|
|
||||||
|
# Bot API 10.1 rich fast-path: send raw markdown via sendRichMessage.
|
||||||
|
# All non-blockquote content tries rich first; _rich_send_disabled
|
||||||
|
# latches off permanently if the server doesn't support it.
|
||||||
|
if (
|
||||||
|
not render_as_blockquote
|
||||||
|
and not getattr(self, "_rich_send_disabled", False)
|
||||||
|
):
|
||||||
|
rich_ok = await self._try_send_rich(
|
||||||
|
chat_id, text, reply_params, thread_kwargs, reply_markup,
|
||||||
|
)
|
||||||
|
if rich_ok:
|
||||||
|
return
|
||||||
|
|
||||||
chunks = _split_telegram_markdown(text, TELEGRAM_MAX_MESSAGE_LEN)
|
chunks = _split_telegram_markdown(text, TELEGRAM_MAX_MESSAGE_LEN)
|
||||||
for i, chunk in enumerate(chunks):
|
for i, chunk in enumerate(chunks):
|
||||||
is_last = (i == len(chunks) - 1)
|
is_last = (i == len(chunks) - 1)
|
||||||
@@ -826,6 +907,28 @@ class TelegramChannel(BaseChannel):
|
|||||||
if message_thread_id := meta.get("message_thread_id"):
|
if message_thread_id := meta.get("message_thread_id"):
|
||||||
thread_kwargs["message_thread_id"] = message_thread_id
|
thread_kwargs["message_thread_id"] = message_thread_id
|
||||||
raw_text = buf.text
|
raw_text = buf.text
|
||||||
|
|
||||||
|
# Try sendRichMessage for final output (Bot API 10.1)
|
||||||
|
if not getattr(self, "_rich_send_disabled", False):
|
||||||
|
reply_params = None
|
||||||
|
if reply_to_message_id := meta.get("message_id"):
|
||||||
|
reply_params = {"message_id": int(reply_to_message_id), "allow_sending_without_reply": True}
|
||||||
|
rich_ok = await self._try_send_rich(
|
||||||
|
int_chat_id, raw_text, reply_params, thread_kwargs, None,
|
||||||
|
)
|
||||||
|
if rich_ok:
|
||||||
|
# Delete the streaming preview message
|
||||||
|
try:
|
||||||
|
await self._call_with_retry(
|
||||||
|
self._app.bot.delete_message,
|
||||||
|
chat_id=int_chat_id, message_id=buf.message_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Preview stays if delete fails
|
||||||
|
self._stream_bufs.pop(chat_id, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Legacy path: edit existing streaming message with HTML
|
||||||
html_chunks = _split_telegram_markdown_html(raw_text, TELEGRAM_HTML_MAX_LEN)
|
html_chunks = _split_telegram_markdown_html(raw_text, TELEGRAM_HTML_MAX_LEN)
|
||||||
primary_html = html_chunks[0]
|
primary_html = html_chunks[0]
|
||||||
extra_html_chunks = html_chunks[1:]
|
extra_html_chunks = html_chunks[1:]
|
||||||
|
|||||||
Reference in New Issue
Block a user