fix(gateway): align api_server and sms checkers with profile scope and startup guard

This commit is contained in:
arimu1 2026-07-19 05:58:21 +07:00
parent 68dfeb4b16
commit 86f437509b
3 changed files with 1227 additions and 446 deletions

View file

@ -277,6 +277,7 @@ class Platform(Enum):
``Platform("irc")`` works without modifying this enum. Dynamic members ``Platform("irc")`` works without modifying this enum. Dynamic members
are cached in ``_value2member_map_`` for identity-stable comparisons. are cached in ``_value2member_map_`` for identity-stable comparisons.
""" """
LOCAL = "local" LOCAL = "local"
TELEGRAM = "telegram" TELEGRAM = "telegram"
DISCORD = "discord" DISCORD = "discord"
@ -301,6 +302,7 @@ class Platform(Enum):
QQBOT = "qqbot" QQBOT = "qqbot"
YUANBAO = "yuanbao" YUANBAO = "yuanbao"
RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL) RELAY = "relay" # generic relay adapter fronted by the connector (EXPERIMENTAL)
@classmethod @classmethod
def _missing_(cls, value): def _missing_(cls, value):
"""Accept unknown platform names only for known plugin adapters. """Accept unknown platform names only for known plugin adapters.
@ -334,6 +336,7 @@ class Platform(Enum):
# the enum was defined). # the enum was defined).
try: try:
from gateway.platform_registry import platform_registry from gateway.platform_registry import platform_registry
if platform_registry.is_registered(value): if platform_registry.is_registered(value):
pseudo = object.__new__(cls) pseudo = object.__new__(cls)
pseudo._value_ = value pseudo._value_ = value
@ -427,6 +430,7 @@ class HomeChannel:
store a thread/topic ID so the bare platform target routes to the exact store a thread/topic ID so the bare platform target routes to the exact
conversation where /sethome was run. conversation where /sethome was run.
""" """
platform: Platform platform: Platform
chat_id: str chat_id: str
name: str # Human-readable name for display name: str # Human-readable name for display
@ -468,11 +472,15 @@ class SessionResetPolicy:
overrides). Changed July 2026 from "both" (24h idle + daily 4am), which overrides). Changed July 2026 from "both" (24h idle + daily 4am), which
surprised users who expected their conversations to persist. surprised users who expected their conversations to persist.
""" """
mode: str = "none" # "daily", "idle", "both", or "none" mode: str = "none" # "daily", "idle", "both", or "none"
at_hour: int = 4 # Hour for daily reset (0-23, local time) at_hour: int = 4 # Hour for daily reset (0-23, local time)
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours) idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
notify: bool = True # Send a notification to the user when auto-reset occurs notify: bool = True # Send a notification to the user when auto-reset occurs
notify_exclude_platforms: tuple = ("api_server", "webhook") # Platforms that don't get reset notifications notify_exclude_platforms: tuple = (
"api_server",
"webhook",
) # Platforms that don't get reset notifications
# A background process this many hours old (or older) no longer blocks # A background process this many hours old (or older) no longer blocks
# session idle/daily reset. A forgotten preview server should not keep a # session idle/daily reset. A forgotten preview server should not keep a
# session alive forever (#29177). The process is NOT killed — only ignored # session alive forever (#29177). The process is NOT killed — only ignored
@ -505,7 +513,9 @@ class SessionResetPolicy:
at_hour=at_hour if at_hour is not None else 4, at_hour=at_hour if at_hour is not None else 4,
idle_minutes=idle_minutes if idle_minutes is not None else 1440, idle_minutes=idle_minutes if idle_minutes is not None else 1440,
notify=_coerce_bool(notify, True), notify=_coerce_bool(notify, True),
notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"), notify_exclude_platforms=tuple(exclude)
if exclude is not None
else ("api_server", "webhook"),
bg_process_max_age_hours=bg_max_age if bg_max_age is not None else 24, bg_process_max_age_hours=bg_max_age if bg_max_age is not None else 24,
) )
@ -519,6 +529,7 @@ class ChannelOverride:
Enables different channels (e.g. Discord #daily vs #dev) to use different Enables different channels (e.g. Discord #daily vs #dev) to use different
models and personas without running separate gateway instances. models and personas without running separate gateway instances.
""" """
model: Optional[str] = None model: Optional[str] = None
provider: Optional[str] = None provider: Optional[str] = None
system_prompt: Optional[str] = None system_prompt: Optional[str] = None
@ -563,6 +574,7 @@ PLATFORM_TOKEN_ENV_NAMES: dict["Platform", str] = {
@dataclass @dataclass
class PlatformConfig: class PlatformConfig:
"""Configuration for a single messaging platform.""" """Configuration for a single messaging platform."""
enabled: bool = False enabled: bool = False
token: Optional[str] = None # Bot token (Telegram, Discord) token: Optional[str] = None # Bot token (Telegram, Discord)
api_key: Optional[str] = None # API key if different from token api_key: Optional[str] = None # API key if different from token
@ -690,6 +702,7 @@ DEFAULT_STREAMING_CURSOR: str = " ▉"
@dataclass @dataclass
class StreamingConfig: class StreamingConfig:
"""Configuration for real-time token streaming to messaging platforms.""" """Configuration for real-time token streaming to messaging platforms."""
enabled: bool = False enabled: bool = False
# Transport selection: # Transport selection:
# "auto" — prefer native streaming-draft updates when the platform # "auto" — prefer native streaming-draft updates when the platform
@ -770,10 +783,12 @@ class StreamingConfig:
enabled=enabled, enabled=enabled,
transport=transport, transport=transport,
edit_interval=_coerce_float( edit_interval=_coerce_float(
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL, data.get("edit_interval"),
DEFAULT_STREAMING_EDIT_INTERVAL,
), ),
buffer_threshold=_coerce_int( buffer_threshold=_coerce_int(
data.get("buffer_threshold"), DEFAULT_STREAMING_BUFFER_THRESHOLD, data.get("buffer_threshold"),
DEFAULT_STREAMING_BUFFER_THRESHOLD,
), ),
cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR), cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR),
fresh_final_after_seconds=_coerce_float( fresh_final_after_seconds=_coerce_float(
@ -789,6 +804,20 @@ class StreamingConfig:
# platform is sufficiently configured to be considered "connected". Platforms # platform is sufficiently configured to be considered "connected". Platforms
# that rely on the generic ``token or api_key`` check (Telegram, Discord, # that rely on the generic ``token or api_key`` check (Telegram, Discord,
# Slack, Matrix, Mattermost, HomeAssistant) do not need an entry here. # Slack, Matrix, Mattermost, HomeAssistant) do not need an entry here.
def _check_api_server(cfg: PlatformConfig) -> bool:
if not cfg:
return False
key = cfg.extra.get("key")
if not key:
return False
try:
from hermes_cli.auth import has_usable_secret
return has_usable_secret(key, min_length=16)
except ImportError:
return len(str(key).strip()) >= 16
_PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = { _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] = {
Platform.WEIXIN: lambda cfg: bool( Platform.WEIXIN: lambda cfg: bool(
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token")) cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
@ -798,8 +827,8 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
), ),
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")), Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")), Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")), Platform.SMS: lambda cfg: bool(_getenv("TWILIO_ACCOUNT_SID")),
Platform.API_SERVER: lambda cfg: bool(cfg.extra.get("key")) if cfg else False, Platform.API_SERVER: _check_api_server,
Platform.WEBHOOK: lambda cfg: True, Platform.WEBHOOK: lambda cfg: True,
Platform.MSGRAPH_WEBHOOK: lambda cfg: bool( Platform.MSGRAPH_WEBHOOK: lambda cfg: bool(
str(cfg.extra.get("client_state") or "").strip() str(cfg.extra.get("client_state") or "").strip()
@ -830,6 +859,7 @@ class GatewayConfig:
Manages all platform connections, session policies, and delivery settings. Manages all platform connections, session policies, and delivery settings.
""" """
# Platform configurations # Platform configurations
platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict) platforms: Dict[Platform, PlatformConfig] = field(default_factory=dict)
@ -866,12 +896,18 @@ class GatewayConfig:
# STT settings # STT settings
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
stt_echo_transcripts: bool = True # Whether to echo raw STT transcripts back to the user stt_echo_transcripts: bool = (
True # Whether to echo raw STT transcripts back to the user
)
# Session isolation in shared chats # Session isolation in shared chats
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants thread_sessions_per_user: bool = (
max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active chat sessions False # When False (default), threads are shared across all participants
)
max_concurrent_sessions: Optional[int] = (
None # Positive int caps simultaneous active chat sessions
)
# Multi-profile multiplexing (opt-in; default off preserves one-gateway-per-profile). # Multi-profile multiplexing (opt-in; default off preserves one-gateway-per-profile).
# When True, the default profile's gateway serves inbound messages for every # When True, the default profile's gateway serves inbound messages for every
@ -924,7 +960,9 @@ class GatewayConfig:
connected.append(platform) connected.append(platform)
return sorted(connected, key=lambda p: str(p.value)) return sorted(connected, key=lambda p: str(p.value))
def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> bool: def _is_platform_connected(
self, platform: Platform, config: PlatformConfig
) -> bool:
"""Check whether a single platform is sufficiently configured.""" """Check whether a single platform is sufficiently configured."""
# Weixin requires both a token and an account_id (checked first so # Weixin requires both a token and an account_id (checked first so
# the generic token branch doesn't let it through without account_id). # the generic token branch doesn't let it through without account_id).
@ -949,8 +987,10 @@ class GatewayConfig:
# discovery in the normal path). discover_plugins() is idempotent. # discovery in the normal path). discover_plugins() is idempotent.
try: try:
from gateway.platform_registry import platform_registry from gateway.platform_registry import platform_registry
try: try:
from hermes_cli.plugins import discover_plugins from hermes_cli.plugins import discover_plugins
discover_plugins() discover_plugins()
except Exception: except Exception:
pass pass
@ -974,9 +1014,7 @@ class GatewayConfig:
return None return None
def get_reset_policy( def get_reset_policy(
self, self, platform: Optional[Platform] = None, session_type: Optional[str] = None
platform: Optional[Platform] = None,
session_type: Optional[str] = None
) -> SessionResetPolicy: ) -> SessionResetPolicy:
""" """
Get the appropriate reset policy for a session. Get the appropriate reset policy for a session.
@ -995,13 +1033,9 @@ class GatewayConfig:
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
"platforms": { "platforms": {p.value: c.to_dict() for p, c in self.platforms.items()},
p.value: c.to_dict() for p, c in self.platforms.items()
},
"default_reset_policy": self.default_reset_policy.to_dict(), "default_reset_policy": self.default_reset_policy.to_dict(),
"reset_by_type": { "reset_by_type": {k: v.to_dict() for k, v in self.reset_by_type.items()},
k: v.to_dict() for k, v in self.reset_by_type.items()
},
"reset_by_platform": { "reset_by_platform": {
p.value: v.to_dict() for p, v in self.reset_by_platform.items() p.value: v.to_dict() for p, v in self.reset_by_platform.items()
}, },
@ -1042,11 +1076,15 @@ class GatewayConfig:
pass # Skip unknown platforms pass # Skip unknown platforms
reset_by_type = {} reset_by_type = {}
for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items(): for type_name, policy_data in _coerce_dict(
data.get("reset_by_type", {})
).items():
reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data)
reset_by_platform = {} reset_by_platform = {}
for platform_name, policy_data in _coerce_dict(data.get("reset_by_platform", {})).items(): for platform_name, policy_data in _coerce_dict(
data.get("reset_by_platform", {})
).items():
try: try:
platform = Platform(platform_name) platform = Platform(platform_name)
reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data)
@ -1067,7 +1105,11 @@ class GatewayConfig:
stt_enabled = data.get("stt_enabled") stt_enabled = data.get("stt_enabled")
if stt_enabled is None: if stt_enabled is None:
stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None stt_enabled = (
data.get("stt", {}).get("enabled")
if isinstance(data.get("stt"), dict)
else None
)
stt_echo_transcripts = data.get("stt_echo_transcripts") stt_echo_transcripts = data.get("stt_echo_transcripts")
if stt_echo_transcripts is None: if stt_echo_transcripts is None:
stt_echo_transcripts = ( stt_echo_transcripts = (
@ -1079,7 +1121,9 @@ class GatewayConfig:
group_sessions_per_user = data.get("group_sessions_per_user") group_sessions_per_user = data.get("group_sessions_per_user")
thread_sessions_per_user = data.get("thread_sessions_per_user") thread_sessions_per_user = data.get("thread_sessions_per_user")
multiplex_profiles = data.get("multiplex_profiles") multiplex_profiles = data.get("multiplex_profiles")
nested_gateway = data.get("gateway") if isinstance(data.get("gateway"), dict) else {} nested_gateway = (
data.get("gateway") if isinstance(data.get("gateway"), dict) else {}
)
if "systemd_watchdog_seconds" in data: if "systemd_watchdog_seconds" in data:
systemd_watchdog_raw = data.get("systemd_watchdog_seconds") systemd_watchdog_raw = data.get("systemd_watchdog_seconds")
systemd_watchdog_key = "systemd_watchdog_seconds" systemd_watchdog_key = "systemd_watchdog_seconds"
@ -1128,6 +1172,7 @@ class GatewayConfig:
# Parse profile routes (validated by gateway.profile_routing) # Parse profile routes (validated by gateway.profile_routing)
from gateway.profile_routing import parse_profile_routes from gateway.profile_routing import parse_profile_routes
profile_routes = parse_profile_routes(data.get("profile_routes") or []) profile_routes = parse_profile_routes(data.get("profile_routes") or [])
return cls( return cls(
@ -1216,6 +1261,7 @@ def load_gateway_config() -> GatewayConfig:
# Primary source: config.yaml # Primary source: config.yaml
try: try:
import yaml import yaml
config_yaml_path = _home / "config.yaml" config_yaml_path = _home / "config.yaml"
if config_yaml_path.exists(): if config_yaml_path.exists():
with open(config_yaml_path, encoding="utf-8") as f: with open(config_yaml_path, encoding="utf-8") as f:
@ -1227,6 +1273,7 @@ def load_gateway_config() -> GatewayConfig:
# session_reset / quick_commands / stt / model would be ignored by # session_reset / quick_commands / stt / model would be ignored by
# the messaging gateway. Fail-open via the shared helper. # the messaging gateway. Fail-open via the shared helper.
from hermes_cli import managed_scope from hermes_cli import managed_scope
yaml_cfg = managed_scope.apply_managed_overlay(yaml_cfg) yaml_cfg = managed_scope.apply_managed_overlay(yaml_cfg)
# Shared nested-fallback source: settings meant to be top-level # Shared nested-fallback source: settings meant to be top-level
@ -1300,11 +1347,18 @@ def load_gateway_config() -> GatewayConfig:
gw_data["profile_routes"] = _pr gw_data["profile_routes"] = _pr
if isinstance(gateway_section, dict): if isinstance(gateway_section, dict):
if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data: if (
"multiplex_profiles" in gateway_section
and "multiplex_profiles" not in gw_data
):
# gateway.multiplex_profiles written by `hermes config set gateway.multiplex_profiles true` # gateway.multiplex_profiles written by `hermes config set gateway.multiplex_profiles true`
gw_data["multiplex_profiles"] = gateway_section["multiplex_profiles"] gw_data["multiplex_profiles"] = gateway_section[
"multiplex_profiles"
]
if "max_concurrent_sessions" in gateway_section: if "max_concurrent_sessions" in gateway_section:
gw_data["max_concurrent_sessions"] = gateway_section["max_concurrent_sessions"] gw_data["max_concurrent_sessions"] = gateway_section[
"max_concurrent_sessions"
]
if "systemd_watchdog_seconds" in gateway_section: if "systemd_watchdog_seconds" in gateway_section:
gw_data["systemd_watchdog_seconds"] = gateway_section[ gw_data["systemd_watchdog_seconds"] = gateway_section[
"systemd_watchdog_seconds" "systemd_watchdog_seconds"
@ -1348,9 +1402,11 @@ def load_gateway_config() -> GatewayConfig:
] ]
if "unauthorized_dm_behavior" in yaml_cfg: if "unauthorized_dm_behavior" in yaml_cfg:
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( gw_data["unauthorized_dm_behavior"] = (
yaml_cfg.get("unauthorized_dm_behavior"), _normalize_unauthorized_dm_behavior(
"pair", yaml_cfg.get("unauthorized_dm_behavior"),
"pair",
)
) )
elif isinstance(gateway_section, dict) and "unauthorized_dm_behavior" in gateway_section: elif isinstance(gateway_section, dict) and "unauthorized_dm_behavior" in gateway_section:
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
@ -1362,7 +1418,9 @@ def load_gateway_config() -> GatewayConfig:
# ``gateway.platforms`` are loaded the same way as top-level # ``gateway.platforms`` are loaded the same way as top-level
# ``platforms``. Merge nested first so top-level config keeps # ``platforms``. Merge nested first so top-level config keeps
# precedence, matching the existing gateway.streaming fallback. # precedence, matching the existing gateway.streaming fallback.
gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None gateway_platforms = (
gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None
)
platforms_data = gw_data.setdefault("platforms", {}) platforms_data = gw_data.setdefault("platforms", {})
if not isinstance(platforms_data, dict): if not isinstance(platforms_data, dict):
platforms_data = {} platforms_data = {}
@ -1378,7 +1436,10 @@ def load_gateway_config() -> GatewayConfig:
if not isinstance(existing, dict): if not isinstance(existing, dict):
existing = {} existing = {}
# Deep-merge extra dicts so gateway.json defaults survive # Deep-merge extra dicts so gateway.json defaults survive
merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})} merged_extra = {
**existing.get("extra", {}),
**plat_block.get("extra", {}),
}
if "enabled" in plat_block: if "enabled" in plat_block:
merged_extra["_enabled_explicit"] = True merged_extra["_enabled_explicit"] = True
merged = {**existing, **plat_block} merged = {**existing, **plat_block}
@ -1394,6 +1455,7 @@ def load_gateway_config() -> GatewayConfig:
# so plugin authors get the same shared-key bridging (#24836). # so plugin authors get the same shared-key bridging (#24836).
try: try:
from hermes_cli.plugins import discover_plugins from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent discover_plugins() # idempotent
from gateway.platform_registry import platform_registry as _pr from gateway.platform_registry import platform_registry as _pr
except Exception as e: except Exception as e:
@ -1438,9 +1500,11 @@ def load_gateway_config() -> GatewayConfig:
# Collect bridgeable keys from this platform section # Collect bridgeable keys from this platform section
bridged = {} bridged = {}
if "unauthorized_dm_behavior" in platform_cfg: if "unauthorized_dm_behavior" in platform_cfg:
bridged["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( bridged["unauthorized_dm_behavior"] = (
platform_cfg.get("unauthorized_dm_behavior"), _normalize_unauthorized_dm_behavior(
gw_data.get("unauthorized_dm_behavior", "pair"), platform_cfg.get("unauthorized_dm_behavior"),
gw_data.get("unauthorized_dm_behavior", "pair"),
)
) )
if "notice_delivery" in platform_cfg: if "notice_delivery" in platform_cfg:
bridged["notice_delivery"] = _normalize_notice_delivery( bridged["notice_delivery"] = _normalize_notice_delivery(
@ -1452,7 +1516,9 @@ def load_gateway_config() -> GatewayConfig:
if "reply_in_thread" in platform_cfg: if "reply_in_thread" in platform_cfg:
bridged["reply_in_thread"] = platform_cfg["reply_in_thread"] bridged["reply_in_thread"] = platform_cfg["reply_in_thread"]
if "cron_continuable_surface" in platform_cfg: if "cron_continuable_surface" in platform_cfg:
bridged["cron_continuable_surface"] = platform_cfg["cron_continuable_surface"] bridged["cron_continuable_surface"] = platform_cfg[
"cron_continuable_surface"
]
if "require_mention" in platform_cfg: if "require_mention" in platform_cfg:
bridged["require_mention"] = platform_cfg["require_mention"] bridged["require_mention"] = platform_cfg["require_mention"]
if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg: if plat == Platform.TELEGRAM and "allowed_chats" in platform_cfg:
@ -1462,13 +1528,22 @@ def load_gateway_config() -> GatewayConfig:
if plat == Platform.TELEGRAM and "allowed_topics" in platform_cfg: if plat == Platform.TELEGRAM and "allowed_topics" in platform_cfg:
bridged["allowed_topics"] = platform_cfg["allowed_topics"] bridged["allowed_topics"] = platform_cfg["allowed_topics"]
if "free_response_channels" in platform_cfg: if "free_response_channels" in platform_cfg:
bridged["free_response_channels"] = platform_cfg["free_response_channels"] bridged["free_response_channels"] = platform_cfg[
"free_response_channels"
]
if "mention_patterns" in platform_cfg: if "mention_patterns" in platform_cfg:
bridged["mention_patterns"] = platform_cfg["mention_patterns"] bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if "exclusive_bot_mentions" in platform_cfg: if "exclusive_bot_mentions" in platform_cfg:
bridged["exclusive_bot_mentions"] = platform_cfg["exclusive_bot_mentions"] bridged["exclusive_bot_mentions"] = platform_cfg[
if plat == Platform.TELEGRAM and "observe_unmentioned_group_messages" in platform_cfg: "exclusive_bot_mentions"
bridged["observe_unmentioned_group_messages"] = platform_cfg["observe_unmentioned_group_messages"] ]
if (
plat == Platform.TELEGRAM
and "observe_unmentioned_group_messages" in platform_cfg
):
bridged["observe_unmentioned_group_messages"] = platform_cfg[
"observe_unmentioned_group_messages"
]
if "dm_policy" in platform_cfg: if "dm_policy" in platform_cfg:
bridged["dm_policy"] = platform_cfg["dm_policy"] bridged["dm_policy"] = platform_cfg["dm_policy"]
if "allow_from" in platform_cfg: if "allow_from" in platform_cfg:
@ -1476,25 +1551,40 @@ def load_gateway_config() -> GatewayConfig:
if "allow_admin_from" in platform_cfg: if "allow_admin_from" in platform_cfg:
bridged["allow_admin_from"] = platform_cfg["allow_admin_from"] bridged["allow_admin_from"] = platform_cfg["allow_admin_from"]
if "user_allowed_commands" in platform_cfg: if "user_allowed_commands" in platform_cfg:
bridged["user_allowed_commands"] = platform_cfg["user_allowed_commands"] bridged["user_allowed_commands"] = platform_cfg[
"user_allowed_commands"
]
if "group_policy" in platform_cfg: if "group_policy" in platform_cfg:
bridged["group_policy"] = platform_cfg["group_policy"] bridged["group_policy"] = platform_cfg["group_policy"]
if "group_allow_from" in platform_cfg: if "group_allow_from" in platform_cfg:
bridged["group_allow_from"] = platform_cfg["group_allow_from"] bridged["group_allow_from"] = platform_cfg["group_allow_from"]
if "group_allow_admin_from" in platform_cfg: if "group_allow_admin_from" in platform_cfg:
bridged["group_allow_admin_from"] = platform_cfg["group_allow_admin_from"] bridged["group_allow_admin_from"] = platform_cfg[
"group_allow_admin_from"
]
if "group_user_allowed_commands" in platform_cfg: if "group_user_allowed_commands" in platform_cfg:
bridged["group_user_allowed_commands"] = platform_cfg["group_user_allowed_commands"] bridged["group_user_allowed_commands"] = platform_cfg[
if plat in {Platform.DISCORD, Platform.SLACK} and "channel_skill_bindings" in platform_cfg: "group_user_allowed_commands"
bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] ]
if (
plat in {Platform.DISCORD, Platform.SLACK}
and "channel_skill_bindings" in platform_cfg
):
bridged["channel_skill_bindings"] = platform_cfg[
"channel_skill_bindings"
]
if "channel_prompts" in platform_cfg: if "channel_prompts" in platform_cfg:
channel_prompts = platform_cfg["channel_prompts"] channel_prompts = platform_cfg["channel_prompts"]
if isinstance(channel_prompts, dict): if isinstance(channel_prompts, dict):
bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()} bridged["channel_prompts"] = {
str(k): v for k, v in channel_prompts.items()
}
else: else:
bridged["channel_prompts"] = channel_prompts bridged["channel_prompts"] = channel_prompts
if "gateway_restart_notification" in platform_cfg: if "gateway_restart_notification" in platform_cfg:
bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"] bridged["gateway_restart_notification"] = platform_cfg[
"gateway_restart_notification"
]
if "typing_indicator" in platform_cfg: if "typing_indicator" in platform_cfg:
bridged["typing_indicator"] = platform_cfg["typing_indicator"] bridged["typing_indicator"] = platform_cfg["typing_indicator"]
if "typing_status_text" in platform_cfg: if "typing_status_text" in platform_cfg:
@ -1512,9 +1602,15 @@ def load_gateway_config() -> GatewayConfig:
if isinstance(ov_data, dict) if isinstance(ov_data, dict)
} }
enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg
if not bridged and not enabled_was_explicit and not has_channel_overrides: if (
not bridged
and not enabled_was_explicit
and not has_channel_overrides
):
continue continue
plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value) plat_data, extra = _ensure_platform_extra_dict(
platforms_data, plat.value
)
if enabled_was_explicit: if enabled_was_explicit:
plat_data["enabled"] = platform_cfg["enabled"] plat_data["enabled"] = platform_cfg["enabled"]
# Mark the explicit enable/disable so the registry-driven # Mark the explicit enable/disable so the registry-driven
@ -1554,7 +1650,8 @@ def load_gateway_config() -> GatewayConfig:
except Exception as e: except Exception as e:
logger.debug( logger.debug(
"apply_yaml_config_fn for %s raised: %s", "apply_yaml_config_fn for %s raised: %s",
entry.name, e, entry.name,
e,
) )
continue continue
if not isinstance(seeded, dict) or not seeded: if not isinstance(seeded, dict) or not seeded:
@ -1585,7 +1682,9 @@ def load_gateway_config() -> GatewayConfig:
# apply_yaml_config_fn hook — which only runs when a telegram config # apply_yaml_config_fn hook — which only runs when a telegram config
# block exists — can't cover the no-telegram-block case (#3979). # block exists — can't cover the no-telegram-block case (#3979).
if not os.getenv("TELEGRAM_REQUIRE_MENTION"): if not os.getenv("TELEGRAM_REQUIRE_MENTION"):
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_tl_require_mention).lower() os.environ["TELEGRAM_REQUIRE_MENTION"] = str(
_tl_require_mention
).lower()
# Telegram settings → env vars / extra: migrated to the telegram # Telegram settings → env vars / extra: migrated to the telegram
# plugin's apply_yaml_config_fn hook # plugin's apply_yaml_config_fn hook
@ -1598,8 +1697,12 @@ def load_gateway_config() -> GatewayConfig:
# Signal settings → env vars (env vars take precedence) # Signal settings → env vars (env vars take precedence)
signal_cfg = yaml_cfg.get("signal", {}) signal_cfg = yaml_cfg.get("signal", {})
if isinstance(signal_cfg, dict): if isinstance(signal_cfg, dict):
if "require_mention" in signal_cfg and not os.getenv("SIGNAL_REQUIRE_MENTION"): if "require_mention" in signal_cfg and not os.getenv(
os.environ["SIGNAL_REQUIRE_MENTION"] = str(signal_cfg["require_mention"]).lower() "SIGNAL_REQUIRE_MENTION"
):
os.environ["SIGNAL_REQUIRE_MENTION"] = str(
signal_cfg["require_mention"]
).lower()
# DingTalk settings → env vars: migrated to the dingtalk plugin's # DingTalk settings → env vars: migrated to the dingtalk plugin's
# apply_yaml_config_fn hook (plugins/platforms/dingtalk/adapter.py). # apply_yaml_config_fn hook (plugins/platforms/dingtalk/adapter.py).
@ -1667,7 +1770,8 @@ def _validate_gateway_config(config: "GatewayConfig") -> None:
logger.warning( logger.warning(
"%s is enabled but %s is empty. " "%s is enabled but %s is empty. "
"The adapter will likely fail to connect.", "The adapter will likely fail to connect.",
platform.value, env_name, platform.value,
env_name,
) )
# Reject known-weak placeholder tokens. # Reject known-weak placeholder tokens.
@ -1692,7 +1796,9 @@ def _validate_gateway_config(config: "GatewayConfig") -> None:
"%s is enabled but %s is set to a placeholder value ('%s'). " "%s is enabled but %s is set to a placeholder value ('%s'). "
"Set a real bot token before starting the gateway. " "Set a real bot token before starting the gateway. "
"The adapter will NOT be started.", "The adapter will NOT be started.",
platform.value, env_name, token.strip()[:6] + "...", platform.value,
env_name,
token.strip()[:6] + "...",
) )
pconfig.enabled = False pconfig.enabled = False
@ -1714,7 +1820,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
# platforms — telegram, matrix — flow through here too, #41112). The # platforms — telegram, matrix — flow through here too, #41112). The
# flag is cleared once for all platforms in the final cleanup at the # flag is cleared once for all platforms in the final cleanup at the
# end of _apply_env_overrides. # end of _apply_env_overrides.
enabled_was_explicit = bool(platform_config.extra.get("_enabled_explicit", False)) enabled_was_explicit = bool(
platform_config.extra.get("_enabled_explicit", False)
)
if not platform_config.enabled and not enabled_was_explicit: if not platform_config.enabled and not enabled_was_explicit:
platform_config.enabled = True platform_config.enabled = True
return platform_config return platform_config
@ -1773,7 +1881,11 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
# WhatsApp (typically uses different auth mechanism) # WhatsApp (typically uses different auth mechanism)
whatsapp_enabled = is_truthy_value(getenv("WHATSAPP_ENABLED", "")) whatsapp_enabled = is_truthy_value(getenv("WHATSAPP_ENABLED", ""))
whatsapp_disabled_explicitly = getenv("WHATSAPP_ENABLED", "").lower() in {"false", "0", "no"} whatsapp_disabled_explicitly = getenv("WHATSAPP_ENABLED", "").lower() in {
"false",
"0",
"no",
}
if Platform.WHATSAPP in config.platforms: if Platform.WHATSAPP in config.platforms:
# YAML config exists — respect explicit disable # YAML config exists — respect explicit disable
wa_cfg = config.platforms[Platform.WHATSAPP] wa_cfg = config.platforms[Platform.WHATSAPP]
@ -1813,32 +1925,46 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.WHATSAPP_CLOUD].extra["app_id"] = wa_cloud_app_id config.platforms[Platform.WHATSAPP_CLOUD].extra["app_id"] = wa_cloud_app_id
wa_cloud_app_secret = getenv("WHATSAPP_CLOUD_APP_SECRET") wa_cloud_app_secret = getenv("WHATSAPP_CLOUD_APP_SECRET")
if wa_cloud_app_secret: if wa_cloud_app_secret:
config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = wa_cloud_app_secret config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = (
wa_cloud_app_secret
)
# Optional: WABA id (analytics, future use) # Optional: WABA id (analytics, future use)
wa_cloud_waba_id = getenv("WHATSAPP_CLOUD_WABA_ID") wa_cloud_waba_id = getenv("WHATSAPP_CLOUD_WABA_ID")
if wa_cloud_waba_id: if wa_cloud_waba_id:
config.platforms[Platform.WHATSAPP_CLOUD].extra["waba_id"] = wa_cloud_waba_id config.platforms[Platform.WHATSAPP_CLOUD].extra["waba_id"] = (
wa_cloud_waba_id
)
# Webhook verify token — Meta hub.verify_token shared secret # Webhook verify token — Meta hub.verify_token shared secret
wa_cloud_verify_token = getenv("WHATSAPP_CLOUD_VERIFY_TOKEN") wa_cloud_verify_token = getenv("WHATSAPP_CLOUD_VERIFY_TOKEN")
if wa_cloud_verify_token: if wa_cloud_verify_token:
config.platforms[Platform.WHATSAPP_CLOUD].extra["verify_token"] = wa_cloud_verify_token config.platforms[Platform.WHATSAPP_CLOUD].extra["verify_token"] = (
wa_cloud_verify_token
)
# Webhook server bind config (defaults baked into the adapter) # Webhook server bind config (defaults baked into the adapter)
wa_cloud_host = getenv("WHATSAPP_CLOUD_WEBHOOK_HOST") wa_cloud_host = getenv("WHATSAPP_CLOUD_WEBHOOK_HOST")
if wa_cloud_host: if wa_cloud_host:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = wa_cloud_host config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = (
wa_cloud_host
)
wa_cloud_port = getenv("WHATSAPP_CLOUD_WEBHOOK_PORT") wa_cloud_port = getenv("WHATSAPP_CLOUD_WEBHOOK_PORT")
if wa_cloud_port: if wa_cloud_port:
try: try:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int(wa_cloud_port) config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int(
wa_cloud_port
)
except ValueError: except ValueError:
pass pass
wa_cloud_path = getenv("WHATSAPP_CLOUD_WEBHOOK_PATH") wa_cloud_path = getenv("WHATSAPP_CLOUD_WEBHOOK_PATH")
if wa_cloud_path: if wa_cloud_path:
config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = wa_cloud_path config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = (
wa_cloud_path
)
# Graph API version override (rarely needed) # Graph API version override (rarely needed)
wa_cloud_api_version = getenv("WHATSAPP_CLOUD_API_VERSION") wa_cloud_api_version = getenv("WHATSAPP_CLOUD_API_VERSION")
if wa_cloud_api_version: if wa_cloud_api_version:
config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = wa_cloud_api_version config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = (
wa_cloud_api_version
)
whatsapp_cloud_home = getenv("WHATSAPP_CLOUD_HOME_CHANNEL") whatsapp_cloud_home = getenv("WHATSAPP_CLOUD_HOME_CHANNEL")
if whatsapp_cloud_home and Platform.WHATSAPP_CLOUD in config.platforms: if whatsapp_cloud_home and Platform.WHATSAPP_CLOUD in config.platforms:
config.platforms[Platform.WHATSAPP_CLOUD].home_channel = HomeChannel( config.platforms[Platform.WHATSAPP_CLOUD].home_channel = HomeChannel(
@ -1862,7 +1988,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
# platform the user explicitly disabled (Slack is now a plugin # platform the user explicitly disabled (Slack is now a plugin
# entry — #41112). The flag is cleared once for all platforms in # entry — #41112). The flag is cleared once for all platforms in
# the final cleanup at the end of _apply_env_overrides. # the final cleanup at the end of _apply_env_overrides.
enabled_was_explicit = bool(slack_config.extra.get("_enabled_explicit", False)) enabled_was_explicit = bool(
slack_config.extra.get("_enabled_explicit", False)
)
if not slack_config.enabled and not enabled_was_explicit: if not slack_config.enabled and not enabled_was_explicit:
# Top-level Slack settings such as channel prompts should not # Top-level Slack settings such as channel prompts should not
# turn an env-token setup into a disabled platform. Only an # turn an env-token setup into a disabled platform. Only an
@ -1923,7 +2051,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
matrix_homeserver = getenv("MATRIX_HOMESERVER", "") matrix_homeserver = getenv("MATRIX_HOMESERVER", "")
if matrix_token or getenv("MATRIX_PASSWORD"): if matrix_token or getenv("MATRIX_PASSWORD"):
if not matrix_homeserver: if not matrix_homeserver:
logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing") logger.warning(
"MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing"
)
matrix_config = _enable_from_env(Platform.MATRIX) matrix_config = _enable_from_env(Platform.MATRIX)
if matrix_token: if matrix_token:
matrix_config.token = matrix_token matrix_config.token = matrix_token
@ -1935,10 +2065,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
if matrix_password: if matrix_password:
matrix_config.extra["password"] = matrix_password matrix_config.extra["password"] = matrix_password
matrix_e2ee_mode = getenv("MATRIX_E2EE_MODE", "").strip().lower() matrix_e2ee_mode = getenv("MATRIX_E2EE_MODE", "").strip().lower()
matrix_e2ee = ( matrix_e2ee = matrix_e2ee_mode in (
matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred") "required",
or is_truthy_value(getenv("MATRIX_ENCRYPTION", "")) "require",
) "optional",
"prefer",
"preferred",
) or is_truthy_value(getenv("MATRIX_ENCRYPTION", ""))
matrix_config.extra["encryption"] = matrix_e2ee matrix_config.extra["encryption"] = matrix_e2ee
if matrix_e2ee_mode: if matrix_e2ee_mode:
matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode
@ -2020,19 +2153,27 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
if api_server_key: if api_server_key:
config.platforms[Platform.API_SERVER].extra["key"] = api_server_key config.platforms[Platform.API_SERVER].extra["key"] = api_server_key
if api_server_cors_origins: if api_server_cors_origins:
origins = [origin.strip() for origin in api_server_cors_origins.split(",") if origin.strip()] origins = [
origin.strip()
for origin in api_server_cors_origins.split(",")
if origin.strip()
]
if origins: if origins:
config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins
if api_server_port: if api_server_port:
try: try:
config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port) config.platforms[Platform.API_SERVER].extra["port"] = int(
api_server_port
)
except ValueError: except ValueError:
pass pass
if api_server_host: if api_server_host:
config.platforms[Platform.API_SERVER].extra["host"] = api_server_host config.platforms[Platform.API_SERVER].extra["host"] = api_server_host
api_server_model_name = getenv("API_SERVER_MODEL_NAME", "") api_server_model_name = getenv("API_SERVER_MODEL_NAME", "")
if api_server_model_name: if api_server_model_name:
config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name config.platforms[Platform.API_SERVER].extra["model_name"] = (
api_server_model_name
)
# Webhook platform # Webhook platform
webhook_enabled = is_truthy_value(getenv("WEBHOOK_ENABLED", "")) webhook_enabled = is_truthy_value(getenv("WEBHOOK_ENABLED", ""))
@ -2055,9 +2196,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
msgraph_webhook_port = getenv("MSGRAPH_WEBHOOK_PORT") msgraph_webhook_port = getenv("MSGRAPH_WEBHOOK_PORT")
msgraph_webhook_client_state = getenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "") msgraph_webhook_client_state = getenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "")
msgraph_webhook_resources = getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES", "") msgraph_webhook_resources = getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES", "")
msgraph_webhook_allowed_cidrs = getenv( msgraph_webhook_allowed_cidrs = getenv("MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS", "")
"MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS", ""
)
if ( if (
msgraph_webhook_enabled msgraph_webhook_enabled
or Platform.MSGRAPH_WEBHOOK in config.platforms or Platform.MSGRAPH_WEBHOOK in config.platforms
@ -2140,7 +2279,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key
feishu_verification_token = getenv("FEISHU_VERIFICATION_TOKEN", "") feishu_verification_token = getenv("FEISHU_VERIFICATION_TOKEN", "")
if feishu_verification_token: if feishu_verification_token:
config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token config.platforms[Platform.FEISHU].extra["verification_token"] = (
feishu_verification_token
)
feishu_home = getenv("FEISHU_HOME_CHANNEL") feishu_home = getenv("FEISHU_HOME_CHANNEL")
if feishu_home: if feishu_home:
config.platforms[Platform.FEISHU].home_channel = HomeChannel( config.platforms[Platform.FEISHU].home_channel = HomeChannel(
@ -2245,7 +2386,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
"webhook_host": getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), "webhook_host": getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"),
"webhook_port": getenv_int("BLUEBUBBLES_WEBHOOK_PORT", 8645), "webhook_port": getenv_int("BLUEBUBBLES_WEBHOOK_PORT", 8645),
"webhook_path": getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), "webhook_path": getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
"send_read_receipts": is_truthy_value(getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true")), "send_read_receipts": is_truthy_value(
getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true")
),
}) })
bluebubbles_require_mention = getenv("BLUEBUBBLES_REQUIRE_MENTION") bluebubbles_require_mention = getenv("BLUEBUBBLES_REQUIRE_MENTION")
if bluebubbles_require_mention is not None: if bluebubbles_require_mention is not None:
@ -2259,10 +2402,14 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
except Exception: except Exception:
parsed_patterns = [ parsed_patterns = [
part.strip() part.strip()
for part in bluebubbles_mention_patterns.replace("\n", ",").split(",") for part in bluebubbles_mention_patterns.replace("\n", ",").split(
","
)
if part.strip() if part.strip()
] ]
config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = (
parsed_patterns
)
bluebubbles_home = getenv("BLUEBUBBLES_HOME_CHANNEL") bluebubbles_home = getenv("BLUEBUBBLES_HOME_CHANNEL")
if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms: if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms:
config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel( config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel(
@ -2306,7 +2453,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.QQBOT].home_channel = HomeChannel( config.platforms[Platform.QQBOT].home_channel = HomeChannel(
platform=Platform.QQBOT, platform=Platform.QQBOT,
chat_id=qq_home, chat_id=qq_home,
name=getenv("QQBOT_HOME_CHANNEL_NAME") or getenv(qq_home_name_env, "Home"), name=getenv("QQBOT_HOME_CHANNEL_NAME")
or getenv(qq_home_name_env, "Home"),
thread_id=( thread_id=(
getenv("QQBOT_HOME_CHANNEL_THREAD_ID") getenv("QQBOT_HOME_CHANNEL_THREAD_ID")
or getenv("QQ_HOME_CHANNEL_THREAD_ID") or getenv("QQ_HOME_CHANNEL_THREAD_ID")
@ -2392,8 +2540,10 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
# counterpart. # counterpart.
try: try:
from hermes_cli.plugins import discover_plugins from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent discover_plugins() # idempotent
from gateway.platform_registry import platform_registry from gateway.platform_registry import platform_registry
for entry in platform_registry.plugin_entries(): for entry in platform_registry.plugin_entries():
try: try:
platform = Platform(entry.name) platform = Platform(entry.name)
@ -2428,9 +2578,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
try: try:
seed_for_probe = entry.env_enablement_fn() seed_for_probe = entry.env_enablement_fn()
except Exception as e: except Exception as e:
logger.debug( logger.debug("env_enablement_fn for %s raised: %s", entry.name, e)
"env_enablement_fn for %s raised: %s", entry.name, e
)
seed_for_probe = None seed_for_probe = None
# Only consult is_connected for platforms that are NOT already # Only consult is_connected for platforms that are NOT already
@ -2473,7 +2621,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
except Exception as exc: except Exception as exc:
logger.debug( logger.debug(
"is_connected for %s raised: %s — skipping enablement", "is_connected for %s raised: %s — skipping enablement",
entry.name, exc, entry.name,
exc,
) )
configured = False configured = False
if not configured: if not configured:
@ -2518,9 +2667,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
chat_id=str(home["chat_id"]), chat_id=str(home["chat_id"]),
name=str(home.get("name") or "Home"), name=str(home.get("name") or "Home"),
thread_id=( thread_id=(
str(home["thread_id"]) str(home["thread_id"]) if home.get("thread_id") else None
if home.get("thread_id")
else None
), ),
) )
except Exception as e: except Exception as e:

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,11 @@ from unittest.mock import MagicMock
import pytest import pytest
from gateway.config import Platform, _PLATFORM_CONNECTED_CHECKERS, _BUILTIN_PLATFORM_VALUES from gateway.config import (
Platform,
_PLATFORM_CONNECTED_CHECKERS,
_BUILTIN_PLATFORM_VALUES,
)
def test_all_builtins_have_checker_or_generic_token_path(): def test_all_builtins_have_checker_or_generic_token_path():
@ -21,14 +25,17 @@ def test_all_builtins_have_checker_or_generic_token_path():
a built-in just because nobody added it to the checker dict. a built-in just because nobody added it to the checker dict.
""" """
# Platforms covered by the generic token/api_key branch # Platforms covered by the generic token/api_key branch
generic_token_values = {p.value for p in { generic_token_values = {
Platform.TELEGRAM, p.value
Platform.DISCORD, for p in {
Platform.SLACK, Platform.TELEGRAM,
Platform.MATRIX, Platform.DISCORD,
Platform.MATTERMOST, Platform.SLACK,
Platform.HOMEASSISTANT, Platform.MATRIX,
}} Platform.MATTERMOST,
Platform.HOMEASSISTANT,
}
}
# Platforms with a bespoke checker # Platforms with a bespoke checker
checker_values = {p.value for p in set(_PLATFORM_CONNECTED_CHECKERS.keys())} checker_values = {p.value for p in set(_PLATFORM_CONNECTED_CHECKERS.keys())}
@ -42,6 +49,7 @@ def test_all_builtins_have_checker_or_generic_token_path():
try: try:
from hermes_cli.plugins import discover_plugins from hermes_cli.plugins import discover_plugins
from gateway.platform_registry import platform_registry from gateway.platform_registry import platform_registry
discover_plugins() discover_plugins()
for _entry in platform_registry.all_entries(): for _entry in platform_registry.all_entries():
if _entry.is_connected is not None or _entry.validate_config is not None: if _entry.is_connected is not None or _entry.validate_config is not None:
@ -66,7 +74,9 @@ def test_all_builtins_have_checker_or_generic_token_path():
) )
@pytest.mark.parametrize("platform, checker", list(_PLATFORM_CONNECTED_CHECKERS.items())) @pytest.mark.parametrize(
"platform, checker", list(_PLATFORM_CONNECTED_CHECKERS.items())
)
def test_checker_handles_minimal_config(platform, checker): def test_checker_handles_minimal_config(platform, checker):
"""Each bespoke checker must not crash on a minimal PlatformConfig.""" """Each bespoke checker must not crash on a minimal PlatformConfig."""
mock_config = MagicMock() mock_config = MagicMock()
@ -80,7 +90,9 @@ def test_checker_handles_minimal_config(platform, checker):
assert isinstance(result, bool) assert isinstance(result, bool)
@pytest.mark.parametrize("platform, checker", list(_PLATFORM_CONNECTED_CHECKERS.items())) @pytest.mark.parametrize(
"platform, checker", list(_PLATFORM_CONNECTED_CHECKERS.items())
)
def test_checker_returns_true_when_configured(platform, checker, monkeypatch): def test_checker_returns_true_when_configured(platform, checker, monkeypatch):
"""Each bespoke checker must return True when the config looks valid.""" """Each bespoke checker must return True when the config looks valid."""
mock_config = MagicMock() mock_config = MagicMock()
@ -99,7 +111,7 @@ def test_checker_returns_true_when_configured(platform, checker, monkeypatch):
monkeypatch.setenv("TWILIO_ACCOUNT_SID", "ACtest") monkeypatch.setenv("TWILIO_ACCOUNT_SID", "ACtest")
mock_config.extra = {} mock_config.extra = {}
elif platform == Platform.API_SERVER: elif platform == Platform.API_SERVER:
mock_config.extra = {"key": "sk-mykey"} mock_config.extra = {"key": "opensslrandhex32strongkey"}
elif platform in { elif platform in {
Platform.WEBHOOK, Platform.WEBHOOK,
Platform.WHATSAPP, Platform.WHATSAPP,
@ -127,4 +139,28 @@ def test_checker_returns_true_when_configured(platform, checker, monkeypatch):
pytest.skip(f"No synthetic config defined for {platform.value}") pytest.skip(f"No synthetic config defined for {platform.value}")
result = checker(mock_config) result = checker(mock_config)
assert result is True, f"{platform.value} checker should return True with valid-looking config" assert result is True, (
f"{platform.value} checker should return True with valid-looking config"
)
def test_api_server_checker_key_validity():
"""Test Platform.API_SERVER checker with missing, placeholder, short, and strong keys."""
checker = _PLATFORM_CONNECTED_CHECKERS[Platform.API_SERVER]
# Missing
cfg = MagicMock()
cfg.extra = {}
assert checker(cfg) is False
# Placeholder
cfg.extra = {"key": "dummy"}
assert checker(cfg) is False
# Too short
cfg.extra = {"key": "shortkey"}
assert checker(cfg) is False
# Strong key (>=16 chars)
cfg.extra = {"key": "opensslrandhex32strongkey"}
assert checker(cfg) is True