From 5600105478ffde29d7566b45421b100eaa29c4ef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:41:08 -0700 Subject: [PATCH] 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//, 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). --- gateway/config.py | 283 ++---- gateway/run.py | 97 +- hermes_cli/gateway.py | 831 +----------------- hermes_cli/setup.py | 228 +---- hermes_logging.py | 6 +- plugins/platforms/dingtalk/__init__.py | 3 + .../platforms/dingtalk/adapter.py | 212 ++++- plugins/platforms/dingtalk/plugin.yaml | 39 + plugins/platforms/email/__init__.py | 3 + .../platforms/email/adapter.py | 98 +++ plugins/platforms/email/plugin.yaml | 39 + plugins/platforms/feishu/__init__.py | 3 + .../platforms/feishu/adapter.py | 302 ++++++- .../platforms/feishu}/feishu_comment.py | 2 +- .../platforms/feishu}/feishu_comment_rules.py | 0 .../feishu}/feishu_meeting_invite.py | 0 plugins/platforms/feishu/plugin.yaml | 44 + plugins/platforms/matrix/__init__.py | 3 + .../platforms/matrix/adapter.py | 265 ++++++ plugins/platforms/matrix/plugin.yaml | 41 + plugins/platforms/slack/__init__.py | 3 + .../platforms/slack/adapter.py | 296 +++++++ plugins/platforms/slack/plugin.yaml | 39 + plugins/platforms/sms/__init__.py | 3 + .../platforms/sms/adapter.py | 114 +++ plugins/platforms/sms/plugin.yaml | 32 + plugins/platforms/telegram/__init__.py | 3 + .../platforms/telegram/adapter.py | 231 ++++- plugins/platforms/telegram/plugin.yaml | 35 + .../platforms/telegram}/telegram_network.py | 0 plugins/platforms/wecom/__init__.py | 3 + .../platforms/wecom/adapter.py | 229 +++++ .../platforms/wecom/callback_adapter.py | 2 +- plugins/platforms/wecom/plugin.yaml | 52 ++ .../platforms/wecom}/wecom_crypto.py | 0 plugins/platforms/whatsapp/__init__.py | 3 + .../platforms/whatsapp/adapter.py | 187 ++++ plugins/platforms/whatsapp/plugin.yaml | 33 + tests/e2e/conftest.py | 6 +- tests/gateway/conftest.py | 2 +- tests/gateway/feishu_helpers.py | 2 +- .../gateway/test_allowed_channels_widening.py | 6 +- tests/gateway/test_config.py | 2 +- .../test_config_driven_access_policy.py | 4 +- tests/gateway/test_dingtalk.py | 108 +-- tests/gateway/test_dm_topics.py | 4 +- tests/gateway/test_email.py | 87 +- tests/gateway/test_feishu.py | 482 +++++----- tests/gateway/test_feishu_approval_buttons.py | 4 +- tests/gateway/test_feishu_bot_admission.py | 32 +- tests/gateway/test_feishu_comment.py | 90 +- tests/gateway/test_feishu_comment_rules.py | 14 +- tests/gateway/test_feishu_meeting_invite.py | 4 +- tests/gateway/test_feishu_onboard.py | 156 ++-- tests/gateway/test_matrix.py | 148 ++-- ...st_matrix_approval_reaction_fail_closed.py | 4 +- tests/gateway/test_matrix_exec_approval.py | 4 +- tests/gateway/test_matrix_mention.py | 2 +- .../test_matrix_project_context_isolation.py | 2 +- tests/gateway/test_matrix_voice.py | 2 +- tests/gateway/test_media_download_retry.py | 4 +- tests/gateway/test_media_metadata_contract.py | 20 +- .../test_platform_connected_checkers.py | 26 +- .../test_platform_http_client_limits.py | 8 +- tests/gateway/test_send_image_file.py | 4 +- tests/gateway/test_send_multiple_images.py | 6 +- tests/gateway/test_setup_feishu.py | 36 +- tests/gateway/test_slack.py | 14 +- tests/gateway/test_slack_approval_buttons.py | 2 +- .../test_slack_channel_session_scope.py | 2 +- tests/gateway/test_slack_channel_skills.py | 2 +- tests/gateway/test_slack_mention.py | 4 +- .../test_slack_plugin_action_handlers.py | 4 +- tests/gateway/test_slack_plugin_setup.py | 57 ++ tests/gateway/test_sms.py | 26 +- tests/gateway/test_stream_consumer.py | 12 +- .../test_stream_consumer_fresh_final.py | 2 +- .../test_stream_consumer_thread_routing.py | 4 +- .../gateway/test_telegram_approval_buttons.py | 2 +- ...test_telegram_callback_auth_fail_closed.py | 2 +- tests/gateway/test_telegram_caption_merge.py | 2 +- tests/gateway/test_telegram_channel_posts.py | 2 +- .../gateway/test_telegram_clarify_buttons.py | 2 +- tests/gateway/test_telegram_conflict.py | 18 +- tests/gateway/test_telegram_documents.py | 12 +- tests/gateway/test_telegram_format.py | 2 +- tests/gateway/test_telegram_forum_commands.py | 2 +- tests/gateway/test_telegram_group_gating.py | 2 +- tests/gateway/test_telegram_max_doc_bytes.py | 2 +- .../test_telegram_mention_boundaries.py | 2 +- tests/gateway/test_telegram_model_picker.py | 4 +- tests/gateway/test_telegram_network.py | 6 +- .../test_telegram_network_reconnect.py | 6 +- .../gateway/test_telegram_overflow_partial.py | 2 +- tests/gateway/test_telegram_reactions.py | 2 +- tests/gateway/test_telegram_reply_mode.py | 2 +- tests/gateway/test_telegram_reply_quote.py | 2 +- tests/gateway/test_telegram_rich_messages.py | 2 +- .../test_telegram_send_draft_format.py | 4 +- .../gateway/test_telegram_send_path_health.py | 6 +- tests/gateway/test_telegram_slash_confirm.py | 2 +- .../gateway/test_telegram_status_indicator.py | 2 +- tests/gateway/test_telegram_status_update.py | 2 +- .../gateway/test_telegram_text_batch_perf.py | 2 +- tests/gateway/test_telegram_text_batching.py | 2 +- .../gateway/test_telegram_thread_fallback.py | 8 +- .../test_telegram_voice_v0_regressions.py | 2 +- tests/gateway/test_text_batching.py | 8 +- tests/gateway/test_wecom.py | 116 +-- tests/gateway/test_wecom_callback.py | 6 +- tests/gateway/test_whatsapp_connect.py | 62 +- tests/gateway/test_whatsapp_formatting.py | 4 +- tests/gateway/test_whatsapp_group_gating.py | 4 +- tests/gateway/test_whatsapp_reply_prefix.py | 6 +- tests/gateway/test_whatsapp_stale_bridge.py | 68 +- tests/gateway/test_whatsapp_text_batching.py | 2 +- tests/gateway/test_ws_auth_retry.py | 6 +- tests/hermes_cli/test_logs.py | 16 +- tests/hermes_cli/test_setup.py | 43 +- tests/test_hermes_logging.py | 25 +- .../test_send_message_missing_platforms.py | 25 +- tests/tools/test_send_message_tool.py | 153 +++- tests/tools/test_signal_media.py | 16 +- tools/send_message_tool.py | 394 ++------- 124 files changed, 3643 insertions(+), 2579 deletions(-) create mode 100644 plugins/platforms/dingtalk/__init__.py rename gateway/platforms/dingtalk.py => plugins/platforms/dingtalk/adapter.py (86%) create mode 100644 plugins/platforms/dingtalk/plugin.yaml create mode 100644 plugins/platforms/email/__init__.py rename gateway/platforms/email.py => plugins/platforms/email/adapter.py (89%) create mode 100644 plugins/platforms/email/plugin.yaml create mode 100644 plugins/platforms/feishu/__init__.py rename gateway/platforms/feishu.py => plugins/platforms/feishu/adapter.py (94%) rename {gateway/platforms => plugins/platforms/feishu}/feishu_comment.py (99%) rename {gateway/platforms => plugins/platforms/feishu}/feishu_comment_rules.py (100%) rename {gateway/platforms => plugins/platforms/feishu}/feishu_meeting_invite.py (100%) create mode 100644 plugins/platforms/feishu/plugin.yaml create mode 100644 plugins/platforms/matrix/__init__.py rename gateway/platforms/matrix.py => plugins/platforms/matrix/adapter.py (92%) create mode 100644 plugins/platforms/matrix/plugin.yaml create mode 100644 plugins/platforms/slack/__init__.py rename gateway/platforms/slack.py => plugins/platforms/slack/adapter.py (91%) create mode 100644 plugins/platforms/slack/plugin.yaml create mode 100644 plugins/platforms/sms/__init__.py rename gateway/platforms/sms.py => plugins/platforms/sms/adapter.py (73%) create mode 100644 plugins/platforms/sms/plugin.yaml create mode 100644 plugins/platforms/telegram/__init__.py rename gateway/platforms/telegram.py => plugins/platforms/telegram/adapter.py (96%) create mode 100644 plugins/platforms/telegram/plugin.yaml rename {gateway/platforms => plugins/platforms/telegram}/telegram_network.py (100%) create mode 100644 plugins/platforms/wecom/__init__.py rename gateway/platforms/wecom.py => plugins/platforms/wecom/adapter.py (87%) rename gateway/platforms/wecom_callback.py => plugins/platforms/wecom/callback_adapter.py (99%) create mode 100644 plugins/platforms/wecom/plugin.yaml rename {gateway/platforms => plugins/platforms/wecom}/wecom_crypto.py (100%) create mode 100644 plugins/platforms/whatsapp/__init__.py rename gateway/platforms/whatsapp.py => plugins/platforms/whatsapp/adapter.py (86%) create mode 100644 plugins/platforms/whatsapp/plugin.yaml create mode 100644 tests/gateway/test_slack_plugin_setup.py diff --git a/gateway/config.py b/gateway/config.py index 8b459c32420..a29f7306924 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -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"]``) diff --git a/gateway/run.py b/gateway/run.py index 4874c28a08b..cb777fbf4da 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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(): diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index f1dddd087f4..cf65af98c40 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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 ") - 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) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index b809af6ecf7..ee160413edc 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -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(): diff --git a/hermes_logging.py b/hermes_logging.py index 2c855d3c253..9e34fbaafbc 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -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"), diff --git a/plugins/platforms/dingtalk/__init__.py b/plugins/platforms/dingtalk/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/dingtalk/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/dingtalk.py b/plugins/platforms/dingtalk/adapter.py similarity index 86% rename from gateway/platforms/dingtalk.py rename to plugins/platforms/dingtalk/adapter.py index 0b3c7f52ace..29abe98ecdf 100644 --- a/gateway/platforms/dingtalk.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -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, + ) diff --git a/plugins/platforms/dingtalk/plugin.yaml b/plugins/platforms/dingtalk/plugin.yaml new file mode 100644 index 00000000000..ab2280382a9 --- /dev/null +++ b/plugins/platforms/dingtalk/plugin.yaml @@ -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 diff --git a/plugins/platforms/email/__init__.py b/plugins/platforms/email/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/email/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/email.py b/plugins/platforms/email/adapter.py similarity index 89% rename from gateway/platforms/email.py rename to plugins/platforms/email/adapter.py index 3ce41d5fe17..106c8616eaa 100644 --- a/gateway/platforms/email.py +++ b/plugins/platforms/email/adapter.py @@ -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, + ) diff --git a/plugins/platforms/email/plugin.yaml b/plugins/platforms/email/plugin.yaml new file mode 100644 index 00000000000..8e9ca3d877b --- /dev/null +++ b/plugins/platforms/email/plugin.yaml @@ -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 diff --git a/plugins/platforms/feishu/__init__.py b/plugins/platforms/feishu/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/feishu/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/feishu.py b/plugins/platforms/feishu/adapter.py similarity index 94% rename from gateway/platforms/feishu.py rename to plugins/platforms/feishu/adapter.py index 7b29ba13528..0c085a50cfe 100644 --- a/gateway/platforms/feishu.py +++ b/plugins/platforms/feishu/adapter.py @@ -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, + ) diff --git a/gateway/platforms/feishu_comment.py b/plugins/platforms/feishu/feishu_comment.py similarity index 99% rename from gateway/platforms/feishu_comment.py rename to plugins/platforms/feishu/feishu_comment.py index 4d757cc7646..83b41469fdd 100644 --- a/gateway/platforms/feishu_comment.py +++ b/plugins/platforms/feishu/feishu_comment.py @@ -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) diff --git a/gateway/platforms/feishu_comment_rules.py b/plugins/platforms/feishu/feishu_comment_rules.py similarity index 100% rename from gateway/platforms/feishu_comment_rules.py rename to plugins/platforms/feishu/feishu_comment_rules.py diff --git a/gateway/platforms/feishu_meeting_invite.py b/plugins/platforms/feishu/feishu_meeting_invite.py similarity index 100% rename from gateway/platforms/feishu_meeting_invite.py rename to plugins/platforms/feishu/feishu_meeting_invite.py diff --git a/plugins/platforms/feishu/plugin.yaml b/plugins/platforms/feishu/plugin.yaml new file mode 100644 index 00000000000..0eabd947ea6 --- /dev/null +++ b/plugins/platforms/feishu/plugin.yaml @@ -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 diff --git a/plugins/platforms/matrix/__init__.py b/plugins/platforms/matrix/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/matrix/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/matrix.py b/plugins/platforms/matrix/adapter.py similarity index 92% rename from gateway/platforms/matrix.py rename to plugins/platforms/matrix/adapter.py index 9aee8622b84..6304f6e53b6 100644 --- a/gateway/platforms/matrix.py +++ b/plugins/platforms/matrix/adapter.py @@ -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"(.*?)", r"\1", 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, + ) diff --git a/plugins/platforms/matrix/plugin.yaml b/plugins/platforms/matrix/plugin.yaml new file mode 100644 index 00000000000..77d65d93396 --- /dev/null +++ b/plugins/platforms/matrix/plugin.yaml @@ -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 diff --git a/plugins/platforms/slack/__init__.py b/plugins/platforms/slack/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/slack/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/slack.py b/plugins/platforms/slack/adapter.py similarity index 91% rename from gateway/platforms/slack.py rename to plugins/platforms/slack/adapter.py index ad1de2a25a1..274fe61665f 100644 --- a/gateway/platforms/slack.py +++ b/plugins/platforms/slack/adapter.py @@ -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 YAML→env 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, + ) diff --git a/plugins/platforms/slack/plugin.yaml b/plugins/platforms/slack/plugin.yaml new file mode 100644 index 00000000000..338925559a7 --- /dev/null +++ b/plugins/platforms/slack/plugin.yaml @@ -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 diff --git a/plugins/platforms/sms/__init__.py b/plugins/platforms/sms/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/sms/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/sms.py b/plugins/platforms/sms/adapter.py similarity index 73% rename from gateway/platforms/sms.py rename to plugins/platforms/sms/adapter.py index 9d9957d5ea1..a1edffb8e16 100644 --- a/gateway/platforms/sms.py +++ b/plugins/platforms/sms/adapter.py @@ -377,3 +377,117 @@ class SmsAdapter(BasePlatformAdapter): text='', 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, + ) diff --git a/plugins/platforms/sms/plugin.yaml b/plugins/platforms/sms/plugin.yaml new file mode 100644 index 00000000000..222106b6dd8 --- /dev/null +++ b/plugins/platforms/sms/plugin.yaml @@ -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 diff --git a/plugins/platforms/telegram/__init__.py b/plugins/platforms/telegram/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/telegram/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/telegram.py b/plugins/platforms/telegram/adapter.py similarity index 96% rename from gateway/platforms/telegram.py rename to plugins/platforms/telegram/adapter.py index d5228d873c1..2560f3813de 100644 --- a/gateway/platforms/telegram.py +++ b/plugins/platforms/telegram/adapter.py @@ -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, + ) diff --git a/plugins/platforms/telegram/plugin.yaml b/plugins/platforms/telegram/plugin.yaml new file mode 100644 index 00000000000..468081d2d38 --- /dev/null +++ b/plugins/platforms/telegram/plugin.yaml @@ -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 diff --git a/gateway/platforms/telegram_network.py b/plugins/platforms/telegram/telegram_network.py similarity index 100% rename from gateway/platforms/telegram_network.py rename to plugins/platforms/telegram/telegram_network.py diff --git a/plugins/platforms/wecom/__init__.py b/plugins/platforms/wecom/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/wecom/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/wecom.py b/plugins/platforms/wecom/adapter.py similarity index 87% rename from gateway/platforms/wecom.py rename to plugins/platforms/wecom/adapter.py index bb8b422cdcf..0d3fe1da3df 100644 --- a/gateway/platforms/wecom.py +++ b/plugins/platforms/wecom/adapter.py @@ -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 ") + 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, + ) diff --git a/gateway/platforms/wecom_callback.py b/plugins/platforms/wecom/callback_adapter.py similarity index 99% rename from gateway/platforms/wecom_callback.py rename to plugins/platforms/wecom/callback_adapter.py index 4335f156f18..496c789e4e0 100644 --- a/gateway/platforms/wecom_callback.py +++ b/plugins/platforms/wecom/callback_adapter.py @@ -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__) diff --git a/plugins/platforms/wecom/plugin.yaml b/plugins/platforms/wecom/plugin.yaml new file mode 100644 index 00000000000..ea213be9ddd --- /dev/null +++ b/plugins/platforms/wecom/plugin.yaml @@ -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 diff --git a/gateway/platforms/wecom_crypto.py b/plugins/platforms/wecom/wecom_crypto.py similarity index 100% rename from gateway/platforms/wecom_crypto.py rename to plugins/platforms/wecom/wecom_crypto.py diff --git a/plugins/platforms/whatsapp/__init__.py b/plugins/platforms/whatsapp/__init__.py new file mode 100644 index 00000000000..d4f1d7bf0e3 --- /dev/null +++ b/plugins/platforms/whatsapp/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/whatsapp.py b/plugins/platforms/whatsapp/adapter.py similarity index 86% rename from gateway/platforms/whatsapp.py rename to plugins/platforms/whatsapp/adapter.py index f31d21cae4a..c692f3536f6 100644 --- a/gateway/platforms/whatsapp.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -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, + ) diff --git a/plugins/platforms/whatsapp/plugin.yaml b/plugins/platforms/whatsapp/plugin.yaml new file mode 100644 index 00000000000..7446f5240b0 --- /dev/null +++ b/plugins/platforms/whatsapp/plugin.yaml @@ -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 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 3adbd557dd1..dcbbb1a1cb8 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -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 diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index 2d56c7c11f4..a16eb76a6fe 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -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 diff --git a/tests/gateway/feishu_helpers.py b/tests/gateway/feishu_helpers.py index 753a61a70a8..ae8a4bfc371 100644 --- a/tests/gateway/feishu_helpers.py +++ b/tests/gateway/feishu_helpers.py @@ -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 diff --git a/tests/gateway/test_allowed_channels_widening.py b/tests/gateway/test_allowed_channels_widening.py index 0d214713a1c..26c1b83983d 100644 --- a/tests/gateway/test_allowed_channels_widening.py +++ b/tests/gateway/test_allowed_channels_widening.py @@ -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: diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 9f38f9b8a0d..2ccb63d8864 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -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 diff --git a/tests/gateway/test_config_driven_access_policy.py b/tests/gateway/test_config_driven_access_policy.py index a6423d19005..4bfbdf59c78 100644 --- a/tests/gateway/test_config_driven_access_policy.py +++ b/tests/gateway/test_config_driven_access_policy.py @@ -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): diff --git a/tests/gateway/test_dingtalk.py b/tests/gateway/test_dingtalk.py index d73b687d7ac..8e4cd822327 100644 --- a/tests/gateway/test_dingtalk.py +++ b/tests/gateway/test_dingtalk.py @@ -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 diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 3f6b0942803..d994cb257de 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -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): diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 8cfaa22c5d3..8613298ceb7 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -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" ) 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 = "

Hello world

" result = _strip_html(html) self.assertIn("Hello", result) @@ -133,14 +133,14 @@ class TestHelperFunctions(unittest.TestCase): self.assertNotIn("", 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
Line 2
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 & b < c > 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("

Hello from HTML

", "html", "utf-8") result = _extract_text_body(msg) self.assertIn("Hello from HTML", result) self.assertNotIn("

", 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("

HTML version

", "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("

Only HTML

", "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() diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 4d78b454b0c..bb97c7e72be 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -81,7 +81,7 @@ class TestConfigEnvOverrides(unittest.TestCase): class TestFeishuMessageNormalization(unittest.TestCase): def test_normalize_merge_forward_preserves_summary_lines(self): - from gateway.platforms.feishu import normalize_feishu_message + from plugins.platforms.feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="merge_forward", @@ -111,7 +111,7 @@ class TestFeishuMessageNormalization(unittest.TestCase): ) def test_normalize_share_chat_exposes_summary_and_metadata(self): - from gateway.platforms.feishu import normalize_feishu_message + from plugins.platforms.feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="share_chat", @@ -129,7 +129,7 @@ class TestFeishuMessageNormalization(unittest.TestCase): self.assertEqual(normalized.metadata["chat_name"], "Backend Guild") def test_normalize_interactive_card_preserves_title_body_and_actions(self): - from gateway.platforms.feishu import normalize_feishu_message + from plugins.platforms.feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="interactive", @@ -172,7 +172,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): }, clear=True) def test_connect_webhook_mode_starts_local_server(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) runner = AsyncMock() @@ -184,14 +184,14 @@ class TestFeishuAdapterMessaging(unittest.TestCase): ) with ( - patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), - patch("gateway.platforms.feishu.FEISHU_WEBHOOK_AVAILABLE", True), - patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, - patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)), - patch("gateway.platforms.feishu.release_scoped_lock"), + patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True), + patch("plugins.platforms.feishu.adapter.FEISHU_WEBHOOK_AVAILABLE", True), + patch("plugins.platforms.feishu.adapter.EventDispatcherHandler") as mock_handler_class, + patch("plugins.platforms.feishu.adapter.acquire_scoped_lock", return_value=(True, None)), + patch("plugins.platforms.feishu.adapter.release_scoped_lock"), patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), - patch("gateway.platforms.feishu.web", web_module), + patch("plugins.platforms.feishu.adapter.web", web_module), ): _mock_event_dispatcher_builder(mock_handler_class) connected = asyncio.run(adapter.connect()) @@ -206,20 +206,20 @@ class TestFeishuAdapterMessaging(unittest.TestCase): }, clear=True) def test_connect_acquires_scoped_lock_and_disconnect_releases_it(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) ws_client = SimpleNamespace() with ( - patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), - patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True), - patch("gateway.platforms.feishu.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), - patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, - patch("gateway.platforms.feishu.FeishuWSClient", return_value=ws_client), - patch("gateway.platforms.feishu._run_official_feishu_ws_client"), - patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)) as acquire_lock, - patch("gateway.platforms.feishu.release_scoped_lock") as release_lock, + patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True), + patch("plugins.platforms.feishu.adapter.FEISHU_WEBSOCKET_AVAILABLE", True), + patch("plugins.platforms.feishu.adapter.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), + patch("plugins.platforms.feishu.adapter.EventDispatcherHandler") as mock_handler_class, + patch("plugins.platforms.feishu.adapter.FeishuWSClient", return_value=ws_client), + patch("plugins.platforms.feishu.adapter._run_official_feishu_ws_client"), + patch("plugins.platforms.feishu.adapter.acquire_scoped_lock", return_value=(True, None)) as acquire_lock, + patch("plugins.platforms.feishu.adapter.release_scoped_lock") as release_lock, patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), ): @@ -237,7 +237,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): return False try: - with patch("gateway.platforms.feishu.asyncio.get_running_loop", return_value=_Loop()): + with patch("plugins.platforms.feishu.adapter.asyncio.get_running_loop", return_value=_Loop()): connected = asyncio.run(adapter.connect()) asyncio.run(adapter.disconnect()) finally: @@ -258,15 +258,15 @@ class TestFeishuAdapterMessaging(unittest.TestCase): }, clear=True) def test_connect_rejects_existing_app_lock(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) with ( - patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), - patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True), + patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True), + patch("plugins.platforms.feishu.adapter.FEISHU_WEBSOCKET_AVAILABLE", True), patch( - "gateway.platforms.feishu.acquire_scoped_lock", + "plugins.platforms.feishu.adapter.acquire_scoped_lock", return_value=(False, {"pid": 4321}), ), ): @@ -283,22 +283,22 @@ class TestFeishuAdapterMessaging(unittest.TestCase): }, clear=True) def test_connect_retries_transient_startup_failure(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) ws_client = SimpleNamespace() sleeps = [] with ( - patch("gateway.platforms.feishu.FEISHU_AVAILABLE", True), - patch("gateway.platforms.feishu.FEISHU_WEBSOCKET_AVAILABLE", True), - patch("gateway.platforms.feishu.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), - patch("gateway.platforms.feishu.EventDispatcherHandler") as mock_handler_class, - patch("gateway.platforms.feishu.FeishuWSClient", return_value=ws_client), - patch("gateway.platforms.feishu.acquire_scoped_lock", return_value=(True, None)), - patch("gateway.platforms.feishu.release_scoped_lock"), + patch("plugins.platforms.feishu.adapter.FEISHU_AVAILABLE", True), + patch("plugins.platforms.feishu.adapter.FEISHU_WEBSOCKET_AVAILABLE", True), + patch("plugins.platforms.feishu.adapter.lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO="INFO", WARNING="WARNING"))), + patch("plugins.platforms.feishu.adapter.EventDispatcherHandler") as mock_handler_class, + patch("plugins.platforms.feishu.adapter.FeishuWSClient", return_value=ws_client), + patch("plugins.platforms.feishu.adapter.acquire_scoped_lock", return_value=(True, None)), + patch("plugins.platforms.feishu.adapter.release_scoped_lock"), patch.object(adapter, "_hydrate_bot_identity", new=AsyncMock()), - patch("gateway.platforms.feishu.asyncio.sleep", side_effect=lambda delay: sleeps.append(delay)), + patch("plugins.platforms.feishu.adapter.asyncio.sleep", side_effect=lambda delay: sleeps.append(delay)), patch.object(adapter, "_build_lark_client", return_value=SimpleNamespace()), ): _mock_event_dispatcher_builder(mock_handler_class) @@ -322,7 +322,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): fake_loop = _Loop() try: - with patch("gateway.platforms.feishu.asyncio.get_running_loop", return_value=fake_loop): + with patch("plugins.platforms.feishu.adapter.asyncio.get_running_loop", return_value=fake_loop): connected = asyncio.run(adapter.connect()) finally: loop.close() @@ -334,7 +334,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_edit_message_updates_existing_feishu_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -355,7 +355,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.edit_message( chat_id="oc_chat", @@ -376,7 +376,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_edit_message_falls_back_to_text_when_post_update_is_rejected(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"calls": []} @@ -399,7 +399,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.edit_message( chat_id="oc_chat", @@ -419,7 +419,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_get_chat_info_uses_real_feishu_chat_api(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -443,7 +443,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): info = asyncio.run(adapter.get_chat_info("oc_chat")) self.assertEqual(chat_api.request.chat_id, "oc_chat") @@ -453,7 +453,7 @@ class TestFeishuAdapterMessaging(unittest.TestCase): class TestAdapterModule(unittest.TestCase): def test_load_settings_uses_sdk_defaults_for_invalid_ws_reconnect_values(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter settings = FeishuAdapter._load_settings( { @@ -466,7 +466,7 @@ class TestAdapterModule(unittest.TestCase): self.assertEqual(settings.ws_reconnect_interval, 120) def test_load_settings_accepts_custom_ws_reconnect_values(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter settings = FeishuAdapter._load_settings( { @@ -479,7 +479,7 @@ class TestAdapterModule(unittest.TestCase): self.assertEqual(settings.ws_reconnect_interval, 3) def test_load_settings_accepts_custom_ws_ping_values(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter settings = FeishuAdapter._load_settings( { @@ -492,7 +492,7 @@ class TestAdapterModule(unittest.TestCase): self.assertEqual(settings.ws_ping_timeout, 8) def test_load_settings_ignores_invalid_ws_ping_values(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter settings = FeishuAdapter._load_settings( { @@ -547,7 +547,7 @@ class TestAdapterModule(unittest.TestCase): sys.modules["lark_oapi.ws"] = fake_ws_module sys.modules["lark_oapi.ws.client"] = fake_client_module try: - from gateway.platforms.feishu import _run_official_feishu_ws_client + from plugins.platforms.feishu.adapter import _run_official_feishu_ws_client _run_official_feishu_ws_client(fake_client, fake_adapter) finally: @@ -574,7 +574,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_event_handler_registers_reaction_and_card_processors(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) calls = [] @@ -630,7 +630,7 @@ class TestAdapterBehavior(unittest.TestCase): calls.append("builder") return _Builder() - with patch("gateway.platforms.feishu.EventDispatcherHandler", _Dispatcher): + with patch("plugins.platforms.feishu.adapter.EventDispatcherHandler", _Dispatcher): handler = adapter._build_event_handler() self.assertEqual(handler, "handler") @@ -656,7 +656,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_bot_origin_reactions_are_dropped_to_avoid_feedback_loops(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = object() @@ -669,7 +669,7 @@ class TestAdapterBehavior(unittest.TestCase): ) data = SimpleNamespace(event=event) with patch( - "gateway.platforms.feishu.asyncio.run_coroutine_threadsafe" + "plugins.platforms.feishu.adapter.asyncio.run_coroutine_threadsafe" ) as run_threadsafe: adapter._on_reaction_event("im.message.reaction.created_v1", data) run_threadsafe.assert_not_called() @@ -680,7 +680,7 @@ class TestAdapterBehavior(unittest.TestCase): # not additionally swallow user-origin reactions just because their # emoji happens to collide with a lifecycle emoji. from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = SimpleNamespace(is_closed=lambda: False) @@ -697,7 +697,7 @@ class TestAdapterBehavior(unittest.TestCase): return SimpleNamespace(add_done_callback=lambda _: None) with patch( - "gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", + "plugins.platforms.feishu.adapter.asyncio.run_coroutine_threadsafe", side_effect=_close_coro_and_return_future, ) as run_threadsafe: adapter._on_reaction_event("im.message.reaction.created_v1", data) @@ -706,7 +706,7 @@ class TestAdapterBehavior(unittest.TestCase): def _build_reaction_adapter(self, *, msg_sender_id: str): """Build a FeishuAdapter wired up to return a single GET-message result.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._app_id = "cli_self_app" @@ -767,7 +767,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_requires_mentions_even_when_policy_open(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace(mentions=[]) @@ -780,7 +780,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unknown(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) sender_id = SimpleNamespace(open_id="ou_any", user_id=None) @@ -804,7 +804,7 @@ class TestAdapterBehavior(unittest.TestCase): ) def test_group_message_allowlist_and_mention_both_required(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) # Mention without IDs — name fallback legitimately engages. @@ -834,7 +834,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_per_group_allowlist_policy_gates_by_sender(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -870,7 +870,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_per_group_blacklist_policy_blocks_specific_users(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -906,7 +906,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_per_group_admin_only_policy_requires_admin(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -942,7 +942,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_per_group_disabled_policy_blocks_all(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -978,7 +978,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_global_admins_bypass_all_group_rules(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -1008,7 +1008,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_default_group_policy_fallback_for_chats_without_explicit_rule(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter config = PlatformConfig( extra={ @@ -1033,7 +1033,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_group_message_matches_bot_open_id_when_configured(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._bot_open_id = "ou_bot" @@ -1061,7 +1061,7 @@ class TestAdapterBehavior(unittest.TestCase): the mention and the bot carry open_ids, IDs are authoritative — a same-name human with a different open_id must NOT admit.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter # Case 1: bot has only a name (open_id not hydrated / not configured). # Name fallback is the only available signal for any mention. @@ -1115,7 +1115,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_as_text(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1134,7 +1134,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_uses_first_available_language_block(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1153,7 +1153,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_with_rich_elements_does_not_drop_content(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1179,7 +1179,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_post_message_downloads_embedded_resources(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_image = AsyncMock(return_value=("/tmp/feishu-image.png", "image/png")) @@ -1215,7 +1215,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_merge_forward_message_as_text_summary(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1245,7 +1245,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_share_chat_message_as_text_summary(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1264,7 +1264,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_interactive_message_as_text_summary(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -1298,7 +1298,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_image_message_downloads_and_caches(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_image = AsyncMock(return_value=("/tmp/feishu-image.png", "image/png")) @@ -1322,7 +1322,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_audio_message_downloads_and_caches(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_message_resource = AsyncMock( @@ -1344,7 +1344,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_file_message_downloads_and_caches(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_message_resource = AsyncMock( @@ -1366,7 +1366,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_media_message_with_image_mime_becomes_photo(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_message_resource = AsyncMock( @@ -1388,7 +1388,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_media_message_with_video_mime_becomes_video(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._download_feishu_message_resource = AsyncMock( @@ -1410,7 +1410,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_text_from_raw_content_uses_relation_message_fallbacks(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -1429,7 +1429,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_text_message_starting_with_slash_becomes_command(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._dispatch_inbound_event = AsyncMock() @@ -1467,7 +1467,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_extract_text_file_injects_content(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as tmp: @@ -1485,7 +1485,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_message_event_submits_to_adapter_loop(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -1512,7 +1512,7 @@ class TestAdapterBehavior(unittest.TestCase): coro.close() return future - with patch("gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", side_effect=_submit) as submit: + with patch("plugins.platforms.feishu.adapter.asyncio.run_coroutine_threadsafe", side_effect=_submit) as submit: adapter._on_message_event(data) self.assertTrue(submit.called) @@ -1520,7 +1520,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_request_uses_same_message_dispatch_path(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._on_message_event = Mock() @@ -1550,7 +1550,7 @@ class TestAdapterBehavior(unittest.TestCase): sending an attacker-controlled challenge string. """ from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) body = json.dumps({ @@ -1573,7 +1573,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_process_inbound_message_uses_event_sender_identity_only(self): from gateway.config import PlatformConfig from gateway.platforms.base import MessageType - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._dispatch_inbound_event = AsyncMock() @@ -1619,7 +1619,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_text_batch_merges_rapid_messages_into_single_event(self): from gateway.config import PlatformConfig from gateway.platforms.base import MessageEvent, MessageType - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter from gateway.session import SessionSource adapter = FeishuAdapter(PlatformConfig()) @@ -1637,7 +1637,7 @@ class TestAdapterBehavior(unittest.TestCase): return None async def _run() -> None: - with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep): + with patch("plugins.platforms.feishu.adapter.asyncio.sleep", side_effect=_sleep): await adapter._dispatch_inbound_event( MessageEvent(text="A", message_type=MessageType.TEXT, source=source, message_id="om_1") ) @@ -1665,7 +1665,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_text_batch_flushes_when_message_count_limit_is_hit(self): from gateway.config import PlatformConfig from gateway.platforms.base import MessageEvent, MessageType - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter from gateway.session import SessionSource adapter = FeishuAdapter(PlatformConfig()) @@ -1683,7 +1683,7 @@ class TestAdapterBehavior(unittest.TestCase): return None async def _run() -> None: - with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep): + with patch("plugins.platforms.feishu.adapter.asyncio.sleep", side_effect=_sleep): await adapter._dispatch_inbound_event( MessageEvent(text="A", message_type=MessageType.TEXT, source=source, message_id="om_1") ) @@ -1709,7 +1709,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_media_batch_merges_rapid_photo_messages(self): from gateway.config import PlatformConfig from gateway.platforms.base import MessageEvent, MessageType - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter from gateway.session import SessionSource adapter = FeishuAdapter(PlatformConfig()) @@ -1727,7 +1727,7 @@ class TestAdapterBehavior(unittest.TestCase): return None async def _run() -> None: - with patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep): + with patch("plugins.platforms.feishu.adapter.asyncio.sleep", side_effect=_sleep): await adapter._dispatch_inbound_event( MessageEvent( text="第一张", @@ -1763,13 +1763,13 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_image_downloads_then_uses_native_image_send(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter.send_image_file = AsyncMock(return_value=SimpleNamespace(success=True, message_id="om_img")) async def _run(): - with patch("gateway.platforms.feishu.cache_image_from_url", new=AsyncMock(return_value="/tmp/cached.png")): + with patch("plugins.platforms.feishu.adapter.cache_image_from_url", new=AsyncMock(return_value="/tmp/cached.png")): return await adapter.send_image("oc_chat", "https://example.com/cat.png", caption="cat") result = asyncio.run(_run()) @@ -1781,7 +1781,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_animation_degrades_to_document_send(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter.send_document = AsyncMock(return_value=SimpleNamespace(success=True, message_id="om_gif")) @@ -1809,7 +1809,7 @@ class TestAdapterBehavior(unittest.TestCase): eagerly buffers it; a future refactor to .stream() would silently read-after-close.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter events: list[str] = [] @@ -1847,7 +1847,7 @@ class TestAdapterBehavior(unittest.TestCase): with patch("tools.url_safety.is_safe_url", return_value=True): with patch("httpx.AsyncClient", _FakeAsyncClient): with patch( - "gateway.platforms.feishu.cache_document_from_bytes", + "plugins.platforms.feishu.adapter.cache_document_from_bytes", return_value="/tmp/cached-doc.bin", ): return await adapter._download_remote_document( @@ -1867,7 +1867,7 @@ class TestAdapterBehavior(unittest.TestCase): def test_dedup_state_persists_across_adapter_restart(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter with tempfile.TemporaryDirectory() as temp_home: with patch.dict(os.environ, {"HERMES_HOME": temp_home}, clear=False): @@ -1879,7 +1879,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_process_inbound_group_message_keeps_group_type_when_chat_lookup_falls_back(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._dispatch_inbound_event = AsyncMock() @@ -1916,7 +1916,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_process_inbound_message_fetches_reply_to_text(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._dispatch_inbound_event = AsyncMock() @@ -1955,7 +1955,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_replies_in_thread_when_thread_metadata_present(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -1979,7 +1979,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -1996,7 +1996,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_uses_metadata_reply_target_for_threaded_feishu_topic(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2016,7 +2016,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2035,7 +2035,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_retries_transient_failure(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"attempts": 0} @@ -2067,8 +2067,8 @@ class TestAdapterBehavior(unittest.TestCase): sleeps.append(delay) with ( - patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct), - patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep), + patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct), + patch("plugins.platforms.feishu.adapter.asyncio.sleep", side_effect=_sleep), ): result = asyncio.run(adapter.send(chat_id="oc_chat", content="hello retry")) @@ -2080,7 +2080,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_does_not_retry_deterministic_api_failure(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"attempts": 0} @@ -2110,8 +2110,8 @@ class TestAdapterBehavior(unittest.TestCase): sleeps.append(delay) with ( - patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct), - patch("gateway.platforms.feishu.asyncio.sleep", side_effect=_sleep), + patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct), + patch("plugins.platforms.feishu.adapter.asyncio.sleep", side_effect=_sleep), ): result = asyncio.run(adapter.send(chat_id="oc_chat", content="bad payload")) @@ -2123,7 +2123,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_document_reply_uses_thread_flag(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2160,7 +2160,7 @@ class TestAdapterBehavior(unittest.TestCase): file_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send_document( chat_id="oc_chat", @@ -2178,7 +2178,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_document_uploads_file_and_sends_file_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2216,7 +2216,7 @@ class TestAdapterBehavior(unittest.TestCase): file_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter.send_document(chat_id="oc_chat", file_path=file_path)) finally: os.unlink(file_path) @@ -2232,7 +2232,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_document_with_caption_uses_single_post_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2269,7 +2269,7 @@ class TestAdapterBehavior(unittest.TestCase): file_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send_document(chat_id="oc_chat", file_path=file_path, caption="报告请看") ) @@ -2285,7 +2285,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_image_file_uploads_image_and_sends_image_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2323,7 +2323,7 @@ class TestAdapterBehavior(unittest.TestCase): image_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter.send_image_file(chat_id="oc_chat", image_path=image_path)) finally: os.unlink(image_path) @@ -2339,7 +2339,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_image_file_with_caption_uses_single_post_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2376,7 +2376,7 @@ class TestAdapterBehavior(unittest.TestCase): image_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send_image_file(chat_id="oc_chat", image_path=image_path, caption="截图说明") ) @@ -2392,7 +2392,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_video_uploads_file_and_sends_media_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2430,7 +2430,7 @@ class TestAdapterBehavior(unittest.TestCase): video_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter.send_video(chat_id="oc_chat", video_path=video_path)) finally: os.unlink(video_path) @@ -2443,7 +2443,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_voice_uploads_opus_and_sends_audio_message(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2481,7 +2481,7 @@ class TestAdapterBehavior(unittest.TestCase): audio_path = tmp.name try: - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter.send_voice(chat_id="oc_chat", audio_path=audio_path)) finally: os.unlink(audio_path) @@ -2494,7 +2494,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_extracts_title_and_links(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads(adapter._build_post_payload("# 标题\n访问 [文档](https://example.com)")) @@ -2505,7 +2505,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_wraps_markdown_in_md_tag(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2523,7 +2523,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_keeps_full_markdown_text(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2541,7 +2541,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_uses_post_for_inline_markdown(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2565,7 +2565,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2582,7 +2582,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_splits_fenced_code_blocks_into_separate_post_rows(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2616,7 +2616,7 @@ class TestAdapterBehavior(unittest.TestCase): "后续说明仍应保留。" ) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2645,7 +2645,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_keeps_fence_like_code_lines_inside_code_block(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2666,7 +2666,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_preserves_trailing_spaces_in_code_block(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2687,7 +2687,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_build_post_payload_splits_multiple_fenced_code_blocks(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) payload = json.loads( @@ -2710,7 +2710,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_falls_back_to_text_when_post_payload_is_rejected(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"calls": []} @@ -2736,7 +2736,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2755,7 +2755,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_falls_back_to_text_when_post_response_is_unsuccessful(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {"calls": []} @@ -2781,7 +2781,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2800,7 +2800,7 @@ class TestAdapterBehavior(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_send_uses_post_for_advanced_markdown_lines(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) captured = {} @@ -2824,7 +2824,7 @@ class TestAdapterBehavior(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run( adapter.send( chat_id="oc_chat", @@ -2854,7 +2854,7 @@ class TestHydrateBotIdentity(unittest.TestCase): def _make_adapter(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter return FeishuAdapter(PlatformConfig()) @@ -2978,12 +2978,12 @@ class TestPendingInboundQueue(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_event_queued_when_loop_not_ready(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = None # Simulate "before start()" or "during reconnect" - with patch("gateway.platforms.feishu.threading.Thread") as thread_cls: + with patch("plugins.platforms.feishu.adapter.threading.Thread") as thread_cls: adapter._on_message_event(SimpleNamespace(tag="evt-1")) adapter._on_message_event(SimpleNamespace(tag="evt-2")) adapter._on_message_event(SimpleNamespace(tag="evt-3")) @@ -2998,7 +2998,7 @@ class TestPendingInboundQueue(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_drainer_replays_queued_events_when_loop_becomes_ready(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = None @@ -3010,7 +3010,7 @@ class TestPendingInboundQueue(unittest.TestCase): # Queue three events while loop is None (simulate the race). events = [SimpleNamespace(tag=f"evt-{i}") for i in range(3)] - with patch("gateway.platforms.feishu.threading.Thread"): + with patch("plugins.platforms.feishu.adapter.threading.Thread"): for ev in events: adapter._on_message_event(ev) @@ -3029,7 +3029,7 @@ class TestPendingInboundQueue(unittest.TestCase): return future with patch( - "gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", + "plugins.platforms.feishu.adapter.asyncio.run_coroutine_threadsafe", side_effect=_submit, ) as submit: adapter._drain_pending_inbound_events() @@ -3044,13 +3044,13 @@ class TestPendingInboundQueue(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_drainer_drops_queue_when_adapter_shuts_down(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = None adapter._running = False # Shutdown state - with patch("gateway.platforms.feishu.threading.Thread"): + with patch("plugins.platforms.feishu.adapter.threading.Thread"): adapter._on_message_event(SimpleNamespace(tag="evt-lost")) self.assertEqual(len(adapter._pending_inbound_events), 1) @@ -3064,13 +3064,13 @@ class TestPendingInboundQueue(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_queue_cap_evicts_oldest_beyond_max_depth(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._loop = None adapter._pending_inbound_max_depth = 3 # Shrink for test - with patch("gateway.platforms.feishu.threading.Thread"): + with patch("plugins.platforms.feishu.adapter.threading.Thread"): for i in range(5): adapter._on_message_event(SimpleNamespace(tag=f"evt-{i}")) @@ -3084,7 +3084,7 @@ class TestPendingInboundQueue(unittest.TestCase): """When the loop is ready, events should dispatch directly without ever touching the pending queue.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -3101,10 +3101,10 @@ class TestPendingInboundQueue(unittest.TestCase): return future with patch( - "gateway.platforms.feishu.asyncio.run_coroutine_threadsafe", + "plugins.platforms.feishu.adapter.asyncio.run_coroutine_threadsafe", side_effect=_submit, ) as submit, patch( - "gateway.platforms.feishu.threading.Thread" + "plugins.platforms.feishu.adapter.threading.Thread" ) as thread_cls: adapter._on_message_event(SimpleNamespace(tag="evt")) @@ -3121,7 +3121,7 @@ class TestWebhookSecurity(unittest.TestCase): def _make_adapter(self, encrypt_key: str = "") -> "FeishuAdapter": from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter with patch.dict(os.environ, {"FEISHU_APP_ID": "cli", "FEISHU_APP_SECRET": "sec", "FEISHU_ENCRYPT_KEY": encrypt_key}, clear=True): return FeishuAdapter(PlatformConfig()) @@ -3158,14 +3158,14 @@ class TestWebhookSecurity(unittest.TestCase): self.assertTrue(adapter._check_webhook_rate_limit("10.0.0.1")) def test_rate_limit_blocks_after_exceeding_max(self): - from gateway.platforms.feishu import _FEISHU_WEBHOOK_RATE_LIMIT_MAX + from plugins.platforms.feishu.adapter import _FEISHU_WEBHOOK_RATE_LIMIT_MAX adapter = self._make_adapter() for _ in range(_FEISHU_WEBHOOK_RATE_LIMIT_MAX): adapter._check_webhook_rate_limit("10.0.0.2") self.assertFalse(adapter._check_webhook_rate_limit("10.0.0.2")) def test_rate_limit_resets_after_window_expires(self): - from gateway.platforms.feishu import _FEISHU_WEBHOOK_RATE_LIMIT_MAX, _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS + from plugins.platforms.feishu.adapter import _FEISHU_WEBHOOK_RATE_LIMIT_MAX, _FEISHU_WEBHOOK_RATE_WINDOW_SECONDS adapter = self._make_adapter() ip = "10.0.0.3" for _ in range(_FEISHU_WEBHOOK_RATE_LIMIT_MAX): @@ -3179,7 +3179,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_request_rejects_oversized_body(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES + from plugins.platforms.feishu.adapter import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES adapter = FeishuAdapter(PlatformConfig()) # Simulate a request whose Content-Length already signals oversize. @@ -3193,7 +3193,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_request_rejects_invalid_json(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) request = SimpleNamespace( @@ -3207,7 +3207,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_ENCRYPT_KEY": "secret"}, clear=True) def test_webhook_request_rejects_bad_signature(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) body = json.dumps({"header": {"event_type": "im.message.receive_v1"}}).encode() @@ -3223,7 +3223,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_connect_requires_inbound_auth_secret(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter( PlatformConfig( @@ -3236,7 +3236,7 @@ class TestWebhookSecurity(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_webhook_loads_auth_secrets_from_platform_extra(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter( PlatformConfig( @@ -3257,7 +3257,7 @@ class TestWebhookSecurity(unittest.TestCase): def test_webhook_url_verification_challenge_passes_without_signature(self): """Challenge requests must succeed even when no encrypt_key is set.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) body = json.dumps({"type": "url_verification", "challenge": "test_challenge_token"}).encode() @@ -3277,7 +3277,7 @@ class TestDedupTTL(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_duplicate_within_ttl_is_rejected(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) with patch.object(adapter, "_persist_seen_message_ids"): @@ -3288,7 +3288,7 @@ class TestDedupTTL(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_expired_entry_is_not_considered_duplicate(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter, _FEISHU_DEDUP_TTL_SECONDS + from plugins.platforms.feishu.adapter import FeishuAdapter, _FEISHU_DEDUP_TTL_SECONDS adapter = FeishuAdapter(PlatformConfig()) # Plant an entry that expired well past the TTL. @@ -3306,7 +3306,7 @@ class TestDedupTTL(unittest.TestCase): """ import tempfile from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter with tempfile.TemporaryDirectory() as temp_home: with patch.dict(os.environ, {"HERMES_HOME": temp_home}, clear=True): @@ -3332,7 +3332,7 @@ class TestDedupTTL(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_persist_saves_timestamps_as_dict(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) ts = time.time() @@ -3348,7 +3348,7 @@ class TestDedupTTL(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_load_backward_compat_list_format(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) with tempfile.TemporaryDirectory() as tmpdir: @@ -3366,7 +3366,7 @@ class TestGroupMentionAtAll(unittest.TestCase): @patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True) def test_at_all_in_content_accepts_without_explicit_bot_mention(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace( @@ -3380,7 +3380,7 @@ class TestGroupMentionAtAll(unittest.TestCase): def test_at_all_still_requires_policy_gate(self): """@_all bypasses mention gating but NOT the allowlist policy.""" from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) message = SimpleNamespace(content='{"text":"@_all attention"}', mentions=[]) @@ -3399,7 +3399,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_returns_none_when_client_is_none(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._client = None @@ -3409,7 +3409,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_returns_cached_name_within_ttl(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._client = SimpleNamespace() @@ -3421,7 +3421,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_fetches_and_caches_name_from_api(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) user_obj = SimpleNamespace(name="Bob", display_name=None, nickname=None, en_name=None) @@ -3441,7 +3441,7 @@ class TestSenderNameResolution(unittest.TestCase): contact=SimpleNamespace(v3=SimpleNamespace(user=_ContactAPI())) ) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_bob")) self.assertEqual(result, "Bob") @@ -3450,7 +3450,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_expired_cache_triggers_new_api_call(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) # Expired cache entry. @@ -3469,7 +3469,7 @@ class TestSenderNameResolution(unittest.TestCase): contact=SimpleNamespace(v3=SimpleNamespace(user=_ContactAPI())) ) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_expired")) self.assertEqual(result, "NewName") @@ -3477,7 +3477,7 @@ class TestSenderNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_api_failure_returns_none_without_raising(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -3492,7 +3492,7 @@ class TestSenderNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_broken")) self.assertIsNone(result) @@ -3513,7 +3513,7 @@ class TestBotNameResolution(unittest.TestCase): def _build_adapter_with_bots(self, bots: Dict[str, str]): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) calls = [] @@ -3528,7 +3528,7 @@ class TestBotNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_returns_cached_bot_name_without_api_call(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) adapter._sender_name_cache["ou_peer"] = ("Peer Bot", time.time() + 600) @@ -3545,7 +3545,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True)) self.assertEqual(result, "Peer Bot") @@ -3558,7 +3558,7 @@ class TestBotNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_api_failure_returns_none_and_does_not_poison_cache(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) @@ -3570,7 +3570,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True)) self.assertIsNone(result) @@ -3585,7 +3585,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_ghost", is_bot=True)) self.assertIsNone(result) @@ -3599,7 +3599,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): first = asyncio.run(adapter._resolve_sender_name_from_api("ou_nameless", is_bot=True)) second = asyncio.run(adapter._resolve_sender_name_from_api("ou_nameless", is_bot=True)) @@ -3611,7 +3611,7 @@ class TestBotNameResolution(unittest.TestCase): @patch.dict(os.environ, {}, clear=True) def test_non_zero_code_returns_none(self): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) error_payload = b'{"code":99991663,"msg":"permission denied"}' @@ -3622,7 +3622,7 @@ class TestBotNameResolution(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct): + with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct): result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True)) self.assertIsNone(result) @@ -3645,7 +3645,7 @@ class TestProcessingReactions(unittest.TestCase): next_reaction_id: str = "r1", ): from gateway.config import PlatformConfig - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) tracker = SimpleNamespace( @@ -3694,7 +3694,7 @@ class TestProcessingReactions(unittest.TestCase): async def _direct(func, *args, **kwargs): return func(*args, **kwargs) - return patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct) + return patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct) # ------------------------------------------------------------------ start @patch.dict(os.environ, {}, clear=True) @@ -3828,7 +3828,7 @@ class TestProcessingReactions(unittest.TestCase): # ------------------------------------------------------------- LRU bounds @patch.dict(os.environ, {}, clear=True) def test_cache_evicts_oldest_entry_beyond_size_limit(self): - from gateway.platforms.feishu import _FEISHU_PROCESSING_REACTION_CACHE_SIZE + from plugins.platforms.feishu.adapter import _FEISHU_PROCESSING_REACTION_CACHE_SIZE adapter, _ = self._build_adapter() counter = {"n": 0} @@ -3859,7 +3859,7 @@ class TestProcessingReactions(unittest.TestCase): class TestFeishuMentionMap(unittest.TestCase): def test_build_mentions_map_handles_at_all(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity, FeishuMentionRef + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity, FeishuMentionRef mention = SimpleNamespace(key="@_all", id=None, name="") result = _build_mentions_map( @@ -3869,7 +3869,7 @@ class TestFeishuMentionMap(unittest.TestCase): self.assertEqual(result["@_all"], FeishuMentionRef(is_all=True)) def test_build_mentions_map_marks_self_by_open_id(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -3882,7 +3882,7 @@ class TestFeishuMentionMap(unittest.TestCase): self.assertEqual(ref.name, "Hermes") def test_build_mentions_map_marks_self_by_name_fallback(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -3897,7 +3897,7 @@ class TestFeishuMentionMap(unittest.TestCase): NOT be flagged as self when their open_id differs. Before the fix, name-match fired even when open_id was present and different, causing their messages to be silently stripped/dropped.""" - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity human_with_same_name = SimpleNamespace( key="@_user_1", @@ -3915,7 +3915,7 @@ class TestFeishuMentionMap(unittest.TestCase): not have populated _bot_open_id yet. During that window, a mention carrying a real open_id should still match via name — otherwise @bot messages silently fail admission.""" - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity bot_mention = SimpleNamespace( key="@_user_1", @@ -3930,7 +3930,7 @@ class TestFeishuMentionMap(unittest.TestCase): self.assertTrue(result["@_user_1"].is_self) def test_build_mentions_map_non_self_user(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -3943,12 +3943,12 @@ class TestFeishuMentionMap(unittest.TestCase): self.assertEqual(ref.name, "Alice") def test_build_mentions_map_returns_empty_for_none_input(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity self.assertEqual(_build_mentions_map(None, _FeishuBotIdentity(open_id="ou_bot")), {}) def test_build_mentions_map_tolerates_missing_id_object(self): - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity mention = SimpleNamespace(key="@_user_9", id=None, name="") ref = _build_mentions_map([mention], _FeishuBotIdentity(open_id="ou_bot"))["@_user_9"] @@ -3958,7 +3958,7 @@ class TestFeishuMentionMap(unittest.TestCase): class TestFeishuMentionHint(unittest.TestCase): def test_hint_single_user(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(name="Alice", open_id="ou_alice")] self.assertEqual( @@ -3967,7 +3967,7 @@ class TestFeishuMentionHint(unittest.TestCase): ) def test_hint_multiple_users(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [ FeishuMentionRef(name="Alice", open_id="ou_alice"), @@ -3979,13 +3979,13 @@ class TestFeishuMentionHint(unittest.TestCase): ) def test_hint_at_all(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(is_all=True)] self.assertEqual(_build_mention_hint(refs), "[Mentioned: @all]") def test_hint_filters_self_mentions(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [ FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True), @@ -3997,30 +3997,30 @@ class TestFeishuMentionHint(unittest.TestCase): ) def test_hint_returns_empty_when_only_self(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True)] self.assertEqual(_build_mention_hint(refs), "") def test_hint_returns_empty_for_no_refs(self): - from gateway.platforms.feishu import _build_mention_hint + from plugins.platforms.feishu.adapter import _build_mention_hint self.assertEqual(_build_mention_hint([]), "") def test_hint_falls_back_when_open_id_missing(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(name="Alice", open_id="")] self.assertEqual(_build_mention_hint(refs), "[Mentioned: Alice]") def test_hint_uses_unknown_placeholder_when_name_missing(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(name="", open_id="ou_xxx")] self.assertEqual(_build_mention_hint(refs), "[Mentioned: unknown (open_id=ou_xxx)]") def test_hint_dedupes_repeated_user(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [ FeishuMentionRef(name="Alice", open_id="ou_alice"), @@ -4033,7 +4033,7 @@ class TestFeishuMentionHint(unittest.TestCase): ) def test_hint_dedupes_repeated_at_all(self): - from gateway.platforms.feishu import FeishuMentionRef, _build_mention_hint + from plugins.platforms.feishu.adapter import FeishuMentionRef, _build_mention_hint refs = [FeishuMentionRef(is_all=True), FeishuMentionRef(is_all=True)] self.assertEqual(_build_mention_hint(refs), "[Mentioned: @all]") @@ -4041,7 +4041,7 @@ class TestFeishuMentionHint(unittest.TestCase): class TestFeishuStripLeadingSelf(unittest.TestCase): def _make_refs(self, *, self_name="Hermes", other_name=None): - from gateway.platforms.feishu import FeishuMentionRef + from plugins.platforms.feishu.adapter import FeishuMentionRef refs = [FeishuMentionRef(name=self_name, open_id="ou_bot", is_self=True)] if other_name: @@ -4049,19 +4049,19 @@ class TestFeishuStripLeadingSelf(unittest.TestCase): return refs def test_strips_leading_self(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions("@Hermes /help", self._make_refs()) self.assertEqual(result, "/help") def test_strips_consecutive_leading_self(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions("@Hermes @Hermes hi", self._make_refs()) self.assertEqual(result, "hi") def test_stops_at_first_non_self_token(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions( "@Hermes @Alice make a group", self._make_refs(other_name="Alice") @@ -4069,26 +4069,26 @@ class TestFeishuStripLeadingSelf(unittest.TestCase): self.assertEqual(result, "@Alice make a group") def test_preserves_mid_text_self(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions("check @Hermes said yesterday", self._make_refs()) self.assertEqual(result, "check @Hermes said yesterday") def test_strips_trailing_self_at_end_of_text(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions result = _strip_edge_self_mentions("look up docs @Hermes", self._make_refs()) self.assertEqual(result, "look up docs") def test_strips_trailing_self_with_terminal_punct(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions # Terminal punct after the mention — strip the mention, keep the punct. result = _strip_edge_self_mentions("look up docs @Hermes.", self._make_refs()) self.assertEqual(result, "look up docs.") def test_preserves_trailing_self_before_non_terminal_char(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions # Non-terminal char (here a Chinese particle) follows — preserve. result = _strip_edge_self_mentions( @@ -4097,25 +4097,25 @@ class TestFeishuStripLeadingSelf(unittest.TestCase): self.assertEqual(result, "please don't @Hermes anymore") def test_returns_input_when_refs_empty(self): - from gateway.platforms.feishu import _strip_edge_self_mentions + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions self.assertEqual(_strip_edge_self_mentions("@Hermes /help", []), "@Hermes /help") def test_returns_input_when_no_self_refs(self): - from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions, FeishuMentionRef refs = [FeishuMentionRef(name="Alice", open_id="ou_alice")] self.assertEqual(_strip_edge_self_mentions("@Alice hi", refs), "@Alice hi") def test_uses_open_id_fallback_when_name_missing(self): - from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions, FeishuMentionRef refs = [FeishuMentionRef(name="", open_id="ou_bot", is_self=True)] self.assertEqual(_strip_edge_self_mentions("@ou_bot hi", refs), "hi") def test_word_boundary_prevents_prefix_collision(self): """A bot named 'Al' must not eat the leading '@Alice' of a different user.""" - from gateway.platforms.feishu import _strip_edge_self_mentions, FeishuMentionRef + from plugins.platforms.feishu.adapter import _strip_edge_self_mentions, FeishuMentionRef refs = [FeishuMentionRef(name="Al", open_id="ou_bot", is_self=True)] self.assertEqual(_strip_edge_self_mentions("@Alice hi", refs), "@Alice hi") @@ -4123,13 +4123,13 @@ class TestFeishuStripLeadingSelf(unittest.TestCase): class TestFeishuNormalizeText(unittest.TestCase): def test_renders_mention_with_display_name(self): - from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + from plugins.platforms.feishu.adapter import _normalize_feishu_text, FeishuMentionRef refs = {"@_user_1": FeishuMentionRef(name="Alice", open_id="ou_alice")} self.assertEqual(_normalize_feishu_text("@_user_1 hello", refs), "@Alice hello") def test_renders_self_mention_with_name(self): - from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + from plugins.platforms.feishu.adapter import _normalize_feishu_text, FeishuMentionRef refs = {"@_user_1": FeishuMentionRef(name="Hermes", open_id="ou_bot", is_self=True)} self.assertEqual( @@ -4138,23 +4138,23 @@ class TestFeishuNormalizeText(unittest.TestCase): ) def test_at_all_rendered_as_english_literal(self): - from gateway.platforms.feishu import _normalize_feishu_text + from plugins.platforms.feishu.adapter import _normalize_feishu_text self.assertEqual(_normalize_feishu_text("@_all notice", None), "@all notice") def test_unknown_placeholder_degrades_to_space(self): - from gateway.platforms.feishu import _normalize_feishu_text + from plugins.platforms.feishu.adapter import _normalize_feishu_text # No map: fall back to the old behavior (substitute with space, then collapse). self.assertEqual(_normalize_feishu_text("@_user_9 hello", None), "hello") def test_backward_compatible_without_map(self): - from gateway.platforms.feishu import _normalize_feishu_text + from plugins.platforms.feishu.adapter import _normalize_feishu_text self.assertEqual(_normalize_feishu_text("hello world"), "hello world") def test_mention_for_missing_map_entry_degrades_to_space(self): - from gateway.platforms.feishu import _normalize_feishu_text, FeishuMentionRef + from plugins.platforms.feishu.adapter import _normalize_feishu_text, FeishuMentionRef refs = {"@_user_1": FeishuMentionRef(name="Alice")} # @_user_2 has no entry — should degrade to a space (legacy behavior) @@ -4169,7 +4169,7 @@ class TestFeishuPostMentionParsing(unittest.TestCase): """Post .user_id is a placeholder ('@_user_N'); the real display name comes from the mentions_map lookup. Confirmed via live im.v1.message.get payload.""" - from gateway.platforms.feishu import parse_feishu_post_payload, FeishuMentionRef + from plugins.platforms.feishu.adapter import parse_feishu_post_payload, FeishuMentionRef payload = { "en_us": { @@ -4188,7 +4188,7 @@ class TestFeishuPostMentionParsing(unittest.TestCase): def test_post_at_tag_falls_back_to_inline_user_name_when_map_misses(self): """When the mentions payload is missing a placeholder, fall back to the inline user_name in the tag itself.""" - from gateway.platforms.feishu import parse_feishu_post_payload + from plugins.platforms.feishu.adapter import parse_feishu_post_payload payload = { "en_us": { @@ -4204,7 +4204,7 @@ class TestFeishuPostMentionParsing(unittest.TestCase): def test_post_at_all_tag_renders_as_at_all(self): """Post-format @everyone has user_id == '@_all' (confirmed via live im.v1.message.get). Rendered as literal '@all' regardless of map.""" - from gateway.platforms.feishu import parse_feishu_post_payload + from plugins.platforms.feishu.adapter import parse_feishu_post_payload payload = { "en_us": { @@ -4220,7 +4220,7 @@ class TestFeishuPostMentionParsing(unittest.TestCase): class TestFeishuNormalizeWithMentions(unittest.TestCase): def test_text_message_renders_mention_by_name(self): - from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import normalize_feishu_message, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -4239,7 +4239,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): self.assertFalse(normalized.mentions[0].is_self) def test_text_message_marks_bot_self_mention(self): - from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import normalize_feishu_message, _FeishuBotIdentity mention = SimpleNamespace( key="@_user_1", @@ -4257,7 +4257,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): self.assertEqual(normalized.text_content, "@Hermes /help") def test_text_message_at_all_surfaces_ref(self): - from gateway.platforms.feishu import normalize_feishu_message + from plugins.platforms.feishu.adapter import normalize_feishu_message mention = SimpleNamespace(key="@_all", id=None, name="") normalized = normalize_feishu_message( @@ -4273,7 +4273,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): """Feishu SDK sometimes omits @_all from the mentions payload (confirmed via im.v1.message.get). The fallback scan on raw text must still yield an is_all ref so [Mentioned: @all] gets injected.""" - from gateway.platforms.feishu import normalize_feishu_message + from plugins.platforms.feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="text", @@ -4286,7 +4286,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): def test_text_message_at_all_not_synthesized_if_absent_from_text(self): """No @_all in text → no synthetic ref even if mentions_map is empty.""" - from gateway.platforms.feishu import normalize_feishu_message + from plugins.platforms.feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="text", @@ -4296,7 +4296,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): self.assertEqual(normalized.mentions, []) def test_text_message_without_mentions_param_is_backward_compatible(self): - from gateway.platforms.feishu import normalize_feishu_message + from plugins.platforms.feishu.adapter import normalize_feishu_message normalized = normalize_feishu_message( message_type="text", @@ -4308,7 +4308,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): def test_post_message_marks_self_via_mentions_map_lookup(self): """Real Feishu post: + top-level mentions array resolves to open_id via placeholder lookup, not direct tag fields.""" - from gateway.platforms.feishu import normalize_feishu_message, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import normalize_feishu_message, _FeishuBotIdentity raw = json.dumps({ "en_us": { @@ -4338,7 +4338,7 @@ class TestFeishuNormalizeWithMentions(unittest.TestCase): class TestFeishuPostMentionsBot(unittest.TestCase): def _build_adapter(self, bot_open_id="ou_bot", bot_user_id="", bot_name=""): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = bot_open_id @@ -4347,7 +4347,7 @@ class TestFeishuPostMentionsBot(unittest.TestCase): return adapter def test_post_mentions_bot_uses_is_self_flag(self): - from gateway.platforms.feishu import FeishuMentionRef + from plugins.platforms.feishu.adapter import FeishuMentionRef adapter = self._build_adapter() self.assertTrue( @@ -4368,7 +4368,7 @@ class TestFeishuPostMentionsBot(unittest.TestCase): class TestFeishuExtractMessageContent(unittest.TestCase): def _build_adapter(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "ou_bot" @@ -4415,7 +4415,7 @@ class TestFeishuExtractMessageContent(unittest.TestCase): class TestFeishuProcessInboundMessage(unittest.TestCase): def _build_adapter(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "ou_bot" @@ -4599,7 +4599,7 @@ class TestFeishuProcessInboundMessage(unittest.TestCase): class TestFeishuFetchMessageText(unittest.TestCase): def _build_adapter(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "ou_bot" @@ -4635,7 +4635,7 @@ class TestFeishuFetchMessageText(unittest.TestCase): self.assertNotIn("[Mentioned:", result) def test_extract_text_from_raw_content_accepts_mentions_kwarg(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "" @@ -4686,7 +4686,7 @@ class TestFeishuFetchMessageText(unittest.TestCase): """_build_mentions_map accepts the reply-history shape (id as str + id_type='open_id'). user_id id_type is not load-bearing for self detection — inbound mention payloads always include an open_id.""" - from gateway.platforms.feishu import _build_mentions_map, _FeishuBotIdentity + from plugins.platforms.feishu.adapter import _build_mentions_map, _FeishuBotIdentity # open_id discriminator, non-self alice = SimpleNamespace(key="@_user_1", id="ou_alice", id_type="open_id", name="Alice") @@ -4705,7 +4705,7 @@ class TestFeishuMentionEndToEnd(unittest.TestCase): """High-level scenarios from the design spec — verify the full pipeline.""" def _build_adapter(self): - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter.__new__(FeishuAdapter) adapter._bot_open_id = "ou_bot" @@ -4893,7 +4893,7 @@ class TestChatLockEviction(unittest.TestCase): def _make_adapter(self, max_size=5): import collections as _collections - from gateway.platforms.feishu import FeishuAdapter + from plugins.platforms.feishu.adapter import FeishuAdapter adapter = object.__new__(FeishuAdapter) adapter._chat_locks = _collections.OrderedDict() diff --git a/tests/gateway/test_feishu_approval_buttons.py b/tests/gateway/test_feishu_approval_buttons.py index 999ac648d23..f5b9a26c1e1 100644 --- a/tests/gateway/test_feishu_approval_buttons.py +++ b/tests/gateway/test_feishu_approval_buttons.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_feishu_bot_admission.py b/tests/gateway/test_feishu_bot_admission.py index 2d71ad06de1..61628f933a8 100644 --- a/tests/gateway/test_feishu_bot_admission.py +++ b/tests/gateway/test_feishu_bot_admission.py @@ -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) diff --git a/tests/gateway/test_feishu_comment.py b/tests/gateway/test_feishu_comment.py index 6241de6f86e..320d1d56ab3 100644 --- a/tests/gateway/test_feishu_comment.py +++ b/tests/gateway/test_feishu_comment.py @@ -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 = [ diff --git a/tests/gateway/test_feishu_comment_rules.py b/tests/gateway/test_feishu_comment_rules.py index baef7a54744..1ecff5ae9d4 100644 --- a/tests/gateway/test_feishu_comment_rules.py +++ b/tests/gateway/test_feishu_comment_rules.py @@ -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() diff --git a/tests/gateway/test_feishu_meeting_invite.py b/tests/gateway/test_feishu_meeting_invite.py index f8da38df6cb..e891ddf0a86 100644 --- a/tests/gateway/test_feishu_meeting_invite.py +++ b/tests/gateway/test_feishu_meeting_invite.py @@ -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 = [] diff --git a/tests/gateway/test_feishu_onboard.py b/tests/gateway/test_feishu_onboard.py index 80a9c826031..72356cb1c32 100644 --- a/tests/gateway/test_feishu_onboard.py +++ b/tests/gateway/test_feishu_onboard.py @@ -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", diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 116bb627032..6c6dd0513f8 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -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('click') 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 """ 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 diff --git a/tests/gateway/test_matrix_approval_reaction_fail_closed.py b/tests/gateway/test_matrix_approval_reaction_fail_closed.py index be181f62e08..fa9f0c7ab7e 100644 --- a/tests/gateway/test_matrix_approval_reaction_fail_closed.py +++ b/tests/gateway/test_matrix_approval_reaction_fail_closed.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_matrix_exec_approval.py b/tests/gateway/test_matrix_exec_approval.py index f3a8eaf86ca..99cf2df793a 100644 --- a/tests/gateway/test_matrix_exec_approval.py +++ b/tests/gateway/test_matrix_exec_approval.py @@ -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). diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index 634c1c765f9..a8691c0cb8b 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -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, diff --git a/tests/gateway/test_matrix_project_context_isolation.py b/tests/gateway/test_matrix_project_context_isolation.py index 871f4a855f5..5094a06feb5 100644 --- a/tests/gateway/test_matrix_project_context_isolation.py +++ b/tests/gateway/test_matrix_project_context_isolation.py @@ -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( diff --git a/tests/gateway/test_matrix_voice.py b/tests/gateway/test_matrix_voice.py index 51bf150b29b..2e1cdc0befa 100644 --- a/tests/gateway/test_matrix_voice.py +++ b/tests/gateway/test_matrix_voice.py @@ -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( diff --git a/tests/gateway/test_media_download_retry.py b/tests/gateway/test_media_download_retry.py index bb45061f842..2cdc8a32b46 100644 --- a/tests/gateway/test_media_download_retry.py +++ b/tests/gateway/test_media_download_retry.py @@ -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 diff --git a/tests/gateway/test_media_metadata_contract.py b/tests/gateway/test_media_metadata_contract.py index 7f423e77342..ce7c0c5a884 100644 --- a/tests/gateway/test_media_metadata_contract.py +++ b/tests/gateway/test_media_metadata_contract.py @@ -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"), ] diff --git a/tests/gateway/test_platform_connected_checkers.py b/tests/gateway/test_platform_connected_checkers.py index e53e0fa4cfc..35cca649bb8 100644 --- a/tests/gateway/test_platform_connected_checkers.py +++ b/tests/gateway/test_platform_connected_checkers.py @@ -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: " diff --git a/tests/gateway/test_platform_http_client_limits.py b/tests/gateway/test_platform_http_client_limits.py index 074a6d52ec3..7eb642c52bd 100644 --- a/tests/gateway/test_platform_http_client_limits.py +++ b/tests/gateway/test_platform_http_client_limits.py @@ -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 diff --git a/tests/gateway/test_send_image_file.py b/tests/gateway/test_send_image_file.py index 9cbf48fd0d7..54a3faadb4c 100644 --- a/tests/gateway/test_send_image_file.py +++ b/tests/gateway/test_send_image_file.py @@ -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: diff --git a/tests/gateway/test_send_multiple_images.py b/tests/gateway/test_send_multiple_images.py index 5fab55c4a70..590a763acc3 100644 --- a/tests/gateway/test_send_multiple_images.py +++ b/tests/gateway/test_send_multiple_images.py @@ -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: diff --git a/tests/gateway/test_setup_feishu.py b/tests/gateway/test_setup_feishu.py index 26165528e24..bd1d341ea73 100644 --- a/tests/gateway/test_setup_feishu.py +++ b/tests/gateway/test_setup_feishu.py @@ -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" diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 5f8a3b62348..a8fa84f9513 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -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", diff --git a/tests/gateway/test_slack_approval_buttons.py b/tests/gateway/test_slack_approval_buttons.py index e09b3406c6d..b85fc378723 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/tests/gateway/test_slack_approval_buttons.py @@ -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 diff --git a/tests/gateway/test_slack_channel_session_scope.py b/tests/gateway/test_slack_channel_session_scope.py index 5b256fc3b82..baef0bf1ce1 100644 --- a/tests/gateway/test_slack_channel_session_scope.py +++ b/tests/gateway/test_slack_channel_session_scope.py @@ -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 diff --git a/tests/gateway/test_slack_channel_skills.py b/tests/gateway/test_slack_channel_skills.py index 6f5987a2e59..0e1a0103c75 100644 --- a/tests/gateway/test_slack_channel_skills.py +++ b/tests/gateway/test_slack_channel_skills.py @@ -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 {} diff --git a/tests/gateway/test_slack_mention.py b/tests/gateway/test_slack_mention.py index 23aa2f15454..78efb478262 100644 --- a/tests/gateway/test_slack_mention.py +++ b/tests/gateway/test_slack_mention.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_slack_plugin_action_handlers.py b/tests/gateway/test_slack_plugin_action_handlers.py index 611446802b2..909c870351a 100644 --- a/tests/gateway/test_slack_plugin_action_handlers.py +++ b/tests/gateway/test_slack_plugin_action_handlers.py @@ -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, diff --git a/tests/gateway/test_slack_plugin_setup.py b/tests/gateway/test_slack_plugin_setup.py new file mode 100644 index 00000000000..1a1ac7eba6c --- /dev/null +++ b/tests/gateway/test_slack_plugin_setup.py @@ -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 diff --git a/tests/gateway/test_sms.py b/tests/gateway/test_sms.py index 8d8b73614aa..85a9501f06a 100644 --- a/tests/gateway/test_sms.py +++ b/tests/gateway/test_sms.py @@ -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", diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index eb867300640..0b8aebf07e5 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -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): diff --git a/tests/gateway/test_stream_consumer_fresh_final.py b/tests/gateway/test_stream_consumer_fresh_final.py index ed934969432..f8270cfd86d 100644 --- a/tests/gateway/test_stream_consumer_fresh_final.py +++ b/tests/gateway/test_stream_consumer_fresh_final.py @@ -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"), ( diff --git a/tests/gateway/test_stream_consumer_thread_routing.py b/tests/gateway/test_stream_consumer_thread_routing.py index 3c84aef4fa8..bb1675f03c0 100644 --- a/tests/gateway/test_stream_consumer_thread_routing.py +++ b/tests/gateway/test_stream_consumer_thread_routing.py @@ -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( diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index 5810b87a59b..96de984a9c2 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -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 diff --git a/tests/gateway/test_telegram_callback_auth_fail_closed.py b/tests/gateway/test_telegram_callback_auth_fail_closed.py index 8f6b0fa5afe..ad00c17c003 100644 --- a/tests/gateway/test_telegram_callback_auth_fail_closed.py +++ b/tests/gateway/test_telegram_callback_auth_fail_closed.py @@ -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) diff --git a/tests/gateway/test_telegram_caption_merge.py b/tests/gateway/test_telegram_caption_merge.py index f5d4390f483..3bb18a225df 100644 --- a/tests/gateway/test_telegram_caption_merge.py +++ b/tests/gateway/test_telegram_caption_merge.py @@ -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 diff --git a/tests/gateway/test_telegram_channel_posts.py b/tests/gateway/test_telegram_channel_posts.py index ade82c2e4aa..729d5c1ee30 100644 --- a/tests/gateway/test_telegram_channel_posts.py +++ b/tests/gateway/test_telegram_channel_posts.py @@ -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 diff --git a/tests/gateway/test_telegram_clarify_buttons.py b/tests/gateway/test_telegram_clarify_buttons.py index 729ee22359a..81cb5c97ac5 100644 --- a/tests/gateway/test_telegram_clarify_buttons.py +++ b/tests/gateway/test_telegram_clarify_buttons.py @@ -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 diff --git a/tests/gateway/test_telegram_conflict.py b/tests/gateway/test_telegram_conflict.py index 440ed196520..04fd2d74feb 100644 --- a/tests/gateway/test_telegram_conflict.py +++ b/tests/gateway/test_telegram_conflict.py @@ -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()) diff --git a/tests/gateway/test_telegram_documents.py b/tests/gateway/test_telegram_documents.py index f4155107aa0..b30f809fe39 100644 --- a/tests/gateway/test_telegram_documents.py +++ b/tests/gateway/test_telegram_documents.py @@ -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) diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index 1d3a2375a78..4d346ef1bf7 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -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, diff --git a/tests/gateway/test_telegram_forum_commands.py b/tests/gateway/test_telegram_forum_commands.py index 0e2ce6d286a..a68a8052610 100644 --- a/tests/gateway/test_telegram_forum_commands.py +++ b/tests/gateway/test_telegram_forum_commands.py @@ -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 diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index d43124b5636..d9b55fa2ad4 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -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: diff --git a/tests/gateway/test_telegram_max_doc_bytes.py b/tests/gateway/test_telegram_max_doc_bytes.py index 163dcc9f576..95f3c3029b9 100644 --- a/tests/gateway/test_telegram_max_doc_bytes.py +++ b/tests/gateway/test_telegram_max_doc_bytes.py @@ -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(): diff --git a/tests/gateway/test_telegram_mention_boundaries.py b/tests/gateway/test_telegram_mention_boundaries.py index 2a203857efb..cc99d15f5bd 100644 --- a/tests/gateway/test_telegram_mention_boundaries.py +++ b/tests/gateway/test_telegram_mention_boundaries.py @@ -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(): diff --git a/tests/gateway/test_telegram_model_picker.py b/tests/gateway/test_telegram_model_picker.py index 7b91b92647a..801807592d5 100644 --- a/tests/gateway/test_telegram_model_picker.py +++ b/tests/gateway/test_telegram_model_picker.py @@ -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 = [] diff --git a/tests/gateway/test_telegram_network.py b/tests/gateway/test_telegram_network.py index fe50fb8c57e..57950d0fb61 100644 --- a/tests/gateway/test_telegram_network.py +++ b/tests/gateway/test_telegram_network.py @@ -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: diff --git a/tests/gateway/test_telegram_network_reconnect.py b/tests/gateway/test_telegram_network_reconnect.py index 81b7bed12e4..bd9e9e3b7b0 100644 --- a/tests/gateway/test_telegram_network_reconnect.py +++ b/tests/gateway/test_telegram_network_reconnect.py @@ -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() diff --git a/tests/gateway/test_telegram_overflow_partial.py b/tests/gateway/test_telegram_overflow_partial.py index 38b10299dc3..663d1c83af0 100644 --- a/tests/gateway/test_telegram_overflow_partial.py +++ b/tests/gateway/test_telegram_overflow_partial.py @@ -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 diff --git a/tests/gateway/test_telegram_reactions.py b/tests/gateway/test_telegram_reactions.py index 8b3b0686bb4..70c2fd4ee84 100644 --- a/tests/gateway/test_telegram_reactions.py +++ b/tests/gateway/test_telegram_reactions.py @@ -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 diff --git a/tests/gateway/test_telegram_reply_mode.py b/tests/gateway/test_telegram_reply_mode.py index f036dc6b785..66b471aadbe 100644 --- a/tests/gateway/test_telegram_reply_mode.py +++ b/tests/gateway/test_telegram_reply_mode.py @@ -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() diff --git a/tests/gateway/test_telegram_reply_quote.py b/tests/gateway/test_telegram_reply_quote.py index d636f0df94a..f9c8d27aa26 100644 --- a/tests/gateway/test_telegram_reply_quote.py +++ b/tests/gateway/test_telegram_reply_quote.py @@ -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(): diff --git a/tests/gateway/test_telegram_rich_messages.py b/tests/gateway/test_telegram_rich_messages.py index de635042e54..db684ea0ac9 100644 --- a/tests/gateway/test_telegram_rich_messages.py +++ b/tests/gateway/test_telegram_rich_messages.py @@ -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 diff --git a/tests/gateway/test_telegram_send_draft_format.py b/tests/gateway/test_telegram_send_draft_format.py index a84a42852e0..6608a365d53 100644 --- a/tests/gateway/test_telegram_send_draft_format.py +++ b/tests/gateway/test_telegram_send_draft_format.py @@ -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: diff --git a/tests/gateway/test_telegram_send_path_health.py b/tests/gateway/test_telegram_send_path_health.py index 05972bdba43..d5285f25109 100644 --- a/tests/gateway/test_telegram_send_path_health.py +++ b/tests/gateway/test_telegram_send_path_health.py @@ -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 diff --git a/tests/gateway/test_telegram_slash_confirm.py b/tests/gateway/test_telegram_slash_confirm.py index 785d9f7c6ac..ef321d817ab 100644 --- a/tests/gateway/test_telegram_slash_confirm.py +++ b/tests/gateway/test_telegram_slash_confirm.py @@ -34,7 +34,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 diff --git a/tests/gateway/test_telegram_status_indicator.py b/tests/gateway/test_telegram_status_indicator.py index ce04ab62dda..b881c6f6cc2 100644 --- a/tests/gateway/test_telegram_status_indicator.py +++ b/tests/gateway/test_telegram_status_indicator.py @@ -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(extra): diff --git a/tests/gateway/test_telegram_status_update.py b/tests/gateway/test_telegram_status_update.py index f49ca9c60e1..85dc1f04053 100644 --- a/tests/gateway/test_telegram_status_update.py +++ b/tests/gateway/test_telegram_status_update.py @@ -64,7 +64,7 @@ def _install_fake_telegram(monkeypatch): @pytest.fixture def adapter(monkeypatch): _install_fake_telegram(monkeypatch) - from gateway.platforms.telegram import TelegramAdapter + from plugins.platforms.telegram.adapter import TelegramAdapter a = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token")) a._bot = MagicMock() diff --git a/tests/gateway/test_telegram_text_batch_perf.py b/tests/gateway/test_telegram_text_batch_perf.py index 194dd0d3ffb..e17365a7771 100644 --- a/tests/gateway/test_telegram_text_batch_perf.py +++ b/tests/gateway/test_telegram_text_batch_perf.py @@ -16,7 +16,7 @@ import math import pytest -from gateway.platforms.telegram import TelegramAdapter +from plugins.platforms.telegram.adapter import TelegramAdapter @pytest.fixture diff --git a/tests/gateway/test_telegram_text_batching.py b/tests/gateway/test_telegram_text_batching.py index 5cd45190067..d506e6a50bd 100644 --- a/tests/gateway/test_telegram_text_batching.py +++ b/tests/gateway/test_telegram_text_batching.py @@ -18,7 +18,7 @@ from gateway.session import build_session_key def _make_adapter(): """Create a minimal TelegramAdapter for testing text batching.""" - from gateway.platforms.telegram import TelegramAdapter + from plugins.platforms.telegram.adapter import TelegramAdapter config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(TelegramAdapter) diff --git a/tests/gateway/test_telegram_thread_fallback.py b/tests/gateway/test_telegram_thread_fallback.py index 036d27e771b..20b38a7cbe4 100644 --- a/tests/gateway/test_telegram_thread_fallback.py +++ b/tests/gateway/test_telegram_thread_fallback.py @@ -116,7 +116,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) @@ -137,7 +137,7 @@ def _make_adapter(): def test_non_forum_group_reply_thread_id_does_not_fork_session_key(): """Reply-derived thread ids in ordinary groups must not create topic lanes.""" - from gateway.platforms import telegram as telegram_mod + import plugins.platforms.telegram.adapter as telegram_mod adapter = _make_adapter() message = SimpleNamespace( @@ -171,7 +171,7 @@ def test_non_forum_group_reply_thread_id_does_not_fork_session_key(): def test_forum_group_topic_message_preserves_thread_session_key(): """Real Telegram forum-topic messages should still route by topic id.""" - from gateway.platforms import telegram as telegram_mod + import plugins.platforms.telegram.adapter as telegram_mod adapter = _make_adapter() message = SimpleNamespace( @@ -201,7 +201,7 @@ def test_forum_group_topic_message_preserves_thread_session_key(): def test_forum_general_topic_without_message_thread_id_keeps_thread_context(): """Forum General-topic messages should keep synthetic thread context.""" - from gateway.platforms import telegram as telegram_mod + import plugins.platforms.telegram.adapter as telegram_mod adapter = _make_adapter() message = SimpleNamespace( diff --git a/tests/gateway/test_telegram_voice_v0_regressions.py b/tests/gateway/test_telegram_voice_v0_regressions.py index b2b8d4d0e8b..b7527601fbc 100644 --- a/tests/gateway/test_telegram_voice_v0_regressions.py +++ b/tests/gateway/test_telegram_voice_v0_regressions.py @@ -10,7 +10,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from gateway.config import Platform -from gateway.platforms.telegram import TelegramAdapter +from plugins.platforms.telegram.adapter import TelegramAdapter from gateway.run import GatewayRunner from gateway.session import SessionSource diff --git a/tests/gateway/test_text_batching.py b/tests/gateway/test_text_batching.py index c0e7bf5d4b6..d72cb439d47 100644 --- a/tests/gateway/test_text_batching.py +++ b/tests/gateway/test_text_batching.py @@ -218,7 +218,7 @@ class TestDiscordTextBatching: def _make_matrix_adapter(): """Create a minimal MatrixAdapter for testing text batching.""" - from gateway.platforms.matrix import MatrixAdapter + from plugins.platforms.matrix.adapter import MatrixAdapter config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(MatrixAdapter) @@ -303,7 +303,7 @@ class TestMatrixTextBatching: def _make_wecom_adapter(): """Create a minimal WeComAdapter for testing text batching.""" - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(WeComAdapter) @@ -388,7 +388,7 @@ class TestWeComTextBatching: def _make_telegram_adapter(): """Create a minimal TelegramAdapter for testing adaptive delay.""" - from gateway.platforms.telegram import TelegramAdapter + from plugins.platforms.telegram.adapter import TelegramAdapter config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(TelegramAdapter) @@ -452,7 +452,7 @@ class TestTelegramAdaptiveDelay: def _make_feishu_adapter(): """Create a minimal FeishuAdapter for testing adaptive delay.""" - from gateway.platforms.feishu import FeishuAdapter, FeishuBatchState + from plugins.platforms.feishu.adapter import FeishuAdapter, FeishuBatchState config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(FeishuAdapter) diff --git a/tests/gateway/test_wecom.py b/tests/gateway/test_wecom.py index c0999a98040..1202ec3f043 100644 --- a/tests/gateway/test_wecom.py +++ b/tests/gateway/test_wecom.py @@ -15,35 +15,35 @@ from gateway.platforms.base import SendResult class TestWeComRequirements: def test_returns_false_without_aiohttp(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", False) - monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", True) - from gateway.platforms.wecom import check_wecom_requirements + monkeypatch.setattr("plugins.platforms.wecom.adapter.AIOHTTP_AVAILABLE", False) + monkeypatch.setattr("plugins.platforms.wecom.adapter.HTTPX_AVAILABLE", True) + from plugins.platforms.wecom.adapter import check_wecom_requirements assert check_wecom_requirements() is False def test_returns_false_without_httpx(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", True) - monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", False) - from gateway.platforms.wecom import check_wecom_requirements + monkeypatch.setattr("plugins.platforms.wecom.adapter.AIOHTTP_AVAILABLE", True) + monkeypatch.setattr("plugins.platforms.wecom.adapter.HTTPX_AVAILABLE", False) + from plugins.platforms.wecom.adapter import check_wecom_requirements assert check_wecom_requirements() is False def test_returns_true_when_available(self, monkeypatch): - monkeypatch.setattr("gateway.platforms.wecom.AIOHTTP_AVAILABLE", True) - monkeypatch.setattr("gateway.platforms.wecom.HTTPX_AVAILABLE", True) - from gateway.platforms.wecom import check_wecom_requirements + monkeypatch.setattr("plugins.platforms.wecom.adapter.AIOHTTP_AVAILABLE", True) + monkeypatch.setattr("plugins.platforms.wecom.adapter.HTTPX_AVAILABLE", True) + from plugins.platforms.wecom.adapter import check_wecom_requirements assert check_wecom_requirements() is True class TestWeComAdapterInit: def test_declares_non_editable_message_capability(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter assert WeComAdapter.SUPPORTS_MESSAGE_EDITING is False def test_reads_config_from_extra(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter config = PlatformConfig( enabled=True, @@ -67,7 +67,7 @@ class TestWeComAdapterInit: monkeypatch.setenv("WECOM_BOT_ID", "env-bot") monkeypatch.setenv("WECOM_SECRET", "env-secret") monkeypatch.setenv("WECOM_WEBSOCKET_URL", "wss://env.example/ws") - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) assert adapter._bot_id == "env-bot" @@ -78,8 +78,8 @@ class TestWeComAdapterInit: class TestWeComConnect: @pytest.mark.asyncio async def test_connect_records_missing_credentials(self, monkeypatch): - import gateway.platforms.wecom as wecom_module - from gateway.platforms.wecom import WeComAdapter + import plugins.platforms.wecom.adapter as wecom_module + from plugins.platforms.wecom.adapter import WeComAdapter monkeypatch.setattr(wecom_module, "AIOHTTP_AVAILABLE", True) monkeypatch.setattr(wecom_module, "HTTPX_AVAILABLE", True) @@ -95,8 +95,8 @@ class TestWeComConnect: @pytest.mark.asyncio async def test_connect_records_handshake_failure_details(self, monkeypatch): - import gateway.platforms.wecom as wecom_module - from gateway.platforms.wecom import WeComAdapter + import plugins.platforms.wecom.adapter as wecom_module + from plugins.platforms.wecom.adapter import WeComAdapter class DummyClient: async def aclose(self): @@ -124,9 +124,9 @@ class TestWeComConnect: class TestWeComQrScan: - @patch("gateway.platforms.wecom.time") - @patch("gateway.platforms.wecom.json.loads") - @patch("gateway.platforms.wecom.logger") + @patch("plugins.platforms.wecom.adapter.time") + @patch("plugins.platforms.wecom.adapter.json.loads") + @patch("plugins.platforms.wecom.adapter.logger") @patch("urllib.request.urlopen") @patch("urllib.request.Request") def test_qr_scan_timeout_uses_monotonic_clock( @@ -137,7 +137,7 @@ class TestWeComQrScan: mock_json_loads, mock_time, ): - from gateway.platforms.wecom import qr_scan_for_bot_info + from plugins.platforms.wecom.adapter import qr_scan_for_bot_info generate_resp = MagicMock() generate_resp.read.return_value = b'{"data":{"scode":"abc","auth_url":"https://example.com/qr"}}' @@ -168,7 +168,7 @@ class TestWeComQrScan: class TestWeComReplyMode: @pytest.mark.asyncio async def test_send_uses_passive_reply_markdown_when_reply_context_exists(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._reply_req_ids["msg-1"] = "req-1" @@ -189,7 +189,7 @@ class TestWeComReplyMode: @pytest.mark.asyncio async def test_send_image_file_uses_passive_reply_media_when_reply_context_exists(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._reply_req_ids["msg-1"] = "req-1" @@ -222,7 +222,7 @@ class TestWeComReplyMode: class TestExtractText: def test_extracts_plain_text(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter body = { "msgtype": "text", @@ -233,7 +233,7 @@ class TestExtractText: assert reply_text is None def test_extracts_mixed_text(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter body = { "msgtype": "mixed", @@ -249,7 +249,7 @@ class TestExtractText: assert text == "part1\npart2" def test_extracts_voice_and_quote(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter body = { "msgtype": "voice", @@ -265,7 +265,7 @@ class TestCallbackDispatch: @pytest.mark.asyncio @pytest.mark.parametrize("cmd", ["aibot_msg_callback", "aibot_callback"]) async def test_dispatch_accepts_new_and_legacy_callback_cmds(self, cmd): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._on_message = AsyncMock() @@ -277,7 +277,7 @@ class TestCallbackDispatch: class TestPolicyHelpers: def test_dm_allowlist(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter( PlatformConfig(enabled=True, extra={"dm_policy": "allowlist", "allow_from": ["user-1"]}) @@ -290,7 +290,7 @@ class TestPolicyHelpers: ``extra``) must populate the DM allowlist. Otherwise ``dm_policy: allowlist`` runs with an empty allowlist and drops every listed user at intake — the documented env vars become no-ops.""" - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter monkeypatch.setenv("WECOM_DM_POLICY", "allowlist") monkeypatch.setenv("WECOM_ALLOWED_USERS", "user-1, user-2") @@ -306,7 +306,7 @@ class TestPolicyHelpers: def test_dm_allowlist_extra_takes_precedence_over_env(self, monkeypatch): """Config ``extra`` wins over the env fallback, so an explicit allowlist is never silently widened by a stray WECOM_ALLOWED_USERS.""" - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter monkeypatch.setenv("WECOM_ALLOWED_USERS", "env-user") @@ -319,7 +319,7 @@ class TestPolicyHelpers: assert adapter._is_dm_allowed("env-user") is False def test_group_allowlist_and_per_group_sender_allowlist(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter( PlatformConfig( @@ -339,7 +339,7 @@ class TestPolicyHelpers: class TestMediaHelpers: def test_detect_wecom_media_type(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter assert WeComAdapter._detect_wecom_media_type("image/png") == "image" assert WeComAdapter._detect_wecom_media_type("video/mp4") == "video" @@ -347,7 +347,7 @@ class TestMediaHelpers: assert WeComAdapter._detect_wecom_media_type("application/pdf") == "file" def test_voice_non_amr_downgrades_to_file(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter result = WeComAdapter._apply_file_size_limits(128, "voice", "audio/mpeg") @@ -356,7 +356,7 @@ class TestMediaHelpers: assert "AMR" in (result["downgrade_note"] or "") def test_oversized_file_is_rejected(self): - from gateway.platforms.wecom import ABSOLUTE_MAX_BYTES, WeComAdapter + from plugins.platforms.wecom.adapter import ABSOLUTE_MAX_BYTES, WeComAdapter result = WeComAdapter._apply_file_size_limits(ABSOLUTE_MAX_BYTES + 1, "file", "application/pdf") @@ -365,7 +365,7 @@ class TestMediaHelpers: def test_decrypt_file_bytes_round_trip(self): from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter plaintext = b"wecom-secret" key = os.urandom(32) @@ -380,7 +380,7 @@ class TestMediaHelpers: @pytest.mark.asyncio async def test_load_outbound_media_rejects_placeholder_path(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) @@ -391,8 +391,8 @@ class TestMediaHelpers: class TestMediaUpload: @pytest.mark.asyncio async def test_upload_media_bytes_uses_sdk_sequence(self, monkeypatch): - import gateway.platforms.wecom as wecom_module - from gateway.platforms.wecom import ( + import plugins.platforms.wecom.adapter as wecom_module + from plugins.platforms.wecom.adapter import ( APP_CMD_UPLOAD_MEDIA_CHUNK, APP_CMD_UPLOAD_MEDIA_FINISH, APP_CMD_UPLOAD_MEDIA_INIT, @@ -439,7 +439,7 @@ class TestMediaUpload: @pytest.mark.asyncio @patch("tools.url_safety.is_safe_url", return_value=True) async def test_download_remote_bytes_rejects_large_content_length(self, _mock_safe): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter class FakeResponse: headers = {"content-length": "10"} @@ -468,7 +468,7 @@ class TestMediaUpload: @pytest.mark.asyncio async def test_cache_media_decrypts_url_payload_before_writing(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) plaintext = b"secret document bytes" @@ -507,7 +507,7 @@ class TestMediaUpload: class TestSend: @pytest.mark.asyncio async def test_send_uses_proactive_payload(self): - from gateway.platforms.wecom import APP_CMD_SEND, WeComAdapter + from plugins.platforms.wecom.adapter import APP_CMD_SEND, WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._send_request = AsyncMock(return_value={"headers": {"req_id": "req-1"}, "errcode": 0}) @@ -526,7 +526,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_reports_wecom_errors(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._send_request = AsyncMock(return_value={"errcode": 40001, "errmsg": "bad request"}) @@ -538,7 +538,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_image_falls_back_to_text_for_remote_url(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._send_media_source = AsyncMock(return_value=SendResult(success=False, error="upload failed")) @@ -551,7 +551,7 @@ class TestSend: @pytest.mark.asyncio async def test_send_voice_sends_caption_and_downgrade_note(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._prepare_outbound_media = AsyncMock( @@ -587,7 +587,7 @@ class TestSend: class TestInboundMessages: @pytest.mark.asyncio async def test_on_message_builds_event(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._text_batch_delay_seconds = 0 # disable batching for tests @@ -619,7 +619,7 @@ class TestInboundMessages: @pytest.mark.asyncio async def test_on_message_preserves_quote_context(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._text_batch_delay_seconds = 0 # disable batching for tests @@ -648,7 +648,7 @@ class TestInboundMessages: @pytest.mark.asyncio async def test_on_message_respects_group_policy(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter( PlatformConfig( @@ -680,7 +680,7 @@ class TestWeComZombieSessionFix: """Tests for PR #11572 — device_id, markdown reply, group req_id fallback.""" def test_adapter_generates_stable_device_id_per_instance(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) assert isinstance(adapter._device_id, str) @@ -691,7 +691,7 @@ class TestWeComZombieSessionFix: assert adapter._device_id == adapter._device_id def test_different_adapter_instances_get_distinct_device_ids(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter a = WeComAdapter(PlatformConfig(enabled=True)) b = WeComAdapter(PlatformConfig(enabled=True)) @@ -699,7 +699,7 @@ class TestWeComZombieSessionFix: @pytest.mark.asyncio async def test_open_connection_includes_device_id_in_subscribe(self): - from gateway.platforms.wecom import APP_CMD_SUBSCRIBE, WeComAdapter + from plugins.platforms.wecom.adapter import APP_CMD_SUBSCRIBE, WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._bot_id = "test-bot" @@ -735,7 +735,7 @@ class TestWeComZombieSessionFix: adapter._cleanup_ws = _fake_cleanup adapter._wait_for_handshake = _fake_handshake - with patch("gateway.platforms.wecom.aiohttp.ClientSession", _FakeSession): + with patch("plugins.platforms.wecom.adapter.aiohttp.ClientSession", _FakeSession): await adapter._open_connection() assert len(sent_payloads) == 1 @@ -747,7 +747,7 @@ class TestWeComZombieSessionFix: @pytest.mark.asyncio async def test_on_message_caches_last_req_id_per_chat(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._text_batch_delay_seconds = 0 @@ -773,7 +773,7 @@ class TestWeComZombieSessionFix: @pytest.mark.asyncio async def test_on_message_does_not_cache_blocked_sender_req_id(self): """Blocked chats shouldn't populate the proactive-send fallback cache.""" - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter( PlatformConfig( @@ -802,7 +802,7 @@ class TestWeComZombieSessionFix: assert "group-blocked" not in adapter._last_chat_req_ids def test_remember_chat_req_id_is_bounded(self): - from gateway.platforms.wecom import DEDUP_MAX_SIZE, WeComAdapter + from plugins.platforms.wecom.adapter import DEDUP_MAX_SIZE, WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) for i in range(DEDUP_MAX_SIZE + 50): @@ -813,7 +813,7 @@ class TestWeComZombieSessionFix: assert adapter._last_chat_req_ids[latest] == f"req-{DEDUP_MAX_SIZE + 49}" def test_remember_chat_req_id_ignores_empty_values(self): - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._remember_chat_req_id("", "req-1") @@ -826,7 +826,7 @@ class TestWeComZombieSessionFix: """Sending into a group without reply_to should use the last cached req_id via APP_CMD_RESPONSE — WeCom AI Bots cannot initiate APP_CMD_SEND in group chats (errcode 600039).""" - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._last_chat_req_ids["group-1"] = "inbound-req-42" @@ -851,7 +851,7 @@ class TestWeComZombieSessionFix: @pytest.mark.asyncio async def test_proactive_send_without_cached_req_id_uses_app_cmd_send(self): """When we have no prior req_id (fresh DM target), APP_CMD_SEND is used.""" - from gateway.platforms.wecom import APP_CMD_SEND, WeComAdapter + from plugins.platforms.wecom.adapter import APP_CMD_SEND, WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._send_request = AsyncMock( @@ -884,7 +884,7 @@ class TestTextBatchFlushRace: """A flush task that has been superseded must leave the event in the batch dict for the new task to handle.""" from gateway.platforms.base import MessageEvent, MessageType - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._text_batch_delay_seconds = 0 @@ -927,7 +927,7 @@ class TestTextBatchFlushRace: async def test_active_task_processes_event_normally(self): """When the task is not superseded it must still process the event.""" from gateway.platforms.base import MessageEvent, MessageType - from gateway.platforms.wecom import WeComAdapter + from plugins.platforms.wecom.adapter import WeComAdapter adapter = WeComAdapter(PlatformConfig(enabled=True)) adapter._text_batch_delay_seconds = 0 diff --git a/tests/gateway/test_wecom_callback.py b/tests/gateway/test_wecom_callback.py index e4646b70b5e..d41131f432d 100644 --- a/tests/gateway/test_wecom_callback.py +++ b/tests/gateway/test_wecom_callback.py @@ -6,8 +6,8 @@ from xml.etree import ElementTree as ET import pytest from gateway.config import PlatformConfig -from gateway.platforms.wecom_callback import WecomCallbackAdapter -from gateway.platforms.wecom_crypto import WXBizMsgCrypt +from plugins.platforms.wecom.callback_adapter import WecomCallbackAdapter +from plugins.platforms.wecom.wecom_crypto import WXBizMsgCrypt def _app(name="test-app", corp_id="ww1234567890", agent_id="1000002"): @@ -49,7 +49,7 @@ class TestWecomCrypto: crypt = WXBizMsgCrypt(app["token"], app["encoding_aes_key"], app["corp_id"]) encrypted_xml = crypt.encrypt("", nonce="n", timestamp="1") root = ET.fromstring(encrypted_xml) - from gateway.platforms.wecom_crypto import SignatureError + from plugins.platforms.wecom.wecom_crypto import SignatureError with pytest.raises(SignatureError): crypt.decrypt("bad-sig", "1", "n", root.findtext("Encrypt", default="")) diff --git a/tests/gateway/test_whatsapp_connect.py b/tests/gateway/test_whatsapp_connect.py index 9d7807734bb..2ae5f2b06d2 100644 --- a/tests/gateway/test_whatsapp_connect.py +++ b/tests/gateway/test_whatsapp_connect.py @@ -40,7 +40,7 @@ class _AsyncCM: def _make_adapter(): """Create a WhatsAppAdapter with test attributes (bypass __init__).""" - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) adapter.platform = Platform.WHATSAPP @@ -85,18 +85,18 @@ def _mock_aiohttp(status=200, json_data=None, json_side_effect=None): def _connect_patches(mock_proc, mock_fh, mock_client_cls=None): """Return a dict of common patches needed to reach the health-check loop.""" patches = { - "gateway.platforms.whatsapp.check_whatsapp_requirements": True, - "gateway.platforms.whatsapp.asyncio.create_task": MagicMock(), + "plugins.platforms.whatsapp.adapter.check_whatsapp_requirements": True, + "plugins.platforms.whatsapp.adapter.asyncio.create_task": MagicMock(), } base = [ - patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), + patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), patch.object(Path, "exists", return_value=True), patch.object(Path, "mkdir", return_value=None), patch("subprocess.run", return_value=MagicMock(returncode=0)), patch("subprocess.Popen", return_value=mock_proc), patch("builtins.open", return_value=mock_fh), - patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), - patch("gateway.platforms.whatsapp.asyncio.create_task"), + patch("plugins.platforms.whatsapp.adapter.asyncio.sleep", new_callable=AsyncMock), + patch("plugins.platforms.whatsapp.adapter.asyncio.create_task"), ] if mock_client_cls is not None: base.append(patch("aiohttp.ClientSession", mock_client_cls)) @@ -112,7 +112,7 @@ class TestCloseBridgeLog: @staticmethod def _bare_adapter(): - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter a = WhatsAppAdapter.__new__(WhatsAppAdapter) a._bridge_log_fh = None return a @@ -223,7 +223,7 @@ class TestConnectCleanup: install_result = MagicMock(returncode=1, stderr="install failed") - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch.object(Path, "exists", autospec=True, side_effect=_path_exists), \ patch("subprocess.run", return_value=install_result), \ patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ @@ -402,7 +402,7 @@ class TestBridgeRuntimeFailure: mock_fh = MagicMock() - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch.object(Path, "exists", return_value=True), \ patch.object(Path, "mkdir", return_value=None), \ patch("subprocess.run", return_value=MagicMock(returncode=0)), \ @@ -423,7 +423,7 @@ class TestKillPortProcess: """Verify _kill_port_process uses platform-appropriate commands.""" def test_uses_netstat_and_taskkill_on_windows(self): - from gateway.platforms.whatsapp import _kill_port_process + from plugins.platforms.whatsapp.adapter import _kill_port_process netstat_output = ( " Proto Local Address Foreign Address State PID\n" @@ -440,8 +440,8 @@ class TestKillPortProcess: return mock_taskkill return MagicMock() - with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \ - patch("gateway.platforms.whatsapp.subprocess.run", side_effect=run_side_effect) as mock_run: + with patch("plugins.platforms.whatsapp.adapter._IS_WINDOWS", True), \ + patch("plugins.platforms.whatsapp.adapter.subprocess.run", side_effect=run_side_effect) as mock_run: _kill_port_process(3000) # netstat called @@ -455,15 +455,15 @@ class TestKillPortProcess: ) def test_does_not_kill_wrong_port_on_windows(self): - from gateway.platforms.whatsapp import _kill_port_process + from plugins.platforms.whatsapp.adapter import _kill_port_process netstat_output = ( " TCP 0.0.0.0:30000 0.0.0.0:0 LISTENING 55555\n" ) mock_netstat = MagicMock(stdout=netstat_output) - with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \ - patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_netstat) as mock_run: + with patch("plugins.platforms.whatsapp.adapter._IS_WINDOWS", True), \ + patch("plugins.platforms.whatsapp.adapter.subprocess.run", return_value=mock_netstat) as mock_run: _kill_port_process(3000) # Should NOT call taskkill because port 30000 != 3000 @@ -473,12 +473,12 @@ class TestKillPortProcess: ) def test_uses_fuser_on_linux(self): - from gateway.platforms.whatsapp import _kill_port_process + from plugins.platforms.whatsapp.adapter import _kill_port_process mock_check = MagicMock(returncode=0) - with patch("gateway.platforms.whatsapp._IS_WINDOWS", False), \ - patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_check) as mock_run: + with patch("plugins.platforms.whatsapp.adapter._IS_WINDOWS", False), \ + patch("plugins.platforms.whatsapp.adapter.subprocess.run", return_value=mock_check) as mock_run: _kill_port_process(3000) calls = [c.args[0] for c in mock_run.call_args_list] @@ -486,12 +486,12 @@ class TestKillPortProcess: assert ["fuser", "-k", "3000/tcp"] in calls def test_skips_fuser_kill_when_port_free(self): - from gateway.platforms.whatsapp import _kill_port_process + from plugins.platforms.whatsapp.adapter import _kill_port_process mock_check = MagicMock(returncode=1) # port not in use - with patch("gateway.platforms.whatsapp._IS_WINDOWS", False), \ - patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_check) as mock_run: + with patch("plugins.platforms.whatsapp.adapter._IS_WINDOWS", False), \ + patch("plugins.platforms.whatsapp.adapter.subprocess.run", return_value=mock_check) as mock_run: _kill_port_process(3000) calls = [c.args[0] for c in mock_run.call_args_list] @@ -499,10 +499,10 @@ class TestKillPortProcess: assert ["fuser", "-k", "3000/tcp"] not in calls def test_suppresses_exceptions(self): - from gateway.platforms.whatsapp import _kill_port_process + from plugins.platforms.whatsapp.adapter import _kill_port_process - with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \ - patch("gateway.platforms.whatsapp.subprocess.run", side_effect=OSError("no netstat")): + with patch("plugins.platforms.whatsapp.adapter._IS_WINDOWS", True), \ + patch("plugins.platforms.whatsapp.adapter.subprocess.run", side_effect=OSError("no netstat")): _kill_port_process(3000) # must not raise @@ -526,9 +526,9 @@ class TestHttpSessionLifecycle: adapter._running = True adapter._session_lock_identity = None - with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \ - patch("gateway.platforms.whatsapp.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, \ - patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock): + with patch("plugins.platforms.whatsapp.adapter._IS_WINDOWS", True), \ + patch("plugins.platforms.whatsapp.adapter.subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, \ + patch("plugins.platforms.whatsapp.adapter.asyncio.sleep", new_callable=AsyncMock): await adapter.disconnect() mock_run.assert_called_once_with( @@ -634,7 +634,7 @@ class TestNoCredsPreflight: @pytest.mark.asyncio async def test_connect_returns_false_when_no_creds(self, tmp_path): - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) adapter.platform = Platform.WHATSAPP @@ -654,7 +654,7 @@ class TestNoCredsPreflight: adapter._fatal_error_retryable = True with patch( - "gateway.platforms.whatsapp.check_whatsapp_requirements", + "plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True, ): result = await adapter.connect() @@ -670,7 +670,7 @@ class TestNoCredsPreflight: connect() proceeds to the bridge bootstrap path. We don't fully simulate the bridge here — we just verify no fast-fail occurs. """ - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) adapter.platform = Platform.WHATSAPP @@ -692,7 +692,7 @@ class TestNoCredsPreflight: adapter._acquire_platform_lock = MagicMock(return_value=False) with patch( - "gateway.platforms.whatsapp.check_whatsapp_requirements", + "plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True, ): result = await adapter.connect() diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index dd88728865b..9d5063882d4 100644 --- a/tests/gateway/test_whatsapp_formatting.py +++ b/tests/gateway/test_whatsapp_formatting.py @@ -20,7 +20,7 @@ from gateway.config import Platform def _make_adapter(): """Create a WhatsAppAdapter with test attributes (bypass __init__).""" - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) adapter.platform = Platform.WHATSAPP @@ -153,7 +153,7 @@ class TestMessageLimits: """WhatsApp message length limits.""" def test_max_message_length_is_practical(self): - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter assert WhatsAppAdapter.MAX_MESSAGE_LENGTH == 4096 def test_chunk_limit_reserves_default_self_chat_prefix(self, monkeypatch): diff --git a/tests/gateway/test_whatsapp_group_gating.py b/tests/gateway/test_whatsapp_group_gating.py index 75560633839..cee3894d6e0 100644 --- a/tests/gateway/test_whatsapp_group_gating.py +++ b/tests/gateway/test_whatsapp_group_gating.py @@ -6,7 +6,7 @@ from gateway.config import Platform, PlatformConfig, load_gateway_config def _make_adapter(require_mention=None, mention_patterns=None, free_response_chats=None, dm_policy=None, allow_from=None, group_policy=None, group_allow_from=None): - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter extra = {} if require_mention is not None: @@ -358,7 +358,7 @@ def test_real_dm_still_processed_after_broadcast_filter(): def test_is_broadcast_chat_helper_recognizes_common_jids(): - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter assert WhatsAppAdapter._is_broadcast_chat("status@broadcast") is True assert WhatsAppAdapter._is_broadcast_chat("STATUS@BROADCAST") is True diff --git a/tests/gateway/test_whatsapp_reply_prefix.py b/tests/gateway/test_whatsapp_reply_prefix.py index 61f37332665..867022ac739 100644 --- a/tests/gateway/test_whatsapp_reply_prefix.py +++ b/tests/gateway/test_whatsapp_reply_prefix.py @@ -87,19 +87,19 @@ class TestAdapterInit: """Test that WhatsAppAdapter reads reply_prefix from config.extra.""" def test_reply_prefix_from_extra(self): - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter config = PlatformConfig(enabled=True, extra={"reply_prefix": "Bot\\n"}) adapter = WhatsAppAdapter(config) assert adapter._reply_prefix == "Bot\\n" def test_reply_prefix_default_none(self): - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter config = PlatformConfig(enabled=True) adapter = WhatsAppAdapter(config) assert adapter._reply_prefix is None def test_reply_prefix_empty_string(self): - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter config = PlatformConfig(enabled=True, extra={"reply_prefix": ""}) adapter = WhatsAppAdapter(config) assert adapter._reply_prefix == "" diff --git a/tests/gateway/test_whatsapp_stale_bridge.py b/tests/gateway/test_whatsapp_stale_bridge.py index d55931ceaf7..2447b7f0840 100644 --- a/tests/gateway/test_whatsapp_stale_bridge.py +++ b/tests/gateway/test_whatsapp_stale_bridge.py @@ -41,7 +41,7 @@ class _AsyncCM: def _make_adapter(bridge_script: str = "/tmp/test-bridge.js", session_path: Path = Path("/tmp/test-wa-session")): """Create a WhatsAppAdapter with test attributes (bypass __init__).""" - from gateway.platforms.whatsapp import WhatsAppAdapter + from plugins.platforms.whatsapp.adapter import WhatsAppAdapter adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) adapter.platform = Platform.WHATSAPP @@ -93,7 +93,7 @@ def _setup_bridge_dir(tmp_path: Path) -> Path: def _fresh_node_modules(bridge_dir: Path) -> None: """Create node_modules with a stamp matching the current package.json.""" - from gateway.platforms.whatsapp import _file_content_hash + from plugins.platforms.whatsapp.adapter import _file_content_hash nm = bridge_dir / "node_modules" nm.mkdir() @@ -104,7 +104,7 @@ def _fresh_node_modules(bridge_dir: Path) -> None: class TestFileContentHash: def test_hashes_file(self, tmp_path): - from gateway.platforms.whatsapp import _file_content_hash + from plugins.platforms.whatsapp.adapter import _file_content_hash f = tmp_path / "x.js" f.write_text("abc") @@ -113,7 +113,7 @@ class TestFileContentHash: assert h == _file_content_hash(f) # deterministic def test_changes_with_content(self, tmp_path): - from gateway.platforms.whatsapp import _file_content_hash + from plugins.platforms.whatsapp.adapter import _file_content_hash f = tmp_path / "x.js" f.write_text("abc") @@ -122,7 +122,7 @@ class TestFileContentHash: assert _file_content_hash(f) != h1 def test_missing_file_returns_empty(self, tmp_path): - from gateway.platforms.whatsapp import _file_content_hash + from plugins.platforms.whatsapp.adapter import _file_content_hash assert _file_content_hash(tmp_path / "nope.js") == "" @@ -130,7 +130,7 @@ class TestFileContentHash: """Python and Node must compute the same hash for the same bytes.""" import hashlib - from gateway.platforms.whatsapp import _file_content_hash + from plugins.platforms.whatsapp.adapter import _file_content_hash f = tmp_path / "bridge.js" f.write_bytes(b"const x = 1;\n") @@ -142,7 +142,7 @@ class TestFileContentHash: class TestStaleBridgeHandshake: @pytest.mark.asyncio async def test_reuses_bridge_when_hash_matches(self, tmp_path): - from gateway.platforms.whatsapp import _file_content_hash + from plugins.platforms.whatsapp.adapter import _file_content_hash bridge_dir = _setup_bridge_dir(tmp_path) _fresh_node_modules(bridge_dir) @@ -153,9 +153,9 @@ class TestStaleBridgeHandshake: disk_hash = _file_content_hash(bridge_dir / "bridge.js") mock_client = _mock_health({"status": "connected", "scriptHash": disk_hash}) - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch("aiohttp.ClientSession", mock_client), \ - patch("gateway.platforms.whatsapp.asyncio.create_task") as mock_task, \ + patch("plugins.platforms.whatsapp.adapter.asyncio.create_task") as mock_task, \ patch("subprocess.Popen") as mock_popen, \ patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True), \ patch.object(adapter, "_mark_connected", create=True): @@ -183,11 +183,11 @@ class TestStaleBridgeHandshake: mock_proc.poll.return_value = 1 mock_proc.returncode = 1 - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch("aiohttp.ClientSession", mock_client), \ - patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \ - patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \ - patch("gateway.platforms.whatsapp._kill_port_process") as mock_kill_port, \ + patch("plugins.platforms.whatsapp.adapter.asyncio.sleep", new_callable=AsyncMock), \ + patch("plugins.platforms.whatsapp.adapter._kill_stale_bridge_by_pidfile"), \ + patch("plugins.platforms.whatsapp.adapter._kill_port_process") as mock_kill_port, \ patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \ patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True): result = await adapter.connect() @@ -211,11 +211,11 @@ class TestStaleBridgeHandshake: mock_proc.poll.return_value = 1 mock_proc.returncode = 1 - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch("aiohttp.ClientSession", mock_client), \ - patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \ - patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \ - patch("gateway.platforms.whatsapp._kill_port_process"), \ + patch("plugins.platforms.whatsapp.adapter.asyncio.sleep", new_callable=AsyncMock), \ + patch("plugins.platforms.whatsapp.adapter._kill_stale_bridge_by_pidfile"), \ + patch("plugins.platforms.whatsapp.adapter._kill_port_process"), \ patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \ patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True): await adapter.connect() @@ -236,11 +236,11 @@ class TestDepRefreshStamp: mock_proc.poll.return_value = 1 mock_proc.returncode = 1 - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \ - patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \ - patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \ - patch("gateway.platforms.whatsapp._kill_port_process"), \ + patch("plugins.platforms.whatsapp.adapter.asyncio.sleep", new_callable=AsyncMock), \ + patch("plugins.platforms.whatsapp.adapter._kill_stale_bridge_by_pidfile"), \ + patch("plugins.platforms.whatsapp.adapter._kill_port_process"), \ patch("subprocess.run") as mock_run, \ patch("subprocess.Popen", return_value=mock_proc), \ patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True): @@ -262,11 +262,11 @@ class TestDepRefreshStamp: mock_proc.poll.return_value = 1 mock_proc.returncode = 1 - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \ - patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \ - patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \ - patch("gateway.platforms.whatsapp._kill_port_process"), \ + patch("plugins.platforms.whatsapp.adapter.asyncio.sleep", new_callable=AsyncMock), \ + patch("plugins.platforms.whatsapp.adapter._kill_stale_bridge_by_pidfile"), \ + patch("plugins.platforms.whatsapp.adapter._kill_port_process"), \ patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, \ patch("subprocess.Popen", return_value=mock_proc), \ patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True): @@ -275,7 +275,7 @@ class TestDepRefreshStamp: mock_run.assert_called_once() assert "install" in mock_run.call_args[0][0] # Stamp updated to the new package.json hash - from gateway.platforms.whatsapp import _file_content_hash + from plugins.platforms.whatsapp.adapter import _file_content_hash stamp = (bridge_dir / "node_modules" / ".hermes-pkg-hash").read_text().strip() assert stamp == _file_content_hash(bridge_dir / "package.json") @@ -295,11 +295,11 @@ class TestDepRefreshStamp: (bridge_dir / "node_modules").mkdir(exist_ok=True) return MagicMock(returncode=0) - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \ - patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \ - patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \ - patch("gateway.platforms.whatsapp._kill_port_process"), \ + patch("plugins.platforms.whatsapp.adapter.asyncio.sleep", new_callable=AsyncMock), \ + patch("plugins.platforms.whatsapp.adapter._kill_stale_bridge_by_pidfile"), \ + patch("plugins.platforms.whatsapp.adapter._kill_port_process"), \ patch("subprocess.run", side_effect=_npm_install) as mock_run, \ patch("subprocess.Popen", return_value=mock_proc), \ patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True): @@ -321,11 +321,11 @@ class TestCacheDirEnvPassthrough: mock_proc.poll.return_value = 1 mock_proc.returncode = 1 - with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \ + with patch("plugins.platforms.whatsapp.adapter.check_whatsapp_requirements", return_value=True), \ patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \ - patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \ - patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \ - patch("gateway.platforms.whatsapp._kill_port_process"), \ + patch("plugins.platforms.whatsapp.adapter.asyncio.sleep", new_callable=AsyncMock), \ + patch("plugins.platforms.whatsapp.adapter._kill_stale_bridge_by_pidfile"), \ + patch("plugins.platforms.whatsapp.adapter._kill_port_process"), \ patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \ patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True): await adapter.connect() diff --git a/tests/gateway/test_whatsapp_text_batching.py b/tests/gateway/test_whatsapp_text_batching.py index 4258617c678..a4d2816c389 100644 --- a/tests/gateway/test_whatsapp_text_batching.py +++ b/tests/gateway/test_whatsapp_text_batching.py @@ -12,7 +12,7 @@ import asyncio from gateway.config import Platform, PlatformConfig from gateway.platforms.base import MessageEvent, MessageType -from gateway.platforms.whatsapp import WhatsAppAdapter +from plugins.platforms.whatsapp.adapter import WhatsAppAdapter from gateway.session import SessionSource diff --git a/tests/gateway/test_ws_auth_retry.py b/tests/gateway/test_ws_auth_retry.py index ada5799538b..997afed733b 100644 --- a/tests/gateway/test_ws_auth_retry.py +++ b/tests/gateway/test_ws_auth_retry.py @@ -123,7 +123,7 @@ class TestMatrixSyncAuthRetry: nio_mock.SyncError = SyncError - from gateway.platforms.matrix import MatrixAdapter + from plugins.platforms.matrix.adapter import MatrixAdapter adapter = MatrixAdapter.__new__(MatrixAdapter) adapter._closing = False @@ -154,7 +154,7 @@ class TestMatrixSyncAuthRetry: def test_exception_with_401_stops_loop(self): """An exception containing '401' should stop syncing.""" - from gateway.platforms.matrix import MatrixAdapter + from plugins.platforms.matrix.adapter import MatrixAdapter adapter = MatrixAdapter.__new__(MatrixAdapter) adapter._closing = False @@ -189,7 +189,7 @@ class TestMatrixSyncAuthRetry: def test_transient_error_retries(self): """A transient error should retry (not stop immediately).""" - from gateway.platforms.matrix import MatrixAdapter + from plugins.platforms.matrix.adapter import MatrixAdapter adapter = MatrixAdapter.__new__(MatrixAdapter) adapter._closing = False diff --git a/tests/hermes_cli/test_logs.py b/tests/hermes_cli/test_logs.py index 52fa63e3ec9..c80f9ffb575 100644 --- a/tests/hermes_cli/test_logs.py +++ b/tests/hermes_cli/test_logs.py @@ -87,8 +87,8 @@ class TestExtractLoggerName: assert _extract_logger_name(line) == "gateway.run" def test_nested_logger(self): - line = "2026-04-11 10:23:45 INFO gateway.platforms.telegram: connected" - assert _extract_logger_name(line) == "gateway.platforms.telegram" + line = "2026-04-11 10:23:45 INFO plugins.platforms.telegram.adapter: connected" + assert _extract_logger_name(line) == "plugins.platforms.telegram.adapter" def test_warning_level(self): line = "2026-04-11 10:23:45 WARNING tools.terminal_tool: timeout" @@ -116,7 +116,17 @@ class TestLineMatchesComponent: assert _line_matches_component(line, ("gateway",)) def test_gateway_nested(self): - line = "2026-04-11 10:23:45 INFO gateway.platforms.telegram: msg" + # Migrated platform adapters log under plugins.platforms.* (#41112) and + # must still resolve to the gateway component. Use the real expanded + # gateway prefixes (COMPONENT_PREFIXES["gateway"]) the CLI passes, not a + # bare ("gateway",), since the logger name no longer literally starts + # with "gateway". + from hermes_logging import COMPONENT_PREFIXES + line = "2026-04-11 10:23:45 INFO plugins.platforms.telegram.adapter: msg" + assert _line_matches_component(line, COMPONENT_PREFIXES["gateway"]) + + def test_gateway_core_nested(self): + line = "2026-04-11 10:23:45 INFO gateway.run: msg" assert _line_matches_component(line, ("gateway",)) def test_tools_component(self): diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index abd26a0a306..ad69bd116f4 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -164,6 +164,12 @@ def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch, monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) + # Keep the checklist pre-selection (so matrix stays "configured" and the + # post-config service guidance runs), but stub the migrated plugins' + # interactive_setup so their wizards don't read real stdin. #41112. + monkeypatch.setattr(setup_mod, "prompt_checklist", lambda _q, _items, pre=(), **k: list(pre)) + import hermes_cli.gateway as _gw_mod + monkeypatch.setattr(_gw_mod, "_configure_platform", lambda *a, **k: None) monkeypatch.setattr("platform.system", lambda: "Linux") monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) @@ -203,6 +209,12 @@ def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys): monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) + # Keep the checklist pre-selection (so matrix stays "configured" and the + # post-config service guidance runs), but stub the migrated plugins' + # interactive_setup so their wizards don't read real stdin. #41112. + monkeypatch.setattr(setup_mod, "prompt_checklist", lambda _q, _items, pre=(), **k: list(pre)) + import hermes_cli.gateway as _gw_mod + monkeypatch.setattr(_gw_mod, "_configure_platform", lambda *a, **k: None) monkeypatch.setattr("platform.system", lambda: "Linux") monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) @@ -479,33 +491,6 @@ def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tm assert config["terminal"]["modal_mode"] == "direct" -def test_setup_slack_saves_home_channel(monkeypatch): - """_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one.""" - saved = {} - prompts = iter(["xoxb-test-token", "xapp-test-token", "", "C01ABC2DE3F"]) +# test_setup_slack_* moved to tests/gateway/test_slack_plugin_setup.py — the +# _setup_slack wizard migrated to the slack plugin's interactive_setup (#41112). - monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "") - monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v})) - monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts)) - monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False) - monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None) - - setup_mod._setup_slack() - - assert saved.get("SLACK_HOME_CHANNEL") == "C01ABC2DE3F" - - -def test_setup_slack_home_channel_empty_not_saved(monkeypatch): - """_setup_slack() does not save SLACK_HOME_CHANNEL when left blank.""" - saved = {} - prompts = iter(["xoxb-test-token", "xapp-test-token", "", ""]) - - monkeypatch.setattr(setup_mod, "get_env_value", lambda key: "") - monkeypatch.setattr(setup_mod, "save_env_value", lambda k, v: saved.update({k: v})) - monkeypatch.setattr(setup_mod, "prompt", lambda *_a, **_kw: next(prompts)) - monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *_a, **_kw: False) - monkeypatch.setattr(setup_mod, "_write_slack_manifest_and_instruct", lambda: None) - - setup_mod._setup_slack() - - assert "SLACK_HOME_CHANNEL" not in saved diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py index 0d1a17ab267..e9cc6052500 100644 --- a/tests/test_hermes_logging.py +++ b/tests/test_hermes_logging.py @@ -311,7 +311,7 @@ class TestGatewayMode: """gateway.log captures records from gateway.* loggers.""" hermes_logging.setup_logging(hermes_home=hermes_home, mode="gateway") - gw_logger = logging.getLogger("gateway.platforms.telegram") + gw_logger = logging.getLogger("plugins.platforms.telegram.adapter") gw_logger.info("telegram connected") for h in logging.getLogger().handlers: @@ -558,9 +558,14 @@ class TestComponentFilter: assert f.filter(record) is True def test_passes_nested_matching_prefix(self): - f = hermes_logging._ComponentFilter(("gateway",)) + # Migrated platform adapters log under plugins.platforms.* (#41112); + # the gateway component filter is built from COMPONENT_PREFIXES["gateway"] + # (which includes "plugins.platforms"), so such records pass. + f = hermes_logging._ComponentFilter( + hermes_logging.COMPONENT_PREFIXES["gateway"] + ) record = logging.LogRecord( - "gateway.platforms.telegram", logging.INFO, "", 0, "msg", (), None + "plugins.platforms.telegram.adapter", logging.INFO, "", 0, "msg", (), None ) assert f.filter(record) is True @@ -592,10 +597,16 @@ class TestComponentPrefixes: def test_gateway_prefix(self): assert "gateway" in hermes_logging.COMPONENT_PREFIXES - # The gateway component captures both core gateway logs and the - # hermes_plugins facility (plugin-installed gateway adapters log - # under that prefix). - assert ("gateway", "hermes_plugins") == hermes_logging.COMPONENT_PREFIXES["gateway"] + # The gateway component captures core gateway logs, the hermes_plugins + # facility, and plugins.platforms (messaging-platform adapters that + # migrated out of gateway/platforms/ into bundled plugins, #41112). + # Assert the required members as an invariant rather than an exact + # tuple snapshot so adding future gateway-component prefixes doesn't + # break this test. + gateway_prefixes = hermes_logging.COMPONENT_PREFIXES["gateway"] + assert "gateway" in gateway_prefixes + assert "hermes_plugins" in gateway_prefixes + assert "plugins.platforms" in gateway_prefixes def test_agent_prefix(self): prefixes = hermes_logging.COMPONENT_PREFIXES["agent"] diff --git a/tests/tools/test_send_message_missing_platforms.py b/tests/tools/test_send_message_missing_platforms.py index 05d1023bcfa..c730fb01f8f 100644 --- a/tests/tools/test_send_message_missing_platforms.py +++ b/tests/tools/test_send_message_missing_platforms.py @@ -5,10 +5,29 @@ import os from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch -from tools.send_message_tool import ( - _send_dingtalk, - _send_matrix, +# ``_send_dingtalk`` and ``_send_matrix`` moved into their bundled plugins +# (``plugins/platforms//adapter.py::_standalone_send``) in #41112. Keep +# thin pre-migration-shaped shims so existing test bodies work unchanged. +from plugins.platforms.dingtalk.adapter import ( + _standalone_send as _dingtalk_standalone_send, ) +from plugins.platforms.matrix.adapter import ( + _standalone_send as _matrix_standalone_send, +) + + +async def _send_dingtalk(extra, chat_id, message): + """Pre-migration ``(extra, chat_id, message)`` shim around the dingtalk + plugin's ``_standalone_send(pconfig, chat_id, message)``.""" + pconfig = SimpleNamespace(token=None, extra=extra or {}) + return await _dingtalk_standalone_send(pconfig, chat_id, message) + + +async def _send_matrix(token, extra, chat_id, message): + """Pre-migration ``(token, extra, chat_id, message)`` shim around the matrix + plugin's ``_standalone_send(pconfig, chat_id, message)``.""" + pconfig = SimpleNamespace(token=token, extra=extra or {}) + return await _matrix_standalone_send(pconfig, chat_id, message) # ``_send_mattermost`` moved into the mattermost plugin # (``plugins/platforms/mattermost/adapter.py::_standalone_send``). Keep a diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 9811f75d67e..dcdb8f83266 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -115,6 +115,67 @@ class _patch_discord_sender: return False +def _slack_entry(): + """Return the live Slack PlatformEntry, importing lazily so plugin + discovery is forced exactly once and patches survive across tests.""" + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import platform_registry + discover_plugins() + return platform_registry.get("slack") + + +def _make_recording_slack_sender(): + """Return a plain AsyncMock used to record the formatted Slack text. + + Paired with ``_patch_slack_standalone_sender``, which wraps it so the + production ``(pconfig, chat_id, raw_text, thread_id=...)`` call is + translated into the pre-migration ``(token, chat_id, formatted_text, + thread_ts=...)`` shape — applying ``SlackAdapter.format_message`` exactly + as the real plugin ``_standalone_send`` does. Tests can then assert on + ``send.await_args.args[2]`` (the formatted mrkdwn) as before. + """ + return AsyncMock(return_value={"success": True, "platform": "slack", "message_id": "1"}) + + +class _patch_slack_standalone_sender: + """Patch the Slack registry entry's ``standalone_sender_fn`` with a wrapper + that replicates the plugin's mrkdwn formatting then delegates to the given + mock in the pre-migration call shape. Mirrors ``_patch_discord_sender``. + + Slack mrkdwn formatting moved INTO the plugin's ``_standalone_send`` when + the adapter migrated (#41112) — previously ``_send_to_platform`` formatted + the message before calling the old ``_send_slack`` helper. This wrapper + keeps the "markdown → Slack mrkdwn reaches the wire" behavior tests valid. + """ + + def __init__(self, mock): + self._mock = mock + self._entry = None + self._original = None + + async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, **_kw): + from plugins.platforms.slack.adapter import SlackAdapter + formatted = message + if message: + try: + formatted = SlackAdapter.__new__(SlackAdapter).format_message(message) + except Exception: + pass + token = getattr(pconfig, "token", None) + return await self._mock(token, chat_id, formatted, thread_ts=thread_id) + + def __enter__(self): + self._entry = _slack_entry() + self._original = self._entry.standalone_sender_fn + self._entry.standalone_sender_fn = self._adapter + return self._mock + + def __exit__(self, exc_type, exc, tb): + if self._entry is not None: + self._entry.standalone_sender_fn = self._original + return False + + def _run_async_immediately(coro): return asyncio.run(coro) @@ -617,12 +678,12 @@ class TestSendToPlatformChunking: def test_slack_messages_are_formatted_before_send(self, monkeypatch): _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) + send = _make_recording_slack_sender() - with patch("tools.send_message_tool._send_slack", send): + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -643,11 +704,11 @@ class TestSendToPlatformChunking: def test_slack_bold_italic_formatted_before_send(self, monkeypatch): """Bold+italic ***text*** survives tool-layer formatting.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_slack", send): + send = _make_recording_slack_sender() + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -663,11 +724,11 @@ class TestSendToPlatformChunking: def test_slack_blockquote_formatted_before_send(self, monkeypatch): """Blockquote '>' markers must survive formatting (not escaped to '>').""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_slack", send): + send = _make_recording_slack_sender() + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -685,10 +746,10 @@ class TestSendToPlatformChunking: def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch): """Pre-escaped HTML entities survive tool-layer formatting without double-escaping.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_slack", send): + send = _make_recording_slack_sender() + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -706,10 +767,10 @@ class TestSendToPlatformChunking: def test_slack_url_with_parens_formatted_before_send(self, monkeypatch): """Wikipedia-style URL with parens survives tool-layer formatting.""" _ensure_slack_mock(monkeypatch) - import gateway.platforms.slack as slack_mod + import plugins.platforms.slack.adapter as slack_mod monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) - send = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_slack", send): + send = _make_recording_slack_sender() + with _patch_slack_standalone_sender(send): result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -771,19 +832,30 @@ class TestSendToPlatformChunking: doc_path.unlink(missing_ok=True) def test_matrix_text_only_uses_lightweight_path(self): - """Text-only Matrix sends should NOT go through the heavy adapter path.""" + """Text-only Matrix sends should NOT go through the heavy adapter path. + + Post-#41112 the lightweight text path flows through the matrix plugin's + registry standalone_sender_fn (not the via-adapter media path).""" + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import platform_registry + discover_plugins() helper = AsyncMock() lightweight = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:ex.com", "message_id": "$txt"}) - with patch("tools.send_message_tool._send_matrix_via_adapter", helper), \ - patch("tools.send_message_tool._send_matrix", lightweight): - result = asyncio.run( - _send_to_platform( - Platform.MATRIX, - SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), - "!room:ex.com", - "just text, no files", + matrix_entry = platform_registry.get("matrix") + original_sender = matrix_entry.standalone_sender_fn + matrix_entry.standalone_sender_fn = lightweight + try: + with patch("tools.send_message_tool._send_matrix_via_adapter", helper): + result = asyncio.run( + _send_to_platform( + Platform.MATRIX, + SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), + "!room:ex.com", + "just text, no files", + ) ) - ) + finally: + matrix_entry.standalone_sender_fn = original_sender assert result["success"] is True helper.assert_not_awaited() @@ -817,7 +889,7 @@ class TestSendToPlatformChunking: fake_module = SimpleNamespace(MatrixAdapter=FakeAdapter) - with patch.dict(sys.modules, {"gateway.platforms.matrix": fake_module}): + with patch.dict(sys.modules, {"plugins.platforms.matrix.adapter": fake_module}): result = asyncio.run( _send_matrix_via_adapter( SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), @@ -848,10 +920,19 @@ class TestSendToPlatformChunking: class TestSendToPlatformWhatsapp: def test_whatsapp_routes_via_local_bridge_sender(self): + """WhatsApp delivery routes through the plugin's registry + standalone_sender_fn (was tools.send_message_tool._send_whatsapp + before the #41112 plugin migration).""" + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import platform_registry + discover_plugins() chat_id = "test-user@lid" async_mock = AsyncMock(return_value={"success": True, "platform": "whatsapp", "chat_id": chat_id, "message_id": "abc123"}) - with patch("tools.send_message_tool._send_whatsapp", async_mock): + wa_entry = platform_registry.get("whatsapp") + original_sender = wa_entry.standalone_sender_fn + wa_entry.standalone_sender_fn = async_mock + try: result = asyncio.run( _send_to_platform( Platform.WHATSAPP, @@ -860,9 +941,15 @@ class TestSendToPlatformWhatsapp: "hello from hermes", ) ) + finally: + wa_entry.standalone_sender_fn = original_sender assert result["success"] is True - async_mock.assert_awaited_once_with({"bridge_port": 3000}, chat_id, "hello from hermes") + # _registry_standalone_send passes (pconfig, chat_id, message, thread_id=None) + async_mock.assert_awaited_once() + _call = async_mock.await_args + assert _call.args[1] == chat_id + assert _call.args[2] == "hello from hermes" class TestSendTelegramHtmlDetection: @@ -1707,7 +1794,8 @@ class TestSendToPlatformDiscordMedia: class TestSendMatrixUrlEncoding: - """_send_matrix URL-encodes Matrix room IDs in the API path.""" + """The matrix plugin's _standalone_send URL-encodes Matrix room IDs in the + API path (was tools.send_message_tool._send_matrix before #41112).""" def test_room_id_is_percent_encoded_in_url(self): """Matrix room IDs with ! and : are percent-encoded in the PUT URL.""" @@ -1724,11 +1812,10 @@ class TestSendMatrixUrlEncoding: mock_session.__aexit__ = AsyncMock(return_value=None) with patch("aiohttp.ClientSession", return_value=mock_session): - from tools.send_message_tool import _send_matrix + from plugins.platforms.matrix.adapter import _standalone_send result = asyncio.get_event_loop().run_until_complete( - _send_matrix( - "test_token", - {"homeserver": "https://matrix.example.org"}, + _standalone_send( + SimpleNamespace(token="test_token", extra={"homeserver": "https://matrix.example.org"}), "!HLOQwxYGgFPMPJUSNR:matrix.org", "hello", ) diff --git a/tests/tools/test_signal_media.py b/tests/tools/test_signal_media.py index 6d1bc2112eb..db40d45e331 100644 --- a/tests/tools/test_signal_media.py +++ b/tests/tools/test_signal_media.py @@ -156,13 +156,23 @@ class TestSendSignalMediaWarningMessages: if not hasattr(httpx, 'Proxy') or not hasattr(httpx, 'URL'): pytest.skip("httpx type annotations incompatible with telegram library") from tools.send_message_tool import _send_to_platform + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import platform_registry config = MagicMock() config.platforms = {Platform.SLACK: MagicMock(enabled=True)} config.get_home_channel.return_value = None - # Mock _send_slack so it succeeds -> then warning gets attached to result - with patch("tools.send_message_tool._send_slack", new=AsyncMock(return_value={"success": True})): + # Slack migrated to a bundled plugin (#41112) — delivery now flows + # through the registry's standalone_sender_fn instead of the old + # tools.send_message_tool._send_slack helper. Patch the registry entry's + # sender so the slack send succeeds and the media-omitted warning (which + # must mention signal) gets attached to the result. + discover_plugins() + slack_entry = platform_registry.get("slack") + original_sender = slack_entry.standalone_sender_fn + slack_entry.standalone_sender_fn = AsyncMock(return_value={"success": True}) + try: result = asyncio.run( _send_to_platform( Platform.SLACK, @@ -172,6 +182,8 @@ class TestSendSignalMediaWarningMessages: media_files=[("/tmp/test.png", False)] ) ) + finally: + slack_entry.standalone_sender_fn = original_sender assert result.get("warnings") is not None # Check that the warning mentions signal as supported diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index a87c39e4294..b654d8ff2ec 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -732,37 +732,30 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, return await _send_weixin(pconfig, chat_id, message, media_files=media_files) from gateway.platforms.base import BasePlatformAdapter, utf16_len - from gateway.platforms.slack import SlackAdapter # Telegram adapter import is optional (requires python-telegram-bot) try: - from gateway.platforms.telegram import TelegramAdapter + from plugins.platforms.telegram.adapter import TelegramAdapter _telegram_available = True except ImportError: _telegram_available = False - # Feishu adapter import is optional (requires lark-oapi) - try: - from gateway.platforms.feishu import FeishuAdapter - _feishu_available = True - except ImportError: - _feishu_available = False + # Feishu adapter migrated to a plugin (#41112); its max_message_length + # (8000) now flows through the registry fallback below. - if platform == Platform.SLACK and message: - try: - slack_adapter = SlackAdapter.__new__(SlackAdapter) - message = slack_adapter.format_message(message) - except Exception: - logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True) + media_files = media_files or [] + + # Slack mrkdwn formatting is applied inside the slack plugin's + # _standalone_send (the registry standalone_sender_fn) rather than here — + # the SlackAdapter moved to plugins/platforms/slack/ in #41112. # Platform message length limits (from adapter class attributes for - # built-in platforms; from PlatformEntry.max_message_length for plugins). + # built-in platforms; from PlatformEntry.max_message_length for plugins, + # resolved via the registry fallback below — covers Slack and Feishu, both + # migrated to plugins in #41112). _MAX_LENGTHS = { Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096, - Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, } - if _feishu_available: - _MAX_LENGTHS[Platform.FEISHU] = FeishuAdapter.MAX_MESSAGE_LENGTH # Check plugin registry for max_message_length if platform not in _MAX_LENGTHS: @@ -879,12 +872,19 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result - # --- Feishu: native media attachment support via adapter --- + # --- Feishu: native media attachment support via the registry's + # standalone_sender_fn (plugins/platforms/feishu/adapter.py::_standalone_send). #41112 if platform == Platform.FEISHU and media_files: + from gateway.platform_registry import platform_registry as _pr_feishu + from hermes_cli.plugins import discover_plugins as _dp_feishu + _dp_feishu() + _feishu_entry = _pr_feishu.get("feishu") + if _feishu_entry is None or _feishu_entry.standalone_sender_fn is None: + return {"error": "Feishu plugin not registered or missing standalone_sender_fn"} last_result = None for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) - result = await _send_feishu( + result = await _feishu_entry.standalone_sender_fn( pconfig, chat_id, chunk, @@ -914,23 +914,33 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = None for chunk in chunks: if platform == Platform.SLACK: - result = await _send_slack(pconfig.token, chat_id, chunk, thread_ts=thread_id) + # Slack migrated to a bundled plugin (#41112); delivery flows + # through the registry's standalone_sender_fn, which applies + # mrkdwn formatting and posts via the Slack Web API. + from gateway.platform_registry import platform_registry + _slack_entry = platform_registry.get("slack") + if _slack_entry is None or _slack_entry.standalone_sender_fn is None: + result = {"error": "Slack plugin not registered or missing standalone_sender_fn"} + else: + result = await _slack_entry.standalone_sender_fn( + pconfig, chat_id, chunk, thread_id=thread_id + ) elif platform == Platform.WHATSAPP: - result = await _send_whatsapp(pconfig.extra, chat_id, chunk) + result = await _registry_standalone_send("whatsapp", pconfig, chat_id, chunk, thread_id) elif platform == Platform.SIGNAL: result = await _send_signal(pconfig.extra, chat_id, chunk) elif platform == Platform.EMAIL: - result = await _send_email(pconfig.extra, chat_id, chunk) + result = await _registry_standalone_send("email", pconfig, chat_id, chunk, thread_id) elif platform == Platform.SMS: - result = await _send_sms(pconfig.api_key, chat_id, chunk) + result = await _registry_standalone_send("sms", pconfig, chat_id, chunk, thread_id) elif platform == Platform.MATRIX: - result = await _send_matrix(pconfig.token, pconfig.extra, chat_id, chunk) + result = await _registry_standalone_send("matrix", pconfig, chat_id, chunk, thread_id) elif platform == Platform.DINGTALK: - result = await _send_dingtalk(pconfig.extra, chat_id, chunk) + result = await _registry_standalone_send("dingtalk", pconfig, chat_id, chunk, thread_id) elif platform == Platform.FEISHU: - result = await _send_feishu(pconfig, chat_id, chunk, thread_id=thread_id) + result = await _registry_standalone_send("feishu", pconfig, chat_id, chunk, thread_id) elif platform == Platform.WECOM: - result = await _send_wecom(pconfig.extra, chat_id, chunk) + result = await _registry_standalone_send("wecom", pconfig, chat_id, chunk, thread_id) elif platform == Platform.BLUEBUBBLES: result = await _send_bluebubbles(pconfig.extra, chat_id, chunk) elif platform == Platform.QQBOT: @@ -992,7 +1002,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No else: # Reuse the gateway adapter's format_message for markdown→MarkdownV2 try: - from gateway.platforms.telegram import TelegramAdapter + from plugins.platforms.telegram.adapter import TelegramAdapter _adapter = TelegramAdapter.__new__(TelegramAdapter) formatted = _adapter.format_message(message) except Exception: @@ -1037,7 +1047,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No # send to a forum group's General topic always errors out # (see issue #22267). try: - from gateway.platforms.telegram import TelegramAdapter + from plugins.platforms.telegram.adapter import TelegramAdapter effective_thread_id = TelegramAdapter._message_thread_id_for_send( str(thread_id) ) @@ -1089,7 +1099,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No ) if not _has_html: try: - from gateway.platforms.telegram import _strip_mdv2 + from plugins.platforms.telegram.adapter import _strip_mdv2 plain = _strip_mdv2(formatted) except Exception: plain = message @@ -1194,57 +1204,28 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No return _error(f"Telegram send failed: {e}") -async def _send_slack(token, chat_id, message, thread_ts=None): - """Send via Slack Web API.""" - 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": message, "mrkdwn": True} - if thread_ts: - payload["thread_ts"] = thread_ts - 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}") +# _send_slack moved to the slack plugin as _standalone_send +# (plugins/platforms/slack/adapter.py), wired via standalone_sender_fn. #41112. -async def _send_whatsapp(extra, chat_id, message): - """Send via the local WhatsApp bridge HTTP API.""" - 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}") +async def _registry_standalone_send(platform_name, pconfig, chat_id, message, thread_id=None): + """Dispatch a one-shot send through a migrated platform plugin's + standalone_sender_fn (registry hook). Used for platforms whose adapter + moved out of gateway/platforms/ into plugins/platforms// (#41112): + the legacy inline ``_send_`` helper now lives in the plugin as + ``_standalone_send`` and is reached via the platform registry. + """ + from gateway.platform_registry import platform_registry + from hermes_cli.plugins import discover_plugins + discover_plugins() # idempotent — ensure the entry is registered + entry = platform_registry.get(platform_name) + if entry is None or entry.standalone_sender_fn is None: + return {"error": f"{platform_name} plugin not registered or missing standalone_sender_fn"} + return await entry.standalone_sender_fn(pconfig, chat_id, message, thread_id=thread_id) + + +# _send_whatsapp moved to plugins/platforms/whatsapp/adapter.py::_standalone_send, +# wired via standalone_sender_fn and reached through _registry_standalone_send. #41112. async def _send_signal(extra, chat_id, message, media_files=None): @@ -1436,143 +1417,20 @@ async def _send_signal(extra, chat_id, message, media_files=None): return _error(f"Signal send failed: {e}") -async def _send_email(extra, chat_id, message): - """Send via SMTP (one-shot, no persistent connection needed).""" - import smtplib - from email.mime.text import MIMEText - - 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: - return _error(f"Email send failed: {e}") +# _send_email moved to plugins/platforms/email/adapter.py::_standalone_send; +# _send_sms moved to plugins/platforms/sms/adapter.py::_standalone_send. Both +# wired via standalone_sender_fn, reached through _registry_standalone_send. #41112. -async def _send_sms(auth_token, chat_id, message): - """Send a single SMS via Twilio REST API. - - Uses HTTP Basic auth (Account SID : Auth Token) and form-encoded POST. - Chunking is handled by _send_to_platform() before this is called. - """ - 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)"} - - # 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) - message = message.strip() - - 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 _error(f"Twilio API error ({resp.status}): {error_msg}") - msg_sid = body.get("sid", "") - return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": msg_sid} - except Exception as e: - return _error(f"SMS send failed: {e}") - - -async def _send_matrix(token, extra, chat_id, message): - """Send via Matrix Client-Server API. - - Converts markdown to HTML for rich rendering in Matrix clients. - Falls back to plain text if the ``markdown`` library is not installed. - """ - 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"} - - # Build message payload with optional HTML formatted_body. - payload = {"msgtype": "m.text", "body": message} - try: - import markdown as _md - html = _md.markdown(message, extensions=["fenced_code", "tables"]) - # Convert h1-h6 to bold for Element X compatibility. - html = re.sub(r"(.*?)", r"\1", 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}") +# _send_matrix moved to plugins/platforms/matrix/adapter.py::_standalone_send, +# wired via standalone_sender_fn and reached through _registry_standalone_send. #41112. +# (_send_matrix_via_adapter below stays — it's the native-media upload path.) async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, thread_id=None): """Send via the Matrix adapter so native Matrix media uploads are preserved.""" try: - from gateway.platforms.matrix import MatrixAdapter + from plugins.platforms.matrix.adapter import MatrixAdapter except ImportError: return {"error": "Matrix dependencies not installed. Run: pip install 'mautrix[encryption]'"} @@ -1629,62 +1487,12 @@ async def _send_matrix_via_adapter(pconfig, chat_id, message, media_files=None, pass -async def _send_dingtalk(extra, chat_id, message): - """Send via DingTalk robot webhook. - - Note: The gateway's DingTalk adapter uses per-session webhook URLs from - incoming messages (dingtalk-stream SDK). For cross-platform send_message - delivery we use a static robot webhook URL instead, which must be - configured via ``DINGTALK_WEBHOOK_URL`` env var or ``webhook_url`` in the - platform's extra config. - """ - 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: - return _error(f"DingTalk send failed: {e}") +# _send_dingtalk moved to plugins/platforms/dingtalk/adapter.py::_standalone_send, +# wired via standalone_sender_fn and reached through _registry_standalone_send. #41112. -async def _send_wecom(extra, chat_id, message): - """Send via WeCom using the adapter's WebSocket send pipeline.""" - try: - from gateway.platforms.wecom import WeComAdapter, check_wecom_requirements - if not check_wecom_requirements(): - return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."} - except ImportError: - return {"error": "WeCom adapter not available."} - - try: - from gateway.config import PlatformConfig - pconfig = PlatformConfig(extra=extra) - adapter = WeComAdapter(pconfig) - connected = await adapter.connect() - if not connected: - return _error(f"WeCom: failed to connect - {adapter.fatal_error_message 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}") +# _send_wecom moved to plugins/platforms/wecom/adapter.py::_standalone_send, +# wired via standalone_sender_fn and reached through _registry_standalone_send. #41112. async def _send_weixin(pconfig, chat_id, message, media_files=None): @@ -1735,61 +1543,9 @@ async def _send_bluebubbles(extra, chat_id, message): return _error(f"BlueBubbles send failed: {e}") -async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=None): - """Send via Feishu/Lark using the adapter's send pipeline.""" - try: - from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE - if not FEISHU_AVAILABLE: - return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"} - from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN - except ImportError: - 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 _IMAGE_EXTS: - last_result = await adapter.send_image_file(chat_id, media_path, metadata=metadata) - elif ext in _VIDEO_EXTS: - last_result = await adapter.send_video(chat_id, media_path, metadata=metadata) - elif ext in _VOICE_EXTS and is_voice: - last_result = await adapter.send_voice(chat_id, media_path, metadata=metadata) - elif ext in _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}") +# _send_feishu moved to plugins/platforms/feishu/adapter.py::_standalone_send, +# wired via standalone_sender_fn and reached through _registry_standalone_send +# (and the feishu media branch above). #41112. def _check_send_message():