mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 10:52:21 +00:00
fix(telegram): recover reply text from native rich echo
Telegram DOES echo a rich message's content back in reply_to_message.api_kwargs['rich_message']['blocks'] when a user replies to it. Read that native field first in _build_message_event, keeping the local send-time index only as a fallback. Duck-type api_kwargs via .get() since it is a mappingproxy, not a dict. Fixes #49534
This commit is contained in:
parent
fcdefb4181
commit
29e5e127c6
2 changed files with 172 additions and 5 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue