refactor(gateway/config): extract env overrides to gateway/config_env.py as a _Cred/_ENV_STEPS table

This commit is contained in:
Teknium
2026-09-02 15:38:08 -07:00
parent 88b74d6ef0
commit ff67ee47ff
3 changed files with 736 additions and 706 deletions
+3 -699
View File
@@ -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.<x>.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 ``<env_base>`` (+``_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.<x>.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)
+723
View File
@@ -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.<x>.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 ``<env_base>`` (+``_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.<x>.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)
@@ -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}"