diff --git a/gateway/config.py b/gateway/config.py index f130fa7da81a9..5bf8845096cc3 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -422,9 +422,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig": # that rely on the generic ``token or api_key`` check (Telegram, Discord, # Slack, Matrix, Mattermost, HomeAssistant) do not need an entry here. _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")) - ), + # weixin migrated to a bundled plugin (plugins/platforms/weixin/); its + # connection check (account_id + token) is registered via is_connected on + # the PlatformEntry and consulted before the generic token branch. Platform.WHATSAPP: lambda cfg: True, # bridge handles auth Platform.SIGNAL: lambda cfg: bool(cfg.extra.get("http_url")), Platform.EMAIL: lambda cfg: bool(cfg.extra.get("address")), @@ -521,24 +521,13 @@ def get_connected_platforms(self) -> List[Platform]: def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> bool: """Check whether a single platform is sufficiently configured.""" - # Weixin requires both a token and an account_id (checked first so - # the generic token branch doesn't let it through without account_id). - if platform == Platform.WEIXIN: - return bool( - config.extra.get("account_id") - and (config.token or config.extra.get("token")) - ) - - # Generic token/api_key auth covers Telegram, Discord, Slack, etc. - if config.token or config.api_key: - return True - - # Platform-specific check - checker = _PLATFORM_CONNECTED_CHECKERS.get(platform) - if checker is not None: - return checker(config) - - # Plugin-registered platforms + # Plugin-registered platforms: consult the plugin's own is_connected + # FIRST, before the generic token branch. Some plugins (e.g. weixin, + # which migrated to plugins/platforms/weixin/) require more than a bare + # token — weixin needs account_id AND token, and it sets config.token + # during env-enablement, so the generic branch below would wrongly pass + # it on token alone. Letting the plugin's is_connected run first keeps + # that requirement intact without a per-platform special-case here. try: from gateway.platform_registry import platform_registry entry = platform_registry.get(platform.value) @@ -547,10 +536,18 @@ def _is_platform_connected(self, platform: Platform, config: PlatformConfig) -> return entry.is_connected(config) if entry.validate_config is not None: return entry.validate_config(config) - return True except Exception: pass # Registry not yet initialised during early import + # Generic token/api_key auth covers Telegram, Discord, Slack, etc. + if config.token or config.api_key: + return True + + # Platform-specific check + checker = _PLATFORM_CONNECTED_CHECKERS.get(platform) + if checker is not None: + return checker(config) + return False def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: diff --git a/gateway/run.py b/gateway/run.py index 8bf024b9886b8..5fee4aecddbdd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6945,12 +6945,8 @@ def _create_adapter( return None return WeComAdapter(config) - elif platform == Platform.WEIXIN: - from gateway.platforms.weixin import WeixinAdapter, check_weixin_requirements - if not check_weixin_requirements(): - logger.warning("Weixin: aiohttp/cryptography not installed") - return None - return WeixinAdapter(config) + # weixin migrated to a bundled plugin (plugins/platforms/weixin/); + # the platform_registry check at the top of this method creates it. elif platform == Platform.MATRIX: from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements @@ -14977,7 +14973,7 @@ async def _handle_deny_command(self, event: MessageEvent) -> str: Platform.TELEGRAM, Platform.SLACK, Platform.WHATSAPP, Platform.SIGNAL, Platform.MATRIX, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, - Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL, + Platform.FEISHU, Platform.WECOM, Platform.WECOM_CALLBACK, Platform.BLUEBUBBLES, Platform.QQBOT, Platform.LOCAL, }) async def _handle_debug_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 03228004053d8..beb5d2e60b351 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4223,12 +4223,9 @@ def _atexit_hook() -> None: }, ], }, - { - "key": "weixin", - "label": "Weixin / WeChat", - "emoji": "💬", - "token_var": "WEIXIN_ACCOUNT_ID", - }, + # weixin migrated to a bundled plugin (plugins/platforms/weixin/); its + # setup wizard is registered via setup_fn on the PlatformEntry and + # surfaced through _all_platforms() + _configure_platform(). { "key": "bluebubbles", "label": "BlueBubbles (iMessage)", @@ -4941,171 +4938,9 @@ def _is_service_running() -> bool: return len(find_gateway_pids()) > 0 -def _setup_weixin(): - """Interactive setup for Weixin / WeChat personal accounts.""" - print() - print(color(" ─── 💬 Weixin / WeChat Setup ───", Colors.CYAN)) - print() - print_info(" 1. Hermes will open Tencent iLink QR login in this terminal.") - print_info(" 2. Use WeChat to scan and confirm the QR code.") - print_info( - " 3. Hermes will store the returned account_id/token in ~/.hermes/.env." - ) - print_info( - " 4. This adapter supports native text, image, video, and document delivery." - ) - - existing_account = get_env_value("WEIXIN_ACCOUNT_ID") - existing_token = get_env_value("WEIXIN_TOKEN") - if existing_account and existing_token: - print() - print_success("Weixin is already configured.") - if not prompt_yes_no(" Reconfigure Weixin?", False): - return - - try: - from gateway.platforms.weixin import check_weixin_requirements, qr_login - except Exception as exc: - print_error(f" Weixin adapter import failed: {exc}") - print_info(" Install gateway dependencies first, then retry.") - return - - if not check_weixin_requirements(): - print_error(" Missing dependencies: Weixin needs aiohttp and cryptography.") - print_info(" Install them, then rerun `hermes gateway setup`.") - return - - print() - if not prompt_yes_no(" Start QR login now?", True): - print_info(" Cancelled.") - return - - import asyncio - - try: - credentials = asyncio.run(qr_login(str(get_hermes_home()))) - except KeyboardInterrupt: - print() - print_warning(" Weixin setup cancelled.") - return - except Exception as exc: - print_error(f" QR login failed: {exc}") - return - - if not credentials: - print_warning(" QR login did not complete.") - return - - account_id = credentials.get("account_id", "") - token = credentials.get("token", "") - base_url = credentials.get("base_url", "") - user_id = credentials.get("user_id", "") - - save_env_value("WEIXIN_ACCOUNT_ID", account_id) - save_env_value("WEIXIN_TOKEN", token) - if base_url: - save_env_value("WEIXIN_BASE_URL", base_url) - save_env_value( - "WEIXIN_CDN_BASE_URL", - get_env_value("WEIXIN_CDN_BASE_URL") or "https://novac2c.cdn.weixin.qq.com/c2c", - ) - - print() - access_choices = [ - "Use DM pairing approval (recommended)", - "Allow all direct messages", - "Only allow listed user IDs", - "Disable direct messages", - ] - access_idx = prompt_choice( - " How should direct messages be authorized?", access_choices, 0 - ) - if access_idx == 0: - save_env_value("WEIXIN_DM_POLICY", "pairing") - save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") - save_env_value("WEIXIN_ALLOWED_USERS", "") - print_success(" DM pairing enabled.") - print_info( - " Unknown DM users can request access and you approve them with `hermes pairing approve`." - ) - elif access_idx == 1: - save_env_value("WEIXIN_DM_POLICY", "open") - save_env_value("WEIXIN_ALLOW_ALL_USERS", "true") - save_env_value("WEIXIN_ALLOWED_USERS", "") - print_warning(" Open DM access enabled for Weixin.") - elif access_idx == 2: - default_allow = user_id or "" - allowlist = prompt( - " Allowed Weixin user IDs (comma-separated)", default_allow, password=False - ).replace(" ", "") - save_env_value("WEIXIN_DM_POLICY", "allowlist") - save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") - save_env_value("WEIXIN_ALLOWED_USERS", allowlist) - print_success(" Weixin allowlist saved.") - else: - save_env_value("WEIXIN_DM_POLICY", "disabled") - save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") - save_env_value("WEIXIN_ALLOWED_USERS", "") - print_warning(" Direct messages disabled.") - - print() - print_info( - " Note: QR login connects an iLink bot identity (e.g. ...@im.bot), not a" - ) - print_info( - " scriptable personal WeChat account. Ordinary WeChat groups typically cannot" - ) - print_info( - " invite an @im.bot identity, and iLink does not deliver ordinary-group events" - ) - print_info( - " to most bot accounts. The settings below only apply when iLink actually" - ) - print_info( - " delivers group events for your account type — otherwise DM remains the only" - ) - print_info(" working channel regardless of this choice.") - group_choices = [ - "Disable group chats (recommended)", - "Allow all group chats", - "Only allow listed group chat IDs", - ] - group_idx = prompt_choice(" How should group chats be handled?", group_choices, 0) - if group_idx == 0: - save_env_value("WEIXIN_GROUP_POLICY", "disabled") - save_env_value("WEIXIN_GROUP_ALLOWED_USERS", "") - print_info(" Group chats disabled.") - elif group_idx == 1: - save_env_value("WEIXIN_GROUP_POLICY", "open") - save_env_value("WEIXIN_GROUP_ALLOWED_USERS", "") - print_warning( - " All group chats enabled (only takes effect if iLink delivers group events)." - ) - else: - allow_groups = prompt( - " Allowed group chat IDs (comma-separated, not member user IDs)", - "", - password=False, - ).replace(" ", "") - save_env_value("WEIXIN_GROUP_POLICY", "allowlist") - save_env_value("WEIXIN_GROUP_ALLOWED_USERS", allow_groups) - print_success( - " Group allowlist saved (only takes effect if iLink delivers group events)." - ) - - if user_id: - print() - if prompt_yes_no( - f" Use your Weixin user ID ({user_id}) as the home channel?", True - ): - save_env_value("WEIXIN_HOME_CHANNEL", user_id) - print_success(f" Home channel set to {user_id}") - - print() - print_success("Weixin configured!") - print_info(f" Account ID: {account_id}") - if user_id: - print_info(f" User ID: {user_id}") +# weixin migrated to a bundled plugin (plugins/platforms/weixin/); its +# interactive_setup (iLink QR login + DM/group policy) lives there, +# dispatched via the registry setup_fn path in _configure_platform(). def _setup_feishu(): @@ -5580,7 +5415,9 @@ def _builtin_setup_fn(key: str): "webhooks": _s._setup_webhooks, "signal": _setup_signal, "whatsapp": _setup_whatsapp, - "weixin": _setup_weixin, + # weixin moved into the plugin: setup_fn is registered by + # plugins/platforms/weixin/adapter.py::register() and dispatched + # via the plugin path in _configure_platform(). "dingtalk": _setup_dingtalk, "feishu": _setup_feishu, "wecom": _setup_wecom, diff --git a/plugins/platforms/weixin/__init__.py b/plugins/platforms/weixin/__init__.py new file mode 100644 index 0000000000000..d4f1d7bf0e3fc --- /dev/null +++ b/plugins/platforms/weixin/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/weixin.py b/plugins/platforms/weixin/adapter.py similarity index 89% rename from gateway/platforms/weixin.py rename to plugins/platforms/weixin/adapter.py index 73e9e68ea70f6..c1c7b6d54fcfe 100644 --- a/gateway/platforms/weixin.py +++ b/plugins/platforms/weixin/adapter.py @@ -2245,3 +2245,262 @@ async def send_weixin_direct( "message_id": last_result.message_id if last_result else None, "context_token_used": bool(context_token), } + + +# --------------------------------------------------------------------------- +# Plugin registration entry point +# --------------------------------------------------------------------------- +# +# Weixin migrated from a built-in adapter (gateway/platforms/weixin.py) into +# this bundled plugin. The hooks below replace the per-platform wiring that +# used to be scattered across core: +# - adapter_factory -> the elif in gateway/run.py::_create_adapter() +# - check_fn -> check_weixin_requirements guard there +# - is_connected -> the Platform.WEIXIN branch in +# gateway/config.py::_is_platform_connected() (which +# requires BOTH account_id and token) + the lambda +# in _PLATFORM_CONNECTED_CHECKERS +# - setup_fn -> _setup_weixin + _PLATFORMS entry + _builtin_setup_fn +# - standalone_sender_fn -> _send_weixin in tools/send_message_tool.py +# - cron_deliver_env_var -> WEIXIN_HOME_CHANNEL +# +# Deliberately left generic in core (same as every other platform): +# - the Platform.WEIXIN enum literal (stable repo-wide identifier) +# - the _apply_env_overrides WEIXIN_* env block in gateway/config.py +# - the WEIXIN_TOKEN entry in the _token_env_names normalization map +# - the _is_user_authorized / _UPDATE_ALLOWED_PLATFORMS allowlist maps +# - the cron _KNOWN_DELIVERY_PLATFORMS frozenset +# Weixin has no load_gateway_config YAML block, so no apply_yaml_config_fn. + + +def _is_connected(config: PlatformConfig) -> bool: + """Weixin is connected when BOTH account_id and a token are present. + + Ports the Platform.WEIXIN branch from gateway/config.py:: + _is_platform_connected (and the _PLATFORM_CONNECTED_CHECKERS lambda). + Weixin sets config.token during env-enablement, so the generic + token-only check would wrongly pass it without an account_id -- which is + why this requires both, and why the registry is_connected is consulted + before the generic token branch. + """ + extra = config.extra or {} + return bool( + extra.get("account_id") + and (config.token or extra.get("token")) + ) + + +async def _standalone_send( + pconfig, + chat_id: str, + message: str, + *, + thread_id=None, + media_files=None, + force_document: bool = False, +): + """Deliver a message for send_message / cron routing. + + Relocated from tools/send_message_tool.py::_send_weixin. Uses the one-shot + send_weixin_direct helper (raw API, bypasses the long-poll adapter + lifecycle), so it works out-of-process. Native text + media delivery. + """ + if not check_weixin_requirements(): + return {"error": "Weixin requirements not met. Need aiohttp + cryptography."} + try: + return await send_weixin_direct( + extra=getattr(pconfig, "extra", None) or {}, + token=getattr(pconfig, "token", None), + chat_id=chat_id, + message=message, + media_files=media_files, + ) + except Exception as e: + return {"error": f"Weixin send failed: {e}"} + + +def interactive_setup() -> None: + """Interactive setup wizard -- replaces hermes_cli/gateway.py::_setup_weixin. + + Runs the Tencent iLink QR login, stores account_id/token, then DM- and + group-policy prompts. Lazy imports keep the plugin's load surface small. + """ + import asyncio + + from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.setup import ( + prompt, + prompt_choice, + prompt_yes_no, + print_info, + print_success, + print_warning, + print_error, + ) + from hermes_cli.colors import color, Colors + + print() + print(color(" ─── 💬 Weixin / WeChat Setup ───", Colors.CYAN)) + print() + print_info(" 1. Hermes will open Tencent iLink QR login in this terminal.") + print_info(" 2. Use WeChat to scan and confirm the QR code.") + print_info(" 3. Hermes will store the returned account_id/token in ~/.hermes/.env.") + print_info(" 4. This adapter supports native text, image, video, and document delivery.") + + existing_account = get_env_value("WEIXIN_ACCOUNT_ID") + existing_token = get_env_value("WEIXIN_TOKEN") + if existing_account and existing_token: + print() + print_success("Weixin is already configured.") + if not prompt_yes_no(" Reconfigure Weixin?", False): + return + + if not check_weixin_requirements(): + print_error(" Missing dependencies: Weixin needs aiohttp and cryptography.") + print_info(" Install them, then rerun `hermes gateway setup`.") + return + + print() + if not prompt_yes_no(" Start QR login now?", True): + print_info(" Cancelled.") + return + + try: + credentials = asyncio.run(qr_login(str(get_hermes_home()))) + except KeyboardInterrupt: + print() + print_warning(" Weixin setup cancelled.") + return + except Exception as exc: + print_error(f" QR login failed: {exc}") + return + + if not credentials: + print_warning(" QR login did not complete.") + return + + account_id = credentials.get("account_id", "") + token = credentials.get("token", "") + base_url = credentials.get("base_url", "") + user_id = credentials.get("user_id", "") + + save_env_value("WEIXIN_ACCOUNT_ID", account_id) + save_env_value("WEIXIN_TOKEN", token) + if base_url: + save_env_value("WEIXIN_BASE_URL", base_url) + save_env_value( + "WEIXIN_CDN_BASE_URL", + get_env_value("WEIXIN_CDN_BASE_URL") or "https://novac2c.cdn.weixin.qq.com/c2c", + ) + + print() + access_choices = [ + "Use DM pairing approval (recommended)", + "Allow all direct messages", + "Only allow listed user IDs", + "Disable direct messages", + ] + access_idx = prompt_choice( + " How should direct messages be authorized?", access_choices, 0 + ) + if access_idx == 0: + save_env_value("WEIXIN_DM_POLICY", "pairing") + save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") + save_env_value("WEIXIN_ALLOWED_USERS", "") + print_success(" DM pairing enabled.") + print_info( + " Unknown DM users can request access and you approve them with `hermes pairing approve`." + ) + elif access_idx == 1: + save_env_value("WEIXIN_DM_POLICY", "open") + save_env_value("WEIXIN_ALLOW_ALL_USERS", "true") + save_env_value("WEIXIN_ALLOWED_USERS", "") + print_warning(" Open DM access enabled for Weixin.") + elif access_idx == 2: + default_allow = user_id or "" + allowlist = prompt( + " Allowed Weixin user IDs (comma-separated)", default_allow, password=False + ).replace(" ", "") + save_env_value("WEIXIN_DM_POLICY", "allowlist") + save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") + save_env_value("WEIXIN_ALLOWED_USERS", allowlist) + print_success(" Weixin allowlist saved.") + else: + save_env_value("WEIXIN_DM_POLICY", "disabled") + save_env_value("WEIXIN_ALLOW_ALL_USERS", "false") + save_env_value("WEIXIN_ALLOWED_USERS", "") + print_warning(" Direct messages disabled.") + + print() + print_info(" Note: QR login connects an iLink bot identity (e.g. ...@im.bot), not a") + print_info(" scriptable personal WeChat account. Ordinary WeChat groups typically cannot") + print_info(" invite an @im.bot identity, and iLink does not deliver ordinary-group events") + print_info(" to most bot accounts. The settings below only apply when iLink actually") + print_info(" delivers group events for your account type -- otherwise DM remains the only") + print_info(" working channel regardless of this choice.") + group_choices = [ + "Disable group chats (recommended)", + "Allow all group chats", + "Only allow listed group chat IDs", + ] + group_idx = prompt_choice(" How should group chats be handled?", group_choices, 0) + if group_idx == 0: + save_env_value("WEIXIN_GROUP_POLICY", "disabled") + save_env_value("WEIXIN_GROUP_ALLOWED_USERS", "") + print_info(" Group chats disabled.") + elif group_idx == 1: + save_env_value("WEIXIN_GROUP_POLICY", "open") + save_env_value("WEIXIN_GROUP_ALLOWED_USERS", "") + print_warning( + " All group chats enabled (only takes effect if iLink delivers group events)." + ) + else: + allow_groups = prompt( + " Allowed group chat IDs (comma-separated, not member user IDs)", + "", + password=False, + ).replace(" ", "") + save_env_value("WEIXIN_GROUP_POLICY", "allowlist") + save_env_value("WEIXIN_GROUP_ALLOWED_USERS", allow_groups) + print_success( + " Group allowlist saved (only takes effect if iLink delivers group events)." + ) + + if user_id: + print() + if prompt_yes_no( + f" Use your Weixin user ID ({user_id}) as the home channel?", True + ): + save_env_value("WEIXIN_HOME_CHANNEL", user_id) + print_success(f" Home channel set to {user_id}") + + print() + print_success("Weixin configured!") + print_info(f" Account ID: {account_id}") + if user_id: + print_info(f" User ID: {user_id}") + + +def _build_adapter(config): + """Factory wrapper that constructs WeixinAdapter from a PlatformConfig.""" + return WeixinAdapter(config) + + +def register(ctx) -> None: + """Plugin entry point -- called by the Hermes plugin system.""" + ctx.register_platform( + name="weixin", + label="Weixin / WeChat", + adapter_factory=_build_adapter, + check_fn=check_weixin_requirements, + is_connected=_is_connected, + required_env=["WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN"], + install_hint="pip install aiohttp cryptography", + setup_fn=interactive_setup, + allowed_users_env="WEIXIN_ALLOWED_USERS", + allow_all_env="WEIXIN_ALLOW_ALL_USERS", + cron_deliver_env_var="WEIXIN_HOME_CHANNEL", + standalone_sender_fn=_standalone_send, + max_message_length=WeixinAdapter.MAX_MESSAGE_LENGTH, + emoji="💬", + ) diff --git a/plugins/platforms/weixin/plugin.yaml b/plugins/platforms/weixin/plugin.yaml new file mode 100644 index 0000000000000..570d7fb422967 --- /dev/null +++ b/plugins/platforms/weixin/plugin.yaml @@ -0,0 +1,53 @@ +name: weixin-platform +label: Weixin / WeChat +kind: platform +version: 1.0.0 +description: > + Weixin (微信 / WeChat) gateway adapter for Hermes Agent. Connects to a + Tencent iLink bot identity via QR login and relays messages between Weixin + DMs / groups and the Hermes agent. Supports native text, image, video, and + document delivery, context-token session continuity, DM pairing approval, + and home-channel cron delivery. +author: NousResearch +requires_env: + - name: WEIXIN_ACCOUNT_ID + description: "Weixin iLink bot account ID (obtained via QR login)." + prompt: "Weixin account ID" + password: false + - name: WEIXIN_TOKEN + description: "Weixin iLink session token (obtained via QR login)." + prompt: "Weixin token" + password: true +optional_env: + - name: WEIXIN_BASE_URL + description: "Override the default Weixin iLink API base URL." + prompt: "Base URL (or empty)" + password: false + - name: WEIXIN_CDN_BASE_URL + description: "CDN base URL for media downloads (defaults to the c2c CDN)." + prompt: "CDN base URL (or empty)" + password: false + - name: WEIXIN_DM_POLICY + description: "DM access policy: pairing, open, allowlist, or disabled." + prompt: "DM policy (or empty)" + password: false + - name: WEIXIN_ALLOWED_USERS + description: "Comma-separated user IDs allowed to DM the bot (allowlist policy)." + prompt: "Allowed users (or empty)" + password: false + - name: WEIXIN_GROUP_POLICY + description: "Group access policy: disabled, open, or allowlist." + prompt: "Group policy (or empty)" + password: false + - name: WEIXIN_GROUP_ALLOWED_USERS + description: "Comma-separated group chat IDs allowed (group allowlist policy)." + prompt: "Allowed groups (or empty)" + password: false + - name: WEIXIN_ALLOW_ALL_USERS + description: "Allow any Weixin user to trigger the bot (dev only)." + prompt: "Allow all users? (true/false)" + password: false + - name: WEIXIN_HOME_CHANNEL + description: "Default chat target for cron / notification delivery." + prompt: "Home channel (or empty)" + password: false diff --git a/tests/gateway/test_config_driven_access_policy.py b/tests/gateway/test_config_driven_access_policy.py index fee79d90b7d00..f2b7131c9e998 100644 --- a/tests/gateway/test_config_driven_access_policy.py +++ b/tests/gateway/test_config_driven_access_policy.py @@ -104,7 +104,7 @@ def test_base_adapter_defaults_to_not_owning_access_policy(): "module_path, class_name", [ ("gateway.platforms.wecom", "WeComAdapter"), - ("gateway.platforms.weixin", "WeixinAdapter"), + ("plugins.platforms.weixin.adapter", "WeixinAdapter"), ("gateway.platforms.yuanbao", "YuanbaoAdapter"), ("gateway.platforms.qqbot.adapter", "QQAdapter"), ("gateway.platforms.whatsapp", "WhatsAppAdapter"), diff --git a/tests/gateway/test_platform_connected_checkers.py b/tests/gateway/test_platform_connected_checkers.py index f7677a3a676d0..e1283a97b29d7 100644 --- a/tests/gateway/test_platform_connected_checkers.py +++ b/tests/gateway/test_platform_connected_checkers.py @@ -33,9 +33,21 @@ 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 is provided by a bundled platform + # plugin's ``is_connected`` hook (consulted by get_connected_platforms() + # as a registry fallback). Their enum literal stays in core for stable + # identification, but the checker lives in plugins/platforms//. + bundled_plugin_values = Platform._scan_bundled_plugin_platforms() + + # 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 + - bundled_plugin_values + - {"local"} + ) assert not missing, ( f"Built-in platforms missing a connection checker: " diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index bbfba37d51c5c..460c37f26d77d 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -12,8 +12,8 @@ from gateway.config import GatewayConfig, HomeChannel, Platform, _apply_env_overrides from gateway.platforms.base import SendResult from gateway.platforms.base import MessageEvent, MessageType -from gateway.platforms import weixin -from gateway.platforms.weixin import ContextTokenStore, WeixinAdapter +from plugins.platforms.weixin import adapter as weixin +from plugins.platforms.weixin.adapter import ContextTokenStore, WeixinAdapter from tools.send_message_tool import _parse_target_ref, _send_to_platform @@ -312,10 +312,10 @@ async def test_qr_login_timeout_uses_monotonic_clock(self, tmp_path): } pending = {"status": "wait"} - with patch("gateway.platforms.weixin._api_get", new_callable=AsyncMock) as api_get_mock, \ - patch("gateway.platforms.weixin.time") as mock_time, \ - patch("gateway.platforms.weixin.AIOHTTP_AVAILABLE", True), \ - patch("gateway.platforms.weixin.aiohttp.ClientSession", create=True) as session_cls, \ + with patch("plugins.platforms.weixin.adapter._api_get", new_callable=AsyncMock) as api_get_mock, \ + patch("plugins.platforms.weixin.adapter.time") as mock_time, \ + patch("plugins.platforms.weixin.adapter.AIOHTTP_AVAILABLE", True), \ + patch("plugins.platforms.weixin.adapter.aiohttp.ClientSession", create=True) as session_cls, \ patch("builtins.print"): api_get_mock.side_effect = [first_qr, pending] mock_time.monotonic.side_effect = [1000, 1000.2, 1001.1] @@ -372,8 +372,8 @@ def _connected_adapter(self) -> WeixinAdapter: adapter._token_store.get = lambda account_id, chat_id: "ctx-token" return adapter - @patch("gateway.platforms.weixin.asyncio.sleep", new_callable=AsyncMock) - @patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock) + @patch("plugins.platforms.weixin.adapter.asyncio.sleep", new_callable=AsyncMock) + @patch("plugins.platforms.weixin.adapter._send_message", new_callable=AsyncMock) def test_send_waits_between_multiple_chunks(self, send_message_mock, sleep_mock): adapter = self._connected_adapter() adapter.MAX_MESSAGE_LENGTH = 12 @@ -385,8 +385,8 @@ def test_send_waits_between_multiple_chunks(self, send_message_mock, sleep_mock) assert send_message_mock.await_count == 3 assert sleep_mock.await_count == 2 - @patch("gateway.platforms.weixin.asyncio.sleep", new_callable=AsyncMock) - @patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock) + @patch("plugins.platforms.weixin.adapter.asyncio.sleep", new_callable=AsyncMock) + @patch("plugins.platforms.weixin.adapter._send_message", new_callable=AsyncMock) def test_send_retries_failed_chunk_before_continuing(self, send_message_mock, sleep_mock): adapter = self._connected_adapter() adapter.MAX_MESSAGE_LENGTH = 12 @@ -502,10 +502,10 @@ def put(self, *_args, **_kwargs): aes_key = bytes(range(16)) expected_aes_key = base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii") - with patch("gateway.platforms.weixin._get_upload_url", new=AsyncMock(return_value={"upload_full_url": "https://upload.example.com/media"})), \ - patch("gateway.platforms.weixin._api_post", new_callable=AsyncMock) as api_post_mock, \ - patch("gateway.platforms.weixin.secrets.token_hex", return_value="filekey-123"), \ - patch("gateway.platforms.weixin.secrets.token_bytes", return_value=aes_key): + with patch("plugins.platforms.weixin.adapter._get_upload_url", new=AsyncMock(return_value={"upload_full_url": "https://upload.example.com/media"})), \ + patch("plugins.platforms.weixin.adapter._api_post", new_callable=AsyncMock) as api_post_mock, \ + patch("plugins.platforms.weixin.adapter.secrets.token_hex", return_value="filekey-123"), \ + patch("plugins.platforms.weixin.adapter.secrets.token_bytes", return_value=aes_key): message_id = asyncio.run(adapter._send_file("wxid_test123", str(image_path), "")) assert message_id.startswith("hermes-weixin-") @@ -583,7 +583,7 @@ def test_split_text_returns_empty_list_for_empty_string_split_per_line(self): ) assert adapter._split_text("") == [] - @patch("gateway.platforms.weixin._send_message", new_callable=AsyncMock) + @patch("plugins.platforms.weixin.adapter._send_message", new_callable=AsyncMock) def test_send_empty_content_does_not_call_send_message(self, send_message_mock): adapter = _make_adapter() adapter._session = object() diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 53a9fc6003753..89ff0ea7f4b52 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1545,7 +1545,7 @@ async def _send_wecom(extra, chat_id, message): async def _send_weixin(pconfig, chat_id, message, media_files=None): """Send via Weixin iLink using the native adapter helper.""" try: - from gateway.platforms.weixin import check_weixin_requirements, send_weixin_direct + from plugins.platforms.weixin.adapter import check_weixin_requirements, send_weixin_direct if not check_weixin_requirements(): return {"error": "Weixin requirements not met. Need aiohttp + cryptography."} except ImportError: