From ffd628c3eec7bb3c8b04170dd04f18b30208fab2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:15:54 -0700 Subject: [PATCH] refactor(gateway/config): table-drive _apply_env_overrides via env-extra/home-channel helpers; extract plugin enable pass --- gateway/config.py | 978 ++++++++++++++++------------------------------ 1 file changed, 342 insertions(+), 636 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 93bccff17b..91f8479b66 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -2038,6 +2038,187 @@ def _warn_explicit_disable_beats_env(platform: Platform) -> None: platform.value, platform.value, creds, platform.value, ) +def _csv_list(value: str) -> List[str]: + return [part.strip() for part in value.split(",") if part.strip()] + + +def _env_extras(extra: Dict[str, Any], spec) -> None: + """``extra[key] = fn(value)`` for each ``(key, env[, fn])`` whose env value is truthy.""" + for key, env, *fn in spec: + value = _getenv_str(env) + if value: + extra[key] = fn[0](value) if fn else value + + +def _env_extras_stripped(extra: Dict[str, Any], spec) -> None: + """Like :func:`_env_extras` but the env value is stripped BEFORE the truthiness check.""" + for key, env, *fn in spec: + value = _getenv_str(env).strip() + if value: + extra[key] = fn[0](value) if fn else value + + +def _env_int_extra(extra: Dict[str, Any], key: str, env: str) -> None: + """Set an int extra from env; a non-integer value is silently ignored.""" + raw = _getenv_str(env) + if raw: + try: + extra[key] = int(raw) + except ValueError: + pass + + +def _env_home_channel( + config: "GatewayConfig", + platform: Platform, + env_base: str, + *, + strip: bool = False, +) -> None: + """Set ``home_channel`` from ```` (+``_NAME``/``_THREAD_ID``) when the platform is configured.""" + chat_id = _getenv_str(env_base) + if strip: + chat_id = chat_id.strip() + if chat_id and platform in config.platforms: + config.platforms[platform].home_channel = HomeChannel( + platform=platform, + chat_id=chat_id, + name=_getenv_str(f"{env_base}_NAME", "Home"), + thread_id=_getenv_str(f"{env_base}_THREAD_ID") or None, + ) + + +def _env_reply_mode(config: "GatewayConfig", platform: Platform, env: str) -> None: + mode = _getenv_str(env, "").lower() + if mode in {"off", "first", "all"}: + config.platforms.setdefault(platform, PlatformConfig()).reply_to_mode = mode + + +def _enable_port_bound_from_env(config: "GatewayConfig", platform: Platform) -> PlatformConfig: + """Enable a port-binding platform (api_server/webhook) unless config.yaml explicitly disabled it. + + In multiplex mode a secondary profile pins ``platforms..enabled: false`` so it shares the + default profile's listener instead of binding its own port, yet still inherits the process env; + without this guard the env presence would force-enable the listener and trip MultiplexConfigError. + POPs the ``_enabled_explicit`` marker: these branches are terminal (no later registry pass). + """ + platform_config = config.platforms.setdefault(platform, PlatformConfig()) + explicit = platform_config.extra.pop("_enabled_explicit", False) + if not explicit or platform_config.enabled: + platform_config.enabled = True + return platform_config + + +def _enable_plugin_platforms_from_env(config: "GatewayConfig") -> None: + """Registry-driven enable for plugin platforms (built-ins have explicit blocks in the caller). + + A plugin platform is enabled when its credentials are configured (``is_connected``) and its deps + are present (passive ``check_fn``) or installable on demand (``ensure_deps_fn`` — run later by + ``create_adapter()``, never here: the active installer used to be wired as ``check_fn`` and this + sweep pip-installed SDKs on every ``load_gateway_config()`` call, boot-looping the desktop app). + ``is_connected`` MUST gate enablement: ``check_fn`` alone (\"is the SDK importable?\") would enable + platforms the user never configured and the gateway would retry-connect forever with no token. + """ + try: + from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent + from gateway.platform_registry import platform_registry + for entry in platform_registry.plugin_entries(): + try: + platform = Platform(entry.name) + except Exception as e: + logger.debug("unknown platform name %r: %s", entry.name, e) + continue + existing_cfg = config.platforms.get(platform) + # Never re-enable a platform the user explicitly disabled (marker set by load_gateway_config). + if ( + existing_cfg is not None + and not existing_cfg.enabled + and bool((existing_cfg.extra or {}).get("_enabled_explicit", False)) + ): + continue + # Seed candidate extras so plugins whose ``is_connected`` reads ``config.extra`` (Google Chat) + # see the same state they will after enablement. + seed_for_probe = None + if entry.env_enablement_fn is not None: + try: + seed_for_probe = entry.env_enablement_fn() + except Exception as e: + logger.debug( + "env_enablement_fn for %s raised: %s", entry.name, e + ) + seed_for_probe = None + + # Only consult is_connected for platforms not already enabled by YAML/env (keep that decision). + if existing_cfg is None or not existing_cfg.enabled: + if entry.is_connected is not None: + try: + # Probe with ``enabled=True``: we ask "would this be configured if enabled?" — + # some ``is_connected`` short-circuit on ``config.enabled`` being False. + if existing_cfg is not None: + probe_cfg = existing_cfg + if not probe_cfg.enabled: + probe_cfg = PlatformConfig( + enabled=True, + extra=dict(probe_cfg.extra or {}), + ) + else: + probe_cfg = PlatformConfig(enabled=True) + if isinstance(seed_for_probe, dict) and seed_for_probe: + # Transient view; never mutate ``existing_cfg`` for the probe. + probe_extra = dict(getattr(probe_cfg, "extra", {}) or {}) + for k, v in seed_for_probe.items(): + if k == "home_channel": + continue + probe_extra.setdefault(k, v) + probe_cfg = PlatformConfig( + enabled=True, + extra=probe_extra, + ) + configured = bool(entry.is_connected(probe_cfg)) + except Exception as exc: + logger.debug( + "is_connected for %s raised: %s — skipping enablement", + entry.name, exc, + ) + configured = False + if not configured: + logger.debug( + "Plugin platform '%s' available but not configured " + "(is_connected returned False) — skipping enable", + entry.name, + ) + continue + # Verify dependencies LAST — only for platforms already enabled or past the credential gate. + try: + deps_ok = bool(entry.check_fn()) + except Exception as e: + logger.debug("check_fn for %s raised: %s", entry.name, e) + deps_ok = False + if not deps_ok and entry.ensure_deps_fn is None: + continue + if platform not in config.platforms: + config.platforms[platform] = PlatformConfig() + config.platforms[platform].enabled = True + # Commit the env-seeded extras (reuse the probe result; don't call env_enablement_fn twice). + if isinstance(seed_for_probe, dict) and seed_for_probe: + seed = dict(seed_for_probe) + home = seed.pop("home_channel", None) + config.platforms[platform].extra.update(seed) + if isinstance(home, dict) and home.get("chat_id"): + config.platforms[platform].home_channel = HomeChannel( + platform=platform, + chat_id=str(home["chat_id"]), + name=str(home.get("name") or "Home"), + thread_id=( + str(home["thread_id"]) + if home.get("thread_id") + else None + ), + ) + except Exception as e: + logger.debug("Plugin platform enable pass failed: %s", e) + def _apply_env_overrides(config: GatewayConfig) -> None: """Apply environment variable overrides to config.""" @@ -2050,149 +2231,74 @@ def _apply_env_overrides(config: GatewayConfig) -> None: return config.platforms[platform] platform_config = config.platforms[platform] - # Read (don't pop) the explicit-enable marker: the registry-driven - # plugin-enable pass later in this function also needs it to avoid - # re-enabling a platform the user explicitly disabled (migrated plugin - # platforms — telegram, matrix — flow through here too, #41112). The - # flag is cleared once for all platforms in the final cleanup at the - # end of _apply_env_overrides. + # READ (don't pop) the explicit-enable marker: the registry-driven plugin-enable pass later + # also needs it to avoid re-enabling a platform the user explicitly disabled. The flag is + # cleared once for all platforms at the end of _apply_env_overrides. enabled_was_explicit = bool(platform_config.extra.get("_enabled_explicit", False)) if not platform_config.enabled: if enabled_was_explicit: - # Credentials are present (that is why we are here) but the - # user said no in config.yaml. Say so once (#48820). + # Credentials are present (that is why we are here) but the user said no in config.yaml. _warn_explicit_disable_beats_env(platform) else: platform_config.enabled = True return platform_config - + # Telegram telegram_token = getenv("TELEGRAM_BOT_TOKEN") if telegram_token: - telegram_config = _enable_from_env(Platform.TELEGRAM) - telegram_config.token = telegram_token - - # Reply threading mode for Telegram (off/first/all) - telegram_reply_mode = getenv("TELEGRAM_REPLY_TO_MODE", "").lower() - if telegram_reply_mode in {"off", "first", "all"}: - if Platform.TELEGRAM not in config.platforms: - config.platforms[Platform.TELEGRAM] = PlatformConfig() - config.platforms[Platform.TELEGRAM].reply_to_mode = telegram_reply_mode - + _enable_from_env(Platform.TELEGRAM).token = telegram_token + _env_reply_mode(config, Platform.TELEGRAM, "TELEGRAM_REPLY_TO_MODE") telegram_fallback_ips = getenv("TELEGRAM_FALLBACK_IPS", "") if telegram_fallback_ips: - if Platform.TELEGRAM not in config.platforms: - config.platforms[Platform.TELEGRAM] = PlatformConfig() - config.platforms[Platform.TELEGRAM].extra["fallback_ips"] = [ - ip.strip() for ip in telegram_fallback_ips.split(",") if ip.strip() - ] - - telegram_home = getenv("TELEGRAM_HOME_CHANNEL") - if telegram_home and Platform.TELEGRAM in config.platforms: - config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( - platform=Platform.TELEGRAM, - chat_id=telegram_home, - name=getenv("TELEGRAM_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("TELEGRAM_HOME_CHANNEL_THREAD_ID") or None, + config.platforms.setdefault(Platform.TELEGRAM, PlatformConfig()).extra["fallback_ips"] = ( + _csv_list(telegram_fallback_ips) ) - + _env_home_channel(config, Platform.TELEGRAM, "TELEGRAM_HOME_CHANNEL") + # Discord discord_token = getenv("DISCORD_BOT_TOKEN") if discord_token: - discord_config = _enable_from_env(Platform.DISCORD) - discord_config.token = discord_token - - discord_home = getenv("DISCORD_HOME_CHANNEL") - if discord_home and Platform.DISCORD in config.platforms: - config.platforms[Platform.DISCORD].home_channel = HomeChannel( - platform=Platform.DISCORD, - chat_id=discord_home, - name=getenv("DISCORD_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("DISCORD_HOME_CHANNEL_THREAD_ID") or None, - ) - - # Reply threading mode for Discord (off/first/all) - discord_reply_mode = getenv("DISCORD_REPLY_TO_MODE", "").lower() - if discord_reply_mode in {"off", "first", "all"}: - if Platform.DISCORD not in config.platforms: - config.platforms[Platform.DISCORD] = PlatformConfig() - config.platforms[Platform.DISCORD].reply_to_mode = discord_reply_mode - + _enable_from_env(Platform.DISCORD).token = discord_token + _env_home_channel(config, Platform.DISCORD, "DISCORD_HOME_CHANNEL") + _env_reply_mode(config, Platform.DISCORD, "DISCORD_REPLY_TO_MODE") + # WhatsApp (typically uses different auth mechanism) whatsapp_enabled = is_truthy_value(getenv("WHATSAPP_ENABLED", "")) whatsapp_disabled_explicitly = getenv("WHATSAPP_ENABLED", "").lower() in {"false", "0", "no"} if Platform.WHATSAPP in config.platforms: - # YAML config exists — respect explicit disable + # YAML config exists — respect explicit disable; otherwise keep whatever the YAML set. wa_cfg = config.platforms[Platform.WHATSAPP] if whatsapp_disabled_explicitly: wa_cfg.enabled = False elif whatsapp_enabled: wa_cfg.enabled = True - # else: keep whatever the YAML set elif whatsapp_enabled: config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True) - whatsapp_home = getenv("WHATSAPP_HOME_CHANNEL") - if whatsapp_home and Platform.WHATSAPP in config.platforms: - config.platforms[Platform.WHATSAPP].home_channel = HomeChannel( - platform=Platform.WHATSAPP, - chat_id=whatsapp_home, - name=getenv("WHATSAPP_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("WHATSAPP_HOME_CHANNEL_THREAD_ID") or None, - ) + _env_home_channel(config, Platform.WHATSAPP, "WHATSAPP_HOME_CHANNEL") - # WhatsApp Cloud API (official Business Platform via Meta). - # Distinct from the Baileys bridge: pure HTTP graph.facebook.com calls - # outbound, public webhook inbound. Both adapters can run in parallel - # against different phone numbers. + # WhatsApp Cloud API (official Business Platform via Meta). Distinct from the Baileys bridge; + # both adapters can run in parallel against different phone numbers. whatsapp_cloud_phone_id = getenv("WHATSAPP_CLOUD_PHONE_NUMBER_ID") whatsapp_cloud_token = getenv("WHATSAPP_CLOUD_ACCESS_TOKEN") if whatsapp_cloud_phone_id and whatsapp_cloud_token: - # Honors an explicit ``platforms.whatsapp_cloud.enabled: false`` (#48820). - _enable_from_env(Platform.WHATSAPP_CLOUD) - config.platforms[Platform.WHATSAPP_CLOUD].extra.update({ + extra = _enable_from_env(Platform.WHATSAPP_CLOUD).extra + extra.update({ "phone_number_id": whatsapp_cloud_phone_id, "access_token": whatsapp_cloud_token, }) - # Optional: app_id / app_secret (signature verification) - wa_cloud_app_id = getenv("WHATSAPP_CLOUD_APP_ID") - if 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") - if wa_cloud_app_secret: - config.platforms[Platform.WHATSAPP_CLOUD].extra["app_secret"] = wa_cloud_app_secret - # Optional: WABA id (analytics, future use) - wa_cloud_waba_id = getenv("WHATSAPP_CLOUD_WABA_ID") - if 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 - wa_cloud_verify_token = getenv("WHATSAPP_CLOUD_VERIFY_TOKEN") - if 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) - wa_cloud_host = getenv("WHATSAPP_CLOUD_WEBHOOK_HOST") - if wa_cloud_host: - config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_host"] = wa_cloud_host - wa_cloud_port = getenv("WHATSAPP_CLOUD_WEBHOOK_PORT") - if wa_cloud_port: - try: - config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_port"] = int(wa_cloud_port) - except ValueError: - pass - wa_cloud_path = getenv("WHATSAPP_CLOUD_WEBHOOK_PATH") - if wa_cloud_path: - config.platforms[Platform.WHATSAPP_CLOUD].extra["webhook_path"] = wa_cloud_path - # Graph API version override (rarely needed) - wa_cloud_api_version = getenv("WHATSAPP_CLOUD_API_VERSION") - if wa_cloud_api_version: - config.platforms[Platform.WHATSAPP_CLOUD].extra["api_version"] = wa_cloud_api_version - whatsapp_cloud_home = getenv("WHATSAPP_CLOUD_HOME_CHANNEL") - if whatsapp_cloud_home and Platform.WHATSAPP_CLOUD in config.platforms: - config.platforms[Platform.WHATSAPP_CLOUD].home_channel = HomeChannel( - platform=Platform.WHATSAPP_CLOUD, - chat_id=whatsapp_cloud_home, - name=getenv("WHATSAPP_CLOUD_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("WHATSAPP_CLOUD_HOME_CHANNEL_THREAD_ID") or None, - ) + _env_extras(extra, ( + ("app_id", "WHATSAPP_CLOUD_APP_ID"), + ("app_secret", "WHATSAPP_CLOUD_APP_SECRET"), + ("waba_id", "WHATSAPP_CLOUD_WABA_ID"), + ("verify_token", "WHATSAPP_CLOUD_VERIFY_TOKEN"), # Meta hub.verify_token shared secret + ("webhook_host", "WHATSAPP_CLOUD_WEBHOOK_HOST"), + )) + _env_int_extra(extra, "webhook_port", "WHATSAPP_CLOUD_WEBHOOK_PORT") + _env_extras(extra, ( + ("webhook_path", "WHATSAPP_CLOUD_WEBHOOK_PATH"), + ("api_version", "WHATSAPP_CLOUD_API_VERSION"), + )) + _env_home_channel(config, Platform.WHATSAPP_CLOUD, "WHATSAPP_CLOUD_HOME_CHANNEL") # Slack slack_token = getenv("SLACK_BOT_TOKEN") @@ -2203,22 +2309,15 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.platforms[Platform.SLACK].enabled = True else: slack_config = config.platforms[Platform.SLACK] - # Read (don't pop) the explicit-enable marker: the registry-driven - # plugin-enable pass below also needs it to avoid re-enabling a - # platform the user explicitly disabled (Slack is now a plugin - # entry — #41112). The flag is cleared once for all platforms in - # the final cleanup at the end of _apply_env_overrides. + # READ (don't pop) the explicit-enable marker; see _enable_from_env. enabled_was_explicit = bool(slack_config.extra.get("_enabled_explicit", False)) if not slack_config.enabled and not enabled_was_explicit: - # Top-level Slack settings such as channel prompts should not - # turn an env-token setup into a disabled platform. Only an - # explicit slack.enabled/platforms.slack.enabled false should. + # Top-level Slack settings such as channel prompts must not turn an env-token setup + # into a disabled platform; only an explicit enabled: false should. slack_config.enabled = True elif not slack_config.enabled: _warn_explicit_disable_beats_env(Platform.SLACK) - # If yaml config exists, respect its enabled flag (don't override - # explicit enabled: false). Token is still stored so skills that - # send Slack messages can use it without activating the gateway adapter. + # Token is stored even when yaml disables the adapter so Slack-sending skills can use it. config.platforms[Platform.SLACK].token = slack_token slack_home = getenv("SLACK_HOME_CHANNEL") if slack_home: @@ -2236,25 +2335,17 @@ def _apply_env_overrides(config: GatewayConfig) -> None: user_id=existing_home.user_id if existing_home and same_home else None, scope_id=existing_home.scope_id if existing_home and same_home else None, ) - + # Signal signal_url = getenv("SIGNAL_HTTP_URL") signal_account = getenv("SIGNAL_ACCOUNT") if signal_url and signal_account: - signal_config = _enable_from_env(Platform.SIGNAL) - signal_config.extra.update({ + _enable_from_env(Platform.SIGNAL).extra.update({ "http_url": signal_url, "account": signal_account, "ignore_stories": is_truthy_value(getenv("SIGNAL_IGNORE_STORIES", "true")), }) - signal_home = getenv("SIGNAL_HOME_CHANNEL") - if signal_home and Platform.SIGNAL in config.platforms: - config.platforms[Platform.SIGNAL].home_channel = HomeChannel( - platform=Platform.SIGNAL, - chat_id=signal_home, - name=getenv("SIGNAL_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("SIGNAL_HOME_CHANNEL_THREAD_ID") or None, - ) + _env_home_channel(config, Platform.SIGNAL, "SIGNAL_HOME_CHANNEL") # Mattermost mattermost_token = getenv("MATTERMOST_TOKEN") @@ -2265,14 +2356,7 @@ def _apply_env_overrides(config: GatewayConfig) -> None: mattermost_config = _enable_from_env(Platform.MATTERMOST) mattermost_config.token = mattermost_token mattermost_config.extra["url"] = mattermost_url - mattermost_home = getenv("MATTERMOST_HOME_CHANNEL") - if mattermost_home and Platform.MATTERMOST in config.platforms: - config.platforms[Platform.MATTERMOST].home_channel = HomeChannel( - platform=Platform.MATTERMOST, - chat_id=mattermost_home, - name=getenv("MATTERMOST_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("MATTERMOST_HOME_CHANNEL_THREAD_ID") or None, - ) + _env_home_channel(config, Platform.MATTERMOST, "MATTERMOST_HOME_CHANNEL") # Matrix matrix_token = getenv("MATRIX_ACCESS_TOKEN") @@ -2284,41 +2368,26 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if matrix_token: matrix_config.token = matrix_token matrix_config.extra["homeserver"] = matrix_homeserver - matrix_user = getenv("MATRIX_USER_ID", "") - if matrix_user: - matrix_config.extra["user_id"] = matrix_user - matrix_password = getenv("MATRIX_PASSWORD", "") - if matrix_password: - matrix_config.extra["password"] = matrix_password + _env_extras(matrix_config.extra, ( + ("user_id", "MATRIX_USER_ID"), + ("password", "MATRIX_PASSWORD"), + )) matrix_e2ee_mode = getenv("MATRIX_E2EE_MODE", "").strip().lower() - matrix_e2ee = ( + matrix_config.extra["encryption"] = ( matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred") or is_truthy_value(getenv("MATRIX_ENCRYPTION", "")) ) - matrix_config.extra["encryption"] = matrix_e2ee if matrix_e2ee_mode: matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode - matrix_device_id = getenv("MATRIX_DEVICE_ID", "") - if matrix_device_id: - matrix_config.extra["device_id"] = matrix_device_id - matrix_home = getenv("MATRIX_HOME_ROOM") - if matrix_home and Platform.MATRIX in config.platforms: - config.platforms[Platform.MATRIX].home_channel = HomeChannel( - platform=Platform.MATRIX, - chat_id=matrix_home, - name=getenv("MATRIX_HOME_ROOM_NAME", "Home"), - thread_id=getenv("MATRIX_HOME_ROOM_THREAD_ID") or None, - ) + _env_extras(matrix_config.extra, (("device_id", "MATRIX_DEVICE_ID"),)) + _env_home_channel(config, Platform.MATRIX, "MATRIX_HOME_ROOM") # Home Assistant hass_token = getenv("HASS_TOKEN") if hass_token: - # Honors an explicit ``platforms.homeassistant.enabled: false`` (#48820). - _enable_from_env(Platform.HOMEASSISTANT) - config.platforms[Platform.HOMEASSISTANT].token = hass_token - hass_url = getenv("HASS_URL") - if hass_url: - config.platforms[Platform.HOMEASSISTANT].extra["url"] = hass_url + hass_config = _enable_from_env(Platform.HOMEASSISTANT) + hass_config.token = hass_token + _env_extras(hass_config.extra, (("url", "HASS_URL"),)) # Email email_addr = getenv("EMAIL_ADDRESS") @@ -2326,118 +2395,50 @@ def _apply_env_overrides(config: GatewayConfig) -> None: email_imap = getenv("EMAIL_IMAP_HOST") email_smtp = getenv("EMAIL_SMTP_HOST") if all([email_addr, email_pwd, email_imap, email_smtp]): - # Honors an explicit ``platforms.email.enabled: false`` (#48820). - _enable_from_env(Platform.EMAIL) - config.platforms[Platform.EMAIL].extra.update({ + _enable_from_env(Platform.EMAIL).extra.update({ "address": email_addr, "imap_host": email_imap, "smtp_host": email_smtp, }) - email_home = getenv("EMAIL_HOME_ADDRESS") - if email_home and Platform.EMAIL in config.platforms: - config.platforms[Platform.EMAIL].home_channel = HomeChannel( - platform=Platform.EMAIL, - chat_id=email_home, - name=getenv("EMAIL_HOME_ADDRESS_NAME", "Home"), - thread_id=getenv("EMAIL_HOME_ADDRESS_THREAD_ID") or None, - ) + _env_home_channel(config, Platform.EMAIL, "EMAIL_HOME_ADDRESS") # SMS (Twilio) twilio_sid = getenv("TWILIO_ACCOUNT_SID") if twilio_sid: - # Honors an explicit ``platforms.sms.enabled: false`` (#48820). - _enable_from_env(Platform.SMS) - config.platforms[Platform.SMS].api_key = getenv("TWILIO_AUTH_TOKEN", "") - sms_home = getenv("SMS_HOME_CHANNEL") - if sms_home and Platform.SMS in config.platforms: - config.platforms[Platform.SMS].home_channel = HomeChannel( - platform=Platform.SMS, - chat_id=sms_home, - name=getenv("SMS_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("SMS_HOME_CHANNEL_THREAD_ID") or None, - ) + _enable_from_env(Platform.SMS).api_key = getenv("TWILIO_AUTH_TOKEN", "") + _env_home_channel(config, Platform.SMS, "SMS_HOME_CHANNEL") - # API Server + # API Server. Require a usable key: API_SERVER_ENABLED alone would load an unauthenticated + # platform whose adapter refuses to start at connect() anyway, leaving the reconnect watcher + # spinning forever. Same strength bar as the startup guard (has_usable_secret, min_length=16). api_server_key = getenv("API_SERVER_KEY", "") - api_server_cors_origins = getenv("API_SERVER_CORS_ORIGINS", "") - api_server_port = getenv("API_SERVER_PORT") - api_server_host = getenv("API_SERVER_HOST") - # Require a usable key: API_SERVER_ENABLED alone would load an - # unauthenticated platform whose adapter refuses to start at connect() - # anyway (startup guard in gateway/platforms/api_server.py), leaving the - # reconnect watcher spinning and logging errors forever. Same strength - # bar as the startup guard (has_usable_secret, min_length=16). if _has_usable_api_server_key(api_server_key): - if Platform.API_SERVER not in config.platforms: - config.platforms[Platform.API_SERVER] = PlatformConfig() - # Respect an explicit ``enabled: false`` in config.yaml (flagged by - # ``_enabled_explicit``). In multiplex mode a secondary profile's - # config.yaml pins ``platforms.api_server.enabled: false`` so it shares - # the default profile's listener instead of binding its own port. That - # profile still inherits the process-level env (including - # ``API_SERVER_KEY``); without this guard the env-var presence would - # force-enable the listener and trip the MultiplexConfigError check. - # Pop (don't read) the marker — the api_server branch is terminal (no - # later registry pass re-enables it), so this both consumes the flag and - # avoids reading it twice, matching the pop convention used elsewhere. - api_server_explicit = config.platforms[Platform.API_SERVER].extra.pop("_enabled_explicit", False) - if not api_server_explicit or config.platforms[Platform.API_SERVER].enabled: - config.platforms[Platform.API_SERVER].enabled = True + extra = _enable_port_bound_from_env(config, Platform.API_SERVER).extra if api_server_key: - config.platforms[Platform.API_SERVER].extra["key"] = api_server_key + extra["key"] = api_server_key + api_server_cors_origins = getenv("API_SERVER_CORS_ORIGINS", "") if api_server_cors_origins: - origins = [origin.strip() for origin in api_server_cors_origins.split(",") if origin.strip()] + origins = _csv_list(api_server_cors_origins) if origins: - config.platforms[Platform.API_SERVER].extra["cors_origins"] = origins - if api_server_port: - try: - config.platforms[Platform.API_SERVER].extra["port"] = int(api_server_port) - except ValueError: - pass - if api_server_host: - config.platforms[Platform.API_SERVER].extra["host"] = api_server_host - api_server_model_name = getenv("API_SERVER_MODEL_NAME", "") - if api_server_model_name: - config.platforms[Platform.API_SERVER].extra["model_name"] = api_server_model_name + extra["cors_origins"] = origins + _env_int_extra(extra, "port", "API_SERVER_PORT") + _env_extras(extra, ( + ("host", "API_SERVER_HOST"), + ("model_name", "API_SERVER_MODEL_NAME"), + )) # Webhook platform - webhook_enabled = is_truthy_value(getenv("WEBHOOK_ENABLED", "")) - webhook_port = getenv("WEBHOOK_PORT") - webhook_secret = getenv("WEBHOOK_SECRET", "") - if webhook_enabled: - if Platform.WEBHOOK not in config.platforms: - config.platforms[Platform.WEBHOOK] = PlatformConfig() - # Honor an explicit ``enabled: false`` in config.yaml (flagged by - # ``_enabled_explicit``). In multiplex mode a secondary profile's - # config.yaml pins ``platforms.webhook.enabled: false`` so it shares - # the default profile's listener instead of binding its own port. That - # profile may still carry ``WEBHOOK_ENABLED`` in its own .env (or the - # process env, single-profile); without this guard the env var would - # force-enable the listener and trip the MultiplexConfigError check. - # Pop (don't read) the marker — the webhook branch is terminal (no - # later registry pass re-enables it), matching the api_server branch - # above. - webhook_explicit = config.platforms[Platform.WEBHOOK].extra.pop( - "_enabled_explicit", False - ) - if not webhook_explicit or config.platforms[Platform.WEBHOOK].enabled: - config.platforms[Platform.WEBHOOK].enabled = True - if webhook_port: - try: - config.platforms[Platform.WEBHOOK].extra["port"] = int(webhook_port) - except ValueError: - pass - if webhook_secret: - config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret + if is_truthy_value(getenv("WEBHOOK_ENABLED", "")): + extra = _enable_port_bound_from_env(config, Platform.WEBHOOK).extra + _env_int_extra(extra, "port", "WEBHOOK_PORT") + _env_extras(extra, (("secret", "WEBHOOK_SECRET"),)) # Microsoft Graph webhook platform msgraph_webhook_enabled = is_truthy_value(getenv("MSGRAPH_WEBHOOK_ENABLED", "")) msgraph_webhook_port = getenv("MSGRAPH_WEBHOOK_PORT") msgraph_webhook_client_state = getenv("MSGRAPH_WEBHOOK_CLIENT_STATE", "") msgraph_webhook_resources = getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES", "") - msgraph_webhook_allowed_cidrs = getenv( - "MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS", "" - ) + msgraph_webhook_allowed_cidrs = getenv("MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS", "") if ( msgraph_webhook_enabled or Platform.MSGRAPH_WEBHOOK in config.platforms @@ -2446,131 +2447,75 @@ def _apply_env_overrides(config: GatewayConfig) -> None: or msgraph_webhook_resources or msgraph_webhook_allowed_cidrs ): - if Platform.MSGRAPH_WEBHOOK not in config.platforms: - config.platforms[Platform.MSGRAPH_WEBHOOK] = PlatformConfig() + msgraph_cfg = config.platforms.setdefault(Platform.MSGRAPH_WEBHOOK, PlatformConfig()) if msgraph_webhook_enabled: - # Same explicit-disable guard as the webhook branch above (#85637). - # READ (don't pop) the marker here: the relay-exclusive pass below - # still consults it, and the end-of-function scrub removes it for - # every platform. - msgraph_cfg = config.platforms[Platform.MSGRAPH_WEBHOOK] + # Same explicit-disable guard as the webhook branch, but READ (don't pop) the marker: + # the relay-exclusive pass below still consults it; the end-of-function scrub removes it. if not msgraph_cfg.extra.get("_enabled_explicit", False) or msgraph_cfg.enabled: msgraph_cfg.enabled = True - if msgraph_webhook_port: - try: - config.platforms[Platform.MSGRAPH_WEBHOOK].extra["port"] = int( - msgraph_webhook_port - ) - except ValueError: - pass + _env_int_extra(msgraph_cfg.extra, "port", "MSGRAPH_WEBHOOK_PORT") if msgraph_webhook_client_state: - config.platforms[Platform.MSGRAPH_WEBHOOK].extra["client_state"] = ( - msgraph_webhook_client_state - ) - if msgraph_webhook_resources: - resources = [ - resource.strip() - for resource in msgraph_webhook_resources.split(",") - if resource.strip() - ] - if resources: - config.platforms[Platform.MSGRAPH_WEBHOOK].extra[ - "accepted_resources" - ] = resources - if msgraph_webhook_allowed_cidrs: - cidrs = [ - cidr.strip() - for cidr in msgraph_webhook_allowed_cidrs.split(",") - if cidr.strip() - ] - if cidrs: - config.platforms[Platform.MSGRAPH_WEBHOOK].extra[ - "allowed_source_cidrs" - ] = cidrs + msgraph_cfg.extra["client_state"] = msgraph_webhook_client_state + for key, raw in ( + ("accepted_resources", msgraph_webhook_resources), + ("allowed_source_cidrs", msgraph_webhook_allowed_cidrs), + ): + if raw: + items = _csv_list(raw) + if items: + msgraph_cfg.extra[key] = items # DingTalk dingtalk_client_id = getenv("DINGTALK_CLIENT_ID") dingtalk_client_secret = getenv("DINGTALK_CLIENT_SECRET") if dingtalk_client_id and dingtalk_client_secret: - # Honors an explicit ``platforms.dingtalk.enabled: false`` (#48820). - _enable_from_env(Platform.DINGTALK) - config.platforms[Platform.DINGTALK].extra.update({ + _enable_from_env(Platform.DINGTALK).extra.update({ "client_id": dingtalk_client_id, "client_secret": dingtalk_client_secret, }) - dingtalk_home = getenv("DINGTALK_HOME_CHANNEL") - if dingtalk_home: - config.platforms[Platform.DINGTALK].home_channel = HomeChannel( - platform=Platform.DINGTALK, - chat_id=dingtalk_home, - name=getenv("DINGTALK_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("DINGTALK_HOME_CHANNEL_THREAD_ID") or None, - ) + _env_home_channel(config, Platform.DINGTALK, "DINGTALK_HOME_CHANNEL") # Feishu / Lark feishu_app_id = getenv("FEISHU_APP_ID") feishu_app_secret = getenv("FEISHU_APP_SECRET") if feishu_app_id and feishu_app_secret: - # Honors an explicit ``platforms.feishu.enabled: false`` (#48820). - _enable_from_env(Platform.FEISHU) - config.platforms[Platform.FEISHU].extra.update({ + extra = _enable_from_env(Platform.FEISHU).extra + extra.update({ "app_id": feishu_app_id, "app_secret": feishu_app_secret, "domain": getenv("FEISHU_DOMAIN", "feishu"), "connection_mode": getenv("FEISHU_CONNECTION_MODE", "websocket"), }) - feishu_encrypt_key = getenv("FEISHU_ENCRYPT_KEY", "") - if feishu_encrypt_key: - config.platforms[Platform.FEISHU].extra["encrypt_key"] = feishu_encrypt_key - feishu_verification_token = getenv("FEISHU_VERIFICATION_TOKEN", "") - if feishu_verification_token: - config.platforms[Platform.FEISHU].extra["verification_token"] = feishu_verification_token - feishu_home = getenv("FEISHU_HOME_CHANNEL") - if feishu_home: - config.platforms[Platform.FEISHU].home_channel = HomeChannel( - platform=Platform.FEISHU, - chat_id=feishu_home, - name=getenv("FEISHU_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("FEISHU_HOME_CHANNEL_THREAD_ID") or None, - ) + _env_extras(extra, ( + ("encrypt_key", "FEISHU_ENCRYPT_KEY"), + ("verification_token", "FEISHU_VERIFICATION_TOKEN"), + )) + _env_home_channel(config, Platform.FEISHU, "FEISHU_HOME_CHANNEL") # WeCom (Enterprise WeChat) wecom_bot_id = getenv("WECOM_BOT_ID") wecom_secret = getenv("WECOM_SECRET") if wecom_bot_id and wecom_secret: - # Honors an explicit ``platforms.wecom.enabled: false`` (#48820). - _enable_from_env(Platform.WECOM) - config.platforms[Platform.WECOM].extra.update({ + extra = _enable_from_env(Platform.WECOM).extra + extra.update({ "bot_id": wecom_bot_id, "secret": wecom_secret, }) - wecom_ws_url = getenv("WECOM_WEBSOCKET_URL", "") - if wecom_ws_url: - config.platforms[Platform.WECOM].extra["websocket_url"] = wecom_ws_url - wecom_home = getenv("WECOM_HOME_CHANNEL") - if wecom_home: - config.platforms[Platform.WECOM].home_channel = HomeChannel( - platform=Platform.WECOM, - chat_id=wecom_home, - name=getenv("WECOM_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("WECOM_HOME_CHANNEL_THREAD_ID") or None, - ) + _env_extras(extra, (("websocket_url", "WECOM_WEBSOCKET_URL"),)) + _env_home_channel(config, Platform.WECOM, "WECOM_HOME_CHANNEL") # WeCom callback mode (self-built apps) wecom_callback_corp_id = getenv("WECOM_CALLBACK_CORP_ID") wecom_callback_corp_secret = getenv("WECOM_CALLBACK_CORP_SECRET") if wecom_callback_corp_id and wecom_callback_corp_secret: - # Honors an explicit ``platforms.wecom_callback.enabled: false`` (#48820). - _enable_from_env(Platform.WECOM_CALLBACK) - config.platforms[Platform.WECOM_CALLBACK].extra.update({ + _enable_from_env(Platform.WECOM_CALLBACK).extra.update({ "corp_id": wecom_callback_corp_id, "corp_secret": wecom_callback_corp_secret, "agent_id": getenv("WECOM_CALLBACK_AGENT_ID", ""), "token": getenv("WECOM_CALLBACK_TOKEN", ""), "encoding_aes_key": getenv("WECOM_CALLBACK_ENCODING_AES_KEY", ""), - # No default here: an unset WECOM_CALLBACK_HOST leaves extra.host - # falsy so the adapter's dual-stack DEFAULT_HOST=None applies - # (binds IPv4 + IPv6; "0.0.0.0" was IPv4-only, NS-603). + # No default: an unset WECOM_CALLBACK_HOST leaves extra.host falsy so the adapter's + # dual-stack DEFAULT_HOST=None applies (binds IPv4 + IPv6; "0.0.0.0" was IPv4-only). "host": getenv("WECOM_CALLBACK_HOST", ""), "port": getenv_int("WECOM_CALLBACK_PORT", 8645), }) @@ -2579,50 +2524,29 @@ def _apply_env_overrides(config: GatewayConfig) -> None: weixin_token = getenv("WEIXIN_TOKEN") weixin_account_id = getenv("WEIXIN_ACCOUNT_ID") if weixin_token or weixin_account_id: - # Honors an explicit ``platforms.weixin.enabled: false`` (#48820). - _enable_from_env(Platform.WEIXIN) + weixin_config = _enable_from_env(Platform.WEIXIN) if weixin_token: - config.platforms[Platform.WEIXIN].token = weixin_token - extra = config.platforms[Platform.WEIXIN].extra + weixin_config.token = weixin_token + extra = weixin_config.extra if weixin_account_id: extra["account_id"] = weixin_account_id - weixin_base_url = getenv("WEIXIN_BASE_URL", "").strip() - if weixin_base_url: - extra["base_url"] = weixin_base_url.rstrip("/") - weixin_cdn_base_url = getenv("WEIXIN_CDN_BASE_URL", "").strip() - if weixin_cdn_base_url: - extra["cdn_base_url"] = weixin_cdn_base_url.rstrip("/") - weixin_dm_policy = getenv("WEIXIN_DM_POLICY", "").strip().lower() - if weixin_dm_policy: - extra["dm_policy"] = weixin_dm_policy - weixin_group_policy = getenv("WEIXIN_GROUP_POLICY", "").strip().lower() - if weixin_group_policy: - extra["group_policy"] = weixin_group_policy - weixin_allowed_users = getenv("WEIXIN_ALLOWED_USERS", "").strip() - if weixin_allowed_users: - extra["allow_from"] = weixin_allowed_users - weixin_group_allowed_users = getenv("WEIXIN_GROUP_ALLOWED_USERS", "").strip() - if weixin_group_allowed_users: - extra["group_allow_from"] = weixin_group_allowed_users - weixin_split_multiline = getenv("WEIXIN_SPLIT_MULTILINE_MESSAGES", "").strip() - if weixin_split_multiline: - extra["split_multiline_messages"] = weixin_split_multiline - weixin_home = getenv("WEIXIN_HOME_CHANNEL", "").strip() - if weixin_home: - config.platforms[Platform.WEIXIN].home_channel = HomeChannel( - platform=Platform.WEIXIN, - chat_id=weixin_home, - name=getenv("WEIXIN_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("WEIXIN_HOME_CHANNEL_THREAD_ID") or None, - ) + _env_extras_stripped(extra, ( + ("base_url", "WEIXIN_BASE_URL", lambda v: v.rstrip("/")), + ("cdn_base_url", "WEIXIN_CDN_BASE_URL", lambda v: v.rstrip("/")), + ("dm_policy", "WEIXIN_DM_POLICY", str.lower), + ("group_policy", "WEIXIN_GROUP_POLICY", str.lower), + ("allow_from", "WEIXIN_ALLOWED_USERS"), + ("group_allow_from", "WEIXIN_GROUP_ALLOWED_USERS"), + ("split_multiline_messages", "WEIXIN_SPLIT_MULTILINE_MESSAGES"), + )) + _env_home_channel(config, Platform.WEIXIN, "WEIXIN_HOME_CHANNEL", strip=True) # BlueBubbles (iMessage) bluebubbles_server_url = getenv("BLUEBUBBLES_SERVER_URL") bluebubbles_password = getenv("BLUEBUBBLES_PASSWORD") if bluebubbles_server_url and bluebubbles_password: - # Honors an explicit ``platforms.bluebubbles.enabled: false`` (#48820). - _enable_from_env(Platform.BLUEBUBBLES) - config.platforms[Platform.BLUEBUBBLES].extra.update({ + extra = _enable_from_env(Platform.BLUEBUBBLES).extra + extra.update({ "server_url": bluebubbles_server_url.rstrip("/"), "password": bluebubbles_password, "webhook_host": getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), @@ -2632,46 +2556,28 @@ def _apply_env_overrides(config: GatewayConfig) -> None: }) bluebubbles_require_mention = getenv("BLUEBUBBLES_REQUIRE_MENTION") if bluebubbles_require_mention is not None: - config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = ( - bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"} - ) + extra["require_mention"] = bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"} bluebubbles_mention_patterns = getenv("BLUEBUBBLES_MENTION_PATTERNS") if bluebubbles_mention_patterns: try: - parsed_patterns = json.loads(bluebubbles_mention_patterns) + extra["mention_patterns"] = json.loads(bluebubbles_mention_patterns) except Exception: - parsed_patterns = [ - part.strip() - for part in bluebubbles_mention_patterns.replace("\n", ",").split(",") - if part.strip() - ] - config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns - bluebubbles_home = getenv("BLUEBUBBLES_HOME_CHANNEL") - if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms: - config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel( - platform=Platform.BLUEBUBBLES, - chat_id=bluebubbles_home, - name=getenv("BLUEBUBBLES_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("BLUEBUBBLES_HOME_CHANNEL_THREAD_ID") or None, - ) + extra["mention_patterns"] = _csv_list(bluebubbles_mention_patterns.replace("\n", ",")) + _env_home_channel(config, Platform.BLUEBUBBLES, "BLUEBUBBLES_HOME_CHANNEL") # QQ (Official Bot API v2) qq_app_id = getenv("QQ_APP_ID") qq_client_secret = getenv("QQ_CLIENT_SECRET") if qq_app_id or qq_client_secret: - # Honors an explicit ``platforms.qqbot.enabled: false`` (#48820). - _enable_from_env(Platform.QQBOT) - extra = config.platforms[Platform.QQBOT].extra + extra = _enable_from_env(Platform.QQBOT).extra if qq_app_id: extra["app_id"] = qq_app_id if qq_client_secret: extra["client_secret"] = qq_client_secret - qq_allowed_users = getenv("QQ_ALLOWED_USERS", "").strip() - if qq_allowed_users: - extra["allow_from"] = qq_allowed_users - qq_group_allowed = getenv("QQ_GROUP_ALLOWED_USERS", "").strip() - if qq_group_allowed: - extra["group_allow_from"] = qq_group_allowed + _env_extras_stripped(extra, ( + ("allow_from", "QQ_ALLOWED_USERS"), + ("group_allow_from", "QQ_GROUP_ALLOWED_USERS"), + )) qq_home = getenv("QQBOT_HOME_CHANNEL", "").strip() qq_home_name_env = "QQBOT_HOME_CHANNEL_NAME" if not qq_home: @@ -2700,227 +2606,38 @@ def _apply_env_overrides(config: GatewayConfig) -> None: yuanbao_app_id = getenv("YUANBAO_APP_ID") or getenv("YUANBAO_APP_KEY") yuanbao_app_secret = getenv("YUANBAO_APP_SECRET") if yuanbao_app_id and yuanbao_app_secret: - # Honors an explicit ``platforms.yuanbao.enabled: false`` (#48820). - _enable_from_env(Platform.YUANBAO) - extra = config.platforms[Platform.YUANBAO].extra + extra = _enable_from_env(Platform.YUANBAO).extra extra["app_id"] = yuanbao_app_id extra["app_secret"] = yuanbao_app_secret - yuanbao_bot_id = getenv("YUANBAO_BOT_ID") - if yuanbao_bot_id: - extra["bot_id"] = yuanbao_bot_id - yuanbao_ws_url = getenv("YUANBAO_WS_URL") - if yuanbao_ws_url: - extra["ws_url"] = yuanbao_ws_url - yuanbao_api_domain = getenv("YUANBAO_API_DOMAIN") - if yuanbao_api_domain: - extra["api_domain"] = yuanbao_api_domain - yuanbao_route_env = getenv("YUANBAO_ROUTE_ENV") - if yuanbao_route_env: - extra["route_env"] = yuanbao_route_env - yuanbao_home = getenv("YUANBAO_HOME_CHANNEL") - if yuanbao_home: - config.platforms[Platform.YUANBAO].home_channel = HomeChannel( - platform=Platform.YUANBAO, - chat_id=yuanbao_home, - name=getenv("YUANBAO_HOME_CHANNEL_NAME", "Home"), - thread_id=getenv("YUANBAO_HOME_CHANNEL_THREAD_ID") or None, - ) - yuanbao_dm_policy = getenv("YUANBAO_DM_POLICY") - if yuanbao_dm_policy: - extra["dm_policy"] = yuanbao_dm_policy.strip().lower() - yuanbao_dm_allow_from = getenv("YUANBAO_DM_ALLOW_FROM") - if yuanbao_dm_allow_from: - extra["dm_allow_from"] = yuanbao_dm_allow_from - yuanbao_group_policy = getenv("YUANBAO_GROUP_POLICY") - if yuanbao_group_policy: - extra["group_policy"] = yuanbao_group_policy.strip().lower() - yuanbao_group_allow_from = getenv("YUANBAO_GROUP_ALLOW_FROM") - if yuanbao_group_allow_from: - extra["group_allow_from"] = yuanbao_group_allow_from + _env_extras(extra, ( + ("bot_id", "YUANBAO_BOT_ID"), + ("ws_url", "YUANBAO_WS_URL"), + ("api_domain", "YUANBAO_API_DOMAIN"), + ("route_env", "YUANBAO_ROUTE_ENV"), + )) + _env_home_channel(config, Platform.YUANBAO, "YUANBAO_HOME_CHANNEL") + _env_extras(extra, ( + ("dm_policy", "YUANBAO_DM_POLICY", lambda v: v.strip().lower()), + ("dm_allow_from", "YUANBAO_DM_ALLOW_FROM"), + ("group_policy", "YUANBAO_GROUP_POLICY", lambda v: v.strip().lower()), + ("group_allow_from", "YUANBAO_GROUP_ALLOW_FROM"), + )) # Session settings - idle_minutes = getenv("SESSION_IDLE_MINUTES") - if idle_minutes: - try: - config.default_reset_policy.idle_minutes = int(idle_minutes) - except ValueError: - pass - - reset_hour = getenv("SESSION_RESET_HOUR") - if reset_hour: - try: - config.default_reset_policy.at_hour = int(reset_hour) - except ValueError: - pass - - # Registry-driven enable for plugin platforms. Built-ins have explicit - # blocks above. A plugin platform is enabled when its credentials are - # configured (``is_connected``) and its dependencies are either present - # (passive ``check_fn``) or installable on demand (``ensure_deps_fn``, - # run later by ``create_adapter()`` — never here). Plugins that need to - # seed ``PlatformConfig.extra`` from env vars (e.g. Google Chat's - # project_id / subscription_name) can supply ``env_enablement_fn`` on - # their PlatformEntry — called here BEFORE adapter construction. - # - # Enablement gate (#31116): when a plugin registers ``is_connected`` - # (the "has the user actually configured credentials for this?" check), - # we MUST consult it before flipping ``enabled = True``. Otherwise - # ``check_fn`` alone — a passive "is the SDK importable?" probe — - # silently enables platforms the user never opted into, and the gateway - # then tries to connect to Discord / Teams / Google Chat with no token - # and emits noisy retry-forever errors. ``_platform_status`` was - # already fixed for the same bug class in commit 7849a3d73; this is the - # runtime counterpart. - try: - from hermes_cli.plugins import discover_plugins - discover_plugins() # idempotent - from gateway.platform_registry import platform_registry - for entry in platform_registry.plugin_entries(): + for env, attr in (("SESSION_IDLE_MINUTES", "idle_minutes"), ("SESSION_RESET_HOUR", "at_hour")): + raw = getenv(env) + if raw: try: - platform = Platform(entry.name) - except Exception as e: - logger.debug("unknown platform name %r: %s", entry.name, e) - continue - existing_cfg = config.platforms.get(platform) - # Respect an explicit ``enabled: false`` (YAML / gateway.json / - # dashboard PUT). ``_enabled_explicit`` is set in - # load_gateway_config() (via _merge_platform_map / the shared-key - # loop) when the user wrote ``enabled`` for this platform; if they - # explicitly disabled it, never re-enable here just because - # check_fn() / is_connected() pass (e.g. a token is present but the - # user set telegram.enabled: false). #41112. - if ( - existing_cfg is not None - and not existing_cfg.enabled - and bool((existing_cfg.extra or {}).get("_enabled_explicit", False)) - ): - continue - # Seed candidate extras from ``env_enablement_fn`` so plugins - # whose ``is_connected`` reads ``config.extra`` (e.g. Google - # Chat's ``_is_connected`` checks ``config.extra["project_id"]``) - # see the same state they will after enablement. Without this, - # Google-Chat-on-env-vars-only setups silently fail the gate - # below even though the user is configured. Plugins whose - # ``is_connected`` reads env vars directly (Discord, IRC, - # Teams, LINE, ntfy, Simplex) are unaffected; this only - # restores Google Chat. - seed_for_probe = None - if entry.env_enablement_fn is not None: - try: - seed_for_probe = entry.env_enablement_fn() - except Exception as e: - logger.debug( - "env_enablement_fn for %s raised: %s", entry.name, e - ) - seed_for_probe = None + setattr(config.default_reset_policy, attr, int(raw)) + except ValueError: + pass - # Only consult is_connected for platforms that are NOT already - # explicitly configured in YAML / env (existing_cfg with - # enabled=True means the user wrote it themselves or another - # env-var bridge enabled it — keep that decision). - if existing_cfg is None or not existing_cfg.enabled: - if entry.is_connected is not None: - try: - # Probe with ``enabled=True`` since we're asking - # "would this plugin BE configured if we enabled - # it?" not "is it currently enabled?". Google - # Chat's ``_is_connected`` short-circuits on - # ``config.enabled`` being False, which on the - # default ``PlatformConfig()`` would fail the - # gate even with proper env vars set. - if existing_cfg is not None: - probe_cfg = existing_cfg - if not probe_cfg.enabled: - probe_cfg = PlatformConfig( - enabled=True, - extra=dict(probe_cfg.extra or {}), - ) - else: - probe_cfg = PlatformConfig(enabled=True) - if isinstance(seed_for_probe, dict) and seed_for_probe: - # Don't mutate ``existing_cfg``; the probe gets - # a transient view with env-seeded extras layered - # on top of whatever's already there. - probe_extra = dict(getattr(probe_cfg, "extra", {}) or {}) - for k, v in seed_for_probe.items(): - if k == "home_channel": - continue - probe_extra.setdefault(k, v) - probe_cfg = PlatformConfig( - enabled=True, - extra=probe_extra, - ) - configured = bool(entry.is_connected(probe_cfg)) - except Exception as exc: - logger.debug( - "is_connected for %s raised: %s — skipping enablement", - entry.name, exc, - ) - configured = False - if not configured: - logger.debug( - "Plugin platform '%s' available but not configured " - "(is_connected returned False) — skipping enable", - entry.name, - ) - continue - # Verify dependencies LAST — only for platforms that are already - # enabled or passed the credential gate above. ``check_fn`` is a - # PASSIVE probe (never installs); a platform whose deps are - # missing but which registered ``ensure_deps_fn`` still gets - # enabled here — the registry's ``create_adapter()`` runs the - # active installer at gateway start, when the user actually - # wants the platform up. Historically the ACTIVE installer was - # wired as ``check_fn`` and this sweep pip-installed - # Discord/Telegram/Slack/Feishu/Dingtalk SDKs on every - # ``load_gateway_config()`` call — including the desktop/dashboard - # readiness probe (``GET /api/status``) — blocking startup until - # every install finished and boot-looping the desktop app at 94%. - # The check_fn/ensure_deps_fn split (#79812) makes that - # impossible by construction. - try: - deps_ok = bool(entry.check_fn()) - except Exception as e: - logger.debug("check_fn for %s raised: %s", entry.name, e) - deps_ok = False - if not deps_ok and entry.ensure_deps_fn is None: - continue - if platform not in config.platforms: - config.platforms[platform] = PlatformConfig() - config.platforms[platform].enabled = True - # Commit env-seeded extras onto the now-enabled platform. - # We've already called ``env_enablement_fn`` above (for the - # probe); reuse that result instead of calling it twice. - if isinstance(seed_for_probe, dict) and seed_for_probe: - seed = dict(seed_for_probe) - # Extract the home_channel dict (if provided) so we wire it - # up as a proper HomeChannel dataclass. Everything else is - # merged into ``extra``. - home = seed.pop("home_channel", None) - config.platforms[platform].extra.update(seed) - if isinstance(home, dict) and home.get("chat_id"): - config.platforms[platform].home_channel = HomeChannel( - platform=platform, - chat_id=str(home["chat_id"]), - name=str(home.get("name") or "Home"), - thread_id=( - str(home["thread_id"]) - if home.get("thread_id") - else None - ), - ) - except Exception as e: - logger.debug("Plugin platform enable pass failed: %s", e) + _enable_plugin_platforms_from_env(config) - # Relay (generic connector-fronted platform, EXPERIMENTAL). Enabled when a - # connector relay URL is configured via GATEWAY_RELAY_URL (env) or - # gateway.relay_url (config.yaml). The adapter is registered into the - # platform_registry at gateway startup (gateway.relay.register_relay_adapter) - # and dials OUT to the connector — so, like Telegram/Matrix, it has no public - # inbound port and just needs Platform.RELAY present+enabled in - # config.platforms for start_gateway()'s connect loop to bring it up. The - # connected-checker (Platform.RELAY in _PLATFORM_CONNECTED_CHECKERS) keys on - # extra["relay_url"], so mirror the URL into extra here. + # Relay (generic connector-fronted platform, EXPERIMENTAL). Enabled by GATEWAY_RELAY_URL (env) + # or gateway.relay_url (config.yaml). The adapter dials OUT to the connector (no inbound port), + # so it only needs Platform.RELAY present+enabled for start_gateway()'s connect loop. The + # connected-checker keys on extra["relay_url"], so mirror the URL into extra here. relay_url_env = getenv("GATEWAY_RELAY_URL", "").strip() relay_url_yaml = "" existing_relay = config.platforms.get(Platform.RELAY) @@ -2928,27 +2645,16 @@ def _apply_env_overrides(config: GatewayConfig) -> None: relay_url_yaml = str(existing_relay.extra.get("relay_url") or "").strip() relay_url_val = relay_url_env or relay_url_yaml if relay_url_val: - relay_config = _enable_from_env(Platform.RELAY) - relay_config.extra["relay_url"] = relay_url_val.rstrip("/") + _enable_from_env(Platform.RELAY).extra["relay_url"] = relay_url_val.rstrip("/") - # Relay-exclusive: a GATEWAY_RELAY_URL env stamp marks a connector-fronted - # deployment where the connector owns every platform connection. Any - # directly-connected messaging adapter in the same process would be a - # second, unmanaged ingress path (duplicate deliveries, split sessions, - # and a live socket that disarms scale-to-zero), so the env stamp disables - # all other messaging platforms — including ones explicitly enabled in - # config.yaml. Non-messaging surfaces (local, api_server, webhook — the - # same exclusion set as the scale-to-zero arm gate) are untouched. - # Deployments that configure relay only via gateway.relay_url in - # config.yaml keep the old additive behavior (relay beside direct - # adapters). - # - # Opt-out: GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS=true keeps direct - # adapters running beside the relay for deployments that intentionally - # mix both ingress paths. Like the trigger, it is a deploy-stamp env var, - # not a config.yaml setting. Both reads go through the profile-scope-aware - # getenv so multiplexed profiles see their own values, not the process - # globals. + # Relay-exclusive: a GATEWAY_RELAY_URL env stamp marks a connector-fronted deployment where the + # connector owns every platform connection; a directly-connected adapter in the same process + # would be a second unmanaged ingress (duplicate deliveries, split sessions, a live socket that + # disarms scale-to-zero). So the env stamp disables all other messaging platforms — even ones + # explicitly enabled in config.yaml. Non-messaging surfaces (local, api_server, webhook — same + # exclusion set as the scale-to-zero arm gate) are untouched; relay via gateway.relay_url only + # keeps the old additive behavior. Opt-out GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS=true is likewise + # a deploy-stamp env var read through the profile-scope-aware getenv. allow_direct = is_truthy_value( getenv("GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS", "") )