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)
508 lines
21 KiB
Python
508 lines
21 KiB
Python
"""
|
|
Delivery routing for cron job outputs and agent responses.
|
|
|
|
Routes messages by target: explicit ("telegram:123456789"), platform home
|
|
channel ("telegram"), origin (back to where the job was created), or local
|
|
(saved to files).
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from dataclasses import dataclass
|
|
from typing import Dict, List, Optional, Any
|
|
|
|
from hermes_cli.config import get_hermes_home
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cap before gateway-level truncation of cron output for non-chunking platform
|
|
# delivery. Telegram's hard API limit is 4096; the headroom covers the "full
|
|
# output saved to …" footer. Adapters that split long messages natively
|
|
# (BasePlatformAdapter.splits_long_messages) bypass this entirely.
|
|
MAX_PLATFORM_OUTPUT = 4000
|
|
|
|
# Matches strings that are *only* a "silence" narration with optional markdown
|
|
# wrappers: *(silent)*, _silent_, `silent`, (silent), silent, 🔇, a bare ".",
|
|
# "…", and marker-padded variants. Anchored so substantive messages that merely
|
|
# *contain* the word "silent" never match.
|
|
_SILENCE_NARRATION = re.compile(
|
|
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
|
|
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _is_silence_narration(content: Optional[str]) -> bool:
|
|
"""True when ``content`` is *only* a silence-narration token (length-guarded)."""
|
|
if not content:
|
|
return False
|
|
stripped = content.strip()
|
|
if not stripped or len(stripped) > 64:
|
|
return False
|
|
return bool(_SILENCE_NARRATION.match(stripped))
|
|
|
|
from .config import Platform, GatewayConfig, PlatformConfig
|
|
from .session import SessionSource
|
|
from .dead_targets import DeadTargetRegistry
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeliveryTransport:
|
|
"""Resolved live transport for one logical delivery platform."""
|
|
|
|
adapter: Any
|
|
config: Optional[PlatformConfig]
|
|
transport_platform: Platform
|
|
|
|
@property
|
|
def is_relay(self) -> bool:
|
|
return self.transport_platform == Platform.RELAY
|
|
|
|
async def send(
|
|
self,
|
|
logical_platform: Platform,
|
|
chat_id: str,
|
|
content: str,
|
|
metadata: Optional[Dict[str, Any]],
|
|
) -> Any:
|
|
"""Send through this transport while preserving the logical platform."""
|
|
if self.is_relay:
|
|
return await self.adapter.send_for_platform(
|
|
logical_platform, chat_id, content, metadata=metadata,
|
|
)
|
|
return await self.adapter.send(chat_id, content, metadata=metadata)
|
|
|
|
|
|
def resolve_delivery_transport(
|
|
platform: Platform,
|
|
config: GatewayConfig,
|
|
adapters: Optional[Dict[Platform, Any]],
|
|
) -> Optional[DeliveryTransport]:
|
|
"""Resolve a logical platform to its live delivery transport.
|
|
|
|
A concrete native adapter always wins. Relay is eligible only when its
|
|
authenticated transport explicitly advertises that it fronts the logical
|
|
platform, so restart-time delivery is independent of per-chat caches without
|
|
letting Relay hijack unrelated platform targets.
|
|
"""
|
|
live_adapters = adapters or {}
|
|
native = live_adapters.get(platform)
|
|
native_config = config.platforms.get(platform)
|
|
# Explicitly supplied live adapters with no config block are honored, but an
|
|
# explicitly disabled native adapter never shadows an enabled Relay transport.
|
|
if native is not None and (native_config is None or native_config.enabled):
|
|
return DeliveryTransport(native, native_config, platform)
|
|
|
|
relay = live_adapters.get(Platform.RELAY)
|
|
relay_config = config.platforms.get(Platform.RELAY)
|
|
fronts_platform = getattr(relay, "fronts_platform", None)
|
|
if (
|
|
relay is not None
|
|
and (relay_config is None or relay_config.enabled)
|
|
and callable(fronts_platform)
|
|
and fronts_platform(platform)
|
|
):
|
|
return DeliveryTransport(relay, relay_config, Platform.RELAY)
|
|
return None
|
|
|
|
|
|
def _as_int(value: Any) -> Optional[int]:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def looks_like_telegram_private_chat_id(chat_id: Optional[str]) -> bool:
|
|
"""True when ``chat_id`` is a positive int — Telegram's private-chat shape.
|
|
|
|
Groups/channels/supergroups use negative IDs. Single source of truth for the
|
|
heuristic, reused by the handoff seed path in ``gateway/run.py`` so
|
|
handoff-created DM topics key the same way as inbound DM-topic messages.
|
|
"""
|
|
if chat_id is None:
|
|
return False
|
|
parsed = _as_int(chat_id)
|
|
return parsed is not None and parsed > 0
|
|
|
|
|
|
def _looks_like_int(value: Optional[str]) -> bool:
|
|
return value is not None and _as_int(value) is not None
|
|
|
|
|
|
def _result_field(result: Any, name: str, default: Any = None) -> Any:
|
|
"""Read ``name`` from a SendResult object or a plain result dict."""
|
|
if isinstance(result, dict):
|
|
return result.get(name, default)
|
|
return getattr(result, name, default)
|
|
|
|
|
|
def _send_result_failed(result: Any) -> bool:
|
|
return _result_field(result, "success", True) is False
|
|
|
|
|
|
def _send_result_error(result: Any) -> Optional[str]:
|
|
error = _result_field(result, "error")
|
|
return str(error) if error else None
|
|
|
|
|
|
def _is_thread_not_found_delivery_error(result: Any) -> bool:
|
|
error = _send_result_error(result)
|
|
return bool(error and "thread not found" in error.lower())
|
|
|
|
|
|
def _classify_dead_from_error_text(error_text: Optional[str]) -> Optional[str]:
|
|
"""Best-effort dead-target classification from a raised error's text.
|
|
|
|
``_deliver_to_platform`` raises on hard failure (no SendResult), so the
|
|
``deliver()`` loop only has the exception string; reuse the platform-neutral
|
|
classifier to recover the error_kind from it.
|
|
"""
|
|
if not error_text:
|
|
return None
|
|
try:
|
|
from .platforms.base import classify_send_error, is_chat_level_not_found
|
|
except Exception: # pragma: no cover - import guard
|
|
return None
|
|
kind = classify_send_error(None, error_text=error_text)
|
|
if not DeadTargetRegistry.is_dead_error_kind(kind):
|
|
return None
|
|
# ``not_found`` collapses chat-level and thread/topic/message-level failures.
|
|
# Only a whole-chat not_found means the target is dead — a deleted forum
|
|
# topic or an edited-away message must not mark the entire chat (and all its
|
|
# future deliveries) dead (see gateway.dead_targets).
|
|
if kind == "not_found" and not is_chat_level_not_found(error_text=error_text):
|
|
return None
|
|
return kind
|
|
|
|
|
|
@dataclass
|
|
class DeliveryTarget:
|
|
"""A single delivery target: "origin", "local", "telegram" (home channel),
|
|
or "telegram:123456[:thread]" (specific chat)."""
|
|
platform: Platform
|
|
chat_id: Optional[str] = None # None means use home channel
|
|
thread_id: Optional[str] = None
|
|
is_origin: bool = False
|
|
is_explicit: bool = False # True if chat_id was explicitly specified
|
|
|
|
@classmethod
|
|
def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "DeliveryTarget":
|
|
"""Parse "origin" | "local" | "<platform>" | "<platform>:<chat_id>[:<thread_id>]"."""
|
|
target_stripped = target.strip()
|
|
target_lower = target_stripped.lower()
|
|
|
|
if target_lower == "origin":
|
|
if origin:
|
|
return cls(
|
|
platform=origin.platform,
|
|
chat_id=origin.chat_id,
|
|
thread_id=origin.thread_id,
|
|
is_origin=True,
|
|
)
|
|
return cls(platform=Platform.LOCAL, is_origin=True)
|
|
|
|
if target_lower == "local":
|
|
return cls(platform=Platform.LOCAL)
|
|
|
|
# Platform names are case-insensitive; chat/thread ids keep original case.
|
|
# Unknown platforms are treated as local.
|
|
if ":" in target_stripped:
|
|
parts = target_stripped.split(":", 2)
|
|
chat_id = parts[1] if len(parts) > 1 else None
|
|
thread_id = parts[2] if len(parts) > 2 else None
|
|
try:
|
|
platform = Platform(parts[0].lower())
|
|
except ValueError:
|
|
return cls(platform=Platform.LOCAL)
|
|
return cls(platform=platform, chat_id=chat_id, thread_id=thread_id, is_explicit=True)
|
|
|
|
try:
|
|
return cls(platform=Platform(target_lower))
|
|
except ValueError:
|
|
return cls(platform=Platform.LOCAL)
|
|
|
|
def to_string(self) -> str:
|
|
"""Convert back to string format."""
|
|
if self.is_origin:
|
|
return "origin"
|
|
if self.platform == Platform.LOCAL:
|
|
return "local"
|
|
if self.chat_id and self.thread_id:
|
|
return f"{self.platform.value}:{self.chat_id}:{self.thread_id}"
|
|
if self.chat_id:
|
|
return f"{self.platform.value}:{self.chat_id}"
|
|
return self.platform.value
|
|
|
|
|
|
async def _ensure_named_dm_topic(adapter: Any, chat_id: str, name: str, *, refresh: bool) -> str:
|
|
"""Create (or force-recreate) a named Telegram private DM topic; return its thread id."""
|
|
verb = "refresh" if refresh else "create"
|
|
ensure_dm_topic = getattr(adapter, "ensure_dm_topic", None)
|
|
if ensure_dm_topic is None:
|
|
raise RuntimeError(f"Telegram adapter cannot {verb} named private DM topics")
|
|
if refresh:
|
|
thread_id = await ensure_dm_topic(chat_id, name, force_create=True)
|
|
else:
|
|
thread_id = await ensure_dm_topic(chat_id, name)
|
|
if not thread_id:
|
|
raise RuntimeError(f"Failed to {verb} Telegram private DM topic '{name}'")
|
|
return str(thread_id)
|
|
|
|
|
|
class DeliveryRouter:
|
|
"""Resolves delivery targets and dispatches messages to platform adapters."""
|
|
|
|
def __init__(self, config: GatewayConfig, adapters: Dict[Platform, Any] = None,
|
|
dead_targets: Optional[DeadTargetRegistry] = None):
|
|
"""``dead_targets``: shared registry of confirmed-unreachable targets;
|
|
a profile-local registry is created when omitted."""
|
|
self.config = config
|
|
self.adapters = adapters or {}
|
|
self.output_dir = get_hermes_home() / "cron" / "output"
|
|
self.dead_targets = dead_targets or DeadTargetRegistry()
|
|
|
|
async def deliver(
|
|
self,
|
|
content: str,
|
|
targets: List[DeliveryTarget],
|
|
job_id: Optional[str] = None,
|
|
job_name: Optional[str] = None,
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""Deliver content to all targets; returns per-target results keyed by target string."""
|
|
results = {}
|
|
|
|
for target in targets:
|
|
# Skip targets proven permanently unreachable (deleted group, blocked
|
|
# bot, deactivated user) — re-sending each tick wastes flood-control
|
|
# budget. Self-healing: a later successful send clears the flag.
|
|
# LOCAL/origin-without-chat targets are never dead-tracked.
|
|
tracked = target.platform != Platform.LOCAL and target.chat_id
|
|
if tracked and self.dead_targets.is_dead(target.platform.value, target.chat_id):
|
|
logger.info(
|
|
"Skipping delivery to known-dead target %s:%s "
|
|
"(send to it again to clear)",
|
|
target.platform.value, target.chat_id,
|
|
)
|
|
results[target.to_string()] = {
|
|
"success": False,
|
|
"skipped": "dead_target",
|
|
"error": "target previously confirmed unreachable",
|
|
}
|
|
continue
|
|
try:
|
|
if target.platform == Platform.LOCAL:
|
|
result = self._deliver_local(content, job_id, job_name, metadata)
|
|
else:
|
|
result = await self._deliver_to_platform(target, content, metadata)
|
|
if target.chat_id and not _send_result_failed(result):
|
|
self.dead_targets.clear(target.platform.value, target.chat_id)
|
|
results[target.to_string()] = {"success": True, "result": result}
|
|
except Exception as e:
|
|
# Hard failures raise. Record a whole-chat death so future
|
|
# deliveries short-circuit.
|
|
if tracked:
|
|
dead_kind = _classify_dead_from_error_text(str(e))
|
|
if dead_kind:
|
|
self.dead_targets.mark_dead(
|
|
target.platform.value, target.chat_id,
|
|
reason=f"{dead_kind}: {str(e)[:120]}",
|
|
)
|
|
results[target.to_string()] = {"success": False, "error": str(e)}
|
|
|
|
return results
|
|
|
|
def _deliver_local(
|
|
self,
|
|
content: str,
|
|
job_id: Optional[str],
|
|
job_name: Optional[str],
|
|
metadata: Optional[Dict[str, Any]]
|
|
) -> Dict[str, Any]:
|
|
"""Save content to local files."""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
output_path = self.output_dir / (job_id or "misc") / f"{timestamp}.md"
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
lines = [
|
|
f"# {job_name}" if job_name else "# Delivery Output",
|
|
"",
|
|
f"**Timestamp:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
]
|
|
if job_id:
|
|
lines.append(f"**Job ID:** {job_id}")
|
|
for key, value in (metadata or {}).items():
|
|
lines.append(f"**{key}:** {value}")
|
|
lines += ["", "---", "", content]
|
|
|
|
output_path.write_text("\n".join(lines), encoding="utf-8")
|
|
return {"path": str(output_path), "timestamp": timestamp}
|
|
|
|
def _save_full_output(self, content: str, job_id: str) -> Path:
|
|
"""Save full cron output to disk and return the file path."""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
out_dir = get_hermes_home() / "cron" / "output"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
path = out_dir / f"{job_id}_{timestamp}.txt"
|
|
path.write_text(content, encoding="utf-8")
|
|
return path
|
|
|
|
def _filter_silence_narration_enabled(self) -> bool:
|
|
"""``HERMES_FILTER_SILENCE_NARRATION`` env overrides the
|
|
``gateway.filter_silence_narration`` config flag (default True)."""
|
|
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
|
|
if env is not None:
|
|
return env.strip().lower() in ("1", "true", "yes", "on")
|
|
return bool(getattr(self.config, "filter_silence_narration", True))
|
|
|
|
def _cap_oversized_output(self, adapter: Any, content: str, job_id: str) -> str:
|
|
"""Audit-save oversized cron output; truncate it for non-chunking adapters.
|
|
|
|
Two independent decisions: (1) above MAX_PLATFORM_OUTPUT the full output
|
|
is always written to disk as an audit trail, best-effort — a failed save
|
|
(full disk, permissions) never blocks delivery; (2) non-chunking adapters
|
|
get the content truncated with a footer pointing to the saved file, while
|
|
``splits_long_messages`` adapters receive the full payload.
|
|
"""
|
|
if len(content) <= MAX_PLATFORM_OUTPUT:
|
|
return content
|
|
saved_path: Optional[Path] = None
|
|
try:
|
|
saved_path = self._save_full_output(content, job_id)
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"Audit save failed for cron output (%d chars, job=%s): %s — "
|
|
"delivery proceeds without audit copy",
|
|
len(content), job_id, exc,
|
|
)
|
|
|
|
if getattr(adapter, "splits_long_messages", False):
|
|
if saved_path:
|
|
logger.info(
|
|
"Cron output preserved for chunking adapter (%d chars) — "
|
|
"full output saved to %s",
|
|
len(content), saved_path,
|
|
)
|
|
return content
|
|
|
|
# The footer needs a valid path: if the best-effort save failed, retry
|
|
# (a failure now is a real delivery problem and propagates).
|
|
if saved_path is None:
|
|
saved_path = self._save_full_output(content, job_id)
|
|
footer = f"\n\n... [truncated, full output saved to {saved_path}]"
|
|
visible = max(0, MAX_PLATFORM_OUTPUT - len(footer))
|
|
logger.info(
|
|
"Cron output truncated (%d chars) — full output: %s",
|
|
len(content), saved_path,
|
|
)
|
|
return content[:visible] + footer
|
|
|
|
async def _deliver_to_platform(
|
|
self,
|
|
target: DeliveryTarget,
|
|
content: str,
|
|
metadata: Optional[Dict[str, Any]]
|
|
) -> Dict[str, Any]:
|
|
"""Deliver content to a messaging platform."""
|
|
transport = resolve_delivery_transport(target.platform, self.config, self.adapters)
|
|
if transport is None:
|
|
raise ValueError(f"No adapter configured for {target.platform.value}")
|
|
adapter = transport.adapter
|
|
|
|
if not target.chat_id:
|
|
raise ValueError(f"No chat ID for {target.platform.value} delivery")
|
|
|
|
content = self._cap_oversized_output(
|
|
adapter, content, (metadata or {}).get("job_id", "unknown")
|
|
)
|
|
|
|
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
|
|
# (*(silent)*, 🔇, a bare ".") before it reaches any adapter — in
|
|
# bot-to-bot channels these mirror back and forth until a model crashes
|
|
# with "no content after all retries"; prompt rules drift across
|
|
# providers, so this single chokepoint covers every platform. Local/file
|
|
# delivery is never filtered (saved silence has no loop risk).
|
|
# Cron output is an ARTIFACT, not model chatter: a legitimately terse job
|
|
# ("...", a single 🔇) has no mirror loop, and dropping it while returning
|
|
# success is how a cron gets logged as delivered with nothing on the
|
|
# wire. Cron sends carry job_id in metadata; everything else is filtered.
|
|
is_cron_artifact = "job_id" in (metadata or {})
|
|
if (
|
|
self._filter_silence_narration_enabled()
|
|
and not is_cron_artifact
|
|
and _is_silence_narration(content)
|
|
):
|
|
logger.warning(
|
|
"Dropped silence-narration outbound to %s (chat=%s): %r",
|
|
target.platform.value,
|
|
target.chat_id,
|
|
content[:40],
|
|
)
|
|
return {
|
|
"success": True,
|
|
"filtered": "silence_narration",
|
|
"delivered": False,
|
|
}
|
|
|
|
send_metadata = dict(metadata or {})
|
|
if transport.is_relay:
|
|
home = self.config.get_home_channel(target.platform)
|
|
if home is not None and home.chat_id == target.chat_id:
|
|
if home.user_id:
|
|
send_metadata["user_id"] = home.user_id
|
|
if home.scope_id:
|
|
send_metadata["scope_id"] = home.scope_id
|
|
|
|
# Caller-supplied thread routing always wins over target.thread_id.
|
|
named_telegram_private_topic_name: Optional[str] = None
|
|
thread_id = target.thread_id
|
|
thread_unrouted = thread_id and not any(
|
|
key in send_metadata
|
|
for key in ("thread_id", "message_thread_id",
|
|
"direct_messages_topic_id", "telegram_direct_messages_topic_id")
|
|
)
|
|
if thread_unrouted:
|
|
is_telegram_private = (
|
|
target.platform == Platform.TELEGRAM
|
|
and looks_like_telegram_private_chat_id(target.chat_id)
|
|
)
|
|
if is_telegram_private and not _looks_like_int(thread_id):
|
|
# Named topic: create via createForumTopic, use message_thread_id directly.
|
|
named_telegram_private_topic_name = thread_id
|
|
send_metadata["thread_id"] = await _ensure_named_dm_topic(
|
|
adapter, target.chat_id, thread_id, refresh=False
|
|
)
|
|
send_metadata["telegram_dm_topic_created_for_send"] = True
|
|
elif is_telegram_private:
|
|
# Legacy numeric private topic ids not created by this send path
|
|
# need a reply anchor to stay visible in the requested lane.
|
|
if send_metadata.get("telegram_reply_to_message_id") is None:
|
|
raise RuntimeError(
|
|
"Telegram private DM topic delivery requires telegram_reply_to_message_id; "
|
|
"send to the bare chat or provide a reply anchor"
|
|
)
|
|
send_metadata["thread_id"] = thread_id
|
|
send_metadata["telegram_dm_topic_reply_fallback"] = True
|
|
else:
|
|
send_metadata["thread_id"] = thread_id
|
|
|
|
result = await transport.send(
|
|
target.platform, target.chat_id, content, metadata=send_metadata or None,
|
|
)
|
|
if _send_result_failed(result):
|
|
if named_telegram_private_topic_name and _is_thread_not_found_delivery_error(result):
|
|
send_metadata["thread_id"] = await _ensure_named_dm_topic(
|
|
adapter, target.chat_id, named_telegram_private_topic_name, refresh=True
|
|
)
|
|
send_metadata["telegram_dm_topic_created_for_send"] = True
|
|
result = await transport.send(
|
|
target.platform, target.chat_id, content, metadata=send_metadata or None,
|
|
)
|
|
if _send_result_failed(result):
|
|
raise RuntimeError(_send_result_error(result) or f"{target.platform.value} delivery failed")
|
|
return result
|