Files
hermes-agent/gateway/rich_sent_store.py
T
teknium1 3ef8b384a9 refactor(persistence): 24 hand-rolled atomic JSON/text writers go through utils.atomic_json_write / atomic_write_text
Each copy re-implemented temp+replace by hand and lacked one or more of
fsync, symlink preservation, atomic_replace's Windows-contention retry and
EXDEV/bind-mount fallback, mode preservation, or interrupt-safe temp
cleanup. Three (gateway/session_persistence, cron/suggestions,
agent/shell_hooks) were verbatim inlines of utils._atomic_write; two
modules defined their own directory-fsync helper, now utils.fsync_directory.
plugins/google_meet/_jsonfile.write_json_atomic is deleted (callers use the
canonical helper directly).

Behavior change: every one of these writers now fsyncs the payload, keeps a
pre-existing target's mode, cleans its temp file on BaseException, and
survives Windows AV/indexer contention and cross-device renames the way
config writes already did. cron/suggestions.json is 0600 from creation
(previously chmod'ed after the replace). Skipped on purpose: cron/jobs.py
two-phase staging, gateway/status._write_json_excl (create-only lock),
kanban_transfer staging (not atomic writers); tools/skill_usage.
_write_suppressed_names lives inside a PLUGIN-COMPAT block.
2026-09-13 05:07:11 -07:00

86 lines
3.4 KiB
Python

"""Local index of what we've sent (and, for WhatsApp, received) keyed by ``(chat_id, message_id)``.
Telegram does NOT echo a rich message's content back in ``reply_to_message`` (``.text``/``.caption``
empty, ``.api_kwargs`` None), and WhatsApp quotes carry only the quoted message's id (Cloud API) or a
thumbnail stub (Baileys) — never the original bytes. So a reply to something we sent arrives with no
quotable text and no way to re-fetch a quoted attachment. We remember ``message_id -> text`` and
``message_id -> [(local_path, mime)]`` at send/receive time and look them up by ``reply_to_id`` on
inbound. 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
from utils import atomic_json_write
_MAX_ENTRIES = 1000
_MAX_TEXT_CHARS = 2000
def _store_path() -> str:
from hermes_constants import get_hermes_home # honors the active profile override
return os.path.join(str(get_hermes_home()), "state", "rich_sent_index.json")
def _load(path: str) -> dict:
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except (FileNotFoundError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _update(chat_id, message_id, fields: dict) -> None:
"""Merge ``fields`` into the ``(chat_id, message_id)`` entry. No-op on any failure."""
path = _store_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
data = _load(path)
key = f"{chat_id}:{message_id}"
entry = data.get(key)
entry = entry if isinstance(entry, dict) else {}
data[key] = {**entry, **fields, "ts": int(time.time())}
if len(data) > _MAX_ENTRIES: # trim oldest by timestamp
for k, _ in sorted(data.items(), key=lambda kv: kv[1].get("ts", 0))[: len(data) - _MAX_ENTRIES]:
data.pop(k, None)
atomic_json_write(path, data, indent=None) # atomic; tolerates concurrent writers racing
except Exception:
return
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
_update(chat_id, message_id, {"t": text[:_MAX_TEXT_CHARS]})
def record_media(chat_id, message_id, media: list[tuple[str, str]]) -> None:
"""Persist local attachment ``(path, mime)`` pairs for ``(chat_id, message_id)``."""
if not media or message_id is None or chat_id is None:
return
_update(chat_id, message_id, {"m": [[str(p), str(mt or "")] for p, mt in media if p]})
def _entry(chat_id, message_id) -> dict:
if message_id is None or chat_id is None:
return {}
entry = _load(_store_path()).get(f"{chat_id}:{message_id}")
return entry if isinstance(entry, dict) else {}
def lookup(chat_id, message_id) -> Optional[str]:
"""Return stored text for ``(chat_id, message_id)`` or ``None``."""
return _entry(chat_id, message_id).get("t") or None
def lookup_media(chat_id, message_id) -> list[tuple[str, str]]:
"""Return stored ``(path, mime)`` pairs whose file still exists (attachments may be temp files)."""
pairs = _entry(chat_id, message_id).get("m") or []
return [(p, mt) for p, mt in pairs if isinstance(p, str) and os.path.isfile(p)]