diff --git a/gateway/config.py b/gateway/config.py index 2e9c9fc603..04dbaaf7b7 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -25,7 +25,6 @@ from gateway.shutdown_watchdog import ( DEFAULT_LOOP_WATCHDOG_TIMEOUT_S, ) from utils import is_truthy_value -import contextlib logger = logging.getLogger(__name__) @@ -1982,703 +1981,8 @@ def _validate_gateway_config(config: "GatewayConfig") -> None: pconfig.enabled = False -# Platforms for which the "explicitly disabled in config.yaml, but credentials -# are present in the environment" WARNING has already been emitted in this -# process. The gateway reloads its config on every turn (and other surfaces -# call load_gateway_config() repeatedly), so the notice is one-time per -# platform per process — loud once at startup, never a per-turn drumbeat. -_EXPLICIT_DISABLE_WARNED: set = set() - - -# Env var(s) whose presence drives each platform's env-enable branch, for the -# explicit-disable WARNING below. Kept next to the branches that read them. -_ENV_ENABLE_CREDENTIALS: dict = { - Platform.TELEGRAM: ("TELEGRAM_BOT_TOKEN",), - Platform.DISCORD: ("DISCORD_BOT_TOKEN",), - Platform.SLACK: ("SLACK_BOT_TOKEN",), - Platform.WHATSAPP_CLOUD: ("WHATSAPP_CLOUD_PHONE_NUMBER_ID", "WHATSAPP_CLOUD_ACCESS_TOKEN"), - Platform.SIGNAL: ("SIGNAL_HTTP_URL",), - Platform.MATTERMOST: ("MATTERMOST_TOKEN",), - Platform.MATRIX: ("MATRIX_ACCESS_TOKEN", "MATRIX_PASSWORD"), - Platform.HOMEASSISTANT: ("HASS_TOKEN",), - Platform.EMAIL: ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"), - Platform.SMS: ("TWILIO_ACCOUNT_SID",), - Platform.DINGTALK: ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), - Platform.FEISHU: ("FEISHU_APP_ID", "FEISHU_APP_SECRET"), - Platform.WECOM: ("WECOM_BOT_ID", "WECOM_SECRET"), - Platform.WECOM_CALLBACK: ("WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"), - Platform.WEIXIN: ("WEIXIN_TOKEN", "WEIXIN_ACCOUNT_ID"), - Platform.BLUEBUBBLES: ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"), - Platform.QQBOT: ("QQ_APP_ID", "QQ_CLIENT_SECRET"), - Platform.YUANBAO: ("YUANBAO_APP_ID", "YUANBAO_APP_SECRET"), - Platform.RELAY: ("GATEWAY_RELAY_URL",), -} - - -def _warn_explicit_disable_beats_env(platform: Platform) -> None: - """One-time WARNING: ``platforms..enabled: false`` wins over env creds. - - Until #48820 the credential-presence branches force-enabled twelve - platforms regardless of an explicit ``enabled: false`` in config.yaml, so - users who relied on "creds in .env = platform on" would see it go dark - after the fix with no explanation. Name the platform, the config key that - is winning, and the env var(s) that used to override it. - """ - if platform in _EXPLICIT_DISABLE_WARNED: - return - _EXPLICIT_DISABLE_WARNED.add(platform) - names = _ENV_ENABLE_CREDENTIALS.get(platform) or () - present = [n for n in names if (os.environ.get(n) or "").strip()] - creds = ", ".join(present or names) or "its credentials" - logger.warning( - "Platform '%s' is explicitly disabled by platforms.%s.enabled: false in " - "config.yaml, so the credentials found in the environment (%s) will NOT " - "start its adapter. Environment credentials no longer override an " - "explicit disable. Remove the key or set platforms.%s.enabled: true to " - "turn it back on.", - 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: - with contextlib.suppress(ValueError): - extra[key] = int(raw) - - -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.""" - getenv = _getenv_str - getenv_int = _getenv_int + """Apply environment variable overrides to config (see ``gateway.config_env``).""" + from gateway.config_env import _apply_env_overrides as _impl - def _enable_from_env(platform: Platform) -> PlatformConfig: - if platform not in config.platforms: - config.platforms[platform] = PlatformConfig(enabled=True) - return config.platforms[platform] - - platform_config = config.platforms[platform] - # 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. - _warn_explicit_disable_beats_env(platform) - else: - platform_config.enabled = True - return platform_config - - # Telegram - telegram_token = getenv("TELEGRAM_BOT_TOKEN") - if telegram_token: - _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: - 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: - _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; 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 - elif whatsapp_enabled: - config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True) - _env_home_channel(config, Platform.WHATSAPP, "WHATSAPP_HOME_CHANNEL") - - # 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: - extra = _enable_from_env(Platform.WHATSAPP_CLOUD).extra - extra.update({ - "phone_number_id": whatsapp_cloud_phone_id, - "access_token": whatsapp_cloud_token, - }) - _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") - if slack_token: - if Platform.SLACK not in config.platforms: - # No yaml config for Slack — env-only setup, enable it - config.platforms[Platform.SLACK] = PlatformConfig() - config.platforms[Platform.SLACK].enabled = True - else: - slack_config = config.platforms[Platform.SLACK] - # 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 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) - # 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: - slack_config = config.platforms.setdefault( - Platform.SLACK, - PlatformConfig(enabled=False), - ) - existing_home = slack_config.home_channel - same_home = existing_home is not None and existing_home.chat_id == slack_home - slack_config.home_channel = HomeChannel( - platform=Platform.SLACK, - chat_id=slack_home, - name=getenv("SLACK_HOME_CHANNEL_NAME", ""), - thread_id=getenv("SLACK_HOME_CHANNEL_THREAD_ID") or 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: - _enable_from_env(Platform.SIGNAL).extra.update({ - "http_url": signal_url, - "account": signal_account, - "ignore_stories": is_truthy_value(getenv("SIGNAL_IGNORE_STORIES", "true")), - }) - _env_home_channel(config, Platform.SIGNAL, "SIGNAL_HOME_CHANNEL") - - # Mattermost - mattermost_token = getenv("MATTERMOST_TOKEN") - if mattermost_token: - mattermost_url = getenv("MATTERMOST_URL", "") - if not mattermost_url: - logger.warning("MATTERMOST_TOKEN set but MATTERMOST_URL is missing") - mattermost_config = _enable_from_env(Platform.MATTERMOST) - mattermost_config.token = mattermost_token - mattermost_config.extra["url"] = mattermost_url - _env_home_channel(config, Platform.MATTERMOST, "MATTERMOST_HOME_CHANNEL") - - # Matrix - matrix_token = getenv("MATRIX_ACCESS_TOKEN") - matrix_homeserver = getenv("MATRIX_HOMESERVER", "") - if matrix_token or getenv("MATRIX_PASSWORD"): - if not matrix_homeserver: - logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing") - matrix_config = _enable_from_env(Platform.MATRIX) - if matrix_token: - matrix_config.token = matrix_token - matrix_config.extra["homeserver"] = matrix_homeserver - _env_extras(matrix_config.extra, ( - ("user_id", "MATRIX_USER_ID"), - ("password", "MATRIX_PASSWORD"), - )) - matrix_e2ee_mode = getenv("MATRIX_E2EE_MODE", "").strip().lower() - matrix_config.extra["encryption"] = ( - matrix_e2ee_mode in ("required", "require", "optional", "prefer", "preferred") - or is_truthy_value(getenv("MATRIX_ENCRYPTION", "")) - ) - if matrix_e2ee_mode: - matrix_config.extra["e2ee_mode"] = matrix_e2ee_mode - _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: - 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") - email_pwd = getenv("EMAIL_PASSWORD") - email_imap = getenv("EMAIL_IMAP_HOST") - email_smtp = getenv("EMAIL_SMTP_HOST") - if all([email_addr, email_pwd, email_imap, email_smtp]): - _enable_from_env(Platform.EMAIL).extra.update({ - "address": email_addr, - "imap_host": email_imap, - "smtp_host": email_smtp, - }) - _env_home_channel(config, Platform.EMAIL, "EMAIL_HOME_ADDRESS") - - # SMS (Twilio) - twilio_sid = getenv("TWILIO_ACCOUNT_SID") - if twilio_sid: - _enable_from_env(Platform.SMS).api_key = getenv("TWILIO_AUTH_TOKEN", "") - _env_home_channel(config, Platform.SMS, "SMS_HOME_CHANNEL") - - # 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", "") - if _has_usable_api_server_key(api_server_key): - extra = _enable_port_bound_from_env(config, Platform.API_SERVER).extra - if api_server_key: - extra["key"] = api_server_key - api_server_cors_origins = getenv("API_SERVER_CORS_ORIGINS", "") - if api_server_cors_origins: - origins = _csv_list(api_server_cors_origins) - if origins: - 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 - 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", "") - if ( - msgraph_webhook_enabled - or Platform.MSGRAPH_WEBHOOK in config.platforms - or msgraph_webhook_port - or msgraph_webhook_client_state - or msgraph_webhook_resources - or msgraph_webhook_allowed_cidrs - ): - msgraph_cfg = config.platforms.setdefault(Platform.MSGRAPH_WEBHOOK, PlatformConfig()) - if msgraph_webhook_enabled: - # 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 - _env_int_extra(msgraph_cfg.extra, "port", "MSGRAPH_WEBHOOK_PORT") - if msgraph_webhook_client_state: - 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: - _enable_from_env(Platform.DINGTALK).extra.update({ - "client_id": dingtalk_client_id, - "client_secret": dingtalk_client_secret, - }) - _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: - 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"), - }) - _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: - extra = _enable_from_env(Platform.WECOM).extra - extra.update({ - "bot_id": wecom_bot_id, - "secret": wecom_secret, - }) - _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: - _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: 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), - }) - - # Weixin (personal WeChat via iLink Bot API) - weixin_token = getenv("WEIXIN_TOKEN") - weixin_account_id = getenv("WEIXIN_ACCOUNT_ID") - if weixin_token or weixin_account_id: - weixin_config = _enable_from_env(Platform.WEIXIN) - if weixin_token: - weixin_config.token = weixin_token - extra = weixin_config.extra - if weixin_account_id: - extra["account_id"] = weixin_account_id - _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: - 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"), - "webhook_port": getenv_int("BLUEBUBBLES_WEBHOOK_PORT", 8645), - "webhook_path": getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), - "send_read_receipts": is_truthy_value(getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true")), - }) - bluebubbles_require_mention = getenv("BLUEBUBBLES_REQUIRE_MENTION") - if bluebubbles_require_mention is not None: - extra["require_mention"] = bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"} - bluebubbles_mention_patterns = getenv("BLUEBUBBLES_MENTION_PATTERNS") - if bluebubbles_mention_patterns: - try: - extra["mention_patterns"] = json.loads(bluebubbles_mention_patterns) - except Exception: - 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: - 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 - _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: - # Back-compat: accept the pre-rename name and log a one-time warning. - legacy_home = getenv("QQ_HOME_CHANNEL", "").strip() - if legacy_home: - qq_home = legacy_home - qq_home_name_env = "QQ_HOME_CHANNEL_NAME" - logging.getLogger(__name__).warning( - "QQ_HOME_CHANNEL is deprecated; rename to QQBOT_HOME_CHANNEL " - "in your .env for consistency with the platform key." - ) - if qq_home: - config.platforms[Platform.QQBOT].home_channel = HomeChannel( - platform=Platform.QQBOT, - chat_id=qq_home, - name=getenv("QQBOT_HOME_CHANNEL_NAME") or getenv(qq_home_name_env, "Home"), - thread_id=( - getenv("QQBOT_HOME_CHANNEL_THREAD_ID") - or getenv("QQ_HOME_CHANNEL_THREAD_ID") - or None - ), - ) - - # Yuanbao — YUANBAO_APP_ID preferred - 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: - extra = _enable_from_env(Platform.YUANBAO).extra - extra["app_id"] = yuanbao_app_id - extra["app_secret"] = yuanbao_app_secret - _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 - for env, attr in (("SESSION_IDLE_MINUTES", "idle_minutes"), ("SESSION_RESET_HOUR", "at_hour")): - raw = getenv(env) - if raw: - with contextlib.suppress(ValueError): - setattr(config.default_reset_policy, attr, int(raw)) - - _enable_plugin_platforms_from_env(config) - - # 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) - if existing_relay is not 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: - _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; 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", "") - ) - if relay_url_env and not allow_direct: - non_messaging = {Platform.LOCAL, Platform.API_SERVER, Platform.WEBHOOK} - for platform, platform_config in config.platforms.items(): - if platform is Platform.RELAY or platform in non_messaging: - continue - if not platform_config.enabled: - continue - if platform_config.extra.get("_enabled_explicit"): - logger.warning( - "Relay connector is configured via GATEWAY_RELAY_URL; " - "disabling directly-connected platform '%s' even though " - "it is explicitly enabled in this profile's configuration. " - "All messaging goes through the connector on this " - "deployment. Set GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS=true " - "to keep direct platforms alongside the relay.", - platform.value, - ) - else: - logger.info( - "Relay connector is configured via GATEWAY_RELAY_URL; " - "disabling directly-connected platform '%s'.", - platform.value, - ) - platform_config.enabled = False - - for platform_config in config.platforms.values(): - platform_config.extra.pop("_enabled_explicit", None) + _impl(config) diff --git a/gateway/config_env.py b/gateway/config_env.py new file mode 100644 index 0000000000..e977ec21c9 --- /dev/null +++ b/gateway/config_env.py @@ -0,0 +1,723 @@ +"""Environment-variable overrides for the gateway config (``_apply_env_overrides``). + +Runs after ``GatewayConfig.from_dict`` so env always wins over config.yaml / +gateway.json. Most platforms follow one shape — "credentials present in env +⇒ enable the platform and copy the values into ``extra``" — and are declared +as ``_Cred`` rows in ``_ENV_STEPS`` (source order = application order). +Platforms with unique gating live as small functions in the same table. +""" + +import contextlib +import json +import logging +import os +from dataclasses import dataclass +from functools import partial +from typing import Any, Callable, Dict, List, Optional + +from gateway.config import ( + GatewayConfig, + HomeChannel, + Platform, + PlatformConfig, + _getenv_str, + _has_usable_api_server_key, +) +from utils import is_truthy_value + +# Logger name parity with the origin module: records stay under "gateway.config". +logger = logging.getLogger("gateway.config") + +getenv = _getenv_str + +# Platforms for which the "explicitly disabled in config.yaml, but credentials +# are present in the environment" WARNING has already been emitted in this +# process. Config reloads on every turn, so the notice is one-time per platform +# per process — loud once at startup, never a per-turn drumbeat. +_EXPLICIT_DISABLE_WARNED: set = set() + + +# Env var(s) whose presence drives each platform's env-enable branch, named in +# the explicit-disable WARNING below. +_ENV_ENABLE_CREDENTIALS: dict = { + Platform.TELEGRAM: ("TELEGRAM_BOT_TOKEN",), + Platform.DISCORD: ("DISCORD_BOT_TOKEN",), + Platform.SLACK: ("SLACK_BOT_TOKEN",), + Platform.WHATSAPP_CLOUD: ("WHATSAPP_CLOUD_PHONE_NUMBER_ID", "WHATSAPP_CLOUD_ACCESS_TOKEN"), + Platform.SIGNAL: ("SIGNAL_HTTP_URL",), + Platform.MATTERMOST: ("MATTERMOST_TOKEN",), + Platform.MATRIX: ("MATRIX_ACCESS_TOKEN", "MATRIX_PASSWORD"), + Platform.HOMEASSISTANT: ("HASS_TOKEN",), + Platform.EMAIL: ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"), + Platform.SMS: ("TWILIO_ACCOUNT_SID",), + Platform.DINGTALK: ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), + Platform.FEISHU: ("FEISHU_APP_ID", "FEISHU_APP_SECRET"), + Platform.WECOM: ("WECOM_BOT_ID", "WECOM_SECRET"), + Platform.WECOM_CALLBACK: ("WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"), + Platform.WEIXIN: ("WEIXIN_TOKEN", "WEIXIN_ACCOUNT_ID"), + Platform.BLUEBUBBLES: ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"), + Platform.QQBOT: ("QQ_APP_ID", "QQ_CLIENT_SECRET"), + Platform.YUANBAO: ("YUANBAO_APP_ID", "YUANBAO_APP_SECRET"), + Platform.RELAY: ("GATEWAY_RELAY_URL",), +} + + +def _warn_explicit_disable_beats_env(platform: Platform) -> None: + """One-time WARNING: ``platforms..enabled: false`` wins over env creds. + + Until #48820 credential presence force-enabled twelve platforms regardless + of an explicit ``enabled: false``; users relying on "creds in .env = + platform on" need to be told why it went dark. Names the platform, the + winning config key, and the env var(s) that used to override it. + """ + if platform in _EXPLICIT_DISABLE_WARNED: + return + _EXPLICIT_DISABLE_WARNED.add(platform) + names = _ENV_ENABLE_CREDENTIALS.get(platform) or () + present = [n for n in names if (os.environ.get(n) or "").strip()] + creds = ", ".join(present or names) or "its credentials" + logger.warning( + "Platform '%s' is explicitly disabled by platforms.%s.enabled: false in " + "config.yaml, so the credentials found in the environment (%s) will NOT " + "start its adapter. Environment credentials no longer override an " + "explicit disable. Remove the key or set platforms.%s.enabled: true to " + "turn it back on.", + platform.value, platform.value, creds, platform.value, + ) + + +# --- small value parsers ----------------------------------------------------- + +def _csv_list(value: str) -> List[str]: + return [part.strip() for part in value.split(",") if part.strip()] + + +def _int_or(default: int) -> Callable[[str], int]: + """``int(raw.strip(), 10)`` with *default* on malformed/blank input (``_getenv_int`` semantics).""" + def parse(raw: str) -> int: + try: + return int(str(raw).strip(), 10) + except (TypeError, ValueError): + return default + return parse + + +def _strip_slash(value: str) -> str: + return value.rstrip("/") + + +def _truthy_token(value: str) -> bool: + return value.lower() in {"true", "1", "yes", "on"} + + +def _mention_patterns(value: str) -> Any: + try: + return json.loads(value) + except Exception: + return _csv_list(value.replace("\n", ",")) + + +def _env_first(envs) -> str: + """First truthy value among *envs* (a single name or a tuple of alternatives).""" + if isinstance(envs, str): + return getenv(envs) + for env in envs: + value = getenv(env) + if value: + return value + return "" + + +# --- reusable steps ----------------------------------------------------------- + +_INT = object() # spec marker: ``int(value)``, silently skipped when malformed + + +def _env_extras(extra: Dict[str, Any], spec, *, strip: bool = False) -> None: + """``extra[key] = fn(value)`` for each ``(key, env[, fn])`` whose env value is truthy. + + With ``strip=True`` the value is stripped BEFORE the truthiness check. ``fn=_INT`` parses an + int and silently ignores a non-integer value. + """ + for key, env, *fn in spec: + value = getenv(env) + if strip: + value = value.strip() + if not value: + continue + if fn and fn[0] is _INT: + with contextlib.suppress(ValueError): + extra[key] = int(value) + else: + extra[key] = fn[0](value) if fn else value + + +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(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(f"{env_base}_NAME", "Home"), + thread_id=getenv(f"{env_base}_THREAD_ID") or None, + ) + + +def _env_reply_mode(config: GatewayConfig, platform: Platform, env: str) -> None: + mode = getenv(env).lower() + if mode in {"off", "first", "all"}: + config.platforms.setdefault(platform, PlatformConfig()).reply_to_mode = mode + + +def _enable_from_env(config: GatewayConfig, platform: Platform) -> PlatformConfig: + """Enable *platform* because its env credentials are present — unless config.yaml explicitly disabled it. + + READS (does not pop) the ``_enabled_explicit`` marker: the registry-driven plugin-enable pass later + also needs it. The marker is scrubbed for all platforms at the end of ``_apply_env_overrides``. + """ + if platform not in config.platforms: + config.platforms[platform] = PlatformConfig(enabled=True) + return config.platforms[platform] + platform_config = config.platforms[platform] + if not platform_config.enabled: + if platform_config.extra.get("_enabled_explicit", False): + # 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 + + +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 + + +@dataclass(frozen=True) +class _Cred: + """Credential-gated platform enable, applied as ``step(config)``. + + ``creds``: env names that must ALL be truthy; an inner tuple lists alternatives (ANY). + ``token``: env whose truthy value becomes ``PlatformConfig.token``. + ``fixed``: ``(extra_key, env[, default[, fn]])`` always written once enabled (``env`` may be a + tuple of alternatives). ``optional`` / ``optional_stripped``: ``_env_extras`` specs, written only + when truthy. ``warn_missing``: ``(env, msg)`` logged BEFORE enabling when *env* is blank. ``then``: unique tail ``fn(config, platform_config)``. + ``home``: ``_env_home_channel`` env base applied only when the gate passed. + """ + platform: Platform + creds: tuple + token: Optional[str] = None + fixed: tuple = () + optional: tuple = () + optional_stripped: tuple = () + warn_missing: Optional[tuple] = None + then: Optional[Callable[[GatewayConfig, PlatformConfig], None]] = None + home: Optional[str] = None + home_strip: bool = False + + def __call__(self, config: GatewayConfig) -> None: + if not all(_env_first(group) for group in self.creds): + return + if self.warn_missing and not getenv(self.warn_missing[0]): + logger.warning(self.warn_missing[1]) + platform_config = _enable_from_env(config, self.platform) + if self.token: + token = getenv(self.token) + if token: + platform_config.token = token + extra = platform_config.extra + for key, env, *rest in self.fixed: + default = rest[0] if rest else "" + value = _env_first(env) or default if isinstance(env, tuple) else getenv(env, default) + extra[key] = rest[1](value) if len(rest) > 1 else value + _env_extras(extra, self.optional) + _env_extras(extra, self.optional_stripped, strip=True) + if self.then is not None: + self.then(config, platform_config) + if self.home: + _env_home_channel(config, self.platform, self.home, strip=self.home_strip) + + +def _Home(platform: Platform, env_base: str, *, strip: bool = False): + return partial(_env_home_channel, platform=platform, env_base=env_base, strip=strip) + + +def _ReplyMode(platform: Platform, env: str): + return partial(_env_reply_mode, platform=platform, env=env) + + +# --- platform-unique branches ------------------------------------------------ + +def _telegram_fallback_ips(config: GatewayConfig) -> None: + ips = getenv("TELEGRAM_FALLBACK_IPS") + if ips: + config.platforms.setdefault(Platform.TELEGRAM, PlatformConfig()).extra["fallback_ips"] = _csv_list(ips) + + +def _whatsapp(config: GatewayConfig) -> None: + """WhatsApp (Baileys bridge) uses a flag, not credentials.""" + raw = getenv("WHATSAPP_ENABLED") + enabled = is_truthy_value(raw) + disabled_explicitly = raw.lower() in {"false", "0", "no"} + if Platform.WHATSAPP in config.platforms: + # YAML config exists — respect explicit disable; otherwise keep whatever the YAML set. + wa_cfg = config.platforms[Platform.WHATSAPP] + if disabled_explicitly: + wa_cfg.enabled = False + elif enabled: + wa_cfg.enabled = True + elif enabled: + config.platforms[Platform.WHATSAPP] = PlatformConfig(enabled=True) + + +def _slack(config: GatewayConfig) -> None: + slack_token = getenv("SLACK_BOT_TOKEN") + if slack_token: + if Platform.SLACK not in config.platforms: + config.platforms[Platform.SLACK] = PlatformConfig(enabled=True) + else: + slack_config = config.platforms[Platform.SLACK] + if not slack_config.enabled and not slack_config.extra.get("_enabled_explicit", False): + # 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) + # 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: + slack_config = config.platforms.setdefault(Platform.SLACK, PlatformConfig(enabled=False)) + existing_home = slack_config.home_channel + same_home = existing_home is not None and existing_home.chat_id == slack_home + slack_config.home_channel = HomeChannel( + platform=Platform.SLACK, + chat_id=slack_home, + name=getenv("SLACK_HOME_CHANNEL_NAME"), + thread_id=getenv("SLACK_HOME_CHANNEL_THREAD_ID") or None, + user_id=existing_home.user_id if same_home else None, + scope_id=existing_home.scope_id if same_home else None, + ) + + +def _matrix_e2ee(config: GatewayConfig, matrix_config: PlatformConfig) -> None: + mode = getenv("MATRIX_E2EE_MODE").strip().lower() + matrix_config.extra["encryption"] = ( + mode in ("required", "require", "optional", "prefer", "preferred") + or is_truthy_value(getenv("MATRIX_ENCRYPTION")) + ) + if mode: + matrix_config.extra["e2ee_mode"] = mode + _env_extras(matrix_config.extra, (("device_id", "MATRIX_DEVICE_ID"),)) + + +def _sms(config: GatewayConfig) -> None: + if getenv("TWILIO_ACCOUNT_SID"): + _enable_from_env(config, Platform.SMS).api_key = getenv("TWILIO_AUTH_TOKEN") + + +def _api_server(config: GatewayConfig) -> None: + """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).""" + key = getenv("API_SERVER_KEY") + if not _has_usable_api_server_key(key): + return + extra = _enable_port_bound_from_env(config, Platform.API_SERVER).extra + extra["key"] = key + origins = _csv_list(getenv("API_SERVER_CORS_ORIGINS")) + if origins: + extra["cors_origins"] = origins + _env_extras(extra, (("port", "API_SERVER_PORT", _INT), ("host", "API_SERVER_HOST"), ("model_name", "API_SERVER_MODEL_NAME"))) + + +def _webhook(config: GatewayConfig) -> None: + if is_truthy_value(getenv("WEBHOOK_ENABLED")): + extra = _enable_port_bound_from_env(config, Platform.WEBHOOK).extra + _env_extras(extra, (("port", "WEBHOOK_PORT", _INT), ("secret", "WEBHOOK_SECRET"))) + + +def _msgraph_webhook(config: GatewayConfig) -> None: + enabled = is_truthy_value(getenv("MSGRAPH_WEBHOOK_ENABLED")) + client_state = getenv("MSGRAPH_WEBHOOK_CLIENT_STATE") + resources = getenv("MSGRAPH_WEBHOOK_ACCEPTED_RESOURCES") + allowed_cidrs = getenv("MSGRAPH_WEBHOOK_ALLOWED_SOURCE_CIDRS") + if not ( + enabled + or Platform.MSGRAPH_WEBHOOK in config.platforms + or getenv("MSGRAPH_WEBHOOK_PORT") + or client_state + or resources + or allowed_cidrs + ): + return + msgraph_cfg = config.platforms.setdefault(Platform.MSGRAPH_WEBHOOK, PlatformConfig()) + if enabled: + # 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 + _env_extras(msgraph_cfg.extra, (("port", "MSGRAPH_WEBHOOK_PORT", _INT),)) + if client_state: + msgraph_cfg.extra["client_state"] = client_state + for key, raw in (("accepted_resources", resources), ("allowed_source_cidrs", allowed_cidrs)): + items = _csv_list(raw) + if items: + msgraph_cfg.extra[key] = items + + +def _qq_home(config: GatewayConfig, qq_config: PlatformConfig) -> None: + qq_home = getenv("QQBOT_HOME_CHANNEL").strip() + name_env = "QQBOT_HOME_CHANNEL_NAME" + if not qq_home: + # Back-compat: accept the pre-rename name and log a one-time warning. + legacy_home = getenv("QQ_HOME_CHANNEL").strip() + if legacy_home: + qq_home = legacy_home + name_env = "QQ_HOME_CHANNEL_NAME" + logger.warning( + "QQ_HOME_CHANNEL is deprecated; rename to QQBOT_HOME_CHANNEL " + "in your .env for consistency with the platform key." + ) + if qq_home: + qq_config.home_channel = HomeChannel( + platform=Platform.QQBOT, + chat_id=qq_home, + name=getenv("QQBOT_HOME_CHANNEL_NAME") or getenv(name_env, "Home"), + thread_id=getenv("QQBOT_HOME_CHANNEL_THREAD_ID") or getenv("QQ_HOME_CHANNEL_THREAD_ID") or None, + ) + + +def _session_settings(config: GatewayConfig) -> None: + for env, attr in (("SESSION_IDLE_MINUTES", "idle_minutes"), ("SESSION_RESET_HOUR", "at_hour")): + raw = getenv(env) + if raw: + with contextlib.suppress(ValueError): + setattr(config.default_reset_policy, attr, int(raw)) + + +def _enable_plugin_platforms_from_env(config: GatewayConfig) -> None: + """Registry-driven enable for plugin platforms (built-ins have explicit rows in ``_ENV_STEPS``). + + 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 the YAML loader). + 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 + has_seed = isinstance(seed_for_probe, dict) and bool(seed_for_probe) + + # 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) and 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. + probe_cfg = existing_cfg + if probe_cfg is None or not probe_cfg.enabled: + probe_cfg = PlatformConfig( + enabled=True, + extra=dict(existing_cfg.extra or {}) if existing_cfg is not None else {}, + ) + if has_seed: + # Transient view; never mutate ``existing_cfg`` for the probe. + probe_extra = dict(probe_cfg.extra or {}) + for k, v in seed_for_probe.items(): + if k != "home_channel": + 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 + platform_config = config.platforms.setdefault(platform, PlatformConfig()) + platform_config.enabled = True + # Commit the env-seeded extras (reuse the probe result; don't call env_enablement_fn twice). + if has_seed: + seed = dict(seed_for_probe) + home = seed.pop("home_channel", None) + platform_config.extra.update(seed) + if isinstance(home, dict) and home.get("chat_id"): + platform_config.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 _relay(config: GatewayConfig) -> None: + """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 the URL is mirrored into extra here. + + 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. + """ + relay_url_env = getenv("GATEWAY_RELAY_URL").strip() + existing_relay = config.platforms.get(Platform.RELAY) + relay_url_yaml = str(existing_relay.extra.get("relay_url") or "").strip() if existing_relay else "" + relay_url_val = relay_url_env or relay_url_yaml + if relay_url_val: + _enable_from_env(config, Platform.RELAY).extra["relay_url"] = relay_url_val.rstrip("/") + + if not relay_url_env or is_truthy_value(getenv("GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS")): + return + non_messaging = {Platform.LOCAL, Platform.API_SERVER, Platform.WEBHOOK} + for platform, platform_config in config.platforms.items(): + if platform is Platform.RELAY or platform in non_messaging or not platform_config.enabled: + continue + if platform_config.extra.get("_enabled_explicit"): + logger.warning( + "Relay connector is configured via GATEWAY_RELAY_URL; " + "disabling directly-connected platform '%s' even though " + "it is explicitly enabled in this profile's configuration. " + "All messaging goes through the connector on this " + "deployment. Set GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS=true " + "to keep direct platforms alongside the relay.", + platform.value, + ) + else: + logger.info( + "Relay connector is configured via GATEWAY_RELAY_URL; " + "disabling directly-connected platform '%s'.", + platform.value, + ) + platform_config.enabled = False + + +def _scrub_explicit_markers(config: GatewayConfig) -> None: + for platform_config in config.platforms.values(): + platform_config.extra.pop("_enabled_explicit", None) + + +# Application order is significant: e.g. Telegram's reply mode may create the (disabled) platform +# entry BEFORE its home channel is read, while Discord reads home first; a home channel is only +# attached to a platform that already exists. Relay-exclusive disabling runs after the plugin +# pass and before the marker scrub, which must be last. +_ENV_STEPS: tuple = ( + _Cred(Platform.TELEGRAM, ("TELEGRAM_BOT_TOKEN",), token="TELEGRAM_BOT_TOKEN"), + _ReplyMode(Platform.TELEGRAM, "TELEGRAM_REPLY_TO_MODE"), + _telegram_fallback_ips, + _Home(Platform.TELEGRAM, "TELEGRAM_HOME_CHANNEL"), + _Cred(Platform.DISCORD, ("DISCORD_BOT_TOKEN",), token="DISCORD_BOT_TOKEN"), + _Home(Platform.DISCORD, "DISCORD_HOME_CHANNEL"), + _ReplyMode(Platform.DISCORD, "DISCORD_REPLY_TO_MODE"), + _whatsapp, + _Home(Platform.WHATSAPP, "WHATSAPP_HOME_CHANNEL"), + # WhatsApp Cloud API (official Business Platform via Meta). Distinct from the Baileys bridge; + # both adapters can run in parallel against different phone numbers. + _Cred( + Platform.WHATSAPP_CLOUD, ("WHATSAPP_CLOUD_PHONE_NUMBER_ID", "WHATSAPP_CLOUD_ACCESS_TOKEN"), + fixed=(("phone_number_id", "WHATSAPP_CLOUD_PHONE_NUMBER_ID"), ("access_token", "WHATSAPP_CLOUD_ACCESS_TOKEN")), + optional=( + ("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"), + ("webhook_port", "WHATSAPP_CLOUD_WEBHOOK_PORT", _INT), + ("webhook_path", "WHATSAPP_CLOUD_WEBHOOK_PATH"), + ("api_version", "WHATSAPP_CLOUD_API_VERSION"), + ), + ), + _Home(Platform.WHATSAPP_CLOUD, "WHATSAPP_CLOUD_HOME_CHANNEL"), + _slack, + _Cred( + Platform.SIGNAL, ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"), + fixed=( + ("http_url", "SIGNAL_HTTP_URL"), + ("account", "SIGNAL_ACCOUNT"), + ("ignore_stories", "SIGNAL_IGNORE_STORIES", "true", is_truthy_value), + ), + ), + _Home(Platform.SIGNAL, "SIGNAL_HOME_CHANNEL"), + _Cred( + Platform.MATTERMOST, ("MATTERMOST_TOKEN",), token="MATTERMOST_TOKEN", + warn_missing=("MATTERMOST_URL", "MATTERMOST_TOKEN set but MATTERMOST_URL is missing"), + fixed=(("url", "MATTERMOST_URL"),), + ), + _Home(Platform.MATTERMOST, "MATTERMOST_HOME_CHANNEL"), + _Cred( + Platform.MATRIX, (("MATRIX_ACCESS_TOKEN", "MATRIX_PASSWORD"),), token="MATRIX_ACCESS_TOKEN", + warn_missing=("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing"), + fixed=(("homeserver", "MATRIX_HOMESERVER"),), + optional=(("user_id", "MATRIX_USER_ID"), ("password", "MATRIX_PASSWORD")), + then=_matrix_e2ee, + ), + _Home(Platform.MATRIX, "MATRIX_HOME_ROOM"), + _Cred(Platform.HOMEASSISTANT, ("HASS_TOKEN",), token="HASS_TOKEN", optional=(("url", "HASS_URL"),)), + _Cred( + Platform.EMAIL, ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST"), + fixed=(("address", "EMAIL_ADDRESS"), ("imap_host", "EMAIL_IMAP_HOST"), ("smtp_host", "EMAIL_SMTP_HOST")), + ), + _Home(Platform.EMAIL, "EMAIL_HOME_ADDRESS"), + _sms, + _Home(Platform.SMS, "SMS_HOME_CHANNEL"), + _api_server, + _webhook, + _msgraph_webhook, + _Cred( + Platform.DINGTALK, ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), + fixed=(("client_id", "DINGTALK_CLIENT_ID"), ("client_secret", "DINGTALK_CLIENT_SECRET")), + home="DINGTALK_HOME_CHANNEL", + ), + _Cred( + Platform.FEISHU, ("FEISHU_APP_ID", "FEISHU_APP_SECRET"), + fixed=( + ("app_id", "FEISHU_APP_ID"), + ("app_secret", "FEISHU_APP_SECRET"), + ("domain", "FEISHU_DOMAIN", "feishu"), + ("connection_mode", "FEISHU_CONNECTION_MODE", "websocket"), + ), + optional=(("encrypt_key", "FEISHU_ENCRYPT_KEY"), ("verification_token", "FEISHU_VERIFICATION_TOKEN")), + home="FEISHU_HOME_CHANNEL", + ), + _Cred( + Platform.WECOM, ("WECOM_BOT_ID", "WECOM_SECRET"), + fixed=(("bot_id", "WECOM_BOT_ID"), ("secret", "WECOM_SECRET")), + optional=(("websocket_url", "WECOM_WEBSOCKET_URL"),), + home="WECOM_HOME_CHANNEL", + ), + _Cred( + Platform.WECOM_CALLBACK, ("WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"), + fixed=( + ("corp_id", "WECOM_CALLBACK_CORP_ID"), + ("corp_secret", "WECOM_CALLBACK_CORP_SECRET"), + ("agent_id", "WECOM_CALLBACK_AGENT_ID"), + ("token", "WECOM_CALLBACK_TOKEN"), + ("encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY"), + # 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", "WECOM_CALLBACK_HOST"), + ("port", "WECOM_CALLBACK_PORT", "", _int_or(8645)), + ), + ), + # Weixin (personal WeChat via iLink Bot API) + _Cred( + Platform.WEIXIN, (("WEIXIN_TOKEN", "WEIXIN_ACCOUNT_ID"),), token="WEIXIN_TOKEN", + optional=(("account_id", "WEIXIN_ACCOUNT_ID"),), + optional_stripped=( + ("base_url", "WEIXIN_BASE_URL", _strip_slash), + ("cdn_base_url", "WEIXIN_CDN_BASE_URL", _strip_slash), + ("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"), + ), + home="WEIXIN_HOME_CHANNEL", home_strip=True, + ), + # BlueBubbles (iMessage). ``require_mention`` is always written: an unset env reads as "" → False. + _Cred( + Platform.BLUEBUBBLES, ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"), + fixed=( + ("server_url", "BLUEBUBBLES_SERVER_URL", "", _strip_slash), + ("password", "BLUEBUBBLES_PASSWORD"), + ("webhook_host", "BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), + ("webhook_port", "BLUEBUBBLES_WEBHOOK_PORT", "", _int_or(8645)), + ("webhook_path", "BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), + ("send_read_receipts", "BLUEBUBBLES_SEND_READ_RECEIPTS", "true", is_truthy_value), + ("require_mention", "BLUEBUBBLES_REQUIRE_MENTION", "", _truthy_token), + ), + optional=(("mention_patterns", "BLUEBUBBLES_MENTION_PATTERNS", _mention_patterns),), + ), + _Home(Platform.BLUEBUBBLES, "BLUEBUBBLES_HOME_CHANNEL"), + # QQ (Official Bot API v2) + _Cred( + Platform.QQBOT, (("QQ_APP_ID", "QQ_CLIENT_SECRET"),), + optional=(("app_id", "QQ_APP_ID"), ("client_secret", "QQ_CLIENT_SECRET")), + optional_stripped=(("allow_from", "QQ_ALLOWED_USERS"), ("group_allow_from", "QQ_GROUP_ALLOWED_USERS")), + then=_qq_home, + ), + # Yuanbao — YUANBAO_APP_ID preferred over the legacy YUANBAO_APP_KEY + _Cred( + Platform.YUANBAO, (("YUANBAO_APP_ID", "YUANBAO_APP_KEY"), "YUANBAO_APP_SECRET"), + fixed=(("app_id", ("YUANBAO_APP_ID", "YUANBAO_APP_KEY")), ("app_secret", "YUANBAO_APP_SECRET")), + optional=( + ("bot_id", "YUANBAO_BOT_ID"), + ("ws_url", "YUANBAO_WS_URL"), + ("api_domain", "YUANBAO_API_DOMAIN"), + ("route_env", "YUANBAO_ROUTE_ENV"), + ("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"), + ), + home="YUANBAO_HOME_CHANNEL", + ), + _session_settings, + _enable_plugin_platforms_from_env, + _relay, + _scrub_explicit_markers, +) + + +def _apply_env_overrides(config: GatewayConfig) -> None: + """Apply environment variable overrides to *config* (see ``_ENV_STEPS``).""" + for step in _ENV_STEPS: + step(config) diff --git a/tests/gateway/test_env_override_explicit_disable_48820.py b/tests/gateway/test_env_override_explicit_disable_48820.py index 1d8452c5a0..e6d9d90bd6 100644 --- a/tests/gateway/test_env_override_explicit_disable_48820.py +++ b/tests/gateway/test_env_override_explicit_disable_48820.py @@ -14,7 +14,7 @@ import logging import pytest -from gateway import config as gateway_config +from gateway import config_env as gateway_config_env from gateway.config import Platform, load_gateway_config @@ -127,7 +127,7 @@ def test_env_credentials_still_populate_extra_when_yaml_disables(tmp_path, monke @pytest.fixture() def _fresh_warn_dedup(monkeypatch): """The explicit-disable notice is one-time per process; start each test clean.""" - monkeypatch.setattr(gateway_config, "_EXPLICIT_DISABLE_WARNED", set()) + monkeypatch.setattr(gateway_config_env, "_EXPLICIT_DISABLE_WARNED", set()) @pytest.mark.usefixtures("_fresh_warn_dedup") @@ -188,12 +188,15 @@ def test_no_warning_when_disabled_and_no_env_credentials(tmp_path, monkeypatch, def test_every_env_enable_branch_is_named_for_the_warning(): - """Each platform routed through ``_enable_from_env`` needs a credential - entry so the WARNING can name what is being ignored.""" + """Each platform routed through ``_enable_from_env`` (every ``_Cred`` row plus + the hand-written steps that call it) needs a credential entry so the WARNING + can name what is being ignored.""" import inspect, re - src = inspect.getsource(gateway_config._apply_env_overrides) - routed = {Platform[name] for name in re.findall(r"_enable_from_env\(Platform\.([A-Z_]+)\)", src)} + src = inspect.getsource(gateway_config_env) + routed = {Platform[name] for name in re.findall(r"_enable_from_env\(config, Platform\.([A-Z_]+)\)", src)} + routed |= {step.platform for step in gateway_config_env._ENV_STEPS if isinstance(step, gateway_config_env._Cred)} routed.add(Platform.SLACK) # Slack has its own inline copy of the logic - missing = {p.value for p in routed} - {p.value for p in gateway_config._ENV_ENABLE_CREDENTIALS} + assert len(routed) > 15 + missing = {p.value for p in routed} - {p.value for p in gateway_config_env._ENV_ENABLE_CREDENTIALS} assert not missing, f"platforms without a credential entry for the explicit-disable warning: {missing}"