Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions gateway/rich_sent_store.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 11 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down