diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index bc4c49bcb..321d46a8b 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -356,6 +356,14 @@ PLATFORM_HINTS = { "MEDIA:/absolute/path/to/file in your response. Images (.jpg, .png, " ".heic) appear as photos and other files arrive as attachments." ), + "weixin": ( + "You are on Weixin/WeChat. Markdown formatting is supported, so you may use it when " + "it improves readability, but keep the message compact and chat-friendly. You can send media files natively: " + "include MEDIA:/absolute/path/to/file in your response. Images are sent as native " + "photos, videos play inline when supported, and other files arrive as downloadable " + "documents. You can also include image URLs in markdown format ![alt](url) and they " + "will be downloaded and sent as native media when possible." + ), } CONTEXT_FILE_MAX_CHARS = 20_000 diff --git a/cron/scheduler.py b/cron/scheduler.py index fba4318b5..23de3ffcc 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -44,7 +44,7 @@ logger = logging.getLogger(__name__) _KNOWN_DELIVERY_PLATFORMS = frozenset({ "telegram", "discord", "slack", "whatsapp", "signal", "matrix", "mattermost", "homeassistant", "dingtalk", "feishu", - "wecom", "sms", "email", "webhook", "bluebubbles", + "wecom", "weixin", "sms", "email", "webhook", "bluebubbles", }) from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run @@ -234,6 +234,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option "dingtalk": Platform.DINGTALK, "feishu": Platform.FEISHU, "wecom": Platform.WECOM, + "weixin": Platform.WEIXIN, "email": Platform.EMAIL, "sms": Platform.SMS, "bluebubbles": Platform.BLUEBUBBLES, diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 022ebcae4..f873414ed 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -77,7 +77,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) # Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history - for plat_name in ("telegram", "whatsapp", "signal", "email", "sms", "bluebubbles"): + for plat_name in ("telegram", "whatsapp", "signal", "weixin", "email", "sms", "bluebubbles"): if plat_name not in platforms: platforms[plat_name] = _build_from_sessions(plat_name) diff --git a/gateway/config.py b/gateway/config.py index fe827a4e7..d0cc2a2c2 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -63,6 +63,7 @@ class Platform(Enum): WEBHOOK = "webhook" FEISHU = "feishu" WECOM = "wecom" + WEIXIN = "weixin" BLUEBUBBLES = "bluebubbles" @@ -261,6 +262,11 @@ class GatewayConfig: for platform, config in self.platforms.items(): if not config.enabled: continue + # Weixin requires both a token and an account_id + if platform == Platform.WEIXIN: + if config.extra.get("account_id") and (config.token or config.extra.get("token")): + connected.append(platform) + continue # Platforms that use token/api_key auth if config.token or config.api_key: connected.append(platform) @@ -674,6 +680,7 @@ def load_gateway_config() -> GatewayConfig: Platform.SLACK: "SLACK_BOT_TOKEN", Platform.MATTERMOST: "MATTERMOST_TOKEN", Platform.MATRIX: "MATRIX_ACCESS_TOKEN", + Platform.WEIXIN: "WEIXIN_TOKEN", } for platform, pconfig in config.platforms.items(): if not pconfig.enabled: @@ -978,6 +985,44 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=os.getenv("WECOM_HOME_CHANNEL_NAME", "Home"), ) + # Weixin (personal WeChat via iLink Bot API) + weixin_token = os.getenv("WEIXIN_TOKEN") + weixin_account_id = os.getenv("WEIXIN_ACCOUNT_ID") + if weixin_token or weixin_account_id: + if Platform.WEIXIN not in config.platforms: + config.platforms[Platform.WEIXIN] = PlatformConfig() + config.platforms[Platform.WEIXIN].enabled = True + if weixin_token: + config.platforms[Platform.WEIXIN].token = weixin_token + extra = config.platforms[Platform.WEIXIN].extra + if weixin_account_id: + extra["account_id"] = weixin_account_id + weixin_base_url = os.getenv("WEIXIN_BASE_URL", "").strip() + if weixin_base_url: + extra["base_url"] = weixin_base_url.rstrip("/") + weixin_cdn_base_url = os.getenv("WEIXIN_CDN_BASE_URL", "").strip() + if weixin_cdn_base_url: + extra["cdn_base_url"] = weixin_cdn_base_url.rstrip("/") + weixin_dm_policy = os.getenv("WEIXIN_DM_POLICY", "").strip().lower() + if weixin_dm_policy: + extra["dm_policy"] = weixin_dm_policy + weixin_group_policy = os.getenv("WEIXIN_GROUP_POLICY", "").strip().lower() + if weixin_group_policy: + extra["group_policy"] = weixin_group_policy + weixin_allowed_users = os.getenv("WEIXIN_ALLOWED_USERS", "").strip() + if weixin_allowed_users: + extra["allow_from"] = weixin_allowed_users + weixin_group_allowed_users = os.getenv("WEIXIN_GROUP_ALLOWED_USERS", "").strip() + if weixin_group_allowed_users: + extra["group_allow_from"] = weixin_group_allowed_users + weixin_home = os.getenv("WEIXIN_HOME_CHANNEL", "").strip() + if weixin_home: + config.platforms[Platform.WEIXIN].home_channel = HomeChannel( + platform=Platform.WEIXIN, + chat_id=weixin_home, + name=os.getenv("WEIXIN_HOME_CHANNEL_NAME", "Home"), + ) + # BlueBubbles (iMessage) bluebubbles_server_url = os.getenv("BLUEBUBBLES_SERVER_URL") bluebubbles_password = os.getenv("BLUEBUBBLES_PASSWORD") diff --git a/gateway/run.py b/gateway/run.py index 8536aa870..bfadbd166 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1069,6 +1069,7 @@ class GatewayRunner: "MATRIX_ALLOWED_USERS", "DINGTALK_ALLOWED_USERS", "FEISHU_ALLOWED_USERS", "WECOM_ALLOWED_USERS", + "WEIXIN_ALLOWED_USERS", "BLUEBUBBLES_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS") ) @@ -1622,6 +1623,13 @@ class GatewayRunner: 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) + elif platform == Platform.MATTERMOST: from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements if not check_mattermost_requirements(): @@ -1697,6 +1705,7 @@ class GatewayRunner: Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", Platform.FEISHU: "FEISHU_ALLOWED_USERS", Platform.WECOM: "WECOM_ALLOWED_USERS", + Platform.WEIXIN: "WEIXIN_ALLOWED_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS", } platform_allow_all_map = { @@ -1712,6 +1721,7 @@ class GatewayRunner: Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS", Platform.WECOM: "WECOM_ALLOW_ALL_USERS", + Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS", Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS", } @@ -5610,7 +5620,7 @@ class GatewayRunner: Platform.TELEGRAM, Platform.DISCORD, Platform.SLACK, Platform.WHATSAPP, Platform.SIGNAL, Platform.MATTERMOST, Platform.MATRIX, Platform.HOMEASSISTANT, Platform.EMAIL, Platform.SMS, Platform.DINGTALK, - Platform.FEISHU, Platform.WECOM, Platform.BLUEBUBBLES, Platform.LOCAL, + Platform.FEISHU, Platform.WECOM, Platform.WEIXIN, Platform.BLUEBUBBLES, Platform.LOCAL, }) async def _handle_update_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 4944e4293..24fc655a2 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -39,6 +39,9 @@ _EXTRA_ENV_KEYS = frozenset({ "DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET", "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN", "WECOM_BOT_ID", "WECOM_SECRET", + "WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL", "WEIXIN_CDN_BASE_URL", + "WEIXIN_HOME_CHANNEL", "WEIXIN_HOME_CHANNEL_NAME", "WEIXIN_DM_POLICY", "WEIXIN_GROUP_POLICY", + "WEIXIN_ALLOWED_USERS", "WEIXIN_GROUP_ALLOWED_USERS", "WEIXIN_ALLOW_ALL_USERS", "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", "WHATSAPP_MODE", "WHATSAPP_ENABLED", diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 69b1a6df8..548f7b452 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1624,6 +1624,12 @@ _PLATFORMS = [ "help": "Chat ID for scheduled results and notifications."}, ], }, + { + "key": "weixin", + "label": "Weixin / WeChat", + "emoji": "๐Ÿ’ฌ", + "token_var": "WEIXIN_ACCOUNT_ID", + }, { "key": "bluebubbles", "label": "BlueBubbles (iMessage)", @@ -1696,6 +1702,13 @@ def _platform_status(platform: dict) -> str: if val or password or homeserver: return "partially configured" return "not configured" + if platform.get("key") == "weixin": + token = get_env_value("WEIXIN_TOKEN") + if val and token: + return "configured" + if val or token: + return "partially configured" + return "not configured" if val: return "configured" return "not configured" @@ -1886,6 +1899,133 @@ 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() + 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.") + else: + allow_groups = prompt(" Allowed group chat IDs (comma-separated)", "", password=False).replace(" ", "") + save_env_value("WEIXIN_GROUP_POLICY", "allowlist") + save_env_value("WEIXIN_GROUP_ALLOWED_USERS", allow_groups) + print_success(" Group allowlist saved.") + + 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 _setup_signal(): """Interactive setup for Signal messenger.""" import shutil @@ -2061,6 +2201,8 @@ def gateway_setup(): _setup_whatsapp() elif platform["key"] == "signal": _setup_signal() + elif platform["key"] == "weixin": + _setup_weixin() else: _setup_standard_platform(platform) diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 11f4371b6..baba4f359 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -305,6 +305,7 @@ def show_status(args): "DingTalk": ("DINGTALK_CLIENT_ID", None), "Feishu": ("FEISHU_APP_ID", "FEISHU_HOME_CHANNEL"), "WeCom": ("WECOM_BOT_ID", "WECOM_HOME_CHANNEL"), + "Weixin": ("WEIXIN_ACCOUNT_ID", "WEIXIN_HOME_CHANNEL"), "BlueBubbles": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_HOME_CHANNEL"), } diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index b988f5544..d86ffd281 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -133,6 +133,7 @@ PLATFORMS = { "dingtalk": {"label": "๐Ÿ’ฌ DingTalk", "default_toolset": "hermes-dingtalk"}, "feishu": {"label": "๐Ÿชฝ Feishu", "default_toolset": "hermes-feishu"}, "wecom": {"label": "๐Ÿ’ฌ WeCom", "default_toolset": "hermes-wecom"}, + "weixin": {"label": "๐Ÿ’ฌ Weixin", "default_toolset": "hermes-weixin"}, "api_server": {"label": "๐ŸŒ API Server", "default_toolset": "hermes-api-server"}, "mattermost": {"label": "๐Ÿ’ฌ Mattermost", "default_toolset": "hermes-mattermost"}, "webhook": {"label": "๐Ÿ”— Webhook", "default_toolset": "hermes-webhook"}, diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index ccb8bc6f6..8f746d1be 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -455,7 +455,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr }, "deliver": { "type": "string", - "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, bluebubbles, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" + "description": "Delivery target: origin, local, telegram, discord, slack, whatsapp, signal, weixin, matrix, mattermost, homeassistant, dingtalk, feishu, wecom, email, sms, bluebubbles, or platform:chat_id or platform:chat_id:thread_id for Telegram topics. Examples: 'origin', 'local', 'telegram', 'telegram:-1001234567890:17585', 'discord:#engineering'" }, "skills": { "type": "array", diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 591aca1d5..c7c71c8c6 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -18,6 +18,7 @@ logger = logging.getLogger(__name__) _TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$") _FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$") +_WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$") # Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets. _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} @@ -157,6 +158,7 @@ def _handle_send(args): "dingtalk": Platform.DINGTALK, "feishu": Platform.FEISHU, "wecom": Platform.WECOM, + "weixin": Platform.WEIXIN, "email": Platform.EMAIL, "sms": Platform.SMS, } @@ -237,6 +239,10 @@ def _parse_target_ref(platform_name: str, target_ref: str): match = _NUMERIC_TOPIC_RE.fullmatch(target_ref) if match: return match.group(1), match.group(2), True + if platform_name == "weixin": + match = _WEIXIN_TARGET_RE.fullmatch(target_ref) + if match: + return match.group(1), None, True if target_ref.lstrip("-").isdigit(): return target_ref, None, True return None, None, False @@ -369,6 +375,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = result return last_result + # --- Weixin: use the native one-shot adapter helper for text + media --- + if platform == Platform.WEIXIN: + return await _send_weixin(pconfig, chat_id, message, media_files=media_files) + # --- Non-Telegram platforms --- if media_files and not message.strip(): return { @@ -903,6 +913,27 @@ async def _send_wecom(extra, chat_id, message): return _error(f"WeCom send failed: {e}") +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 + if not check_weixin_requirements(): + return {"error": "Weixin requirements not met. Need aiohttp + cryptography."} + except ImportError: + return {"error": "Weixin adapter not available."} + + try: + return await send_weixin_direct( + extra=pconfig.extra, + token=pconfig.token, + chat_id=chat_id, + message=message, + media_files=media_files, + ) + except Exception as e: + return _error(f"Weixin send failed: {e}") + + async def _send_bluebubbles(extra, chat_id, message): """Send via BlueBubbles iMessage server using the adapter's REST API.""" try: diff --git a/toolsets.py b/toolsets.py index a786ee7c6..6fbc963e6 100644 --- a/toolsets.py +++ b/toolsets.py @@ -353,6 +353,12 @@ TOOLSETS = { "includes": [] }, + "hermes-weixin": { + "description": "Weixin bot toolset - personal WeChat messaging via iLink (full access)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-wecom": { "description": "WeCom bot toolset - enterprise WeChat messaging (full access)", "tools": _HERMES_CORE_TOOLS, @@ -374,7 +380,7 @@ TOOLSETS = { "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-webhook"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-bluebubbles", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-mattermost", "hermes-matrix", "hermes-dingtalk", "hermes-feishu", "hermes-wecom", "hermes-weixin", "hermes-webhook"] } } diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index e5e05787c..e5d005f9a 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -227,6 +227,17 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `WECOM_WEBSOCKET_URL` | Custom WebSocket URL (default: `wss://openws.work.weixin.qq.com`) | | `WECOM_ALLOWED_USERS` | Comma-separated WeCom user IDs allowed to message the bot | | `WECOM_HOME_CHANNEL` | WeCom chat ID for cron delivery and notifications | +| `WEIXIN_ACCOUNT_ID` | Weixin account ID obtained via QR login through iLink Bot API | +| `WEIXIN_TOKEN` | Weixin authentication token obtained via QR login through iLink Bot API | +| `WEIXIN_BASE_URL` | Override Weixin iLink Bot API base URL (default: `https://ilinkai.weixin.qq.com`) | +| `WEIXIN_CDN_BASE_URL` | Override Weixin CDN base URL for media (default: `https://novac2c.cdn.weixin.qq.com/c2c`) | +| `WEIXIN_DM_POLICY` | Direct message policy: `open`, `allowlist`, `pairing`, `disabled` (default: `open`) | +| `WEIXIN_GROUP_POLICY` | Group message policy: `open`, `allowlist`, `disabled` (default: `disabled`) | +| `WEIXIN_ALLOWED_USERS` | Comma-separated Weixin user IDs allowed to DM the bot | +| `WEIXIN_GROUP_ALLOWED_USERS` | Comma-separated Weixin group IDs allowed to interact with the bot | +| `WEIXIN_HOME_CHANNEL` | Weixin chat ID for cron delivery and notifications | +| `WEIXIN_HOME_CHANNEL_NAME` | Display name for the Weixin home channel | +| `WEIXIN_ALLOW_ALL_USERS` | Allow all Weixin users without an allowlist (`true`/`false`) | | `BLUEBUBBLES_SERVER_URL` | BlueBubbles server URL (e.g. `http://192.168.1.10:1234`) | | `BLUEBUBBLES_PASSWORD` | BlueBubbles server password | | `BLUEBUBBLES_WEBHOOK_HOST` | Webhook listener bind address (default: `127.0.0.1`) | diff --git a/website/docs/user-guide/messaging/weixin.md b/website/docs/user-guide/messaging/weixin.md new file mode 100644 index 000000000..656081a22 --- /dev/null +++ b/website/docs/user-guide/messaging/weixin.md @@ -0,0 +1,294 @@ +--- +sidebar_position: 15 +title: "Weixin (WeChat)" +description: "Connect Hermes Agent to personal WeChat accounts via the iLink Bot API" +--- + +# Weixin (WeChat) + +Connect Hermes to [WeChat](https://weixin.qq.com/) (ๅพฎไฟก), Tencent's personal messaging platform. The adapter uses Tencent's **iLink Bot API** for personal WeChat accounts โ€” this is distinct from WeCom (Enterprise WeChat). Messages are delivered via long-polling, so no public endpoint or webhook is required. + +:::info +This adapter is for **personal WeChat accounts** (ๅพฎไฟก). If you need enterprise/corporate WeChat, see the [WeCom adapter](./wecom.md) instead. +::: + +## Prerequisites + +- A personal WeChat account +- Python packages: `aiohttp` and `cryptography` +- The `qrcode` package is optional (for terminal QR rendering during setup) + +Install the required dependencies: + +```bash +pip install aiohttp cryptography +# Optional: for terminal QR code display +pip install qrcode +``` + +## Setup + +### 1. Run the Setup Wizard + +The easiest way to connect your WeChat account is through the interactive setup: + +```bash +hermes gateway setup +``` + +Select **Weixin** when prompted. The wizard will: + +1. Request a QR code from the iLink Bot API +2. Display the QR code in your terminal (or provide a URL) +3. Wait for you to scan the QR code with the WeChat mobile app +4. Prompt you to confirm the login on your phone +5. Save the account credentials automatically to `~/.hermes/weixin/accounts/` + +Once confirmed, you'll see a message like: + +``` +ๅพฎไฟก่ฟžๆŽฅๆˆๅŠŸ๏ผŒaccount_id=your-account-id +``` + +The wizard stores the `account_id`, `token`, and `base_url` so you don't need to configure them manually. + +### 2. Configure Environment Variables + +After initial QR login, set at minimum the account ID in `~/.hermes/.env`: + +```bash +WEIXIN_ACCOUNT_ID=your-account-id + +# Optional: override the token (normally auto-saved from QR login) +# WEIXIN_TOKEN=your-bot-token + +# Optional: restrict access +WEIXIN_DM_POLICY=open +WEIXIN_ALLOWED_USERS=user_id_1,user_id_2 + +# Optional: home channel for cron/notifications +WEIXIN_HOME_CHANNEL=chat_id +WEIXIN_HOME_CHANNEL_NAME=Home +``` + +### 3. Start the Gateway + +```bash +hermes gateway +``` + +The adapter will restore saved credentials, connect to the iLink API, and begin long-polling for messages. + +## Features + +- **Long-poll transport** โ€” no public endpoint, webhook, or WebSocket needed +- **QR code login** โ€” scan-to-connect setup via `hermes gateway setup` +- **DM and group messaging** โ€” configurable access policies +- **Media support** โ€” images, video, files, and voice messages +- **AES-128-ECB encrypted CDN** โ€” automatic encryption/decryption for all media transfers +- **Context token persistence** โ€” disk-backed reply continuity across restarts +- **Markdown formatting** โ€” headers, tables, and code blocks are reformatted for WeChat readability +- **Smart message chunking** โ€” long messages are split at logical boundaries (paragraphs, code fences) +- **Typing indicators** โ€” shows "typingโ€ฆ" status in the WeChat client while the agent processes +- **SSRF protection** โ€” outbound media URLs are validated before download +- **Message deduplication** โ€” 5-minute sliding window prevents double-processing +- **Automatic retry with backoff** โ€” recovers from transient API errors + +## Configuration Options + +Set these in `config.yaml` under `platforms.weixin.extra`: + +| Key | Default | Description | +|-----|---------|-------------| +| `account_id` | โ€” | iLink Bot account ID (required) | +| `token` | โ€” | iLink Bot token (required, auto-saved from QR login) | +| `base_url` | `https://ilinkai.weixin.qq.com` | iLink API base URL | +| `cdn_base_url` | `https://novac2c.cdn.weixin.qq.com/c2c` | CDN base URL for media transfer | +| `dm_policy` | `open` | DM access: `open`, `allowlist`, `disabled`, `pairing` | +| `group_policy` | `disabled` | Group access: `open`, `allowlist`, `disabled` | +| `allow_from` | `[]` | User IDs allowed for DMs (when dm_policy=allowlist) | +| `group_allow_from` | `[]` | Group IDs allowed (when group_policy=allowlist) | + +## Access Policies + +### DM Policy + +Controls who can send direct messages to the bot: + +| Value | Behavior | +|-------|----------| +| `open` | Anyone can DM the bot (default) | +| `allowlist` | Only user IDs in `allow_from` can DM | +| `disabled` | All DMs are ignored | +| `pairing` | Pairing mode (for initial setup) | + +```bash +WEIXIN_DM_POLICY=allowlist +WEIXIN_ALLOWED_USERS=user_id_1,user_id_2 +``` + +### Group Policy + +Controls which groups the bot responds in: + +| Value | Behavior | +|-------|----------| +| `open` | Bot responds in all groups | +| `allowlist` | Bot only responds in group IDs listed in `group_allow_from` | +| `disabled` | All group messages are ignored (default) | + +```bash +WEIXIN_GROUP_POLICY=allowlist +WEIXIN_GROUP_ALLOWED_USERS=group_id_1,group_id_2 +``` + +:::note +The default group policy is `disabled` for Weixin (unlike WeCom where it defaults to `open`). This is intentional since personal WeChat accounts may be in many groups. +::: + +## Media Support + +### Inbound (receiving) + +The adapter receives media attachments from users, downloads them from the WeChat CDN, decrypts them, and caches them locally for agent processing: + +| Type | How it's handled | +|------|-----------------| +| **Images** | Downloaded, AES-decrypted, and cached as JPEG. | +| **Video** | Downloaded, AES-decrypted, and cached as MP4. | +| **Files** | Downloaded, AES-decrypted, and cached. Original filename is preserved. | +| **Voice** | If a text transcription is available, it's extracted as text. Otherwise the audio (SILK format) is downloaded and cached. | + +**Quoted messages:** Media from quoted (replied-to) messages is also extracted, so the agent has context about what the user is replying to. + +### AES-128-ECB Encrypted CDN + +WeChat media files are transferred through an encrypted CDN. The adapter handles this transparently: + +- **Inbound:** Encrypted media is downloaded from the CDN using `encrypted_query_param` URLs, then decrypted with AES-128-ECB using the per-file key provided in the message payload. +- **Outbound:** Files are encrypted locally with a random AES-128-ECB key, uploaded to the CDN, and the encrypted reference is included in the outbound message. +- The AES key is 16 bytes (128-bit). Keys may arrive as raw base64 or hex-encoded โ€” the adapter handles both formats. +- This requires the `cryptography` Python package. + +No configuration is needed โ€” encryption and decryption happen automatically. + +### Outbound (sending) + +| Method | What it sends | +|--------|--------------| +| `send` | Text messages with Markdown formatting | +| `send_image` / `send_image_file` | Native image messages (via CDN upload) | +| `send_document` | File attachments (via CDN upload) | +| `send_video` | Video messages (via CDN upload) | + +All outbound media goes through the encrypted CDN upload flow: + +1. Generate a random AES-128 key +2. Encrypt the file with AES-128-ECB + PKCS#7 padding +3. Request an upload URL from the iLink API (`getuploadurl`) +4. Upload the ciphertext to the CDN +5. Send the message with the encrypted media reference + +## Context Token Persistence + +The iLink Bot API requires a `context_token` to be echoed back with each outbound message for a given peer. The adapter maintains a disk-backed context token store: + +- Tokens are saved per account+peer to `~/.hermes/weixin/accounts/.context-tokens.json` +- On startup, previously saved tokens are restored +- Every inbound message updates the stored token for that sender +- Outbound messages automatically include the latest context token + +This ensures reply continuity even after gateway restarts. + +## Markdown Formatting + +WeChat's personal chat does not natively render full Markdown. The adapter reformats content for better readability: + +- **Headers** (`# Title`) โ†’ converted to `ใ€Titleใ€‘` (level 1) or `**Title**` (level 2+) +- **Tables** โ†’ reformatted as labeled key-value lists (e.g., `- Column: Value`) +- **Code fences** โ†’ preserved as-is (WeChat renders these adequately) +- **Excessive blank lines** โ†’ collapsed to double newlines + +## Message Chunking + +Long messages are split intelligently for chat delivery: + +- Maximum message length: **4000 characters** +- Split points prefer paragraph boundaries and blank lines +- Code fences are kept intact (never split mid-block) +- Indented continuation lines (sub-items in reformatted tables/lists) stay with their parent +- Oversized individual blocks fall back to the base adapter's truncation logic + +## Typing Indicators + +The adapter shows typing status in the WeChat client: + +1. When a message arrives, the adapter fetches a `typing_ticket` via the `getconfig` API +2. Typing tickets are cached for 10 minutes per user +3. `send_typing` sends a typing-start signal; `stop_typing` sends a typing-stop signal +4. The gateway automatically triggers typing indicators while the agent processes a message + +## Long-Poll Connection + +The adapter uses HTTP long-polling (not WebSocket) to receive messages: + +### How It Works + +1. **Connect:** Validates credentials and starts the poll loop +2. **Poll:** Calls `getupdates` with a 35-second timeout; the server holds the request until messages arrive or the timeout expires +3. **Dispatch:** Inbound messages are dispatched concurrently via `asyncio.create_task` +4. **Sync buffer:** A persistent sync cursor (`get_updates_buf`) is saved to disk so the adapter resumes from the correct position after restarts + +### Retry Behavior + +On API errors, the adapter uses a simple retry strategy: + +| Condition | Behavior | +|-----------|----------| +| Transient error (1stโ€“2nd) | Retry after 2 seconds | +| Repeated errors (3+) | Back off for 30 seconds, then reset counter | +| Session expired (`errcode=-14`) | Pause for 10 minutes (re-login may be needed) | +| Timeout | Immediately re-poll (normal long-poll behavior) | + +### Deduplication + +Inbound messages are deduplicated using message IDs with a 5-minute window. This prevents double-processing during network hiccups or overlapping poll responses. + +### Token Lock + +Only one Weixin gateway instance can use a given token at a time. The adapter acquires a scoped lock on startup and releases it on shutdown. If another gateway is already using the same token, startup fails with an informative error message. + +## All Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `WEIXIN_ACCOUNT_ID` | โœ… | โ€” | iLink Bot account ID (from QR login) | +| `WEIXIN_TOKEN` | โœ… | โ€” | iLink Bot token (auto-saved from QR login) | +| `WEIXIN_BASE_URL` | โ€” | `https://ilinkai.weixin.qq.com` | iLink API base URL | +| `WEIXIN_CDN_BASE_URL` | โ€” | `https://novac2c.cdn.weixin.qq.com/c2c` | CDN base URL for media transfer | +| `WEIXIN_DM_POLICY` | โ€” | `open` | DM access policy: `open`, `allowlist`, `disabled`, `pairing` | +| `WEIXIN_GROUP_POLICY` | โ€” | `disabled` | Group access policy: `open`, `allowlist`, `disabled` | +| `WEIXIN_ALLOWED_USERS` | โ€” | _(empty)_ | Comma-separated user IDs for DM allowlist | +| `WEIXIN_GROUP_ALLOWED_USERS` | โ€” | _(empty)_ | Comma-separated group IDs for group allowlist | +| `WEIXIN_HOME_CHANNEL` | โ€” | โ€” | Chat ID for cron/notification output | +| `WEIXIN_HOME_CHANNEL_NAME` | โ€” | `Home` | Display name for the home channel | +| `WEIXIN_ALLOW_ALL_USERS` | โ€” | โ€” | Gateway-level flag to allow all users (used by setup wizard) | + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `Weixin startup failed: aiohttp and cryptography are required` | Install both: `pip install aiohttp cryptography` | +| `Weixin startup failed: WEIXIN_TOKEN is required` | Run `hermes gateway setup` to complete QR login, or set `WEIXIN_TOKEN` manually | +| `Weixin startup failed: WEIXIN_ACCOUNT_ID is required` | Set `WEIXIN_ACCOUNT_ID` in your `.env` or run `hermes gateway setup` | +| `Another local Hermes gateway is already using this Weixin token` | Stop the other gateway instance first โ€” only one poller per token is allowed | +| Session expired (`errcode=-14`) | Your login session has expired. Re-run `hermes gateway setup` to scan a new QR code | +| QR code expired during setup | The QR auto-refreshes up to 3 times. If it keeps expiring, check your network connection | +| Bot doesn't respond to DMs | Check `WEIXIN_DM_POLICY` โ€” if set to `allowlist`, the sender must be in `WEIXIN_ALLOWED_USERS` | +| Bot ignores group messages | Group policy defaults to `disabled`. Set `WEIXIN_GROUP_POLICY=open` or `allowlist` | +| Media download/upload fails | Ensure `cryptography` is installed. Check network access to `novac2c.cdn.weixin.qq.com` | +| `Blocked unsafe URL (SSRF protection)` | The outbound media URL points to a private/internal address. Only public URLs are allowed | +| Voice messages show as text | If WeChat provides a transcription, the adapter uses the text. This is expected behavior | +| Messages appear duplicated | The adapter deduplicates by message ID. If you see duplicates, check if multiple gateway instances are running | +| `iLink POST ... HTTP 4xx/5xx` | API error from the iLink service. Check your token validity and network connectivity | +| Terminal QR code doesn't render | Install `qrcode`: `pip install qrcode`. Alternatively, open the URL printed above the QR | diff --git a/website/sidebars.ts b/website/sidebars.ts index a8fb0b6b8..875383596 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -108,6 +108,7 @@ const sidebars: SidebarsConfig = { 'user-guide/messaging/dingtalk', 'user-guide/messaging/feishu', 'user-guide/messaging/wecom', + 'user-guide/messaging/weixin', 'user-guide/messaging/bluebubbles', 'user-guide/messaging/open-webui', 'user-guide/messaging/webhooks',