Merge branch 'simp/r3-10-c2' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 22:42:00 -07:00
8 changed files with 217 additions and 377 deletions
+9 -13
View File
@@ -223,9 +223,9 @@ class GatewayAuthorizationMixin:
"""Resolve the live adapter for an inbound ``SessionSource``."""
if source is None:
return None
transport_adapter = self._registered_transport_adapter(source)
if transport_adapter is not None:
return transport_adapter
owner = self._transport_owner(source)
if owner is not None:
return owner[0]
# Relay ingress keeps the underlying platform on the source, but delivery must use the one
# process-level RelayAdapter owning the connector socket; a profile-aware lookup would
# silently disable streaming/typing/tool progress.
@@ -243,8 +243,8 @@ class GatewayAuthorizationMixin:
return True, profile
return False, None
def _registered_transport_adapter(self, source: SessionSource):
"""The registered adapter that created *source*, if retained.
def _transport_owner(self, source: SessionSource):
"""``(adapter, profile)`` of the registered adapter that created *source*, if retained; else None.
``source.profile`` may differ from the adapter profile when one shared credential serves
several routed runtimes; ``build_source`` keeps the receiving adapter as provenance so replies
@@ -255,17 +255,13 @@ class GatewayAuthorizationMixin:
platform = getattr(source, "platform", None)
if adapter is None or platform is None:
return None
registered, _ = self._owning_profile(adapter, platform)
return adapter if registered else None
registered, profile = self._owning_profile(adapter, platform)
return (adapter, profile) if registered else None
def _adapter_profile_for_source(self, source: SessionSource) -> Optional[str]:
"""Resolve the transport-owning profile for adapter policy lookups."""
adapter = self._registered_transport_adapter(source)
if adapter is not None:
registered, profile = self._owning_profile(adapter, getattr(source, "platform", None))
if registered:
return profile
return getattr(source, "profile", None)
owner = self._transport_owner(source)
return owner[1] if owner is not None else getattr(source, "profile", None)
def _adapter_flag(self, platform, name: str, profile) -> bool:
"""Adapter-declared boolean, False when unknown. ``authorization_is_upstream`` (relay: a trusted
+40 -100
View File
@@ -18,15 +18,13 @@ from utils import atomic_json_write
logger = logging.getLogger(__name__)
# Paths resolve lazily: a multiplexed gateway serves several profile homes from
# one process, so an import-time constant would pin every profile to whichever
# home imported first. These globals are explicit overrides (tests patch them);
# ``None`` means "resolve from the current home".
# Paths resolve lazily: a multiplexed gateway serves several profile homes from one
# process, so an import-time constant would pin every profile to whichever home imported
# first. These globals are explicit overrides (tests patch them); None = current home.
DIRECTORY_PATH: Optional[Path] = None
# User-maintained friendly-name overlay, re-applied on every build AND load
# (hand-edits to the regenerated channel_directory.json don't survive); also
# lets a chat be pre-named before its first traffic.
# Format: {"<platform>": {"<chat_id>": "<friendly name>", ...}, ...}
# User-maintained friendly-name overlay {"<platform>": {"<chat_id>": "<friendly name>"}},
# re-applied on every build AND load (hand-edits to the regenerated
# channel_directory.json don't survive); also lets a chat be pre-named before first traffic.
CHANNEL_ALIASES_PATH: Optional[Path] = None
# Slack refresh failures recur on every timed rebuild (missing scope, revoked
@@ -47,13 +45,17 @@ def _aliases_path() -> Path:
return CHANNEL_ALIASES_PATH or get_hermes_home() / "channel_aliases.json"
def _read_json(path: Path) -> Any:
with open(path, encoding="utf-8") as f:
return json.load(f)
def _load_json_dict(path: Path) -> Dict[str, Any]:
"""Read a JSON object from *path*; {} when missing, unreadable, or not a dict."""
if not path.exists():
return {}
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
data = _read_json(path)
return data if isinstance(data, dict) else {}
except Exception:
return {}
@@ -62,8 +64,8 @@ def _load_json_dict(path: Path) -> Dict[str, Any]:
def _apply_channel_aliases(platforms: Dict[str, Any]) -> None:
"""Overlay friendly names onto directory entries by chat_id, in place.
An aliased id not yet discovered gets a placeholder entry so a
freshly-created group is addressable by name before its first message.
An aliased id not yet discovered gets a placeholder entry so a freshly-created
group is addressable by name before its first message.
"""
for plat_name, id_map in _load_json_dict(_aliases_path()).items():
if not isinstance(id_map, dict):
@@ -93,28 +95,23 @@ def _normalize_channel_query(value: str) -> str:
def _channel_target_name(platform_name: str, channel: Dict[str, Any]) -> str:
"""Human-facing target label for a channel entry."""
name = channel["name"]
if platform_name == "discord" and channel.get("guild"):
return f"#{name}"
if platform_name != "discord" and channel.get("type"):
return f"{name} ({channel['type']})"
return name
if platform_name == "discord":
return f"#{name}" if channel.get("guild") else name
return f"{name} ({channel['type']})" if channel.get("type") else name
def _session_entry_id(origin: Dict[str, Any]) -> Optional[str]:
chat_id = origin.get("chat_id")
if not chat_id:
return None
thread_id = origin.get("thread_id")
return f"{chat_id}:{thread_id}" if thread_id else str(chat_id)
return f"{chat_id}:{thread_id}" if (thread_id := origin.get("thread_id")) else str(chat_id)
def _session_entry_name(origin: Dict[str, Any]) -> str:
base_name = origin.get("chat_name") or origin.get("user_name") or str(origin.get("chat_id"))
thread_id = origin.get("thread_id")
if not thread_id:
if not (thread_id := origin.get("thread_id")):
return base_name
topic_label = origin.get("chat_topic") or f"topic {thread_id}"
return f"{base_name} / {topic_label}"
return f"{base_name} / {origin.get('chat_topic') or f'topic {thread_id}'}"
def _report_slack_failure(team_id: str, error_code: Optional[str], detail: str) -> None:
@@ -137,9 +134,7 @@ def _report_slack_failure(team_id: str, error_code: Optional[str], detail: str)
async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
"""Build the directory from connected adapters + session data and persist it."""
from gateway.config import Platform
platforms: Dict[str, List[Dict[str, str]]] = {}
for platform, adapter in adapters.items():
try:
list_channels = getattr(adapter, "list_channels", None)
@@ -154,21 +149,14 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
platforms["slack"] = await _build_slack(adapter)
except Exception as e:
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
# Platforms without channel enumeration get session-based discovery, but
# only when connected in THIS gateway process: origins for disabled or
# decommissioned platforms must not resurface as stale send targets.
# Platforms without channel enumeration get session-based discovery, but only when
# connected in THIS gateway process: origins for disabled or decommissioned
# platforms must not resurface as stale send targets.
adapter_platform_names = {getattr(p, "value", str(p)) for p in adapters}
async def _discover(plat_name: str) -> None:
if (
plat_name in _SKIP_SESSION_DISCOVERY
or plat_name in platforms
or plat_name not in adapter_platform_names
):
if plat_name in _SKIP_SESSION_DISCOVERY or plat_name in platforms or plat_name not in adapter_platform_names:
return
platforms[plat_name] = await asyncio.to_thread(_build_from_sessions, plat_name)
for plat in Platform:
await _discover(plat.value)
# Plugin platforms are dynamic enum members missing from Platform.__members__.
@@ -176,16 +164,12 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries():
await _discover(entry.name)
_apply_channel_aliases(platforms)
directory = {"updated_at": datetime.now().isoformat(), "platforms": platforms}
try:
await asyncio.to_thread(atomic_json_write, _directory_path(), directory)
except Exception as e:
logger.warning("Channel directory: failed to write: %s", e)
return directory
@@ -195,19 +179,16 @@ def _build_discord(adapter) -> List[Dict[str, str]]:
client = getattr(adapter, "_client", None)
if not client:
return channels
try:
import discord as _discord # noqa: F401 — SDK presence check
except ImportError:
return channels
for guild in client.guilds:
# Forum channels (type 15): creating a message auto-spawns a thread post.
forums = getattr(guild, "forum_channels", None) or []
for chs, ch_type in ((guild.text_channels, "channel"), (forums, "forum")):
for ch in chs:
channels.append({"id": str(ch.id), "name": ch.name, "guild": guild.name, "type": ch_type})
# DM-capable users aren't reachable via guild enumeration; they come from sessions.
channels.extend(_build_from_sessions("discord"))
return channels
@@ -233,9 +214,7 @@ def _normalize_adapter_channels(raw_channels: Any) -> List[Dict[str, Any]]:
if not channel_id or not name or channel_id in seen_ids:
continue
entry: Dict[str, Any] = {"id": channel_id, "name": name, "type": str(raw.get("type") or "dm")}
for key in ("thread_id", "guild"):
if raw.get(key):
entry[key] = str(raw.get(key))
entry.update({key: str(raw[key]) for key in ("thread_id", "guild") if raw.get(key)})
channels.append(entry)
seen_ids.add(channel_id)
return channels
@@ -257,10 +236,7 @@ async def _slack_team_channels(team_id: str, client, seen_ids: set) -> List[Dict
cursor: Optional[str] = None
for _page in range(20): # safety cap on pagination
response = await client.users_conversations(
types="public_channel,private_channel",
exclude_archived=True,
limit=200,
cursor=cursor,
types="public_channel,private_channel", exclude_archived=True, limit=200, cursor=cursor,
)
if not response.get("ok"):
error_code = response.get("error", "unknown")
@@ -289,7 +265,6 @@ async def _slack_resolve_raw_names(client, channels: List[Dict[str, Any]]) -> No
unresolved_by_base.setdefault(_slack_base_id(entry["id"]), []).append(entry)
if not unresolved_by_base:
return
async def _resolve_base(base_id: str, entries: list) -> None:
try:
resp = await client.conversations_info(channel=base_id)
@@ -311,7 +286,6 @@ async def _slack_resolve_raw_names(client, channels: List[Dict[str, Any]]) -> No
entry["type"] = resolved_type
except Exception as e:
logger.debug("Channel directory: failed to resolve %s: %s", base_id, e)
await asyncio.gather(*[_resolve_base(bid, ents) for bid, ents in unresolved_by_base.items()])
@@ -321,12 +295,10 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]:
team_clients = getattr(adapter, "_team_clients", None) or {}
if not team_clients:
return await asyncio.to_thread(_build_from_sessions, "slack")
channels: List[Dict[str, Any]] = []
seen_ids: set = set()
for team_id, client in team_clients.items():
channels.extend(await _slack_team_channels(team_id, client, seen_ids))
# Merge session-history DM/group entries, naming raw-ID entries from the
# API-discovered channels where the base conversation ID is known.
api_name_lookup = {ch["id"]: ch["name"] for ch in channels}
@@ -338,25 +310,18 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]:
entry["name"] = api_name_lookup[_slack_base_id(eid)]
channels.append(entry)
seen_ids.add(eid)
await _slack_resolve_raw_names(next(iter(team_clients.values())), channels)
return channels
def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
"""Pull known channels/contacts from gateway session origin data.
state.db is the primary source; sessions.json is the fallback for
pre-migration databases.
"""
"""Known channels/contacts from session origins: state.db first, sessions.json fallback (pre-migration)."""
return _build_from_sessions_db(platform_name) or _build_from_sessions_json(platform_name)
def _entries_from_origins(platform_name: str, source: str, origins_fn) -> List[Dict[str, Any]]:
"""Deduped directory entries for the (origin, chat_type) pairs yielded by ``origins_fn()``.
Appends incrementally so a mid-iteration failure keeps the entries read so far.
"""
"""Deduped entries for the (origin, chat_type) pairs from ``origins_fn()``; a mid-iteration
failure keeps entries read so far."""
entries: List[Dict[str, Any]] = []
try:
seen_ids = set()
@@ -393,7 +358,6 @@ def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]:
if not isinstance(origin, dict) or not origin:
origin = {"chat_id": row.get("chat_id"), "thread_id": row.get("thread_id"), "chat_name": row.get("display_name")}
yield origin, row.get("chat_type") or "dm"
return _entries_from_origins(platform_name, "state.db session read failed", _origins)
@@ -402,18 +366,14 @@ def _build_from_sessions_json(platform_name: str) -> List[Dict[str, str]]:
sessions_path = get_hermes_home() / "sessions" / "sessions.json"
if not sessions_path.exists():
return []
def _origins() -> Iterable[Tuple[Dict[str, Any], Any]]:
with open(sessions_path, encoding="utf-8") as f:
data = json.load(f)
for _key, session in data.items():
for _key, session in _read_json(sessions_path).items():
# Keys starting with "_" (e.g. the gateway's "_README") are metadata sentinels.
if str(_key).startswith("_") or not isinstance(session, dict):
continue
origin = session.get("origin") or {}
if origin.get("platform") == platform_name:
yield origin, session.get("chat_type", "dm")
return _entries_from_origins(platform_name, "failed to read sessions", _origins)
@@ -424,8 +384,7 @@ def load_directory() -> Dict[str, Any]:
directory_path = _directory_path()
if directory_path.exists():
with contextlib.suppress(Exception):
with open(directory_path, encoding="utf-8") as f:
data = json.load(f)
data = _read_json(directory_path)
# Aliases apply on read too, so new names take effect between timed rebuilds.
_apply_channel_aliases(data.setdefault("platforms", {}))
return data
@@ -436,37 +395,27 @@ def load_directory() -> Dict[str, Any]:
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
"""Channel ``type`` string (e.g. ``"channel"``, ``"forum"``) for *chat_id*, or None if unknown."""
for ch in load_directory().get("platforms", {}).get(platform_name, []):
if ch.get("id") == chat_id:
return ch.get("type")
return None
channels = load_directory().get("platforms", {}).get(platform_name, [])
return next((ch.get("type") for ch in channels if ch.get("id") == chat_id), None)
def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
"""Resolve a human-friendly channel name to a numeric ID (case-insensitive, first match wins).
Accepts e.g. Discord "bot-home" / "#bot-home" / "GuildName/bot-home", Telegram
display or group names, Slack "engineering" / "#engineering".
"""
"""Resolve a friendly channel name (e.g. "bot-home", "#bot-home", "GuildName/bot-home",
Slack "#engineering") to an ID; case-insensitive, first match wins."""
channels = load_directory().get("platforms", {}).get(platform_name, [])
if not channels:
return None
# 0. Exact ID match — case-sensitive, no normalization, so raw platform IDs
# (e.g. Slack "C0B0QV5434G") work even when _parse_target_ref's format
# guard didn't recognize them as explicit.
# 0. Exact ID match — case-sensitive, no normalization, so raw platform IDs (e.g. Slack
# "C0B0QV5434G") work even when _parse_target_ref's format guard didn't recognize them.
raw = name.strip()
for ch in channels:
if ch.get("id") == raw:
return ch["id"]
query = _normalize_channel_query(name)
# 1. Exact name match, including the display labels shown by send_message(action="list")
for ch in channels:
if query in (_normalize_channel_query(ch["name"]), _normalize_channel_query(_channel_target_name(platform_name, ch))):
return ch["id"]
# 2. Guild-qualified match for Discord ("GuildName/channel")
if "/" in query:
guild_part, ch_part = query.rsplit("/", 1)
@@ -474,30 +423,23 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]:
guild = ch.get("guild", "").strip().lower()
if guild == guild_part and _normalize_channel_query(ch["name"]) == ch_part:
return ch["id"]
# 3. Partial prefix match (only if unambiguous)
matches = [ch for ch in channels if _normalize_channel_query(ch["name"]).startswith(query)]
if len(matches) == 1:
return matches[0]["id"]
return None
return matches[0]["id"] if len(matches) == 1 else None
def format_directory_for_display(platforms: Optional[Dict[str, Any]] = None) -> str:
"""Format the channel directory as a human-readable list for the model.
``platforms`` overrides the on-disk directory (``hermes send --list`` merges in
configured-but-undiscovered platforms). An empty channel list renders a
"(no channels discovered yet)" hint: the platform is still a valid send target.
configured-but-undiscovered platforms); an empty channel list renders a "(no channels
discovered yet)" hint because the platform is still a valid send target.
"""
if platforms is None:
platforms = load_directory().get("platforms", {})
if not platforms:
return "No messaging platforms connected or no channels discovered yet."
lines = ["Available messaging targets:\n"]
for plat_name, channels in sorted(platforms.items()):
if not channels:
lines.append(f"{plat_name.title()}:")
@@ -521,8 +463,6 @@ def format_directory_for_display(platforms: Optional[Dict[str, Any]] = None) ->
lines.append(f"{plat_name.title()}:")
lines.extend(f" {plat_name}:{_channel_target_name(plat_name, ch)}" for ch in channels)
lines.append("")
lines.append('Use these as the "target" parameter when sending.')
lines.append('Bare platform name (e.g. "telegram") sends to home channel.')
return "\n".join(lines)
+9 -8
View File
@@ -105,19 +105,20 @@ def _normalize_transport_token(value: Any) -> str:
return str(value).strip().lower() or "auto"
def _coerce_float(value: Any, default: float) -> float:
def _coerce_num(cast, value: Any, default):
# OverflowError: ``int(float("inf"))`` — non-finite YAML must degrade, not abort loading.
try:
return default if value is None else float(value)
except (TypeError, ValueError):
return default if value is None else cast(value)
except (TypeError, ValueError, OverflowError):
return default
def _coerce_float(value: Any, default: float) -> float:
return _coerce_num(float, value, default)
def _coerce_int(value: Any, default: int) -> int:
# OverflowError: ``int(float("inf"))`` — non-finite YAML must degrade, not abort loading.
try:
return default if value is None else int(value)
except (TypeError, ValueError, OverflowError):
return default
return _coerce_num(int, value, default)
def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]:
+17 -24
View File
@@ -168,32 +168,26 @@ def _env_reply_mode(config: GatewayConfig, platform: Platform, env: str) -> None
config.platforms.setdefault(platform, PlatformConfig()).reply_to_mode = mode
def _enable_from_env(config: GatewayConfig, platform: Platform) -> PlatformConfig:
def _enable_from_env(
config: GatewayConfig, platform: Platform, *, pop_marker: bool = False, warn: bool = True
) -> PlatformConfig:
"""Enable *platform* on env credentials unless config.yaml explicitly disabled it.
READS (does not pop) ``_enabled_explicit``: the plugin-enable pass also needs it."""
if platform not in config.platforms:
config.platforms[platform] = PlatformConfig(enabled=True)
return config.platforms[platform]
platform_config = config.platforms[platform]
if not platform_config.enabled:
if platform_config.extra.get("_enabled_explicit", False):
_warn_explicit_disable_beats_env(platform)
else:
platform_config.enabled = True
return platform_config
def _enable_port_bound_from_env(config: GatewayConfig, platform: Platform) -> PlatformConfig:
"""Enable a port-binding platform unless config.yaml explicitly disabled it.
A multiplex secondary profile pins ``enabled: false`` to share the default profile's listener
yet inherits the process env; without this guard env presence would force-enable it and trip
MultiplexConfigError. POPs the marker (terminal branch).
MultiplexConfigError. By default the ``_enabled_explicit`` marker is READ (the plugin-enable
and relay passes still need it) and the disable is warned once; port-binding platforms POP it
(terminal branch) and stay silent.
"""
platform_config = config.platforms.setdefault(platform, PlatformConfig())
explicit = platform_config.extra.pop("_enabled_explicit", False)
if not explicit or platform_config.enabled:
extra = platform_config.extra
explicit = extra.pop("_enabled_explicit", False) if pop_marker else extra.get("_enabled_explicit", False)
if platform_config.enabled:
return platform_config
if not explicit:
platform_config.enabled = True
elif warn:
_warn_explicit_disable_beats_env(platform)
return platform_config
@@ -305,7 +299,7 @@ def _api_server(config: GatewayConfig) -> None:
key = getenv("API_SERVER_KEY")
if not _has_usable_api_server_key(key):
return
extra = _enable_port_bound_from_env(config, Platform.API_SERVER).extra
extra = _enable_from_env(config, Platform.API_SERVER, pop_marker=True, warn=False).extra
extra["key"] = key
_csv_extras(extra, (("cors_origins", getenv("API_SERVER_CORS_ORIGINS")),))
_env_extras(extra, (("port", "API_SERVER_PORT", _INT), ("host", "API_SERVER_HOST"), ("model_name", "API_SERVER_MODEL_NAME")))
@@ -313,7 +307,7 @@ def _api_server(config: GatewayConfig) -> None:
def _webhook(config: GatewayConfig) -> None:
if is_truthy_value(getenv("WEBHOOK_ENABLED")):
extra = _enable_port_bound_from_env(config, Platform.WEBHOOK).extra
extra = _enable_from_env(config, Platform.WEBHOOK, pop_marker=True, warn=False).extra
_env_extras(extra, (("port", "WEBHOOK_PORT", _INT), ("secret", "WEBHOOK_SECRET")))
@@ -326,9 +320,8 @@ def _msgraph_webhook(config: GatewayConfig) -> None:
or client_state or resources or allowed_cidrs):
return
msgraph_cfg = config.platforms.setdefault(Platform.MSGRAPH_WEBHOOK, PlatformConfig())
# Same guard as webhook, but READ (don't pop) the marker: the relay pass still consults it.
if enabled and (not msgraph_cfg.extra.get("_enabled_explicit", False) or msgraph_cfg.enabled):
msgraph_cfg.enabled = True
if enabled: # same guard as webhook, but READ (don't pop) the marker: the relay pass still consults it
_enable_from_env(config, Platform.MSGRAPH_WEBHOOK, warn=False)
_env_extras(msgraph_cfg.extra, (("port", "MSGRAPH_WEBHOOK_PORT", _INT),))
if client_state:
msgraph_cfg.extra["client_state"] = client_state
+1 -5
View File
@@ -58,10 +58,6 @@ def _quick_commands_ok(value: Any) -> bool:
return False
def _is_dict(value: Any) -> bool:
return isinstance(value, dict)
def _dm_behavior_choice(value: Any, default: str = "pair") -> str:
return _normalize_choice(value, {"pair", "ignore"}, default)
@@ -74,7 +70,7 @@ def _presence(*keys: str) -> tuple:
_TOPLEVEL_BRIDGE: tuple = (
("session_reset", "default_reset_policy", "presence", lambda v: bool(v) and isinstance(v, dict), None),
("quick_commands", "quick_commands", "none", _quick_commands_ok, None),
("stt", "stt", "presence", _is_dict, None),
("stt", "stt", "presence", lambda v: isinstance(v, dict), None),
*_presence("stt_echo_transcripts", "group_sessions_per_user", "thread_sessions_per_user"),
("multiplex_profiles", "multiplex_profiles", "gwdata", None, None),
*_presence("multiplex_profile_allowlist", "room_link_url"),
+77 -118
View File
@@ -1,12 +1,10 @@
"""DM pairing: code-based approval of new users on messaging platforms.
Unknown users receive a one-time pairing code that the bot owner approves via
the CLI, instead of maintaining static user-ID allowlists.
Security properties (OWASP + NIST SP 800-63-4): 8-char codes from a 32-char
unambiguous alphabet via ``secrets``, 1-hour expiry, max 3 pending per
platform, 1 request per user per 10 min, lockout after 5 failed approvals,
chmod 0600 data files, codes never logged. Storage: ~/.hermes/pairing/
Unknown users receive a one-time pairing code that the bot owner approves via the
CLI, instead of static user-ID allowlists. Security properties (OWASP + NIST SP
800-63-4): 8-char codes from a 32-char unambiguous alphabet via ``secrets``, 1-hour
expiry, max 3 pending per platform, 1 request per user per 10 min, lockout after 5
failed approvals, chmod 0600 data files, codes never logged. Storage: ~/.hermes/pairing/
"""
import contextlib
@@ -21,15 +19,8 @@ import time
from pathlib import Path
from typing import Optional
from gateway.whatsapp_identity import (
expand_whatsapp_aliases,
normalize_whatsapp_identifier,
)
from hermes_constants import (
get_default_hermes_root,
get_hermes_dir,
get_hermes_home,
)
from gateway.whatsapp_identity import expand_whatsapp_aliases, normalize_whatsapp_identifier
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home
from utils import atomic_replace
logger = logging.getLogger(__name__)
@@ -53,9 +44,7 @@ PAIRING_DIR = None
def _default_pairing_dir() -> Path:
if PAIRING_DIR is not None:
return PAIRING_DIR
return get_hermes_dir("platforms/pairing", "pairing")
return PAIRING_DIR if PAIRING_DIR is not None else get_hermes_dir("platforms/pairing", "pairing")
# Platform value -> allowlist env var. Approving a code also writes the user into
@@ -63,36 +52,25 @@ def _default_pairing_dir() -> Path:
# the visible source of truth. Platforms absent here (or with no allowlist
# configured) keep the pairing store as the sole grant record (authz union).
_PLATFORM_ALLOWLIST_ENV = {
"telegram": "TELEGRAM_ALLOWED_USERS",
"discord": "DISCORD_ALLOWED_USERS",
"whatsapp": "WHATSAPP_ALLOWED_USERS",
"whatsapp_cloud": "WHATSAPP_CLOUD_ALLOWED_USERS",
"slack": "SLACK_ALLOWED_USERS",
"signal": "SIGNAL_ALLOWED_USERS",
"email": "EMAIL_ALLOWED_USERS",
"sms": "SMS_ALLOWED_USERS",
"mattermost": "MATTERMOST_ALLOWED_USERS",
"matrix": "MATRIX_ALLOWED_USERS",
"dingtalk": "DINGTALK_ALLOWED_USERS",
"feishu": "FEISHU_ALLOWED_USERS",
"wecom": "WECOM_ALLOWED_USERS",
"wecom_callback": "WECOM_CALLBACK_ALLOWED_USERS",
"weixin": "WEIXIN_ALLOWED_USERS",
"bluebubbles": "BLUEBUBBLES_ALLOWED_USERS",
"qqbot": "QQ_ALLOWED_USERS",
"yuanbao": "YUANBAO_ALLOWED_USERS",
"telegram": "TELEGRAM_ALLOWED_USERS", "discord": "DISCORD_ALLOWED_USERS",
"whatsapp": "WHATSAPP_ALLOWED_USERS", "whatsapp_cloud": "WHATSAPP_CLOUD_ALLOWED_USERS",
"slack": "SLACK_ALLOWED_USERS", "signal": "SIGNAL_ALLOWED_USERS",
"email": "EMAIL_ALLOWED_USERS", "sms": "SMS_ALLOWED_USERS",
"mattermost": "MATTERMOST_ALLOWED_USERS", "matrix": "MATRIX_ALLOWED_USERS",
"dingtalk": "DINGTALK_ALLOWED_USERS", "feishu": "FEISHU_ALLOWED_USERS",
"wecom": "WECOM_ALLOWED_USERS", "wecom_callback": "WECOM_CALLBACK_ALLOWED_USERS",
"weixin": "WEIXIN_ALLOWED_USERS", "bluebubbles": "BLUEBUBBLES_ALLOWED_USERS",
"qqbot": "QQ_ALLOWED_USERS", "yuanbao": "YUANBAO_ALLOWED_USERS",
}
def _allowlist_env_for_platform(platform: str) -> Optional[str]:
"""Allowlist env var name for ``platform`` (plugin registry fallback), or None."""
platform = (platform or "").lower().strip()
env_var = _PLATFORM_ALLOWLIST_ENV.get(platform)
if env_var:
if env_var := _PLATFORM_ALLOWLIST_ENV.get(platform):
return env_var
with contextlib.suppress(Exception):
from gateway.platform_registry import platform_registry
return platform_registry.get(platform).allowed_users_env or None
return None
@@ -139,14 +117,13 @@ def _matching_ids(platform: str, approved: dict, user_id: str) -> list:
def _read_allowlist_env(env_var: str) -> str:
"""Read a platform allowlist env var through the profile secret scope.
Under multiplexing the process env may hold ANOTHER profile's allowlist, so a
scoped miss must return empty rather than borrow it. Unscoped callers keep the
legacy ``os.getenv`` read. Writes go through ``save_env_value``/``remove_env_value``,
which target the active profile's ``.env`` / installed scope, not ``os.environ``.
Under multiplexing the process env may hold ANOTHER profile's allowlist, so a scoped
miss must return empty rather than borrow it; unscoped callers keep the legacy
``os.getenv`` read. Writes (``save_env_value``/``remove_env_value``) target the
active profile's ``.env`` / installed scope, not ``os.environ``.
"""
with contextlib.suppress(Exception):
from agent.secret_scope import UnscopedSecretError, get_secret
try:
return (get_secret(env_var) or "").strip()
except UnscopedSecretError:
@@ -169,7 +146,6 @@ def _write_allowlist_env(env_var: str, ids: list) -> None:
"""Best-effort persist (empty list removes the key); the pairing store grant still authorizes via the union."""
with contextlib.suppress(Exception):
from hermes_cli.config import save_env_value, remove_env_value
save_env_value(env_var, ",".join(ids)) if ids else remove_env_value(env_var)
@@ -189,12 +165,10 @@ def _iter_live_gateway_adapters():
runner = None
with contextlib.suppress(Exception):
from gateway.run import _gateway_runner_ref
runner = _gateway_runner_ref()
if runner is None:
return
mappings = [getattr(runner, "adapters", None) or {}]
mappings.extend((getattr(runner, "_profile_adapters", None) or {}).values())
mappings = [getattr(runner, "adapters", None) or {}, *(getattr(runner, "_profile_adapters", None) or {}).values()]
for mapping in mappings:
for adapter in (mapping or {}).values():
if adapter is not None:
@@ -203,16 +177,13 @@ def _iter_live_gateway_adapters():
def _adapter_platform_name(adapter) -> str:
value = getattr(getattr(adapter, "platform", None), "value", None)
if value:
return str(value).strip().lower()
return str(getattr(adapter, "name", None) or "").strip().lower()
return str(value or getattr(adapter, "name", None) or "").strip().lower()
def _purge_allowlist_entries(entries, platform: str, user_id: str):
"""Drop alias-equivalent allowlist entries while preserving ``*``."""
def keep(entry) -> bool:
return str(entry).strip() == "*" or not _user_ids_match(platform, str(entry), str(user_id))
if isinstance(entries, str):
return ",".join(filter(keep, _split_allowlist(entries)))
if isinstance(entries, (set, frozenset)):
@@ -233,15 +204,11 @@ def _sync_live_adapter_allowlist_remove(platform: str, user_id: str) -> None:
continue
if hasattr(adapter, "_allow_from"):
with contextlib.suppress(Exception):
adapter._allow_from = _purge_allowlist_entries(
set(adapter._allow_from or ()), platform_name, user_id
)
adapter._allow_from = _purge_allowlist_entries(set(adapter._allow_from or ()), platform_name, user_id)
extra = getattr(getattr(adapter, "config", None), "extra", None)
if isinstance(extra, dict) and "allow_from" in extra:
with contextlib.suppress(Exception):
extra["allow_from"] = _purge_allowlist_entries(
extra.get("allow_from"), platform_name, user_id
)
extra["allow_from"] = _purge_allowlist_entries(extra.get("allow_from"), platform_name, user_id)
def _sync_allowlist_remove(platform: str, user_id: str) -> None:
@@ -249,10 +216,11 @@ def _sync_allowlist_remove(platform: str, user_id: str) -> None:
Approve mirrors a normalized phone while revoke is often given a JID/device
form, so matching uses alias rules -- exact delete would leave the sender authorized.
An unconfigured allowlist is left alone (config-only snapshots are not touched).
"""
configured = _configured_allowlist(platform)
if configured is None:
return # No allowlist configured — do not touch config-only snapshots.
return
env_var, ids = configured
remaining = _purge_allowlist_entries(ids, platform, user_id)
if len(remaining) == len(ids):
@@ -342,28 +310,31 @@ def _is_hashed_entry(entry) -> bool:
return isinstance(entry, dict) and "salt" in entry and "hash" in entry
def _entry_created_at(info):
"""Numeric ``created_at`` of a pending entry, or None for malformed/legacy entries."""
created_at = info.get("created_at") if isinstance(info, dict) else None
return created_at if isinstance(created_at, (int, float)) else None
class PairingStore:
"""Pairing codes and approved user lists.
Files per platform: ``{platform}-pending.json``, ``{platform}-approved.json``,
plus shared ``_rate_limits.json``. With ``profile="<name>"`` storage resolves from
that profile's HERMES_HOME exactly as ``hermes -p <name> pairing ...`` does, so
multiplex gateways and profile-scoped CLI approvals share one whitelist.
Files per platform: ``{platform}-pending.json``, ``{platform}-approved.json``, plus
shared ``_rate_limits.json``. With ``profile="<name>"`` storage resolves from that
profile's HERMES_HOME exactly as ``hermes -p <name> pairing ...`` does, so multiplex
gateways and profile-scoped CLI approvals share one whitelist.
"""
def __init__(self, profile: Optional[str] = None):
profile_home = None
if profile:
root = get_default_hermes_root()
profile_home = root if profile == "default" else root / "profiles" / profile
self._dir = get_hermes_dir("platforms/pairing", "pairing", home=profile_home)
else:
profile_home = None
self._dir = _default_pairing_dir()
self._dir = get_hermes_dir("platforms/pairing", "pairing", home=profile_home) if profile else _default_pairing_dir()
self._dir.mkdir(parents=True, exist_ok=True)
# Merge the alternate old/new layout so upgrades cannot split approvals.
_migrate_split_pairing_dirs(home=profile_home, active=self._dir)
# Adapters run concurrently in threads sharing one store.
self._lock = threading.RLock()
self._lock = threading.RLock() # adapters run concurrently in threads sharing one store
self._profile = profile # for diagnostics / log lines
@property
@@ -383,6 +354,9 @@ class PairingStore:
_load_json = staticmethod(_load_json_file)
_save_json = staticmethod(_save_json_file)
def _platforms(self, platform: Optional[str], suffix: str) -> list:
return [platform] if platform else self._all_platforms(suffix)
# ----- Approved users -----
def is_approved(self, platform: str, user_id: str) -> bool:
@@ -391,11 +365,11 @@ class PairingStore:
def list_approved(self, platform: str = None) -> list:
"""List approved users, optionally filtered by platform."""
results = []
for p in [platform] if platform else self._all_platforms("approved"):
for uid, info in self._load_json(self._approved_path(p)).items():
results.append({"platform": p, "user_id": uid, **info})
return results
return [
{"platform": p, "user_id": uid, **info}
for p in self._platforms(platform, "approved")
for uid, info in self._load_json(self._approved_path(p)).items()
]
def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None:
"""Add a user to the approved list. Must be called under self._lock."""
@@ -403,10 +377,7 @@ class PairingStore:
normalized_user_id = _normalize_user_id(platform, user_id)
for approved_user_id in _matching_ids(platform, approved, normalized_user_id):
del approved[approved_user_id]
approved[normalized_user_id] = {
"user_name": user_name,
"approved_at": time.time(),
}
approved[normalized_user_id] = {"user_name": user_name, "approved_at": time.time()}
self._save_json(self._approved_path(platform), approved)
# Mirror the grant into the operator's allowlist when one is configured.
_sync_allowlist_add(platform, normalized_user_id)
@@ -436,11 +407,9 @@ class PairingStore:
"""Remove a pending request and approve its user. Must hold self._lock."""
del pending[matched_key]
self._save_json(self._pending_path(platform), pending)
# A successful approval proves legitimacy, so the persisted brute-force streak
# must not carry over (isolated typos would accumulate into a spurious lockout).
self._reset_failed_attempts(platform)
result = {"user_id": matched_entry["user_id"], "user_name": matched_entry.get("user_name", "")}
self._approve_user(platform, result["user_id"], result["user_name"])
return result
@@ -448,29 +417,23 @@ class PairingStore:
def generate_code(self, platform: str, user_id: str, user_name: str = "") -> Optional[str]:
"""Generate a pairing code for a new user.
Returns None if the user is rate-limited, the platform hit
MAX_PENDING_PER_PLATFORM, or the platform is locked out. Only a salted
SHA-256 hash of the code is persisted, never the plaintext.
Returns None if the user is rate-limited, the platform hit MAX_PENDING_PER_PLATFORM,
or the platform is locked out. Only a salted SHA-256 hash of the code is persisted.
"""
with self._lock:
self._cleanup_expired(platform)
normalized_user_id = _normalize_user_id(platform, user_id)
if self._is_locked_out(platform) or self._is_rate_limited(platform, user_id):
return None
pending = self._load_json(self._pending_path(platform))
if len(pending) >= MAX_PENDING_PER_PLATFORM:
return None
code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
salt = os.urandom(16)
# Keyed by a random entry id, not the code itself.
pending[secrets.token_hex(8)] = {
"hash": self._hash_code(code, salt),
"salt": salt.hex(),
"user_id": normalized_user_id,
"user_name": user_name,
"created_at": time.time(),
"hash": self._hash_code(code, salt), "salt": salt.hex(),
"user_id": normalized_user_id, "user_name": user_name, "created_at": time.time(),
}
self._save_json(self._pending_path(platform), pending)
self._record_rate_limit(platform, user_id)
@@ -479,18 +442,16 @@ class PairingStore:
def approve_code(self, platform: str, code: str) -> Optional[dict]:
"""Approve a pairing code and add its user to the approved list.
Returns ``{user_id, user_name}``, or ``None`` if the code is invalid/expired OR
the platform is locked out (disambiguate with ``_is_locked_out``). Constant-time
Returns ``{user_id, user_name}``, or ``None`` if the code is invalid/expired OR the
platform is locked out (disambiguate with ``_is_locked_out``). Constant-time
salted-hash compare; legacy plaintext entries are ignored and pruned at TTL.
"""
with self._lock:
self._cleanup_expired(platform)
code = code.upper().strip()
# Before the lookup, or an already-issued valid code would bypass lockout.
if self._is_locked_out(platform):
return None
pending = self._load_json(self._pending_path(platform))
# Skip legacy/malformed entries so an in-place upgrade doesn't crash.
for entry_id, entry in pending.items():
@@ -502,7 +463,6 @@ class PairingStore:
continue
if secrets.compare_digest(self._hash_code(code, salt), entry["hash"]):
return self._finish_approval(platform, pending, entry_id, entry)
self._record_failed_attempt(platform)
return None
@@ -526,7 +486,6 @@ class PairingStore:
request_id = str(request_id or "").strip().lower()
if not request_id:
return None
pending = self._load_json(self._pending_path(platform))
for entry_id, entry in pending.items():
if _is_hashed_entry(entry) and secrets.compare_digest(str(entry_id).lower(), request_id):
@@ -538,20 +497,17 @@ class PairingStore:
for :meth:`approve_request`; legacy pre-hash entries report an empty id)."""
results = []
with self._lock:
for p in [platform] if platform else self._all_platforms("pending"):
for p in self._platforms(platform, "pending"):
self._cleanup_expired(p)
for entry_id, info in self._load_json(self._pending_path(p)).items():
if not isinstance(info, dict):
continue
created_at = info.get("created_at")
if not isinstance(created_at, (int, float)):
created_at = _entry_created_at(info)
if created_at is None:
continue
is_modern = isinstance(info.get("hash"), str) and isinstance(info.get("salt"), str)
results.append({
"platform": p,
"request_id": str(entry_id) if is_modern else "",
"user_id": info.get("user_id", ""),
"user_name": info.get("user_name", ""),
"user_id": info.get("user_id", ""), "user_name": info.get("user_name", ""),
"age_minutes": int((time.time() - created_at) / 60),
})
return results
@@ -560,35 +516,40 @@ class PairingStore:
"""Clear all pending requests. Returns count removed."""
with self._lock:
count = 0
for p in [platform] if platform else self._all_platforms("pending"):
for p in self._platforms(platform, "pending"):
count += len(self._load_json(self._pending_path(p)))
self._save_json(self._pending_path(p), {})
return count
# ----- Rate limiting and lockout -----
def _limits(self) -> dict:
return self._load_json(self._rate_limit_path())
def _save_limits(self, limits: dict) -> None:
self._save_json(self._rate_limit_path(), limits)
def _is_rate_limited(self, platform: str, user_id: str) -> bool:
"""Whether a user (under any alias) has requested a code too recently."""
limits = self._load_json(self._rate_limit_path())
limits = self._limits()
return any(
(time.time() - limits.get(f"{platform}:{alias}", 0)) < RATE_LIMIT_SECONDS
for alias in _user_id_aliases(platform, user_id)
)
def _record_rate_limit(self, platform: str, user_id: str) -> None:
limits = self._load_json(self._rate_limit_path())
limits = self._limits()
now = time.time()
for alias in _user_id_aliases(platform, user_id):
limits[f"{platform}:{alias}"] = now
self._save_json(self._rate_limit_path(), limits)
self._save_limits(limits)
def _is_locked_out(self, platform: str) -> bool:
limits = self._load_json(self._rate_limit_path())
return time.time() < limits.get(f"_lockout:{platform}", 0)
return time.time() < self._limits().get(f"_lockout:{platform}", 0)
def _record_failed_attempt(self, platform: str) -> None:
"""Record a failed approval attempt; triggers lockout after MAX_FAILED_ATTEMPTS."""
limits = self._load_json(self._rate_limit_path())
limits = self._limits()
fail_key = f"_failures:{platform}"
fails = limits.get(fail_key, 0) + 1
limits[fail_key] = fails
@@ -597,15 +558,15 @@ class PairingStore:
limits[fail_key] = 0
print(f"[pairing] Platform {platform} locked out for {LOCKOUT_SECONDS}s "
f"after {MAX_FAILED_ATTEMPTS} failed attempts", flush=True)
self._save_json(self._rate_limit_path(), limits)
self._save_limits(limits)
def _reset_failed_attempts(self, platform: str) -> None:
"""Clear the failed-approval counter after a success (it tracks *consecutive* failures)."""
limits = self._load_json(self._rate_limit_path())
limits = self._limits()
fail_key = f"_failures:{platform}"
if limits.get(fail_key):
limits[fail_key] = 0
self._save_json(self._rate_limit_path(), limits)
self._save_limits(limits)
# ----- Cleanup -----
@@ -616,9 +577,7 @@ class PairingStore:
now = time.time()
expired = [
entry_id for entry_id, info in pending.items()
if not isinstance(info, dict)
or not isinstance(info.get("created_at"), (int, float))
or (now - info["created_at"]) > CODE_TTL_SECONDS
if (created := _entry_created_at(info)) is None or (now - created) > CODE_TTL_SECONDS
]
if expired:
self._save_json(path, {k: v for k, v in pending.items() if k not in expired})
@@ -626,5 +585,5 @@ class PairingStore:
def _all_platforms(self, suffix: str) -> list:
"""Platforms that have a ``-<suffix>.json`` data file (``_``-prefixed files are shared state)."""
tail = f"-{suffix}.json"
platforms = [f.name.replace(tail, "") for f in self._dir.iterdir() if f.name.endswith(tail)]
platforms = (f.name.replace(tail, "") for f in self._dir.iterdir() if f.name.endswith(tail))
return [p for p in platforms if not p.startswith("_")]
+60 -105
View File
@@ -1,11 +1,10 @@
"""Platform adapter registry.
Platform adapters (built-in and plugin) self-register here so the gateway can
discover and instantiate them without hardcoded if/elif chains. Plugins
register via ``PluginContext.register_platform()``; ``GatewayRunner
._create_adapter()`` consults the registry first and falls back to the legacy
built-in path. Plugin side: ``platform_registry.register(PlatformEntry(...))``;
gateway side: ``platform_registry.create_adapter("irc", platform_config)``.
Adapters (built-in and plugin) self-register here so the gateway can discover and
instantiate them without hardcoded if/elif chains. Plugins register via
``PluginContext.register_platform()``; ``GatewayRunner._create_adapter()`` consults the
registry first, then the legacy built-in path. Plugin side: ``platform_registry
.register(PlatformEntry(...))``; gateway side: ``create_adapter("irc", platform_config)``.
"""
import logging
@@ -26,7 +25,6 @@ def _plugin_scope_from_callable(callback: Callable) -> Optional[str]:
"""Infer a plugin profile from code registered outside PluginContext."""
try:
from tools.registry import registry as tool_registry
return tool_registry.plugin_scope_for_callable(callback)
except (ImportError, AttributeError):
return None
@@ -37,9 +35,7 @@ def _caller_plugin_scope() -> Optional[str]:
module_name = sys._getframe(2).f_globals.get("__name__", "") or ""
except Exception:
return None
return _plugin_scope_from_callable(
type("_Caller", (), {"__module__": module_name})
)
return _plugin_scope_from_callable(type("_Caller", (), {"__module__": module_name}))
@dataclass
@@ -49,14 +45,13 @@ class PlatformEntry:
name: str # config.yaml identifier (e.g. "irc")
label: str # human-readable
adapter_factory: Callable[[Any], Any] # PlatformConfig -> adapter (factory allows custom init)
# PASSIVE dependency probe (deps importable RIGHT NOW). Must be side-effect free: it
# PASSIVE dependency probe (deps importable RIGHT NOW); must be side-effect free since it
# runs from status displays and the config enablement pass, which may never pip-install.
check_fn: Callable[[], bool]
validate_config: Optional[Callable[[Any], bool]] = None # None = let connect() fail descriptively
# ACTIVE installer, run by ``create_adapter()`` only when ``check_fn`` is False (the
# platform is enabled+configured and about to connect). None = a False check_fn is a
# hard block. The split exists because one field either installed from every status
# display or never at all.
# ACTIVE installer, run by ``create_adapter()`` only when ``check_fn`` is False (platform
# enabled+configured, about to connect); None = a False check_fn is a hard block. Split
# from check_fn because one field either installed from every status display or never.
ensure_deps_fn: Optional[Callable[[], bool]] = None
# Connected/configured for this PlatformConfig (``get_connected_platforms``, setup UI);
# None falls back to ``validate_config`` or ``check_fn``.
@@ -73,13 +68,12 @@ class PlatformEntry:
emoji: str = "🔌" # CLI/gateway display
allow_update_command: bool = True # /update may be issued from this platform
platform_hint: str = "" # injected into the system prompt; empty = none
# ``() -> Optional[dict]`` of ``extra`` fields to seed when auto-enabled from env. Runs
# in ``_apply_env_overrides`` BEFORE adapter construction so ``gateway status`` sees it.
# ``() -> Optional[dict]`` of ``extra`` fields to seed when auto-enabled from env; runs in
# ``_apply_env_overrides`` BEFORE adapter construction so ``gateway status`` sees it.
env_enablement_fn: Optional[Callable[[], Optional[dict]]] = None
# YAML->env bridge ``(yaml_cfg, platform_cfg) -> Optional[dict]`` merged into ``extra``;
# runs after the shared-key loop, before ``_apply_env_overrides``. May set ``os.environ``
# (guard with ``not os.getenv(...)`` to keep env > YAML). Contract:
# website/docs/developer-guide/adding-platform-adapters.md.
# YAML->env bridge ``(yaml_cfg, platform_cfg) -> Optional[dict]`` merged into ``extra``; runs
# after the shared-key loop, before ``_apply_env_overrides``. May set ``os.environ`` (guard
# with ``not os.getenv(...)`` to keep env > YAML). Contract: docs/developer-guide/adding-platform-adapters.md.
apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None
cron_deliver_env_var: str = "" # home-channel env var read for cron ``deliver=<name>``
# ``(target_ref) -> Optional[(chat_id, thread_id)]`` run before channel-directory
@@ -88,30 +82,26 @@ class PlatformEntry:
# Post-resolution validation: True accept, False reject, non-empty str = reject + diagnostic.
validate_target_ref_fn: Optional[Callable[[str], bool | str]] = None
# Whole-request delivery ``(args, normalized_chat_id, platform_name, pconfig)``, sync or
# async. Prefer standalone_sender_fn when the standard send contract suffices.
# async; prefer standalone_sender_fn when the standard send contract suffices.
send_message_handler: Optional[Callable[[dict, str, str, Any], Any]] = None
# Out-of-process sender for cron without a co-resident gateway::
# async (pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False) -> dict
# returning ``{"success": True, "message_id": ...}`` or ``{"error": str}``.
# Out-of-process sender for cron without a co-resident gateway:
# ``async (pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False)
# -> {"success": True, "message_id": ...} | {"error": str}``.
standalone_sender_fn: Optional[Callable[..., Awaitable[dict]]] = None
class PlatformRegistry:
"""Central registry of platform adapters.
Registrations are serialized, and concurrent lazy lookups share an
in-flight event while the loader runs outside the registry lock.
"""
"""Central registry of platform adapters. Registrations are serialized; concurrent
lazy lookups share an in-flight event while the loader runs outside the registry lock."""
def __init__(self) -> None:
self._lock = threading.RLock()
# Process-global registrations (e.g. the built-in relay).
self._entries: dict[str, PlatformEntry] = {}
self._entries: dict[str, PlatformEntry] = {} # process-global (e.g. the built-in relay)
# Plugin adapters are isolated per resolved HERMES_HOME and overlay the
# process-global entries for lookups in that profile's runtime scope.
self._scoped_entries: dict[str, dict[str, PlatformEntry]] = {}
# Deferred loaders: name -> callable importing the owning plugin module (which calls
# register()). Eagerly importing ~20 SDK-heavy adapters added seconds to every
# register()); eagerly importing ~20 SDK-heavy adapters added seconds to every
# `hermes` invocation, so the import happens only when a lookup asks for it.
self._deferred: dict[str, _Loader] = {}
self._scoped_deferred: dict[str, dict[str, _Loader]] = {}
@@ -157,8 +147,8 @@ class PlatformRegistry:
# -- deferred loading ----------------------------------------------------
def register_deferred(self, name: str, loader: _Loader, *, scope: Optional[str] = None) -> None:
"""Register a lazy loader (imports the plugin module, which must call :meth:`register`).
Runs at most once, on first lookup; a concrete registration drops it."""
"""Register a lazy loader (imports the plugin module, which must call :meth:`register`);
runs at most once, on first lookup; a concrete registration drops it."""
with self._lock:
entries, deferred = self._scope_maps(scope, create=True)
self._consumed_loaders.pop((scope, name), None)
@@ -168,21 +158,14 @@ class PlatformRegistry:
def snapshot_registration(
self, name: str, *, scope: Optional[str] = None
) -> tuple[Optional[PlatformEntry], Optional[_Loader]]:
"""Concrete and deferred state for *name* without resolving it.
Lets the plugin ledger restore a deferred loader displaced by a concrete
registration without importing the displaced adapter as a side effect.
"""
"""Concrete and deferred state for *name* without resolving it, so the plugin ledger can
restore a deferred loader displaced by a concrete registration without importing it."""
with self._lock:
return self._registration_state(scope, name)
def restore_registration(
self,
name: str,
current: tuple[Optional[PlatformEntry], Optional[_Loader]],
previous: tuple[Optional[PlatformEntry], Optional[_Loader]],
*,
scope: Optional[str] = None,
self, name: str, current: tuple[Optional[PlatformEntry], Optional[_Loader]],
previous: tuple[Optional[PlatformEntry], Optional[_Loader]], *, scope: Optional[str] = None,
) -> bool:
"""Restore a registration if its full state is still *current* (CAS): a later
registration is never removed, and deferred loaders are part of the state."""
@@ -190,17 +173,12 @@ class PlatformRegistry:
entry, loader = self._registration_state(scope, name, create=True)
if entry is not current[0] or loader is not current[1]:
return False
entries, deferred = self._scope_maps(scope)
load_key = (scope, name)
previous_entry, previous_loader = previous
if previous_entry is None:
entries.pop(name, None)
else:
entries[name] = previous_entry
if previous_loader is None:
deferred.pop(name, None)
else:
deferred[name] = previous_loader
for mapping, value in zip(self._scope_maps(scope), previous):
if value is None:
mapping.pop(name, None)
else:
mapping[name] = value
if load_key in self._inflight:
self._cancelled_inflight.add(load_key)
self._consumed_loaders.pop(load_key, None)
@@ -210,8 +188,6 @@ class PlatformRegistry:
def _resolve(self, name: str, scope: Optional[str] = None) -> None:
"""Run the deferred loader for *name* if one is pending."""
loader: Optional[_Loader] = None
event: Optional[threading.Event] = None
load_key: _LoadKey
is_loader = False
with self._lock:
active_scope = scope or self.current_scope_key()
@@ -223,11 +199,10 @@ class PlatformRegistry:
if event is None and name not in entries:
loader = deferred.pop(name, None)
if event is None and loader is None and name not in entries:
load_key = global_key
event = self._inflight.get(global_key)
load_key = global_key
if event is None and loader is None and name not in entries:
loader = self._deferred.pop(name, None)
load_key = global_key
if event is None:
loader = self._deferred.pop(name, None)
if event is None and loader is not None:
event = threading.Event()
self._inflight[load_key] = event
@@ -239,15 +214,12 @@ class PlatformRegistry:
if not is_loader and self._inflight_owners.get(load_key) == threading.get_ident():
logger.warning("Deferred platform '%s' recursively requested while loading", name)
return
if not is_loader:
event.wait()
# Teardown may have restored an older deferred generation while
# cancelling the one we waited for; resolve that predecessor now
# instead of returning a one-shot false negative.
# Teardown may have restored an older deferred generation while cancelling the one
# we waited for; resolve that predecessor instead of a one-shot false negative.
self._resolve(name, active_scope)
return
try:
loader()
except Exception as e:
@@ -272,8 +244,8 @@ class PlatformRegistry:
return (scope, name) in self._cancelled_inflight
def _resolve_all(self) -> None:
"""Run every pending deferred loader (only ``all_entries``/``plugin_entries`` call
this, from paths that need every adapter; CLI chat never iterates the full set)."""
"""Run every pending deferred loader (only ``all_entries``/``plugin_entries`` call this;
CLI chat never iterates the full set)."""
active_scope = self.current_scope_key()
with self._lock:
_entries, scoped_deferred = self._scope_maps(active_scope)
@@ -284,8 +256,8 @@ class PlatformRegistry:
scoped_names.add(name)
elif inflight_scope is None:
global_names.add(name)
# Load outside the registry lock; each name has an in-flight event so
# concurrent readers wait for the same materialization.
# Load outside the registry lock; each name has an in-flight event so concurrent
# readers wait for the same materialization.
for name in (*sorted(scoped_names), *sorted(global_names)):
self._resolve(name, active_scope)
@@ -304,9 +276,7 @@ class PlatformRegistry:
deferred.pop(entry.name, None)
prev = entries.get(entry.name)
if prev is not None:
logger.info(
"Platform '%s' re-registered (was %s, now %s)", entry.name, prev.source, entry.source
)
logger.info("Platform '%s' re-registered (was %s, now %s)", entry.name, prev.source, entry.source)
entries[entry.name] = entry
logger.debug("Registered platform adapter: %s (%s)", entry.name, entry.source)
@@ -328,10 +298,8 @@ class PlatformRegistry:
"""True when a lookup of *name* must run/await a deferred loader (lock held)."""
_entries, deferred = self._scope_maps(scope)
return (
name in deferred
or (name not in self._entries and name in self._deferred)
or (scope, name) in self._inflight
or (None, name) in self._inflight
name in deferred or (name not in self._entries and name in self._deferred)
or (scope, name) in self._inflight or (None, name) in self._inflight
)
def get(self, name: str) -> Optional[PlatformEntry]:
@@ -350,9 +318,7 @@ class PlatformRegistry:
"""Return all registered platform entries."""
self._resolve_all()
with self._lock:
entries = dict(self._entries)
entries.update(self._scoped_entries.get(self.current_scope_key(), {}))
return list(entries.values())
return list({**self._entries, **self._scoped_entries.get(self.current_scope_key(), {})}.values())
def plugin_entries(self) -> list[PlatformEntry]:
"""Return only plugin-registered platform entries."""
@@ -366,43 +332,35 @@ class PlatformRegistry:
return entries.keys() | deferred.keys() | self._entries.keys() | self._deferred.keys()
def is_registered(self, name: str) -> bool:
# A deferred (not-yet-imported) platform still counts as registered so
# cheap membership checks (toolset resolution, webhook deliver-target
# checks) never trigger a heavy import.
# A deferred (not-yet-imported) platform still counts as registered so cheap membership
# checks (toolset resolution, webhook deliver-target checks) never trigger a heavy import.
with self._lock:
scope = self.current_scope_key()
entries, _deferred = self._scope_maps(scope)
return name in entries or name in self._entries or self._load_pending(scope, name)
def create_adapter(self, name: str, config: Any) -> Optional[Any]:
"""Create an adapter instance for *name*.
Returns None when no entry exists, deps are missing and cannot be
installed, ``validate_config`` fails, or the factory raises.
"""
"""Create an adapter instance for *name*; None when no entry exists, deps are missing
and cannot be installed, ``validate_config`` fails, or the factory raises."""
entry = self.get(name)
if entry is None:
return None
try:
deps_ok = bool(entry.check_fn())
except Exception as e:
logger.warning("Platform '%s' check_fn raised: %s", entry.label, e)
deps_ok = False
def _probe(fn: Callable[[], Any], failure_msg: str) -> bool:
try:
return bool(fn())
except Exception as e:
logger.warning(failure_msg, entry.label, e)
return False
deps_ok = _probe(entry.check_fn, "Platform '%s' check_fn raised: %s")
if not deps_ok and entry.ensure_deps_fn is not None:
# The ONE place the active installer runs: the platform is enabled+configured
# and about to connect, so an install is what the user wants.
logger.info("Platform '%s' dependencies missing — attempting install...", entry.label)
try:
deps_ok = bool(entry.ensure_deps_fn())
except Exception as e:
logger.warning("Platform '%s' dependency install raised: %s", entry.label, e)
deps_ok = False
deps_ok = _probe(entry.ensure_deps_fn, "Platform '%s' dependency install raised: %s")
if not deps_ok:
hint = f" ({entry.install_hint})" if entry.install_hint else ""
logger.warning("Platform '%s' requirements not met%s", entry.label, hint)
return None
if entry.validate_config is not None:
try:
if not entry.validate_config(config):
@@ -411,13 +369,10 @@ class PlatformRegistry:
except Exception as e:
logger.warning("Platform '%s' config validation error: %s", entry.label, e)
return None
try:
return entry.adapter_factory(config)
except Exception as e:
logger.error(
"Failed to create adapter for platform '%s': %s", entry.label, e, exc_info=True
)
logger.error("Failed to create adapter for platform '%s': %s", entry.label, e, exc_info=True)
return None
+4 -4
View File
@@ -146,7 +146,7 @@ def match_profile_route(
thread_id: Optional[str] = None, parent_chat_id: Optional[str] = None,
) -> Optional[ProfileRoute]:
"""Return the first (most specific) matching route, or None."""
return next(
(r for r in routes if r.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id)),
None,
)
for route in routes:
if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id):
return route
return None