"""Persistent registry of delivery targets that are confirmed unreachable. When a platform reports a target chat is permanently gone (deleted group, bot kicked/blocked, deactivated user), re-sending on every cron tick wastes a send against flood control and spams logs. The delivery layer short-circuits targets proven dead; any later successful send clears the flag (self-healing). Scope is deliberately narrow: only *whole-chat* deaths (``forbidden`` and chat-level ``not_found``) are recorded. Thread/topic-level ``not_found`` is NOT — adapters self-heal that by retrying without ``reply_to``, and a deleted topic does not mean the parent chat is dead. Storage is a JSON file under the active profile's HERMES_HOME (each profile keeps its own dead set). Reads/writes are best-effort: a corrupt or unwritable file degrades to in-memory-only rather than raising on the delivery path. """ from __future__ import annotations import json import logging import threading import time from pathlib import Path from typing import Dict, Optional from hermes_cli.config import get_hermes_home logger = logging.getLogger(__name__) # Error kinds (gateway.platforms.base.classify_send_error) meaning the whole # chat is unreachable, not a transient or thread-level problem. _DEAD_ERROR_KINDS = frozenset({"forbidden", "not_found"}) def _normalize(platform: str, chat_id: str) -> str: """Canonical key for a (platform, chat_id) pair.""" return f"{str(platform).strip().lower()}:{str(chat_id).strip()}" class DeadTargetRegistry: """Thread-safe, persistent set of confirmed-dead targets keyed ``platform:chat_id``. Each entry stores reason + timestamp for observability; :meth:`clear` (called on a successful send) removes the flag. """ def __init__(self, path: Optional[Path] = None) -> None: self._lock = threading.RLock() self._dead: Dict[str, Dict[str, object]] = {} self._path = path if path is not None else get_hermes_home() / "gateway" / "dead_targets.json" self._load() def _load(self) -> None: try: if self._path.exists(): raw = json.loads(self._path.read_text(encoding="utf-8")) if isinstance(raw, dict): self._dead = {k: v for k, v in raw.items() if isinstance(v, dict)} except (OSError, ValueError) as exc: logger.debug("dead_targets: could not load %s (%s) — starting empty", self._path, exc) self._dead = {} def _flush_locked(self) -> None: try: self._path.parent.mkdir(parents=True, exist_ok=True) tmp = self._path.with_suffix(self._path.suffix + ".tmp") tmp.write_text(json.dumps(self._dead, indent=2), encoding="utf-8") tmp.replace(self._path) except OSError as exc: # Best-effort: keep in-memory state, never break delivery. logger.debug("dead_targets: could not persist %s (%s)", self._path, exc) @staticmethod def is_dead_error_kind(error_kind: Optional[str]) -> bool: """True when ``error_kind`` denotes a permanent whole-chat death.""" return bool(error_kind) and error_kind in _DEAD_ERROR_KINDS def is_dead(self, platform: str, chat_id: Optional[str]) -> bool: if not chat_id: return False with self._lock: return _normalize(platform, chat_id) in self._dead def mark_dead(self, platform: str, chat_id: Optional[str], reason: str = "") -> bool: """Record a target as confirmed-dead. Returns True if newly added.""" if not chat_id: return False key = _normalize(platform, chat_id) with self._lock: existed = key in self._dead self._dead[key] = { "platform": str(platform).strip().lower(), "chat_id": str(chat_id), "reason": str(reason)[:200], "marked_at": time.time(), } self._flush_locked() if not existed: logger.info( "dead_targets: marked %s as unreachable (%s) — future deliveries " "to this target will be skipped until a send succeeds", key, reason or "no reason given", ) return not existed def clear(self, platform: str, chat_id: Optional[str]) -> bool: """Remove a target's dead flag (self-healing). Returns True if it was set.""" if not chat_id: return False key = _normalize(platform, chat_id) with self._lock: if key in self._dead: del self._dead[key] self._flush_locked() logger.info("dead_targets: cleared %s (delivery succeeded again)", key) return True return False