3c7069bdcb
authz_mixin, browser_control_broker, delivery, delivery_ledger, display_config, drain_control, hosted_room_links/peer/policy_checkpoint, hosted_rooms, platform_registry, relay/__init__, relay/ws_transport, run.py and slash_commands.py (comments), session_context, session_state, streaming_tts_consumer, turn_lease and small modules. - HostedRoomPolicyCheckpoint._apply_event -> per-kind handler table - WebSocketRelayTransport._handle_frame -> frame-handler table - GatewayAuthorizationMixin: unified adapter setting/flag/extra readers - dead symbols removed (verified zero references): RoomLinkProbe/select_room_link, relay_bot_username, is_restart_loop_tripped, debug_rows, DeadTargetRegistry.all_dead, BrowserControlBroker.detach_owner/_prune_tickets, StreamingTTSConsumer.started/_enqueue_done/ _iter_stream_chunks/_next_stream_chunk, RecoverableHandleCache.status_for, _auth_env, _copy_default_catalog, _parse_timestamp_prefix, _present_* helpers, _send_result_error_kind, _truthy_env, SessionFieldView/TurnLeaseTokenView dunder shims, and their orphaned tests. - lost WHY/invariant text from the earlier compaction restored compactly (541 hunks audited)
121 lines
4.8 KiB
Python
121 lines
4.8 KiB
Python
"""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
|