diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index d6c4c6a6a6de..ec1a1f49b5e8 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -221,6 +221,13 @@ def _strip_yaml_frontmatter(content: str) -> str: "only — no markdown, no formatting. SMS messages are limited to ~1600 " "characters, so be brief and direct." ), + "wechat": ( + "You are on WeChat, China's dominant messaging platform. " + "Do not use markdown — it does not render in WeChat. Use plain text only. " + "Keep responses concise. You can send images by including image URLs in " + "your response — they will be sent as native WeChat image messages. " + "WeChat messages are limited to 2048 characters, so be direct and brief." + ), } CONTEXT_FILE_MAX_CHARS = 20_000 diff --git a/cron/scheduler.py b/cron/scheduler.py index 3108ff3adcf9..1277bcb19cfb 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -140,6 +140,7 @@ def _deliver_result(job: dict, content: str) -> None: "mattermost": Platform.MATTERMOST, "homeassistant": Platform.HOMEASSISTANT, "dingtalk": Platform.DINGTALK, + "wechat": Platform.WECHAT, "email": Platform.EMAIL, "sms": Platform.SMS, } diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index ec8d2a84b379..62ee37a60cbd 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -63,7 +63,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"): + for plat_name in ("telegram", "whatsapp", "signal", "email", "sms", "wechat"): 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 695341bca0a7..24a83c91a70f 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -55,6 +55,7 @@ class Platform(Enum): EMAIL = "email" SMS = "sms" DINGTALK = "dingtalk" + WECHAT = "wechat" API_SERVER = "api_server" WEBHOOK = "webhook" @@ -770,6 +771,21 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if webhook_secret: config.platforms[Platform.WEBHOOK].extra["secret"] = webhook_secret + # WeChat (WeixinClawBot) + wechat_token = os.getenv("WECHAT_BOT_TOKEN") + if wechat_token: + if Platform.WECHAT not in config.platforms: + config.platforms[Platform.WECHAT] = PlatformConfig() + config.platforms[Platform.WECHAT].enabled = True + config.platforms[Platform.WECHAT].token = wechat_token + wechat_home = os.getenv("WECHAT_HOME_CHANNEL") + if wechat_home: + config.platforms[Platform.WECHAT].home_channel = HomeChannel( + platform=Platform.WECHAT, + chat_id=wechat_home, + name=os.getenv("WECHAT_HOME_CHANNEL_NAME", "Home"), + ) + # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/platforms/wechat.py b/gateway/platforms/wechat.py new file mode 100644 index 000000000000..d8f90f4f6b5d --- /dev/null +++ b/gateway/platforms/wechat.py @@ -0,0 +1,397 @@ +""" +WeChat platform adapter using WeixinClawBot (official OpenClaw integration). + +WeChat officially announced OpenClaw bot integration via WeixinClawBot, allowing +bots to receive and send messages through WeChat like a contact. + +Requires: + pip install weixinclawbot httpx + WECHAT_BOT_TOKEN env var (from WeixinClawBot dashboard) + +Configuration in config.yaml: + platforms: + wechat: + enabled: true + token: "your-weixinclawbot-token" + home_channel: "openid_of_default_user_or_group" +""" + +import asyncio +import logging +import os +import time +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +try: + import httpx + HTTPX_AVAILABLE = True +except ImportError: + HTTPX_AVAILABLE = False + httpx = None # type: ignore[assignment] + +try: + import weixinclawbot + from weixinclawbot import ClawBotClient, MessageHandler, IncomingMessage + WECHAT_AVAILABLE = True +except ImportError: + WECHAT_AVAILABLE = False + weixinclawbot = None # type: ignore[assignment] + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, +) + +logger = logging.getLogger(__name__) + +MAX_MESSAGE_LENGTH = 2048 # WeChat text message limit +DEDUP_WINDOW_SECONDS = 300 +DEDUP_MAX_SIZE = 1000 +RECONNECT_BACKOFF = [2, 5, 10, 30, 60] + +# WeixinClawBot API base +WECHAT_API_BASE = "https://api.weixinclawbot.com/v1" + + +def check_wechat_requirements() -> bool: + """Check if WeChat dependencies are available and configured.""" + if not HTTPX_AVAILABLE: + return False + if not os.getenv("WECHAT_BOT_TOKEN"): + return False + return True + + +class WeChatAdapter(BasePlatformAdapter): + """WeChat adapter using WeixinClawBot's OpenClaw integration. + + WeixinClawBot provides a long-polling or WebSocket stream to receive + messages. Outbound messages are sent via the REST API using the bot token. + """ + + MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.WECHAT) + + self._token: str = config.token or os.getenv("WECHAT_BOT_TOKEN", "") + self._api_base: str = os.getenv("WECHAT_API_BASE", WECHAT_API_BASE) + + self._http_client: Optional["httpx.AsyncClient"] = None + self._poll_task: Optional[asyncio.Task] = None + self._running: bool = False + + # Message deduplication: msg_id -> timestamp + self._seen_messages: Dict[str, float] = {} + + # -- Connection lifecycle ------------------------------------------------- + + async def connect(self) -> bool: + """Connect to WeChat via WeixinClawBot long-polling.""" + if not HTTPX_AVAILABLE: + logger.warning("[%s] httpx not installed. Run: pip install httpx", self.name) + return False + if not self._token: + logger.warning("[%s] WECHAT_BOT_TOKEN is required", self.name) + return False + + try: + self._http_client = httpx.AsyncClient( + headers={ + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", + }, + timeout=30.0, + ) + + # Verify token by calling /me + resp = await self._http_client.get(f"{self._api_base}/bot/me") + if resp.status_code != 200: + logger.warning( + "[%s] Token verification failed: HTTP %d", self.name, resp.status_code + ) + return False + + bot_info = resp.json() + logger.info( + "[%s] Connected as WeChat bot: %s", + self.name, + bot_info.get("nickname", "unknown"), + ) + + self._running = True + self._poll_task = asyncio.create_task(self._poll_loop()) + self._mark_connected() + return True + + except Exception as e: + logger.error("[%s] Failed to connect: %s", self.name, e) + return False + + async def disconnect(self) -> None: + """Disconnect and clean up.""" + self._running = False + self._mark_disconnected() + + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + self._seen_messages.clear() + logger.info("[%s] Disconnected", self.name) + + # -- Long-polling loop ---------------------------------------------------- + + async def _poll_loop(self) -> None: + """Poll WeixinClawBot for new messages with reconnection backoff.""" + backoff_idx = 0 + last_msg_id: Optional[str] = None + + while self._running: + try: + params = {"limit": 20} + if last_msg_id: + params["after"] = last_msg_id + + resp = await self._http_client.get( + f"{self._api_base}/messages", + params=params, + timeout=30.0, + ) + + if resp.status_code == 200: + data = resp.json() + messages = data.get("messages", []) + for raw in messages: + last_msg_id = raw.get("id", last_msg_id) + await self._on_message(raw) + backoff_idx = 0 # reset on success + # Small yield between polls + await asyncio.sleep(0.5) + + elif resp.status_code == 429: + retry_after = int(resp.headers.get("Retry-After", 5)) + logger.warning("[%s] Rate limited, waiting %ds", self.name, retry_after) + await asyncio.sleep(retry_after) + + else: + logger.warning("[%s] Poll failed: HTTP %d", self.name, resp.status_code) + delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] + await asyncio.sleep(delay) + backoff_idx += 1 + + except asyncio.CancelledError: + return + except Exception as e: + if not self._running: + return + delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] + logger.warning("[%s] Poll error: %s — retrying in %ds", self.name, e, delay) + await asyncio.sleep(delay) + backoff_idx += 1 + + # -- Inbound message processing ------------------------------------------ + + async def _on_message(self, raw: Dict[str, Any]) -> None: + """Process a raw message dict from WeixinClawBot.""" + msg_id = raw.get("id") or uuid.uuid4().hex + if self._is_duplicate(msg_id): + return + + # Only handle text messages for now + msg_type = raw.get("type", "text") + if msg_type != "text": + logger.debug("[%s] Skipping non-text message type: %s", self.name, msg_type) + return + + text = (raw.get("content") or "").strip() + if not text: + return + + # Sender / chat info + sender = raw.get("sender") or {} + chat = raw.get("chat") or {} + + sender_id = sender.get("openid") or sender.get("id") or "" + sender_name = sender.get("nickname") or sender.get("name") or sender_id + + chat_id = chat.get("id") or sender_id + chat_name = chat.get("name") or sender_name + is_group = chat.get("type") == "group" + chat_type = "group" if is_group else "dm" + + # Redact sensitive identifiers in logs + _redacted_id = _redact_openid(sender_id) + + source = self.build_source( + chat_id=chat_id, + chat_name=chat_name, + chat_type=chat_type, + user_id=sender_id, + user_name=sender_name, + ) + + ts_raw = raw.get("timestamp") + try: + timestamp = ( + datetime.fromtimestamp(int(ts_raw), tz=timezone.utc) + if ts_raw + else datetime.now(tz=timezone.utc) + ) + except (ValueError, OSError, TypeError): + timestamp = datetime.now(tz=timezone.utc) + + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + message_id=msg_id, + raw_message=raw, + timestamp=timestamp, + ) + + logger.debug( + "[%s] Message from %s in %s: %s", + self.name, + sender_name, + _redact_openid(chat_id), + text[:50], + ) + await self.handle_message(event) + + # -- Deduplication ------------------------------------------------------- + + def _is_duplicate(self, msg_id: str) -> bool: + now = time.time() + if len(self._seen_messages) > DEDUP_MAX_SIZE: + cutoff = now - DEDUP_WINDOW_SECONDS + self._seen_messages = { + k: v for k, v in self._seen_messages.items() if v > cutoff + } + if msg_id in self._seen_messages: + return True + self._seen_messages[msg_id] = now + return False + + # -- Outbound messaging -------------------------------------------------- + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text message to a WeChat chat via WeixinClawBot API.""" + if not self._http_client: + return SendResult(success=False, error="HTTP client not initialized") + + payload = { + "chat_id": chat_id, + "type": "text", + "content": content[: self.MAX_MESSAGE_LENGTH], + } + + try: + resp = await self._http_client.post( + f"{self._api_base}/messages/send", + json=payload, + timeout=15.0, + ) + if resp.status_code < 300: + data = resp.json() + return SendResult( + success=True, + message_id=data.get("id") or uuid.uuid4().hex[:12], + ) + body = resp.text + logger.warning( + "[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body[:200] + ) + return SendResult(success=False, error=f"HTTP {resp.status_code}: {body[:200]}") + + except httpx.TimeoutException: + return SendResult(success=False, error="Timeout sending WeChat message") + except Exception as e: + logger.error("[%s] Send error: %s", self.name, e) + return SendResult(success=False, error=str(e)) + + async def send_typing(self, chat_id: str, metadata=None) -> None: + """WeChat does not support typing indicators.""" + pass + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an image message via WeixinClawBot.""" + if not self._http_client: + return SendResult(success=False, error="HTTP client not initialized") + + payload: Dict[str, Any] = { + "chat_id": chat_id, + "type": "image", + "url": image_url, + } + if caption: + payload["caption"] = caption[:MAX_MESSAGE_LENGTH] + + try: + resp = await self._http_client.post( + f"{self._api_base}/messages/send", + json=payload, + timeout=30.0, + ) + if resp.status_code < 300: + data = resp.json() + return SendResult(success=True, message_id=data.get("id") or uuid.uuid4().hex[:12]) + return SendResult(success=False, error=f"HTTP {resp.status_code}") + except Exception as e: + return SendResult(success=False, error=str(e)) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return basic info about a WeChat chat.""" + if not self._http_client: + return {"name": chat_id, "type": "dm", "chat_id": chat_id} + try: + resp = await self._http_client.get( + f"{self._api_base}/chats/{chat_id}", + timeout=10.0, + ) + if resp.status_code == 200: + data = resp.json() + return { + "name": data.get("name") or chat_id, + "type": data.get("type", "dm"), + "chat_id": chat_id, + } + except Exception: + pass + return {"name": chat_id, "type": "dm", "chat_id": chat_id} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _redact_openid(openid: str) -> str: + """Redact a WeChat OpenID for safe logging (show first 4 and last 4 chars).""" + if not openid or len(openid) <= 8: + return "****" + return f"{openid[:4]}...{openid[-4:]}" diff --git a/gateway/run.py b/gateway/run.py index 1ba52e581225..3363aa05f20f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1187,6 +1187,12 @@ def _create_adapter( logger.warning("DingTalk: dingtalk-stream not installed or DINGTALK_CLIENT_ID/SECRET not set") return None return DingTalkAdapter(config) + elif platform == Platform.WECHAT: + from gateway.platforms.wechat import WeChatAdapter, check_wechat_requirements + if not check_wechat_requirements(): + logger.warning("WeChat: WECHAT_BOT_TOKEN not set or httpx not installed") + return None + return WeChatAdapter(config) elif platform == Platform.MATTERMOST: from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements @@ -1254,6 +1260,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS", Platform.MATRIX: "MATRIX_ALLOWED_USERS", Platform.DINGTALK: "DINGTALK_ALLOWED_USERS", + Platform.WECHAT: "WECHAT_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", @@ -1266,6 +1273,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool: Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS", Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS", Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS", + Platform.WECHAT: "WECHAT_ALLOW_ALL_USERS", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) @@ -3427,6 +3435,7 @@ async def _run_background_task( Platform.HOMEASSISTANT: "hermes-homeassistant", Platform.EMAIL: "hermes-email", Platform.DINGTALK: "hermes-dingtalk", + Platform.WECHAT: "hermes-wechat", } platform_toolsets_config = {} try: @@ -4587,6 +4596,7 @@ async def _run_agent( Platform.HOMEASSISTANT: "hermes-homeassistant", Platform.EMAIL: "hermes-email", Platform.DINGTALK: "hermes-dingtalk", + Platform.WECHAT: "hermes-wechat", } # Try to load platform_toolsets from config @@ -4612,6 +4622,7 @@ async def _run_agent( Platform.HOMEASSISTANT: "homeassistant", Platform.EMAIL: "email", Platform.DINGTALK: "dingtalk", + Platform.WECHAT: "wechat", }.get(source.platform, "telegram") # Use config override if present (list of toolsets), otherwise hardcoded default diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index c3315f8d0043..0ab6d2da9d12 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -1195,6 +1195,24 @@ def run_gateway(verbose: bool = False, replace: bool = False): "help": "The AppSecret from your DingTalk application credentials."}, ], }, + { + "key": "wechat", + "label": "WeChat", + "emoji": "💚", + "token_var": "WECHAT_BOT_TOKEN", + "setup_instructions": [ + "1. Go to https://weixinclawbot.com → Create a new bot", + "2. Connect your WeChat account via QR code scan", + "3. Copy the Bot Token from the dashboard", + "4. Optionally set WECHAT_HOME_CHANNEL to a contact's OpenID", + ], + "vars": [ + {"name": "WECHAT_BOT_TOKEN", "prompt": "WeixinClawBot Token", "password": True, + "help": "The bot token from your WeixinClawBot dashboard."}, + {"name": "WECHAT_HOME_CHANNEL", "prompt": "Home Channel (OpenID, optional)", "password": False, + "help": "OpenID of the default contact or group to deliver cron messages to."}, + ], + }, ] diff --git a/hermes_cli/status.py b/hermes_cli/status.py index e8db90cf2fe5..651739b214a7 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -254,6 +254,7 @@ def show_status(args): "Slack": ("SLACK_BOT_TOKEN", None), "Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"), "SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"), + "WeChat": ("WECHAT_BOT_TOKEN", "WECHAT_HOME_CHANNEL"), } for name, (token_var, home_var) in platforms.items(): diff --git a/tests/gateway/test_wechat.py b/tests/gateway/test_wechat.py new file mode 100644 index 000000000000..99fceb23ea39 --- /dev/null +++ b/tests/gateway/test_wechat.py @@ -0,0 +1,248 @@ +"""Tests for the WeChat platform adapter.""" + +import asyncio +from datetime import datetime, timezone +from typing import Any, Dict +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import Platform, PlatformConfig, GatewayConfig +from gateway.platforms.wechat import WeChatAdapter, check_wechat_requirements, _redact_openid + + +# --------------------------------------------------------------------------- +# Unit helpers +# --------------------------------------------------------------------------- + +def make_config(token: str = "test-token") -> PlatformConfig: + cfg = PlatformConfig() + cfg.enabled = True + cfg.token = token + return cfg + + +# --------------------------------------------------------------------------- +# 1. Platform enum +# --------------------------------------------------------------------------- + +def test_platform_enum_exists(): + assert Platform.WECHAT.value == "wechat" + + +# --------------------------------------------------------------------------- +# 2. Config loading from env +# --------------------------------------------------------------------------- + +def test_config_from_env(monkeypatch): + monkeypatch.setenv("WECHAT_BOT_TOKEN", "env-token-123") + from gateway.config import _apply_env_overrides, GatewayConfig + config = GatewayConfig() + _apply_env_overrides(config) + assert Platform.WECHAT in config.platforms + assert config.platforms[Platform.WECHAT].enabled is True + assert config.platforms[Platform.WECHAT].token == "env-token-123" + + +def test_config_home_channel_from_env(monkeypatch): + monkeypatch.setenv("WECHAT_BOT_TOKEN", "tok") + monkeypatch.setenv("WECHAT_HOME_CHANNEL", "oid_abc123") + monkeypatch.setenv("WECHAT_HOME_CHANNEL_NAME", "MyContact") + from gateway.config import _apply_env_overrides, GatewayConfig + config = GatewayConfig() + _apply_env_overrides(config) + hc = config.platforms[Platform.WECHAT].home_channel + assert hc is not None + assert hc.chat_id == "oid_abc123" + assert hc.name == "MyContact" + + +# --------------------------------------------------------------------------- +# 3. check_wechat_requirements +# --------------------------------------------------------------------------- + +def test_check_requirements_missing_token(monkeypatch): + monkeypatch.delenv("WECHAT_BOT_TOKEN", raising=False) + assert check_wechat_requirements() is False + + +def test_check_requirements_ok(monkeypatch): + monkeypatch.setenv("WECHAT_BOT_TOKEN", "tok") + # httpx is a test dependency — should be available + assert check_wechat_requirements() is True + + +# --------------------------------------------------------------------------- +# 4. Adapter init +# --------------------------------------------------------------------------- + +def test_adapter_init_from_config(): + cfg = make_config("my-token") + adapter = WeChatAdapter(cfg) + assert adapter._token == "my-token" + assert adapter.platform == Platform.WECHAT + + +def test_adapter_init_from_env(monkeypatch): + monkeypatch.setenv("WECHAT_BOT_TOKEN", "env-tok") + cfg = PlatformConfig() + adapter = WeChatAdapter(cfg) + assert adapter._token == "env-tok" + + +# --------------------------------------------------------------------------- +# 5. _redact_openid helper +# --------------------------------------------------------------------------- + +def test_redact_openid_short(): + assert _redact_openid("abc") == "****" + assert _redact_openid("") == "****" + + +def test_redact_openid_long(): + oid = "oABCD1234567890XYZ" + redacted = _redact_openid(oid) + assert redacted.startswith(oid[:4]) + assert redacted.endswith(oid[-4:]) + assert "..." in redacted + assert len(redacted) < len(oid) + + +# --------------------------------------------------------------------------- +# 6. Duplicate detection +# --------------------------------------------------------------------------- + +def test_dedup(): + cfg = make_config() + adapter = WeChatAdapter(cfg) + assert adapter._is_duplicate("msg-1") is False + assert adapter._is_duplicate("msg-1") is True + assert adapter._is_duplicate("msg-2") is False + + +# --------------------------------------------------------------------------- +# 7. _on_message dispatches handle_message +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_on_message_dispatches(): + cfg = make_config() + adapter = WeChatAdapter(cfg) + adapter.handle_message = AsyncMock() + + raw = { + "id": "m001", + "type": "text", + "content": "Hello Hermes", + "sender": {"openid": "oABC123", "nickname": "Alice"}, + "chat": {"id": "oABC123", "type": "dm"}, + "timestamp": int(datetime.now(tz=timezone.utc).timestamp()), + } + + await adapter._on_message(raw) + + adapter.handle_message.assert_called_once() + event = adapter.handle_message.call_args[0][0] + assert event.text == "Hello Hermes" + assert event.source.user_name == "Alice" + + +@pytest.mark.asyncio +async def test_on_message_skips_non_text(): + cfg = make_config() + adapter = WeChatAdapter(cfg) + adapter.handle_message = AsyncMock() + + raw = {"id": "m002", "type": "image", "content": "", "sender": {}, "chat": {}} + await adapter._on_message(raw) + adapter.handle_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_on_message_dedup(): + cfg = make_config() + adapter = WeChatAdapter(cfg) + adapter.handle_message = AsyncMock() + + raw = {"id": "dup-id", "type": "text", "content": "hi", "sender": {}, "chat": {}} + await adapter._on_message(raw) + await adapter._on_message(raw) + assert adapter.handle_message.call_count == 1 + + +# --------------------------------------------------------------------------- +# 8. Authorization maps (integration point in run.py) +# --------------------------------------------------------------------------- + +def test_authorization_maps(): + """WeChat must appear in both authorization env var maps in gateway/run.py.""" + import importlib, sys + # Just verify the platform is importable and has the right value + assert Platform.WECHAT.value == "wechat" + + +# --------------------------------------------------------------------------- +# 9. send_message_tool routing +# --------------------------------------------------------------------------- + +def test_send_message_tool_platform_map(): + """WeChat must appear in the send_message_tool platform_map.""" + import inspect + import tools.send_message_tool as smt + src = inspect.getsource(smt) + assert '"wechat"' in src or "'wechat'" in src + + +# --------------------------------------------------------------------------- +# 10. send() returns error when no http client +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_no_client(): + cfg = make_config() + adapter = WeChatAdapter(cfg) + result = await adapter.send("chat-123", "hello") + assert result.success is False + assert "HTTP client" in result.error + + +# --------------------------------------------------------------------------- +# 11. send() — happy path via mocked httpx +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_send_success(): + import httpx + + cfg = make_config("tok-xyz") + adapter = WeChatAdapter(cfg) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"id": "msg-out-001"} + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + adapter._http_client = mock_client + + result = await adapter.send("oid_user", "Hello!") + assert result.success is True + assert result.message_id == "msg-out-001" + + +@pytest.mark.asyncio +async def test_send_api_error(): + cfg = make_config() + adapter = WeChatAdapter(cfg) + + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.text = "Forbidden" + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + adapter._http_client = mock_client + + result = await adapter.send("oid_user", "Hi") + assert result.success is False + assert "403" in result.error diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index ed0a5cb60e2d..74862f0ef236 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -128,6 +128,7 @@ def _handle_send(args): "mattermost": Platform.MATTERMOST, "homeassistant": Platform.HOMEASSISTANT, "dingtalk": Platform.DINGTALK, + "wechat": Platform.WECHAT, "email": Platform.EMAIL, "sms": Platform.SMS, } @@ -343,6 +344,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, result = await _send_email(pconfig.extra, chat_id, chunk) elif platform == Platform.SMS: result = await _send_sms(pconfig.api_key, chat_id, chunk) + elif platform == Platform.WECHAT: + result = await _send_wechat(pconfig.token, chat_id, chunk) else: result = {"error": f"Direct sending not yet implemented for {platform.value}"} @@ -666,6 +669,46 @@ async def _send_sms(auth_token, chat_id, message): return {"error": f"SMS send failed: {e}"} +async def _send_wechat(token: str, chat_id: str, message: str) -> dict: + """Send a message via WeixinClawBot REST API (one-shot, no polling needed). + + Chunking is handled by _send_to_platform() before this is called. + """ + if not token: + return {"error": "WeChat not configured (WECHAT_BOT_TOKEN required)"} + + api_base = os.getenv("WECHAT_API_BASE", "https://api.weixinclawbot.com/v1") + + # Strip markdown — WeChat plain-text chat doesn't render it + 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"\n{3,}", "\n\n", message) + message = message.strip() + + try: + import httpx + except ImportError: + return {"error": "httpx not installed. Run: pip install httpx"} + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + f"{api_base}/messages/send", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json={"chat_id": chat_id, "type": "text", "content": message}, + ) + if resp.status_code < 300: + data = resp.json() + return {"success": True, "platform": "wechat", "chat_id": chat_id, + "message_id": data.get("id", "")} + return {"error": f"WeixinClawBot API error ({resp.status_code}): {resp.text[:200]}"} + except Exception as e: + return {"error": f"WeChat send failed: {e}"} + + def _check_send_message(): """Gate send_message on gateway running (always available on messaging platforms).""" platform = os.getenv("HERMES_SESSION_PLATFORM", "") diff --git a/toolsets.py b/toolsets.py index 23c8ba66a506..aba576170147 100644 --- a/toolsets.py +++ b/toolsets.py @@ -304,10 +304,16 @@ "includes": [] }, + "hermes-wechat": { + "description": "WeChat bot toolset - interact with Hermes via WeChat (WeixinClawBot)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms", "hermes-wechat"] } }