refactor(gateway): migrate slack/dingtalk/whatsapp/matrix/feishu/telegram/wecom/email/sms adapters to bundled plugins

Salvage of PR #41284 onto current main. Relocates the last 9 inline messaging
adapters (+ satellites: telegram_network, feishu_comment/_rules/meeting_invite,
wecom_crypto, wecom_callback) from gateway/platforms/ into self-contained
bundled plugins under plugins/platforms/<x>/, discovered via the platform
registry. Strips the per-platform core touchpoints from gateway/run.py,
gateway/config.py, hermes_cli/gateway.py, hermes_cli/setup.py, and
tools/send_message_tool.py.

Carries forward the migration fixes (explicit enabled:false honored,
get_connected_platforms forces discovery, plugin is_connected via
gateway.get_env_value, logs --component gateway matches plugins.platforms.*,
matrix hidden on Windows).

Additionally ports config keys main added since the PR base: the matrix
plugin's _apply_yaml_config now also covers allowed_users,
ignore_user_patterns, process_notices, and session_scope (the inline
gateway/config.py matrix block gained these in the 1340 commits the PR sat
open; they would otherwise have been silently dropped on deletion).
This commit is contained in:
Teknium 2026-06-19 20:41:08 -07:00
parent 2ab09a6c50
commit 5600105478
124 changed files with 3643 additions and 2579 deletions

View file

@ -463,23 +463,15 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
Platform.WEIXIN: lambda cfg: bool(
cfg.extra.get("account_id") and (cfg.token or cfg.extra.get("token"))
),
Platform.WHATSAPP: lambda cfg: True, # bridge handles auth
Platform.WHATSAPP_CLOUD: lambda cfg: bool(
cfg.extra.get("phone_number_id") and cfg.extra.get("access_token")
),
Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")),
Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")),
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
Platform.API_SERVER: lambda cfg: True,
Platform.WEBHOOK: lambda cfg: True,
Platform.MSGRAPH_WEBHOOK: lambda cfg: bool(
str(cfg.extra.get("client_state") or "").strip()
),
Platform.FEISHU: lambda cfg: bool(cfg.extra.get("app_id")),
Platform.WECOM: lambda cfg: bool(cfg.extra.get("bot_id")),
Platform.WECOM_CALLBACK: lambda cfg: bool(
cfg.extra.get("corp_id") or cfg.extra.get("apps")
),
Platform.BLUEBUBBLES: lambda cfg: bool(
cfg.extra.get("server_url") and cfg.extra.get("password")
),
@ -489,10 +481,6 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
Platform.YUANBAO: lambda cfg: bool(
cfg.extra.get("app_id") and cfg.extra.get("app_secret")
),
Platform.DINGTALK: lambda cfg: bool(
(cfg.extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (cfg.extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
),
# Relay dials OUT to a connector; it is "connected" once an endpoint URL is
# configured (extra["relay_url"] or extra["url"]). The capability descriptor
# is negotiated at handshake time, so the URL is the only config-level
@ -594,9 +582,17 @@ class GatewayConfig:
if checker is not None:
return checker(config)
# Plugin-registered platforms
# Plugin-registered platforms. Force plugin discovery first so this
# works even when GatewayConfig is constructed directly (e.g. in tests
# or callers that bypass load_gateway_config(), which is what triggers
# discovery in the normal path). discover_plugins() is idempotent.
try:
from gateway.platform_registry import platform_registry
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception:
pass
entry = platform_registry.get(platform.value)
if entry:
if entry.is_connected is not None:
@ -1026,7 +1022,11 @@ def load_gateway_config() -> GatewayConfig:
plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value)
if enabled_was_explicit:
plat_data["enabled"] = platform_cfg["enabled"]
if plat == Platform.SLACK and enabled_was_explicit:
# Mark the explicit enable/disable so the registry-driven
# plugin-enable pass in _apply_env_overrides honors an
# explicit ``enabled: false`` for migrated plugin platforms
# (slack, telegram, matrix, dingtalk, whatsapp, feishu …)
# instead of re-enabling them on token/SDK presence. #41112.
extra["_enabled_explicit"] = True
extra.update(bridged)
@ -1067,28 +1067,10 @@ def load_gateway_config() -> GatewayConfig:
_, extra = _ensure_platform_extra_dict(platforms_data, entry.name)
extra.update(seeded)
# Slack settings → env vars (env vars take precedence)
slack_cfg = yaml_cfg.get("slack", {})
if isinstance(slack_cfg, dict):
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"):
os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower()
if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
frc = slack_cfg.get("free_response_channels")
if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"):
os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower()
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
ac = slack_cfg.get("allowed_channels")
if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
# Slack settings → env vars: migrated to the slack plugin's
# ``apply_yaml_config_fn`` hook (see plugins/platforms/slack/
# adapter.py::_apply_yaml_config), dispatched in the
# ``apply_yaml_config_fn`` loop above. #41112 / #3823.
# Bridge top-level require_mention to Telegram when the telegram: section
# does not already provide one. Users often write "require_mention: true"
@ -1101,125 +1083,22 @@ def load_gateway_config() -> GatewayConfig:
_tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
_tg_extra = _tg_plat.setdefault("extra", {})
_tg_extra.setdefault("require_mention", _tl_require_mention)
# Also bridge to the TELEGRAM_REQUIRE_MENTION env var that the
# adapter reads at runtime. This used to live in the telegram_cfg
# block in core; it stays in core because it keys off the TOP-LEVEL
# require_mention (not a telegram: block), so the telegram plugin's
# apply_yaml_config_fn hook — which only runs when a telegram config
# block exists — can't cover the no-telegram-block case (#3979).
if not os.getenv("TELEGRAM_REQUIRE_MENTION"):
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_tl_require_mention).lower()
# Telegram settings → env vars (env vars take precedence)
telegram_cfg = yaml_cfg.get("telegram", {})
if isinstance(telegram_cfg, dict):
# Bridge top-level legacy `telegram.disable_topic_auto_rename` into
# gateway.platforms.telegram.extra so the runtime config sees it.
# Read as a runtime-config flag, not env-var (no need for env override).
if "disable_topic_auto_rename" in telegram_cfg:
_tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
_tg_extra = _tg_plat.setdefault("extra", {})
_tg_extra.setdefault(
"disable_topic_auto_rename",
telegram_cfg["disable_topic_auto_rename"],
)
# Prefer telegram.require_mention; fall back to the top-level shorthand.
_effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
if _effective_rm is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
os.environ["TELEGRAM_MENTION_PATTERNS"] = json.dumps(telegram_cfg["mention_patterns"])
if "exclusive_bot_mentions" in telegram_cfg and not os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS"):
os.environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] = str(telegram_cfg["exclusive_bot_mentions"]).lower()
if "guest_mode" in telegram_cfg and not os.getenv("TELEGRAM_GUEST_MODE"):
os.environ["TELEGRAM_GUEST_MODE"] = str(telegram_cfg["guest_mode"]).lower()
if "observe_unmentioned_group_messages" in telegram_cfg and not os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"):
os.environ["TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] = str(telegram_cfg["observe_unmentioned_group_messages"]).lower()
frc = telegram_cfg.get("free_response_chats")
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
# allowed_chats: if set, bot ONLY responds in these group chats (whitelist)
ac = telegram_cfg.get("allowed_chats")
if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac)
allowed_topics = telegram_cfg.get("allowed_topics")
if allowed_topics is not None and not os.getenv("TELEGRAM_ALLOWED_TOPICS"):
if isinstance(allowed_topics, list):
allowed_topics = ",".join(str(v) for v in allowed_topics)
os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics)
ignored_threads = telegram_cfg.get("ignored_threads")
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
if isinstance(ignored_threads, list):
ignored_threads = ",".join(str(v) for v in ignored_threads)
os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads)
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
_telegram_extra = telegram_cfg.get("extra") if isinstance(telegram_cfg.get("extra"), dict) else {}
_telegram_rtm = (
telegram_cfg["reply_to_mode"] if "reply_to_mode" in telegram_cfg
else _telegram_extra.get("reply_to_mode")
)
if _telegram_rtm is not None and not os.getenv("TELEGRAM_REPLY_TO_MODE"):
_rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower()
os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str
allowed_users = telegram_cfg.get("allow_from")
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
if isinstance(allowed_users, list):
allowed_users = ",".join(str(v) for v in allowed_users)
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
group_allowed_users = telegram_cfg.get("group_allow_from")
if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
if isinstance(group_allowed_users, list):
group_allowed_users = ",".join(str(v) for v in group_allowed_users)
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
group_allowed_chats = telegram_cfg.get("group_allowed_chats")
if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
if isinstance(group_allowed_chats, list):
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
for _telegram_extra_key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages"):
if _telegram_extra_key in telegram_cfg:
plat_data = platforms_data.setdefault(Platform.TELEGRAM.value, {})
if not isinstance(plat_data, dict):
plat_data = {}
platforms_data[Platform.TELEGRAM.value] = plat_data
extra = plat_data.setdefault("extra", {})
if not isinstance(extra, dict):
extra = {}
plat_data["extra"] = extra
extra[_telegram_extra_key] = telegram_cfg[_telegram_extra_key]
if _telegram_extra:
_plat_data, _plat_extra = _ensure_platform_extra_dict(
platforms_data, Platform.TELEGRAM.value
)
for _telegram_extra_key, _telegram_extra_value in _telegram_extra.items():
_plat_extra.setdefault(_telegram_extra_key, _telegram_extra_value)
# Telegram settings → env vars / extra: migrated to the telegram
# plugin's apply_yaml_config_fn hook
# (plugins/platforms/telegram/adapter.py). #41112 / #3823.
whatsapp_cfg = yaml_cfg.get("whatsapp", {})
if isinstance(whatsapp_cfg, dict):
if "require_mention" in whatsapp_cfg and not os.getenv("WHATSAPP_REQUIRE_MENTION"):
os.environ["WHATSAPP_REQUIRE_MENTION"] = str(whatsapp_cfg["require_mention"]).lower()
if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"):
os.environ["WHATSAPP_MENTION_PATTERNS"] = json.dumps(whatsapp_cfg["mention_patterns"])
frc = whatsapp_cfg.get("free_response_chats")
if frc is not None and not os.getenv("WHATSAPP_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc)
if "dm_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_DM_POLICY"):
os.environ["WHATSAPP_DM_POLICY"] = str(whatsapp_cfg["dm_policy"]).lower()
af = whatsapp_cfg.get("allow_from")
if af is not None and not os.getenv("WHATSAPP_ALLOWED_USERS"):
if isinstance(af, list):
af = ",".join(str(v) for v in af)
os.environ["WHATSAPP_ALLOWED_USERS"] = str(af)
if "group_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_GROUP_POLICY"):
os.environ["WHATSAPP_GROUP_POLICY"] = str(whatsapp_cfg["group_policy"]).lower()
gaf = whatsapp_cfg.get("group_allow_from")
if gaf is not None and not os.getenv("WHATSAPP_GROUP_ALLOWED_USERS"):
if isinstance(gaf, list):
gaf = ",".join(str(v) for v in gaf)
os.environ["WHATSAPP_GROUP_ALLOWED_USERS"] = str(gaf)
# WhatsApp settings → env vars: migrated to the whatsapp plugin's
# apply_yaml_config_fn hook (plugins/platforms/whatsapp/adapter.py).
# #41112 / #3823.
# Signal settings → env vars (env vars take precedence)
signal_cfg = yaml_cfg.get("signal", {})
@ -1227,72 +1106,20 @@ def load_gateway_config() -> GatewayConfig:
if "require_mention" in signal_cfg and not os.getenv("SIGNAL_REQUIRE_MENTION"):
os.environ["SIGNAL_REQUIRE_MENTION"] = str(signal_cfg["require_mention"]).lower()
# DingTalk settings → env vars (env vars take precedence)
dingtalk_cfg = yaml_cfg.get("dingtalk", {})
if isinstance(dingtalk_cfg, dict):
if "require_mention" in dingtalk_cfg and not os.getenv("DINGTALK_REQUIRE_MENTION"):
os.environ["DINGTALK_REQUIRE_MENTION"] = str(dingtalk_cfg["require_mention"]).lower()
if "mention_patterns" in dingtalk_cfg and not os.getenv("DINGTALK_MENTION_PATTERNS"):
os.environ["DINGTALK_MENTION_PATTERNS"] = json.dumps(dingtalk_cfg["mention_patterns"])
frc = dingtalk_cfg.get("free_response_chats")
if frc is not None and not os.getenv("DINGTALK_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc)
# allowed_chats: if set, bot ONLY responds in these group chats (whitelist)
ac = dingtalk_cfg.get("allowed_chats")
if ac is not None and not os.getenv("DINGTALK_ALLOWED_CHATS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["DINGTALK_ALLOWED_CHATS"] = str(ac)
allowed = dingtalk_cfg.get("allowed_users")
if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"):
if isinstance(allowed, list):
allowed = ",".join(str(v) for v in allowed)
os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
# DingTalk settings → env vars: migrated to the dingtalk plugin's
# apply_yaml_config_fn hook (plugins/platforms/dingtalk/adapter.py).
# #41112 / #3823.
# Mattermost config bridge moved into plugins/platforms/mattermost/
# adapter.py::_apply_yaml_config — see #25443 (apply_yaml_config_fn).
# Matrix settings → env vars (env vars take precedence)
matrix_cfg = yaml_cfg.get("matrix", {})
if isinstance(matrix_cfg, dict):
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
allowed_users = matrix_cfg.get("allowed_users")
if allowed_users is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
if isinstance(allowed_users, list):
allowed_users = ",".join(str(v) for v in allowed_users)
os.environ["MATRIX_ALLOWED_USERS"] = str(allowed_users)
allowed_rooms = matrix_cfg.get("allowed_rooms")
if allowed_rooms is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
if isinstance(allowed_rooms, list):
allowed_rooms = ",".join(str(v) for v in allowed_rooms)
os.environ["MATRIX_ALLOWED_ROOMS"] = str(allowed_rooms)
frc = matrix_cfg.get("free_response_rooms")
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
if isinstance(ignore_patterns, list):
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
# Matrix settings → env vars: migrated to the matrix plugin's
# apply_yaml_config_fn hook (plugins/platforms/matrix/adapter.py).
# #41112 / #3823.
# Feishu settings → env vars (env vars take precedence)
feishu_cfg = yaml_cfg.get("feishu", {})
if isinstance(feishu_cfg, dict):
if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"):
os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower()
# Feishu settings → env vars: migrated to the feishu plugin's
# apply_yaml_config_fn hook (plugins/platforms/feishu/adapter.py).
# #41112 / #3823.
except Exception as e:
logger.warning(
@ -1391,7 +1218,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
return config.platforms[platform]
platform_config = config.platforms[platform]
enabled_was_explicit = bool(platform_config.extra.pop("_enabled_explicit", False))
# Read (don't pop) the explicit-enable marker: the registry-driven
# plugin-enable pass later in this function also needs it to avoid
# re-enabling a platform the user explicitly disabled (migrated plugin
# platforms — telegram, matrix — flow through here too, #41112). The
# flag is cleared once for all platforms in the final cleanup at the
# end of _apply_env_overrides.
enabled_was_explicit = bool(platform_config.extra.get("_enabled_explicit", False))
if not platform_config.enabled and not enabled_was_explicit:
platform_config.enabled = True
return platform_config
@ -1534,7 +1367,12 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.platforms[Platform.SLACK].enabled = True
else:
slack_config = config.platforms[Platform.SLACK]
enabled_was_explicit = bool(slack_config.extra.pop("_enabled_explicit", False))
# Read (don't pop) the explicit-enable marker: the registry-driven
# plugin-enable pass below also needs it to avoid re-enabling a
# platform the user explicitly disabled (Slack is now a plugin
# entry — #41112). The flag is cleared once for all platforms in
# the final cleanup at the end of _apply_env_overrides.
enabled_was_explicit = bool(slack_config.extra.get("_enabled_explicit", False))
if not slack_config.enabled and not enabled_was_explicit:
# Top-level Slack settings such as channel prompts should not
# turn an env-token setup into a disabled platform. Only an
@ -2076,6 +1914,19 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
continue
platform = Platform(entry.name)
existing_cfg = config.platforms.get(platform)
# Respect an explicit ``enabled: false`` (YAML / gateway.json /
# dashboard PUT). ``_enabled_explicit`` is set in
# load_gateway_config() (via _merge_platform_map / the shared-key
# loop) when the user wrote ``enabled`` for this platform; if they
# explicitly disabled it, never re-enable here just because
# check_fn() / is_connected() pass (e.g. a token is present but the
# user set telegram.enabled: false). #41112.
if (
existing_cfg is not None
and not existing_cfg.enabled
and bool((existing_cfg.extra or {}).get("_enabled_explicit", False))
):
continue
# Seed candidate extras from ``env_enablement_fn`` so plugins
# whose ``is_connected`` reads ``config.extra`` (e.g. Google
# Chat's ``_is_connected`` checks ``config.extra["project_id"]``)

View file

@ -6988,43 +6988,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("Platform registry lookup for '%s' failed: %s", platform.value, e)
# Fall through to built-in adapters below
if platform == Platform.TELEGRAM:
from gateway.platforms.telegram import TelegramAdapter, check_telegram_requirements
if not check_telegram_requirements():
logger.warning("Telegram: python-telegram-bot not installed")
return None
adapter = TelegramAdapter(config)
# Apply Telegram notification mode from config. Controls whether
# intermediate messages (tool progress, streaming, status) trigger
# push notifications. Supports ENV override for quick testing.
_notify_mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
if not _notify_mode:
try:
_gw_cfg = _load_gateway_config()
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
if _raw not in {None, ""}:
_notify_mode = str(_raw).strip().lower()
except Exception:
pass
_notify_mode = _notify_mode or "important"
if _notify_mode not in {"all", "important"}:
logger.warning(
"Unknown telegram notifications mode '%s', "
"defaulting to 'important' (valid: all, important)",
_notify_mode,
)
_notify_mode = "important"
adapter._notifications_mode = _notify_mode
return adapter
elif platform == Platform.WHATSAPP:
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
if not check_whatsapp_requirements():
logger.warning("WhatsApp: Node.js not installed or bridge not configured")
return None
return WhatsAppAdapter(config)
elif platform == Platform.WHATSAPP_CLOUD:
if platform == Platform.WHATSAPP_CLOUD:
from gateway.platforms.whatsapp_cloud import (
WhatsAppCloudAdapter,
check_whatsapp_cloud_requirements,
@ -7036,13 +7000,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return None
return WhatsAppCloudAdapter(config)
elif platform == Platform.SLACK:
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
if not check_slack_requirements():
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
return None
return SlackAdapter(config)
elif platform == Platform.SIGNAL:
from gateway.platforms.signal import SignalAdapter, check_signal_requirements
if not check_signal_requirements():
@ -7050,51 +7007,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return None
return SignalAdapter(config)
elif platform == Platform.EMAIL:
from gateway.platforms.email import EmailAdapter, check_email_requirements
if not check_email_requirements():
logger.warning("Email: EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, or EMAIL_SMTP_HOST not set")
return None
return EmailAdapter(config)
elif platform == Platform.SMS:
from gateway.platforms.sms import SmsAdapter, check_sms_requirements
if not check_sms_requirements():
logger.warning("SMS: aiohttp not installed or TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN not set")
return None
return SmsAdapter(config)
elif platform == Platform.DINGTALK:
from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
if not check_dingtalk_requirements():
logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set")
return None
return DingTalkAdapter(config)
elif platform == Platform.FEISHU:
from gateway.platforms.feishu import FeishuAdapter, check_feishu_requirements
if not check_feishu_requirements():
logger.warning("Feishu: lark-oapi not installed or FEISHU_APP_ID/SECRET not set")
return None
return FeishuAdapter(config)
elif platform == Platform.WECOM_CALLBACK:
from gateway.platforms.wecom_callback import (
WecomCallbackAdapter,
check_wecom_callback_requirements,
)
if not check_wecom_callback_requirements():
logger.warning("WeComCallback: aiohttp/httpx/defusedxml not installed")
return None
return WecomCallbackAdapter(config)
elif platform == Platform.WECOM:
from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements
if not check_wecom_requirements():
logger.warning("WeCom: aiohttp not installed or WECOM_BOT_ID/SECRET not set")
return None
return WeComAdapter(config)
elif platform == Platform.WEIXIN:
from gateway.platforms.weixin import WeixinAdapter, check_weixin_requirements
if not check_weixin_requirements():
@ -7102,13 +7014,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return None
return WeixinAdapter(config)
elif platform == Platform.MATRIX:
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
if not check_matrix_requirements():
logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'")
return None
return MatrixAdapter(config)
elif platform == Platform.API_SERVER:
from gateway.platforms.api_server import APIServerAdapter, check_api_server_requirements
if not check_api_server_requirements():

View file

@ -4210,134 +4210,18 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, fo
# Per-platform config: each entry defines the env vars, setup instructions,
# and prompts needed to configure a messaging platform.
_PLATFORMS = [
{
"key": "telegram",
"label": "Telegram",
"emoji": "📱",
"token_var": "TELEGRAM_BOT_TOKEN",
"setup_instructions": [
"1. Open Telegram and message @BotFather",
"2. Send /newbot and follow the prompts to create your bot",
"3. Copy the bot token BotFather gives you",
"4. To find your user ID: message @userinfobot — it replies with your numeric ID",
],
"vars": [
{
"name": "TELEGRAM_BOT_TOKEN",
"prompt": "Bot token",
"password": True,
"help": "Paste the token from @BotFather (step 3 above).",
},
{
"name": "TELEGRAM_ALLOWED_USERS",
"prompt": "Allowed user IDs (comma-separated)",
"password": False,
"is_allowlist": True,
"help": "Paste your user ID from step 4 above.",
},
{
"name": "TELEGRAM_HOME_CHANNEL",
"prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)",
"password": False,
"help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat.",
},
],
},
# Telegram moved to plugins/platforms/telegram/ — setup metadata discovered
# dynamically via the platform registry entry registered by
# plugins/platforms/telegram/adapter.py::register(). #41112.
# Discord moved to plugins/platforms/discord/ — its setup metadata is
# discovered dynamically via _all_platforms() from the platform registry
# entry registered by plugins/platforms/discord/adapter.py::register().
{
"key": "slack",
"label": "Slack",
"emoji": "💼",
"token_var": "SLACK_BOT_TOKEN",
"setup_instructions": [
"1. Go to https://api.slack.com/apps → Create New App → From Scratch",
"2. Enable Socket Mode: Settings → Socket Mode → Enable",
" Create an App-Level Token with scope: connections:write → copy xapp-... token",
"3. Add Bot Token Scopes: Features → OAuth & Permissions → Scopes",
" Required: chat:write, app_mentions:read, channels:history, channels:read,",
" groups:history, im:history, im:read, im:write, users:read, files:read, files:write",
"4. Subscribe to Events: Features → Event Subscriptions → Enable",
" Required events: message.im, message.channels, app_mention",
" Optional: message.groups (for private channels)",
" ⚠ Without message.channels the bot will ONLY work in DMs!",
"5. Install to Workspace: Settings → Install App → copy xoxb-... token",
"6. Reinstall the app after any scope or event changes",
"7. Find your user ID: click your profile → three dots → Copy member ID",
"8. Invite the bot to channels: /invite @YourBot",
],
"vars": [
{
"name": "SLACK_BOT_TOKEN",
"prompt": "Bot Token (xoxb-...)",
"password": True,
"help": "Paste the bot token from step 3 above.",
},
{
"name": "SLACK_APP_TOKEN",
"prompt": "App Token (xapp-...)",
"password": True,
"help": "Paste the app-level token from step 4 above.",
},
{
"name": "SLACK_ALLOWED_USERS",
"prompt": "Allowed user IDs (comma-separated)",
"password": False,
"is_allowlist": True,
"help": "Paste your member ID from step 7 above.",
},
],
},
{
"key": "matrix",
"label": "Matrix",
"emoji": "🔐",
"token_var": "MATRIX_ACCESS_TOKEN",
"setup_instructions": [
"1. Works with any Matrix homeserver (self-hosted Synapse/Conduit/Dendrite or matrix.org)",
"2. Create a bot user on your homeserver, or use your own account",
"3. Get an access token: Element → Settings → Help & About → Access Token",
" Or via API: curl -X POST https://your-server/_matrix/client/v3/login \\",
' -d \'{"type":"m.login.password","user":"@bot:server","password":"..."}\'',
"4. Alternatively, provide user ID + password and Hermes will log in directly",
"5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'mautrix[encryption]')",
"6. To find your user ID: it's @username:your-server (shown in Element profile)",
],
"vars": [
{
"name": "MATRIX_HOMESERVER",
"prompt": "Homeserver URL (e.g. https://matrix.example.org)",
"password": False,
"help": "Your Matrix homeserver URL. Works with any self-hosted instance.",
},
{
"name": "MATRIX_ACCESS_TOKEN",
"prompt": "Access token (leave empty to use password login instead)",
"password": True,
"help": "Paste your access token, or leave empty and provide user ID + password below.",
},
{
"name": "MATRIX_USER_ID",
"prompt": "User ID (@bot:server — required for password login)",
"password": False,
"help": "Full Matrix user ID, e.g. @hermes:matrix.example.org",
},
{
"name": "MATRIX_ALLOWED_USERS",
"prompt": "Allowed user IDs (comma-separated, e.g. @you:server)",
"password": False,
"is_allowlist": True,
"help": "Matrix user IDs who can interact with the bot.",
},
{
"name": "MATRIX_HOME_ROOM",
"prompt": "Home room ID (for cron/notification delivery, or empty to set later with /set-home)",
"password": False,
"help": "Room ID (e.g. !abc123:server) for delivering cron results and notifications.",
},
],
},
# Slack moved to plugins/platforms/slack/ for the same reason — its setup
# metadata is discovered dynamically via the platform registry entry
# registered by plugins/platforms/slack/adapter.py::register(). #41112.
# Matrix moved to plugins/platforms/matrix/ — setup metadata discovered
# dynamically via the platform registry entry registered by
# plugins/platforms/matrix/adapter.py::register(). #41112.
{
"key": "mattermost",
"label": "Mattermost",
@ -4387,289 +4271,18 @@ _PLATFORMS = [
},
],
},
{
"key": "whatsapp",
"label": "WhatsApp",
"emoji": "📲",
"token_var": "WHATSAPP_ENABLED",
},
# WhatsApp moved to plugins/platforms/whatsapp/ — setup metadata discovered
# dynamically via the platform registry entry registered by
# plugins/platforms/whatsapp/adapter.py::register(). #41112.
{
"key": "signal",
"label": "Signal",
"emoji": "📡",
"token_var": "SIGNAL_HTTP_URL",
},
{
"key": "email",
"label": "Email",
"emoji": "📧",
"token_var": "EMAIL_ADDRESS",
"setup_instructions": [
"1. Use a dedicated email account for your Hermes agent",
"2. For Gmail: enable 2FA, then create an App Password at",
" https://myaccount.google.com/apppasswords",
"3. For other providers: use your email password or app-specific password",
"4. IMAP must be enabled on your email account",
],
"vars": [
{
"name": "EMAIL_ADDRESS",
"prompt": "Email address",
"password": False,
"help": "The email address Hermes will use (e.g., hermes@gmail.com).",
},
{
"name": "EMAIL_PASSWORD",
"prompt": "Email password (or app password)",
"password": True,
"help": "For Gmail, use an App Password (not your regular password).",
},
{
"name": "EMAIL_IMAP_HOST",
"prompt": "IMAP host",
"password": False,
"help": "e.g., imap.gmail.com for Gmail, outlook.office365.com for Outlook.",
},
{
"name": "EMAIL_SMTP_HOST",
"prompt": "SMTP host",
"password": False,
"help": "e.g., smtp.gmail.com for Gmail, smtp.office365.com for Outlook.",
},
{
"name": "EMAIL_ALLOWED_USERS",
"prompt": "Allowed sender emails (comma-separated)",
"password": False,
"is_allowlist": True,
"help": "Only emails from these addresses will be processed.",
},
],
},
{
"key": "sms",
"label": "SMS (Twilio)",
"emoji": "📱",
"token_var": "TWILIO_ACCOUNT_SID",
"setup_instructions": [
"1. Create a Twilio account at https://www.twilio.com/",
"2. Get your Account SID and Auth Token from the Twilio Console dashboard",
"3. Buy or configure a phone number capable of sending SMS",
"4. Set up your webhook URL for inbound SMS:",
" Twilio Console → Phone Numbers → Active Numbers → your number",
" → Messaging → A MESSAGE COMES IN → Webhook → https://your-server:8080/webhooks/twilio",
],
"vars": [
{
"name": "TWILIO_ACCOUNT_SID",
"prompt": "Twilio Account SID",
"password": False,
"help": "Found on the Twilio Console dashboard.",
},
{
"name": "TWILIO_AUTH_TOKEN",
"prompt": "Twilio Auth Token",
"password": True,
"help": "Found on the Twilio Console dashboard (click to reveal).",
},
{
"name": "TWILIO_PHONE_NUMBER",
"prompt": "Twilio phone number (E.164 format, e.g. +15551234567)",
"password": False,
"help": "The Twilio phone number to send SMS from.",
},
{
"name": "SMS_ALLOWED_USERS",
"prompt": "Allowed phone numbers (comma-separated, E.164 format)",
"password": False,
"is_allowlist": True,
"help": "Only messages from these phone numbers will be processed.",
},
{
"name": "SMS_HOME_CHANNEL",
"prompt": "Home channel phone number (for cron/notification delivery, or empty)",
"password": False,
"help": "Phone number to deliver cron job results and notifications to.",
},
],
},
{
"key": "dingtalk",
"label": "DingTalk",
"emoji": "💬",
"token_var": "DINGTALK_CLIENT_ID",
"setup_instructions": [
"1. Go to https://open-dev.dingtalk.com → Create Application",
"2. Under 'Credentials', copy the AppKey (Client ID) and AppSecret (Client Secret)",
"3. Enable 'Stream Mode' under the bot settings",
"4. Add the bot to a group chat or message it directly",
],
"vars": [
{
"name": "DINGTALK_CLIENT_ID",
"prompt": "AppKey (Client ID)",
"password": False,
"help": "The AppKey from your DingTalk application credentials.",
},
{
"name": "DINGTALK_CLIENT_SECRET",
"prompt": "AppSecret (Client Secret)",
"password": True,
"help": "The AppSecret from your DingTalk application credentials.",
},
],
},
{
"key": "feishu",
"label": "Feishu / Lark",
"emoji": "🪽",
"token_var": "FEISHU_APP_ID",
"setup_instructions": [
"1. Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)",
"2. Create an app and copy the App ID and App Secret",
"3. Enable the Bot capability for the app",
"4. Choose WebSocket (recommended) or Webhook connection mode",
"5. Add the bot to a group chat or message it directly",
"6. Restrict access with FEISHU_ALLOWED_USERS for production use",
],
"vars": [
{
"name": "FEISHU_APP_ID",
"prompt": "App ID",
"password": False,
"help": "The App ID from your Feishu/Lark application.",
},
{
"name": "FEISHU_APP_SECRET",
"prompt": "App Secret",
"password": True,
"help": "The App Secret from your Feishu/Lark application.",
},
{
"name": "FEISHU_DOMAIN",
"prompt": "Domain — feishu or lark (default: feishu)",
"password": False,
"help": "Use 'feishu' for Feishu China, or 'lark' for Lark international.",
},
{
"name": "FEISHU_CONNECTION_MODE",
"prompt": "Connection mode — websocket or webhook (default: websocket)",
"password": False,
"help": "websocket is recommended unless you specifically need webhook mode.",
},
{
"name": "FEISHU_ALLOWED_USERS",
"prompt": "Allowed user IDs (comma-separated, or empty)",
"password": False,
"is_allowlist": True,
"help": "Restrict which Feishu/Lark users can interact with the bot.",
},
{
"name": "FEISHU_HOME_CHANNEL",
"prompt": "Home chat ID (optional, for cron/notifications)",
"password": False,
"help": "Chat ID for scheduled results and notifications.",
},
],
},
{
"key": "wecom",
"label": "WeCom (Enterprise WeChat)",
"emoji": "💬",
"token_var": "WECOM_BOT_ID",
"setup_instructions": [
"1. Go to WeCom Admin Console → Applications → Create AI Bot",
"2. Copy the Bot ID and Secret from the bot's credentials page",
"3. The bot connects via WebSocket — no public endpoint needed",
"4. Add the bot to a group chat or message it directly in WeCom",
"5. Restrict access with WECOM_ALLOWED_USERS for production use",
],
"vars": [
{
"name": "WECOM_BOT_ID",
"prompt": "Bot ID",
"password": False,
"help": "The Bot ID from your WeCom AI Bot.",
},
{
"name": "WECOM_SECRET",
"prompt": "Secret",
"password": True,
"help": "The secret from your WeCom AI Bot.",
},
{
"name": "WECOM_ALLOWED_USERS",
"prompt": "Allowed user IDs (comma-separated, or empty)",
"password": False,
"is_allowlist": True,
"help": "Restrict which WeCom users can interact with the bot.",
},
{
"name": "WECOM_HOME_CHANNEL",
"prompt": "Home chat ID (optional, for cron/notifications)",
"password": False,
"help": "Chat ID for scheduled results and notifications.",
},
],
},
{
"key": "wecom_callback",
"label": "WeCom Callback (Self-Built App)",
"emoji": "💬",
"token_var": "WECOM_CALLBACK_CORP_ID",
"setup_instructions": [
"1. Go to WeCom Admin Console → Applications → Create Self-Built App",
"2. Note the Corp ID (top of admin console) and create a Corp Secret",
"3. Under Receive Messages, configure the callback URL to point to your server",
"4. Copy the Token and EncodingAESKey from the callback configuration",
"5. The adapter runs an HTTP server — ensure the port is reachable from WeCom",
"6. Restrict access with WECOM_CALLBACK_ALLOWED_USERS for production use",
],
"vars": [
{
"name": "WECOM_CALLBACK_CORP_ID",
"prompt": "Corp ID",
"password": False,
"help": "Your WeCom enterprise Corp ID.",
},
{
"name": "WECOM_CALLBACK_CORP_SECRET",
"prompt": "Corp Secret",
"password": True,
"help": "The secret for your self-built application.",
},
{
"name": "WECOM_CALLBACK_AGENT_ID",
"prompt": "Agent ID",
"password": False,
"help": "The Agent ID of your self-built application.",
},
{
"name": "WECOM_CALLBACK_TOKEN",
"prompt": "Callback Token",
"password": True,
"help": "The Token from your WeCom callback configuration.",
},
{
"name": "WECOM_CALLBACK_ENCODING_AES_KEY",
"prompt": "Encoding AES Key",
"password": True,
"help": "The EncodingAESKey from your WeCom callback configuration.",
},
{
"name": "WECOM_CALLBACK_PORT",
"prompt": "Callback server port (default: 8645)",
"password": False,
"help": "Port for the HTTP callback server.",
},
{
"name": "WECOM_CALLBACK_ALLOWED_USERS",
"prompt": "Allowed user IDs (comma-separated, or empty)",
"password": False,
"is_allowlist": True,
"help": "Restrict which WeCom users can interact with the app.",
},
],
},
# Email and SMS moved to plugins/platforms/{email,sms}/ — setup metadata
# discovered dynamically via the platform registry entries registered by
# plugins/platforms/{email,sms}/adapter.py::register(). #41112.
{
"key": "weixin",
"label": "Weixin / WeChat",
@ -4835,6 +4448,11 @@ def _all_platforms() -> list[dict]:
for entry in platform_registry.all_entries():
if entry.name in by_key:
continue # built-in already covers it
# Drop platforms that can't function on this host. Matrix is hidden on
# Windows (python-olm has no Windows wheel) — applies whether matrix is
# a built-in or, post-#41112, a registry-discovered plugin.
if sys.platform == "win32" and entry.name == "matrix":
continue
platforms.append(
{
"key": entry.name,
@ -5122,197 +4740,13 @@ def _setup_standard_platform(platform: dict):
print_success(f"{emoji} {label} configured!")
def _setup_whatsapp():
"""Delegate to the existing WhatsApp setup flow."""
from hermes_cli.main import cmd_whatsapp
import argparse
cmd_whatsapp(argparse.Namespace())
# _setup_whatsapp and _setup_dingtalk moved into their plugins:
# plugins/platforms/{whatsapp,dingtalk}/adapter.py::interactive_setup
# (registered via setup_fn, dispatched through the plugin path). #41112.
def _setup_dingtalk():
"""Configure DingTalk — QR scan (recommended) or manual credential entry."""
from hermes_cli.setup import (
prompt_choice,
prompt_yes_no,
print_success,
print_warning,
)
dingtalk_platform = next(p for p in _PLATFORMS if p["key"] == "dingtalk")
emoji = dingtalk_platform["emoji"]
label = dingtalk_platform["label"]
print()
print(color(f" ─── {emoji} {label} Setup ───", Colors.CYAN))
existing = get_env_value("DINGTALK_CLIENT_ID")
if existing:
print()
print_success(f"{label} is already configured (Client ID: {existing}).")
if not prompt_yes_no(f" Reconfigure {label}?", False):
return
print()
method = prompt_choice(
" Choose setup method",
[
"QR Code Scan (Recommended, auto-obtain Client ID and Client Secret)",
"Manual Input (Client ID and Client Secret)",
],
default=0,
)
if method == 0:
# ── QR-code device-flow authorization ──
try:
from hermes_cli.dingtalk_auth import dingtalk_qr_auth
except ImportError as exc:
print_warning(
f" QR auth module failed to load ({exc}), falling back to manual input."
)
_setup_standard_platform(dingtalk_platform)
return
result = dingtalk_qr_auth()
if result is None:
print_warning(" QR auth incomplete, falling back to manual input.")
_setup_standard_platform(dingtalk_platform)
return
client_id, client_secret = result
save_env_value("DINGTALK_CLIENT_ID", client_id)
save_env_value("DINGTALK_CLIENT_SECRET", client_secret)
print()
print_success(f"{emoji} {label} configured via QR scan!")
else:
# ── Manual entry ──
_setup_standard_platform(dingtalk_platform)
def _setup_wecom():
"""Interactive setup for WeCom — scan QR code or manual credential input."""
print()
print(color(" ─── 💬 WeCom (Enterprise WeChat) Setup ───", Colors.CYAN))
existing_bot_id = get_env_value("WECOM_BOT_ID")
existing_secret = get_env_value("WECOM_SECRET")
if existing_bot_id and existing_secret:
print()
print_success("WeCom is already configured.")
if not prompt_yes_no(" Reconfigure WeCom?", False):
return
# ── Choose setup method ──
print()
method_choices = [
"Scan QR code to obtain Bot ID and Secret automatically (recommended)",
"Enter existing Bot ID and Secret manually",
]
method_idx = prompt_choice(
" How would you like to set up WeCom?", method_choices, 0
)
bot_id = None
secret = None
if method_idx == 0:
# ── QR scan flow ──
try:
from gateway.platforms.wecom import qr_scan_for_bot_info
except Exception as exc:
print_error(f" WeCom QR scan import failed: {exc}")
qr_scan_for_bot_info = None
if qr_scan_for_bot_info is not None:
try:
credentials = qr_scan_for_bot_info()
except KeyboardInterrupt:
print()
print_warning(" WeCom setup cancelled.")
return
except Exception as exc:
print_warning(f" QR scan failed: {exc}")
credentials = None
if credentials:
bot_id = credentials.get("bot_id", "")
secret = credentials.get("secret", "")
print_success(" ✔ QR scan successful! Bot ID and Secret obtained.")
if not bot_id or not secret:
print_info(" QR scan did not complete. Continuing with manual input.")
bot_id = None
secret = None
# ── Manual credential input ──
if not bot_id or not secret:
print()
print_info(
" 1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots"
)
print_info(" 2. Select API Mode")
print_info(" 3. Copy the Bot ID and Secret from the bot's credentials info")
print_info(" 4. The bot connects via WebSocket — no public endpoint needed")
print()
bot_id = prompt(" Bot ID", password=False)
if not bot_id:
print_warning(" Skipped — WeCom won't work without a Bot ID.")
return
secret = prompt(" Secret", password=True)
if not secret:
print_warning(" Skipped — WeCom won't work without a Secret.")
return
# ── Save core credentials ──
save_env_value("WECOM_BOT_ID", bot_id)
save_env_value("WECOM_SECRET", secret)
# ── Allowed users (deny-by-default security) ──
print()
print_info(" The gateway DENIES all users by default for security.")
print_info(" Enter user IDs to create an allowlist, or leave empty.")
allowed = prompt(" Allowed user IDs (comma-separated, or empty)", password=False)
if allowed:
cleaned = allowed.replace(" ", "")
save_env_value("WECOM_ALLOWED_USERS", cleaned)
print_success(" Saved — only these users can interact with the bot.")
else:
print()
access_choices = [
"Enable open access (anyone can message the bot)",
"Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')",
"Disable direct messages",
"Skip for now (bot will deny all users until configured)",
]
access_idx = prompt_choice(
" How should unauthorized users be handled?", access_choices, 1
)
if access_idx == 0:
save_env_value("WECOM_DM_POLICY", "open")
save_env_value("GATEWAY_ALLOW_ALL_USERS", "true")
print_warning(" Open access enabled — anyone can use your bot!")
elif access_idx == 1:
save_env_value("WECOM_DM_POLICY", "pairing")
print_success(
" DM pairing mode — users will receive a code to request access."
)
print_info(" Approve with: hermes pairing approve <platform> <code>")
elif access_idx == 2:
save_env_value("WECOM_DM_POLICY", "disabled")
print_warning(" Direct messages disabled.")
else:
print_info(" Skipped — configure later with 'hermes gateway setup'")
# ── Home channel (optional) ──
print()
print_info(" Chat ID for scheduled results and notifications.")
home = prompt(" Home chat ID (optional, for cron/notifications)", password=False)
if home:
save_env_value("WECOM_HOME_CHANNEL", home)
print_success(f" Home channel set to {home}")
print()
print_success("💬 WeCom configured!")
# _setup_wecom moved to plugins/platforms/wecom/adapter.py::interactive_setup
# (registered via setup_fn, dispatched through the plugin path). #41112.
def _is_service_installed() -> bool:
@ -5555,197 +4989,8 @@ def _setup_weixin():
print_info(f" User ID: {user_id}")
def _setup_feishu():
"""Interactive setup for Feishu / Lark — scan-to-create or manual credentials."""
print()
print(color(" ─── 🪽 Feishu / Lark Setup ───", Colors.CYAN))
existing_app_id = get_env_value("FEISHU_APP_ID")
existing_secret = get_env_value("FEISHU_APP_SECRET")
if existing_app_id and existing_secret:
print()
print_success("Feishu / Lark is already configured.")
if not prompt_yes_no(" Reconfigure Feishu / Lark?", False):
return
# ── Choose setup method ──
print()
method_choices = [
"Scan QR code to create a new bot automatically (recommended)",
"Enter existing App ID and App Secret manually",
]
method_idx = prompt_choice(
" How would you like to set up Feishu / Lark?", method_choices, 0
)
credentials = None
used_qr = False
if method_idx == 0:
# ── QR scan-to-create ──
try:
from gateway.platforms.feishu import qr_register
except Exception as exc:
print_error(f" Feishu / Lark onboard import failed: {exc}")
qr_register = None
if qr_register is not None:
try:
credentials = qr_register()
except KeyboardInterrupt:
print()
print_warning(" Feishu / Lark setup cancelled.")
return
except Exception as exc:
print_warning(f" QR registration failed: {exc}")
if credentials:
used_qr = True
if not credentials:
print_info(" QR setup did not complete. Continuing with manual input.")
# ── Manual credential input ──
if not credentials:
print()
print_info(
" Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)"
)
print_info(
" Create an app, enable the Bot capability, and copy the credentials."
)
print()
app_id = prompt(" App ID", password=False)
if not app_id:
print_warning(" Skipped — Feishu / Lark won't work without an App ID.")
return
app_secret = prompt(" App Secret", password=True)
if not app_secret:
print_warning(" Skipped — Feishu / Lark won't work without an App Secret.")
return
domain_choices = ["feishu (China)", "lark (International)"]
domain_idx = prompt_choice(" Domain", domain_choices, 0)
domain = "lark" if domain_idx == 1 else "feishu"
# Try to probe the bot with manual credentials
bot_name = None
try:
from gateway.platforms.feishu import probe_bot
bot_info = probe_bot(app_id, app_secret, domain)
if bot_info:
bot_name = bot_info.get("bot_name")
print_success(f" Credentials verified — bot: {bot_name or 'unnamed'}")
else:
print_warning(
" Could not verify bot connection. Credentials saved anyway."
)
except Exception as exc:
print_warning(f" Credential verification skipped: {exc}")
credentials = {
"app_id": app_id,
"app_secret": app_secret,
"domain": domain,
"open_id": None,
"bot_name": bot_name,
}
# ── Save core credentials ──
app_id = credentials["app_id"]
app_secret = credentials["app_secret"]
domain = credentials.get("domain", "feishu")
open_id = credentials.get("open_id")
bot_name = credentials.get("bot_name")
save_env_value("FEISHU_APP_ID", app_id)
save_env_value("FEISHU_APP_SECRET", app_secret)
save_env_value("FEISHU_DOMAIN", domain)
# Bot identity is resolved at runtime via _hydrate_bot_identity().
# ── Connection mode ──
if used_qr:
connection_mode = "websocket"
else:
print()
mode_choices = [
"WebSocket (recommended — no public URL needed)",
"Webhook (requires a reachable HTTP endpoint)",
]
mode_idx = prompt_choice(" Connection mode", mode_choices, 0)
connection_mode = "webhook" if mode_idx == 1 else "websocket"
if connection_mode == "webhook":
print_info(" Webhook defaults: 127.0.0.1:8765/feishu/webhook")
print_info(
" Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH"
)
print_info(
" For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN"
)
save_env_value("FEISHU_CONNECTION_MODE", connection_mode)
if bot_name:
print()
print_success(f" Bot created: {bot_name}")
# ── DM security policy ──
print()
access_choices = [
"Use DM pairing approval (recommended)",
"Allow all direct messages",
"Only allow listed user IDs",
]
access_idx = prompt_choice(
" How should direct messages be authorized?", access_choices, 0
)
if access_idx == 0:
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
save_env_value("FEISHU_ALLOWED_USERS", "")
print_success(" DM pairing enabled.")
print_info(
" Unknown users can request access; approve with `hermes pairing approve`."
)
elif access_idx == 1:
save_env_value("FEISHU_ALLOW_ALL_USERS", "true")
save_env_value("FEISHU_ALLOWED_USERS", "")
print_warning(" Open DM access enabled for Feishu / Lark.")
else:
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
default_allow = open_id or ""
allowlist = prompt(
" Allowed user IDs (comma-separated)", default_allow, password=False
).replace(" ", "")
save_env_value("FEISHU_ALLOWED_USERS", allowlist)
print_success(" Allowlist saved.")
# ── Group policy ──
print()
group_choices = [
"Respond only when @mentioned in groups (recommended)",
"Disable group chats",
]
group_idx = prompt_choice(" How should group chats be handled?", group_choices, 0)
if group_idx == 0:
save_env_value("FEISHU_GROUP_POLICY", "open")
print_info(" Group chats enabled (bot must be @mentioned).")
else:
save_env_value("FEISHU_GROUP_POLICY", "disabled")
print_info(" Group chats disabled.")
# ── Home channel ──
print()
home_channel = prompt(
" Home chat ID (optional, for cron/notifications)", password=False
)
if home_channel:
save_env_value("FEISHU_HOME_CHANNEL", home_channel)
print_success(f" Home channel set to {home_channel}")
print()
print_success("🪽 Feishu / Lark configured!")
print_info(f" App ID: {app_id}")
print_info(f" Domain: {domain}")
if bot_name:
print_info(f" Bot: {bot_name}")
# _setup_feishu moved to plugins/platforms/feishu/adapter.py::interactive_setup
# (registered via setup_fn, dispatched through the plugin path). #41112.
def _setup_qqbot():
@ -6014,23 +5259,31 @@ def _builtin_setup_fn(key: str):
from hermes_cli import setup as _s
return {
"telegram": _s._setup_telegram,
# telegram moved into the plugin: setup_fn registered by
# plugins/platforms/telegram/adapter.py::register(). #41112.
# discord moved into the plugin: setup_fn is registered by
# plugins/platforms/discord/adapter.py::register() and dispatched
# via the plugin path in _configure_platform().
"slack": _s._setup_slack,
"matrix": _s._setup_matrix,
# slack moved into the plugin: setup_fn is registered by
# plugins/platforms/slack/adapter.py::register() and dispatched
# via the plugin path in _configure_platform(). #41112.
# matrix moved into the plugin: setup_fn registered by
# plugins/platforms/matrix/adapter.py::register() and dispatched via
# the plugin path in _configure_platform(). #41112.
# mattermost moved into the plugin: setup_fn is registered by
# plugins/platforms/mattermost/adapter.py::register() and dispatched
# via the plugin path in _configure_platform().
"bluebubbles": _s._setup_bluebubbles,
"webhooks": _s._setup_webhooks,
"signal": _setup_signal,
"whatsapp": _setup_whatsapp,
# whatsapp + dingtalk moved into plugins: setup_fn registered by
# plugins/platforms/{whatsapp,dingtalk}/adapter.py::register() and
# dispatched via the plugin path in _configure_platform(). #41112.
"weixin": _setup_weixin,
"dingtalk": _setup_dingtalk,
"feishu": _setup_feishu,
"wecom": _setup_wecom,
# feishu moved into the plugin: setup_fn registered by
# plugins/platforms/feishu/adapter.py::register(). #41112.
# wecom moved into the plugin: setup_fn registered by
# plugins/platforms/wecom/adapter.py::register(). #41112.
"qqbot": _setup_qqbot,
}.get(key)

View file

@ -1800,231 +1800,13 @@ def _setup_telegram():
save_env_value("TELEGRAM_HOME_CHANNEL", home_channel)
def _setup_slack():
"""Configure Slack bot credentials."""
print_header("Slack")
existing = get_env_value("SLACK_BOT_TOKEN")
if existing:
print_info("Slack: already configured")
if not prompt_yes_no("Reconfigure Slack?", False):
# Even without reconfiguring, offer to refresh the manifest so
# new commands (e.g. /btw, /stop, ...) get registered in Slack.
if prompt_yes_no(
"Regenerate the Slack app manifest with the latest command "
"list? (recommended after `hermes update`)",
True,
):
_write_slack_manifest_and_instruct()
return
print_info("Steps to create a Slack app:")
print_info(" 1. Go to https://api.slack.com/apps → Create New App")
print_info(" Pick 'From an app manifest' — we'll generate one for you below.")
print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable")
print_info(" • Create an App-Level Token with 'connections:write' scope")
print_info(" 3. Install to Workspace: Settings → Install App")
print_info(" 4. After installing, invite the bot to channels: /invite @YourBot")
print()
print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/")
print()
# Generate and write manifest up-front so the user can paste it into
# the "Create from manifest" flow instead of clicking through scopes /
# events / slash commands one at a time.
_write_slack_manifest_and_instruct()
print()
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
if not bot_token:
return
save_env_value("SLACK_BOT_TOKEN", bot_token)
app_token = prompt("Slack App Token (xapp-...)", password=True)
if app_token:
save_env_value("SLACK_APP_TOKEN", app_token)
print_success("Slack tokens saved")
print()
print_info("🔒 Security: Restrict who can use your bot")
print_info(" To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID")
print()
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty to deny everyone except paired users)"
)
if allowed_users:
save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("Slack allowlist configured")
else:
print_warning("⚠️ No Slack allowlist set - unpaired users will be denied by default.")
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
print()
print_info("📬 Home Channel: where Hermes delivers cron job results,")
print_info(" cross-platform messages, and notifications.")
print_info(" To get a channel ID: open the channel in Slack, then right-click")
print_info(" the channel name → Copy link — the ID starts with C (e.g. C01ABC2DE3F).")
print_info(" You can also set this later by typing /set-home in a Slack channel.")
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
if home_channel:
save_env_value("SLACK_HOME_CHANNEL", home_channel.strip())
# _setup_slack and _write_slack_manifest_and_instruct moved to the slack
# plugin: plugins/platforms/slack/adapter.py::interactive_setup (registered
# via setup_fn and dispatched through the plugin path). #41112 / #3823.
def _write_slack_manifest_and_instruct():
"""Generate the Slack manifest, write it under HERMES_HOME, and print
paste-into-Slack instructions.
Exposed as its own helper so both the initial setup flow and the
"reconfigure? → no" branch can refresh the manifest without the user
re-entering tokens. Failures are non-fatal if the manifest write
fails for any reason, we print a warning and skip rather than abort
the whole Slack setup.
"""
try:
from hermes_cli.slack_cli import _build_full_manifest
from hermes_constants import get_hermes_home
manifest = _build_full_manifest(
bot_name="Hermes",
bot_description="Your Hermes agent on Slack",
)
target = Path(get_hermes_home()) / "slack-manifest.json"
target.parent.mkdir(parents=True, exist_ok=True)
import json as _json
target.write_text(
_json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print_success(f"Slack app manifest written to: {target}")
print_info(
" Paste it into https://api.slack.com/apps → your app → Features "
"→ App Manifest → Edit, then Save. Slack will prompt to "
"reinstall if scopes or slash commands changed."
)
print_info(
" Re-run `hermes slack manifest --write` anytime to refresh after "
"Hermes adds new commands."
)
except Exception as exc: # pragma: no cover - best-effort UX helper
print_warning(f"Couldn't write Slack manifest: {exc}")
print_info(
" You can generate it manually later with: "
"hermes slack manifest --write"
)
def _setup_matrix():
"""Configure Matrix credentials."""
print_header("Matrix")
existing = get_env_value("MATRIX_ACCESS_TOKEN") or get_env_value("MATRIX_PASSWORD")
if existing:
print_info("Matrix: already configured")
if not prompt_yes_no("Reconfigure Matrix?", False):
return
print_info("Works with any Matrix homeserver (Synapse, Conduit, Dendrite, or matrix.org).")
print_info(" 1. Create a bot user on your homeserver, or use your own account")
print_info(" 2. Get an access token from Element, or provide user ID + password")
print()
homeserver = prompt("Homeserver URL (e.g. https://matrix.example.org)")
if homeserver:
save_env_value("MATRIX_HOMESERVER", homeserver.rstrip("/"))
print()
print_info("Auth: provide an access token (recommended), or user ID + password.")
token = prompt("Access token (leave empty for password login)", password=True)
if token:
save_env_value("MATRIX_ACCESS_TOKEN", token)
user_id = prompt("User ID (@bot:server — optional, will be auto-detected)")
if user_id:
save_env_value("MATRIX_USER_ID", user_id)
print_success("Matrix access token saved")
else:
user_id = prompt("User ID (@bot:server)")
if user_id:
save_env_value("MATRIX_USER_ID", user_id)
password = prompt("Password", password=True)
if password:
save_env_value("MATRIX_PASSWORD", password)
print_success("Matrix credentials saved")
if token or get_env_value("MATRIX_PASSWORD"):
print()
want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False)
if want_e2ee:
save_env_value("MATRIX_ENCRYPTION", "true")
print_success("E2EE enabled")
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
# Use the central lazy-deps feature group so we install ALL of
# platform.matrix's dependencies (mautrix, Markdown, aiosqlite,
# asyncpg, aiohttp-socks) — not just mautrix itself. The previous
# hand-rolled ``pip install mautrix[encryption]`` left asyncpg /
# aiosqlite uninstalled and broke E2EE connect with
# ``No module named 'asyncpg'`` on every fresh install (#31116).
try:
from tools.lazy_deps import ensure as _lazy_ensure, feature_missing
_missing_before = feature_missing("platform.matrix")
if _missing_before:
print_info(
f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)..."
)
try:
_lazy_ensure("platform.matrix", prompt=False)
print_success(f"{matrix_pkg} installed")
except Exception as exc:
print_warning(
f"Install failed — run manually: pip install "
f"'mautrix[encryption]' asyncpg aiosqlite Markdown "
f"aiohttp-socks"
)
print_info(f" Error: {exc}")
except ImportError:
# tools.lazy_deps unavailable (extreme edge case — partial
# install). Fall back to the legacy single-package install
# path so the wizard still does *something*.
try:
__import__("mautrix")
except ImportError:
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install "
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
)
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
print()
print_info("🔒 Security: Restrict who can use your bot")
print_info(" Matrix user IDs look like @username:server")
print()
allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)")
if allowed_users:
save_env_value("MATRIX_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("Matrix allowlist configured")
else:
print_info("⚠️ No allowlist set - anyone who can message the bot can use it!")
print()
print_info("📬 Home Room: where Hermes delivers cron job results and notifications.")
print_info(" Room IDs look like !abc123:server (shown in Element room settings)")
print_info(" You can also set this later by typing /set-home in a Matrix room.")
home_room = prompt("Home room ID (leave empty to set later with /set-home)")
if home_room:
save_env_value("MATRIX_HOME_ROOM", home_room)
# _setup_matrix moved to plugins/platforms/matrix/adapter.py::interactive_setup
# (registered via setup_fn, dispatched through the plugin path). #41112.
def _setup_bluebubbles():

View file

@ -210,7 +210,11 @@ class _ComponentFilter(logging.Filter):
# Logger name prefixes that belong to each component.
# Used by _ComponentFilter and exposed for ``hermes logs --component``.
COMPONENT_PREFIXES = {
"gateway": ("gateway", "hermes_plugins"),
# ``plugins.platforms`` covers messaging-platform adapters that migrated
# out of ``gateway/platforms/`` into bundled plugins (#41112) — they are
# still gateway components and their logs belong in gateway.log / match
# ``hermes logs --component gateway``.
"gateway": ("gateway", "hermes_plugins", "plugins.platforms"),
"agent": ("agent", "run_agent", "model_tools", "batch_runner"),
"tools": ("tools",),
"cli": ("hermes_cli", "cli"),

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -42,7 +42,7 @@ try:
from dingtalk_stream.frames import CallbackMessage, AckMessage
DINGTALK_STREAM_AVAILABLE = True
except ImportError:
except Exception: # noqa: BLE001 — broad: optional SDK's transitive deps (cryptography) may raise non-ImportError; degrade gracefully (#41112)
DINGTALK_STREAM_AVAILABLE = False
dingtalk_stream = None # type: ignore[assignment]
ChatbotMessage = None # type: ignore[assignment]
@ -64,7 +64,14 @@ except ImportError:
HTTPX_AVAILABLE = False
httpx = None # type: ignore[assignment]
# Card SDK for AI Cards (following QwenPaw pattern)
# Card SDK for AI Cards (following QwenPaw pattern).
# Catch broad Exception, not just ImportError: the alibabacloud_dingtalk SDK
# transitively imports cryptography and can raise AttributeError (not
# ImportError) when the installed cryptography version skews from what the SDK
# expects (e.g. `cryptography.utils.DeprecatedIn46` missing on older
# cryptography). An optional SDK with a broken dependency chain must degrade
# gracefully — same as a missing one — rather than crash the whole adapter
# (and therefore the whole plugin) import. #41112.
try:
from alibabacloud_dingtalk.card_1_0 import (
client as dingtalk_card_client,
@ -78,7 +85,7 @@ try:
from alibabacloud_tea_util import models as tea_util_models
CARD_SDK_AVAILABLE = True
except ImportError:
except Exception:
CARD_SDK_AVAILABLE = False
dingtalk_card_client = None
dingtalk_card_models = None
@ -129,7 +136,7 @@ def check_dingtalk_requirements() -> bool:
from dingtalk_stream import ChatbotMessage as _CM
from dingtalk_stream.frames import CallbackMessage as _CBM, AckMessage as _AM
import httpx as _httpx
except ImportError:
except Exception:
return False
dingtalk_stream = _ds
ChatbotMessage = _CM
@ -1501,3 +1508,200 @@ class _IncomingHandler(
logger.exception(
"[%s] Error processing incoming message", self._adapter.name
)
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Added when the DingTalk adapter moved from gateway/platforms/dingtalk.py into
# this bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
# register(ctx) entry point plus hook implementations that replace the
# per-platform core touchpoints (the Platform.DINGTALK elif in gateway/run.py,
# the dingtalk_cfg YAML→env block + _PLATFORM_CONNECTED_CHECKERS entry in
# gateway/config.py, the _setup_dingtalk wizard + _PLATFORMS["dingtalk"] static
# dict in hermes_cli/gateway.py, and the _send_dingtalk dispatch in
# tools/send_message_tool.py).
# ──────────────────────────────────────────────────────────────────────────
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process DingTalk delivery via a static robot webhook URL.
Implements the standalone_sender_fn contract so deliver=dingtalk cron jobs
succeed when cron runs separately from the gateway. The live adapter uses
per-session webhook URLs from incoming messages, which aren't available
out-of-process; this path uses the static DINGTALK_WEBHOOK_URL / extra
webhook_url instead. Replaces the legacy _send_dingtalk helper.
"""
extra = getattr(pconfig, "extra", {}) or {}
try:
import httpx
except ImportError:
return {"error": "httpx not installed"}
try:
webhook_url = extra.get("webhook_url") or os.getenv("DINGTALK_WEBHOOK_URL", "")
if not webhook_url:
return {"error": "DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config."}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
webhook_url,
json={"msgtype": "text", "text": {"content": message}},
)
resp.raise_for_status()
data = resp.json()
if data.get("errcode", 0) != 0:
return {"error": f"DingTalk API error: {data.get('errmsg', 'unknown')}"}
return {"success": True, "platform": "dingtalk", "chat_id": chat_id}
except Exception as e:
# Redact the access_token from webhook URLs that may appear in the
# exception text. Reuse send_message_tool._error's redaction so the
# logic stays single-sourced (lazy import avoids a circular at module
# load). Falls back to a plain message if that helper is unavailable.
try:
from tools.send_message_tool import _error as _redact_error
return _redact_error(f"DingTalk send failed: {e}")
except Exception:
return {"error": f"DingTalk send failed: {e}"}
def interactive_setup() -> None:
"""Configure DingTalk — QR scan (recommended) or manual credential entry.
Replaces hermes_cli/setup.py-era _setup_dingtalk + the static
_PLATFORMS["dingtalk"] dict in hermes_cli/gateway.py. CLI helpers are
lazy-imported so the plugin's module-load surface stays minimal.
"""
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.setup import prompt_choice
from hermes_cli.cli_output import (
prompt,
prompt_yes_no,
print_header,
print_success,
print_warning,
)
print_header("DingTalk")
existing = get_env_value("DINGTALK_CLIENT_ID")
if existing:
print_success(f"DingTalk is already configured (Client ID: {existing}).")
if not prompt_yes_no("Reconfigure DingTalk?", False):
return
method = prompt_choice(
"Choose setup method",
[
"QR Code Scan (Recommended, auto-obtain Client ID and Client Secret)",
"Manual Input (Client ID and Client Secret)",
],
default=0,
)
if method == 0:
try:
from hermes_cli.dingtalk_auth import dingtalk_qr_auth
except ImportError as exc:
print_warning(f"QR auth module failed to load ({exc}), falling back to manual input.")
_manual_credential_entry(prompt, save_env_value, print_success)
return
result = dingtalk_qr_auth()
if result is None:
print_warning("QR auth incomplete, falling back to manual input.")
_manual_credential_entry(prompt, save_env_value, print_success)
return
client_id, client_secret = result
save_env_value("DINGTALK_CLIENT_ID", client_id)
save_env_value("DINGTALK_CLIENT_SECRET", client_secret)
print_success("DingTalk configured via QR scan!")
else:
_manual_credential_entry(prompt, save_env_value, print_success)
def _manual_credential_entry(prompt, save_env_value, print_success) -> None:
client_id = prompt("DingTalk Client ID (app key)")
if not client_id:
return
save_env_value("DINGTALK_CLIENT_ID", client_id)
client_secret = prompt("DingTalk Client Secret", password=True)
if client_secret:
save_env_value("DINGTALK_CLIENT_SECRET", client_secret)
print_success("DingTalk credentials saved")
def _apply_yaml_config(yaml_cfg: dict, dingtalk_cfg: dict) -> dict | None:
"""Translate config.yaml dingtalk: keys into DINGTALK_* env vars.
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
dingtalk_cfg block from gateway/config.py::load_gateway_config(). Env vars
take precedence over YAML (each assignment guarded by not os.getenv(...)).
Returns None everything flows through env.
"""
import json as _json
if "require_mention" in dingtalk_cfg and not os.getenv("DINGTALK_REQUIRE_MENTION"):
os.environ["DINGTALK_REQUIRE_MENTION"] = str(dingtalk_cfg["require_mention"]).lower()
if "mention_patterns" in dingtalk_cfg and not os.getenv("DINGTALK_MENTION_PATTERNS"):
os.environ["DINGTALK_MENTION_PATTERNS"] = _json.dumps(dingtalk_cfg["mention_patterns"])
frc = dingtalk_cfg.get("free_response_chats")
if frc is not None and not os.getenv("DINGTALK_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["DINGTALK_FREE_RESPONSE_CHATS"] = str(frc)
ac = dingtalk_cfg.get("allowed_chats")
if ac is not None and not os.getenv("DINGTALK_ALLOWED_CHATS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["DINGTALK_ALLOWED_CHATS"] = str(ac)
allowed = dingtalk_cfg.get("allowed_users")
if allowed is not None and not os.getenv("DINGTALK_ALLOWED_USERS"):
if isinstance(allowed, list):
allowed = ",".join(str(v) for v in allowed)
os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
return None
def _is_connected(config) -> bool:
"""DingTalk is connected when client_id + client_secret are present.
Mirrors the legacy _PLATFORM_CONNECTED_CHECKERS[Platform.DINGTALK] entry.
Reads from PlatformConfig.extra first, then env vars.
"""
extra = getattr(config, "extra", {}) or {}
return bool(
(extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID"))
and (extra.get("client_secret") or os.getenv("DINGTALK_CLIENT_SECRET"))
)
def _build_adapter(config):
"""Factory wrapper that constructs DingTalkAdapter from a PlatformConfig."""
return DingTalkAdapter(config)
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="dingtalk",
label="DingTalk",
adapter_factory=_build_adapter,
check_fn=check_dingtalk_requirements,
is_connected=_is_connected,
validate_config=_is_connected,
required_env=["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"],
install_hint="pip install 'dingtalk-stream>=0.20' httpx",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="DINGTALK_ALLOWED_USERS",
allow_all_env="DINGTALK_ALLOW_ALL_USERS",
cron_deliver_env_var="DINGTALK_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
emoji="🐳",
allow_update_command=True,
)

View file

@ -0,0 +1,39 @@
name: dingtalk-platform
label: DingTalk
kind: platform
version: 1.0.0
description: >
DingTalk gateway adapter for Hermes Agent.
Connects to DingTalk via the dingtalk-stream SDK (Stream Mode) and relays
messages between DingTalk chats and the Hermes agent. Supports text, images,
audio, video, rich text, files, group @mention gating, free-response chats,
and per-user allowlists.
author: NousResearch
requires_env:
- name: DINGTALK_CLIENT_ID
description: "DingTalk app key (Client ID)"
prompt: "DingTalk Client ID (app key)"
url: "https://open-dev.dingtalk.com"
password: false
- name: DINGTALK_CLIENT_SECRET
description: "DingTalk app secret (Client Secret)"
prompt: "DingTalk Client Secret"
url: "https://open-dev.dingtalk.com"
password: true
optional_env:
- name: DINGTALK_WEBHOOK_URL
description: "Static robot webhook URL for cross-platform / cron delivery"
prompt: "DingTalk robot webhook URL (optional)"
password: false
- name: DINGTALK_ALLOWED_USERS
description: "Comma-separated staff/sender IDs allowed to talk to the bot (* = any)"
prompt: "Allowed users (comma-separated)"
password: false
- name: DINGTALK_HOME_CHANNEL
description: "Default conversation ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: DINGTALK_HOME_CHANNEL_NAME
description: "Display name for the DingTalk home channel"
prompt: "Home channel display name"
password: false

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -882,3 +882,101 @@ class EmailAdapter(BasePlatformAdapter):
"chat_id": chat_id,
"subject": ctx.get("subject", ""),
}
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Added when the Email adapter moved from gateway/platforms/email.py into this
# bundled plugin. register() exposes the platform via the registry, replacing
# the Platform.EMAIL elif in gateway/run.py, the _PLATFORM_CONNECTED_CHECKERS
# entry in gateway/config.py, the _PLATFORMS["email"] static dict in
# hermes_cli/gateway.py, and the _send_email dispatch in
# tools/send_message_tool.py. EMAIL_* env→PlatformConfig seeding stays in core.
# ──────────────────────────────────────────────────────────────────────────
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process Email delivery via SMTP (one-shot). Implements the
standalone_sender_fn contract; replaces the legacy _send_email helper."""
import smtplib
import ssl as _ssl
from email.mime.text import MIMEText
from email.utils import formatdate
extra = getattr(pconfig, "extra", {}) or {}
address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "")
password = os.getenv("EMAIL_PASSWORD", "")
smtp_host = extra.get("smtp_host") or os.getenv("EMAIL_SMTP_HOST", "")
try:
smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587"))
except (ValueError, TypeError):
smtp_port = 587
if not all([address, password, smtp_host]):
return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"}
try:
msg = MIMEText(message, "plain", "utf-8")
msg["From"] = address
msg["To"] = chat_id
msg["Subject"] = "Hermes Agent"
msg["Date"] = formatdate(localtime=True)
server = smtplib.SMTP(smtp_host, smtp_port)
server.starttls(context=_ssl.create_default_context())
server.login(address, password)
server.send_message(msg)
server.quit()
return {"success": True, "platform": "email", "chat_id": chat_id}
except Exception as e:
try:
from tools.send_message_tool import _error as _e
return _e(f"Email send failed: {e}")
except Exception:
return {"error": f"Email send failed: {e}"}
def _is_connected(config) -> bool:
"""Email is connected when an address is configured (in PlatformConfig.extra
or via EMAIL_ADDRESS). Mirrors the legacy
_PLATFORM_CONNECTED_CHECKERS[Platform.EMAIL] = bool(extra.get('address'))."""
extra = getattr(config, "extra", {}) or {}
if extra.get("address"):
return True
import hermes_cli.gateway as gateway_mod
return bool((gateway_mod.get_env_value("EMAIL_ADDRESS") or "").strip())
def _build_adapter(config):
"""Factory wrapper that constructs EmailAdapter from a PlatformConfig."""
return EmailAdapter(config)
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="email",
label="Email",
adapter_factory=_build_adapter,
check_fn=check_email_requirements,
is_connected=_is_connected,
required_env=["EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_SMTP_HOST"],
install_hint="Email uses the Python stdlib (smtplib/imaplib) — no extra deps",
allowed_users_env="EMAIL_ALLOWED_USERS",
allow_all_env="EMAIL_ALLOW_ALL_USERS",
cron_deliver_env_var="EMAIL_HOME_ADDRESS",
standalone_sender_fn=_standalone_send,
max_message_length=50_000,
pii_safe=True,
emoji="📧",
allow_update_command=True,
)

View file

@ -0,0 +1,39 @@
name: email-platform
label: Email
kind: platform
version: 1.0.0
description: >
Email gateway adapter for Hermes Agent. Polls an IMAP mailbox for inbound
messages and replies over SMTP, relaying email threads to and from the
Hermes agent.
author: NousResearch
requires_env:
- name: EMAIL_ADDRESS
description: "Email account address"
prompt: "Email address"
password: false
- name: EMAIL_PASSWORD
description: "Email account password / app password"
prompt: "Email password"
password: true
- name: EMAIL_SMTP_HOST
description: "SMTP host (e.g. smtp.gmail.com)"
prompt: "SMTP host"
password: false
optional_env:
- name: EMAIL_SMTP_PORT
description: "SMTP port (default 587)"
prompt: "SMTP port"
password: false
- name: EMAIL_IMAP_HOST
description: "IMAP host for inbound polling (e.g. imap.gmail.com)"
prompt: "IMAP host"
password: false
- name: EMAIL_ALLOWED_USERS
description: "Comma-separated email addresses allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: EMAIL_HOME_ADDRESS
description: "Default address for cron / notification delivery"
prompt: "Home address"
password: false

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -2469,7 +2469,7 @@ class FeishuAdapter(BasePlatformAdapter):
logging, and reaction. Scheduling follows the same
``run_coroutine_threadsafe`` pattern used by ``_on_message_event``.
"""
from gateway.platforms.feishu_comment import handle_drive_comment_event
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
loop = self._loop
if not self._loop_accepts_callbacks(loop):
@ -2482,7 +2482,7 @@ class FeishuAdapter(BasePlatformAdapter):
def _on_meeting_invited_event(self, data: Any) -> None:
"""Handle VC bot meeting invitation notification (vc.bot.meeting_invited_v1)."""
from gateway.platforms.feishu_meeting_invite import handle_meeting_invited_event
from plugins.platforms.feishu.feishu_meeting_invite import handle_meeting_invited_event
loop = self._loop
if not self._loop_accepts_callbacks(loop):
@ -5211,3 +5211,301 @@ def _qr_register_inner(
result["bot_open_id"] = None
return result
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Added when the Feishu adapter (+ its feishu_comment / feishu_comment_rules /
# feishu_meeting_invite satellites) moved from gateway/platforms/ into this
# bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
# register(ctx) entry point plus hook implementations that replace the
# per-platform core touchpoints (the Platform.FEISHU elif in gateway/run.py,
# the feishu_cfg YAML→env block + _PLATFORM_CONNECTED_CHECKERS entry in
# gateway/config.py, the _setup_feishu wizard + _PLATFORMS["feishu"] static
# dict in hermes_cli/gateway.py, and the _send_feishu dispatch in
# tools/send_message_tool.py).
# ──────────────────────────────────────────────────────────────────────────
_MIGRATION_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
_MIGRATION_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
_MIGRATION_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
_MIGRATION_VOICE_EXTS = {".ogg", ".opus"}
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process Feishu/Lark delivery via the adapter's send pipeline.
Implements the standalone_sender_fn contract so deliver=feishu cron jobs
succeed when cron runs separately from the gateway. Builds a transient
FeishuAdapter, hydrates its lark client, and sends text + native media
(images, video, voice, documents). Replaces the legacy _send_feishu helper.
"""
if not FEISHU_AVAILABLE:
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
media_files = media_files or []
try:
adapter = FeishuAdapter(pconfig)
domain_name = getattr(adapter, "_domain_name", "feishu")
domain = FEISHU_DOMAIN if domain_name != "lark" else LARK_DOMAIN
adapter._client = adapter._build_lark_client(domain)
metadata = {"thread_id": thread_id} if thread_id else None
last_result = None
if message.strip():
last_result = await adapter.send(chat_id, message, metadata=metadata)
if not last_result.success:
return {"error": f"Feishu send failed: {last_result.error}"}
for media_path, is_voice in media_files:
if not os.path.exists(media_path):
return {"error": f"Media file not found: {media_path}"}
ext = os.path.splitext(media_path)[1].lower()
if ext in _MIGRATION_IMAGE_EXTS:
last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata)
elif ext in _MIGRATION_VIDEO_EXTS:
last_result = await adapter.send_video(chat_id, media_path, metadata=metadata)
elif ext in _MIGRATION_VOICE_EXTS and is_voice:
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
elif ext in _MIGRATION_AUDIO_EXTS:
last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata)
else:
last_result = await adapter.send_document(chat_id, media_path, metadata=metadata)
if not last_result.success:
return {"error": f"Feishu media send failed: {last_result.error}"}
if last_result is None:
return {"error": "No deliverable text or media remained after processing MEDIA tags"}
return {
"success": True,
"platform": "feishu",
"chat_id": chat_id,
"message_id": last_result.message_id,
}
except Exception as e:
return {"error": f"Feishu send failed: {e}"}
def interactive_setup() -> None:
"""Interactive setup for Feishu / Lark — scan-to-create or manual creds.
Replaces the central _setup_feishu in hermes_cli/gateway.py and the static
_PLATFORMS["feishu"] dict. CLI helpers are lazy-imported.
"""
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.setup import prompt_choice
from hermes_cli.cli_output import (
prompt,
prompt_yes_no,
print_header,
print_info,
print_success,
print_warning,
print_error,
)
print_header("Feishu / Lark")
existing_app_id = get_env_value("FEISHU_APP_ID")
existing_secret = get_env_value("FEISHU_APP_SECRET")
if existing_app_id and existing_secret:
print_success("Feishu / Lark is already configured.")
if not prompt_yes_no("Reconfigure Feishu / Lark?", False):
return
method_idx = prompt_choice(
"How would you like to set up Feishu / Lark?",
[
"Scan QR code to create a new bot automatically (recommended)",
"Enter existing App ID and App Secret manually",
],
0,
)
credentials = None
used_qr = False
if method_idx == 0:
try:
credentials = qr_register()
except KeyboardInterrupt:
print_warning("Feishu / Lark setup cancelled.")
return
except Exception as exc:
print_warning(f"QR registration failed: {exc}")
if credentials:
used_qr = True
else:
print_info("QR setup did not complete. Continuing with manual input.")
if not credentials:
print_info("Go to https://open.feishu.cn/ (or https://open.larksuite.com/ for Lark)")
print_info("Create an app, enable the Bot capability, and copy the credentials.")
app_id = prompt("App ID", password=False)
if not app_id:
print_warning("Skipped — Feishu / Lark won't work without an App ID.")
return
app_secret = prompt("App Secret", password=True)
if not app_secret:
print_warning("Skipped — Feishu / Lark won't work without an App Secret.")
return
domain_idx = prompt_choice("Domain", ["feishu (China)", "lark (International)"], 0)
domain = "lark" if domain_idx == 1 else "feishu"
bot_name = None
try:
bot_info = probe_bot(app_id, app_secret, domain)
if bot_info:
bot_name = bot_info.get("bot_name")
print_success(f"Credentials verified — bot: {bot_name or 'unnamed'}")
else:
print_warning("Could not verify bot connection. Credentials saved anyway.")
except Exception as exc:
print_warning(f"Credential verification skipped: {exc}")
credentials = {
"app_id": app_id,
"app_secret": app_secret,
"domain": domain,
"open_id": None,
"bot_name": bot_name,
}
app_id = credentials["app_id"]
app_secret = credentials["app_secret"]
domain = credentials.get("domain", "feishu")
open_id = credentials.get("open_id")
bot_name = credentials.get("bot_name")
save_env_value("FEISHU_APP_ID", app_id)
save_env_value("FEISHU_APP_SECRET", app_secret)
save_env_value("FEISHU_DOMAIN", domain)
if used_qr:
connection_mode = "websocket"
else:
mode_idx = prompt_choice(
"Connection mode",
[
"WebSocket (recommended — no public URL needed)",
"Webhook (requires a reachable HTTP endpoint)",
],
0,
)
connection_mode = "webhook" if mode_idx == 1 else "websocket"
if connection_mode == "webhook":
print_info("Webhook defaults: 127.0.0.1:8765/feishu/webhook")
print_info("Override with FEISHU_WEBHOOK_HOST / FEISHU_WEBHOOK_PORT / FEISHU_WEBHOOK_PATH")
print_info("For signature verification, set FEISHU_ENCRYPT_KEY and FEISHU_VERIFICATION_TOKEN")
save_env_value("FEISHU_CONNECTION_MODE", connection_mode)
if bot_name:
print_success(f"Bot created: {bot_name}")
access_idx = prompt_choice(
"How should direct messages be authorized?",
[
"Use DM pairing approval (recommended)",
"Allow all direct messages",
"Only allow listed user IDs",
],
0,
)
if access_idx == 0:
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
save_env_value("FEISHU_ALLOWED_USERS", "")
print_success("DM pairing enabled.")
print_info("Unknown users can request access; approve with `hermes pairing approve`.")
elif access_idx == 1:
save_env_value("FEISHU_ALLOW_ALL_USERS", "true")
save_env_value("FEISHU_ALLOWED_USERS", "")
print_warning("Open DM access enabled for Feishu / Lark.")
else:
save_env_value("FEISHU_ALLOW_ALL_USERS", "false")
default_allow = open_id or ""
allowlist = prompt(
"Allowed user IDs (comma-separated)", default_allow, password=False
).replace(" ", "")
save_env_value("FEISHU_ALLOWED_USERS", allowlist)
print_success("Allowlist saved.")
group_idx = prompt_choice(
"How should group chats be handled?",
[
"Respond only when @mentioned in groups (recommended)",
"Disable group chats",
],
0,
)
if group_idx == 0:
save_env_value("FEISHU_GROUP_POLICY", "open")
print_info("Group chats enabled (bot must be @mentioned).")
else:
save_env_value("FEISHU_GROUP_POLICY", "disabled")
print_info("Group chats disabled.")
home_channel = prompt("Home chat ID (optional, for cron/notifications)", password=False)
if home_channel:
save_env_value("FEISHU_HOME_CHANNEL", home_channel)
print_success(f"Home channel set to {home_channel}")
print_success("🪽 Feishu / Lark configured!")
print_info(f"App ID: {app_id}")
print_info(f"Domain: {domain}")
if bot_name:
print_info(f"Bot: {bot_name}")
def _apply_yaml_config(yaml_cfg: dict, feishu_cfg: dict) -> dict | None:
"""Translate config.yaml feishu: keys into FEISHU_* env vars.
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
feishu_cfg block from gateway/config.py::load_gateway_config() (allow_bots).
Env vars take precedence over YAML. Returns None flows through env.
"""
if "allow_bots" in feishu_cfg and not os.getenv("FEISHU_ALLOW_BOTS"):
os.environ["FEISHU_ALLOW_BOTS"] = str(feishu_cfg["allow_bots"]).lower()
return None
def _is_connected(config) -> bool:
"""Feishu is connected when app_id is configured. Mirrors the legacy
_PLATFORM_CONNECTED_CHECKERS[Platform.FEISHU] = lambda cfg: bool(app_id)."""
extra = getattr(config, "extra", {}) or {}
return bool(extra.get("app_id"))
def _build_adapter(config):
"""Factory wrapper that constructs FeishuAdapter from a PlatformConfig."""
return FeishuAdapter(config)
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="feishu",
label="Feishu / Lark",
adapter_factory=_build_adapter,
check_fn=check_feishu_requirements,
is_connected=_is_connected,
validate_config=_is_connected,
required_env=["FEISHU_APP_ID", "FEISHU_APP_SECRET"],
install_hint="pip install 'hermes-agent[feishu]'",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="FEISHU_ALLOWED_USERS",
allow_all_env="FEISHU_ALLOW_ALL_USERS",
cron_deliver_env_var="FEISHU_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
max_message_length=8000,
emoji="🪽",
allow_update_command=True,
)

View file

@ -1164,7 +1164,7 @@ async def handle_drive_comment_event(
)
# Access control
from gateway.platforms.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys
from plugins.platforms.feishu.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys
comments_cfg = load_config()
rule = resolve_rule(comments_cfg, file_type, file_token)

View file

@ -0,0 +1,44 @@
name: feishu-platform
label: Feishu / Lark
kind: platform
version: 1.0.0
description: >
Feishu / Lark gateway adapter for Hermes Agent.
Connects to Feishu (China) or Lark (International) via the official
lark-oapi SDK over WebSocket or webhook and relays messages between
Feishu/Lark chats and the Hermes agent. Supports text, images, video,
voice, documents, threads, DM pairing, group @mention gating, drive
comment events, and meeting invites.
author: NousResearch
requires_env:
- name: FEISHU_APP_ID
description: "Feishu/Lark app ID"
prompt: "Feishu App ID"
url: "https://open.feishu.cn/"
password: false
- name: FEISHU_APP_SECRET
description: "Feishu/Lark app secret"
prompt: "Feishu App Secret"
url: "https://open.feishu.cn/"
password: true
optional_env:
- name: FEISHU_DOMAIN
description: "Domain: 'feishu' (China) or 'lark' (International)"
prompt: "Domain (feishu/lark)"
password: false
- name: FEISHU_ALLOWED_USERS
description: "Comma-separated Feishu user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: FEISHU_ALLOW_ALL_USERS
description: "Allow any Feishu user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: FEISHU_HOME_CHANNEL
description: "Default chat ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: FEISHU_HOME_CHANNEL_NAME
description: "Display name for the Feishu home channel"
prompt: "Home channel display name"
password: false

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -4106,3 +4106,268 @@ class MatrixAdapter(BasePlatformAdapter):
result = result.replace(f"\x00PROTECTED{idx}\x00", original)
return result
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Added when the Matrix adapter moved from gateway/platforms/matrix.py into
# this bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
# register(ctx) entry point plus hook implementations that replace the
# per-platform core touchpoints (the Platform.MATRIX elif in gateway/run.py,
# the matrix_cfg YAML→env block in gateway/config.py, the _setup_matrix wizard
# + _PLATFORMS["matrix"] static dict in hermes_cli/{setup,gateway}.py, and the
# _send_matrix dispatch in tools/send_message_tool.py). Matrix uses the
# generic token/api_key connected check, so no is_connected override is needed.
# ──────────────────────────────────────────────────────────────────────────
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process Matrix delivery via the Client-Server API.
Implements the standalone_sender_fn contract so deliver=matrix cron jobs
succeed when cron runs separately from the gateway. Converts markdown to
HTML for rich rendering, falling back to plain text when the markdown
library is absent. Replaces the legacy _send_matrix helper.
"""
extra = getattr(pconfig, "extra", {}) or {}
token = getattr(pconfig, "token", None)
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try:
homeserver = (extra.get("homeserver") or os.getenv("MATRIX_HOMESERVER", "")).rstrip("/")
token = token or os.getenv("MATRIX_ACCESS_TOKEN", "")
if not homeserver or not token:
return {"error": "Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)"}
txn_id = f"hermes_{int(time.time() * 1000)}_{os.urandom(4).hex()}"
from urllib.parse import quote
encoded_room = quote(chat_id, safe="")
url = f"{homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}"
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
payload = {"msgtype": "m.text", "body": message}
try:
import markdown as _md
html = _md.markdown(message, extensions=["fenced_code", "tables"])
html = re.sub(r"<h[1-6]>(.*?)</h[1-6]>", r"<strong>\1</strong>", html)
payload["format"] = "org.matrix.custom.html"
payload["formatted_body"] = html
except ImportError:
pass
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.put(url, headers=headers, json=payload) as resp:
if resp.status not in {200, 201}:
body = await resp.text()
return {"error": f"Matrix API error ({resp.status}): {body}"}
data = await resp.json()
return {"success": True, "platform": "matrix", "chat_id": chat_id, "message_id": data.get("event_id")}
except Exception as e:
return {"error": f"Matrix send failed: {e}"}
def interactive_setup() -> None:
"""Configure Matrix credentials. Replaces hermes_cli/setup.py::_setup_matrix
and the static _PLATFORMS["matrix"] dict. CLI helpers are lazy-imported."""
import shutil
import sys as _sys
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.cli_output import (
prompt,
prompt_yes_no,
print_header,
print_info,
print_success,
print_warning,
)
print_header("Matrix")
existing = get_env_value("MATRIX_ACCESS_TOKEN") or get_env_value("MATRIX_PASSWORD")
if existing:
print_info("Matrix: already configured")
if not prompt_yes_no("Reconfigure Matrix?", False):
return
print_info("Works with any Matrix homeserver (Synapse, Conduit, Dendrite, or matrix.org).")
print_info(" 1. Create a bot user on your homeserver, or use your own account")
print_info(" 2. Get an access token from Element, or provide user ID + password")
homeserver = prompt("Homeserver URL (e.g. https://matrix.example.org)")
if homeserver:
save_env_value("MATRIX_HOMESERVER", homeserver.rstrip("/"))
print_info("Auth: provide an access token (recommended), or user ID + password.")
token = prompt("Access token (leave empty for password login)", password=True)
if token:
save_env_value("MATRIX_ACCESS_TOKEN", token)
user_id = prompt("User ID (@bot:server — optional, will be auto-detected)")
if user_id:
save_env_value("MATRIX_USER_ID", user_id)
print_success("Matrix access token saved")
else:
user_id = prompt("User ID (@bot:server)")
if user_id:
save_env_value("MATRIX_USER_ID", user_id)
password = prompt("Password", password=True)
if password:
save_env_value("MATRIX_PASSWORD", password)
print_success("Matrix credentials saved")
if token or get_env_value("MATRIX_PASSWORD"):
want_e2ee = prompt_yes_no("Enable end-to-end encryption (E2EE)?", False)
if want_e2ee:
save_env_value("MATRIX_ENCRYPTION", "true")
print_success("E2EE enabled")
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
try:
from tools.lazy_deps import ensure as _lazy_ensure, feature_missing
_missing_before = feature_missing("platform.matrix")
if _missing_before:
print_info(f"Installing {matrix_pkg} (+ {len(_missing_before)} runtime deps)...")
try:
_lazy_ensure("platform.matrix", prompt=False)
print_success(f"{matrix_pkg} installed")
except Exception as exc:
print_warning(
"Install failed — run manually: pip install "
"'mautrix[encryption]' asyncpg aiosqlite Markdown aiohttp-socks"
)
print_info(f" Error: {exc}")
except ImportError:
try:
__import__("mautrix")
except ImportError:
print_info(f"Installing {matrix_pkg}...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", _sys.executable, matrix_pkg],
capture_output=True, text=True,
)
else:
result = subprocess.run(
[_sys.executable, "-m", "pip", "install", matrix_pkg],
capture_output=True, text=True,
)
if result.returncode == 0:
print_success(f"{matrix_pkg} installed")
else:
print_warning(
f"Install failed — run manually: pip install "
f"'{matrix_pkg}' asyncpg aiosqlite Markdown aiohttp-socks"
)
print_info("🔒 Security: Restrict who can use your bot")
print_info(" Matrix user IDs look like @username:server")
allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)")
if allowed_users:
save_env_value("MATRIX_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("Matrix allowlist configured")
else:
print_info("⚠️ No allowlist set - anyone who can message the bot can use it!")
print_info("📬 Home Room: where Hermes delivers cron job results and notifications.")
print_info(" Room IDs look like !abc123:server (shown in Element room settings)")
print_info(" You can also set this later by typing /set-home in a Matrix room.")
home_room = prompt("Home room ID (leave empty to set later with /set-home)")
if home_room:
save_env_value("MATRIX_HOME_ROOM", home_room)
def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None:
"""Translate config.yaml matrix: keys into MATRIX_* env vars.
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
matrix_cfg block from gateway/config.py::load_gateway_config(). Env vars
take precedence over YAML. Returns None everything flows through env.
"""
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
os.environ["MATRIX_REQUIRE_MENTION"] = str(matrix_cfg["require_mention"]).lower()
au = matrix_cfg.get("allowed_users")
if au is not None and not os.getenv("MATRIX_ALLOWED_USERS"):
if isinstance(au, list):
au = ",".join(str(v) for v in au)
os.environ["MATRIX_ALLOWED_USERS"] = str(au)
frc = matrix_cfg.get("free_response_rooms")
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["MATRIX_FREE_RESPONSE_ROOMS"] = str(frc)
ar = matrix_cfg.get("allowed_rooms")
if ar is not None and not os.getenv("MATRIX_ALLOWED_ROOMS"):
if isinstance(ar, list):
ar = ",".join(str(v) for v in ar)
os.environ["MATRIX_ALLOWED_ROOMS"] = str(ar)
ignore_patterns = matrix_cfg.get("ignore_user_patterns")
if ignore_patterns is not None and not os.getenv("MATRIX_IGNORE_USER_PATTERNS"):
if isinstance(ignore_patterns, list):
ignore_patterns = ",".join(str(v) for v in ignore_patterns)
os.environ["MATRIX_IGNORE_USER_PATTERNS"] = str(ignore_patterns)
if "process_notices" in matrix_cfg and not os.getenv("MATRIX_PROCESS_NOTICES"):
os.environ["MATRIX_PROCESS_NOTICES"] = str(matrix_cfg["process_notices"]).lower()
if "session_scope" in matrix_cfg and not os.getenv("MATRIX_SESSION_SCOPE"):
os.environ["MATRIX_SESSION_SCOPE"] = str(matrix_cfg["session_scope"]).lower()
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
return None
def _is_connected(config) -> bool:
"""Matrix is connected when a homeserver + access token (or password) are
configured. Read via hermes_cli.gateway.get_env_value so setup-status
callers that patch get_env_value observe the same value, and PlatformConfig
extras (homeserver) are honored too. As a built-in, Matrix used the generic
token check; as a plugin it needs an explicit is_connected so
_platform_status / get_connected_platforms reflect real configuration
rather than mere SDK presence. #41112.
"""
extra = getattr(config, "extra", {}) or {}
import hermes_cli.gateway as gateway_mod
homeserver = extra.get("homeserver") or gateway_mod.get_env_value("MATRIX_HOMESERVER") or ""
token = (
getattr(config, "token", None)
or gateway_mod.get_env_value("MATRIX_ACCESS_TOKEN")
or gateway_mod.get_env_value("MATRIX_PASSWORD")
or ""
)
return bool(str(homeserver).strip() and str(token).strip())
def _build_adapter(config):
"""Factory wrapper that constructs MatrixAdapter from a PlatformConfig."""
return MatrixAdapter(config)
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="matrix",
label="Matrix",
adapter_factory=_build_adapter,
check_fn=check_matrix_requirements,
is_connected=_is_connected,
required_env=["MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN"],
install_hint="pip install 'mautrix[encryption]'",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="MATRIX_ALLOWED_USERS",
allow_all_env="MATRIX_ALLOW_ALL_USERS",
cron_deliver_env_var="MATRIX_HOME_ROOM",
standalone_sender_fn=_standalone_send,
max_message_length=4000,
emoji="🔐",
allow_update_command=True,
)

View file

@ -0,0 +1,41 @@
name: matrix-platform
label: Matrix
kind: platform
version: 1.0.0
description: >
Matrix gateway adapter for Hermes Agent.
Connects to a Matrix homeserver via mautrix (with optional E2EE) and relays
messages between Matrix rooms/DMs and the Hermes agent. Supports threads,
HTML/markdown rendering, native media uploads, mention gating, free-response
rooms, and per-room allowlists.
author: NousResearch
requires_env:
- name: MATRIX_HOMESERVER
description: "Matrix homeserver URL (e.g. https://matrix.org)"
prompt: "Matrix homeserver URL"
password: false
- name: MATRIX_ACCESS_TOKEN
description: "Matrix access token (or use MATRIX_PASSWORD for password login)"
prompt: "Matrix access token"
password: true
optional_env:
- name: MATRIX_PASSWORD
description: "Matrix account password (alternative to MATRIX_ACCESS_TOKEN)"
prompt: "Matrix password"
password: true
- name: MATRIX_ALLOWED_USERS
description: "Comma-separated Matrix user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: MATRIX_ALLOW_ALL_USERS
description: "Allow any Matrix user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: MATRIX_HOME_CHANNEL
description: "Default room ID for cron / notification delivery"
prompt: "Home room ID"
password: false
- name: MATRIX_HOME_CHANNEL_NAME
description: "Display name for the Matrix home room"
prompt: "Home room display name"
password: false

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -3813,3 +3813,299 @@ class SlackAdapter(BasePlatformAdapter):
if isinstance(raw, str) and raw.strip():
return {part.strip() for part in raw.split(",") if part.strip()}
return set()
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Everything below this line was added when the Slack adapter moved from
# ``gateway/platforms/slack.py`` into this bundled plugin. It mirrors the
# Discord migration (PR #24356) exactly: a ``register(ctx)`` entry point plus
# the hook implementations (``_standalone_send``, ``interactive_setup``,
# ``_apply_yaml_config``, ``_is_connected``, ``_build_adapter``) that replace
# the per-platform core touchpoints (the ``Platform.SLACK`` elif in
# ``gateway/run.py``, the ``slack_cfg`` YAML→env block in ``gateway/config.py``,
# the ``_setup_slack`` wizard + ``_PLATFORMS["slack"]`` static dict in
# ``hermes_cli/{setup,gateway}.py``, and the ``_send_slack`` dispatch in
# ``tools/send_message_tool.py``).
# ──────────────────────────────────────────────────────────────────────────
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process Slack delivery via the Web API ``chat.postMessage``.
Implements the ``standalone_sender_fn`` contract so ``deliver=slack`` cron
jobs succeed when the cron process is not co-located with the gateway (the
in-process adapter weakref is ``None`` in that case). Replaces the legacy
``_send_slack`` helper that used to live in ``tools/send_message_tool.py``.
mrkdwn formatting is applied exactly as the legacy core path did via a
throwaway ``SlackAdapter`` instance's ``format_message`` — so cron-delivered
Slack messages render identically to gateway-delivered ones.
"""
token = getattr(pconfig, "token", None) or os.getenv("SLACK_BOT_TOKEN", "")
if not token:
return {"error": "Slack send failed: SLACK_BOT_TOKEN not configured"}
formatted = message
if message:
try:
_fmt_adapter = SlackAdapter.__new__(SlackAdapter)
formatted = _fmt_adapter.format_message(message)
except Exception:
logger.debug(
"Failed to apply Slack mrkdwn formatting in _standalone_send",
exc_info=True,
)
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try:
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_proxy = resolve_proxy_url()
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
url = "https://slack.com/api/chat.postMessage"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30), **_sess_kw
) as session:
payload = {"channel": chat_id, "text": formatted, "mrkdwn": True}
if thread_id:
payload["thread_ts"] = thread_id
async with session.post(
url, headers=headers, json=payload, **_req_kw
) as resp:
data = await resp.json()
if data.get("ok"):
return {
"success": True,
"platform": "slack",
"chat_id": chat_id,
"message_id": data.get("ts"),
}
return {"error": f"Slack API error: {data.get('error', 'unknown')}"}
except Exception as e:
return {"error": f"Slack send failed: {e}"}
def interactive_setup() -> None:
"""Guide the user through Slack bot setup.
Mirrors Discord's ``interactive_setup`` shape: lazy-imports CLI helpers so
the plugin's import surface stays small, generates and writes the Slack app
manifest, prompts for the bot + app tokens, captures an allowlist, and
offers to set a home channel. Replaces ``hermes_cli/setup.py::_setup_slack``.
"""
from pathlib import Path
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.cli_output import (
prompt,
prompt_yes_no,
print_header,
print_info,
print_success,
print_warning,
)
def _write_slack_manifest_and_instruct() -> None:
"""Generate the Slack manifest, write it under HERMES_HOME, and print
paste-into-Slack instructions. Failures are non-fatal."""
try:
from hermes_cli.slack_cli import _build_full_manifest
from hermes_constants import get_hermes_home
import json as _json
manifest = _build_full_manifest(
bot_name="Hermes",
bot_description="Your Hermes agent on Slack",
)
target = Path(get_hermes_home()) / "slack-manifest.json"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(
_json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print_success(f"Slack app manifest written to: {target}")
print_info(
" Paste it into https://api.slack.com/apps → your app → Features "
"→ App Manifest → Edit, then Save. Slack will prompt to "
"reinstall if scopes or slash commands changed."
)
print_info(
" Re-run `hermes slack manifest --write` anytime to refresh after "
"Hermes adds new commands."
)
except Exception as e:
print_warning(f"Could not write Slack manifest: {e}")
print_header("Slack")
existing = get_env_value("SLACK_BOT_TOKEN")
if existing:
print_info("Slack: already configured")
if not prompt_yes_no("Reconfigure Slack?", False):
# Even without reconfiguring, offer to refresh the manifest so
# new commands (e.g. /btw, /stop, ...) get registered in Slack.
if prompt_yes_no(
"Regenerate the Slack app manifest with the latest command "
"list? (recommended after `hermes update`)",
True,
):
_write_slack_manifest_and_instruct()
return
print_info("Steps to create a Slack app:")
print_info(" 1. Go to https://api.slack.com/apps → Create New App")
print_info(" Pick 'From an app manifest' — we'll generate one for you below.")
print_info(" 2. Enable Socket Mode: Settings → Socket Mode → Enable")
print_info(" • Create an App-Level Token with 'connections:write' scope")
print_info(" 3. Install to Workspace: Settings → Install App")
print_info(" 4. After installing, invite the bot to channels: /invite @YourBot")
print()
print_info(" Full guide: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack/")
print()
# Generate and write manifest up-front so the user can paste it into
# the "Create from manifest" flow instead of clicking through scopes /
# events / slash commands one at a time.
_write_slack_manifest_and_instruct()
print()
bot_token = prompt("Slack Bot Token (xoxb-...)", password=True)
if not bot_token:
return
save_env_value("SLACK_BOT_TOKEN", bot_token)
app_token = prompt("Slack App Token (xapp-...)", password=True)
if app_token:
save_env_value("SLACK_APP_TOKEN", app_token)
print_success("Slack tokens saved")
print()
print_info("🔒 Security: Restrict who can use your bot")
print_info(" To find a Member ID: click a user's name → View full profile → ⋮ → Copy member ID")
print()
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty to deny everyone except paired users)"
)
if allowed_users:
save_env_value("SLACK_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("Slack allowlist configured")
else:
print_warning("⚠️ No Slack allowlist set - unpaired users will be denied by default.")
print_info(" Set SLACK_ALLOW_ALL_USERS=true or GATEWAY_ALLOW_ALL_USERS=true only if you intentionally want open workspace access.")
print()
print_info("📬 Home Channel: where Hermes delivers cron job results,")
print_info(" cross-platform messages, and notifications.")
print_info(" To get a channel ID: open the channel in Slack, then right-click")
print_info(" the channel name → Copy link — the ID starts with C (e.g. C01ABC2DE3F).")
print_info(" You can also set this later by typing /set-home in a Slack channel.")
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
if home_channel:
save_env_value("SLACK_HOME_CHANNEL", home_channel.strip())
def _apply_yaml_config(yaml_cfg: dict, slack_cfg: dict) -> dict | None:
"""Translate ``config.yaml`` ``slack:`` keys into ``SLACK_*`` env vars.
Implements the ``apply_yaml_config_fn`` contract (#24849). Mirrors the
legacy ``slack_cfg`` block that used to live in
``gateway/config.py::load_gateway_config()`` before this migration.
The SlackAdapter reads its runtime configuration via ``os.getenv()``
throughout the connect / handle code paths, so rather than rewrite those
call sites to read from ``PlatformConfig.extra``, this hook keeps the
existing env-driven model and owns the YAMLenv translation here, next to
the adapter that consumes it. Env vars take precedence over YAML every
assignment is guarded by ``not os.getenv(...)`` so explicit env vars
survive a config.yaml update. Returns ``None`` because no extras are
seeded into ``PlatformConfig.extra`` directly (everything flows through env).
"""
if "require_mention" in slack_cfg and not os.getenv("SLACK_REQUIRE_MENTION"):
os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower()
if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"):
os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower()
if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"):
os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower()
frc = slack_cfg.get("free_response_channels")
if frc is not None and not os.getenv("SLACK_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["SLACK_FREE_RESPONSE_CHANNELS"] = str(frc)
if "reactions" in slack_cfg and not os.getenv("SLACK_REACTIONS"):
os.environ["SLACK_REACTIONS"] = str(slack_cfg["reactions"]).lower()
ac = slack_cfg.get("allowed_channels")
if ac is not None and not os.getenv("SLACK_ALLOWED_CHANNELS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
return None # all settings flow through env; nothing to merge into extras
def _is_connected(config) -> bool:
"""Slack is considered connected when SLACK_BOT_TOKEN is set.
Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via the
plugin's own bound import) so tests that patch ``gateway_mod.get_env_value``
can suppress ambient ``SLACK_BOT_TOKEN`` env vars. Matches what the legacy
``Platform.SLACK`` connected-check did before this migration.
"""
import hermes_cli.gateway as gateway_mod
return bool((gateway_mod.get_env_value("SLACK_BOT_TOKEN") or "").strip())
def _build_adapter(config):
"""Factory wrapper that constructs SlackAdapter from a PlatformConfig."""
return SlackAdapter(config)
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="slack",
label="Slack",
adapter_factory=_build_adapter,
check_fn=check_slack_requirements,
is_connected=_is_connected,
required_env=["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"],
install_hint="pip install 'hermes-agent[slack]'",
# Interactive setup wizard — replaces hermes_cli/setup.py::_setup_slack
# and the static _PLATFORMS["slack"] dict in hermes_cli/gateway.py.
setup_fn=interactive_setup,
# YAML→env config bridge — owns the translation of config.yaml slack:
# keys (require_mention, strict_mention, allow_bots,
# free_response_channels, reactions, allowed_channels) into SLACK_*
# env vars that the adapter reads via os.getenv(). Replaces the
# hardcoded block in gateway/config.py. Hook contract: #24849.
apply_yaml_config_fn=_apply_yaml_config,
# Auth env vars for _is_user_authorized() integration
allowed_users_env="SLACK_ALLOWED_USERS",
allow_all_env="SLACK_ALLOW_ALL_USERS",
# Cron home-channel delivery
cron_deliver_env_var="SLACK_HOME_CHANNEL",
# Out-of-process cron delivery via the Slack Web API. Without this hook,
# deliver=slack cron jobs fail with "No live adapter" when cron runs
# separately from the gateway. Replaces the _send_slack helper.
standalone_sender_fn=_standalone_send,
# Slack API allows 40,000 chars; leave margin (matches the legacy
# SlackAdapter.MAX_MESSAGE_LENGTH).
max_message_length=39000,
# Display
emoji="💼",
allow_update_command=True,
)

View file

@ -0,0 +1,39 @@
name: slack-platform
label: Slack
kind: platform
version: 1.0.0
description: >
Slack gateway adapter for Hermes Agent.
Connects to Slack via slack-bolt in Socket Mode and relays messages
between Slack channels/DMs and the Hermes agent. Supports slash
commands, threads, mrkdwn rendering, approval blocks, free-response
channels, mention gating, and channel skill bindings.
author: NousResearch
requires_env:
- name: SLACK_BOT_TOKEN
description: "Slack bot token (xoxb-...)"
prompt: "Slack Bot Token (xoxb-...)"
url: "https://api.slack.com/apps"
password: true
- name: SLACK_APP_TOKEN
description: "Slack app-level token for Socket Mode (xapp-..., scope connections:write)"
prompt: "Slack App Token (xapp-...)"
url: "https://api.slack.com/apps"
password: true
optional_env:
- name: SLACK_ALLOWED_USERS
description: "Comma-separated Slack member IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: SLACK_ALLOW_ALL_USERS
description: "Allow any Slack user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: SLACK_HOME_CHANNEL
description: "Default channel ID for cron / notification delivery (starts with C)"
prompt: "Home channel ID"
password: false
- name: SLACK_HOME_CHANNEL_NAME
description: "Display name for the Slack home channel"
prompt: "Home channel display name"
password: false

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -377,3 +377,117 @@ class SmsAdapter(BasePlatformAdapter):
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
content_type="application/xml",
)
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Added when the SMS (Twilio) adapter moved from gateway/platforms/sms.py into
# this bundled plugin. register() exposes the platform via the registry,
# replacing the Platform.SMS elif in gateway/run.py, the
# _PLATFORM_CONNECTED_CHECKERS entry in gateway/config.py, the _PLATFORMS["sms"]
# static dict in hermes_cli/gateway.py, and the _send_sms dispatch in
# tools/send_message_tool.py. TWILIO_* env→PlatformConfig seeding stays in core.
# ──────────────────────────────────────────────────────────────────────────
def _strip_markdown_for_sms(message: str) -> str:
"""Strip markdown — SMS renders it as literal characters."""
message = re.sub(r"\*\*(.+?)\*\*", r"\1", message, flags=re.DOTALL)
message = re.sub(r"\*(.+?)\*", r"\1", message, flags=re.DOTALL)
message = re.sub(r"__(.+?)__", r"\1", message, flags=re.DOTALL)
message = re.sub(r"_(.+?)_", r"\1", message, flags=re.DOTALL)
message = re.sub(r"```[a-z]*\n?", "", message)
message = re.sub(r"`(.+?)`", r"\1", message)
message = re.sub(r"^#{1,6}\s+", "", message, flags=re.MULTILINE)
message = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", message)
message = re.sub(r"\n{3,}", "\n\n", message)
return message.strip()
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process SMS delivery via the Twilio REST API. Implements the
standalone_sender_fn contract; replaces the legacy _send_sms helper."""
auth_token = getattr(pconfig, "api_key", None) or os.getenv("TWILIO_AUTH_TOKEN", "")
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
import base64
account_sid = os.getenv("TWILIO_ACCOUNT_SID", "")
from_number = os.getenv("TWILIO_PHONE_NUMBER", "")
if not account_sid or not auth_token or not from_number:
return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"}
message = _strip_markdown_for_sms(message)
def _redacted_error(text):
try:
from tools.send_message_tool import _error as _e
return _e(text)
except Exception:
return {"error": text}
try:
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
_proxy = resolve_proxy_url()
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
creds = f"{account_sid}:{auth_token}"
encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
headers = {"Authorization": f"Basic {encoded}"}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
form_data = aiohttp.FormData()
form_data.add_field("From", from_number)
form_data.add_field("To", chat_id)
form_data.add_field("Body", message)
async with session.post(url, data=form_data, headers=headers, **_req_kw) as resp:
body = await resp.json()
if resp.status >= 400:
error_msg = body.get("message", str(body))
return _redacted_error(f"Twilio API error ({resp.status}): {error_msg}")
return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": body.get("sid", "")}
except Exception as e:
return _redacted_error(f"SMS send failed: {e}")
def _is_connected(config) -> bool:
"""SMS is connected when Twilio credentials are present. Mirrors the legacy
_PLATFORM_CONNECTED_CHECKERS[Platform.SMS] = bool(TWILIO_ACCOUNT_SID)."""
import hermes_cli.gateway as gateway_mod
return bool((gateway_mod.get_env_value("TWILIO_ACCOUNT_SID") or "").strip())
def _build_adapter(config):
"""Factory wrapper that constructs SmsAdapter from a PlatformConfig."""
return SmsAdapter(config)
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="sms",
label="SMS (Twilio)",
adapter_factory=_build_adapter,
check_fn=check_sms_requirements,
is_connected=_is_connected,
required_env=["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"],
install_hint="pip install aiohttp",
allowed_users_env="SMS_ALLOWED_USERS",
allow_all_env="SMS_ALLOW_ALL_USERS",
cron_deliver_env_var="SMS_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
max_message_length=MAX_SMS_LENGTH,
pii_safe=True,
emoji="📱",
allow_update_command=True,
)

View file

@ -0,0 +1,32 @@
name: sms-platform
label: SMS (Twilio)
kind: platform
version: 1.0.0
description: >
SMS gateway adapter for Hermes Agent via Twilio. Sends and receives SMS
through the Twilio REST API + inbound webhook, relaying texts between phone
numbers and the Hermes agent. Markdown is stripped to plain text.
author: NousResearch
requires_env:
- name: TWILIO_ACCOUNT_SID
description: "Twilio Account SID"
prompt: "Twilio Account SID"
url: "https://www.twilio.com/"
password: false
- name: TWILIO_AUTH_TOKEN
description: "Twilio Auth Token"
prompt: "Twilio Auth Token"
password: true
- name: TWILIO_PHONE_NUMBER
description: "Twilio phone number (SMS-capable, E.164 format)"
prompt: "Twilio phone number"
password: false
optional_env:
- name: SMS_ALLOWED_USERS
description: "Comma-separated phone numbers allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: SMS_HOME_CHANNEL
description: "Default phone number for cron / notification delivery"
prompt: "Home number"
password: false

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -82,7 +82,7 @@ from gateway.platforms.base import (
SUPPORTED_IMAGE_DOCUMENT_TYPES,
utf16_len,
)
from gateway.platforms.telegram_network import (
from plugins.platforms.telegram.telegram_network import (
TelegramFallbackTransport,
discover_fallback_ips,
parse_fallback_ip_env,
@ -6886,3 +6886,232 @@ class TelegramAdapter(BasePlatformAdapter):
message_id,
"\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e",
)
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Added when the Telegram adapter (+ its telegram_network satellite) moved from
# gateway/platforms/ into this bundled plugin. Mirrors the Discord (#24356) /
# Slack migrations: a register(ctx) entry point plus hook implementations that
# replace the per-platform core touchpoints (the Platform.TELEGRAM branch in
# gateway/run.py, the telegram_cfg YAML→env/extra block in gateway/config.py,
# the _setup_telegram wizard + _PLATFORMS["telegram"] static dict in
# hermes_cli/{setup,gateway}.py, and the _send_telegram dispatch in
# tools/send_message_tool.py). Telegram uses the generic token connected
# check, so no is_connected override is needed.
# ──────────────────────────────────────────────────────────────────────────
def _resolve_notifications_mode() -> str:
"""Resolve the Telegram notification mode (all/important) from env or
config.yaml display.platforms.telegram.notifications, defaulting to
'important'. Mirrors the post-construction logic that used to live in
gateway/run.py::_create_adapter()."""
mode = os.getenv("HERMES_TELEGRAM_NOTIFICATIONS", "")
if not mode:
try:
from gateway.config import load_gateway_config
from gateway.run import cfg_get
_gw_cfg = load_gateway_config()
_raw = cfg_get(_gw_cfg, "display", "platforms", "telegram", "notifications")
if _raw not in {None, ""}:
mode = str(_raw).strip().lower()
except Exception:
pass
mode = mode or "important"
if mode not in {"all", "important"}:
logger.warning(
"Unknown telegram notifications mode '%s', defaulting to 'important' "
"(valid: all, important)", mode,
)
mode = "important"
return mode
def _build_adapter(config):
"""Factory wrapper that constructs TelegramAdapter and applies the
notification mode (preserving the gateway/run.py post-construction step)."""
adapter = TelegramAdapter(config)
try:
adapter._notifications_mode = _resolve_notifications_mode()
except Exception:
adapter._notifications_mode = "important"
return adapter
def _is_connected(config) -> bool:
"""Telegram is connected when a bot token is configured.
check_telegram_requirements() only verifies the python-telegram-bot SDK is
importable, NOT that a token is set so without this is_connected the
registry-driven plugin-enable pass in gateway/config.py would enable
Telegram on any machine that merely has the SDK installed. Gate on the
token (env or PlatformConfig.token), matching the generic token check
Telegram had as a built-in.
"""
token = getattr(config, "token", None)
if not token:
import hermes_cli.gateway as gateway_mod
token = gateway_mod.get_env_value("TELEGRAM_BOT_TOKEN") or ""
return bool(str(token).strip())
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process Telegram delivery. Delegates to the standalone
``_send_telegram`` REST sender in tools/send_message_tool.py (which already
handles chunking-agnostic single sends, threads, media, retries, and
parse-mode fallback). Implements the standalone_sender_fn contract so
deliver=telegram cron jobs succeed when cron runs separately from the
gateway."""
token = getattr(pconfig, "token", None) or os.getenv("TELEGRAM_BOT_TOKEN", "")
disable_link_previews = bool(
getattr(pconfig, "extra", {}) and pconfig.extra.get("disable_link_previews")
)
from tools.send_message_tool import _send_telegram
return await _send_telegram(
token,
chat_id,
message,
media_files=media_files,
thread_id=thread_id,
disable_link_previews=disable_link_previews,
force_document=force_document,
)
def interactive_setup() -> None:
"""Configure Telegram bot credentials and allowlist.
Delegates to the existing CLI setup helpers (managed-bot QR onboarding,
token validation, allowlist capture) via lazy import so the full wizard
behavior is preserved without duplicating ~150 lines. Replaces the
_PLATFORMS["telegram"] static dict dispatch in hermes_cli/gateway.py.
"""
from hermes_cli import setup as _setup_mod
_setup_mod._setup_telegram()
def _apply_yaml_config(yaml_cfg: dict, telegram_cfg: dict) -> dict | None:
"""Translate config.yaml telegram: keys into TELEGRAM_* env vars and
PlatformConfig.extra entries.
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
telegram_cfg block from gateway/config.py::load_gateway_config(). Env vars
take precedence over YAML. Returns a dict of extras to merge into
PlatformConfig.extra (disable_topic_auto_rename + runtime flags), or None.
"""
import json as _json
extras: dict = {}
if "disable_topic_auto_rename" in telegram_cfg:
extras.setdefault("disable_topic_auto_rename", telegram_cfg["disable_topic_auto_rename"])
_effective_rm = telegram_cfg.get("require_mention", yaml_cfg.get("require_mention"))
if _effective_rm is not None and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(_effective_rm).lower()
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"])
if "exclusive_bot_mentions" in telegram_cfg and not os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS"):
os.environ["TELEGRAM_EXCLUSIVE_BOT_MENTIONS"] = str(telegram_cfg["exclusive_bot_mentions"]).lower()
if "guest_mode" in telegram_cfg and not os.getenv("TELEGRAM_GUEST_MODE"):
os.environ["TELEGRAM_GUEST_MODE"] = str(telegram_cfg["guest_mode"]).lower()
if "observe_unmentioned_group_messages" in telegram_cfg and not os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"):
os.environ["TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES"] = str(telegram_cfg["observe_unmentioned_group_messages"]).lower()
frc = telegram_cfg.get("free_response_chats")
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
ac = telegram_cfg.get("allowed_chats")
if ac is not None and not os.getenv("TELEGRAM_ALLOWED_CHATS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["TELEGRAM_ALLOWED_CHATS"] = str(ac)
allowed_topics = telegram_cfg.get("allowed_topics")
if allowed_topics is not None and not os.getenv("TELEGRAM_ALLOWED_TOPICS"):
if isinstance(allowed_topics, list):
allowed_topics = ",".join(str(v) for v in allowed_topics)
os.environ["TELEGRAM_ALLOWED_TOPICS"] = str(allowed_topics)
ignored_threads = telegram_cfg.get("ignored_threads")
if ignored_threads is not None and not os.getenv("TELEGRAM_IGNORED_THREADS"):
if isinstance(ignored_threads, list):
ignored_threads = ",".join(str(v) for v in ignored_threads)
os.environ["TELEGRAM_IGNORED_THREADS"] = str(ignored_threads)
if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
if "proxy_url" in telegram_cfg and not os.getenv("TELEGRAM_PROXY"):
os.environ["TELEGRAM_PROXY"] = str(telegram_cfg["proxy_url"]).strip()
_telegram_extra = telegram_cfg.get("extra") if isinstance(telegram_cfg.get("extra"), dict) else {}
_telegram_rtm = (
telegram_cfg["reply_to_mode"] if "reply_to_mode" in telegram_cfg
else _telegram_extra.get("reply_to_mode")
)
if _telegram_rtm is not None and not os.getenv("TELEGRAM_REPLY_TO_MODE"):
_rtm_str = "off" if _telegram_rtm is False else str(_telegram_rtm).lower()
os.environ["TELEGRAM_REPLY_TO_MODE"] = _rtm_str
allowed_users = telegram_cfg.get("allow_from")
if allowed_users is not None and not os.getenv("TELEGRAM_ALLOWED_USERS"):
if isinstance(allowed_users, list):
allowed_users = ",".join(str(v) for v in allowed_users)
os.environ["TELEGRAM_ALLOWED_USERS"] = str(allowed_users)
group_allowed_users = telegram_cfg.get("group_allow_from")
if group_allowed_users is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_USERS"):
if isinstance(group_allowed_users, list):
group_allowed_users = ",".join(str(v) for v in group_allowed_users)
os.environ["TELEGRAM_GROUP_ALLOWED_USERS"] = str(group_allowed_users)
group_allowed_chats = telegram_cfg.get("group_allowed_chats")
if group_allowed_chats is not None and not os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS"):
if isinstance(group_allowed_chats, list):
group_allowed_chats = ",".join(str(v) for v in group_allowed_chats)
os.environ["TELEGRAM_GROUP_ALLOWED_CHATS"] = str(group_allowed_chats)
for _key in ("guest_mode", "disable_link_previews", "observe_unmentioned_group_messages"):
if _key in telegram_cfg:
extras.setdefault(_key, telegram_cfg[_key])
# Pass through telegram-specific extra keys (e.g. base_url proxy override),
# but EXCLUDE the generic shared-config keys that _merge_platform_map in
# gateway/config.py already merges with correct top-level-over-nested
# precedence. The apply_yaml_config_fn dispatch merges our return via
# dict.update() (clobber), so re-emitting those generic keys here would
# undo that precedence (top-level losing to a nested-fallback block).
_GENERIC_MERGE_KEYS = {
"reply_prefix", "reply_in_thread", "reply_to_mode",
"unauthorized_dm_behavior", "notice_delivery", "require_mention",
"channel_skill_bindings", "channel_prompts", "gateway_restart_notification",
"allow_from", "allow_admin_from", "dm_policy", "group_policy",
}
for _k, _v in _telegram_extra.items():
if _k not in _GENERIC_MERGE_KEYS:
extras.setdefault(_k, _v)
return extras or None
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="telegram",
label="Telegram",
adapter_factory=_build_adapter,
check_fn=check_telegram_requirements,
is_connected=_is_connected,
required_env=["TELEGRAM_BOT_TOKEN"],
install_hint="pip install 'hermes-agent[telegram]'",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="TELEGRAM_ALLOWED_USERS",
allow_all_env="TELEGRAM_ALLOW_ALL_USERS",
cron_deliver_env_var="TELEGRAM_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
max_message_length=4096,
emoji="✈️",
allow_update_command=True,
)

View file

@ -0,0 +1,35 @@
name: telegram-platform
label: Telegram
kind: platform
version: 1.0.0
description: >
Telegram gateway adapter for Hermes Agent.
Connects to Telegram via python-telegram-bot and relays messages between
Telegram chats/groups/topics and the Hermes agent. Supports threads/topics,
streaming edits, native media, inline keyboards, slash commands, fallback
network transport (direct-IP failover), notification modes, mention gating,
and per-user/chat allowlists.
author: NousResearch
requires_env:
- name: TELEGRAM_BOT_TOKEN
description: "Telegram bot token from @BotFather"
prompt: "Telegram bot token"
url: "https://t.me/BotFather"
password: true
optional_env:
- name: TELEGRAM_ALLOWED_USERS
description: "Comma-separated Telegram user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: TELEGRAM_ALLOW_ALL_USERS
description: "Allow any Telegram user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: TELEGRAM_HOME_CHANNEL
description: "Default chat ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: TELEGRAM_HOME_CHANNEL_NAME
description: "Display name for the Telegram home channel"
prompt: "Home channel display name"
password: false

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -1634,3 +1634,232 @@ def qr_scan_for_bot_info(
print() # newline after dots
print(f" QR scan timed out ({timeout_seconds // 60} minutes). Please try again.")
return None
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Added when the WeCom adapters (wecom + wecom_callback, sharing the
# wecom_crypto satellite) moved from gateway/platforms/ into this bundled
# plugin. register() exposes BOTH platforms via the registry, replacing the
# Platform.WECOM / Platform.WECOM_CALLBACK elifs in gateway/run.py, the
# _PLATFORM_CONNECTED_CHECKERS entries in gateway/config.py, the _setup_wecom
# wizard + _PLATFORMS["wecom"] static dict in hermes_cli/gateway.py, and the
# _send_wecom dispatch in tools/send_message_tool.py. Env→PlatformConfig
# seeding stays in core, same as prior migrations.
# ──────────────────────────────────────────────────────────────────────────
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process WeCom delivery via the adapter's WebSocket send pipeline.
Implements the standalone_sender_fn contract so deliver=wecom cron jobs
succeed when cron runs separately from the gateway. Opens an ephemeral
WeComAdapter, connects, sends, and disconnects. Replaces the legacy
_send_wecom helper.
"""
if not check_wecom_requirements():
return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."}
try:
adapter = WeComAdapter(pconfig)
connected = await adapter.connect()
if not connected:
return {"error": f"WeCom: failed to connect - {getattr(adapter, 'fatal_error_message', None) or 'unknown error'}"}
try:
result = await adapter.send(chat_id, message)
if not result.success:
return {"error": f"WeCom send failed: {result.error}"}
return {
"success": True,
"platform": "wecom",
"chat_id": chat_id,
"message_id": result.message_id,
}
finally:
await adapter.disconnect()
except Exception as e:
return {"error": f"WeCom send failed: {e}"}
def interactive_setup() -> None:
"""Interactive setup for WeCom — QR scan or manual credential input.
Replaces hermes_cli/gateway.py::_setup_wecom and the static
_PLATFORMS["wecom"] dict. CLI helpers are lazy-imported.
"""
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.setup import prompt_choice
from hermes_cli.cli_output import (
prompt,
prompt_yes_no,
print_header,
print_info,
print_success,
print_warning,
print_error,
)
print_header("WeCom (Enterprise WeChat)")
existing_bot_id = get_env_value("WECOM_BOT_ID")
existing_secret = get_env_value("WECOM_SECRET")
if existing_bot_id and existing_secret:
print_success("WeCom is already configured.")
if not prompt_yes_no("Reconfigure WeCom?", False):
return
method_idx = prompt_choice(
"How would you like to set up WeCom?",
[
"Scan QR code to obtain Bot ID and Secret automatically (recommended)",
"Enter existing Bot ID and Secret manually",
],
0,
)
bot_id = None
secret = None
if method_idx == 0:
try:
credentials = qr_scan_for_bot_info()
except KeyboardInterrupt:
print_warning("WeCom setup cancelled.")
return
except Exception as exc:
print_warning(f"QR scan failed: {exc}")
credentials = None
if credentials:
bot_id = credentials.get("bot_id", "")
secret = credentials.get("secret", "")
print_success("✔ QR scan successful! Bot ID and Secret obtained.")
if not bot_id or not secret:
print_info("QR scan did not complete. Continuing with manual input.")
bot_id = None
secret = None
if not bot_id or not secret:
print_info("1. Go to WeCom Application → Workspace → Smart Robot -> Create smart robots")
print_info("2. Select API Mode")
print_info("3. Copy the Bot ID and Secret from the bot's credentials info")
print_info("4. The bot connects via WebSocket — no public endpoint needed")
bot_id = prompt("Bot ID", password=False)
if not bot_id:
print_warning("Skipped — WeCom won't work without a Bot ID.")
return
secret = prompt("Secret", password=True)
if not secret:
print_warning("Skipped — WeCom won't work without a Secret.")
return
save_env_value("WECOM_BOT_ID", bot_id)
save_env_value("WECOM_SECRET", secret)
print_info("The gateway DENIES all users by default for security.")
print_info("Enter user IDs to create an allowlist, or leave empty.")
allowed = prompt("Allowed user IDs (comma-separated, or empty)", password=False)
if allowed:
save_env_value("WECOM_ALLOWED_USERS", allowed.replace(" ", ""))
print_success("Saved — only these users can interact with the bot.")
else:
access_idx = prompt_choice(
"How should unauthorized users be handled?",
[
"Enable open access (anyone can message the bot)",
"Use DM pairing (unknown users request access, you approve with 'hermes pairing approve')",
"Disable direct messages",
"Skip for now (bot will deny all users until configured)",
],
1,
)
if access_idx == 0:
save_env_value("WECOM_DM_POLICY", "open")
save_env_value("GATEWAY_ALLOW_ALL_USERS", "true")
print_warning("Open access enabled — anyone can use your bot!")
elif access_idx == 1:
save_env_value("WECOM_DM_POLICY", "pairing")
print_success("DM pairing mode — users will receive a code to request access.")
print_info("Approve with: hermes pairing approve <platform> <code>")
elif access_idx == 2:
save_env_value("WECOM_DM_POLICY", "disabled")
print_warning("Direct messages disabled.")
else:
print_info("Skipped — configure later with 'hermes gateway setup'")
home = prompt("Home chat ID (optional, for cron/notifications)", password=False)
if home:
save_env_value("WECOM_HOME_CHANNEL", home)
print_success(f"Home channel set to {home}")
print_success("💬 WeCom configured!")
def _is_connected(config) -> bool:
"""WeCom (Smart Robot) is connected when a bot_id is configured. Mirrors the
legacy _PLATFORM_CONNECTED_CHECKERS[Platform.WECOM] entry."""
extra = getattr(config, "extra", {}) or {}
return bool(extra.get("bot_id"))
def _callback_is_connected(config) -> bool:
"""WeCom callback mode is connected when corp_id (or a multi-app `apps`
block) is configured. Mirrors the legacy
_PLATFORM_CONNECTED_CHECKERS[Platform.WECOM_CALLBACK] entry."""
extra = getattr(config, "extra", {}) or {}
return bool(extra.get("corp_id") or extra.get("apps"))
def _build_adapter(config):
"""Factory wrapper that constructs WeComAdapter from a PlatformConfig."""
return WeComAdapter(config)
def _build_callback_adapter(config):
"""Factory wrapper that constructs WecomCallbackAdapter from a PlatformConfig."""
from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter
return WecomCallbackAdapter(config)
def register(ctx) -> None:
"""Plugin entry point — registers both WeCom platforms."""
ctx.register_platform(
name="wecom",
label="WeCom (Enterprise WeChat)",
adapter_factory=_build_adapter,
check_fn=check_wecom_requirements,
is_connected=_is_connected,
validate_config=_is_connected,
required_env=["WECOM_BOT_ID", "WECOM_SECRET"],
install_hint="pip install 'hermes-agent[wecom]'",
setup_fn=interactive_setup,
allowed_users_env="WECOM_ALLOWED_USERS",
allow_all_env="WECOM_ALLOW_ALL_USERS",
cron_deliver_env_var="WECOM_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
max_message_length=4000,
emoji="💼",
allow_update_command=True,
)
from plugins.platforms.wecom.callback_adapter import check_wecom_callback_requirements
ctx.register_platform(
name="wecom_callback",
label="WeCom Callback (self-built apps)",
adapter_factory=_build_callback_adapter,
check_fn=check_wecom_callback_requirements,
is_connected=_callback_is_connected,
validate_config=_callback_is_connected,
required_env=["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
install_hint="pip install 'hermes-agent[wecom]'",
allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS",
allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS",
emoji="💼",
allow_update_command=True,
)

View file

@ -47,7 +47,7 @@ except ImportError:
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
from gateway.platforms.wecom_crypto import WXBizMsgCrypt, WeComCryptoError
from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt, WeComCryptoError
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,52 @@
name: wecom-platform
label: WeCom (Enterprise WeChat)
kind: platform
version: 1.0.0
description: >
WeCom / Enterprise WeChat gateway adapter for Hermes Agent. Registers two
platforms: ``wecom`` (Smart Robot over WebSocket) and ``wecom_callback``
(self-built apps over an HTTP callback endpoint with AES message crypto).
Relays messages between WeCom chats and the Hermes agent.
author: NousResearch
requires_env:
- name: WECOM_BOT_ID
description: "WeCom Smart Robot bot ID"
prompt: "WeCom bot ID"
password: false
- name: WECOM_SECRET
description: "WeCom Smart Robot secret"
prompt: "WeCom secret"
password: true
optional_env:
- name: WECOM_WEBSOCKET_URL
description: "WeCom Smart Robot WebSocket URL"
prompt: "WeCom WebSocket URL"
password: false
- name: WECOM_HOME_CHANNEL
description: "Default chat ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: WECOM_ALLOWED_USERS
description: "Comma-separated WeCom user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: WECOM_CALLBACK_CORP_ID
description: "WeCom callback-mode corp ID (self-built apps)"
prompt: "WeCom callback corp ID"
password: false
- name: WECOM_CALLBACK_CORP_SECRET
description: "WeCom callback-mode corp secret"
prompt: "WeCom callback corp secret"
password: true
- name: WECOM_CALLBACK_AGENT_ID
description: "WeCom callback-mode agent ID"
prompt: "WeCom callback agent ID"
password: false
- name: WECOM_CALLBACK_TOKEN
description: "WeCom callback verification token"
prompt: "WeCom callback token"
password: true
- name: WECOM_CALLBACK_ENCODING_AES_KEY
description: "WeCom callback EncodingAESKey for message crypto"
prompt: "WeCom callback EncodingAESKey"
password: true

View file

@ -0,0 +1,3 @@
from .adapter import register
__all__ = ["register"]

View file

@ -1195,3 +1195,190 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
except Exception as e:
print(f"[{self.name}] Error building event: {e}")
return None
# ──────────────────────────────────────────────────────────────────────────
# Plugin migration glue (#41112 / #3823)
#
# Added when the WhatsApp adapter moved from gateway/platforms/whatsapp.py into
# this bundled plugin. Mirrors the Discord (#24356) / Slack migrations: a
# register(ctx) entry point plus hook implementations that replace the
# per-platform core touchpoints (the Platform.WHATSAPP elif in gateway/run.py,
# the whatsapp_cfg YAML→env block + _PLATFORM_CONNECTED_CHECKERS entry in
# gateway/config.py, the _setup_whatsapp wizard + _PLATFORMS["whatsapp"] static
# dict in hermes_cli/gateway.py, and the _send_whatsapp dispatch in
# tools/send_message_tool.py). WhatsApp auth is handled by the Node.js bridge,
# so is_connected is always True (matches the legacy checker).
# ──────────────────────────────────────────────────────────────────────────
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
"""Out-of-process WhatsApp delivery via the local bridge HTTP API.
Implements the standalone_sender_fn contract so deliver=whatsapp cron jobs
succeed when cron runs separately from the gateway. Replaces the legacy
_send_whatsapp helper.
"""
extra = getattr(pconfig, "extra", {}) or {}
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try:
bridge_port = extra.get("bridge_port", 3000)
async with aiohttp.ClientSession() as session:
async with session.post(
f"http://localhost:{bridge_port}/send",
json={"chatId": chat_id, "message": message},
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status == 200:
data = await resp.json()
return {
"success": True,
"platform": "whatsapp",
"chat_id": chat_id,
"message_id": data.get("messageId"),
}
body = await resp.text()
return {"error": f"WhatsApp bridge error ({resp.status}): {body}"}
except Exception as e:
return {"error": f"WhatsApp send failed: {e}"}
def interactive_setup() -> None:
"""Guide the user through WhatsApp setup.
Replaces the central _setup_whatsapp in hermes_cli/gateway.py and the
static _PLATFORMS["whatsapp"] dict. CLI helpers are lazy-imported so the
plugin's module-load surface stays minimal.
"""
from hermes_cli.config import get_env_value, save_env_value
from hermes_cli.cli_output import (
prompt,
prompt_yes_no,
print_header,
print_info,
print_success,
)
print_header("WhatsApp")
print_info("WhatsApp uses a local Node.js bridge (WhatsApp Web client).")
print_info("Start the bridge separately; the gateway connects to it over HTTP.")
existing = get_env_value("WHATSAPP_ENABLED")
if existing and existing.lower() in {"true", "1", "yes"}:
print_info("WhatsApp: already enabled")
if not prompt_yes_no("Reconfigure WhatsApp?", False):
return
if prompt_yes_no("Enable WhatsApp?", True):
save_env_value("WHATSAPP_ENABLED", "true")
print_success("WhatsApp enabled")
else:
save_env_value("WHATSAPP_ENABLED", "false")
print_info("WhatsApp left disabled")
return
allowed_users = prompt(
"Allowed user IDs (comma-separated, leave empty for no allowlist)"
)
if allowed_users:
save_env_value("WHATSAPP_ALLOWED_USERS", allowed_users.replace(" ", ""))
print_success("WhatsApp allowlist configured")
home_channel = prompt("Home chat ID for cron delivery (leave empty to skip)")
if home_channel:
save_env_value("WHATSAPP_HOME_CHANNEL", home_channel.strip())
def _apply_yaml_config(yaml_cfg: dict, whatsapp_cfg: dict) -> dict | None:
"""Translate config.yaml whatsapp: keys into WHATSAPP_* env vars.
Implements the apply_yaml_config_fn contract (#24849). Mirrors the legacy
whatsapp_cfg block from gateway/config.py::load_gateway_config(). Env vars
take precedence over YAML. Returns None everything flows through env.
"""
import json as _json
if "require_mention" in whatsapp_cfg and not os.getenv("WHATSAPP_REQUIRE_MENTION"):
os.environ["WHATSAPP_REQUIRE_MENTION"] = str(whatsapp_cfg["require_mention"]).lower()
if "mention_patterns" in whatsapp_cfg and not os.getenv("WHATSAPP_MENTION_PATTERNS"):
os.environ["WHATSAPP_MENTION_PATTERNS"] = _json.dumps(whatsapp_cfg["mention_patterns"])
frc = whatsapp_cfg.get("free_response_chats")
if frc is not None and not os.getenv("WHATSAPP_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["WHATSAPP_FREE_RESPONSE_CHATS"] = str(frc)
if "dm_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_DM_POLICY"):
os.environ["WHATSAPP_DM_POLICY"] = str(whatsapp_cfg["dm_policy"]).lower()
af = whatsapp_cfg.get("allow_from")
if af is not None and not os.getenv("WHATSAPP_ALLOWED_USERS"):
if isinstance(af, list):
af = ",".join(str(v) for v in af)
os.environ["WHATSAPP_ALLOWED_USERS"] = str(af)
if "group_policy" in whatsapp_cfg and not os.getenv("WHATSAPP_GROUP_POLICY"):
os.environ["WHATSAPP_GROUP_POLICY"] = str(whatsapp_cfg["group_policy"]).lower()
gaf = whatsapp_cfg.get("group_allow_from")
if gaf is not None and not os.getenv("WHATSAPP_GROUP_ALLOWED_USERS"):
if isinstance(gaf, list):
gaf = ",".join(str(v) for v in gaf)
os.environ["WHATSAPP_GROUP_ALLOWED_USERS"] = str(gaf)
return None
def _is_connected(config) -> bool:
"""WhatsApp is considered connected when the user has explicitly enabled it
via ``WHATSAPP_ENABLED`` (or the YAML-bridged equivalent on the config).
Auth itself is handled by the external Node.js bridge we can't verify the
bridge token here so the opt-in flag is the connection signal. The legacy
built-in path keyed off ``WHATSAPP_ENABLED`` in both the connected-platforms
check and the setup-status display; returning an unconditional True here
would make WhatsApp always show as "configured" in ``hermes setup`` even
when the user never enabled it. #41112.
"""
extra = getattr(config, "extra", {}) or {}
if config is not None and getattr(config, "enabled", False) and extra:
# An explicitly-enabled PlatformConfig with seeded extras (e.g. from
# YAML) counts as configured.
return True
# Read via hermes_cli.gateway.get_env_value (not os.getenv) so setup-status
# callers that patch get_env_value — and the gateway connected-platforms
# check — observe the same value. Matches the discord/slack plugin pattern.
import hermes_cli.gateway as gateway_mod
val = (gateway_mod.get_env_value("WHATSAPP_ENABLED") or "").strip().lower()
return val in {"true", "1", "yes"}
def _build_adapter(config):
"""Factory wrapper that constructs WhatsAppAdapter from a PlatformConfig."""
return WhatsAppAdapter(config)
def register(ctx) -> None:
"""Plugin entry point — called by the Hermes plugin system."""
ctx.register_platform(
name="whatsapp",
label="WhatsApp",
adapter_factory=_build_adapter,
check_fn=check_whatsapp_requirements,
is_connected=_is_connected,
required_env=["WHATSAPP_ENABLED"],
install_hint="WhatsApp requires a Node.js bridge — see the WhatsApp messaging docs",
setup_fn=interactive_setup,
apply_yaml_config_fn=_apply_yaml_config,
allowed_users_env="WHATSAPP_ALLOWED_USERS",
allow_all_env="WHATSAPP_ALLOW_ALL_USERS",
cron_deliver_env_var="WHATSAPP_HOME_CHANNEL",
standalone_sender_fn=_standalone_send,
max_message_length=4096,
emoji="💬",
allow_update_command=True,
)

View file

@ -0,0 +1,33 @@
name: whatsapp-platform
label: WhatsApp
kind: platform
version: 1.0.0
description: >
WhatsApp gateway adapter for Hermes Agent.
Connects to WhatsApp via a local Node.js bridge (WhatsApp Web client) over
an HTTP API and relays messages between WhatsApp chats and the Hermes agent.
Supports DM/group policies, mention gating, free-response chats, and
per-user allowlists.
author: NousResearch
requires_env:
- name: WHATSAPP_ENABLED
description: "Enable the WhatsApp adapter (requires the Node.js bridge running)"
prompt: "Enable WhatsApp? (true/false)"
password: false
optional_env:
- name: WHATSAPP_ALLOWED_USERS
description: "Comma-separated WhatsApp user IDs allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: WHATSAPP_ALLOW_ALL_USERS
description: "Allow any WhatsApp user to trigger the bot (dev only)"
prompt: "Allow all users? (true/false)"
password: false
- name: WHATSAPP_HOME_CHANNEL
description: "Default chat ID for cron / notification delivery"
prompt: "Home channel ID"
password: false
- name: WHATSAPP_HOME_CHANNEL_NAME
description: "Display name for the WhatsApp home channel"
prompt: "Home channel display name"
password: false

View file

@ -118,12 +118,12 @@ _ensure_discord_mock()
_ensure_slack_mock()
import discord # noqa: E402 — mocked above
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
import gateway.platforms.slack as _slack_mod # noqa: E402
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True
from gateway.platforms.slack import SlackAdapter # noqa: E402
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
# Platform-generic factories

View file

@ -2,7 +2,7 @@
The ``_ensure_telegram_mock`` helper guarantees that a minimal mock of
the ``telegram`` package is registered in :data:`sys.modules` **before**
any test file triggers ``from gateway.platforms.telegram import ...``.
any test file triggers ``from plugins.platforms.telegram.adapter import ...``.
Without this, ``pytest-xdist`` workers that happen to collect
``test_telegram_caption_merge.py`` (bare top-level import, no per-file

View file

@ -35,7 +35,7 @@ def make_adapter_skeleton(
require_mention: bool = True,
group_policy: str = "allowlist",
) -> Any:
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
adapter = object.__new__(FeishuAdapter)
adapter._bot_open_id = bot_open_id

View file

@ -24,7 +24,7 @@ from gateway.config import Platform, PlatformConfig
# ---------------------------------------------------------------------------
def _make_telegram_adapter(*, allowed_chats=None, require_mention=None, guest_mode=False):
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
extra = {"guest_mode": guest_mode}
if allowed_chats is not None:
@ -162,8 +162,8 @@ class TestTelegramAllowedChats:
def _make_dingtalk_adapter(*, allowed_chats=None, require_mention=None):
# Import lazily — DingTalk SDK may not be installed.
pytest.importorskip("gateway.platforms.dingtalk", reason="DingTalk adapter not importable")
from gateway.platforms.dingtalk import DingTalkAdapter
pytest.importorskip("plugins.platforms.dingtalk.adapter", reason="DingTalk adapter not importable")
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
extra = {}
if allowed_chats is not None:

View file

@ -667,7 +667,7 @@ class TestLoadGatewayConfig:
telegram = config.platforms[Platform.TELEGRAM]
assert telegram.extra.get("allow_from") == ["777888999"], (
"allow_from configured under gateway.platforms.telegram must be "
"allow_from configured under plugins.platforms.telegram.adapter must be "
"bridged into PlatformConfig.extra by the shared-key loop"
)
assert telegram.extra.get("require_mention") is False

View file

@ -108,11 +108,11 @@ def test_base_adapter_defaults_to_not_owning_access_policy():
@pytest.mark.parametrize(
"module_path, class_name",
[
("gateway.platforms.wecom", "WeComAdapter"),
("plugins.platforms.wecom.adapter", "WeComAdapter"),
("gateway.platforms.weixin", "WeixinAdapter"),
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
("gateway.platforms.qqbot.adapter", "QQAdapter"),
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
],
)
def test_own_policy_adapters_declare_the_flag(module_path, class_name):

View file

@ -39,7 +39,7 @@ class _FakeChatbotMessage(SimpleNamespace):
@pytest.fixture(autouse=True)
def _fake_dingtalk_optional_sdks(monkeypatch):
"""Keep DingTalk adapter tests hermetic when optional SDKs are absent."""
from gateway.platforms import dingtalk as dt
import plugins.platforms.dingtalk.adapter as dt
card_models = SimpleNamespace(**{
name: _FakeDingTalkModel
@ -94,29 +94,29 @@ class TestDingTalkRequirements:
with patch.dict("sys.modules", {"dingtalk_stream": None}), \
patch("tools.lazy_deps.ensure", side_effect=ImportError("dingtalk_stream unavailable")):
monkeypatch.setattr(
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False
"plugins.platforms.dingtalk.adapter.DINGTALK_STREAM_AVAILABLE", False
)
from gateway.platforms.dingtalk import check_dingtalk_requirements
from plugins.platforms.dingtalk.adapter import check_dingtalk_requirements
assert check_dingtalk_requirements() is False
def test_returns_false_when_env_vars_missing(self, monkeypatch):
monkeypatch.setattr(
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", True
"plugins.platforms.dingtalk.adapter.DINGTALK_STREAM_AVAILABLE", True
)
monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True)
monkeypatch.setattr("plugins.platforms.dingtalk.adapter.HTTPX_AVAILABLE", True)
monkeypatch.delenv("DINGTALK_CLIENT_ID", raising=False)
monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False)
from gateway.platforms.dingtalk import check_dingtalk_requirements
from plugins.platforms.dingtalk.adapter import check_dingtalk_requirements
assert check_dingtalk_requirements() is False
def test_returns_true_when_all_available(self, monkeypatch):
monkeypatch.setattr(
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", True
"plugins.platforms.dingtalk.adapter.DINGTALK_STREAM_AVAILABLE", True
)
monkeypatch.setattr("gateway.platforms.dingtalk.HTTPX_AVAILABLE", True)
monkeypatch.setattr("plugins.platforms.dingtalk.adapter.HTTPX_AVAILABLE", True)
monkeypatch.setenv("DINGTALK_CLIENT_ID", "test-id")
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "test-secret")
from gateway.platforms.dingtalk import check_dingtalk_requirements
from plugins.platforms.dingtalk.adapter import check_dingtalk_requirements
assert check_dingtalk_requirements() is True
@ -128,7 +128,7 @@ class TestDingTalkRequirements:
class TestDingTalkAdapterInit:
def test_reads_config_from_extra(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
config = PlatformConfig(
enabled=True,
extra={"client_id": "cfg-id", "client_secret": "cfg-secret"},
@ -141,7 +141,7 @@ class TestDingTalkAdapterInit:
def test_falls_back_to_env_vars(self, monkeypatch):
monkeypatch.setenv("DINGTALK_CLIENT_ID", "env-id")
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env-secret")
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
config = PlatformConfig(enabled=True)
adapter = DingTalkAdapter(config)
assert adapter._client_id == "env-id"
@ -156,28 +156,28 @@ class TestDingTalkAdapterInit:
class TestExtractText:
def test_extracts_dict_text(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = {"content": " hello world "}
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == "hello world"
def test_extracts_string_text(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = "plain text"
msg.rich_text = None
assert DingTalkAdapter._extract_text(msg) == "plain text"
def test_falls_back_to_rich_text(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = ""
msg.rich_text = [{"text": "part1"}, {"text": "part2"}, {"image": "url"}]
assert DingTalkAdapter._extract_text(msg) == "part1 part2"
def test_returns_empty_for_no_content(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = ""
msg.rich_text = None
@ -192,24 +192,24 @@ class TestExtractText:
class TestDeduplication:
def test_first_message_not_duplicate(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
assert adapter._dedup.is_duplicate("msg-1") is False
def test_second_same_message_is_duplicate(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._dedup.is_duplicate("msg-1")
assert adapter._dedup.is_duplicate("msg-1") is True
def test_different_messages_not_duplicate(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._dedup.is_duplicate("msg-1")
assert adapter._dedup.is_duplicate("msg-2") is False
def test_cache_cleanup_on_overflow(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
max_size = adapter._dedup._max_size
# Fill beyond max
@ -228,7 +228,7 @@ class TestSend:
@pytest.mark.asyncio
async def test_send_posts_to_webhook(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
mock_response = MagicMock()
@ -254,7 +254,7 @@ class TestSend:
@pytest.mark.asyncio
async def test_send_fails_without_webhook(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._http_client = AsyncMock()
@ -264,7 +264,7 @@ class TestSend:
@pytest.mark.asyncio
async def test_send_uses_cached_webhook(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
mock_response = MagicMock()
@ -280,7 +280,7 @@ class TestSend:
@pytest.mark.asyncio
async def test_send_handles_http_error(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
mock_response = MagicMock()
@ -299,7 +299,7 @@ class TestSend:
@pytest.mark.asyncio
async def test_send_image_renders_markdown_image(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
mock_response = MagicMock()
@ -324,7 +324,7 @@ class TestSend:
@pytest.mark.asyncio
async def test_send_image_file_returns_explicit_unsupported_error(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
result = await adapter.send_image_file("chat-123", "/tmp/demo.png")
@ -334,7 +334,7 @@ class TestSend:
@pytest.mark.asyncio
async def test_send_document_returns_explicit_unsupported_error(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
result = await adapter.send_document("chat-123", "/tmp/demo.pdf")
@ -352,7 +352,7 @@ class TestConnect:
@pytest.mark.asyncio
async def test_disconnect_closes_session_websocket(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
websocket = AsyncMock()
@ -376,16 +376,16 @@ class TestConnect:
@pytest.mark.asyncio
async def test_connect_fails_without_sdk(self, monkeypatch):
monkeypatch.setattr(
"gateway.platforms.dingtalk.DINGTALK_STREAM_AVAILABLE", False
"plugins.platforms.dingtalk.adapter.DINGTALK_STREAM_AVAILABLE", False
)
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
result = await adapter.connect()
assert result is False
@pytest.mark.asyncio
async def test_connect_fails_without_credentials(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._client_id = ""
adapter._client_secret = ""
@ -394,7 +394,7 @@ class TestConnect:
@pytest.mark.asyncio
async def test_disconnect_cleans_up(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._session_webhooks["a"] = "http://x"
adapter._dedup._seen["b"] = 1.0
@ -410,7 +410,7 @@ class TestConnect:
async def test_disconnect_finalizes_open_streaming_cards(self):
"""Streaming cards must be finalized before HTTP client closes."""
from unittest.mock import AsyncMock, patch
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._http_client = AsyncMock()
adapter._stream_task = None
@ -456,29 +456,29 @@ class TestWebhookDomainAllowlist:
"""
def test_api_domain_accepted(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
assert _DINGTALK_WEBHOOK_RE.match(
"https://api.dingtalk.com/robot/send?access_token=x"
)
def test_oapi_domain_accepted(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
assert _DINGTALK_WEBHOOK_RE.match(
"https://oapi.dingtalk.com/robot/send?access_token=x"
)
def test_http_rejected(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
assert not _DINGTALK_WEBHOOK_RE.match("http://api.dingtalk.com/robot/send")
def test_suffix_attack_rejected(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
assert not _DINGTALK_WEBHOOK_RE.match(
"https://api.dingtalk.com.evil.example/"
)
def test_unsanctioned_subdomain_rejected(self):
from gateway.platforms.dingtalk import _DINGTALK_WEBHOOK_RE
from plugins.platforms.dingtalk.adapter import _DINGTALK_WEBHOOK_RE
# Only api.* and oapi.* are allowed — e.g. eapi.dingtalk.com must not slip through
assert not _DINGTALK_WEBHOOK_RE.match("https://eapi.dingtalk.com/robot/send")
@ -487,7 +487,7 @@ class TestHandlerProcessIsAsync:
"""dingtalk-stream >= 0.20 requires ``process`` to be a coroutine."""
def test_process_is_coroutine_function(self):
from gateway.platforms.dingtalk import _IncomingHandler
from plugins.platforms.dingtalk.adapter import _IncomingHandler
assert asyncio.iscoroutinefunction(_IncomingHandler.process)
@ -501,7 +501,7 @@ class TestExtractText:
"""
def test_text_as_dict_legacy(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = {"content": "hello world"}
msg.rich_text_content = None
@ -510,7 +510,7 @@ class TestExtractText:
def test_text_as_textcontent_object(self):
"""SDK >= 0.20 shape: object with ``.content`` attribute."""
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
class FakeTextContent:
content = "hello from new sdk"
@ -527,7 +527,7 @@ class TestExtractText:
assert "TextContent(" not in result
def test_text_content_attr_with_empty_string(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
class FakeTextContent:
content = ""
@ -540,7 +540,7 @@ class TestExtractText:
def test_rich_text_content_new_shape(self):
"""SDK >= 0.20 exposes rich text as ``message.rich_text_content.rich_text_list``."""
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
class FakeRichText:
rich_text_list = [{"text": "hello "}, {"text": "world"}]
@ -554,7 +554,7 @@ class TestExtractText:
def test_rich_text_legacy_shape(self):
"""Legacy ``message.rich_text`` list remains supported."""
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text_content = None
@ -563,7 +563,7 @@ class TestExtractText:
assert "legacy" in result and "rich" in result
def test_empty_message(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
msg = MagicMock()
msg.text = None
msg.rich_text_content = None
@ -586,7 +586,7 @@ class TestExtractMedia:
def test_voice_rich_text_item_classified_as_voice(self):
"""Native DingTalk voice notes (type=voice) must enter the auto-STT
path via MessageType.VOICE the gateway skips STT for AUDIO."""
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
from gateway.platforms.base import MessageType
msg = self._msg_with_rich_text(
@ -602,7 +602,7 @@ class TestExtractMedia:
def test_audio_rich_text_item_stays_audio(self):
"""Generic audio uploads (e.g. an mp3 the user attached) must NOT
be auto-transcribed they stay MessageType.AUDIO."""
from gateway.platforms.dingtalk import DingTalkAdapter, DINGTALK_TYPE_MAPPING
from plugins.platforms.dingtalk.adapter import DingTalkAdapter, DINGTALK_TYPE_MAPPING
from gateway.platforms.base import MessageType
# Simulate a future/non-voice audio rich-text item by extending the
@ -643,7 +643,7 @@ def _make_gating_adapter(monkeypatch, *, extra=None, env=None):
monkeypatch.delenv(key, raising=False)
for key, value in (env or {}).items():
monkeypatch.setenv(key, value)
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
return DingTalkAdapter(PlatformConfig(enabled=True, extra=extra or {}))
@ -790,7 +790,7 @@ class TestIncomingHandlerProcess:
@pytest.mark.asyncio
async def test_process_extracts_session_webhook(self):
"""session_webhook must be populated from callback data."""
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
from plugins.platforms.dingtalk.adapter import _IncomingHandler, DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._on_message = AsyncMock()
@ -823,7 +823,7 @@ class TestIncomingHandlerProcess:
"""If ChatbotMessage.from_dict does not map sessionWebhook (e.g. SDK
version mismatch), the handler should fall back to extracting it
directly from the raw data dict."""
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
from plugins.platforms.dingtalk.adapter import _IncomingHandler, DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
adapter._on_message = AsyncMock()
@ -851,7 +851,7 @@ class TestIncomingHandlerProcess:
async def test_process_returns_ack_immediately(self):
"""process() must not block on _on_message — it should return
the ACK tuple before the message is fully processed."""
from gateway.platforms.dingtalk import _IncomingHandler, DingTalkAdapter
from plugins.platforms.dingtalk.adapter import _IncomingHandler, DingTalkAdapter
processing_started = asyncio.Event()
processing_gate = asyncio.Event()
@ -895,7 +895,7 @@ class TestExtractTextMentions:
Stripping all @handles collateral-damages emails, SSH URLs, and
literal references the user wrote.
"""
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
cases = [
("@bot hello", "@bot hello"),
("contact alice@example.com", "contact alice@example.com"),
@ -928,7 +928,7 @@ class TestMessageContextIsolation:
def test_contexts_keyed_by_chat_id(self):
"""Two concurrent chats must not clobber each other's context."""
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(PlatformConfig(enabled=True))
msg_a = MagicMock(conversation_id="chat-A", sender_staff_id="user-A")
@ -953,7 +953,7 @@ class TestCardLifecycle:
@pytest.fixture
def adapter_with_card(self):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
a = DingTalkAdapter(PlatformConfig(
enabled=True,
extra={"card_template_id": "tmpl-1"},
@ -1144,7 +1144,7 @@ class TestDingTalkAdapterAICards:
@pytest.mark.asyncio
async def test_send_uses_ai_card_if_configured(self, config, mock_stream_client, mock_http_client, mock_message):
from gateway.platforms.dingtalk import DingTalkAdapter
from plugins.platforms.dingtalk.adapter import DingTalkAdapter
adapter = DingTalkAdapter(config)
adapter._stream_client = mock_stream_client

View file

@ -40,12 +40,12 @@ def _ensure_telegram_mock():
sys.modules["telegram.request"] = telegram_mod.request
# Force reimport so the adapter picks up the mock ChatType.
sys.modules.pop("gateway.platforms.telegram", None)
sys.modules.pop("plugins.platforms.telegram.adapter", None)
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
def _make_adapter(dm_topics_config=None, group_topics_config=None):

View file

@ -72,19 +72,19 @@ class TestCheckRequirements(unittest.TestCase):
"EMAIL_SMTP_HOST": "smtp.b.com",
}, clear=False)
def test_requirements_met(self):
from gateway.platforms.email import check_email_requirements
from plugins.platforms.email.adapter import check_email_requirements
self.assertTrue(check_email_requirements())
@patch.dict(os.environ, {
"EMAIL_ADDRESS": "a@b.com",
}, clear=True)
def test_requirements_not_met(self):
from gateway.platforms.email import check_email_requirements
from plugins.platforms.email.adapter import check_email_requirements
self.assertFalse(check_email_requirements())
@patch.dict(os.environ, {}, clear=True)
def test_requirements_empty_env(self):
from gateway.platforms.email import check_email_requirements
from plugins.platforms.email.adapter import check_email_requirements
self.assertFalse(check_email_requirements())
@ -92,39 +92,39 @@ class TestHelperFunctions(unittest.TestCase):
"""Test email parsing helper functions."""
def test_decode_header_plain(self):
from gateway.platforms.email import _decode_header_value
from plugins.platforms.email.adapter import _decode_header_value
self.assertEqual(_decode_header_value("Hello World"), "Hello World")
def test_decode_header_encoded(self):
from gateway.platforms.email import _decode_header_value
from plugins.platforms.email.adapter import _decode_header_value
# RFC 2047 encoded subject
encoded = "=?utf-8?B?TWVyaGFiYQ==?=" # "Merhaba" in base64
result = _decode_header_value(encoded)
self.assertEqual(result, "Merhaba")
def test_extract_email_address_with_name(self):
from gateway.platforms.email import _extract_email_address
from plugins.platforms.email.adapter import _extract_email_address
self.assertEqual(
_extract_email_address("John Doe <john@example.com>"),
"john@example.com"
)
def test_extract_email_address_bare(self):
from gateway.platforms.email import _extract_email_address
from plugins.platforms.email.adapter import _extract_email_address
self.assertEqual(
_extract_email_address("john@example.com"),
"john@example.com"
)
def test_extract_email_address_uppercase(self):
from gateway.platforms.email import _extract_email_address
from plugins.platforms.email.adapter import _extract_email_address
self.assertEqual(
_extract_email_address("John@Example.COM"),
"john@example.com"
)
def test_strip_html_basic(self):
from gateway.platforms.email import _strip_html
from plugins.platforms.email.adapter import _strip_html
html = "<p>Hello <b>world</b></p>"
result = _strip_html(html)
self.assertIn("Hello", result)
@ -133,14 +133,14 @@ class TestHelperFunctions(unittest.TestCase):
self.assertNotIn("<b>", result)
def test_strip_html_br_tags(self):
from gateway.platforms.email import _strip_html
from plugins.platforms.email.adapter import _strip_html
html = "Line 1<br>Line 2<br/>Line 3"
result = _strip_html(html)
self.assertIn("Line 1", result)
self.assertIn("Line 2", result)
def test_strip_html_entities(self):
from gateway.platforms.email import _strip_html
from plugins.platforms.email.adapter import _strip_html
html = "a &amp; b &lt; c &gt; d"
result = _strip_html(html)
self.assertIn("a & b", result)
@ -150,20 +150,20 @@ class TestExtractTextBody(unittest.TestCase):
"""Test email body extraction from different message formats."""
def test_plain_text_body(self):
from gateway.platforms.email import _extract_text_body
from plugins.platforms.email.adapter import _extract_text_body
msg = MIMEText("Hello, this is a test.", "plain", "utf-8")
result = _extract_text_body(msg)
self.assertEqual(result, "Hello, this is a test.")
def test_html_body_fallback(self):
from gateway.platforms.email import _extract_text_body
from plugins.platforms.email.adapter import _extract_text_body
msg = MIMEText("<p>Hello from HTML</p>", "html", "utf-8")
result = _extract_text_body(msg)
self.assertIn("Hello from HTML", result)
self.assertNotIn("<p>", result)
def test_multipart_prefers_plain(self):
from gateway.platforms.email import _extract_text_body
from plugins.platforms.email.adapter import _extract_text_body
msg = MIMEMultipart("alternative")
msg.attach(MIMEText("<p>HTML version</p>", "html", "utf-8"))
msg.attach(MIMEText("Plain version", "plain", "utf-8"))
@ -171,14 +171,14 @@ class TestExtractTextBody(unittest.TestCase):
self.assertEqual(result, "Plain version")
def test_multipart_html_only(self):
from gateway.platforms.email import _extract_text_body
from plugins.platforms.email.adapter import _extract_text_body
msg = MIMEMultipart("alternative")
msg.attach(MIMEText("<p>Only HTML</p>", "html", "utf-8"))
result = _extract_text_body(msg)
self.assertIn("Only HTML", result)
def test_empty_body(self):
from gateway.platforms.email import _extract_text_body
from plugins.platforms.email.adapter import _extract_text_body
msg = MIMEText("", "plain", "utf-8")
result = _extract_text_body(msg)
self.assertEqual(result, "")
@ -188,14 +188,14 @@ class TestExtractAttachments(unittest.TestCase):
"""Test attachment extraction and caching."""
def test_no_attachments(self):
from gateway.platforms.email import _extract_attachments
from plugins.platforms.email.adapter import _extract_attachments
msg = MIMEText("No attachments here.", "plain", "utf-8")
result = _extract_attachments(msg)
self.assertEqual(result, [])
@patch("gateway.platforms.email.cache_document_from_bytes")
@patch("plugins.platforms.email.adapter.cache_document_from_bytes")
def test_document_attachment(self, mock_cache):
from gateway.platforms.email import _extract_attachments
from plugins.platforms.email.adapter import _extract_attachments
mock_cache.return_value = "/tmp/cached_doc.pdf"
msg = MIMEMultipart()
@ -213,9 +213,9 @@ class TestExtractAttachments(unittest.TestCase):
self.assertEqual(result[0]["filename"], "report.pdf")
mock_cache.assert_called_once()
@patch("gateway.platforms.email.cache_image_from_bytes")
@patch("plugins.platforms.email.adapter.cache_image_from_bytes")
def test_image_attachment(self, mock_cache):
from gateway.platforms.email import _extract_attachments
from plugins.platforms.email.adapter import _extract_attachments
mock_cache.return_value = "/tmp/cached_img.jpg"
msg = MIMEMultipart()
@ -248,7 +248,7 @@ class TestDispatchMessage(unittest.TestCase):
"EMAIL_SMTP_PORT": "587",
"EMAIL_POLL_INTERVAL": "15",
}):
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter
@ -582,7 +582,7 @@ class TestThreadContext(unittest.TestCase):
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter
@ -679,7 +679,7 @@ class TestSendMethods(unittest.TestCase):
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter
@ -798,7 +798,7 @@ class TestConnectDisconnect(unittest.TestCase):
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter
@ -876,7 +876,7 @@ class TestFetchNewMessages(unittest.TestCase):
"EMAIL_IMAP_HOST": "imap.test.com",
"EMAIL_SMTP_HOST": "smtp.test.com",
}):
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter
@ -970,7 +970,7 @@ class TestPollLoop(unittest.TestCase):
"EMAIL_SMTP_HOST": "smtp.test.com",
"EMAIL_POLL_INTERVAL": "1",
}):
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter
@ -1021,7 +1021,10 @@ class TestSendEmailStandalone(unittest.TestCase):
"""_send_email should use verified STARTTLS when sending."""
import asyncio
import ssl
from tools.send_message_tool import _send_email
from plugins.platforms.email.adapter import _standalone_send as _email_send
from types import SimpleNamespace
async def _send_email(extra, chat_id, message):
return await _email_send(SimpleNamespace(token=None, api_key=None, extra=extra or {}), chat_id, message)
with patch("smtplib.SMTP") as mock_smtp:
mock_server = MagicMock()
@ -1049,7 +1052,10 @@ class TestSendEmailStandalone(unittest.TestCase):
def test_send_email_tool_failure(self):
"""SMTP failure should return error dict."""
import asyncio
from tools.send_message_tool import _send_email
from plugins.platforms.email.adapter import _standalone_send as _email_send
from types import SimpleNamespace
async def _send_email(extra, chat_id, message):
return await _email_send(SimpleNamespace(token=None, api_key=None, extra=extra or {}), chat_id, message)
with patch("smtplib.SMTP", side_effect=Exception("SMTP error")):
result = asyncio.run(
@ -1063,7 +1069,10 @@ class TestSendEmailStandalone(unittest.TestCase):
def test_send_email_tool_not_configured(self):
"""Missing config should return error."""
import asyncio
from tools.send_message_tool import _send_email
from plugins.platforms.email.adapter import _standalone_send as _email_send
from types import SimpleNamespace
async def _send_email(extra, chat_id, message):
return await _email_send(SimpleNamespace(token=None, api_key=None, extra=extra or {}), chat_id, message)
result = asyncio.run(
_send_email({}, "user@test.com", "Hello")
@ -1085,7 +1094,7 @@ class TestSmtpConnectionCleanup(unittest.TestCase):
}, clear=False)
def _make_adapter(self):
from gateway.config import PlatformConfig
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
return EmailAdapter(PlatformConfig(enabled=True))
@patch.dict(os.environ, {
@ -1140,7 +1149,7 @@ class TestImapConnectionCleanup(unittest.TestCase):
}, clear=False)
def _make_adapter(self):
from gateway.config import PlatformConfig
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
return EmailAdapter(PlatformConfig(enabled=True))
@patch.dict(os.environ, {
@ -1205,7 +1214,7 @@ class TestImapIdExtensionForNetEase(unittest.TestCase):
"EMAIL_IMAP_HOST": "imap.163.com",
"EMAIL_SMTP_HOST": "smtp.163.com",
}):
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
return adapter
@ -1256,7 +1265,7 @@ class TestImapIdExtensionForNetEase(unittest.TestCase):
def test_send_imap_id_swallows_errors_for_non_supporting_servers(self):
"""Servers that reject ID must not break the connection."""
from gateway.platforms.email import _send_imap_id
from plugins.platforms.email.adapter import _send_imap_id
mock_imap = MagicMock()
mock_imap.xatom.side_effect = Exception("BAD command unknown: ID")
@ -1277,7 +1286,7 @@ class TestConnectSmtp(unittest.TestCase):
"EMAIL_SMTP_HOST": "smtp.test.com",
"EMAIL_SMTP_PORT": port,
}):
from gateway.platforms.email import EmailAdapter
from plugins.platforms.email.adapter import EmailAdapter
return EmailAdapter(PlatformConfig(enabled=True))
def test_port_587_uses_smtp_with_starttls(self):
@ -1314,7 +1323,7 @@ class TestConnectSmtp(unittest.TestCase):
def test_ipv6_timeout_falls_back_to_ipv4(self):
"""When default connection times out, retry with an IPv4-only SMTP path."""
import socket as _socket
from gateway.platforms import email as email_mod
import plugins.platforms.email.adapter as email_mod
adapter = self._make_adapter("587")
@ -1332,7 +1341,7 @@ class TestConnectSmtp(unittest.TestCase):
def test_port_465_ipv6_fallback(self):
"""Port 465 IPv6 timeout falls back to IPv4 with SMTP_SSL."""
import socket as _socket
from gateway.platforms import email as email_mod
import plugins.platforms.email.adapter as email_mod
adapter = self._make_adapter("465")
@ -1351,7 +1360,7 @@ class TestConnectSmtp(unittest.TestCase):
def test_tls_verification_error_does_not_retry_ipv4(self):
"""Certificate failures are security errors, not IPv6 reachability failures."""
import ssl as _ssl
from gateway.platforms import email as email_mod
import plugins.platforms.email.adapter as email_mod
adapter = self._make_adapter("465")
@ -1365,7 +1374,7 @@ class TestConnectSmtp(unittest.TestCase):
def test_ipv4_connection_does_not_mutate_global_resolver(self):
"""IPv4 fallback must not monkeypatch process-global socket state."""
import socket as _socket
from gateway.platforms.email import _create_ipv4_connection
from plugins.platforms.email.adapter import _create_ipv4_connection
original_getaddrinfo = _socket.getaddrinfo
fake_sock = MagicMock()

File diff suppressed because it is too large Load diff

View file

@ -38,8 +38,8 @@ def _ensure_feishu_mocks():
_ensure_feishu_mocks()
from gateway.config import PlatformConfig
import gateway.platforms.feishu as feishu_module
from gateway.platforms.feishu import FeishuAdapter
import plugins.platforms.feishu.adapter as feishu_module
from plugins.platforms.feishu.adapter import FeishuAdapter
# ---------------------------------------------------------------------------

View file

@ -28,7 +28,7 @@ from tests.gateway.feishu_helpers import (
],
)
def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expected):
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
@ -39,7 +39,7 @@ def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expec
def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch):
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
@ -51,7 +51,7 @@ def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch):
def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch):
# extra is ignored — env is single source of truth (yaml is bridged to env).
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
@ -62,7 +62,7 @@ def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch):
def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch):
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
@ -75,13 +75,13 @@ def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch):
def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog):
import logging
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "menton") # typo
with caplog.at_level(logging.WARNING, logger="gateway.platforms.feishu"):
with caplog.at_level(logging.WARNING, logger="plugins.platforms.feishu.adapter"):
settings = FeishuAdapter._load_settings(extra={})
assert settings.allow_bots == "none"
@ -98,7 +98,7 @@ def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog):
],
)
def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, expected):
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
@ -112,7 +112,7 @@ def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, exp
def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch):
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
@ -133,7 +133,7 @@ def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch):
def test_sender_identity_collects_every_non_empty_id_variant():
from gateway.platforms.feishu import _sender_identity
from plugins.platforms.feishu.adapter import _sender_identity
sender = SimpleNamespace(
sender_id=SimpleNamespace(open_id="ou_x", user_id="", union_id="un_x"),
@ -142,21 +142,21 @@ def test_sender_identity_collects_every_non_empty_id_variant():
def test_sender_identity_handles_missing_sender_id():
from gateway.platforms.feishu import _sender_identity
from plugins.platforms.feishu.adapter import _sender_identity
assert _sender_identity(SimpleNamespace()) == frozenset()
@pytest.mark.parametrize("sender_type", ["bot", "app"])
def test_is_bot_sender_treats_bot_and_app_as_bot_origin(sender_type):
from gateway.platforms.feishu import _is_bot_sender
from plugins.platforms.feishu.adapter import _is_bot_sender
assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is True
@pytest.mark.parametrize("sender_type", ["user", "", None])
def test_is_bot_sender_rejects_non_bot_origin(sender_type):
from gateway.platforms.feishu import _is_bot_sender
from plugins.platforms.feishu.adapter import _is_bot_sender
assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is False
@ -430,7 +430,7 @@ def test_admit_group_mention_checked_once_per_call():
def test_admit_per_group_require_mention_overrides_global():
from gateway.platforms.feishu import FeishuGroupRule
from plugins.platforms.feishu.adapter import FeishuGroupRule
adapter = make_adapter_skeleton(
bot_open_id="ou_self", require_mention=True, group_policy="open",
@ -454,7 +454,7 @@ def test_admit_per_group_require_mention_overrides_global():
def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):
import asyncio
from gateway.platforms import feishu as feishu_mod
import plugins.platforms.feishu.adapter as feishu_mod
FeishuAdapter = feishu_mod.FeishuAdapter
class _FakeBaseRequestBuilder:
@ -515,7 +515,7 @@ def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):
def test_resolve_sender_profile_uses_open_id_for_bot_name_lookup():
import asyncio
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
adapter = object.__new__(FeishuAdapter)
adapter._client = object()
@ -569,7 +569,7 @@ def _group_case(
def _group_rule(policy: str, **kwargs):
from gateway.platforms.feishu import FeishuGroupRule
from plugins.platforms.feishu.adapter import FeishuGroupRule
return FeishuGroupRule(policy=policy, **kwargs)

View file

@ -5,7 +5,7 @@ import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from gateway.platforms.feishu_comment import (
from plugins.platforms.feishu.feishu_comment import (
parse_drive_comment_event,
_ALLOWED_NOTICE_TYPES,
_sanitize_comment_text,
@ -62,45 +62,45 @@ class TestEventFiltering(unittest.TestCase):
def _run(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
@patch("gateway.platforms.feishu_comment_rules.load_config")
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed")
def test_self_reply_filtered(self, mock_allowed, mock_resolve, mock_load):
"""Events where from_open_id == self_open_id should be dropped."""
from gateway.platforms.feishu_comment import handle_drive_comment_event
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
evt = _make_event(from_open_id="ou_bot", to_open_id="ou_bot")
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
mock_load.assert_not_called()
@patch("gateway.platforms.feishu_comment_rules.load_config")
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed")
def test_wrong_receiver_filtered(self, mock_allowed, mock_resolve, mock_load):
"""Events where to_open_id != self_open_id should be dropped."""
from gateway.platforms.feishu_comment import handle_drive_comment_event
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
evt = _make_event(to_open_id="ou_other_bot")
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
mock_load.assert_not_called()
@patch("gateway.platforms.feishu_comment_rules.load_config")
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed")
def test_empty_to_open_id_filtered(self, mock_allowed, mock_resolve, mock_load):
"""Events with empty to_open_id should be dropped."""
from gateway.platforms.feishu_comment import handle_drive_comment_event
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
evt = _make_event(to_open_id="")
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
mock_load.assert_not_called()
@patch("gateway.platforms.feishu_comment_rules.load_config")
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed")
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed")
def test_invalid_notice_type_filtered(self, mock_allowed, mock_resolve, mock_load):
"""Events with unsupported notice_type should be dropped."""
from gateway.platforms.feishu_comment import handle_drive_comment_event
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
evt = _make_event(notice_type="resolve_comment")
self._run(handle_drive_comment_event(Mock(), evt, self_open_id="ou_bot"))
@ -116,14 +116,14 @@ class TestAccessControlIntegration(unittest.TestCase):
def _run(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False)
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False)
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
@patch("gateway.platforms.feishu_comment_rules.load_config")
@patch("plugins.platforms.feishu.feishu_comment_rules.has_wiki_keys", return_value=False)
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed", return_value=False)
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
def test_denied_user_no_side_effects(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):
"""Denied user should not trigger typing reaction or agent."""
from gateway.platforms.feishu_comment import handle_drive_comment_event
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
from plugins.platforms.feishu.feishu_comment_rules import ResolvedCommentRule
mock_resolve.return_value = ResolvedCommentRule(True, "allowlist", frozenset(), "top")
mock_load.return_value = Mock()
@ -135,14 +135,14 @@ class TestAccessControlIntegration(unittest.TestCase):
# No API calls should be made for denied users
client.request.assert_not_called()
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=False)
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=False)
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
@patch("gateway.platforms.feishu_comment_rules.load_config")
@patch("plugins.platforms.feishu.feishu_comment_rules.has_wiki_keys", return_value=False)
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed", return_value=False)
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
def test_disabled_comment_skipped(self, mock_load, mock_resolve, mock_allowed, mock_wiki_keys):
"""Disabled comments should return immediately."""
from gateway.platforms.feishu_comment import handle_drive_comment_event
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
from plugins.platforms.feishu.feishu_comment_rules import ResolvedCommentRule
mock_resolve.return_value = ResolvedCommentRule(False, "allowlist", frozenset(), "top")
mock_load.return_value = Mock()
@ -184,9 +184,9 @@ class TestWikiReverseLookup(unittest.TestCase):
def _run(self, coro):
return asyncio.get_event_loop().run_until_complete(coro)
@patch("gateway.platforms.feishu_comment._exec_request")
@patch("plugins.platforms.feishu.feishu_comment._exec_request")
def test_reverse_lookup_success(self, mock_exec):
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
from plugins.platforms.feishu.feishu_comment import _reverse_lookup_wiki_token
mock_exec.return_value = (0, "Success", {
"node": {"node_token": "WIKI_TOKEN_123", "obj_token": "docx_abc"},
@ -200,37 +200,37 @@ class TestWikiReverseLookup(unittest.TestCase):
self.assertEqual(query_dict["token"], "docx_abc")
self.assertEqual(query_dict["obj_type"], "docx")
@patch("gateway.platforms.feishu_comment._exec_request")
@patch("plugins.platforms.feishu.feishu_comment._exec_request")
def test_reverse_lookup_not_wiki(self, mock_exec):
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
from plugins.platforms.feishu.feishu_comment import _reverse_lookup_wiki_token
mock_exec.return_value = (131001, "not found", {})
result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
self.assertIsNone(result)
@patch("gateway.platforms.feishu_comment._exec_request")
@patch("plugins.platforms.feishu.feishu_comment._exec_request")
def test_reverse_lookup_service_error(self, mock_exec):
from gateway.platforms.feishu_comment import _reverse_lookup_wiki_token
from plugins.platforms.feishu.feishu_comment import _reverse_lookup_wiki_token
mock_exec.return_value = (500, "internal error", {})
result = self._run(_reverse_lookup_wiki_token(Mock(), "docx", "docx_abc"))
self.assertIsNone(result)
@patch("gateway.platforms.feishu_comment._reverse_lookup_wiki_token", new_callable=AsyncMock)
@patch("gateway.platforms.feishu_comment_rules.has_wiki_keys", return_value=True)
@patch("gateway.platforms.feishu_comment_rules.is_user_allowed", return_value=True)
@patch("gateway.platforms.feishu_comment_rules.resolve_rule")
@patch("gateway.platforms.feishu_comment_rules.load_config")
@patch("gateway.platforms.feishu_comment.add_comment_reaction", new_callable=AsyncMock)
@patch("gateway.platforms.feishu_comment.batch_query_comment", new_callable=AsyncMock)
@patch("gateway.platforms.feishu_comment.query_document_meta", new_callable=AsyncMock)
@patch("plugins.platforms.feishu.feishu_comment._reverse_lookup_wiki_token", new_callable=AsyncMock)
@patch("plugins.platforms.feishu.feishu_comment_rules.has_wiki_keys", return_value=True)
@patch("plugins.platforms.feishu.feishu_comment_rules.is_user_allowed", return_value=True)
@patch("plugins.platforms.feishu.feishu_comment_rules.resolve_rule")
@patch("plugins.platforms.feishu.feishu_comment_rules.load_config")
@patch("plugins.platforms.feishu.feishu_comment.add_comment_reaction", new_callable=AsyncMock)
@patch("plugins.platforms.feishu.feishu_comment.batch_query_comment", new_callable=AsyncMock)
@patch("plugins.platforms.feishu.feishu_comment.query_document_meta", new_callable=AsyncMock)
def test_wiki_lookup_triggered_when_no_exact_match(
self, mock_meta, mock_batch, mock_reaction,
mock_load, mock_resolve, mock_allowed, mock_wiki_keys, mock_lookup,
):
"""Wiki reverse lookup should fire when rule falls to wildcard/top and wiki keys exist."""
from gateway.platforms.feishu_comment import handle_drive_comment_event
from gateway.platforms.feishu_comment_rules import ResolvedCommentRule
from plugins.platforms.feishu.feishu_comment import handle_drive_comment_event
from plugins.platforms.feishu.feishu_comment_rules import ResolvedCommentRule
# First resolve returns wildcard (no exact match), second returns exact wiki match
mock_resolve.side_effect = [

View file

@ -8,7 +8,7 @@ import unittest
from pathlib import Path
from unittest.mock import patch
from gateway.platforms.feishu_comment_rules import (
from plugins.platforms.feishu.feishu_comment_rules import (
CommentsConfig,
CommentDocumentRule,
ResolvedCommentRule,
@ -195,7 +195,7 @@ class TestIsUserAllowed(unittest.TestCase):
def test_pairing_checks_store(self):
rule = ResolvedCommentRule(True, "pairing", frozenset(), "top")
with patch(
"gateway.platforms.feishu_comment_rules._load_pairing_approved",
"plugins.platforms.feishu.feishu_comment_rules._load_pairing_approved",
return_value={"ou_approved"},
):
self.assertTrue(is_user_allowed(rule, "ou_approved"))
@ -256,8 +256,8 @@ class TestLoadConfig(unittest.TestCase):
json.dump(raw, f)
path = Path(f.name)
try:
with patch("gateway.platforms.feishu_comment_rules.RULES_FILE", path):
with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(path)):
with patch("plugins.platforms.feishu.feishu_comment_rules.RULES_FILE", path):
with patch("plugins.platforms.feishu.feishu_comment_rules._rules_cache", _MtimeCache(path)):
cfg = load_config()
self.assertTrue(cfg.enabled)
self.assertEqual(cfg.policy, "allowlist")
@ -269,7 +269,7 @@ class TestLoadConfig(unittest.TestCase):
path.unlink()
def test_load_missing_file_returns_defaults(self):
with patch("gateway.platforms.feishu_comment_rules._rules_cache", _MtimeCache(Path("/nonexistent"))):
with patch("plugins.platforms.feishu.feishu_comment_rules._rules_cache", _MtimeCache(Path("/nonexistent"))):
cfg = load_config()
self.assertTrue(cfg.enabled)
self.assertEqual(cfg.policy, "pairing")
@ -283,9 +283,9 @@ class TestPairingStore(unittest.TestCase):
self._pairing_file = Path(self._tmpdir) / "pairing.json"
with open(self._pairing_file, "w") as f:
json.dump({"approved": {}}, f)
self._patcher_file = patch("gateway.platforms.feishu_comment_rules.PAIRING_FILE", self._pairing_file)
self._patcher_file = patch("plugins.platforms.feishu.feishu_comment_rules.PAIRING_FILE", self._pairing_file)
self._patcher_cache = patch(
"gateway.platforms.feishu_comment_rules._pairing_cache",
"plugins.platforms.feishu.feishu_comment_rules._pairing_cache",
_MtimeCache(self._pairing_file),
)
self._patcher_file.start()

View file

@ -6,7 +6,7 @@ from types import SimpleNamespace
from unittest.mock import patch
from gateway.platforms.base import MessageEvent
from gateway.platforms.feishu_meeting_invite import (
from plugins.platforms.feishu.feishu_meeting_invite import (
build_meeting_invite_prompt,
handle_meeting_invited_event,
parse_meeting_invited_event,
@ -212,7 +212,7 @@ class TestMeetingInviteSendRouting(unittest.TestCase):
def test_feishu_user_id_prefix_sends_with_user_id_receive_type(self):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
created_requests = []

View file

@ -1,4 +1,4 @@
"""Tests for gateway.platforms.feishu — Feishu scan-to-create registration."""
"""Tests for plugins.platforms.feishu.adapter — Feishu scan-to-create registration."""
import json
from unittest.mock import patch, MagicMock
@ -18,18 +18,18 @@ def _mock_urlopen(response_data, status=200):
class TestPostRegistration:
"""Tests for the low-level HTTP helper."""
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_post_registration_returns_parsed_json(self, mock_urlopen_fn):
from gateway.platforms.feishu import _post_registration
from plugins.platforms.feishu.adapter import _post_registration
mock_urlopen_fn.return_value = _mock_urlopen({"nonce": "abc", "supported_auth_methods": ["client_secret"]})
result = _post_registration("https://accounts.feishu.cn", {"action": "init"})
assert result["nonce"] == "abc"
assert "client_secret" in result["supported_auth_methods"]
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_post_registration_sends_form_encoded_body(self, mock_urlopen_fn):
from gateway.platforms.feishu import _post_registration
from plugins.platforms.feishu.adapter import _post_registration
mock_urlopen_fn.return_value = _mock_urlopen({})
_post_registration("https://accounts.feishu.cn", {"action": "init", "key": "val"})
@ -44,9 +44,9 @@ class TestPostRegistration:
class TestInitRegistration:
"""Tests for the init step."""
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_init_succeeds_when_client_secret_supported(self, mock_urlopen_fn):
from gateway.platforms.feishu import _init_registration
from plugins.platforms.feishu.adapter import _init_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"nonce": "abc",
@ -54,9 +54,9 @@ class TestInitRegistration:
})
_init_registration("feishu")
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_init_raises_when_client_secret_not_supported(self, mock_urlopen_fn):
from gateway.platforms.feishu import _init_registration
from plugins.platforms.feishu.adapter import _init_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"nonce": "abc",
@ -65,9 +65,9 @@ class TestInitRegistration:
with pytest.raises(RuntimeError, match="client_secret"):
_init_registration("feishu")
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_init_uses_lark_url_for_lark_domain(self, mock_urlopen_fn):
from gateway.platforms.feishu import _init_registration
from plugins.platforms.feishu.adapter import _init_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"nonce": "abc",
@ -82,9 +82,9 @@ class TestInitRegistration:
class TestBeginRegistration:
"""Tests for the begin step."""
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_begin_returns_device_code_and_qr_url(self, mock_urlopen_fn):
from gateway.platforms.feishu import _begin_registration
from plugins.platforms.feishu.adapter import _begin_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"device_code": "dc_123",
@ -101,9 +101,9 @@ class TestBeginRegistration:
assert result["interval"] == 5
assert result["expire_in"] == 600
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_begin_sends_correct_archetype(self, mock_urlopen_fn):
from gateway.platforms.feishu import _begin_registration
from plugins.platforms.feishu.adapter import _begin_registration
mock_urlopen_fn.return_value = _mock_urlopen({
"device_code": "dc_123",
@ -122,10 +122,10 @@ class TestBeginRegistration:
class TestPollRegistration:
"""Tests for the poll step."""
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.time")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_poll_returns_credentials_on_success(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
from plugins.platforms.feishu.adapter import _poll_registration
mock_time.monotonic.side_effect = [0, 1]
mock_time.sleep = MagicMock()
@ -144,10 +144,10 @@ class TestPollRegistration:
assert result["domain"] == "feishu"
assert result["open_id"] == "ou_owner"
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.time")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_poll_switches_domain_on_lark_tenant_brand(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
from plugins.platforms.feishu.adapter import _poll_registration
mock_time.monotonic.side_effect = [0, 1, 2]
mock_time.sleep = MagicMock()
@ -169,11 +169,11 @@ class TestPollRegistration:
assert result is not None
assert result["domain"] == "lark"
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.time")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_poll_success_with_lark_brand_in_same_response(self, mock_urlopen_fn, mock_time):
"""Credentials and lark tenant_brand in one response must not be discarded."""
from gateway.platforms.feishu import _poll_registration
from plugins.platforms.feishu.adapter import _poll_registration
mock_time.monotonic.side_effect = [0, 1]
mock_time.sleep = MagicMock()
@ -191,10 +191,10 @@ class TestPollRegistration:
assert result["domain"] == "lark"
assert result["open_id"] == "ou_lark_direct"
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.time")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_poll_returns_none_on_access_denied(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
from plugins.platforms.feishu.adapter import _poll_registration
mock_time.monotonic.side_effect = [0, 1]
mock_time.sleep = MagicMock()
@ -207,10 +207,10 @@ class TestPollRegistration:
)
assert result is None
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.time")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_poll_returns_none_on_timeout(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
from plugins.platforms.feishu.adapter import _poll_registration
mock_time.monotonic.side_effect = [0, 999]
mock_time.sleep = MagicMock()
@ -223,10 +223,10 @@ class TestPollRegistration:
)
assert result is None
@patch("gateway.platforms.feishu.time")
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.time")
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_poll_timeout_uses_monotonic_clock(self, mock_urlopen_fn, mock_time):
from gateway.platforms.feishu import _poll_registration
from plugins.platforms.feishu.adapter import _poll_registration
mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1]
mock_time.time.side_effect = [1000, 900, 901, 902]
@ -246,9 +246,9 @@ class TestPollRegistration:
class TestRenderQr:
"""Tests for QR code terminal rendering."""
@patch("gateway.platforms.feishu._qrcode_mod", create=True)
@patch("plugins.platforms.feishu.adapter._qrcode_mod", create=True)
def test_render_qr_returns_true_on_success(self, mock_qrcode_mod):
from gateway.platforms.feishu import _render_qr
from plugins.platforms.feishu.adapter import _render_qr
mock_qr = MagicMock()
mock_qrcode_mod.QRCode.return_value = mock_qr
@ -258,20 +258,20 @@ class TestRenderQr:
mock_qr.print_ascii.assert_called_once()
def test_render_qr_returns_false_when_qrcode_missing(self):
from gateway.platforms.feishu import _render_qr
from plugins.platforms.feishu.adapter import _render_qr
with patch("gateway.platforms.feishu._qrcode_mod", None):
with patch("plugins.platforms.feishu.adapter._qrcode_mod", None):
assert _render_qr("https://example.com/qr") is False
class TestProbeBot:
"""Tests for bot connectivity verification."""
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True)
@patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True)
def test_probe_returns_bot_info_on_success(self):
from gateway.platforms.feishu import probe_bot
from plugins.platforms.feishu.adapter import probe_bot
with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk:
with patch("plugins.platforms.feishu.adapter._probe_bot_sdk") as mock_sdk:
mock_sdk.return_value = {"bot_name": "TestBot", "bot_open_id": "ou_bot123"}
result = probe_bot("cli_app", "secret", "feishu")
@ -279,21 +279,21 @@ class TestProbeBot:
assert result["bot_name"] == "TestBot"
assert result["bot_open_id"] == "ou_bot123"
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True)
@patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True)
def test_probe_returns_none_on_failure(self):
from gateway.platforms.feishu import probe_bot
from plugins.platforms.feishu.adapter import probe_bot
with patch("gateway.platforms.feishu._probe_bot_sdk") as mock_sdk:
with patch("plugins.platforms.feishu.adapter._probe_bot_sdk") as mock_sdk:
mock_sdk.return_value = None
result = probe_bot("bad_id", "bad_secret", "feishu")
assert result is None
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False)
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", False)
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_http_fallback_when_sdk_unavailable(self, mock_urlopen_fn):
"""Without lark_oapi, probe falls back to raw HTTP."""
from gateway.platforms.feishu import probe_bot
from plugins.platforms.feishu.adapter import probe_bot
token_resp = _mock_urlopen({"code": 0, "tenant_access_token": "t-123"})
bot_resp = _mock_urlopen({"code": 0, "bot": {"bot_name": "HttpBot", "open_id": "ou_http"}})
@ -303,10 +303,10 @@ class TestProbeBot:
assert result is not None
assert result["bot_name"] == "HttpBot"
@patch("gateway.platforms.feishu.FEISHU_AVAILABLE", False)
@patch("gateway.platforms.feishu.urlopen")
@patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", False)
@patch("plugins.platforms.feishu.adapter.urlopen")
def test_http_fallback_returns_none_on_network_error(self, mock_urlopen_fn):
from gateway.platforms.feishu import probe_bot
from plugins.platforms.feishu.adapter import probe_bot
from urllib.error import URLError
mock_urlopen_fn.side_effect = URLError("connection refused")
@ -317,15 +317,15 @@ class TestProbeBot:
class TestQrRegister:
"""Tests for the public qr_register entry point."""
@patch("gateway.platforms.feishu.probe_bot")
@patch("gateway.platforms.feishu._render_qr")
@patch("gateway.platforms.feishu._poll_registration")
@patch("gateway.platforms.feishu._begin_registration")
@patch("gateway.platforms.feishu._init_registration")
@patch("plugins.platforms.feishu.adapter.probe_bot")
@patch("plugins.platforms.feishu.adapter._render_qr")
@patch("plugins.platforms.feishu.adapter._poll_registration")
@patch("plugins.platforms.feishu.adapter._begin_registration")
@patch("plugins.platforms.feishu.adapter._init_registration")
def test_qr_register_success_flow(
self, mock_init, mock_begin, mock_poll, mock_render, mock_probe
):
from gateway.platforms.feishu import qr_register
from plugins.platforms.feishu.adapter import qr_register
mock_begin.return_value = {
"device_code": "dc_123",
@ -350,22 +350,22 @@ class TestQrRegister:
mock_init.assert_called_once()
mock_render.assert_called_once()
@patch("gateway.platforms.feishu._init_registration")
@patch("plugins.platforms.feishu.adapter._init_registration")
def test_qr_register_returns_none_on_init_failure(self, mock_init):
from gateway.platforms.feishu import qr_register
from plugins.platforms.feishu.adapter import qr_register
mock_init.side_effect = RuntimeError("not supported")
result = qr_register()
assert result is None
@patch("gateway.platforms.feishu._render_qr")
@patch("gateway.platforms.feishu._poll_registration")
@patch("gateway.platforms.feishu._begin_registration")
@patch("gateway.platforms.feishu._init_registration")
@patch("plugins.platforms.feishu.adapter._render_qr")
@patch("plugins.platforms.feishu.adapter._poll_registration")
@patch("plugins.platforms.feishu.adapter._begin_registration")
@patch("plugins.platforms.feishu.adapter._init_registration")
def test_qr_register_returns_none_on_poll_failure(
self, mock_init, mock_begin, mock_poll, mock_render
):
from gateway.platforms.feishu import qr_register
from plugins.platforms.feishu.adapter import qr_register
mock_begin.return_value = {
"device_code": "dc_123",
@ -381,29 +381,29 @@ class TestQrRegister:
# -- Contract: expected errors → None, unexpected errors → propagate --
@patch("gateway.platforms.feishu._init_registration")
@patch("plugins.platforms.feishu.adapter._init_registration")
def test_qr_register_returns_none_on_network_error(self, mock_init):
"""URLError (network down) is an expected failure → None."""
from gateway.platforms.feishu import qr_register
from plugins.platforms.feishu.adapter import qr_register
from urllib.error import URLError
mock_init.side_effect = URLError("DNS resolution failed")
result = qr_register()
assert result is None
@patch("gateway.platforms.feishu._init_registration")
@patch("plugins.platforms.feishu.adapter._init_registration")
def test_qr_register_returns_none_on_json_error(self, mock_init):
"""Malformed server response is an expected failure → None."""
from gateway.platforms.feishu import qr_register
from plugins.platforms.feishu.adapter import qr_register
mock_init.side_effect = json.JSONDecodeError("bad json", "", 0)
result = qr_register()
assert result is None
@patch("gateway.platforms.feishu._init_registration")
@patch("plugins.platforms.feishu.adapter._init_registration")
def test_qr_register_propagates_unexpected_errors(self, mock_init):
"""Bugs (e.g. AttributeError) must not be swallowed — they propagate."""
from gateway.platforms.feishu import qr_register
from plugins.platforms.feishu.adapter import qr_register
mock_init.side_effect = AttributeError("some internal bug")
with pytest.raises(AttributeError, match="some internal bug"):
@ -411,29 +411,29 @@ class TestQrRegister:
# -- Negative paths: partial/malformed server responses --
@patch("gateway.platforms.feishu._render_qr")
@patch("gateway.platforms.feishu._begin_registration")
@patch("gateway.platforms.feishu._init_registration")
@patch("plugins.platforms.feishu.adapter._render_qr")
@patch("plugins.platforms.feishu.adapter._begin_registration")
@patch("plugins.platforms.feishu.adapter._init_registration")
def test_qr_register_returns_none_when_begin_missing_device_code(
self, mock_init, mock_begin, mock_render
):
"""Server returns begin response without device_code → RuntimeError → None."""
from gateway.platforms.feishu import qr_register
from plugins.platforms.feishu.adapter import qr_register
mock_begin.side_effect = RuntimeError("Feishu registration did not return a device_code")
result = qr_register()
assert result is None
@patch("gateway.platforms.feishu.probe_bot")
@patch("gateway.platforms.feishu._render_qr")
@patch("gateway.platforms.feishu._poll_registration")
@patch("gateway.platforms.feishu._begin_registration")
@patch("gateway.platforms.feishu._init_registration")
@patch("plugins.platforms.feishu.adapter.probe_bot")
@patch("plugins.platforms.feishu.adapter._render_qr")
@patch("plugins.platforms.feishu.adapter._poll_registration")
@patch("plugins.platforms.feishu.adapter._begin_registration")
@patch("plugins.platforms.feishu.adapter._init_registration")
def test_qr_register_succeeds_even_when_probe_fails(
self, mock_init, mock_begin, mock_poll, mock_render, mock_probe
):
"""Registration succeeds but probe fails → result with bot_name=None."""
from gateway.platforms.feishu import qr_register
from plugins.platforms.feishu.adapter import qr_register
mock_begin.return_value = {
"device_code": "dc_123",

View file

@ -365,7 +365,7 @@ class TestMatrixConfigLoading:
def _make_adapter():
"""Create a MatrixAdapter with mocked config."""
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
token="syt_test_token",
@ -391,7 +391,7 @@ class TestMatrixTypingIndicator:
@pytest.mark.asyncio
async def test_stop_typing_clears_matrix_typing_state(self):
"""stop_typing() should send typing=false instead of waiting for timeout expiry."""
from gateway.platforms.matrix import RoomID
from plugins.platforms.matrix.adapter import RoomID
await self.adapter.stop_typing("!room:example.org")
@ -712,7 +712,7 @@ class TestMatrixBangCommandAlias:
return captured_event
def test_known_bang_command_normalizes_to_slash_command(self):
from gateway.platforms.matrix import _normalize_matrix_bang_command
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
assert _normalize_matrix_bang_command("!model") == "/model"
assert (
@ -726,7 +726,7 @@ class TestMatrixBangCommandAlias:
assert _normalize_matrix_bang_command("!tasks") == "/tasks"
def test_unknown_bang_text_is_not_treated_as_command(self):
from gateway.platforms.matrix import _normalize_matrix_bang_command
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
assert _normalize_matrix_bang_command("!important note") == "!important note"
assert _normalize_matrix_bang_command("! wow") == "! wow"
@ -786,7 +786,7 @@ class TestMatrixBangCommandAlias:
def test_bang_alias_underscore_resolves_to_hyphen_form(self):
"""!set_home must emit a dispatchable token even though set_home is
not itself registered the hyphenated alias set-home is."""
from gateway.platforms.matrix import _normalize_matrix_bang_command
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
# set_home (underscore) is NOT a registered command/alias, but
# set-home (hyphen) is. The normalizer must emit the resolvable form.
@ -806,7 +806,7 @@ class TestMatrixBangCommandAlias:
with patch.object(
skill_commands_mod, "get_skill_commands", return_value=fake_skills
):
from gateway.platforms.matrix import _normalize_matrix_bang_command
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
# is_gateway_known_command won't know these; the skill branch must.
assert _normalize_matrix_bang_command("!arxiv") == "/arxiv"
@ -1077,7 +1077,7 @@ class TestMatrixMarkdownToHtml:
assert "blob:" not in result.lower()
def test_matrix_markdown_rejects_obfuscated_javascript_links(self):
from gateway.platforms.matrix import _sanitize_matrix_html
from plugins.platforms.matrix.adapter import _sanitize_matrix_html
result = _sanitize_matrix_html('<a href="java\nscript:alert(1)">click</a>')
assert "javascript:" not in result.lower()
@ -1160,7 +1160,7 @@ class TestMatrixDisplayName:
class TestMatrixModuleImport:
def test_module_importable_without_mautrix(self):
"""gateway.platforms.matrix must be importable even when mautrix is
"""plugins.platforms.matrix.adapter must be importable even when mautrix is
not installed otherwise the gateway crashes for ALL platforms.
This test uses a subprocess to avoid polluting the current process's
@ -1182,7 +1182,7 @@ class TestMatrixModuleImport:
"for k in list(sys.modules):\n"
" if k.startswith('mautrix'): del sys.modules[k]\n"
"from unittest.mock import patch\n"
"from gateway.platforms.matrix import check_matrix_requirements\n"
"from plugins.platforms.matrix.adapter import check_matrix_requirements\n"
"with patch('tools.lazy_deps.ensure', side_effect=ImportError('blocked')):\n"
" assert not check_matrix_requirements()\n"
"print('OK')\n"
@ -1199,7 +1199,7 @@ class TestMatrixRequirements:
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
from gateway.platforms.matrix import check_matrix_requirements
from plugins.platforms.matrix.adapter import check_matrix_requirements
with patch("tools.lazy_deps.feature_missing", return_value=()):
assert check_matrix_requirements() is True
@ -1207,13 +1207,13 @@ class TestMatrixRequirements:
monkeypatch.delenv("MATRIX_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("MATRIX_PASSWORD", raising=False)
monkeypatch.delenv("MATRIX_HOMESERVER", raising=False)
from gateway.platforms.matrix import check_matrix_requirements
from plugins.platforms.matrix.adapter import check_matrix_requirements
assert check_matrix_requirements() is False
def test_check_requirements_without_homeserver(self, monkeypatch):
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
monkeypatch.delenv("MATRIX_HOMESERVER", raising=False)
from gateway.platforms.matrix import check_matrix_requirements
from plugins.platforms.matrix.adapter import check_matrix_requirements
assert check_matrix_requirements() is False
def test_check_requirements_encryption_true_no_e2ee_deps(self, monkeypatch):
@ -1222,7 +1222,7 @@ class TestMatrixRequirements:
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
monkeypatch.setenv("MATRIX_ENCRYPTION", "true")
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
patch("tools.lazy_deps.feature_missing", return_value=()):
assert matrix_mod.check_matrix_requirements() is False
@ -1234,7 +1234,7 @@ class TestMatrixRequirements:
monkeypatch.setenv("MATRIX_E2EE_MODE", "optional")
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
patch("tools.lazy_deps.feature_missing", return_value=()), \
patch("tools.lazy_deps.ensure_and_bind", return_value=True):
@ -1246,7 +1246,7 @@ class TestMatrixRequirements:
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
patch("tools.lazy_deps.feature_missing", return_value=()):
assert matrix_mod.check_matrix_requirements() is True
@ -1257,7 +1257,7 @@ class TestMatrixRequirements:
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
monkeypatch.setenv("MATRIX_ENCRYPTION", "true")
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True), \
patch("tools.lazy_deps.feature_missing", return_value=()):
assert matrix_mod.check_matrix_requirements() is True
@ -1272,7 +1272,7 @@ class TestMatrixRequirements:
a confusing ``No module named 'asyncpg'`` deep in
``MatrixAdapter.connect()``.
"""
from gateway.platforms.matrix import _check_e2ee_deps
from plugins.platforms.matrix.adapter import _check_e2ee_deps
import builtins
real_import = builtins.__import__
@ -1290,7 +1290,7 @@ class TestMatrixRequirements:
Mautrix's ``Database.create("sqlite:///...")`` driver lookup imports
aiosqlite lazily without it, connect fails at ``crypto_db.start()``.
"""
from gateway.platforms.matrix import _check_e2ee_deps
from plugins.platforms.matrix.adapter import _check_e2ee_deps
import builtins
real_import = builtins.__import__
@ -1314,7 +1314,7 @@ class TestMatrixRequirements:
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
# Simulate "mautrix installed, asyncpg missing" → feature_missing
# returns a non-empty tuple → ensure_and_bind MUST be called.
@ -1344,7 +1344,7 @@ class TestMatrixAccessTokenAuth:
@pytest.mark.asyncio
async def test_connect_with_access_token_and_encryption(self):
"""connect() should call whoami, set user_id/device_id, set up crypto."""
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1398,7 +1398,7 @@ class TestMatrixAccessTokenAuth:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
with patch.dict("sys.modules", fake_mautrix_mods):
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
@ -1450,7 +1450,7 @@ class TestMatrixE2EEHardFail:
@pytest.mark.asyncio
async def test_connect_fails_when_encryption_true_but_no_e2ee_deps(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1477,7 +1477,7 @@ class TestMatrixE2EEHardFail:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False):
with patch.dict("sys.modules", fake_mautrix_mods):
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
@ -1487,7 +1487,7 @@ class TestMatrixE2EEHardFail:
@pytest.mark.asyncio
async def test_connect_continues_when_e2ee_optional_but_no_deps(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1524,7 +1524,7 @@ class TestMatrixE2EEHardFail:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False):
with patch.dict("sys.modules", fake_mautrix_mods):
with patch.object(matrix_mod, "_create_matrix_session", return_value=MagicMock()):
@ -1538,7 +1538,7 @@ class TestMatrixE2EEHardFail:
@pytest.mark.asyncio
async def test_connect_fails_when_crypto_setup_raises(self):
"""Even if _check_e2ee_deps passes, if OlmMachine raises, hard-fail."""
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1566,7 +1566,7 @@ class TestMatrixE2EEHardFail:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(side_effect=Exception("olm init failed"))
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
with patch.dict("sys.modules", fake_mautrix_mods):
result = await adapter.connect()
@ -1578,7 +1578,7 @@ class TestMatrixDeviceId:
"""MATRIX_DEVICE_ID should be used for stable device identity."""
def test_device_id_from_config_extra(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1594,7 +1594,7 @@ class TestMatrixDeviceId:
def test_device_id_from_env(self, monkeypatch):
monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV")
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1609,7 +1609,7 @@ class TestMatrixDeviceId:
def test_device_id_config_takes_precedence_over_env(self, monkeypatch):
monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV")
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1625,7 +1625,7 @@ class TestMatrixDeviceId:
@pytest.mark.asyncio
async def test_connect_uses_configured_device_id_over_whoami(self):
"""When MATRIX_DEVICE_ID is set, it should be used instead of whoami device_id."""
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1672,7 +1672,7 @@ class TestMatrixDeviceId:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
with patch.dict("sys.modules", fake_mautrix_mods):
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
@ -1691,7 +1691,7 @@ class TestMatrixPasswordLoginDeviceId:
@pytest.mark.asyncio
async def test_password_login_uses_device_id(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -1905,7 +1905,7 @@ class TestMatrixSyncLoop:
@pytest.mark.asyncio
async def test_connect_receives_dm_from_initial_sync_dispatch(self):
"""A DM delivered by initial sync should reach the message handler after connect."""
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
adapter = MatrixAdapter(
PlatformConfig(
@ -1972,7 +1972,7 @@ class TestMatrixSyncLoop:
mock_client.handle_sync = MagicMock(side_effect=handle_sync)
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.dict("sys.modules", fake_mautrix_mods):
with patch.object(matrix_mod, "_create_matrix_session", return_value=MagicMock()):
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
@ -2220,7 +2220,7 @@ class TestMatrixUploadAndSend:
class TestMatrixDiagnostics:
def test_diagnostics_redacts_credentials_and_reports_status(self, monkeypatch):
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
monkeypatch.setenv("MATRIX_RECOVERY_KEY", "secret recovery key")
adapter = _make_adapter()
@ -2248,7 +2248,7 @@ class TestMatrixDiagnostics:
assert diagnostics["media"]["max_media_bytes"] == 123
def test_matrix_recovery_key_is_never_logged(self, caplog, monkeypatch):
from gateway.platforms.matrix import _handle_generated_matrix_recovery_key
from plugins.platforms.matrix.adapter import _handle_generated_matrix_recovery_key
secret = "super-secret-generated-recovery-key"
monkeypatch.delenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", raising=False)
@ -2259,7 +2259,7 @@ class TestMatrixDiagnostics:
assert "will not be logged" in caplog.text
def test_matrix_recovery_key_output_file_is_0600(self, tmp_path, monkeypatch, caplog):
from gateway.platforms.matrix import _handle_generated_matrix_recovery_key
from plugins.platforms.matrix.adapter import _handle_generated_matrix_recovery_key
secret = "super-secret-generated-recovery-key"
output_path = tmp_path / "matrix-recovery-key.txt"
@ -2277,7 +2277,7 @@ class TestMatrixDiagnostics:
monkeypatch,
caplog,
):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
monkeypatch.delenv("MATRIX_RECOVERY_KEY", raising=False)
monkeypatch.delenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", raising=False)
@ -2327,7 +2327,7 @@ class TestMatrixDiagnostics:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
with patch.dict("sys.modules", fake_mautrix_mods):
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
@ -2346,7 +2346,7 @@ class TestMatrixDiagnostics:
monkeypatch,
caplog,
):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
output_path = tmp_path / "matrix-recovery-key.txt"
output_path.write_text("existing\n")
@ -2398,7 +2398,7 @@ class TestMatrixDiagnostics:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
with patch.dict("sys.modules", fake_mautrix_mods):
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
@ -2421,7 +2421,7 @@ class TestMatrixDiagnostics:
assert "diagnostic-secret-recovery-key" not in str(diagnostics)
def test_capability_matrix_is_declared_for_docs(self):
from gateway.platforms.matrix import get_matrix_capabilities
from plugins.platforms.matrix.adapter import get_matrix_capabilities
capabilities = get_matrix_capabilities()
@ -2442,7 +2442,7 @@ class TestMatrixDiagnostics:
}
def test_matrix_capability_claims_match_adapter_surfaces(self):
from gateway.platforms.matrix import MatrixAdapter, get_matrix_capabilities
from plugins.platforms.matrix.adapter import MatrixAdapter, get_matrix_capabilities
capabilities = get_matrix_capabilities()
required_methods = {
@ -2468,7 +2468,7 @@ class TestMatrixDiagnostics:
def test_matrix_docs_capability_table_matches_declaration(self):
from pathlib import Path
from gateway.platforms.matrix import get_matrix_capabilities
from plugins.platforms.matrix.adapter import get_matrix_capabilities
docs = (
Path(__file__).resolve().parents[2]
@ -2515,7 +2515,7 @@ class TestMatrixEncryptedSendFallback:
class TestJoinedRoomsReference:
def test_joined_rooms_reference_preserved_after_reassignment(self):
"""_CryptoStateStore must see updates after initial sync populates rooms."""
from gateway.platforms.matrix import _CryptoStateStore
from plugins.platforms.matrix.adapter import _CryptoStateStore
joined = set()
store = _CryptoStateStore(MagicMock(), joined)
@ -2536,7 +2536,7 @@ class TestJoinedRoomsReference:
class TestMatrixEncryptedEventHandler:
@pytest.mark.asyncio
async def test_connect_registers_encrypted_event_handler_when_encryption_on(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -2582,7 +2582,7 @@ class TestMatrixEncryptedEventHandler:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
with patch.dict("sys.modules", fake_mautrix_mods):
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
@ -2602,7 +2602,7 @@ class TestMatrixEncryptedEventHandler:
@pytest.mark.asyncio
async def test_connect_fails_on_stale_otk_conflict(self):
"""connect() must refuse E2EE when OTK upload hits 'already exists'."""
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -2651,7 +2651,7 @@ class TestMatrixEncryptedEventHandler:
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
from gateway.platforms import matrix as matrix_mod
import plugins.platforms.matrix.adapter as matrix_mod
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
with patch.dict("sys.modules", fake_mautrix_mods):
result = await adapter.connect()
@ -2724,7 +2724,7 @@ class TestMatrixMarkdownHtmlSecurity:
"""Tests for HTML injection prevention in _markdown_to_html_fallback."""
def setup_method(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
self.convert = MatrixAdapter._markdown_to_html_fallback
def test_script_injection_in_header(self):
@ -2785,7 +2785,7 @@ class TestMatrixMarkdownHtmlFormatting:
"""Tests for new formatting capabilities in _markdown_to_html_fallback."""
def setup_method(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
self.convert = MatrixAdapter._markdown_to_html_fallback
def test_fenced_code_block(self):
@ -2852,23 +2852,23 @@ class TestMatrixMarkdownHtmlFormatting:
class TestMatrixLinkSanitization:
def test_safe_https_url(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
assert MatrixAdapter._sanitize_link_url("https://example.com") == "https://example.com"
def test_javascript_blocked(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
assert MatrixAdapter._sanitize_link_url("javascript:alert(1)") == ""
def test_data_blocked(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
assert MatrixAdapter._sanitize_link_url("data:text/html,bad") == ""
def test_vbscript_blocked(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
assert MatrixAdapter._sanitize_link_url("vbscript:bad") == ""
def test_quotes_escaped(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
result = MatrixAdapter._sanitize_link_url('http://x"y')
assert '"' not in result
assert "&quot;" in result
@ -3906,7 +3906,7 @@ class TestMatrixRequireMention:
"""require_mention should honor config.extra like thread_require_mention."""
def test_require_mention_from_config_extra_false(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -3922,7 +3922,7 @@ class TestMatrixRequireMention:
def test_require_mention_from_env_when_extra_unset(self, monkeypatch):
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -3935,7 +3935,7 @@ class TestMatrixRequireMention:
def test_require_mention_config_takes_precedence_over_env(self, monkeypatch):
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -3950,7 +3950,7 @@ class TestMatrixRequireMention:
@pytest.mark.asyncio
async def test_require_mention_false_allows_unmentioned_group_message(self):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
@ -4061,7 +4061,7 @@ class TestMatrixClockSkewWarning:
# Server events are dated 2h before startup_ts (skewed clock).
skewed_event_ts_ms = int((self.adapter._startup_ts - 7200) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
for i in range(5):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=skewed_event_ts_ms
@ -4075,7 +4075,7 @@ class TestMatrixClockSkewWarning:
# assertion.
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
if r.name == "plugins.platforms.matrix.adapter"
and r.levelname == "WARNING"
and "set-ntp" in r.getMessage()
]
@ -4100,7 +4100,7 @@ class TestMatrixClockSkewWarning:
self.adapter._startup_ts = now - 1
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
for i in range(5):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
@ -4111,7 +4111,7 @@ class TestMatrixClockSkewWarning:
assert self.adapter._clock_skew_warned is False
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
if r.name == "plugins.platforms.matrix.adapter"
and "set-ntp" in r.getMessage()
]
assert skew_warnings == []
@ -4126,7 +4126,7 @@ class TestMatrixClockSkewWarning:
self.adapter._startup_ts = now - 120 # extra slack vs the 30s gate
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
for i in range(2): # only 2 late drops — under the threshold
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
@ -4152,7 +4152,7 @@ class TestMatrixClockSkewWarning:
self.adapter._startup_ts = now - 120
# Each event has a different age, ranging from 1h to 30d ago.
ages_in_hours = [1, 24, 168, 720, 4] # 1h, 1d, 1w, 30d, 4h
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
for i, hrs in enumerate(ages_in_hours):
ts_ms = int((self.adapter._startup_ts - hrs * 3600) * 1000)
ev = self._mk_event(
@ -4165,7 +4165,7 @@ class TestMatrixClockSkewWarning:
assert self.adapter._clock_skew_warned is False
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
if r.name == "plugins.platforms.matrix.adapter"
and "set-ntp" in r.getMessage()
]
assert skew_warnings == []
@ -4189,7 +4189,7 @@ class TestMatrixClockSkewWarning:
self.adapter._startup_ts = now - 60
skewed_ms = int((self.adapter._startup_ts - 7200) * 1000)
with caplog.at_level(logging.WARNING, logger="gateway.platforms.matrix"):
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
for i in range(3):
ev = self._mk_event(
sender=f"@alice{i}:example.org", ts_ms=skewed_ms,
@ -4215,7 +4215,7 @@ class TestMatrixClockSkewWarning:
skew_warnings = [
r for r in caplog.records
if r.name == "gateway.platforms.matrix"
if r.name == "plugins.platforms.matrix.adapter"
and "set-ntp" in r.getMessage()
]
assert len(skew_warnings) == 2, (
@ -4292,7 +4292,7 @@ class TestMatrixProxyConfig:
for k, v in proxy_env.items():
monkeypatch.setenv(k, v)
with patch.dict("sys.modules", _make_fake_mautrix()):
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
cfg = PlatformConfig(enabled=True, token="syt_test",
extra={"homeserver": "https://matrix.example.org",
"user_id": "@bot:example.org"})
@ -4325,7 +4325,7 @@ class TestCreateMatrixSession:
@pytest.mark.asyncio
async def test_no_proxy_returns_trust_env_session(self):
with patch.dict("sys.modules", _make_fake_mautrix()):
from gateway.platforms.matrix import _create_matrix_session
from plugins.platforms.matrix.adapter import _create_matrix_session
session = _create_matrix_session(None)
try:
assert session.trust_env is True
@ -4335,7 +4335,7 @@ class TestCreateMatrixSession:
@pytest.mark.asyncio
async def test_http_proxy_sets_default_proxy(self):
with patch.dict("sys.modules", _make_fake_mautrix()):
from gateway.platforms.matrix import _create_matrix_session
from plugins.platforms.matrix.adapter import _create_matrix_session
session = _create_matrix_session("http://proxy:8080")
try:
assert str(session._default_proxy) == "http://proxy:8080"
@ -4353,7 +4353,7 @@ class TestCreateMatrixSession:
)
),
}):
from gateway.platforms.matrix import _create_matrix_session
from plugins.platforms.matrix.adapter import _create_matrix_session
session = _create_matrix_session("socks5://proxy:1080")
try:
assert session.connector is fake_connector

View file

@ -17,7 +17,7 @@ import pytest
# ---------------------------------------------------------------------------
# Stub mautrix so gateway.platforms.matrix can be imported without the SDK.
# Stub mautrix so plugins.platforms.matrix.adapter can be imported without the SDK.
# ---------------------------------------------------------------------------
def _stub_mautrix():
@ -64,7 +64,7 @@ def _stub_mautrix():
_stub_mautrix()
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt # noqa: E402
from plugins.platforms.matrix.adapter import MatrixAdapter, _MatrixApprovalPrompt # noqa: E402
# ---------------------------------------------------------------------------

View file

@ -10,7 +10,7 @@ class TestMatrixExecApprovalReactions:
@pytest.mark.asyncio
async def test_send_exec_approval_registers_prompt_and_seeds_reactions(self, monkeypatch):
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
adapter._client = types.SimpleNamespace()
@ -34,7 +34,7 @@ class TestMatrixExecApprovalReactions:
@pytest.mark.asyncio
async def test_reaction_resolves_pending_approval(self, monkeypatch):
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt
from plugins.platforms.matrix.adapter import MatrixAdapter, _MatrixApprovalPrompt
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
# Resolve user_id so _is_self_sender doesn't defensively drop all traffic (#15763).

View file

@ -17,7 +17,7 @@ from gateway.config import PlatformConfig
def _make_adapter(tmp_path=None):
"""Create a MatrixAdapter with mocked config."""
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,

View file

@ -32,7 +32,7 @@ SENDER = "@alice:example.org"
def _make_adapter():
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
adapter = MatrixAdapter(
PlatformConfig(

View file

@ -27,7 +27,7 @@ from gateway.platforms.base import MessageType
def _make_adapter():
"""Create a MatrixAdapter with mocked config."""
from gateway.platforms.matrix import MatrixAdapter
from plugins.platforms.matrix.adapter import MatrixAdapter
from gateway.config import PlatformConfig
config = PlatformConfig(

View file

@ -532,10 +532,10 @@ def _ensure_slack_mock():
_ensure_slack_mock()
import gateway.platforms.slack as _slack_mod # noqa: E402
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True
from gateway.platforms.slack import SlackAdapter # noqa: E402
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
from gateway.config import PlatformConfig # noqa: E402

View file

@ -33,8 +33,8 @@ def _accepts_metadata(method) -> bool:
@pytest.mark.parametrize(
"module_name, class_name",
[
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
("gateway.platforms.email", "EmailAdapter"),
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
("plugins.platforms.email.adapter", "EmailAdapter"),
],
)
def test_send_image_accepts_metadata(module_name, class_name):
@ -50,18 +50,18 @@ def test_send_image_accepts_metadata(module_name, class_name):
# whose override drops metadata is a hard failure.
_ALL_ADAPTERS = [
("gateway.platforms.bluebubbles", "BlueBubblesAdapter"),
("gateway.platforms.dingtalk", "DingTalkAdapter"),
("plugins.platforms.dingtalk.adapter", "DingTalkAdapter"),
("gateway.platforms.discord", "DiscordAdapter"),
("gateway.platforms.email", "EmailAdapter"),
("gateway.platforms.feishu", "FeishuAdapter"),
("gateway.platforms.matrix", "MatrixAdapter"),
("plugins.platforms.email.adapter", "EmailAdapter"),
("plugins.platforms.feishu.adapter", "FeishuAdapter"),
("plugins.platforms.matrix.adapter", "MatrixAdapter"),
("gateway.platforms.mattermost", "MattermostAdapter"),
("gateway.platforms.signal", "SignalAdapter"),
("gateway.platforms.slack", "SlackAdapter"),
("gateway.platforms.telegram", "TelegramAdapter"),
("gateway.platforms.wecom", "WeComAdapter"),
("plugins.platforms.slack.adapter", "SlackAdapter"),
("plugins.platforms.telegram.adapter", "TelegramAdapter"),
("plugins.platforms.wecom.adapter", "WeComAdapter"),
("gateway.platforms.weixin", "WeixinAdapter"),
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
]

View file

@ -33,9 +33,31 @@ def test_all_builtins_have_checker_or_generic_token_path():
# Platforms with a bespoke checker
checker_values = {p.value for p in set(_PLATFORM_CONNECTED_CHECKERS.keys())}
# Every built-in should be in one of the two sets
# Platforms whose connection check now comes from a registered plugin entry
# (is_connected / validate_config). Several adapters migrated out of core
# into bundled plugins (#41112); their checker moved with them to the
# platform registry, so get_connected_platforms() resolves them via the
# registry fallback rather than _PLATFORM_CONNECTED_CHECKERS.
plugin_checker_values: set[str] = set()
try:
from hermes_cli.plugins import discover_plugins
from gateway.platform_registry import platform_registry
discover_plugins()
for _entry in platform_registry.all_entries():
if _entry.is_connected is not None or _entry.validate_config is not None:
plugin_checker_values.add(_entry.name)
except Exception:
pass
# Every built-in should be in one of the sets
all_builtins = set(_BUILTIN_PLATFORM_VALUES)
missing = all_builtins - generic_token_values - checker_values - {"local"}
missing = (
all_builtins
- generic_token_values
- checker_values
- plugin_checker_values
- {"local"}
)
assert not missing, (
f"Built-in platforms missing a connection checker: "

View file

@ -77,11 +77,11 @@ def test_helper_is_importable_from_every_platform_that_uses_it():
the regression shows up as a runtime adapter-startup crash."""
# Just importing exercises the helper's import path for each adapter.
import gateway.platforms.qqbot.adapter # noqa: F401
import gateway.platforms.wecom # noqa: F401
import gateway.platforms.dingtalk # noqa: F401
import plugins.platforms.wecom.adapter # noqa: F401
import plugins.platforms.dingtalk.adapter # noqa: F401
import gateway.platforms.signal # noqa: F401
import gateway.platforms.bluebubbles # noqa: F401
import gateway.platforms.wecom_callback # noqa: F401
import plugins.platforms.wecom.callback_adapter # noqa: F401
class TestWhatsappTypingLeakFix:
@ -98,7 +98,7 @@ class TestWhatsappTypingLeakFix:
def test_bare_await_removed(self):
import inspect
import gateway.platforms.whatsapp as mod
import plugins.platforms.whatsapp.adapter as mod
src = inspect.getsource(mod.WhatsAppAdapter.send_typing)
# The fix must be structural: the post() call is inside an

View file

@ -82,7 +82,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
class TestTelegramSendImageFile:
@ -313,7 +313,7 @@ def _ensure_slack_mock():
_ensure_slack_mock()
from gateway.platforms.slack import SlackAdapter # noqa: E402
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
class TestSlackSendImageFile:

View file

@ -115,7 +115,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
class TestTelegramMultiImage:
@ -286,7 +286,7 @@ def _ensure_slack_mock():
_ensure_slack_mock()
from gateway.platforms.slack import SlackAdapter # noqa: E402
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
class TestSlackMultiImage:
@ -402,7 +402,7 @@ class TestMattermostMultiImage:
# ---------------------------------------------------------------------------
from gateway.platforms.email import EmailAdapter # noqa: E402
from plugins.platforms.email.adapter import EmailAdapter # noqa: E402
class TestEmailMultiImage:

View file

@ -39,20 +39,20 @@ def _run_setup_feishu(
def mock_get(name):
return existing_env.get(name, "")
with patch("hermes_cli.gateway.save_env_value", side_effect=mock_save), \
patch("hermes_cli.gateway.get_env_value", side_effect=mock_get), \
patch("hermes_cli.gateway.prompt_yes_no", side_effect=prompt_yes_no_responses), \
patch("hermes_cli.gateway.prompt_choice", side_effect=prompt_choice_responses), \
patch("hermes_cli.gateway.prompt", side_effect=prompt_responses), \
patch("hermes_cli.gateway.print_info"), \
patch("hermes_cli.gateway.print_success"), \
patch("hermes_cli.gateway.print_warning"), \
patch("hermes_cli.gateway.print_error"), \
patch("hermes_cli.gateway.color", side_effect=lambda t, c: t), \
patch("gateway.platforms.feishu.qr_register", return_value=qr_result):
with patch("hermes_cli.config.save_env_value", side_effect=mock_save), \
patch("hermes_cli.config.get_env_value", side_effect=mock_get), \
patch("hermes_cli.cli_output.prompt_yes_no", side_effect=prompt_yes_no_responses), \
patch("hermes_cli.setup.prompt_choice", side_effect=prompt_choice_responses), \
patch("hermes_cli.cli_output.prompt", side_effect=prompt_responses), \
patch("hermes_cli.cli_output.print_header"), \
patch("hermes_cli.cli_output.print_info"), \
patch("hermes_cli.cli_output.print_success"), \
patch("hermes_cli.cli_output.print_warning"), \
patch("hermes_cli.cli_output.print_error"), \
patch("plugins.platforms.feishu.adapter.qr_register", return_value=qr_result):
from hermes_cli.gateway import _setup_feishu
_setup_feishu()
from plugins.platforms.feishu.adapter import interactive_setup
interactive_setup()
return saved_env
@ -120,7 +120,7 @@ class TestSetupFeishuConnectionMode:
)
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
@patch("plugins.platforms.feishu.adapter.probe_bot", return_value=None)
def test_manual_path_websocket(self, _mock_probe):
env = _run_setup_feishu(
qr_result=None,
@ -129,7 +129,7 @@ class TestSetupFeishuConnectionMode:
)
assert env["FEISHU_CONNECTION_MODE"] == "websocket"
@patch("gateway.platforms.feishu.probe_bot", return_value=None)
@patch("plugins.platforms.feishu.adapter.probe_bot", return_value=None)
def test_manual_path_webhook(self, _mock_probe):
env = _run_setup_feishu(
qr_result=None,
@ -248,7 +248,7 @@ class TestSetupFeishuAdapterIntegration:
with patch.dict(os.environ, env, clear=True):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
assert adapter._app_id == "cli_test_app"
assert adapter._app_secret == "test_secret_value"
@ -261,7 +261,7 @@ class TestSetupFeishuAdapterIntegration:
env = self._make_env_from_setup(dm_idx=1)
with patch.dict(os.environ, env, clear=True):
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
from gateway.config import PlatformConfig
# Verify adapter initializes without error and env var is correct.
FeishuAdapter(PlatformConfig())
@ -274,6 +274,6 @@ class TestSetupFeishuAdapterIntegration:
with patch.dict(os.environ, env, clear=True):
from gateway.config import PlatformConfig
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
adapter = FeishuAdapter(PlatformConfig())
assert adapter._group_policy == "open"

View file

@ -64,11 +64,11 @@ def _ensure_slack_mock():
_ensure_slack_mock()
# Patch SLACK_AVAILABLE before importing the adapter
import gateway.platforms.slack as _slack_mod
import plugins.platforms.slack.adapter as _slack_mod
_slack_mod.SLACK_AVAILABLE = True
from gateway.platforms.slack import SlackAdapter # noqa: E402
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
async def _pending_for_fake_task():
@ -3627,7 +3627,7 @@ class TestSlashEphemeralAck:
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch(
"gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session
"plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session
):
result = await adapter.send("C_SLASH", "Queued for the next turn.")
@ -3677,7 +3677,7 @@ class TestSlashEphemeralAck:
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch(
"gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session
"plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session
):
result = await adapter.send("C1", "Some response")
@ -3700,7 +3700,7 @@ class TestSlashEphemeralAck:
mock_session.__aexit__ = AsyncMock(return_value=False)
with patch(
"gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session
"plugins.platforms.slack.adapter.aiohttp.ClientSession", return_value=mock_session
):
result = await adapter.send("C1", "Some response")
@ -3766,7 +3766,7 @@ class TestSlashEphemeralAck:
async def test_concurrent_users_same_channel_isolates_contexts(self, adapter):
"""Two users slash on the same channel — each gets their own context."""
import time
from gateway.platforms.slack import _slash_user_id
from plugins.platforms.slack.adapter import _slash_user_id
# Simulate two users stashing contexts on the same channel.
adapter._slash_command_contexts[("C_SHARED", "U_ALICE")] = {
@ -3806,7 +3806,7 @@ class TestSlashEphemeralAck:
async def test_no_contextvar_does_not_match_any_context(self, adapter):
"""send() without ContextVar (non-slash path) must not steal contexts."""
import time
from gateway.platforms.slack import _slash_user_id
from plugins.platforms.slack.adapter import _slash_user_id
adapter._slash_command_contexts[("C1", "U1")] = {
"response_url": "https://hooks.slack.com/test",

View file

@ -42,7 +42,7 @@ def _ensure_slack_mock():
_ensure_slack_mock()
from gateway.platforms.slack import SlackAdapter
from plugins.platforms.slack.adapter import SlackAdapter
from gateway.config import PlatformConfig, Platform

View file

@ -26,7 +26,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import PlatformConfig
from gateway.platforms.slack import SlackAdapter
from plugins.platforms.slack.adapter import SlackAdapter
@pytest.fixture

View file

@ -4,7 +4,7 @@ from unittest.mock import MagicMock
def _make_adapter(extra=None):
"""Create a minimal SlackAdapter stub with the given ``config.extra``."""
from gateway.platforms.slack import SlackAdapter
from plugins.platforms.slack.adapter import SlackAdapter
adapter = object.__new__(SlackAdapter)
adapter.config = MagicMock()
adapter.config.extra = extra or {}

View file

@ -40,10 +40,10 @@ def _ensure_slack_mock():
_ensure_slack_mock()
import gateway.platforms.slack as _slack_mod
import plugins.platforms.slack.adapter as _slack_mod
_slack_mod.SLACK_AVAILABLE = True
from gateway.platforms.slack import SlackAdapter # noqa: E402
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
# ---------------------------------------------------------------------------

View file

@ -58,11 +58,11 @@ def _ensure_slack_mock() -> None:
_ensure_slack_mock()
import gateway.platforms.slack as _slack_mod # noqa: E402
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True
from gateway.config import PlatformConfig # noqa: E402
from gateway.platforms.slack import SlackAdapter # noqa: E402
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
from hermes_cli.plugins import ( # noqa: E402
PluginContext,

View file

@ -0,0 +1,57 @@
"""Tests for the Slack plugin's interactive_setup wizard.
These cover the home-channel save logic that previously lived in
``hermes_cli/setup.py::_setup_slack`` before the Slack adapter migrated to a
bundled plugin (#41112). ``interactive_setup`` lazy-imports its CLI helpers
from ``hermes_cli.config`` (get_env_value / save_env_value) and
``hermes_cli.cli_output`` (prompt / prompt_yes_no / print_*), so we patch those
source modules.
"""
import hermes_cli.config as config_mod
import hermes_cli.cli_output as cli_output_mod
from plugins.platforms.slack.adapter import interactive_setup
def _patch_setup_io(monkeypatch, prompts, saved):
"""Wire interactive_setup's lazy-imported CLI helpers to test doubles."""
prompt_iter = iter(prompts)
monkeypatch.setattr(config_mod, "get_env_value", lambda key: "")
monkeypatch.setattr(config_mod, "save_env_value", lambda k, v: saved.update({k: v}))
monkeypatch.setattr(cli_output_mod, "prompt", lambda *_a, **_kw: next(prompt_iter))
monkeypatch.setattr(cli_output_mod, "prompt_yes_no", lambda *_a, **_kw: False)
for name in ("print_header", "print_info", "print_success", "print_warning"):
monkeypatch.setattr(cli_output_mod, name, lambda *_a, **_kw: None)
# Manifest writing reaches out to hermes_cli.slack_cli + filesystem; stub it.
import hermes_cli.slack_cli as slack_cli_mod
monkeypatch.setattr(slack_cli_mod, "_build_full_manifest", lambda **_kw: {"display_information": {}})
def test_interactive_setup_saves_home_channel(monkeypatch, tmp_path):
"""interactive_setup() saves SLACK_HOME_CHANNEL when the user provides one."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
saved = {}
# prompts: bot token, app token, allowed users (empty), home channel
_patch_setup_io(
monkeypatch,
["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"],
saved,
)
interactive_setup()
assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F"
def test_interactive_setup_home_channel_empty_not_saved(monkeypatch, tmp_path):
"""interactive_setup() does not save SLACK_HOME_CHANNEL when left blank."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
saved = {}
_patch_setup_io(
monkeypatch,
["xoxb-test-token", "xapp-test-token", "", ""],
saved,
)
interactive_setup()
assert "SLACK_HOME_CHANNEL" not in saved

View file

@ -59,7 +59,7 @@ class TestSmsFormatAndTruncate:
"""Test SmsAdapter.format_message strips markdown."""
def _make_adapter(self):
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -115,7 +115,7 @@ class TestSmsEchoPrevention:
def test_own_number_detection(self):
"""The adapter stores _from_number for echo prevention."""
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -132,21 +132,21 @@ class TestSmsEchoPrevention:
class TestSmsRequirements:
def test_check_sms_requirements_missing_sid(self):
from gateway.platforms.sms import check_sms_requirements
from plugins.platforms.sms.adapter import check_sms_requirements
env = {"TWILIO_AUTH_TOKEN": "tok"}
with patch.dict(os.environ, env, clear=True):
assert check_sms_requirements() is False
def test_check_sms_requirements_missing_token(self):
from gateway.platforms.sms import check_sms_requirements
from plugins.platforms.sms.adapter import check_sms_requirements
env = {"TWILIO_ACCOUNT_SID": "ACtest"}
with patch.dict(os.environ, env, clear=True):
assert check_sms_requirements() is False
def test_check_sms_requirements_both_set(self):
from gateway.platforms.sms import check_sms_requirements
from plugins.platforms.sms.adapter import check_sms_requirements
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -170,11 +170,11 @@ class TestWebhookHostConfig:
"""Verify SMS_WEBHOOK_HOST env var and default."""
def test_default_host_is_localhost(self):
from gateway.platforms.sms import DEFAULT_WEBHOOK_HOST
from plugins.platforms.sms.adapter import DEFAULT_WEBHOOK_HOST
assert DEFAULT_WEBHOOK_HOST == "127.0.0.1"
def test_host_from_env(self):
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -188,7 +188,7 @@ class TestWebhookHostConfig:
assert adapter._webhook_host == "127.0.0.1"
def test_webhook_url_from_env(self):
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -202,7 +202,7 @@ class TestWebhookHostConfig:
assert adapter._webhook_url == "https://example.com/webhooks/twilio"
def test_webhook_url_stripped(self):
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -222,7 +222,7 @@ class TestStartupGuard:
"""Adapter must refuse to start without SMS_WEBHOOK_URL."""
def _make_adapter(self, extra_env=None):
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -252,7 +252,7 @@ class TestStartupGuard:
@pytest.mark.asyncio
async def test_missing_phone_number_is_non_retryable(self):
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -335,7 +335,7 @@ class TestTwilioSignatureValidation:
"""Unit tests for SmsAdapter._validate_twilio_signature."""
def _make_adapter(self, auth_token="test_token_secret"):
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",
@ -445,7 +445,7 @@ class TestWebhookSignatureEnforcement:
"""Integration tests for signature validation in _handle_webhook."""
def _make_adapter(self, webhook_url=""):
from gateway.platforms.sms import SmsAdapter
from plugins.platforms.sms.adapter import SmsAdapter
env = {
"TWILIO_ACCOUNT_SID": "ACtest",

View file

@ -148,14 +148,14 @@ class TestEditMessageFinalizeSignature:
@pytest.mark.parametrize(
"module_path,class_name",
[
("gateway.platforms.telegram", "TelegramAdapter"),
("plugins.platforms.telegram.adapter", "TelegramAdapter"),
("plugins.platforms.discord.adapter", "DiscordAdapter"),
("gateway.platforms.slack", "SlackAdapter"),
("gateway.platforms.matrix", "MatrixAdapter"),
("plugins.platforms.slack.adapter", "SlackAdapter"),
("plugins.platforms.matrix.adapter", "MatrixAdapter"),
("plugins.platforms.mattermost.adapter", "MattermostAdapter"),
("gateway.platforms.feishu", "FeishuAdapter"),
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
("gateway.platforms.dingtalk", "DingTalkAdapter"),
("plugins.platforms.feishu.adapter", "FeishuAdapter"),
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
("plugins.platforms.dingtalk.adapter", "DingTalkAdapter"),
],
)
def test_edit_message_accepts_finalize(self, module_path, class_name):

View file

@ -646,7 +646,7 @@ class TestTelegramAdapterDeleteMessage:
"""Contract: Telegram adapter implements ``delete_message``."""
def test_delete_message_method_exists(self):
telegram = pytest.importorskip("gateway.platforms.telegram")
telegram = pytest.importorskip("plugins.platforms.telegram.adapter")
import inspect
cls = telegram.TelegramAdapter
assert hasattr(cls, "delete_message"), (

View file

@ -180,7 +180,7 @@ class TestFeishuFallbackThreadRouting:
async def test_create_uses_thread_id_when_available(self):
"""When reply_to=None and metadata has thread_id, message.create
should use receive_id_type='thread_id'."""
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
# We test the _send_raw_message method directly by mocking the client
adapter = MagicMock(spec=FeishuAdapter)
@ -237,7 +237,7 @@ class TestFeishuFallbackThreadRouting:
async def test_create_uses_chat_id_when_no_thread(self):
"""When reply_to=None and metadata has no thread_id, message.create
should use receive_id_type='chat_id' (original behavior)."""
from gateway.platforms.feishu import FeishuAdapter
from plugins.platforms.feishu.adapter import FeishuAdapter
mock_client = MagicMock()
mock_create_response = SimpleNamespace(

View file

@ -46,7 +46,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
from gateway.config import Platform, PlatformConfig

View file

@ -55,7 +55,7 @@ def _inject_fake_telegram(monkeypatch):
def _make_adapter():
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
config = PlatformConfig(enabled=True, token="fake-token")
adapter = object.__new__(TelegramAdapter)

View file

@ -1,7 +1,7 @@
"""Tests for TelegramPlatform._merge_caption caption deduplication logic."""
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
merge = TelegramAdapter._merge_caption

View file

@ -63,7 +63,7 @@ def _build_telegram_stubs():
@pytest.fixture
def telegram_adapter_cls(monkeypatch):
"""Import TelegramAdapter without leaking temporary telegram stubs."""
module_name = "gateway.platforms.telegram"
module_name = "plugins.platforms.telegram.adapter"
existing_module = sys.modules.get(module_name)
if existing_module is not None:
yield existing_module.TelegramAdapter

View file

@ -47,7 +47,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
from gateway.config import PlatformConfig

View file

@ -34,7 +34,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
@pytest.fixture(autouse=True)
@ -42,9 +42,9 @@ def _no_auto_discovery(monkeypatch):
"""Disable DoH auto-discovery so connect() uses the plain builder chain."""
async def _noop():
return []
monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop)
monkeypatch.setattr("plugins.platforms.telegram.adapter.discover_fallback_ips", _noop)
# Mock HTTPXRequest so the builder chain doesn't fail
monkeypatch.setattr("gateway.platforms.telegram.HTTPXRequest", lambda **kwargs: MagicMock())
monkeypatch.setattr("plugins.platforms.telegram.adapter.HTTPXRequest", lambda **kwargs: MagicMock())
@pytest.mark.asyncio
@ -103,7 +103,7 @@ async def test_polling_conflict_retries_before_fatal(monkeypatch):
builder.request.return_value = builder
builder.get_updates_request.return_value = builder
builder.build.return_value = app
monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
monkeypatch.setattr("plugins.platforms.telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
# Speed up retries for testing
monkeypatch.setattr("asyncio.sleep", AsyncMock())
@ -179,7 +179,7 @@ async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch):
builder.request.return_value = builder
builder.get_updates_request.return_value = builder
builder.build.return_value = app
monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
monkeypatch.setattr("plugins.platforms.telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
# Speed up retries for testing
monkeypatch.setattr("asyncio.sleep", AsyncMock())
@ -232,7 +232,7 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m
start=AsyncMock(),
)
builder.build.return_value = app
monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
monkeypatch.setattr("plugins.platforms.telegram.adapter.Application", SimpleNamespace(builder=MagicMock(return_value=builder)))
ok = await adapter.connect()
@ -277,7 +277,7 @@ async def test_connect_clears_webhook_before_polling(monkeypatch):
builder.get_updates_request.return_value = builder
builder.build.return_value = app
monkeypatch.setattr(
"gateway.platforms.telegram.Application",
"plugins.platforms.telegram.adapter.Application",
SimpleNamespace(builder=MagicMock(return_value=builder)),
)
@ -301,7 +301,7 @@ async def test_disconnect_skips_inactive_updater_and_app(monkeypatch):
adapter._app = app
warning = MagicMock()
monkeypatch.setattr("gateway.platforms.telegram.logger.warning", warning)
monkeypatch.setattr("plugins.platforms.telegram.adapter.logger.warning", warning)
await adapter.disconnect()
@ -367,7 +367,7 @@ async def test_polling_conflict_reschedule_uses_running_loop(monkeypatch):
builder.get_updates_request.return_value = builder
builder.build.return_value = app
monkeypatch.setattr(
"gateway.platforms.telegram.Application",
"plugins.platforms.telegram.adapter.Application",
SimpleNamespace(builder=MagicMock(return_value=builder)),
)
monkeypatch.setattr("asyncio.sleep", AsyncMock())

View file

@ -51,7 +51,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
# Now we can safely import
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
# ---------------------------------------------------------------------------
@ -442,7 +442,7 @@ class TestMediaGroups:
msg1 = _make_message(caption="two images", photo=[first_photo])
msg2 = _make_message(photo=[second_photo])
with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]):
with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/burst-one.jpg", "/tmp/burst-two.jpg"]):
await adapter._handle_media_message(_make_update(msg1), MagicMock())
await adapter._handle_media_message(_make_update(msg2), MagicMock())
assert adapter.handle_message.await_count == 0
@ -462,7 +462,7 @@ class TestMediaGroups:
msg1 = _make_message(caption="two images", media_group_id="album-1", photo=[first_photo])
msg2 = _make_message(media_group_id="album-1", photo=[second_photo])
with patch("gateway.platforms.telegram.cache_image_from_bytes", side_effect=["/tmp/one.jpg", "/tmp/two.jpg"]):
with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", side_effect=["/tmp/one.jpg", "/tmp/two.jpg"]):
await adapter._handle_media_message(_make_update(msg1), MagicMock())
await adapter._handle_media_message(_make_update(msg2), MagicMock())
assert adapter.handle_message.await_count == 0
@ -479,7 +479,7 @@ class TestMediaGroups:
first_photo = _make_photo(_make_file_obj(b"first"))
msg = _make_message(caption="two images", media_group_id="album-2", photo=[first_photo])
with patch("gateway.platforms.telegram.cache_image_from_bytes", return_value="/tmp/one.jpg"):
with patch("plugins.platforms.telegram.adapter.cache_image_from_bytes", return_value="/tmp/one.jpg"):
await adapter._handle_media_message(_make_update(msg), MagicMock())
assert "album-2" in adapter._media_group_events
@ -782,8 +782,8 @@ class TestTelegramPhotoBatching:
)
with (
patch("gateway.platforms.telegram.asyncio.current_task", return_value=old_task),
patch("gateway.platforms.telegram.asyncio.sleep", new=AsyncMock()),
patch("plugins.platforms.telegram.adapter.asyncio.current_task", return_value=old_task),
patch("plugins.platforms.telegram.adapter.asyncio.sleep", new=AsyncMock()),
):
await adapter._flush_photo_batch(batch_key)

View file

@ -35,7 +35,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import ( # noqa: E402
from plugins.platforms.telegram.adapter import ( # noqa: E402
TelegramAdapter,
_escape_mdv2,
_strip_mdv2,

View file

@ -11,7 +11,7 @@ from gateway.config import Platform, PlatformConfig
def _make_test_adapter():
"""Build a TelegramAdapter without running __init__."""
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
adapter = object.__new__(TelegramAdapter)
adapter.platform = Platform.TELEGRAM

View file

@ -23,7 +23,7 @@ def _make_adapter(
observe_unmentioned_group_messages=None,
bot_username="hermes_bot",
):
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
extra = {}
if require_mention is not None:

View file

@ -29,7 +29,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
def test_max_doc_bytes_defaults_to_20mb_without_base_url():

View file

@ -14,7 +14,7 @@ those contexts.
from types import SimpleNamespace
from gateway.config import Platform, PlatformConfig
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
def _make_adapter():

View file

@ -32,7 +32,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.config import PlatformConfig
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
def _make_adapter():
@ -147,7 +147,7 @@ class TestTelegramModelPicker:
which is robust to whether `telegram` is the real SDK or the module
mock (the SDK markup objects don't expose a plain iterable under the
mock)."""
import gateway.platforms.telegram as tg
import plugins.platforms.telegram.adapter as tg
built: list = []

View file

@ -1,4 +1,4 @@
"""Tests for gateway.platforms.telegram_network fallback transport layer.
"""Tests for plugins.platforms.telegram.telegram_network fallback transport layer.
Background
----------
@ -18,7 +18,7 @@ fallback IPs in order, then "stick" to whichever IP works.
import httpx
import pytest
from gateway.platforms import telegram_network as tnet
import plugins.platforms.telegram.telegram_network as tnet
# ---------------------------------------------------------------------------
@ -438,7 +438,7 @@ class TestAdapterFallbackIps:
sys.modules.setdefault(name, mod)
from gateway.config import PlatformConfig
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
config = PlatformConfig(enabled=True, token="test-token")
if extra:

View file

@ -33,7 +33,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
@pytest.fixture(autouse=True)
@ -41,7 +41,7 @@ def _no_auto_discovery(monkeypatch):
"""Disable DoH auto-discovery so connect() uses the plain builder chain."""
async def _noop():
return []
monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop)
monkeypatch.setattr("plugins.platforms.telegram.adapter.discover_fallback_ips", _noop)
def _make_adapter() -> TelegramAdapter:
@ -379,7 +379,7 @@ async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out():
raise asyncio.TimeoutError()
with patch("asyncio.sleep", new_callable=AsyncMock):
with patch("gateway.platforms.telegram.asyncio.wait_for", new=fast_wait_for):
with patch("plugins.platforms.telegram.adapter.asyncio.wait_for", new=fast_wait_for):
await adapter._verify_polling_after_reconnect()
adapter._handle_polling_network_error.assert_awaited_once()

View file

@ -7,7 +7,7 @@ import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import SendResult
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
from gateway.stream_consumer import GatewayStreamConsumer

View file

@ -11,7 +11,7 @@ from gateway.session import SessionSource
def _make_adapter(**extra_env):
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
adapter = object.__new__(TelegramAdapter)
adapter.platform = Platform.TELEGRAM

View file

@ -31,7 +31,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
@pytest.fixture()

View file

@ -33,7 +33,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
def _make_adapter():

View file

@ -17,7 +17,7 @@ import pytest
from gateway.config import PlatformConfig
from gateway.platforms.base import SendResult
from gateway.platforms.telegram import TelegramAdapter
from plugins.platforms.telegram.adapter import TelegramAdapter
from telegram.error import BadRequest, NetworkError, TimedOut

View file

@ -35,8 +35,8 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms import telegram as tg_mod # noqa: E402
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
import plugins.platforms.telegram.adapter as tg_mod # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
def _make_adapter() -> TelegramAdapter:

View file

@ -27,7 +27,7 @@ def _ensure_telegram_mock():
_ensure_telegram_mock()
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
def _make_adapter() -> TelegramAdapter:
@ -78,12 +78,12 @@ async def test_reconnect_storm_sets_and_heartbeat_clears_flag(monkeypatch):
adapter._app.bot.get_me = AsyncMock(return_value=MagicMock())
adapter._polling_error_callback_ref = AsyncMock()
monkeypatch.setattr(
"gateway.platforms.telegram.Update", MagicMock(ALL_TYPES=[])
"plugins.platforms.telegram.adapter.Update", MagicMock(ALL_TYPES=[])
)
await adapter._handle_polling_network_error(OSError("Bad Gateway"))
assert adapter._send_path_degraded is True
with patch("gateway.platforms.telegram.asyncio.sleep", new_callable=AsyncMock):
with patch("plugins.platforms.telegram.adapter.asyncio.sleep", new_callable=AsyncMock):
await adapter._verify_polling_after_reconnect()
assert adapter._send_path_degraded is False

Some files were not shown because too many files have changed in this diff Show more