Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""Persistent registry of delivery targets that are confirmed unreachable.
|
||||
|
||||
When a messaging platform reports that a target chat is permanently gone — a
|
||||
deleted group (``Forbidden: the group chat was deleted``), a bot kicked/blocked,
|
||||
or a deactivated user — re-sending to it on every cron tick or every fan-out
|
||||
delivery wastes a send attempt against the platform's flood-control envelope and
|
||||
spams the logs. This registry lets the delivery layer short-circuit a target it
|
||||
has already proven dead, while staying self-healing: any successful send to that
|
||||
target clears the flag, so a user who re-adds the bot (or restores the chat)
|
||||
recovers automatically with no manual cleanup.
|
||||
|
||||
Scope is deliberately narrow. Only *whole-chat* deaths are recorded — the
|
||||
``forbidden`` and chat-level ``not_found`` (``chat not found``) error kinds.
|
||||
Thread/topic-level ``not_found`` is NOT recorded here: the adapters already
|
||||
self-heal that by retrying without ``reply_to`` (see the Telegram adapter's
|
||||
reply-target-deleted path), and a deleted topic does not mean the parent chat is
|
||||
dead.
|
||||
|
||||
The store is a small JSON file under the active profile's HERMES_HOME so each
|
||||
profile keeps its own dead set. Reads/writes are best-effort: a corrupt or
|
||||
unwritable file degrades to an in-memory-only registry 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 (from gateway.platforms.base.classify_send_error) that mean 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 delivery targets.
|
||||
|
||||
Keyed on ``platform:chat_id``. Stores the reason and a timestamp for
|
||||
observability. Self-healing: :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]] = {}
|
||||
if path is not None:
|
||||
self._path = path
|
||||
else:
|
||||
self._path = get_hermes_home() / "gateway" / "dead_targets.json"
|
||||
self._load()
|
||||
|
||||
# -- persistence -------------------------------------------------------
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
if self._path.exists():
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
# Only keep well-shaped entries.
|
||||
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 the in-memory state, don't break delivery.
|
||||
logger.debug("dead_targets: could not persist %s (%s)", self._path, exc)
|
||||
|
||||
# -- public API --------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def is_dead_error_kind(error_kind: Optional[str]) -> bool:
|
||||
"""Return 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
|
||||
|
||||
def all_dead(self) -> Dict[str, Dict[str, object]]:
|
||||
"""Snapshot of the current dead set (for diagnostics / `hermes` CLI)."""
|
||||
with self._lock:
|
||||
return {k: dict(v) for k, v in self._dead.items()}
|
||||
Reference in New Issue
Block a user