diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 0fede455a92..aed7b71af9b 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1241,6 +1241,14 @@ class TelegramAdapter(BasePlatformAdapter): 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 @@ class TelegramAdapter(BasePlatformAdapter): 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 00000000000..fee63602bd0 --- /dev/null +++ b/gateway/rich_sent_store.py @@ -0,0 +1,80 @@ +"""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. + +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 0f25f215a42..c0c5e9dfa0e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8259,10 +8259,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _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