From 2bd7fe1b7a76b9966786f3e77587dfb67ffca790 Mon Sep 17 00:00:00 2001 From: "Sierra (Hermes Agent)" Date: Tue, 16 Jun 2026 12:44:15 -0400 Subject: [PATCH] fix(telegram): resolve replies to rich (sendRichMessage) messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram does not echo a rich message's content in reply_to_message, so replies to launchd briefings (and the gateway's own rich finals) arrived with empty .text/.caption and .api_kwargs=None — the agent was blind to what was being quoted. Read-side recovery is impossible. Fix: remember sent text at send time and recover it on reply. - new gateway/rich_sent_store.py: JSON index chat_id:message_id -> text (state/rich_sent_index.json, cap 1000, atomic writes) - _try_send_rich records its content on success - inbound reply extractor falls back to rich_sent_store.lookup when .text/.caption are empty - run.py: make the [Replying to: ...] injection an explicit instruction so MiniMax-M3 stops claiming it can't see quoted context Verified end-to-end: reply to a fresh rich message now resolves its text. Cron-side sender lives in ~/.hermes/scripts/telegram/send_rich.py (separate repo). Co-Authored-By: Claude Opus 4.8 (1M context) --- gateway/platforms/telegram.py | 21 +++++++++ gateway/rich_sent_store.py | 82 +++++++++++++++++++++++++++++++++++ gateway/run.py | 14 ++++-- 3 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 gateway/rich_sent_store.py diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0fede455a929e..aed7b71af9b54 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1241,6 +1241,14 @@ async def _try_send_rich( message_id = (msg.get("result") or {}).get("message_id") else: message_id = getattr(msg, "message_id", None) + if message_id is not None: + # Telegram won't echo rich content in reply_to_message, so remember + # what we sent — replies to this message resolve via this index. + try: + from gateway import rich_sent_store + rich_sent_store.record(str(chat_id), str(message_id), content) + except Exception: + pass return SendResult( success=True, message_id=str(message_id) if message_id is not None else None, @@ -6700,6 +6708,19 @@ def _build_message_event( or message.reply_to_message.caption 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. + try: + from gateway import rich_sent_store + reply_to_text = rich_sent_store.lookup( + str(chat.id), reply_to_id + ) + except Exception: + reply_to_text = None # Per-channel/topic ephemeral prompt from gateway.platforms.base import resolve_channel_prompt diff --git a/gateway/rich_sent_store.py b/gateway/rich_sent_store.py new file mode 100644 index 0000000000000..c0a2cbe022852 --- /dev/null +++ b/gateway/rich_sent_store.py @@ -0,0 +1,82 @@ +"""Local index of text we've sent via ``sendRichMessage`` (Bot API 10.1). + +Telegram does NOT echo a rich message's content back in ``reply_to_message`` +when a user replies to it (verified: ``.text``/``.caption`` empty, +``.api_kwargs`` None). So replies to the launchd briefings / any rich send +arrive with no quotable text and the agent is blind to what was referenced. + +Fix: remember ``message_id -> text`` at send time, look it up by +``reply_to_id`` on inbound. This module is the single source of truth for that +index. ``scripts/telegram/send_rich.py`` (the cron sender) writes the same +file format with an inlined copy of ``record`` since it can't import this +package; keep the two in sync if the format changes. + +Best-effort and dependency-free: every operation swallows errors and degrades +to a no-op / ``None`` so it can never break a send or an inbound message. +""" + +from __future__ import annotations + +import json +import os +import time +from typing import Optional + +_MAX_ENTRIES = 1000 +_MAX_TEXT_CHARS = 2000 + + +def _store_path() -> str: + home = os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes") + return os.path.join(home, "state", "rich_sent_index.json") + + +def _key(chat_id, message_id) -> str: + return f"{chat_id}:{message_id}" + + +def record(chat_id, message_id, text: Optional[str]) -> None: + """Persist ``text`` for ``(chat_id, message_id)``. No-op on any failure.""" + if not text or message_id is None or chat_id is None: + return + path = _store_path() + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if not isinstance(data, dict): + data = {} + except (FileNotFoundError, ValueError): + data = {} + data[_key(chat_id, message_id)] = { + "t": text[:_MAX_TEXT_CHARS], + "ts": int(time.time()), + } + # Trim oldest by timestamp when over cap. + if len(data) > _MAX_ENTRIES: + for k, _ in sorted( + data.items(), key=lambda kv: kv[1].get("ts", 0) + )[: len(data) - _MAX_ENTRIES]: + data.pop(k, None) + tmp = f"{path}.tmp.{os.getpid()}" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False) + os.replace(tmp, path) # atomic; tolerates concurrent writers racing + except Exception: + return + + +def lookup(chat_id, message_id) -> Optional[str]: + """Return stored text for ``(chat_id, message_id)`` or ``None``.""" + if message_id is None or chat_id is None: + return None + try: + with open(_store_path(), "r", encoding="utf-8") as fh: + data = json.load(fh) + entry = data.get(_key(chat_id, message_id)) + if isinstance(entry, dict): + return entry.get("t") or None + except (FileNotFoundError, ValueError, AttributeError): + return None + return None diff --git a/gateway/run.py b/gateway/run.py index b688f3a3613be..b6967a76cd923 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8167,7 +8167,13 @@ async def _prepare_inbound_message_text( # multiple times, and without an explicit pointer the agent has to # guess (or answer for both subjects). Token overhead is minimal. reply_snippet = event.reply_to_text[:500] - message_text = f'[Replying to: "{reply_snippet}"]\n\n{message_text}' + message_text = ( + f'[The user is replying to your earlier message, quoted below. ' + f'This IS the message they are referring to — it is visible to you ' + f'right here. Do NOT claim you cannot see what they replied to; ' + f'answer using this quoted text as the referent:]\n' + f'"{reply_snippet}"\n\n{message_text}' + ) if "@" in message_text: try: @@ -8259,10 +8265,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g _msg_start_time = time.time() _platform_name = source.platform.value if hasattr(source.platform, "value") else str(source.platform) _msg_preview = (event.text or "")[:80].replace("\n", " ") + _reply_id = getattr(event, "reply_to_message_id", None) + _reply_txt = (getattr(event, "reply_to_text", None) or "")[:80].replace("\n", " ") logger.info( - "inbound message: platform=%s user=%s chat=%s msg=%r", + "inbound message: platform=%s user=%s chat=%s msg=%r reply_to_id=%s reply_to_text=%r", _platform_name, source.user_name or source.user_id or "unknown", - source.chat_id or "unknown", _msg_preview, + source.chat_id or "unknown", _msg_preview, _reply_id, _reply_txt, ) # Get or create session