refactor(gateway/config): extract env overrides to gateway/config_env.py as a _Cred/_ENV_STEPS table
This commit is contained in:
+3
-699
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user