From 9efffc76f500c897a62f951b5d3441a067e1732c Mon Sep 17 00:00:00 2001 From: "rui.zhou" Date: Fri, 26 Jun 2026 16:10:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20WeCom=20(=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1)=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WecomChannel: HTTP webhook receiver + WeCom Agent API sender - _WecomCrypto: AES-256-CBC decrypt/verify matching WeCom Bot protocol - _WecomAgentClient: token cache, send_text, upload/send/download media - Handles text, image, voice, file, video inbound message types - Config: WECOM_TOKEN, WECOM_ENCODING_AES_KEY, WECOM_CORP_ID, WECOM_CORP_SECRET, WECOM_AGENT_ID, WECOM_WEBHOOK_PORT - CLI: uv run agent-box --wecom --- sample.env | 9 + src/agent_box/channels/wecom.py | 472 ++++++++++++++++++++++++++++++++ src/agent_box/config.py | 9 + src/agent_box/main.py | 7 +- 4 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 src/agent_box/channels/wecom.py diff --git a/sample.env b/sample.env index 67c4e35..2088f04 100644 --- a/sample.env +++ b/sample.env @@ -9,3 +9,12 @@ ENABLE_TOOL_SEARCH=0 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + +# WeCom (企业微信) Bot Webhook channel +# Run: uv run agent-box --wecom +WECOM_TOKEN= +WECOM_ENCODING_AES_KEY= +WECOM_CORP_ID= +WECOM_CORP_SECRET= +WECOM_AGENT_ID= +WECOM_WEBHOOK_PORT=8088 \ No newline at end of file diff --git a/src/agent_box/channels/wecom.py b/src/agent_box/channels/wecom.py new file mode 100644 index 0000000..64d04f3 --- /dev/null +++ b/src/agent_box/channels/wecom.py @@ -0,0 +1,472 @@ +"""WeCom (企业微信) channel adapter. + +Inbound: HTTP webhook receiver (WeCom Bot callback mode). + - GET /wecom → URL verification (echostr decryption) + - POST /wecom → message callback (AES decrypt → parse → emit) + +Outbound: WeCom Agent HTTP API (corpId + corpSecret + agentId). + +Required config: + WECOM_TOKEN — Webhook 验证 token + WECOM_ENCODING_AES_KEY — 43-char Base64 AES key + WECOM_CORP_ID — 企业 ID (corpId) + WECOM_CORP_SECRET — 应用 secret + WECOM_AGENT_ID — 应用 agentId + WECOM_WEBHOOK_PORT — HTTP 监听端口 (default 8088) +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import mimetypes +import random +import struct +import time +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +import anyio +import anyio.from_thread +import httpx +from anyio.abc import ObjectSendStream + +from ..config import settings +from ..models import IncomingMessage, MessageType, OutgoingMessage +from .base import BaseChannel + +log = logging.getLogger(__name__) + +WECOM_GET_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" +WECOM_SEND_MSG_URL = "https://qyapi.weixin.qq.com/cgi-bin/message/send" +WECOM_UPLOAD_MEDIA_URL = "https://qyapi.weixin.qq.com/cgi-bin/media/upload" +WECOM_DOWNLOAD_MEDIA_URL = "https://qyapi.weixin.qq.com/cgi-bin/media/get" + +_IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}) +_AUDIO_EXTS = frozenset({".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".amr"}) + + +# ── WeCom AES crypto (PKCS#7 + AES-CBC) ───────────────────────────────────── + +def _wecom_sha1(*parts: str) -> str: + return hashlib.sha1("".join(sorted(parts)).encode()).hexdigest() + + +def _verify_signature(token: str, timestamp: str, nonce: str, *extras: str) -> str: + return _wecom_sha1(token, timestamp, nonce, *extras) + + +class _WecomCrypto: + """Minimal WeCom AES-256-CBC encrypt/decrypt (mirrors @wecom/aibot-node-sdk WecomCrypto).""" + + def __init__(self, token: str, encoding_aes_key: str, receive_id: str) -> None: + self.token = token + self.receive_id = receive_id + raw = base64.b64decode(encoding_aes_key + "=") # pad to multiple of 4 + self._key = raw[:32] + self._iv = raw[:16] + + def verify_signature(self, signature: str, timestamp: str, nonce: str, encrypt: str) -> bool: + expected = _wecom_sha1(self.token, timestamp, nonce, encrypt) + return hmac.compare_digest(expected, signature) + + def decrypt(self, encrypt: str) -> str: + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + raw = base64.b64decode(encrypt) + cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv)) + dec = cipher.decryptor() + plain = dec.update(raw) + dec.finalize() + # skip random 16-byte prefix, then 4-byte big-endian length + content_len = struct.unpack(">I", plain[16:20])[0] + content = plain[20 : 20 + content_len].decode("utf-8") + return content + + def encrypt(self, plain: str) -> tuple[str, str, str]: + """Returns (encrypt_b64, timestamp, nonce).""" + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + nonce = str(random.randint(100000, 999999)) + timestamp = str(int(time.time())) + content = plain.encode("utf-8") + rand16 = bytes(random.getrandbits(8) for _ in range(16)) + msg = rand16 + struct.pack(">I", len(content)) + content + self.receive_id.encode() + # PKCS#7 pad to 32-byte blocks + pad = 32 - len(msg) % 32 + msg += bytes([pad]) * pad + cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv)) + enc = cipher.encryptor() + encrypted = enc.update(msg) + enc.finalize() + encrypt_b64 = base64.b64encode(encrypted).decode() + sig = _wecom_sha1(self.token, timestamp, nonce, encrypt_b64) + return encrypt_b64, timestamp, nonce + + +# ── WeCom Agent API client ─────────────────────────────────────────────────── + +class _WecomAgentClient: + def __init__(self, corp_id: str, corp_secret: str, agent_id: int) -> None: + self._corp_id = corp_id + self._corp_secret = corp_secret + self._agent_id = agent_id + self._token: str | None = None + self._token_expires_at: float = 0.0 + + async def _get_token(self) -> str: + if self._token and time.time() < self._token_expires_at - 300: + return self._token + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + WECOM_GET_TOKEN_URL, + params={"corpid": self._corp_id, "corpsecret": self._corp_secret}, + ) + resp.raise_for_status() + data = resp.json() + if not data.get("access_token"): + raise RuntimeError(f"WeCom gettoken failed: {data}") + self._token = data["access_token"] + self._token_expires_at = time.time() + data.get("expires_in", 7200) + return self._token + + async def send_text(self, to_user: str, text: str) -> None: + token = await self._get_token() + body: dict[str, Any] = { + "touser": to_user, + "msgtype": "text", + "agentid": self._agent_id, + "text": {"content": text}, + } + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + f"{WECOM_SEND_MSG_URL}?access_token={token}", + json=body, + ) + resp.raise_for_status() + data = resp.json() + if data.get("errcode", 0) != 0: + raise RuntimeError(f"WeCom send_text failed: {data}") + + async def upload_media(self, file_path: str, media_type: str) -> str: + """Upload temporary media, return media_id.""" + token = await self._get_token() + p = Path(file_path) + suffix = p.suffix.lower() + mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + content = p.read_bytes() + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post( + f"{WECOM_UPLOAD_MEDIA_URL}?access_token={token}&type={media_type}", + files={"media": (p.name, content, mime)}, + ) + resp.raise_for_status() + data = resp.json() + if not data.get("media_id"): + raise RuntimeError(f"WeCom upload_media failed: {data}") + return data["media_id"] + + async def send_media(self, to_user: str, media_id: str, media_type: str) -> None: + token = await self._get_token() + body: dict[str, Any] = { + "touser": to_user, + "msgtype": media_type, + "agentid": self._agent_id, + media_type: {"media_id": media_id}, + } + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + f"{WECOM_SEND_MSG_URL}?access_token={token}", + json=body, + ) + resp.raise_for_status() + data = resp.json() + if data.get("errcode", 0) != 0: + raise RuntimeError(f"WeCom send_media failed: {data}") + + async def download_media(self, media_id: str, dest_dir: Path) -> str | None: + token = await self._get_token() + dest_dir.mkdir(parents=True, exist_ok=True) + try: + async with httpx.AsyncClient(timeout=60) as client: + async with client.stream( + "GET", + f"{WECOM_DOWNLOAD_MEDIA_URL}?access_token={token}&media_id={media_id}", + ) as resp: + resp.raise_for_status() + ct = resp.headers.get("content-type", "").split(";")[0].strip() + ext = mimetypes.guess_extension(ct) if ct else None + dest = dest_dir / f"media-{media_id}{ext or '.bin'}" + dest.write_bytes(await resp.aread()) + return str(dest) + except Exception: + log.exception("wecom: failed to download media_id=%s", media_id) + return None + + +# ── Inbound message parsing ────────────────────────────────────────────────── + +def _parse_wecom_xml(xml_text: str) -> dict[str, str]: + """Parse WeCom callback XML into a flat dict.""" + root = ET.fromstring(xml_text) + return {child.tag: (child.text or "") for child in root} + + +def _detect_media_type(file_path: str) -> str: + ext = Path(file_path).suffix.lower() + if ext in _IMAGE_EXTS: + return "image" + if ext in _AUDIO_EXTS: + return "voice" + return "file" + + +# ── HTTP webhook server ────────────────────────────────────────────────────── + +async def _handle_http( + request_method: str, + query: dict[str, str], + body_bytes: bytes, + crypto: _WecomCrypto, + send_stream: ObjectSendStream[IncomingMessage], + agent: _WecomAgentClient, + download_dir: Path, +) -> tuple[int, str, str]: + """Handle one HTTP request. Returns (status, content_type, body).""" + msg_sig = query.get("msg_signature", query.get("signature", "")) + timestamp = query.get("timestamp", "") + nonce = query.get("nonce", "") + + if request_method == "GET": + echostr = query.get("echostr", "") + if not all([msg_sig, timestamp, nonce, echostr]): + return 400, "text/plain", "missing params" + if not crypto.verify_signature(msg_sig, timestamp, nonce, echostr): + return 403, "text/plain", "signature mismatch" + try: + plain = crypto.decrypt(echostr) + return 200, "text/plain", plain + except Exception: + log.exception("wecom: echostr decrypt failed") + return 403, "text/plain", "decrypt failed" + + if request_method == "POST": + if not body_bytes: + return 400, "text/plain", "empty body" + + # WeCom POST body is XML with field + try: + root = ET.fromstring(body_bytes.decode("utf-8")) + encrypt = (root.findtext("Encrypt") or "").strip() + except Exception: + return 400, "text/plain", "invalid xml" + + if not encrypt: + return 400, "text/plain", "missing Encrypt" + + if not crypto.verify_signature(msg_sig, timestamp, nonce, encrypt): + return 403, "text/plain", "signature mismatch" + + try: + plain = crypto.decrypt(encrypt) + except Exception: + log.exception("wecom: message decrypt failed") + return 400, "text/plain", "decrypt failed" + + msg = _parse_wecom_xml(plain) + msg_type = msg.get("MsgType", "") + from_user = msg.get("FromUserName", "") + + if not from_user: + return 200, "text/plain", "" + + text = "" + if msg_type == "text": + text = msg.get("Content", "").strip() + elif msg_type in ("image", "voice", "file", "video"): + media_id = msg.get("MediaId", "") + label = {"image": "图片", "voice": "语音", "video": "视频"}.get(msg_type, "文件") + if media_id: + local = await agent.download_media(media_id, download_dir) + if local: + text = f"用户发送了一个{label},文件路径: {local}" + else: + text = f"用户发送了一个{label},文件路径: (下载失败)" + else: + text = f"用户发送了一个{label}" + elif msg_type == "event": + # Ignore events (enter_chat, etc.) + return 200, "text/plain", "" + else: + log.debug("wecom: unhandled msgtype=%s", msg_type) + return 200, "text/plain", "" + + if not text: + return 200, "text/plain", "" + + log.debug("wecom: inbound from %s: %s", from_user, text[:80]) + await send_stream.send( + IncomingMessage( + text=text, + user_id=from_user, + channel="wecom", + raw=msg, + ) + ) + return 200, "text/plain", "" + + return 405, "text/plain", "method not allowed" + + +def _parse_query(url: str) -> dict[str, str]: + idx = url.find("?") + if idx < 0: + return {} + from urllib.parse import parse_qs + qs = parse_qs(url[idx + 1 :], keep_blank_values=True) + return {k: v[0] for k, v in qs.items()} + + +async def _serve_http( + port: int, + path: str, + crypto: _WecomCrypto, + send_stream: ObjectSendStream[IncomingMessage], + agent: _WecomAgentClient, + download_dir: Path, +) -> None: + """Minimal async HTTP server using anyio TCP sockets.""" + listener = await anyio.create_tcp_listener(local_port=port, local_host="0.0.0.0") + log.info("wecom webhook listening on port %d at %s", port, path) + + async def handle(stream: anyio.abc.ByteStream) -> None: + try: + # Read full HTTP request (until headers end) + raw = b"" + async with stream: + while b"\r\n\r\n" not in raw: + chunk = await stream.receive(4096) + if not chunk: + break + raw += chunk + + header_end = raw.index(b"\r\n\r\n") + header_bytes = raw[:header_end] + body_so_far = raw[header_end + 4 :] + + lines = header_bytes.decode("latin-1").split("\r\n") + request_line = lines[0] + parts = request_line.split(" ") + method = parts[0] if parts else "GET" + url = parts[1] if len(parts) > 1 else "/" + + headers: dict[str, str] = {} + for line in lines[1:]: + if ":" in line: + k, _, v = line.partition(":") + headers[k.strip().lower()] = v.strip() + + content_length = int(headers.get("content-length", "0")) + while len(body_so_far) < content_length: + chunk = await stream.receive(4096) + if not chunk: + break + body_so_far += chunk + + req_path = url.split("?")[0] + if req_path != path: + resp = b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nNot Found" + await stream.send(resp) + return + + query = _parse_query(url) + status, ct, body_str = await _handle_http( + method, query, body_so_far, crypto, send_stream, agent, download_dir + ) + body_b = body_str.encode("utf-8") + resp = ( + f"HTTP/1.1 {status} OK\r\n" + f"Content-Type: {ct}; charset=utf-8\r\n" + f"Content-Length: {len(body_b)}\r\n" + f"Connection: close\r\n\r\n" + ).encode() + body_b + await stream.send(resp) + except Exception: + log.exception("wecom: HTTP handler error") + + async with listener: + async with anyio.create_task_group() as tg: + async for stream in listener: # type: ignore[attr-defined] + tg.start_soon(handle, stream) + + +# ── WecomChannel ───────────────────────────────────────────────────────────── + +class WecomChannel(BaseChannel): + """WeCom Bot webhook channel + Agent API for sending.""" + + def __init__(self, send_stream: ObjectSendStream[IncomingMessage]) -> None: + super().__init__(send_stream) + self._crypto = _WecomCrypto( + token=settings.wecom_token, + encoding_aes_key=settings.wecom_encoding_aes_key, + receive_id=settings.wecom_corp_id, + ) + self._agent = _WecomAgentClient( + corp_id=settings.wecom_corp_id, + corp_secret=settings.wecom_corp_secret, + agent_id=settings.wecom_agent_id, + ) + self._port = settings.wecom_webhook_port + self._path = settings.wecom_webhook_path + self._download_dir = settings.config_dir / "channels" / "wecom" / "downloads" + + def _check_config(self) -> bool: + missing = [ + name for name, val in [ + ("WECOM_TOKEN", settings.wecom_token), + ("WECOM_ENCODING_AES_KEY", settings.wecom_encoding_aes_key), + ("WECOM_CORP_ID", settings.wecom_corp_id), + ("WECOM_CORP_SECRET", settings.wecom_corp_secret), + ("WECOM_AGENT_ID", str(settings.wecom_agent_id)), + ] if not val + ] + if missing: + log.error("WeCom channel: missing config: %s", ", ".join(missing)) + return False + return True + + async def start(self) -> None: + if not self._check_config(): + await self.send_stream.aclose() + return + try: + await _serve_http( + port=self._port, + path=self._path, + crypto=self._crypto, + send_stream=self.send_stream, + agent=self._agent, + download_dir=self._download_dir, + ) + finally: + await self.send_stream.aclose() + + async def send_reply(self, msg: OutgoingMessage) -> None: + if msg.type != MessageType.text: + return + + data = msg.data or {} + file_path: str | None = data.get("file_path") or data.get("image_path") + if file_path: + try: + media_type = _detect_media_type(file_path) + media_id = await self._agent.upload_media(file_path, media_type) + await self._agent.send_media(msg.user_id, media_id, media_type) + except Exception: + log.exception("wecom: failed to send file %s", file_path) + return + + try: + await self._agent.send_text(msg.user_id, msg.text) + except Exception: + log.exception("wecom: failed to send text to %s", msg.user_id) diff --git a/src/agent_box/config.py b/src/agent_box/config.py index 52aee95..c62b5e4 100644 --- a/src/agent_box/config.py +++ b/src/agent_box/config.py @@ -20,6 +20,15 @@ class Settings(BaseSettings): qqbot_app_id: str = "" qqbot_client_secret: str = "" + # WeCom (企业微信) Bot Webhook channel + wecom_token: str = "" + wecom_encoding_aes_key: str = "" # 43-char Base64 + wecom_corp_id: str = "" + wecom_corp_secret: str = "" + wecom_agent_id: int = 0 + wecom_webhook_port: int = 8088 + wecom_webhook_path: str = "/wecom" + # GLM (ZhipuAI) ASR — voice-to-text for audio attachments. Empty skips transcription. glm_api_key: str = "" glm_asr_model: str = "glm-asr-2512" diff --git a/src/agent_box/main.py b/src/agent_box/main.py index 2d9cac4..dc00627 100644 --- a/src/agent_box/main.py +++ b/src/agent_box/main.py @@ -39,6 +39,9 @@ def _create_channel(self, channel_type: str, send_in: anyio.abc.ObjectSendStream elif channel_type == "qq": from .channels.qq import QQChannel return QQChannel(send_in) + elif channel_type == "wecom": + from .channels.wecom import WecomChannel + return WecomChannel(send_in) else: from .channels.weixin import WeixinChannel return WeixinChannel(send_in) @@ -245,12 +248,14 @@ def main() -> None: pass return - # Parse channel flags: --qq --weixin --tui + # Parse channel flags: --qq --weixin --tui --wecom channel_types: list[str] = [] if "--qq" in sys.argv: channel_types.append("qq") if "--tui" in sys.argv: channel_types.append("tui") + if "--wecom" in sys.argv: + channel_types.append("wecom") if "--weixin" in sys.argv or not channel_types: channel_types.append("weixin") From 9415660e40c579cd43cd0fa3e6ff862bfaeb7239 Mon Sep 17 00:00:00 2001 From: "rui.zhou" Date: Mon, 6 Jul 2026 18:04:42 +0800 Subject: [PATCH 2/2] feat(wecom): rewrite channel to WebSocket mode + add wecom_mcp tool + CLI help - Rewrite wecom channel from HTTP callback to WebSocket long connection using official wecom-aibot-sdk (only needs bot_id + secret) - Add wecom_mcp SDK MCP tool for calling WeCom MCP Server (contact, doc, schedule, meeting, todo, msg, smartsheet) - Tool is conditionally registered only when --wecom channel is active - Add --help/-h CLI option with usage documentation - Simplify config: WECOM_BOT_ID + WECOM_SECRET replaces 6 old params - Add wecom-aibot-sdk>=1.0.8 dependency --- pyproject.toml | 1 + sample.env | 10 +- src/agent_box/agents/claude_code.py | 6 + src/agent_box/channels/wecom.py | 627 ++++++++++------------------ src/agent_box/config.py | 11 +- src/agent_box/main.py | 34 ++ src/agent_box/tools/__init__.py | 0 src/agent_box/tools/wecom_mcp.py | 349 ++++++++++++++++ uv.lock | 16 + 9 files changed, 638 insertions(+), 416 deletions(-) create mode 100644 src/agent_box/tools/__init__.py create mode 100644 src/agent_box/tools/wecom_mcp.py diff --git a/pyproject.toml b/pyproject.toml index 3c15813..d70c389 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "textual>=3.0", "httpx>=0.27", "websockets>=12.0", + "wecom-aibot-sdk>=1.0.8", ] [project.scripts] diff --git a/sample.env b/sample.env index 2088f04..903585c 100644 --- a/sample.env +++ b/sample.env @@ -10,11 +10,7 @@ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 -# WeCom (企业微信) Bot Webhook channel +# WeCom (企业微信) Bot WebSocket channel # Run: uv run agent-box --wecom -WECOM_TOKEN= -WECOM_ENCODING_AES_KEY= -WECOM_CORP_ID= -WECOM_CORP_SECRET= -WECOM_AGENT_ID= -WECOM_WEBHOOK_PORT=8088 \ No newline at end of file +WECOM_BOT_ID= +WECOM_SECRET= \ No newline at end of file diff --git a/src/agent_box/agents/claude_code.py b/src/agent_box/agents/claude_code.py index 03202d4..4739655 100644 --- a/src/agent_box/agents/claude_code.py +++ b/src/agent_box/agents/claude_code.py @@ -410,6 +410,12 @@ def _build_options(self) -> ClaudeAgentOptions: ) if self.project.session_id: opts.resume = self.project.session_id + # Conditionally add wecom_mcp tool when WeCom channel is active + from ..tools.wecom_mcp import is_wecom_mcp_enabled + if is_wecom_mcp_enabled(): + from ..tools.wecom_mcp import create_wecom_mcp_server + opts.mcp_servers = {"wecom_mcp": create_wecom_mcp_server()} + opts.allowed_tools = ["wecom_mcp"] return opts async def _ensure_client(self) -> ClaudeSDKClient: diff --git a/src/agent_box/channels/wecom.py b/src/agent_box/channels/wecom.py index 64d04f3..3b56516 100644 --- a/src/agent_box/channels/wecom.py +++ b/src/agent_box/channels/wecom.py @@ -1,216 +1,36 @@ -"""WeCom (企业微信) channel adapter. +"""WeCom (企业微信) channel adapter — WebSocket long connection mode. -Inbound: HTTP webhook receiver (WeCom Bot callback mode). - - GET /wecom → URL verification (echostr decryption) - - POST /wecom → message callback (AES decrypt → parse → emit) - -Outbound: WeCom Agent HTTP API (corpId + corpSecret + agentId). +Uses the official ``wecom-aibot-sdk`` Python SDK which connects via WebSocket +to wss://openws.work.weixin.qq.com. Only requires two credentials: Required config: - WECOM_TOKEN — Webhook 验证 token - WECOM_ENCODING_AES_KEY — 43-char Base64 AES key - WECOM_CORP_ID — 企业 ID (corpId) - WECOM_CORP_SECRET — 应用 secret - WECOM_AGENT_ID — 应用 agentId - WECOM_WEBHOOK_PORT — HTTP 监听端口 (default 8088) + WECOM_BOT_ID — 机器人 ID (from 企业微信管理后台 → 智能机器人) + WECOM_SECRET — 机器人 Secret """ from __future__ import annotations -import base64 -import hashlib -import hmac +import asyncio import logging import mimetypes -import random -import struct -import time -import xml.etree.ElementTree as ET from pathlib import Path from typing import Any -import anyio -import anyio.from_thread -import httpx from anyio.abc import ObjectSendStream +from wecom_aibot_sdk import WSClient, generate_req_id +from wecom_aibot_sdk.types import WsFrame, MessageType as WeComMsgType + from ..config import settings from ..models import IncomingMessage, MessageType, OutgoingMessage from .base import BaseChannel log = logging.getLogger(__name__) -WECOM_GET_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" -WECOM_SEND_MSG_URL = "https://qyapi.weixin.qq.com/cgi-bin/message/send" -WECOM_UPLOAD_MEDIA_URL = "https://qyapi.weixin.qq.com/cgi-bin/media/upload" -WECOM_DOWNLOAD_MEDIA_URL = "https://qyapi.weixin.qq.com/cgi-bin/media/get" - _IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}) _AUDIO_EXTS = frozenset({".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".amr"}) -# ── WeCom AES crypto (PKCS#7 + AES-CBC) ───────────────────────────────────── - -def _wecom_sha1(*parts: str) -> str: - return hashlib.sha1("".join(sorted(parts)).encode()).hexdigest() - - -def _verify_signature(token: str, timestamp: str, nonce: str, *extras: str) -> str: - return _wecom_sha1(token, timestamp, nonce, *extras) - - -class _WecomCrypto: - """Minimal WeCom AES-256-CBC encrypt/decrypt (mirrors @wecom/aibot-node-sdk WecomCrypto).""" - - def __init__(self, token: str, encoding_aes_key: str, receive_id: str) -> None: - self.token = token - self.receive_id = receive_id - raw = base64.b64decode(encoding_aes_key + "=") # pad to multiple of 4 - self._key = raw[:32] - self._iv = raw[:16] - - def verify_signature(self, signature: str, timestamp: str, nonce: str, encrypt: str) -> bool: - expected = _wecom_sha1(self.token, timestamp, nonce, encrypt) - return hmac.compare_digest(expected, signature) - - def decrypt(self, encrypt: str) -> str: - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - raw = base64.b64decode(encrypt) - cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv)) - dec = cipher.decryptor() - plain = dec.update(raw) + dec.finalize() - # skip random 16-byte prefix, then 4-byte big-endian length - content_len = struct.unpack(">I", plain[16:20])[0] - content = plain[20 : 20 + content_len].decode("utf-8") - return content - - def encrypt(self, plain: str) -> tuple[str, str, str]: - """Returns (encrypt_b64, timestamp, nonce).""" - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - nonce = str(random.randint(100000, 999999)) - timestamp = str(int(time.time())) - content = plain.encode("utf-8") - rand16 = bytes(random.getrandbits(8) for _ in range(16)) - msg = rand16 + struct.pack(">I", len(content)) + content + self.receive_id.encode() - # PKCS#7 pad to 32-byte blocks - pad = 32 - len(msg) % 32 - msg += bytes([pad]) * pad - cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv)) - enc = cipher.encryptor() - encrypted = enc.update(msg) + enc.finalize() - encrypt_b64 = base64.b64encode(encrypted).decode() - sig = _wecom_sha1(self.token, timestamp, nonce, encrypt_b64) - return encrypt_b64, timestamp, nonce - - -# ── WeCom Agent API client ─────────────────────────────────────────────────── - -class _WecomAgentClient: - def __init__(self, corp_id: str, corp_secret: str, agent_id: int) -> None: - self._corp_id = corp_id - self._corp_secret = corp_secret - self._agent_id = agent_id - self._token: str | None = None - self._token_expires_at: float = 0.0 - - async def _get_token(self) -> str: - if self._token and time.time() < self._token_expires_at - 300: - return self._token - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.get( - WECOM_GET_TOKEN_URL, - params={"corpid": self._corp_id, "corpsecret": self._corp_secret}, - ) - resp.raise_for_status() - data = resp.json() - if not data.get("access_token"): - raise RuntimeError(f"WeCom gettoken failed: {data}") - self._token = data["access_token"] - self._token_expires_at = time.time() + data.get("expires_in", 7200) - return self._token - - async def send_text(self, to_user: str, text: str) -> None: - token = await self._get_token() - body: dict[str, Any] = { - "touser": to_user, - "msgtype": "text", - "agentid": self._agent_id, - "text": {"content": text}, - } - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.post( - f"{WECOM_SEND_MSG_URL}?access_token={token}", - json=body, - ) - resp.raise_for_status() - data = resp.json() - if data.get("errcode", 0) != 0: - raise RuntimeError(f"WeCom send_text failed: {data}") - - async def upload_media(self, file_path: str, media_type: str) -> str: - """Upload temporary media, return media_id.""" - token = await self._get_token() - p = Path(file_path) - suffix = p.suffix.lower() - mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream" - content = p.read_bytes() - async with httpx.AsyncClient(timeout=60) as client: - resp = await client.post( - f"{WECOM_UPLOAD_MEDIA_URL}?access_token={token}&type={media_type}", - files={"media": (p.name, content, mime)}, - ) - resp.raise_for_status() - data = resp.json() - if not data.get("media_id"): - raise RuntimeError(f"WeCom upload_media failed: {data}") - return data["media_id"] - - async def send_media(self, to_user: str, media_id: str, media_type: str) -> None: - token = await self._get_token() - body: dict[str, Any] = { - "touser": to_user, - "msgtype": media_type, - "agentid": self._agent_id, - media_type: {"media_id": media_id}, - } - async with httpx.AsyncClient(timeout=15) as client: - resp = await client.post( - f"{WECOM_SEND_MSG_URL}?access_token={token}", - json=body, - ) - resp.raise_for_status() - data = resp.json() - if data.get("errcode", 0) != 0: - raise RuntimeError(f"WeCom send_media failed: {data}") - - async def download_media(self, media_id: str, dest_dir: Path) -> str | None: - token = await self._get_token() - dest_dir.mkdir(parents=True, exist_ok=True) - try: - async with httpx.AsyncClient(timeout=60) as client: - async with client.stream( - "GET", - f"{WECOM_DOWNLOAD_MEDIA_URL}?access_token={token}&media_id={media_id}", - ) as resp: - resp.raise_for_status() - ct = resp.headers.get("content-type", "").split(";")[0].strip() - ext = mimetypes.guess_extension(ct) if ct else None - dest = dest_dir / f"media-{media_id}{ext or '.bin'}" - dest.write_bytes(await resp.aread()) - return str(dest) - except Exception: - log.exception("wecom: failed to download media_id=%s", media_id) - return None - - -# ── Inbound message parsing ────────────────────────────────────────────────── - -def _parse_wecom_xml(xml_text: str) -> dict[str, str]: - """Parse WeCom callback XML into a flat dict.""" - root = ET.fromstring(xml_text) - return {child.tag: (child.text or "") for child in root} - - def _detect_media_type(file_path: str) -> str: ext = Path(file_path).suffix.lower() if ext in _IMAGE_EXTS: @@ -220,253 +40,258 @@ def _detect_media_type(file_path: str) -> str: return "file" -# ── HTTP webhook server ────────────────────────────────────────────────────── - -async def _handle_http( - request_method: str, - query: dict[str, str], - body_bytes: bytes, - crypto: _WecomCrypto, - send_stream: ObjectSendStream[IncomingMessage], - agent: _WecomAgentClient, - download_dir: Path, -) -> tuple[int, str, str]: - """Handle one HTTP request. Returns (status, content_type, body).""" - msg_sig = query.get("msg_signature", query.get("signature", "")) - timestamp = query.get("timestamp", "") - nonce = query.get("nonce", "") - - if request_method == "GET": - echostr = query.get("echostr", "") - if not all([msg_sig, timestamp, nonce, echostr]): - return 400, "text/plain", "missing params" - if not crypto.verify_signature(msg_sig, timestamp, nonce, echostr): - return 403, "text/plain", "signature mismatch" - try: - plain = crypto.decrypt(echostr) - return 200, "text/plain", plain - except Exception: - log.exception("wecom: echostr decrypt failed") - return 403, "text/plain", "decrypt failed" +# ── Message parsing helpers ────────────────────────────────────────────────── + +def _parse_message_body(body: dict[str, Any]) -> tuple[str, list[str]]: + """Parse WeCom message body into (text, image_urls). + + Returns extracted text and list of image URLs that need to be downloaded. + """ + msgtype = body.get("msgtype", "") + text_parts: list[str] = [] + image_urls: list[str] = [] + + if msgtype == "mixed" and body.get("mixed"): + # 图文混排消息 + for item in body["mixed"].get("msg_item", []): + if item.get("msgtype") == "text" and item.get("text", {}).get("content"): + text_parts.append(item["text"]["content"]) + elif item.get("msgtype") == "image" and item.get("image", {}).get("url"): + image_urls.append(item["image"]["url"]) + else: + # 单条消息 + if body.get("text", {}).get("content"): + text_parts.append(body["text"]["content"]) + if msgtype == "voice" and body.get("voice", {}).get("content"): + # 语音转文字 + text_parts.append(body["voice"]["content"]) + if body.get("image", {}).get("url"): + image_urls.append(body["image"]["url"]) + + # 处理引用消息 + quote = body.get("quote") + if quote: + if quote.get("msgtype") == "text" and quote.get("text", {}).get("content"): + if not text_parts: + text_parts.append(quote["text"]["content"]) + elif quote.get("msgtype") == "voice" and quote.get("voice", {}).get("content"): + if not text_parts: + text_parts.append(quote["voice"]["content"]) + elif quote.get("msgtype") == "image" and quote.get("image", {}).get("url"): + image_urls.append(quote["image"]["url"]) + + text = "\n".join(text_parts).strip() + + # 对于纯媒体消息(无文本),生成描述 + if not text and (image_urls or msgtype in ("file", "video")): + label = {"image": "图片", "voice": "语音", "video": "视频", "file": "文件"}.get(msgtype, "文件") + text = f"[用户发送了{label}]" + + return text, image_urls - if request_method == "POST": - if not body_bytes: - return 400, "text/plain", "empty body" - # WeCom POST body is XML with field - try: - root = ET.fromstring(body_bytes.decode("utf-8")) - encrypt = (root.findtext("Encrypt") or "").strip() - except Exception: - return 400, "text/plain", "invalid xml" +# ── WecomChannel ───────────────────────────────────────────────────────────── - if not encrypt: - return 400, "text/plain", "missing Encrypt" +class WecomChannel(BaseChannel): + """WeCom Bot WebSocket channel using official wecom-aibot-sdk.""" - if not crypto.verify_signature(msg_sig, timestamp, nonce, encrypt): - return 403, "text/plain", "signature mismatch" + def __init__(self, send_stream: ObjectSendStream[IncomingMessage]) -> None: + super().__init__(send_stream) + self._client: WSClient | None = None + self._download_dir = settings.config_dir / "channels" / "wecom" / "downloads" + self._download_dir.mkdir(parents=True, exist_ok=True) + + def _check_config(self) -> bool: + missing = [ + name for name, val in [ + ("WECOM_BOT_ID", settings.wecom_bot_id), + ("WECOM_SECRET", settings.wecom_secret), + ] if not val + ] + if missing: + log.error("WeCom channel: missing config: %s", ", ".join(missing)) + return False + return True + + async def start(self) -> None: + if not self._check_config(): + await self.send_stream.aclose() + return + + self._client = WSClient( + bot_id=settings.wecom_bot_id, + secret=settings.wecom_secret, + heartbeat_interval=30000, + max_reconnect_attempts=10, + max_auth_failure_attempts=5, + ) + + # Register event handlers + self._client.on("connected", self._on_connected) + self._client.on("authenticated", self._on_authenticated) + self._client.on("disconnected", self._on_disconnected) + self._client.on("error", self._on_error) + self._client.on("message", self._on_message) + self._client.on("event", self._on_event) try: - plain = crypto.decrypt(encrypt) + await self._client.connect() + # Keep running until cancelled + while True: + await asyncio.sleep(1) + except asyncio.CancelledError: + pass except Exception: - log.exception("wecom: message decrypt failed") - return 400, "text/plain", "decrypt failed" + log.exception("wecom: unexpected error in start loop") + finally: + if self._client: + await self._client.disconnect() + await self.send_stream.aclose() + + # ── Event handlers ──────────────────────────────────────────────── + + def _on_connected(self) -> None: + log.info("wecom: WebSocket connected") + + def _on_authenticated(self) -> None: + log.info("wecom: authenticated successfully") + # Expose WSClient to the wecom_mcp tool + from ..tools.wecom_mcp import set_ws_client + set_ws_client(self._client) + + def _on_disconnected(self, reason: str) -> None: + log.warning("wecom: disconnected: %s", reason) + from ..tools.wecom_mcp import set_ws_client + set_ws_client(None) - msg = _parse_wecom_xml(plain) - msg_type = msg.get("MsgType", "") - from_user = msg.get("FromUserName", "") + def _on_error(self, error: Exception) -> None: + log.error("wecom: error: %s", error) + + async def _on_message(self, frame: WsFrame) -> None: + """Handle incoming message callback.""" + body = frame.get("body") or {} + msgtype = body.get("msgtype", "") + from_user = body.get("from", {}).get("userid", "") + chat_id = body.get("chatid") or from_user if not from_user: - return 200, "text/plain", "" - - text = "" - if msg_type == "text": - text = msg.get("Content", "").strip() - elif msg_type in ("image", "voice", "file", "video"): - media_id = msg.get("MediaId", "") - label = {"image": "图片", "voice": "语音", "video": "视频"}.get(msg_type, "文件") - if media_id: - local = await agent.download_media(media_id, download_dir) - if local: - text = f"用户发送了一个{label},文件路径: {local}" - else: - text = f"用户发送了一个{label},文件路径: (下载失败)" - else: - text = f"用户发送了一个{label}" - elif msg_type == "event": - # Ignore events (enter_chat, etc.) - return 200, "text/plain", "" - else: - log.debug("wecom: unhandled msgtype=%s", msg_type) - return 200, "text/plain", "" + return + + text, image_urls = _parse_message_body(body) + + # Download images to local files + if image_urls: + for url in image_urls: + aes_key = body.get("image", {}).get("aeskey") + local_path = await self._download_media(url, aes_key) + if local_path: + text += f"\n文件路径: {local_path}" if not text: - return 200, "text/plain", "" + return log.debug("wecom: inbound from %s: %s", from_user, text[:80]) - await send_stream.send( + await self.send_stream.send( IncomingMessage( text=text, user_id=from_user, channel="wecom", - raw=msg, + raw={ + "frame": frame, + "chat_id": chat_id, + "chat_type": body.get("chattype", "single"), + }, ) ) - return 200, "text/plain", "" - - return 405, "text/plain", "method not allowed" + async def _on_event(self, frame: WsFrame) -> None: + """Handle event callbacks (template card clicks, etc.).""" + body = frame.get("body") or {} + event = body.get("event", {}) + event_type = event.get("eventtype", "") -def _parse_query(url: str) -> dict[str, str]: - idx = url.find("?") - if idx < 0: - return {} - from urllib.parse import parse_qs - qs = parse_qs(url[idx + 1 :], keep_blank_values=True) - return {k: v[0] for k, v in qs.items()} + # For now, log events but don't route them + log.debug("wecom: event received: type=%s", event_type) + # ── Media download ──────────────────────────────────────────────── -async def _serve_http( - port: int, - path: str, - crypto: _WecomCrypto, - send_stream: ObjectSendStream[IncomingMessage], - agent: _WecomAgentClient, - download_dir: Path, -) -> None: - """Minimal async HTTP server using anyio TCP sockets.""" - listener = await anyio.create_tcp_listener(local_port=port, local_host="0.0.0.0") - log.info("wecom webhook listening on port %d at %s", port, path) - - async def handle(stream: anyio.abc.ByteStream) -> None: + async def _download_media(self, url: str, aes_key: str | None = None) -> str | None: + """Download and optionally decrypt a media file.""" + if not self._client: + return None try: - # Read full HTTP request (until headers end) - raw = b"" - async with stream: - while b"\r\n\r\n" not in raw: - chunk = await stream.receive(4096) - if not chunk: - break - raw += chunk - - header_end = raw.index(b"\r\n\r\n") - header_bytes = raw[:header_end] - body_so_far = raw[header_end + 4 :] - - lines = header_bytes.decode("latin-1").split("\r\n") - request_line = lines[0] - parts = request_line.split(" ") - method = parts[0] if parts else "GET" - url = parts[1] if len(parts) > 1 else "/" - - headers: dict[str, str] = {} - for line in lines[1:]: - if ":" in line: - k, _, v = line.partition(":") - headers[k.strip().lower()] = v.strip() - - content_length = int(headers.get("content-length", "0")) - while len(body_so_far) < content_length: - chunk = await stream.receive(4096) - if not chunk: - break - body_so_far += chunk - - req_path = url.split("?")[0] - if req_path != path: - resp = b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nNot Found" - await stream.send(resp) - return - - query = _parse_query(url) - status, ct, body_str = await _handle_http( - method, query, body_so_far, crypto, send_stream, agent, download_dir - ) - body_b = body_str.encode("utf-8") - resp = ( - f"HTTP/1.1 {status} OK\r\n" - f"Content-Type: {ct}; charset=utf-8\r\n" - f"Content-Length: {len(body_b)}\r\n" - f"Connection: close\r\n\r\n" - ).encode() + body_b - await stream.send(resp) + result = await self._client.download_file(url, aes_key) + buffer: bytes = result["buffer"] + filename: str | None = result.get("filename") + + if not filename: + # Guess extension from content + import magic # noqa: F401 - optional + ext = ".bin" + filename = f"media_{generate_req_id('dl')}{ext}" + + dest = self._download_dir / filename + dest.write_bytes(buffer) + return str(dest) + except ImportError: + # No python-magic, use a generic extension + pass except Exception: - log.exception("wecom: HTTP handler error") - - async with listener: - async with anyio.create_task_group() as tg: - async for stream in listener: # type: ignore[attr-defined] - tg.start_soon(handle, stream) - - -# ── WecomChannel ───────────────────────────────────────────────────────────── - -class WecomChannel(BaseChannel): - """WeCom Bot webhook channel + Agent API for sending.""" - - def __init__(self, send_stream: ObjectSendStream[IncomingMessage]) -> None: - super().__init__(send_stream) - self._crypto = _WecomCrypto( - token=settings.wecom_token, - encoding_aes_key=settings.wecom_encoding_aes_key, - receive_id=settings.wecom_corp_id, - ) - self._agent = _WecomAgentClient( - corp_id=settings.wecom_corp_id, - corp_secret=settings.wecom_corp_secret, - agent_id=settings.wecom_agent_id, - ) - self._port = settings.wecom_webhook_port - self._path = settings.wecom_webhook_path - self._download_dir = settings.config_dir / "channels" / "wecom" / "downloads" + log.exception("wecom: failed to download media from %s", url) + return None - def _check_config(self) -> bool: - missing = [ - name for name, val in [ - ("WECOM_TOKEN", settings.wecom_token), - ("WECOM_ENCODING_AES_KEY", settings.wecom_encoding_aes_key), - ("WECOM_CORP_ID", settings.wecom_corp_id), - ("WECOM_CORP_SECRET", settings.wecom_corp_secret), - ("WECOM_AGENT_ID", str(settings.wecom_agent_id)), - ] if not val - ] - if missing: - log.error("WeCom channel: missing config: %s", ", ".join(missing)) - return False - return True + # ── Outbound reply ──────────────────────────────────────────────── - async def start(self) -> None: - if not self._check_config(): - await self.send_stream.aclose() + async def send_reply(self, msg: OutgoingMessage) -> None: + if not self._client or not self._client.is_connected: + log.warning("wecom: cannot send reply, not connected") return - try: - await _serve_http( - port=self._port, - path=self._path, - crypto=self._crypto, - send_stream=self.send_stream, - agent=self._agent, - download_dir=self._download_dir, - ) - finally: - await self.send_stream.aclose() - async def send_reply(self, msg: OutgoingMessage) -> None: if msg.type != MessageType.text: return - data = msg.data or {} - file_path: str | None = data.get("file_path") or data.get("image_path") + raw = msg.data or {} + chat_id = raw.get("chat_id") or msg.user_id + file_path: str | None = raw.get("file_path") or raw.get("image_path") + if file_path: - try: - media_type = _detect_media_type(file_path) - media_id = await self._agent.upload_media(file_path, media_type) - await self._agent.send_media(msg.user_id, media_id, media_type) - except Exception: - log.exception("wecom: failed to send file %s", file_path) + await self._send_file(chat_id, file_path) + return + + # Send text as markdown via proactive send + try: + await self._client.send_message(chat_id, { + "msgtype": "markdown", + "markdown": {"content": msg.text}, + }) + except Exception: + log.exception("wecom: failed to send text to %s", chat_id) + + async def _send_file(self, chat_id: str, file_path: str) -> None: + """Upload and send a file/image.""" + if not self._client: return try: - await self._agent.send_text(msg.user_id, msg.text) + p = Path(file_path) + if not p.exists(): + log.error("wecom: file not found: %s", file_path) + return + + media_type = _detect_media_type(file_path) + file_data = p.read_bytes() + + upload_result = await self._client.upload_media( + file_data, + type=media_type, # type: ignore[arg-type] + filename=p.name, + ) + media_id = upload_result["media_id"] + + await self._client.send_media_message( + chat_id, + media_type=media_type, # type: ignore[arg-type] + media_id=media_id, + ) except Exception: - log.exception("wecom: failed to send text to %s", msg.user_id) + log.exception("wecom: failed to send file %s to %s", file_path, chat_id) diff --git a/src/agent_box/config.py b/src/agent_box/config.py index c62b5e4..69060ed 100644 --- a/src/agent_box/config.py +++ b/src/agent_box/config.py @@ -20,14 +20,9 @@ class Settings(BaseSettings): qqbot_app_id: str = "" qqbot_client_secret: str = "" - # WeCom (企业微信) Bot Webhook channel - wecom_token: str = "" - wecom_encoding_aes_key: str = "" # 43-char Base64 - wecom_corp_id: str = "" - wecom_corp_secret: str = "" - wecom_agent_id: int = 0 - wecom_webhook_port: int = 8088 - wecom_webhook_path: str = "/wecom" + # WeCom (企业微信) Bot WebSocket channel (long connection mode) + wecom_bot_id: str = "" + wecom_secret: str = "" # GLM (ZhipuAI) ASR — voice-to-text for audio attachments. Empty skips transcription. glm_api_key: str = "" diff --git a/src/agent_box/main.py b/src/agent_box/main.py index dc00627..97a1637 100644 --- a/src/agent_box/main.py +++ b/src/agent_box/main.py @@ -23,6 +23,7 @@ def __init__(self) -> None: self.sessions = SessionManager(settings.workspace_dir) self.router = Router(self.sessions) self.agents: dict[str, BaseAgent] = {} + self.channel_types: list[str] = [] def _get_or_create_agent(self, name: str) -> BaseAgent: if name not in self.agents: @@ -98,6 +99,13 @@ async def run(self, channel_types: list[str] | None = None) -> None: if not channel_types: channel_types = ["weixin"] + self.channel_types = channel_types + + # Enable wecom_mcp tool when wecom channel is active + if "wecom" in channel_types: + from .tools.wecom_mcp import set_wecom_mcp_enabled + set_wecom_mcp_enabled(True) + send_in, recv_in = anyio.create_memory_object_stream[IncomingMessage](16) send_out, recv_out = anyio.create_memory_object_stream[OutgoingMessage](16) @@ -240,6 +248,32 @@ def _setup_logging(channel: str) -> None: def main() -> None: + if "--help" in sys.argv or "-h" in sys.argv: + print("""agent-box — IM → Router → Agent pipeline for managing coding projects via chat + +Usage: agent-box [OPTIONS] + +Channel options (at least one required, defaults to --weixin): + --weixin Enable WeChat (微信) channel + --wecom Enable WeCom (企业微信) WebSocket channel + --qq Enable QQ Bot channel + --tui Enable terminal UI channel (for local testing) + +Multiple channels can be enabled simultaneously: + agent-box --wecom --tui + +Other options: + --test-router Launch interactive router REPL for testing + -h, --help Show this help message + +Environment variables (see sample.env): + WECOM_BOT_ID, WECOM_SECRET WeCom bot credentials + QQBOT_APP_ID, QQBOT_CLIENT_SECRET QQ bot credentials + WEIXIN_ACCOUNT_ID WeChat account ID + ANTHROPIC_AUTH_TOKEN Anthropic API key +""") + return + if "--test-router" in sys.argv: _setup_logging("test-router") try: diff --git a/src/agent_box/tools/__init__.py b/src/agent_box/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agent_box/tools/wecom_mcp.py b/src/agent_box/tools/wecom_mcp.py new file mode 100644 index 0000000..8462d48 --- /dev/null +++ b/src/agent_box/tools/wecom_mcp.py @@ -0,0 +1,349 @@ +"""wecom_mcp — SDK MCP tool for calling WeCom MCP Server. + +Provides two actions: + - list: List available tools in a given category (doc, contact, schedule, etc.) + - call: Call a specific tool method with JSON arguments + +The tool communicates with the WeCom MCP Server via: +1. WebSocket: send `aibot_get_mcp_config` to get the HTTP URL for a category +2. HTTP JSON-RPC: call the MCP Server using Streamable HTTP protocol + +This module exposes: + - `create_wecom_mcp_server()` — returns an McpSdkServerConfig for ClaudeAgentOptions + - `set_ws_client()` / `get_ws_client()` — manage the global WSClient reference + - `wecom_mcp_enabled` / `set_wecom_mcp_enabled()` — flag for whether wecom channel is active +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import httpx + +from claude_agent_sdk import tool, create_sdk_mcp_server +from claude_agent_sdk.types import McpSdkServerConfig + +from wecom_aibot_sdk import WSClient, generate_req_id +from wecom_aibot_sdk.types import WsCmd + +log = logging.getLogger(__name__) + +# ── Enabled flag ───────────────────────────────────────────────────────────── +# Set by App.run() when wecom channel is in the active channel list. + +_enabled: bool = False + + +def set_wecom_mcp_enabled(enabled: bool) -> None: + """Set whether wecom_mcp tool should be active.""" + global _enabled + _enabled = enabled + + +def is_wecom_mcp_enabled() -> bool: + """Check if wecom_mcp tool is currently enabled.""" + return _enabled + +# ── Global WSClient reference ──────────────────────────────────────────────── +# Set by WecomChannel when the WebSocket connection is established. + +_ws_client: WSClient | None = None + + +def set_ws_client(client: WSClient | None) -> None: + """Store the active WSClient for use by the MCP tool.""" + global _ws_client + _ws_client = client + + +def get_ws_client() -> WSClient | None: + """Get the currently active WSClient.""" + return _ws_client + + +# ── MCP Config cache ───────────────────────────────────────────────────────── + +_mcp_config_cache: dict[str, str] = {} # category → URL + + +def clear_mcp_cache() -> None: + """Clear all cached MCP config URLs.""" + _mcp_config_cache.clear() + + +async def _get_mcp_url(category: str) -> str: + """Fetch MCP Server URL for a category via WSClient. + + Sends `aibot_get_mcp_config` command through the WebSocket connection + to retrieve the HTTP endpoint for the given MCP category. + """ + if category in _mcp_config_cache: + return _mcp_config_cache[category] + + client = get_ws_client() + if not client or not client.is_connected: + raise RuntimeError("WeCom WebSocket 未连接,无法获取 MCP 配置") + + req_id = generate_req_id("mcp_config") + response = await client.reply( + {"headers": {"req_id": req_id}}, + {"biz_type": category}, + "aibot_get_mcp_config", + ) + + errcode = response.get("errcode", -1) + if errcode != 0: + raise RuntimeError( + f"MCP 配置请求失败: errcode={errcode}, errmsg={response.get('errmsg', '')}" + ) + + body = response.get("body") or {} + url = body.get("url") + if not url: + raise RuntimeError(f"MCP 配置响应缺少 url 字段 (category={category!r})") + + _mcp_config_cache[category] = url + log.info("wecom_mcp: config fetched for category=%s url=%s", category, url) + return url + + +# ── Streamable HTTP session management ─────────────────────────────────────── + +# category → session_id (None = stateless) +_sessions: dict[str, str | None] = {} +_stateless_categories: set[str] = set() + +_HTTP_TIMEOUT = 30.0 +_INIT_TIMEOUT = 15.0 + + +async def _send_jsonrpc( + url: str, + method: str, + params: dict[str, Any] | None = None, + session_id: str | None = None, + timeout: float = _HTTP_TIMEOUT, +) -> tuple[Any, str | None]: + """Send a JSON-RPC request to the MCP Server. + + Returns (result, new_session_id). + """ + body: dict[str, Any] = { + "jsonrpc": "2.0", + "id": generate_req_id("mcp_rpc"), + "method": method, + } + if params is not None: + body["params"] = params + + headers: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + } + if session_id: + headers["Mcp-Session-Id"] = session_id + + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, json=body, headers=headers) + + new_session_id = resp.headers.get("mcp-session-id") + + if resp.status_code == 204 or not resp.text.strip(): + return None, new_session_id + + if not resp.is_success: + raise RuntimeError(f"MCP HTTP 请求失败: {resp.status_code} {resp.reason_phrase}") + + content_type = resp.headers.get("content-type", "") + + # Handle SSE response + if "text/event-stream" in content_type: + result = _parse_sse(resp.text) + return result, new_session_id + + # Normal JSON response + data = resp.json() + if "error" in data and data["error"]: + err = data["error"] + raise RuntimeError(f"MCP 调用错误 [{err.get('code', '?')}]: {err.get('message', '')}") + return data.get("result"), new_session_id + + +def _parse_sse(text: str) -> Any: + """Parse SSE response, extract last event's data as JSON-RPC result.""" + lines = text.split("\n") + current_parts: list[str] = [] + last_data = "" + + for line in lines: + if line.startswith("data: "): + current_parts.append(line[6:]) + elif line.startswith("data:"): + current_parts.append(line[5:]) + elif line.strip() == "" and current_parts: + last_data = "\n".join(current_parts).strip() + current_parts = [] + + if current_parts: + last_data = "\n".join(current_parts).strip() + + if not last_data: + raise RuntimeError("SSE 响应中未包含有效数据") + + rpc = json.loads(last_data) + if "error" in rpc and rpc["error"]: + err = rpc["error"] + raise RuntimeError(f"MCP 调用错误 [{err.get('code', '?')}]: {err.get('message', '')}") + return rpc.get("result") + + +async def _ensure_session(category: str, url: str) -> str | None: + """Ensure MCP session is initialized for a category. Returns session_id.""" + if category in _stateless_categories: + return None + if category in _sessions: + return _sessions[category] + + # Initialize session + init_params = { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "wecom_mcp", "version": "1.0.0"}, + } + _, session_id = await _send_jsonrpc( + url, "initialize", init_params, timeout=_INIT_TIMEOUT + ) + + if not session_id: + # Stateless server + _stateless_categories.add(category) + _sessions[category] = None + return None + + # Send initialized notification + notify_body: dict[str, Any] = { + "jsonrpc": "2.0", + "method": "notifications/initialized", + } + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "Mcp-Session-Id": session_id, + } + async with httpx.AsyncClient(timeout=_INIT_TIMEOUT) as client: + await client.post(url, json=notify_body, headers=headers) + + _sessions[category] = session_id + log.info("wecom_mcp: session established for category=%s", category) + return session_id + + +async def _mcp_request(category: str, method: str, params: dict[str, Any] | None = None) -> Any: + """High-level MCP request with session management and retry on 404.""" + url = await _get_mcp_url(category) + session_id = await _ensure_session(category, url) + + try: + result, new_sid = await _send_jsonrpc(url, method, params, session_id) + if new_sid: + _sessions[category] = new_sid + return result + except RuntimeError as e: + # Session expired (404) — rebuild and retry once + if "404" in str(e): + _sessions.pop(category, None) + session_id = await _ensure_session(category, url) + result, new_sid = await _send_jsonrpc(url, method, params, session_id) + if new_sid: + _sessions[category] = new_sid + return result + raise + + +# ── Tool definitions ───────────────────────────────────────────────────────── + +@tool( + "wecom_mcp", + "调用企业微信 MCP 工具。支持两种操作:\n" + " - list: 列出指定品类的所有可用工具及其参数定义\n" + " - call: 调用指定品类的某个工具方法\n\n" + "品类包括: contact(通讯录), doc(文档), schedule(日程), " + "meeting(会议), todo(待办), msg(消息), smartsheet(智能表格)\n\n" + "示例:\n" + " list contact → 列出通讯录相关的所有工具\n" + " call doc createDocument {\"title\": \"会议纪要\"} → 创建文档", + { + "action": str, + "category": str, + "method": str, + "args": str, + }, +) +async def wecom_mcp_tool(args: dict[str, Any]) -> dict[str, Any]: + """Execute a wecom_mcp action (list or call).""" + action = args.get("action", "") + category = args.get("category", "") + method = args.get("method", "") + raw_args = args.get("args", "") + + if not category: + return _text_result({"error": "缺少 category 参数"}) + + try: + if action == "list": + result = await _mcp_request(category, "tools/list") + tools = (result or {}).get("tools", []) + return _text_result({ + "category": category, + "count": len(tools), + "tools": [ + { + "name": t.get("name"), + "description": t.get("description", ""), + "inputSchema": t.get("inputSchema"), + } + for t in tools + ], + }) + + elif action == "call": + if not method: + return _text_result({"error": "action 为 call 时必须提供 method 参数"}) + + # Parse args + call_args: dict[str, Any] = {} + if raw_args: + if isinstance(raw_args, str): + try: + call_args = json.loads(raw_args) + except json.JSONDecodeError as e: + return _text_result({"error": f"args 不是合法 JSON: {e}"}) + elif isinstance(raw_args, dict): + call_args = raw_args + + result = await _mcp_request(category, "tools/call", { + "name": method, + "arguments": call_args, + }) + return _text_result(result) + + else: + return _text_result({"error": f"未知操作: {action},支持 list 和 call"}) + + except Exception as e: + log.exception("wecom_mcp: action=%s category=%s method=%s failed", action, category, method) + return _text_result({"error": str(e)}) + + +def _text_result(data: Any) -> dict[str, Any]: + """Format result as MCP tool response.""" + return {"content": [{"type": "text", "text": json.dumps(data, ensure_ascii=False, indent=2)}]} + + +# ── Public API ─────────────────────────────────────────────────────────────── + +def create_wecom_mcp_server() -> McpSdkServerConfig: + """Create the wecom_mcp SDK MCP server config for ClaudeAgentOptions.""" + return create_sdk_mcp_server("wecom_mcp", tools=[wecom_mcp_tool]) diff --git a/uv.lock b/uv.lock index 027cea6..17daca1 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "textual" }, { name = "websockets" }, + { name = "wecom-aibot-sdk" }, ] [package.dev-dependencies] @@ -38,6 +39,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.0" }, { name = "textual", specifier = ">=3.0" }, { name = "websockets", specifier = ">=12.0" }, + { name = "wecom-aibot-sdk", specifier = ">=1.0.8" }, ] [package.metadata.requires-dev] @@ -1060,3 +1062,17 @@ wheels = [ { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "wecom-aibot-sdk" +version = "1.0.8" +source = { registry = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/" } +dependencies = [ + { name = "cryptography" }, + { name = "httpx" }, + { name = "websockets" }, +] +sdist = { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/45/72/e14f62362ecee2bae1fb13e7f48e000a39514c883f2b49568294624e0241/wecom_aibot_sdk-1.0.8.tar.gz", hash = "sha256:dd8ac2917e0b23a190a718b2c13f717acd242af487e2bf98a5ca40a117c133e6", size = 64863, upload-time = "2026-06-02T09:43:50.569Z" } +wheels = [ + { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/a8/82/8e342b64b29c52d9e92fa6f3596d53df5d2dd3dab871493ce0f871b01914/wecom_aibot_sdk-1.0.8-py3-none-any.whl", hash = "sha256:9577742c1b9d76232ece675342871e26c3f5a9d9617c7f860f951e2c6265c184", size = 28069, upload-time = "2026-06-02T09:43:49.316Z" }, +]