diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 2a1054b1d2e..b4c7995cf4e 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -6646,6 +6646,77 @@ class TelegramAdapter(BasePlatformAdapter): self.name, cache_key, thread_id, ) + @classmethod + def _flatten_rich_inline_text(cls, value: Any) -> str: + """Best-effort plaintext flattener for Bot API rich-message inline nodes.""" + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, list): + return "".join(cls._flatten_rich_inline_text(item) for item in value) + if isinstance(value, dict): + text = value.get("text") + if text is not None: + return cls._flatten_rich_inline_text(text) + children = value.get("children") + if children is not None: + return cls._flatten_rich_inline_text(children) + return "" + + @classmethod + def _flatten_rich_blocks(cls, blocks: Any) -> str: + """Best-effort plaintext flattener for Bot API rich-message blocks.""" + if not isinstance(blocks, list): + return "" + + lines: List[str] = [] + for block in blocks: + if not isinstance(block, dict): + continue + + block_type = block.get("type") + if block_type == "list": + for item in block.get("items", []): + if not isinstance(item, dict): + continue + item_text = cls._flatten_rich_blocks(item.get("blocks")) + if not item_text: + continue + label = item.get("label") + item_lines = item_text.splitlines() + if not item_lines: + continue + first_line = item_lines[0] + if label: + first_line = f"{label} {first_line}".strip() + lines.append(first_line) + lines.extend(item_lines[1:]) + continue + + text = cls._flatten_rich_inline_text(block.get("text")) + if text: + lines.extend(text.splitlines()) + + return "\n".join(line.rstrip() for line in lines if line) + + @classmethod + def _extract_rich_reply_text(cls, reply_to_message: Any) -> Optional[str]: + """Return plaintext echoed by Telegram's rich_message reply payload.""" + try: + api_kwargs = getattr(reply_to_message, "api_kwargs", None) + getter = getattr(api_kwargs, "get", None) + if not callable(getter): + return None + rich_message = getter("rich_message") + rich_getter = getattr(rich_message, "get", None) + if not callable(rich_getter): + return None + text = cls._flatten_rich_blocks(rich_getter("blocks")).strip() + return text or None + except Exception: + return None + def _build_message_event( self, message: Message, @@ -6772,11 +6843,11 @@ class TelegramAdapter(BasePlatformAdapter): or None ) if not reply_to_text: - # Rich messages (sendRichMessage — the launchd briefings and - # the gateway's own rich finals) are NOT echoed with their - # content in reply_to_message; Telegram sends no text, - # caption, or api_kwargs for them. Recover the text we sent - # from our local send-time index, keyed by message id. + # Prefer Telegram's native rich-message echo when present; + # keep the local send-time index only as a fallback for + # older/unrecoverable reply payloads. + reply_to_text = self._extract_rich_reply_text(message.reply_to_message) + if not reply_to_text: try: from gateway import rich_sent_store reply_to_text = rich_sent_store.lookup( diff --git a/tests/gateway/test_telegram_rich_messages.py b/tests/gateway/test_telegram_rich_messages.py index db684ea0ac9..266b69ec9e9 100644 --- a/tests/gateway/test_telegram_rich_messages.py +++ b/tests/gateway/test_telegram_rich_messages.py @@ -791,6 +791,39 @@ def _reply_message(reply_to_id, *, reply_text=None, reply_caption=None, quote_te ) +def _reply_message_with_rich_blocks( + reply_to_id, + *, + blocks, + quote_text=None, + api_kwargs_factory=dict, +): + """Build a reply whose echoed content lives only in api_kwargs.rich_message.""" + replied = SimpleNamespace( + message_id=int(reply_to_id), + text=None, + caption=None, + api_kwargs=api_kwargs_factory({"rich_message": {"blocks": blocks}}), + ) + quote = SimpleNamespace(text=quote_text) if quote_text is not None else None + return SimpleNamespace( + message_id=999, + chat=SimpleNamespace(id=12345, type="private", title=None, full_name="U"), + from_user=SimpleNamespace( + id=42, username="u", first_name="U", last_name=None, + full_name="U", is_bot=False, + ), + text="what did this mean?", + caption=None, + reply_to_message=replied, + quote=quote, + message_thread_id=None, + is_topic_message=False, + entities=[], + date=None, + ) + + @pytest.mark.asyncio async def test_rich_reply_records_and_recovers_text(monkeypatch, tmp_path): """A reply to a rich-sent message resolves the original text via the index.""" @@ -863,3 +896,66 @@ async def test_rich_reply_caption_wins_over_lookup(monkeypatch, tmp_path): _reply_message("678", reply_caption="echoed caption"), MessageType.TEXT, ) assert event.reply_to_text == "echoed caption" + + +@pytest.mark.asyncio +async def test_rich_reply_native_blocks_fill_reply_text_without_index(monkeypatch, tmp_path): + """Echoed rich_message blocks should recover reply text natively.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from gateway.platforms.base import MessageType + + adapter = _make_adapter() + event = adapter._build_message_event( + _reply_message_with_rich_blocks( + "678", + blocks=[ + {"type": "paragraph", "text": ["Hello ", {"type": "bold", "text": "world"}]}, + {"type": "pre", "text": "Line 2"}, + ], + ), + MessageType.TEXT, + ) + assert event.reply_to_text == "Hello world\nLine 2" + + +@pytest.mark.asyncio +async def test_rich_reply_native_blocks_win_over_index(monkeypatch, tmp_path): + """Native rich echo should beat the local send-time index fallback.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from gateway.platforms.base import MessageType + from gateway import rich_sent_store + + rich_sent_store.record("12345", "678", "recorded body") + adapter = _make_adapter() + event = adapter._build_message_event( + _reply_message_with_rich_blocks( + "678", + blocks=[{"type": "paragraph", "text": ["Echoed ", {"type": "italic", "text": "body"}]}], + ), + MessageType.TEXT, + ) + assert event.reply_to_text == "Echoed body" + + +@pytest.mark.asyncio +async def test_rich_reply_native_blocks_support_mappingproxy_like_api_kwargs(monkeypatch, tmp_path): + """Duck-type api_kwargs via .get() so mappingproxy-like objects also work.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from gateway.platforms.base import MessageType + + class MappingProxyLike(dict): + pass + + adapter = _make_adapter() + event = adapter._build_message_event( + _reply_message_with_rich_blocks( + "678", + blocks=[ + {"type": "heading", "text": "Status", "size": 2}, + {"type": "list", "items": [{"label": "-", "blocks": [{"type": "paragraph", "text": ["done"]}]}]}, + ], + api_kwargs_factory=MappingProxyLike, + ), + MessageType.TEXT, + ) + assert event.reply_to_text == "Status\n- done"