From 43f5c6fc1e0324183bf552340bd0bda832691a07 Mon Sep 17 00:00:00 2001 From: zccyman Date: Sat, 4 Apr 2026 02:51:24 +0800 Subject: [PATCH] feat(wechat): add WeChat channel plugin Add @openclaw/wechat channel plugin for WeChat integration with support for: - Text, image, and voice messages - Rich outbound formatting with markdown - Multi-account support - 103 passing tests Co-authored-by: OpenClaw Maintainers --- extensions/wechat/README.md | 333 +++++++++++++++++ extensions/wechat/README_CN.md | 335 ++++++++++++++++++ extensions/wechat/index.ts | 15 + extensions/wechat/openclaw.plugin.json | 9 + extensions/wechat/package.json | 53 +++ extensions/wechat/setup-entry.ts | 4 + extensions/wechat/src/accounts.ts | 85 +++++ extensions/wechat/src/bot.ts | 98 +++++ extensions/wechat/src/channel.ts | 282 +++++++++++++++ extensions/wechat/src/client.ts | 306 ++++++++++++++++ extensions/wechat/src/config-schema.ts | 78 ++++ extensions/wechat/src/outbound.ts | 77 ++++ extensions/wechat/src/runtime-api.ts | 37 ++ extensions/wechat/src/runtime.ts | 7 + extensions/wechat/src/send.ts | 44 +++ extensions/wechat/src/types.ts | 100 ++++++ extensions/wechat/tests/accounts.test.ts | 189 ++++++++++ extensions/wechat/tests/bot.test.ts | 285 +++++++++++++++ extensions/wechat/tests/channel.test.ts | 79 +++++ extensions/wechat/tests/client.test.ts | 197 ++++++++++ extensions/wechat/tests/config-schema.test.ts | 221 ++++++++++++ extensions/wechat/tests/index.test.ts | 16 + extensions/wechat/tests/outbound.test.ts | 45 +++ extensions/wechat/tests/runtime-api.test.ts | 50 +++ extensions/wechat/tests/runtime.test.ts | 30 ++ extensions/wechat/tests/send.test.ts | 175 +++++++++ extensions/wechat/tests/types.test.ts | 10 + extensions/wechat/tsconfig.json | 18 + extensions/wechat/vitest.config.ts | 8 + 29 files changed, 3186 insertions(+) create mode 100755 extensions/wechat/README.md create mode 100755 extensions/wechat/README_CN.md create mode 100755 extensions/wechat/index.ts create mode 100755 extensions/wechat/openclaw.plugin.json create mode 100755 extensions/wechat/package.json create mode 100755 extensions/wechat/setup-entry.ts create mode 100755 extensions/wechat/src/accounts.ts create mode 100755 extensions/wechat/src/bot.ts create mode 100755 extensions/wechat/src/channel.ts create mode 100755 extensions/wechat/src/client.ts create mode 100755 extensions/wechat/src/config-schema.ts create mode 100755 extensions/wechat/src/outbound.ts create mode 100755 extensions/wechat/src/runtime-api.ts create mode 100755 extensions/wechat/src/runtime.ts create mode 100755 extensions/wechat/src/send.ts create mode 100755 extensions/wechat/src/types.ts create mode 100755 extensions/wechat/tests/accounts.test.ts create mode 100755 extensions/wechat/tests/bot.test.ts create mode 100755 extensions/wechat/tests/channel.test.ts create mode 100755 extensions/wechat/tests/client.test.ts create mode 100755 extensions/wechat/tests/config-schema.test.ts create mode 100755 extensions/wechat/tests/index.test.ts create mode 100755 extensions/wechat/tests/outbound.test.ts create mode 100755 extensions/wechat/tests/runtime-api.test.ts create mode 100755 extensions/wechat/tests/runtime.test.ts create mode 100755 extensions/wechat/tests/send.test.ts create mode 100755 extensions/wechat/tests/types.test.ts create mode 100755 extensions/wechat/tsconfig.json create mode 100755 extensions/wechat/vitest.config.ts diff --git a/extensions/wechat/README.md b/extensions/wechat/README.md new file mode 100755 index 0000000000000..8dcca23c65ed0 --- /dev/null +++ b/extensions/wechat/README.md @@ -0,0 +1,333 @@ +# @openclaw/wechat + +[中文文档](./README_CN.md) + +WeChat channel plugin for [OpenClaw](https://github.com/openclaw/openclaw), supporting both **WeChat Official Account (公众号服务号)** and **WeCom (企业微信)** platforms. + +## Features + +- **Dual Platform**: WeChat Official Account (服务号) and WeCom (企业微信) in one plugin +- **Webhook Gateway**: Built-in HTTP callback server for receiving WeChat messages +- **Signature Verification**: SHA1-based signature validation for all inbound requests +- **AES Encryption**: Optional message encryption/decryption via `encodingAESKey` +- **Passive & Customer Service Reply**: 5-second passive reply + async customer service API +- **Media Support**: Image upload and sending for both platforms +- **Account Management**: Multi-account support with per-account platform configuration +- **Pairing & Allowlist**: DM pairing flow with allowlist-based access control +- **Text Chunking**: Automatic message chunking for WeChat's 2048-byte text limit +- **Access Token Management**: Auto-refresh with 2-hour token caching + +## Installation + +```bash +# In your OpenClaw monorepo +pnpm add @openclaw/wechat --workspace +``` + +Or place in `extensions/` directory for auto-discovery. + +## Configuration + +### WeChat Official Account (公众号服务号) + +```jsonc +{ + "channels": { + "wechat": { + "enabled": true, + "platform": "official", + "appId": "wx1234567890abcdef", + "appSecret": "your-app-secret", + "token": "your-verify-token", + "encodingAESKey": "optional-aes-key", + "webhookPort": 8080, + "webhookPath": "/wechat/callback", + "dmPolicy": "pairing", + "replyToMode": "flat", + }, + }, +} +``` + +### WeCom (企业微信) + +```jsonc +{ + "channels": { + "wechat": { + "enabled": true, + "platform": "wecom", + "corpid": "ww1234567890abcdef", + "corpsecret": "your-corp-secret", + "agentid": 1000002, + "token": "your-verify-token", + "encodingAESKey": "optional-aes-key", + "webhookPort": 8080, + "webhookPath": "/wechat/callback", + "dmPolicy": "open", + }, + }, +} +``` + +### Multi-Account Setup + +```jsonc +{ + "channels": { + "wechat": { + "enabled": true, + "defaultAccount": "official", + "accounts": { + "official": { + "platform": "official", + "appId": "wx111...", + "appSecret": "secret1", + "token": "token1", + "webhookPort": 8081, + }, + "wecom": { + "platform": "wecom", + "corpid": "ww222...", + "corpsecret": "secret2", + "agentid": 1000002, + "token": "token2", + "webhookPort": 8082, + }, + }, + }, + }, +} +``` + +### Configuration Options + +| Option | Type | Default | Description | +| ---------------- | -------------------------------------------------- | -------------------- | ---------------------------------------------- | +| `platform` | `"official" \| "wecom"` | — | Platform type (required unless using accounts) | +| `appId` | string | — | Official Account AppID | +| `appSecret` | string | — | Official Account AppSecret | +| `token` | string | — | Verification token for webhook signature | +| `encodingAESKey` | string | — | Optional AES encryption key (43 chars) | +| `corpid` | string | — | WeCom Corp ID | +| `corpsecret` | string | — | WeCom Corp Secret | +| `agentid` | number | — | WeCom Agent ID | +| `webhookPort` | number | — | HTTP port for webhook callback server | +| `webhookPath` | string | `"/wechat/callback"` | URL path for webhook endpoint | +| `dmPolicy` | `"open" \| "pairing" \| "allowlist" \| "disabled"` | `"pairing"` | Direct message policy | +| `groupPolicy` | `"open" \| "allowlist" \| "disabled"` | `"disabled"` | Group message policy | +| `allowFrom` | string[] | `[]` | Allowed user IDs (for allowlist mode) | +| `requireMention` | boolean | `false` | Require @mention in group chats | +| `replyToMode` | `"thread" \| "flat"` | `"flat"` | Reply mode | +| `mediaMaxMb` | number | `10` | Max media upload size in MB | +| `maxRetries` | number | `3` | Max API call retries | + +## How to Use Plugins on WeChat + +OpenClaw plugins (like `dev-workflow`) are **cross-channel agent tools**. They are not bound to a specific channel. When a user sends a message through WeChat, the LLM agent decides whether to invoke plugin tools based on intent. + +### Message Flow + +``` +WeChat webhook event (XML over HTTP) + → channel gateway receives and verifies signature + → parseXmlMessage() extracts message content + → resolveAgentRoute() determines target agent + → dispatchReplyFromConfig() runs agent inference loop + → LLM may call plugin tools (e.g., dev_workflow_start) + → Plugin tools execute, may spawn subagents + → Response streamed back through outbound adapter + → sendMessageWeChat() delivers reply via customer service API +``` + +### Enable Plugins for WeChat + +```jsonc +{ + "channels": { + "wechat": { + "enabled": true, + "platform": "official", + "appId": "...", + "appSecret": "...", + "token": "...", + }, + }, + "plugins": { + "enabled": true, + "allow": ["wechat", "dev-workflow"], + "entries": { + "dev-workflow": { + "enabled": true, + "config": { "defaultMode": "standard" }, + }, + }, + }, +} +``` + +Key points: + +- `plugins.enabled: true` — master switch for all plugins +- `plugins.allow` — allowlist; only listed plugin IDs are loaded +- `plugins.entries..enabled: true` — explicitly enable plugins with `enabledByDefault: false` + +### Verify Plugin Is Being Called + +#### 1. Startup Logs + +```bash +OPENCLAW_LOG_LEVEL=debug openclaw gateway run +``` + +Look for lines like: + +``` +[plugins] wechat: channel registered (source=workspace) +[plugins] dev-workflow: 5 tools registered +[plugins] dev-workflow: 4 hooks registered +``` + +#### 2. Hook Logs + +Plugins like `dev-workflow` register event hooks that log activity: + +``` +[dev-workflow] Session started: ... +[dev-workflow] Tool about to be called: dev_workflow_start +[dev-workflow] Tool call completed: ... +``` + +#### 3. CLI Inspection + +```bash +openclaw plugins list # List all discovered plugins +openclaw plugins status # Show loaded plugin status +openclaw gateway status # Show gateway and channel health +``` + +#### 4. Debug Environment Variables + +```bash +OPENCLAW_DEBUG=1 openclaw gateway run # Global debug mode +OPENCLAW_PLUGIN_LOADER_DEBUG_STACKS=1 openclaw gateway run # Plugin load error stacks +``` + +#### 5. End-to-End Test + +Send a message via WeChat that triggers plugin behavior (e.g., `"implement dark mode for the login page"`), then verify: + +- `[dev-workflow]` prefixed log lines appear in gateway output +- `dev_workflow_start` tool call is logged +- Bot responds with workflow progress + +## WeChat Official Account Setup + +### 1. Create a Service Account + +Register at [mp.weixin.qq.com](https://mp.weixin.qq.com) and create a **服务号 (Service Account)**. + +### 2. Configure Server URL + +In the Official Account admin panel, set the server URL to: + +``` +http://your-server:8080/wechat/callback +``` + +### 3. Set Token and EncodingAESKey + +Match the values in your OpenClaw config. WeChat will send a GET verification request on save. + +### 4. Start Gateway + +```bash +openclaw gateway run +``` + +The webhook server will listen on the configured port and respond to WeChat's verification handshake. + +## WeCom Setup + +### 1. Create a Self-Built Application + +In the WeCom admin console ([work.weixin.qq.com](https://work.weixin.qq.com)), create a self-built application. + +### 2. Configure Callback URL + +Set the receive message callback URL to: + +``` +http://your-server:8080/wechat/callback +``` + +### 3. Record Credentials + +Note down `corpid`, `corpsecret`, `agentid`, `token`, and optionally `encodingAESKey`. + +### 4. Configure and Start + +Add credentials to OpenClaw config and run `openclaw gateway run`. + +## Architecture + +``` +src/ +├── types.ts # Domain types (WeChatPlatform, configs, message context) +├── config-schema.ts # Zod config validation schemas +├── runtime.ts # Plugin runtime store +├── runtime-api.ts # Local SDK helpers (PAIRING_APPROVED_MESSAGE, chunkTextForOutbound) +├── client.ts # API clients (WeChatOfficialClient, WeChatWecomClient) +├── accounts.ts # Account resolution and listing +├── bot.ts # XML parsing, message context, passive reply builders +├── send.ts # Message sending (text + customer service API) +├── outbound.ts # Outbound adapter (chunking, sendText, sendMedia) +└── channel.ts # Main channel plugin (createChatChannelPlugin) +``` + +### API Endpoints Used + +| Platform | Endpoint | Purpose | +| -------- | ------------------------------------------------------- | ------------------------ | +| Official | `https://api.weixin.qq.com/cgi-bin/token` | Access token | +| Official | `https://api.weixin.qq.com/cgi-bin/message/custom/send` | Customer service message | +| Official | `https://api.weixin.qq.com/cgi-bin/media/upload` | Media upload | +| WeCom | `https://qyapi.weixin.qq.com/cgi-bin/gettoken` | Access token | +| WeCom | `https://qyapi.weixin.qq.com/cgi-bin/message/send` | Send message | +| WeCom | `https://qyapi.weixin.qq.com/cgi-bin/media/upload` | Media upload | + +## Development + +```bash +# Install dependencies +pnpm install + +# Type check +npx tsc --noEmit + +# Run tests (103 tests) +npx vitest run + +# Run tests in watch mode +npx vitest + +# Test a specific module +npx vitest run tests/client.test.ts +``` + +## Plugin Discovery + +OpenClaw discovers plugins in this order (`src/plugins/discovery.ts`): + +| Priority | Path | Origin | +| -------- | ----------------------------------- | ----------- | +| 1 | `plugins.load.paths` in config | `config` | +| 2 | `{workspace}/.openclaw/extensions/` | `workspace` | +| 3 | Bundled plugins directory | `bundled` | +| 4 | `{configDir}/extensions/` | `global` | + +This plugin is auto-discovered as part of the `extensions/` workspace in the monorepo. + +## License + +MIT diff --git a/extensions/wechat/README_CN.md b/extensions/wechat/README_CN.md new file mode 100755 index 0000000000000..4edeafa226f84 --- /dev/null +++ b/extensions/wechat/README_CN.md @@ -0,0 +1,335 @@ +# @openclaw/wechat + +[English](./README.md) + +微信渠道插件,为 [OpenClaw](https://github.com/openclaw/openclaw) 提供 **微信公众号服务号** 和 **企业微信** 双平台支持。 + +## 功能特性 + +- **双平台支持**:公众号服务号和企业微信合二为一 +- **Webhook 网关**:内置 HTTP 回调服务器接收微信消息 +- **签名验证**:基于 SHA1 的请求签名校验 +- **AES 加解密**:可选的消息加解密(`encodingAESKey`) +- **被动回复 + 客服消息**:5 秒内被动回复 + 异步客服 API +- **媒体支持**:双平台的图片上传与发送 +- **多账号管理**:支持按账号配置不同平台 +- **配对与白名单**:私聊配对流程 + 白名单访问控制 +- **文本分片**:自动按微信 2048 字节限制分片发送 +- **Access Token 管理**:自动刷新,2 小时缓存 + +## 安装 + +```bash +# 在 OpenClaw monorepo 中 +pnpm add @openclaw/wechat --workspace +``` + +或放入 `extensions/` 目录自动发现。 + +## 配置 + +### 公众号服务号 + +```jsonc +{ + "channels": { + "wechat": { + "enabled": true, + "platform": "official", + "appId": "wx1234567890abcdef", + "appSecret": "your-app-secret", + "token": "your-verify-token", + "encodingAESKey": "optional-aes-key", + "webhookPort": 8080, + "webhookPath": "/wechat/callback", + "dmPolicy": "pairing", + "replyToMode": "flat", + }, + }, +} +``` + +### 企业微信 + +```jsonc +{ + "channels": { + "wechat": { + "enabled": true, + "platform": "wecom", + "corpid": "ww1234567890abcdef", + "corpsecret": "your-corp-secret", + "agentid": 1000002, + "token": "your-verify-token", + "encodingAESKey": "optional-aes-key", + "webhookPort": 8080, + "webhookPath": "/wechat/callback", + "dmPolicy": "open", + }, + }, +} +``` + +### 多账号配置 + +```jsonc +{ + "channels": { + "wechat": { + "enabled": true, + "defaultAccount": "official", + "accounts": { + "official": { + "platform": "official", + "appId": "wx111...", + "appSecret": "secret1", + "token": "token1", + "webhookPort": 8081, + }, + "wecom": { + "platform": "wecom", + "corpid": "ww222...", + "corpsecret": "secret2", + "agentid": 1000002, + "token": "token2", + "webhookPort": 8082, + }, + }, + }, + }, +} +``` + +### 配置选项 + +| 选项 | 类型 | 默认值 | 说明 | +| ---------------- | -------------------------------------------------- | -------------------- | -------------------------------- | +| `platform` | `"official" \| "wecom"` | — | 平台类型(使用 accounts 时可选) | +| `appId` | string | — | 公众号 AppID | +| `appSecret` | string | — | 公众号 AppSecret | +| `token` | string | — | Webhook 签名验证 Token | +| `encodingAESKey` | string | — | 可选 AES 加密密钥(43 字符) | +| `corpid` | string | — | 企业微信 Corp ID | +| `corpsecret` | string | — | 企业微信 Corp Secret | +| `agentid` | number | — | 企业微信应用 Agent ID | +| `webhookPort` | number | — | HTTP 回调端口 | +| `webhookPath` | string | `"/wechat/callback"` | 回调 URL 路径 | +| `dmPolicy` | `"open" \| "pairing" \| "allowlist" \| "disabled"` | `"pairing"` | 私聊策略 | +| `groupPolicy` | `"open" \| "allowlist" \| "disabled"` | `"disabled"` | 群聊策略 | +| `allowFrom` | string[] | `[]` | 允许的用户 ID(白名单模式) | +| `requireMention` | boolean | `false` | 群聊中是否需要 @机器人 | +| `replyToMode` | `"thread" \| "flat"` | `"flat"` | 回复模式 | +| `mediaMaxMb` | number | `10` | 媒体上传大小上限(MB) | +| `maxRetries` | number | `3` | API 调用最大重试次数 | + +## 如何在微信上使用 OpenClaw 插件 + +OpenClaw 插件(如 `dev-workflow`)是**跨渠道的 agent 工具**,不绑定到特定渠道。用户通过微信发消息时,LLM agent 根据意图自动决定是否调用插件工具。 + +### 消息流转 + +``` +微信 webhook 事件(XML over HTTP) + → 渠道网关接收并验证签名 + → parseXmlMessage() 提取消息内容 + → resolveAgentRoute() 确定目标 agent + → dispatchReplyFromConfig() 启动 agent 推理循环 + → LLM 可能调用插件工具(如 dev_workflow_start) + → 插件工具执行,可能 spawn subagent + → 响应通过 outbound adapter 流式返回 + → sendMessageWeChat() 通过客服 API 投递回复 +``` + +### 为微信启用插件 + +```jsonc +{ + "channels": { + "wechat": { + "enabled": true, + "platform": "official", + "appId": "...", + "appSecret": "...", + "token": "...", + }, + }, + "plugins": { + "enabled": true, + "allow": ["wechat", "dev-workflow"], + "entries": { + "dev-workflow": { + "enabled": true, + "config": { "defaultMode": "standard" }, + }, + }, + }, +} +``` + +关键配置: + +- `plugins.enabled: true` — 插件总开关 +- `plugins.allow` — 白名单,只有列出的插件 ID 会被加载 +- `plugins.entries..enabled: true` — 显式启用 `enabledByDefault: false` 的插件 + +### 如何确认插件真的被调用了 + +#### 1. 启动日志 + +```bash +OPENCLAW_LOG_LEVEL=debug openclaw gateway run +``` + +观察是否出现: + +``` +[plugins] wechat: channel registered (source=workspace) +[plugins] dev-workflow: 5 tools registered +[plugins] dev-workflow: 4 hooks registered +``` + +#### 2. Hook 日志 + +`dev-workflow` 等插件会注册事件 hook 并输出日志: + +``` +[dev-workflow] Session started: ... +[dev-workflow] Tool about to be called: dev_workflow_start +[dev-workflow] Tool call completed: ... +``` + +只要用户发了消息触发 session,就应看到 `session_start` 日志。 + +#### 3. CLI 命令查看 + +```bash +openclaw plugins list # 列出所有已发现的插件 +openclaw plugins status # 查看已加载插件状态 +openclaw gateway status # 查看 gateway 状态和渠道健康 +``` + +#### 4. 调试环境变量 + +```bash +OPENCLAW_DEBUG=1 openclaw gateway run # 全局 debug 模式 +OPENCLAW_PLUGIN_LOADER_DEBUG_STACKS=1 openclaw gateway run # 插件加载错误堆栈 +``` + +#### 5. 端到端测试 + +通过微信发送一条会触发插件行为的消息(如 `"实现登录页面的暗黑模式"`),然后验证: + +- gateway 日志中是否出现 `[dev-workflow]` 前缀的日志 +- 是否看到 `dev_workflow_start` 工具被调用 +- 机器人是否回复了工作流进度 + +## 公众号服务号配置步骤 + +### 1. 创建服务号 + +在 [mp.weixin.qq.com](https://mp.weixin.qq.com) 注册并创建**服务号**。 + +### 2. 配置服务器地址 + +在公众号管理后台设置服务器 URL: + +``` +http://your-server:8080/wechat/callback +``` + +### 3. 设置 Token 和 EncodingAESKey + +与 OpenClaw 配置中的值一致。保存时微信会发送 GET 验证请求。 + +### 4. 启动 Gateway + +```bash +openclaw gateway run +``` + +Webhook 服务器将在配置的端口监听,并响应微信的验证握手。 + +## 企业微信配置步骤 + +### 1. 创建自建应用 + +在企业微信管理后台 ([work.weixin.qq.com](https://work.weixin.qq.com)) 创建自建应用。 + +### 2. 配置回调 URL + +设置接收消息回调 URL: + +``` +http://your-server:8080/wechat/callback +``` + +### 3. 记录凭证 + +记下 `corpid`、`corpsecret`、`agentid`、`token` 及可选的 `encodingAESKey`。 + +### 4. 配置并启动 + +将凭证填入 OpenClaw 配置,然后运行 `openclaw gateway run`。 + +## 架构 + +``` +src/ +├── types.ts # 领域类型(WeChatPlatform、配置、消息上下文) +├── config-schema.ts # Zod 配置校验 schema +├── runtime.ts # 插件运行时存储 +├── runtime-api.ts # 本地 SDK 辅助(PAIRING_APPROVED_MESSAGE、chunkTextForOutbound) +├── client.ts # API 客户端(WeChatOfficialClient、WeChatWecomClient) +├── accounts.ts # 账号解析和列举 +├── bot.ts # XML 解析、消息上下文、被动回复构建 +├── send.ts # 消息发送(文本 + 客服 API) +├── outbound.ts # 出站适配器(分片、sendText、sendMedia) +└── channel.ts # 主渠道插件(createChatChannelPlugin) +``` + +### 使用的 API 端点 + +| 平台 | 端点 | 用途 | +| -------- | ------------------------------------------------------- | ------------ | +| 公众号 | `https://api.weixin.qq.com/cgi-bin/token` | Access Token | +| 公众号 | `https://api.weixin.qq.com/cgi-bin/message/custom/send` | 客服消息 | +| 公众号 | `https://api.weixin.qq.com/cgi-bin/media/upload` | 媒体上传 | +| 企业微信 | `https://qyapi.weixin.qq.com/cgi-bin/gettoken` | Access Token | +| 企业微信 | `https://qyapi.weixin.qq.com/cgi-bin/message/send` | 发送消息 | +| 企业微信 | `https://qyapi.weixin.qq.com/cgi-bin/media/upload` | 媒体上传 | + +## 开发 + +```bash +# 安装依赖 +pnpm install + +# 类型检查 +npx tsc --noEmit + +# 运行测试(103 个测试) +npx vitest run + +# 监听模式 +npx vitest + +# 测试特定模块 +npx vitest run tests/client.test.ts +``` + +## 插件发现 + +OpenClaw 按以下顺序发现插件(`src/plugins/discovery.ts`): + +| 优先级 | 路径 | 来源 | +| ------ | ----------------------------------- | ----------- | +| 1 | 配置中的 `plugins.load.paths` | `config` | +| 2 | `{workspace}/.openclaw/extensions/` | `workspace` | +| 3 | 内置插件目录 | `bundled` | +| 4 | `{configDir}/extensions/` | `global` | + +本插件作为 monorepo 中 `extensions/` workspace 的一部分被自动发现。 + +## License + +MIT diff --git a/extensions/wechat/index.ts b/extensions/wechat/index.ts new file mode 100755 index 0000000000000..5a47817a33591 --- /dev/null +++ b/extensions/wechat/index.ts @@ -0,0 +1,15 @@ +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; +import { wechatPlugin } from "./src/channel.js"; +import { setWeChatRuntime } from "./src/runtime.js"; + +export { wechatPlugin } from "./src/channel.js"; +export { setWeChatRuntime } from "./src/runtime.js"; + +export default defineChannelPluginEntry({ + id: "wechat", + name: "WeChat", + description: "WeChat Official Account and WeCom (企业微信) channel plugin", + plugin: wechatPlugin as ChannelPlugin, + setRuntime: setWeChatRuntime, +}); diff --git a/extensions/wechat/openclaw.plugin.json b/extensions/wechat/openclaw.plugin.json new file mode 100755 index 0000000000000..94a9df335227f --- /dev/null +++ b/extensions/wechat/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "wechat", + "channels": ["wechat"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/wechat/package.json b/extensions/wechat/package.json new file mode 100755 index 0000000000000..f666278174813 --- /dev/null +++ b/extensions/wechat/package.json @@ -0,0 +1,53 @@ +{ + "name": "@openclaw/wechat", + "version": "2026.4.1-beta.1", + "private": true, + "description": "OpenClaw WeChat channel plugin (Official Account + WeCom)", + "type": "module", + "dependencies": { + "fast-xml-parser": "^5.0.0" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.4.1-beta.1" + }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "setupEntry": "./setup-entry.ts", + "channel": { + "id": "wechat", + "label": "WeChat", + "selectionLabel": "WeChat / 微信 (公众号 & 企业微信)", + "detailLabel": "WeChat Bot", + "docsPath": "/channels/wechat", + "docsLabel": "wechat", + "blurb": "WeChat Official Account and WeCom (企业微信) channel support.", + "aliases": [ + "weixin", + "wecom", + "weixin-official" + ], + "order": 36 + }, + "install": { + "npmSpec": "@openclaw/wechat", + "defaultChoice": "npm", + "minHostVersion": ">=2026.4.1" + }, + "bundle": { + "stageRuntimeDependencies": true + }, + "release": { + "publishToNpm": true + } + } +} diff --git a/extensions/wechat/setup-entry.ts b/extensions/wechat/setup-entry.ts new file mode 100755 index 0000000000000..0d31f0115a956 --- /dev/null +++ b/extensions/wechat/setup-entry.ts @@ -0,0 +1,4 @@ +import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core"; +import { wechatPlugin } from "./src/channel.js"; + +export default defineSetupPluginEntry(wechatPlugin); diff --git a/extensions/wechat/src/accounts.ts b/extensions/wechat/src/accounts.ts new file mode 100755 index 0000000000000..66c4f7834473b --- /dev/null +++ b/extensions/wechat/src/accounts.ts @@ -0,0 +1,85 @@ +import { + normalizeOptionalAccountId, + resolveMergedAccountConfig, + createAccountListHelpers, +} from "openclaw/plugin-sdk/account-resolution"; +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core"; +import type { + WeChatAccountConfig, + WeChatPlatform, + ResolvedWeChatAccount, + WeChatConfig, +} from "./types.js"; + +const { listAccountIds: listWeChatAccountIds } = createAccountListHelpers("wechat", { + allowUnlistedDefaultAccount: true, +}); + +export { listWeChatAccountIds }; + +function resolvePlatform(config: WeChatAccountConfig): WeChatPlatform { + if (config.platform) return config.platform; + if (config.corpid) return "wecom"; + return "official"; +} + +export function resolveWeChatAccount(cfg: any, accountId?: string | null): ResolvedWeChatAccount { + const raw = (cfg?.channels?.wechat ?? {}) as WeChatConfig; + const resolvedAccountId = normalizeOptionalAccountId(accountId) ?? DEFAULT_ACCOUNT_ID; + + let accountConfig: WeChatAccountConfig; + + if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { + accountConfig = raw; + } else { + const accountEntry = raw.accounts?.[resolvedAccountId]; + if (!accountEntry) { + throw new Error(`WeChat account "${resolvedAccountId}" not found in config`); + } + accountConfig = resolveMergedAccountConfig({ + channelConfig: raw as Record, + accounts: raw.accounts as Record>> | undefined, + accountId: resolvedAccountId, + }) as WeChatAccountConfig; + } + + const platform = resolvePlatform(accountConfig); + + const official = + platform === "official" && accountConfig.appId && accountConfig.appSecret && accountConfig.token + ? { + appId: accountConfig.appId, + appSecret: accountConfig.appSecret, + token: accountConfig.token!, + encodingAESKey: accountConfig.encodingAESKey, + } + : null; + + const wecom = + platform === "wecom" && accountConfig.corpid && accountConfig.corpsecret && accountConfig.token + ? { + corpid: accountConfig.corpid, + corpsecret: accountConfig.corpsecret, + agentid: accountConfig.agentid ?? 0, + token: accountConfig.token!, + encodingAESKey: accountConfig.encodingAESKey, + } + : null; + + const configured = platform === "official" ? !!official : !!wecom; + + return { + accountId: resolvedAccountId, + selectionSource: resolvedAccountId === DEFAULT_ACCOUNT_ID ? "default" : "named", + enabled: accountConfig.enabled !== false, + configured, + platform, + official, + wecom, + config: accountConfig, + }; +} + +export function listConfiguredWeChatAccountIds(cfg: Record): string[] { + return listWeChatAccountIds(cfg); +} diff --git a/extensions/wechat/src/bot.ts b/extensions/wechat/src/bot.ts new file mode 100755 index 0000000000000..3ca3c2939c216 --- /dev/null +++ b/extensions/wechat/src/bot.ts @@ -0,0 +1,98 @@ +import type { WeChatInboundMessage, WeChatMessageContext } from "./types.js"; + +export function parseXmlMessage(xml: string): WeChatInboundMessage | null { + try { + const decode = (val: string | string[] | undefined): string => { + if (!val) return ""; + return Array.isArray(val) ? (val[0] ?? "") : val; + }; + + const tag = (name: string): string => { + const regex = new RegExp( + `<${name}>|<${name}>([^<]*?)`, + "i", + ); + const m = xml.match(regex); + if (!m) return ""; + return m[1] ?? m[2] ?? ""; + }; + + return { + xml: { + ToUserName: tag("ToUserName"), + FromUserName: tag("FromUserName"), + CreateTime: tag("CreateTime"), + MsgType: tag("MsgType"), + Content: tag("Content"), + MsgId: tag("MsgId"), + AgentID: tag("AgentID"), + Encrypt: tag("Encrypt"), + PicUrl: tag("PicUrl"), + MediaId: tag("MediaId"), + Format: tag("Format"), + Recognition: tag("Recognition"), + Event: tag("Event"), + EventKey: tag("EventKey"), + }, + }; + } catch { + return null; + } +} + +export function buildPassiveTextReply(toUser: string, fromUser: string, content: string): string { + return [ + "", + ``, + ``, + `${Math.floor(Date.now() / 1000)}`, + ``, + ``, + "", + ].join("\n"); +} + +export function buildPassiveSuccessReply(): string { + return "success"; +} + +export function parseWeChatMessage( + msg: WeChatInboundMessage, + chatType: "direct" | "group" = "direct", +): WeChatMessageContext { + const decode = (val: string | string[] | undefined): string => { + if (!val) return ""; + return Array.isArray(val) ? (val[0] ?? "") : val; + }; + + const content = decode(msg.xml.Content); + const msgType = decode(msg.xml.MsgType); + + if (msgType === "event") { + return { + messageId: `event_${Date.now()}`, + chatId: decode(msg.xml.FromUserName), + senderId: decode(msg.xml.FromUserName), + chatType, + content: `[event:${decode(msg.xml.Event)}:${decode(msg.xml.EventKey)}]`, + msgType: "event", + createTime: parseInt(decode(msg.xml.CreateTime)) || Date.now(), + raw: msg, + }; + } + + return { + messageId: decode(msg.xml.MsgId) || `msg_${Date.now()}`, + chatId: decode(msg.xml.FromUserName), + senderId: decode(msg.xml.FromUserName), + chatType, + content, + msgType, + createTime: parseInt(decode(msg.xml.CreateTime)) || Date.now(), + raw: msg, + }; +} + +export function extractPlainText(content: string): string { + return content.replace(/<[^>]+>/g, "").trim(); +} diff --git a/extensions/wechat/src/channel.ts b/extensions/wechat/src/channel.ts new file mode 100755 index 0000000000000..faa12e2fbc426 --- /dev/null +++ b/extensions/wechat/src/channel.ts @@ -0,0 +1,282 @@ +import { createChatChannelPlugin, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core"; +import { resolveWeChatAccount, listConfiguredWeChatAccountIds } from "./accounts.js"; +import { parseXmlMessage, parseWeChatMessage, buildPassiveSuccessReply } from "./bot.js"; +import { WeChatConfigSchema } from "./config-schema.js"; +import { wechatOutbound } from "./outbound.js"; +import { PAIRING_APPROVED_MESSAGE, chunkTextForOutbound } from "./runtime-api.js"; +import { sendMessageWeChat } from "./send.js"; +import type { ResolvedWeChatAccount } from "./types.js"; +import type { WeChatProbeResult } from "./types.js"; + +function describeWeChatMessageTool(_params: any): any { + return { + actions: ["send"] as const, + capabilities: [] as string[], + schema: null, + }; +} + +async function probeWeChatAccount(params: { + account: ResolvedWeChatAccount; +}): Promise { + try { + if (params.account.platform === "official" && params.account.official) { + const { getOrCreateOfficialClient } = await import("./client.js"); + const client = getOrCreateOfficialClient(params.account.accountId, params.account.official); + const token = await client.getAccessToken(); + return { + ok: true, + platform: "official", + accountId: params.account.accountId, + accessTokenValid: !!token, + }; + } + if (params.account.platform === "wecom" && params.account.wecom) { + const { getOrCreateWecomClient } = await import("./client.js"); + const client = getOrCreateWecomClient(params.account.accountId, params.account.wecom); + const token = await client.getAccessToken(); + return { + ok: true, + platform: "wecom", + accountId: params.account.accountId, + accessTokenValid: !!token, + }; + } + return { + ok: false, + platform: params.account.platform, + accountId: params.account.accountId, + accessTokenValid: false, + error: "Account not configured", + }; + } catch (err) { + return { + ok: false, + platform: params.account.platform, + accountId: params.account.accountId, + accessTokenValid: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export const wechatPlugin = createChatChannelPlugin({ + base: { + id: "wechat", + meta: { + id: "wechat", + label: "WeChat", + selectionLabel: "WeChat / 微信", + docsPath: "/channels/wechat", + blurb: "WeChat Official Account and WeCom channel support", + }, + capabilities: { + chatTypes: ["direct"], + polls: false, + threads: false, + media: true, + reactions: false, + edit: false, + reply: true, + }, + reload: { configPrefixes: ["channels.wechat"] }, + config: { + resolveAccount: (cfg: any, accountId?: string | null) => + resolveWeChatAccount(cfg, accountId) as any, + listAccountIds: (cfg: any) => listConfiguredWeChatAccountIds(cfg), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + } as any, + configSchema: { + safeParse: (value: unknown) => { + const result = WeChatConfigSchema.safeParse(value); + if (result.success) { + return { success: true as const, data: result.data }; + } + return { + success: false as const, + error: { + issues: result.error.issues.map((i) => ({ + path: i.path as Array, + message: i.message, + })), + }, + }; + }, + } as any, + gateway: { + startAccount: async (ctx: any) => { + const account = resolveWeChatAccount(ctx.cfg as Record, ctx.accountId); + + if (!account.configured) { + throw new Error(`WeChat account "${ctx.accountId}" is not fully configured`); + } + + const webhookPath = account.config.webhookPath ?? "/wechat/callback"; + const webhookPort = account.config.webhookPort; + + if (webhookPort) { + const { createServer } = await import("http"); + const server = createServer(async (req: any, res: any) => { + const url = new URL(req.url ?? "/", `http://localhost:${webhookPort}`); + + if (!url.pathname.startsWith(webhookPath)) { + res.writeHead(404); + res.end("Not Found"); + return; + } + + if (req.method === "GET") { + const signature = url.searchParams.get("signature") ?? ""; + const timestamp = url.searchParams.get("timestamp") ?? ""; + const nonce = url.searchParams.get("nonce") ?? ""; + const echostr = url.searchParams.get("echostr") ?? ""; + + let isValid = false; + try { + if (account.platform === "official" && account.official) { + const { getOrCreateOfficialClient } = await import("./client.js"); + const client = getOrCreateOfficialClient(account.accountId, account.official); + isValid = client.verifySignature(signature, timestamp, nonce); + } else if (account.wecom) { + const { getOrCreateWecomClient } = await import("./client.js"); + const client = getOrCreateWecomClient(account.accountId, account.wecom); + isValid = client.verifySignature(signature, timestamp, nonce); + } + } catch { + isValid = false; + } + + res.writeHead(isValid ? 200 : 403, { "Content-Type": "text/plain" }); + res.end(isValid ? echostr : "Forbidden"); + return; + } + + if (req.method === "POST") { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + const body = Buffer.concat(chunks).toString("utf8"); + + const msg = parseXmlMessage(body); + if (!msg) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(buildPassiveSuccessReply()); + return; + } + + const parsed = parseWeChatMessage(msg); + + if (parsed.msgType === "event" || !parsed.content.trim()) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(buildPassiveSuccessReply()); + return; + } + + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(buildPassiveSuccessReply()); + + try { + if (ctx.channelRuntime?.reply?.dispatchReplyFromConfig) { + await ctx.channelRuntime.reply.dispatchReplyFromConfig({ + cfg: ctx.cfg, + dispatcher: ctx.dispatcher, + replyOptions: ctx.replyOptions, + }); + } + } catch (err) { + ctx.log?.error?.("WeChat dispatch error", err); + } + return; + } + + res.writeHead(405); + res.end("Method Not Allowed"); + }); + + server.listen(webhookPort, () => { + ctx.log?.info?.(`WeChat webhook listening on :${webhookPort}${webhookPath}`); + }); + + return { + stop: async () => { + server.close(); + }, + }; + } + + return { stop: async () => {} }; + }, + } as any, + actions: { + describeMessageTool: describeWeChatMessageTool, + handleAction: async (ctx: any) => { + const { action, cfg, target, text, accountId } = ctx; + if (action === "send" && text) { + const account = resolveWeChatAccount(cfg as Record, accountId); + const result = await sendMessageWeChat({ account, to: target, text }); + return { + content: [{ type: "text" as const, text: `Message sent: ${result.messageId}` }], + details: result, + }; + } + return { + content: [{ type: "text" as const, text: `Unsupported action: ${action}` }], + details: { success: false, error: `Unsupported action: ${action}` }, + }; + }, + } as any, + agentPrompt: { + messageToolHints: () => ["Use wechat_send_message to send text messages to WeChat users"], + } as any, + mentions: { + stripPatterns: () => [], + }, + messaging: { + normalizeTarget: (raw: string) => raw.trim() || undefined, + } as any, + status: { + probeAccount: async (params: { account: ResolvedWeChatAccount }) => + probeWeChatAccount(params), + } as any, + }, + pairing: { + text: { + idLabel: "wechatUserId", + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: (entry: string) => entry.trim(), + notify: async () => {}, + }, + }, + outbound: { + deliveryMode: "direct", + chunker: chunkTextForOutbound, + textChunkLimit: 2000, + sendText: async (ctx: any) => { + const { cfg, to, text, accountId } = ctx; + const account = resolveWeChatAccount(cfg as Record, accountId); + const result = await sendMessageWeChat({ account, to, text }); + return { messageId: result.messageId, chatId: result.chatId }; + }, + sendMedia: async (ctx: any) => { + const { cfg, to, mediaUrl, accountId } = ctx; + const account = resolveWeChatAccount(cfg as Record, accountId); + + if (account.platform === "official" && account.official) { + const { getOrCreateOfficialClient } = await import("./client.js"); + const client = getOrCreateOfficialClient(account.accountId, account.official); + const media = await client.uploadMedia(mediaUrl); + const send = await client.sendImageMessage(to, media.media_id); + return { messageId: send.msgid ?? `wx_${Date.now()}`, chatId: to }; + } + if (account.platform === "wecom" && account.wecom) { + const { getOrCreateWecomClient } = await import("./client.js"); + const client = getOrCreateWecomClient(account.accountId, account.wecom); + const media = await client.uploadMedia(mediaUrl); + const send = await client.sendImageMessage(to, media.media_id); + return { messageId: send.msgid ?? `wc_${Date.now()}`, chatId: to }; + } + throw new Error(`Account ${account.accountId} not configured for media`); + }, + } as any, +}); diff --git a/extensions/wechat/src/client.ts b/extensions/wechat/src/client.ts new file mode 100755 index 0000000000000..7ad2ee1a1ab14 --- /dev/null +++ b/extensions/wechat/src/client.ts @@ -0,0 +1,306 @@ +import crypto from "crypto"; +import type { + WeChatAccessToken, + WeChatOfficialConfig, + WeChatPlatform, + WeChatWecomConfig, +} from "./types.js"; + +const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; + +type ClientCache = { + official: Map; + wecom: Map; +}; + +const cache: ClientCache = { + official: new Map(), + wecom: new Map(), +}; + +export function clearClientCache(): void { + cache.official.clear(); + cache.wecom.clear(); +} + +export class WeChatOfficialClient { + readonly platform: WeChatPlatform = "official"; + readonly appId: string; + readonly appSecret: string; + readonly token: string; + readonly encodingAESKey: string | null; + private accessToken: WeChatAccessToken | null = null; + + constructor(config: WeChatOfficialConfig) { + this.appId = config.appId; + this.appSecret = config.appSecret; + this.token = config.token; + this.encodingAESKey = config.encodingAESKey ?? null; + } + + async getAccessToken(): Promise { + if (this.accessToken && this.accessToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { + return this.accessToken.token; + } + const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`; + const res = await fetch(url); + const data = await res.json(); + if (!data.access_token) { + throw new Error(`WeChat access token error: ${JSON.stringify(data)}`); + } + this.accessToken = { + token: data.access_token, + expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000, + }; + return this.accessToken.token; + } + + verifySignature(signature: string, timestamp: string, nonce: string): boolean { + const parts = [this.token, timestamp, nonce].sort(); + const hash = crypto.createHash("sha1").update(parts.join("")).digest("hex"); + return hash === signature; + } + + decryptMessage( + encrypted: string, + msgSignature: string, + timestamp: string, + nonce: string, + ): string { + if (!this.encodingAESKey) { + throw new Error("encodingAESKey is required for message decryption"); + } + const key = Buffer.from(this.encodingAESKey + "=", "base64"); + const iv = key.subarray(0, 16); + + const signParts = [this.token, timestamp, nonce, encrypted].sort(); + const signHash = crypto.createHash("sha1").update(signParts.join("")).digest("hex"); + if (signHash !== msgSignature) { + throw new Error("Message signature verification failed"); + } + + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(encrypted, "base64")), + decipher.final(), + ]); + + const content = decrypted.subarray(20); + const xmlLen = content.readUInt32BE(16); + return content.subarray(20, 20 + xmlLen).toString("utf8"); + } + + async sendCustomerServiceMessage( + toUser: string, + content: string, + ): Promise<{ msgid?: string; errcode?: number; errmsg?: string }> { + const accessToken = await this.getAccessToken(); + const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${accessToken}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + touser: toUser, + msgtype: "text", + text: { content }, + }), + }); + return res.json(); + } + + async uploadMedia( + mediaPath: string, + type: "image" | "voice" | "video" | "thumb" = "image", + ): Promise<{ media_id: string }> { + const accessToken = await this.getAccessToken(); + const url = `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${accessToken}&type=${type}`; + const fs = await import("fs"); + const buffer = fs.readFileSync(mediaPath); + const blob = new Blob([buffer]); + const formData = new FormData(); + formData.append("media", blob, mediaPath.split("/").pop() ?? "media"); + const res = await fetch(url, { + method: "POST", + body: formData, + }); + return res.json(); + } + + async sendImageMessage( + toUser: string, + mediaId: string, + ): Promise<{ msgid?: string; errcode?: number; errmsg?: string }> { + const accessToken = await this.getAccessToken(); + const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${accessToken}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + touser: toUser, + msgtype: "image", + image: { media_id: mediaId }, + }), + }); + return res.json(); + } +} + +export class WeChatWecomClient { + readonly platform: WeChatPlatform = "wecom"; + readonly corpid: string; + readonly corpsecret: string; + readonly agentid: number; + readonly token: string; + readonly encodingAESKey: string | null; + private accessToken: WeChatAccessToken | null = null; + + constructor(config: WeChatWecomConfig) { + this.corpid = config.corpid; + this.corpsecret = config.corpsecret; + this.agentid = config.agentid; + this.token = config.token; + this.encodingAESKey = config.encodingAESKey ?? null; + } + + async getAccessToken(): Promise { + if (this.accessToken && this.accessToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) { + return this.accessToken.token; + } + const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${this.corpid}&corpsecret=${this.corpsecret}`; + const res = await fetch(url); + const data = await res.json(); + if (data.errcode !== 0) { + throw new Error(`WeCom access token error: ${data.errmsg} (code: ${data.errcode})`); + } + this.accessToken = { + token: data.access_token, + expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000, + }; + return this.accessToken.token; + } + + verifySignature(signature: string, timestamp: string, nonce: string): boolean { + const parts = [this.token, timestamp, nonce].sort(); + const hash = crypto.createHash("sha1").update(parts.join("")).digest("hex"); + return hash === signature; + } + + decryptMessage( + encrypted: string, + msgSignature: string, + timestamp: string, + nonce: string, + ): string { + if (!this.encodingAESKey) { + throw new Error("encodingAESKey is required for message decryption"); + } + const key = Buffer.from(this.encodingAESKey + "=", "base64"); + const iv = key.subarray(0, 16); + + const signParts = [this.token, timestamp, nonce, encrypted].sort(); + const signHash = crypto.createHash("sha1").update(signParts.join("")).digest("hex"); + if (signHash !== msgSignature) { + throw new Error("WeCom message signature verification failed"); + } + + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(encrypted, "base64")), + decipher.final(), + ]); + + const content = decrypted.subarray(20); + const xmlLen = content.readUInt32BE(16); + return content.subarray(20, 20 + xmlLen).toString("utf8"); + } + + async sendMessage( + toUser: string, + content: string, + msgType: "text" | "markdown" = "text", + ): Promise<{ msgid?: string; errcode?: number; errmsg?: string }> { + const accessToken = await this.getAccessToken(); + const url = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${accessToken}`; + const body: Record = { + touser: toUser, + msgtype: msgType, + agentid: this.agentid, + safe: 0, + }; + if (msgType === "text") { + body.text = { content }; + } else { + body.markdown = { content }; + } + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return res.json(); + } + + async sendImageMessage( + toUser: string, + mediaId: string, + ): Promise<{ msgid?: string; errcode?: number; errmsg?: string }> { + const accessToken = await this.getAccessToken(); + const url = `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${accessToken}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + touser: toUser, + msgtype: "image", + agentid: this.agentid, + image: { media_id: mediaId }, + safe: 0, + }), + }); + return res.json(); + } + + async uploadMedia( + mediaPath: string, + type: "image" | "voice" | "video" | "file" = "image", + ): Promise<{ media_id: string }> { + const accessToken = await this.getAccessToken(); + const url = `https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=${accessToken}&type=${type}`; + const fs = await import("fs"); + const buffer = fs.readFileSync(mediaPath); + const blob = new Blob([buffer]); + const formData = new FormData(); + formData.append("media", blob, mediaPath.split("/").pop() ?? "media"); + const res = await fetch(url, { + method: "POST", + body: formData, + }); + return res.json(); + } +} + +export function getOrCreateOfficialClient( + accountId: string, + config: WeChatOfficialConfig, +): WeChatOfficialClient { + let entry = cache.official.get(accountId); + if (!entry) { + const client = new WeChatOfficialClient(config); + entry = { client, accessToken: null }; + cache.official.set(accountId, entry); + } + return entry.client; +} + +export function getOrCreateWecomClient( + accountId: string, + config: WeChatWecomConfig, +): WeChatWecomClient { + let entry = cache.wecom.get(accountId); + if (!entry) { + const client = new WeChatWecomClient(config); + entry = { client, accessToken: null }; + cache.wecom.set(accountId, entry); + } + return entry.client; +} diff --git a/extensions/wechat/src/config-schema.ts b/extensions/wechat/src/config-schema.ts new file mode 100755 index 0000000000000..1b2787c6adecd --- /dev/null +++ b/extensions/wechat/src/config-schema.ts @@ -0,0 +1,78 @@ +import { z } from "openclaw/plugin-sdk/zod"; +export { z }; + +const DmPolicySchema = z.enum(["open", "pairing", "allowlist", "disabled"]); +const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]); +const WeChatPlatformSchema = z.enum(["official", "wecom"]); +const ReplyToModeSchema = z.enum(["thread", "flat"]); + +const WeChatSharedConfigShape = { + enabled: z.boolean().optional(), + platform: WeChatPlatformSchema.optional(), + dmPolicy: DmPolicySchema.optional(), + groupPolicy: GroupPolicySchema.optional(), + allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + requireMention: z.boolean().optional(), + replyToMode: ReplyToModeSchema.optional(), + mediaMaxMb: z.number().positive().optional(), + maxRetries: z.number().int().positive().optional(), + webhookPort: z.number().int().positive().optional(), + webhookPath: z.string().optional(), +}; + +const WeChatCredentialsShape = { + appId: z.string().optional(), + appSecret: z.string().optional(), + corpid: z.string().optional(), + corpsecret: z.string().optional(), + agentid: z.number().int().positive().optional(), + token: z.string().optional(), + encodingAESKey: z.string().optional(), +}; + +export const WeChatAccountConfigSchema = z + .object({ + ...WeChatSharedConfigShape, + ...WeChatCredentialsShape, + }) + .strict(); + +export const WeChatConfigSchema = z + .object({ + ...WeChatSharedConfigShape, + ...WeChatCredentialsShape, + defaultAccount: z.string().optional(), + accounts: z.record(z.string(), WeChatAccountConfigSchema.optional()).optional(), + }) + .strict() + .superRefine((data, ctx) => { + if (data.platform === "official") { + if (!data.appId && !data.accounts) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "appId is required for official account platform", + path: ["appId"], + }); + } + if (!data.appSecret && !data.accounts) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "appSecret is required for official account platform", + path: ["appSecret"], + }); + } + } + if (data.platform === "wecom") { + if (!data.corpid && !data.accounts) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "corpid is required for WeCom platform", + path: ["corpid"], + }); + } + } + }); + +export type WeChatConfigInput = z.infer; +export type WeChatAccountConfigInput = z.infer; diff --git a/extensions/wechat/src/outbound.ts b/extensions/wechat/src/outbound.ts new file mode 100755 index 0000000000000..4ed178051438f --- /dev/null +++ b/extensions/wechat/src/outbound.ts @@ -0,0 +1,77 @@ +import { resolveWeChatAccount } from "./accounts.js"; +import { sendMessageWeChat } from "./send.js"; +import type { ResolvedWeChatAccount, WeChatSendResult } from "./types.js"; + +const WECHAT_TEXT_CHUNK_LIMIT = 2000; + +function chunkForWeChat(text: string, limit: number = WECHAT_TEXT_CHUNK_LIMIT): string[] { + const chunks: string[] = []; + if (text.length <= limit) { + return [text]; + } + let remaining = text; + while (remaining.length > 0) { + let splitAt = Math.min(limit, remaining.length); + if (splitAt < remaining.length) { + const lastNewline = remaining.lastIndexOf("\n", splitAt); + if (lastNewline > splitAt * 0.5) { + splitAt = lastNewline + 1; + } + } + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt); + } + return chunks; +} + +export const wechatOutbound = { + deliveryMode: "direct" as const, + chunker: chunkForWeChat, + + sendText: async (params: { + cfg: unknown; + to: string; + text: string; + accountId?: string; + replyToId?: string; + }): Promise<{ messageId: string; chatId: string }> => { + const { cfg, to, text, accountId } = params; + const account = resolveWeChatAccount(cfg as Record, accountId); + const result = await sendMessageWeChat({ account, to, text }); + return { messageId: result.messageId, chatId: result.chatId }; + }, + + sendMedia: async (params: { + cfg: unknown; + to: string; + mediaUrl: string; + accountId?: string; + }): Promise<{ messageId: string; chatId: string }> => { + const { cfg, to, mediaUrl, accountId } = params; + const account = resolveWeChatAccount(cfg as Record, accountId); + + if (account.platform === "official" && account.official) { + const { getOrCreateOfficialClient } = await import("./client.js"); + const client = getOrCreateOfficialClient(account.accountId, account.official); + const mediaResult = await client.uploadMedia(mediaUrl); + const sendResult = await client.sendImageMessage(to, mediaResult.media_id); + return { + messageId: sendResult.msgid ?? `wx_media_${Date.now()}`, + chatId: to, + }; + } + + if (account.platform === "wecom" && account.wecom) { + const { getOrCreateWecomClient } = await import("./client.js"); + const client = getOrCreateWecomClient(account.accountId, account.wecom); + const mediaResult = await client.uploadMedia(mediaUrl); + const sendResult = await client.sendImageMessage(to, mediaResult.media_id); + return { + messageId: sendResult.msgid ?? `wc_media_${Date.now()}`, + chatId: to, + }; + } + + throw new Error(`Cannot send media: account ${account.accountId} not configured`); + }, +}; diff --git a/extensions/wechat/src/runtime-api.ts b/extensions/wechat/src/runtime-api.ts new file mode 100755 index 0000000000000..da0d6d2b2ef7f --- /dev/null +++ b/extensions/wechat/src/runtime-api.ts @@ -0,0 +1,37 @@ +export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; + +export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +export { + DEFAULT_ACCOUNT_ID, + createChatChannelPlugin, + defineChannelPluginEntry, + defineSetupPluginEntry, + buildChannelConfigSchema, +} from "openclaw/plugin-sdk/core"; + +export { + createAccountListHelpers, + resolveMergedAccountConfig, + normalizeOptionalAccountId, +} from "openclaw/plugin-sdk/account-resolution"; + +export { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; + +export const PAIRING_APPROVED_MESSAGE = "✓ Paired successfully."; + +export function chunkTextForOutbound(text: string, limit: number = 2000): string[] { + if (text.length <= limit) return [text]; + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + let splitAt = Math.min(limit, remaining.length); + if (splitAt < remaining.length) { + const lastNewline = remaining.lastIndexOf("\n", splitAt); + if (lastNewline > splitAt * 0.5) splitAt = lastNewline + 1; + } + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt); + } + return chunks; +} diff --git a/extensions/wechat/src/runtime.ts b/extensions/wechat/src/runtime.ts new file mode 100755 index 0000000000000..82c7d03a7b583 --- /dev/null +++ b/extensions/wechat/src/runtime.ts @@ -0,0 +1,7 @@ +import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import type { PluginRuntime } from "./runtime-api.js"; + +const { setRuntime: setWeChatRuntime, getRuntime: getWeChatRuntime } = + createPluginRuntimeStore("WeChat runtime not initialized"); + +export { getWeChatRuntime, setWeChatRuntime }; diff --git a/extensions/wechat/src/send.ts b/extensions/wechat/src/send.ts new file mode 100755 index 0000000000000..1dc85dc89625a --- /dev/null +++ b/extensions/wechat/src/send.ts @@ -0,0 +1,44 @@ +import { getOrCreateOfficialClient, getOrCreateWecomClient } from "./client.js"; +import type { ResolvedWeChatAccount, WeChatSendResult } from "./types.js"; + +export async function sendMessageWeChat(params: { + account: ResolvedWeChatAccount; + to: string; + text: string; +}): Promise { + const { account, to, text } = params; + + if (account.platform === "official" && account.official) { + const client = getOrCreateOfficialClient(account.accountId, account.official); + const result = await client.sendCustomerServiceMessage(to, text); + if (result.errcode && result.errcode !== 0) { + throw new Error(`WeChat send error: ${result.errmsg} (code: ${result.errcode})`); + } + return { + messageId: result.msgid ?? `wx_${Date.now()}`, + chatId: to, + }; + } + + if (account.platform === "wecom" && account.wecom) { + const client = getOrCreateWecomClient(account.accountId, account.wecom); + const result = await client.sendMessage(to, text); + if (result.errcode && result.errcode !== 0) { + throw new Error(`WeCom send error: ${result.errmsg} (code: ${result.errcode})`); + } + return { + messageId: result.msgid ?? `wc_${Date.now()}`, + chatId: to, + }; + } + + throw new Error(`WeChat account ${account.accountId} is not properly configured`); +} + +export async function sendTypingWeChat(_params: { + account: ResolvedWeChatAccount; + to: string; +}): Promise { + // WeChat doesn't have a typing indicator API + // This is a no-op placeholder +} diff --git a/extensions/wechat/src/types.ts b/extensions/wechat/src/types.ts new file mode 100755 index 0000000000000..4e3b1d3c9813f --- /dev/null +++ b/extensions/wechat/src/types.ts @@ -0,0 +1,100 @@ +export type WeChatPlatform = "official" | "wecom"; + +export interface WeChatOfficialConfig { + appId: string; + appSecret: string; + token: string; + encodingAESKey?: string; +} + +export interface WeChatWecomConfig { + corpid: string; + corpsecret: string; + agentid: number; + token: string; + encodingAESKey?: string; +} + +export interface WeChatSharedConfig { + enabled?: boolean; + platform?: WeChatPlatform; + dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; + groupPolicy?: "open" | "allowlist" | "disabled"; + allowFrom?: string[]; + groupAllowFrom?: string[]; + requireMention?: boolean; + replyToMode?: "thread" | "flat"; + mediaMaxMb?: number; + maxRetries?: number; + webhookPort?: number; + webhookPath?: string; +} + +export type WeChatAccountConfig = WeChatSharedConfig & + Partial & + Partial & { enabled?: boolean }; + +export interface WeChatConfig extends WeChatSharedConfig { + enabled?: boolean; + defaultAccount?: string; + accounts?: Record; +} + +export type ResolvedWeChatAccount = { + accountId: string; + selectionSource: string; + enabled: boolean; + configured: boolean; + platform: WeChatPlatform; + official: WeChatOfficialConfig | null; + wecom: WeChatWecomConfig | null; + config: WeChatAccountConfig; +}; + +export type WeChatMessageContext = { + messageId: string; + chatId: string; + senderId: string; + chatType: "direct" | "group"; + content: string; + msgType: string; + createTime: number; + raw: unknown; +}; + +export type WeChatSendResult = { + messageId: string; + chatId: string; +}; + +export type WeChatProbeResult = { + ok: boolean; + platform: WeChatPlatform; + accountId: string; + accessTokenValid: boolean; + error?: string; +}; + +export type WeChatAccessToken = { + token: string; + expiresAt: number; +}; + +export type WeChatInboundMessage = { + xml: { + ToUserName: string | string[]; + FromUserName: string | string[]; + CreateTime: string | string[]; + MsgType: string | string[]; + Content?: string | string[]; + MsgId?: string | string[]; + AgentID?: string | string[]; + Encrypt?: string | string[]; + PicUrl?: string | string[]; + MediaId?: string | string[]; + Format?: string | string[]; + Recognition?: string | string[]; + Event?: string | string[]; + EventKey?: string | string[]; + }; +}; diff --git a/extensions/wechat/tests/accounts.test.ts b/extensions/wechat/tests/accounts.test.ts new file mode 100755 index 0000000000000..9ab7e5af20207 --- /dev/null +++ b/extensions/wechat/tests/accounts.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { resolveWeChatAccount, listConfiguredWeChatAccountIds } from "../src/accounts.js"; + +function makeCfg(channels: Record) { + return { channels } as Record; +} + +describe("resolveWeChatAccount", () => { + it("resolves default account with official platform", () => { + const cfg = makeCfg({ + wechat: { + platform: "official", + appId: "wx_default", + appSecret: "default_secret", + token: "default_token", + }, + }); + + const account = resolveWeChatAccount(cfg); + expect(account.accountId).toBe("default"); + expect(account.selectionSource).toBe("default"); + expect(account.platform).toBe("official"); + expect(account.enabled).toBe(true); + expect(account.configured).toBe(true); + expect(account.official).toEqual({ + appId: "wx_default", + appSecret: "default_secret", + token: "default_token", + encodingAESKey: undefined, + }); + expect(account.wecom).toBeNull(); + }); + + it("resolves default account with wecom platform", () => { + const cfg = makeCfg({ + wechat: { + platform: "wecom", + corpid: "corp123", + corpsecret: "corpsecret123", + agentid: 100, + token: "wecom_token", + }, + }); + + const account = resolveWeChatAccount(cfg); + expect(account.platform).toBe("wecom"); + expect(account.configured).toBe(true); + expect(account.wecom).toEqual({ + corpid: "corp123", + corpsecret: "corpsecret123", + agentid: 100, + token: "wecom_token", + encodingAESKey: undefined, + }); + expect(account.official).toBeNull(); + }); + + it("infers platform from corpid when platform not set", () => { + const cfg = makeCfg({ + wechat: { + corpid: "corp_auto", + corpsecret: "secret_auto", + agentid: 50, + token: "auto_token", + }, + }); + + const account = resolveWeChatAccount(cfg); + expect(account.platform).toBe("wecom"); + expect(account.configured).toBe(true); + }); + + it("defaults to official platform when no platform or corpid", () => { + const cfg = makeCfg({ + wechat: { + appId: "wx_auto", + appSecret: "secret_auto", + token: "auto_token", + }, + }); + + const account = resolveWeChatAccount(cfg); + expect(account.platform).toBe("official"); + }); + + it("resolves named account from accounts map", () => { + const cfg = makeCfg({ + wechat: { + platform: "official", + appId: "wx_top", + appSecret: "top_secret", + token: "top_token", + accounts: { + named: { + appId: "wx_named", + appSecret: "named_secret", + token: "named_token", + }, + }, + }, + }); + + const account = resolveWeChatAccount(cfg, "named"); + expect(account.accountId).toBe("named"); + expect(account.selectionSource).toBe("named"); + expect(account.configured).toBe(true); + expect(account.official?.appId).toBe("wx_named"); + }); + + it("throws for missing named account", () => { + const cfg = makeCfg({ + wechat: { + accounts: {}, + }, + }); + + expect(() => resolveWeChatAccount(cfg, "nonexistent")).toThrow(/not found/); + }); + + it("marks unconfigured account when credentials are missing", () => { + const cfg = makeCfg({ + wechat: { + platform: "official", + }, + }); + + const account = resolveWeChatAccount(cfg); + expect(account.configured).toBe(false); + expect(account.official).toBeNull(); + }); + + it("respects enabled: false", () => { + const cfg = makeCfg({ + wechat: { + enabled: false, + platform: "official", + appId: "wx_disabled", + appSecret: "secret", + token: "tok", + }, + }); + + const account = resolveWeChatAccount(cfg); + expect(account.enabled).toBe(false); + expect(account.configured).toBe(true); + }); + + it("handles missing wechat config gracefully", () => { + const cfg = makeCfg({}); + const account = resolveWeChatAccount(cfg); + expect(account.accountId).toBe("default"); + expect(account.configured).toBe(false); + expect(account.platform).toBe("official"); + }); +}); + +describe("listConfiguredWeChatAccountIds", () => { + it("returns default for minimal config", () => { + const ids = listConfiguredWeChatAccountIds({}); + expect(ids).toEqual(["default"]); + }); + + it("returns default for single account config", () => { + const ids = listConfiguredWeChatAccountIds({ + channels: { + wechat: { + appId: "wx123", + appSecret: "secret", + token: "tok", + }, + }, + }); + expect(ids).toEqual(["default"]); + }); + + it("returns account ids from accounts map", () => { + const ids = listConfiguredWeChatAccountIds({ + channels: { + wechat: { + accounts: { + main: { appId: "wx_main", appSecret: "s1", token: "t1" }, + backup: { corpid: "c1", corpsecret: "s2", agentid: 1, token: "t2" }, + }, + }, + }, + }); + expect(ids.sort()).toEqual(["backup", "main"].sort()); + }); +}); diff --git a/extensions/wechat/tests/bot.test.ts b/extensions/wechat/tests/bot.test.ts new file mode 100755 index 0000000000000..8014ce68959b6 --- /dev/null +++ b/extensions/wechat/tests/bot.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; +import { + parseXmlMessage, + parseWeChatMessage, + buildPassiveTextReply, + buildPassiveSuccessReply, + extractPlainText, +} from "../src/bot.js"; + +describe("parseXmlMessage", () => { + it("parses a basic text message", () => { + const xml = [ + "", + "", + "", + "1234567890", + "", + "", + "1234567890123456", + "", + ].join("\n"); + + const msg = parseXmlMessage(xml); + expect(msg).not.toBeNull(); + expect(msg!.xml.ToUserName).toBe("gh_abc123"); + expect(msg!.xml.FromUserName).toBe("oUser123"); + expect(msg!.xml.CreateTime).toBe("1234567890"); + expect(msg!.xml.MsgType).toBe("text"); + expect(msg!.xml.Content).toBe("Hello World"); + expect(msg!.xml.MsgId).toBe("1234567890123456"); + }); + + it("parses an event message", () => { + const xml = [ + "", + "", + "", + "1234567890", + "", + "", + "", + "", + ].join("\n"); + + const msg = parseXmlMessage(xml); + expect(msg).not.toBeNull(); + expect(msg!.xml.MsgType).toBe("event"); + expect(msg!.xml.Event).toBe("subscribe"); + }); + + it("parses image message with PicUrl and MediaId", () => { + const xml = [ + "", + "", + "", + "1234567890", + "", + "", + "", + "999", + "", + ].join("\n"); + + const msg = parseXmlMessage(xml); + expect(msg!.xml.MsgType).toBe("image"); + expect(msg!.xml.PicUrl).toBe("http://example.com/pic.jpg"); + expect(msg!.xml.MediaId).toBe("media_123"); + }); + + it("parses encrypted message", () => { + const xml = [ + "", + "", + "", + "", + ].join("\n"); + + const msg = parseXmlMessage(xml); + expect(msg!.xml.Encrypt).toBe("encrypted_content_here"); + }); + + it("parses wecom message with AgentID", () => { + const xml = [ + "", + "", + "", + "1234567890", + "", + "", + "123", + "1000002", + "", + ].join("\n"); + + const msg = parseXmlMessage(xml); + expect(msg!.xml.AgentID).toBe("1000002"); + }); + + it("returns null for invalid input", () => { + expect(parseXmlMessage("")).not.toBeNull(); + }); + + it("handles tags without CDATA", () => { + const xml = [ + "", + "gh_nocdata", + "oUser", + "9999", + "text", + "Plain text", + "111", + "", + ].join("\n"); + + const msg = parseXmlMessage(xml); + expect(msg!.xml.ToUserName).toBe("gh_nocdata"); + expect(msg!.xml.Content).toBe("Plain text"); + }); +}); + +describe("parseWeChatMessage", () => { + it("parses text message into context", () => { + const msg = { + xml: { + ToUserName: "gh_abc", + FromUserName: "oUser", + CreateTime: "1234567890", + MsgType: "text", + Content: "Hello", + MsgId: "msg_001", + AgentID: "", + Encrypt: "", + PicUrl: "", + MediaId: "", + Format: "", + Recognition: "", + Event: "", + EventKey: "", + }, + }; + + const ctx = parseWeChatMessage(msg); + expect(ctx.messageId).toBe("msg_001"); + expect(ctx.chatId).toBe("oUser"); + expect(ctx.senderId).toBe("oUser"); + expect(ctx.chatType).toBe("direct"); + expect(ctx.content).toBe("Hello"); + expect(ctx.msgType).toBe("text"); + expect(ctx.createTime).toBe(1234567890); + expect(ctx.raw).toBe(msg); + }); + + it("parses event message into context", () => { + const msg = { + xml: { + ToUserName: "gh_abc", + FromUserName: "oUser", + CreateTime: "1234567890", + MsgType: "event", + Content: "", + MsgId: "", + AgentID: "", + Encrypt: "", + PicUrl: "", + MediaId: "", + Format: "", + Recognition: "", + Event: "subscribe", + EventKey: "qrscene_123", + }, + }; + + const ctx = parseWeChatMessage(msg); + expect(ctx.msgType).toBe("event"); + expect(ctx.content).toContain("subscribe"); + expect(ctx.content).toContain("qrscene_123"); + expect(ctx.messageId).toMatch(/^event_/); + }); + + it("handles array values by taking first element", () => { + const msg = { + xml: { + ToUserName: ["gh_abc"], + FromUserName: ["oUser"], + CreateTime: ["1234567890"], + MsgType: ["text"], + Content: ["Array content"], + MsgId: ["msg_arr"], + AgentID: "", + Encrypt: "", + PicUrl: "", + MediaId: "", + Format: "", + Recognition: "", + Event: "", + EventKey: "", + }, + }; + + const ctx = parseWeChatMessage(msg); + expect(ctx.content).toBe("Array content"); + expect(ctx.messageId).toBe("msg_arr"); + }); + + it("generates fallback messageId when MsgId is empty", () => { + const msg = { + xml: { + ToUserName: "gh_abc", + FromUserName: "oUser", + CreateTime: "12345", + MsgType: "text", + Content: "Hi", + MsgId: "", + AgentID: "", + Encrypt: "", + PicUrl: "", + MediaId: "", + Format: "", + Recognition: "", + Event: "", + EventKey: "", + }, + }; + + const ctx = parseWeChatMessage(msg); + expect(ctx.messageId).toMatch(/^msg_/); + }); + + it("defaults to direct chatType", () => { + const msg = { + xml: { + ToUserName: "gh_abc", + FromUserName: "oUser", + CreateTime: "12345", + MsgType: "text", + Content: "Hi", + MsgId: "123", + AgentID: "", + Encrypt: "", + PicUrl: "", + MediaId: "", + Format: "", + Recognition: "", + Event: "", + EventKey: "", + }, + }; + + const ctx = parseWeChatMessage(msg, "direct"); + expect(ctx.chatType).toBe("direct"); + }); +}); + +describe("buildPassiveTextReply", () => { + it("builds valid XML reply", () => { + const reply = buildPassiveTextReply("oUser", "gh_abc", "Reply message"); + expect(reply).toContain(""); + expect(reply).toContain(""); + expect(reply).toContain(""); + expect(reply).toContain(""); + expect(reply).toContain(""); + expect(reply).toMatch(/^/); + expect(reply).toMatch(/<\/xml>$/); + }); +}); + +describe("buildPassiveSuccessReply", () => { + it("returns 'success'", () => { + expect(buildPassiveSuccessReply()).toBe("success"); + }); +}); + +describe("extractPlainText", () => { + it("removes XML tags", () => { + expect(extractPlainText("bold text")).toBe("bold text"); + }); + + it("trims whitespace", () => { + expect(extractPlainText(" hello ")).toBe("hello"); + }); + + it("handles plain text without tags", () => { + expect(extractPlainText("plain text")).toBe("plain text"); + }); +}); diff --git a/extensions/wechat/tests/channel.test.ts b/extensions/wechat/tests/channel.test.ts new file mode 100755 index 0000000000000..571abae0decca --- /dev/null +++ b/extensions/wechat/tests/channel.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { wechatPlugin } from "../src/channel.js"; + +const p = wechatPlugin as any; + +describe("wechatPlugin", () => { + it("is defined", () => { + expect(wechatPlugin).toBeDefined(); + }); + + it("has id equal to 'wechat'", () => { + expect(p.id).toBe("wechat"); + }); + + it("has meta with required fields", () => { + expect(p.meta.id).toBe("wechat"); + expect(p.meta.label).toBe("WeChat"); + expect(p.meta.selectionLabel).toBe("WeChat / 微信"); + expect(p.meta.docsPath).toBe("/channels/wechat"); + expect(p.meta.blurb).toBeTruthy(); + }); + + it("declares direct chat capability", () => { + expect(p.capabilities.chatTypes).toContain("direct"); + expect(p.capabilities.media).toBe(true); + expect(p.capabilities.reply).toBe(true); + }); + + it("has configSchema with safeParse", () => { + expect(typeof p.configSchema?.safeParse).toBe("function"); + }); + + it("has gateway with startAccount", () => { + expect(typeof p.gateway?.startAccount).toBe("function"); + }); + + it("has actions with describeMessageTool and handleAction", () => { + expect(typeof p.actions?.describeMessageTool).toBe("function"); + expect(typeof p.actions?.handleAction).toBe("function"); + }); + + it("has status with probeAccount", () => { + expect(typeof p.status?.probeAccount).toBe("function"); + }); + + it("has messaging with normalizeTarget", () => { + expect(typeof p.messaging?.normalizeTarget).toBe("function"); + }); + + it("normalizeTarget trims whitespace", () => { + expect(p.messaging.normalizeTarget(" user123 ")).toBe("user123"); + }); + + it("normalizeTarget returns undefined for empty string", () => { + expect(p.messaging.normalizeTarget(" ")).toBeUndefined(); + expect(p.messaging.normalizeTarget("")).toBeUndefined(); + }); + + it("has pairing config", () => { + expect(p.pairing.idLabel).toBe("wechatUserId"); + expect(typeof p.pairing.notifyApproval).toBe("function"); + expect(typeof p.pairing.normalizeAllowEntry).toBe("function"); + }); + + it("has outbound config", () => { + expect(p.outbound.deliveryMode).toBe("direct"); + expect(typeof p.outbound.chunker).toBe("function"); + expect(p.outbound.textChunkLimit).toBe(2000); + expect(typeof p.outbound.sendText).toBe("function"); + expect(typeof p.outbound.sendMedia).toBe("function"); + }); + + it("describeMessageTool returns correct structure", () => { + const result = p.actions.describeMessageTool({}); + expect(result.actions).toBeDefined(); + expect(result.capabilities).toBeDefined(); + expect(result.schema).toBeDefined(); + }); +}); diff --git a/extensions/wechat/tests/client.test.ts b/extensions/wechat/tests/client.test.ts new file mode 100755 index 0000000000000..3e84eb2fa88d2 --- /dev/null +++ b/extensions/wechat/tests/client.test.ts @@ -0,0 +1,197 @@ +import crypto from "crypto"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { WeChatOfficialClient, WeChatWecomClient, clearClientCache } from "../src/client.js"; + +describe("WeChatOfficialClient", () => { + const config = { + appId: "wx_test_appid", + appSecret: "test_app_secret", + token: "test_token", + encodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH", + }; + + it("stores config properties", () => { + const client = new WeChatOfficialClient(config); + expect(client.platform).toBe("official"); + expect(client.appId).toBe("wx_test_appid"); + expect(client.appSecret).toBe("test_app_secret"); + expect(client.token).toBe("test_token"); + expect(client.encodingAESKey).toBe("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"); + }); + + it("sets encodingAESKey to null when not provided", () => { + const client = new WeChatOfficialClient({ + appId: "wx_test", + appSecret: "secret", + token: "tok", + }); + expect(client.encodingAESKey).toBeNull(); + }); + + describe("verifySignature", () => { + it("verifies a correct signature", () => { + const client = new WeChatOfficialClient(config); + const sorted = [config.token, "1234567890", "testnonce"].sort().join(""); + const expected = crypto.createHash("sha1").update(sorted).digest("hex"); + + expect(client.verifySignature(expected, "1234567890", "testnonce")).toBe(true); + }); + + it("rejects an incorrect signature", () => { + const client = new WeChatOfficialClient(config); + expect(client.verifySignature("badsignature", "1234567890", "testnonce")).toBe(false); + }); + }); + + describe("getAccessToken", () => { + it("fetches and caches access token", async () => { + const client = new WeChatOfficialClient(config); + const mockFetch = vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ access_token: "test_token_123", expires_in: 7200 }), + }); + vi.stubGlobal("fetch", mockFetch); + + const token1 = await client.getAccessToken(); + expect(token1).toBe("test_token_123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const token2 = await client.getAccessToken(); + expect(token2).toBe("test_token_123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + + vi.restoreAllMocks(); + }); + + it("throws on API error", async () => { + const client = new WeChatOfficialClient(config); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ errcode: 40013, errmsg: "invalid appid" }), + }), + ); + + await expect(client.getAccessToken()).rejects.toThrow(/access token error/i); + vi.restoreAllMocks(); + }); + }); + + describe("sendCustomerServiceMessage", () => { + it("sends text message via customer service API", async () => { + const client = new WeChatOfficialClient(config); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ access_token: "tok123", expires_in: 7200 }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ msgid: "msg_001" }), + }), + ); + + const result = await client.sendCustomerServiceMessage("user_openid", "Hello"); + expect(result.msgid).toBe("msg_001"); + vi.restoreAllMocks(); + }); + }); + + describe("decryptMessage", () => { + it("throws when encodingAESKey is not set", () => { + const client = new WeChatOfficialClient({ + appId: "wx_test", + appSecret: "secret", + token: "tok", + }); + expect(() => client.decryptMessage("encrypted", "sig", "ts", "nonce")).toThrow( + /encodingAESKey is required/, + ); + }); + }); +}); + +describe("WeChatWecomClient", () => { + const config = { + corpid: "test_corp_id", + corpsecret: "test_corp_secret", + agentid: 1000002, + token: "wecom_token", + encodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH", + }; + + it("stores config properties", () => { + const client = new WeChatWecomClient(config); + expect(client.platform).toBe("wecom"); + expect(client.corpid).toBe("test_corp_id"); + expect(client.corpsecret).toBe("test_corp_secret"); + expect(client.agentid).toBe(1000002); + expect(client.token).toBe("wecom_token"); + }); + + describe("getAccessToken", () => { + it("fetches wecom access token", async () => { + const client = new WeChatWecomClient(config); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ errcode: 0, access_token: "wecom_tok", expires_in: 7200 }), + }), + ); + + const token = await client.getAccessToken(); + expect(token).toBe("wecom_tok"); + vi.restoreAllMocks(); + }); + + it("throws on API error", async () => { + const client = new WeChatWecomClient(config); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ errcode: 40013, errmsg: "invalid corpid" }), + }), + ); + + await expect(client.getAccessToken()).rejects.toThrow(/WeCom access token error/); + vi.restoreAllMocks(); + }); + }); + + describe("sendMessage", () => { + it("sends text message", async () => { + const client = new WeChatWecomClient(config); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ errcode: 0, access_token: "tok", expires_in: 7200 }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ msgid: "wc_msg_001" }), + }), + ); + + const result = await client.sendMessage("user123", "Hello WeCom"); + expect(result.msgid).toBe("wc_msg_001"); + vi.restoreAllMocks(); + }); + }); + + describe("verifySignature", () => { + it("verifies a correct signature", () => { + const client = new WeChatWecomClient(config); + const sorted = [config.token, "ts123", "nonce456"].sort().join(""); + const expected = crypto.createHash("sha1").update(sorted).digest("hex"); + + expect(client.verifySignature(expected, "ts123", "nonce456")).toBe(true); + }); + }); +}); + +describe("clearClientCache", () => { + it("does not throw", () => { + expect(() => clearClientCache()).not.toThrow(); + }); +}); diff --git a/extensions/wechat/tests/config-schema.test.ts b/extensions/wechat/tests/config-schema.test.ts new file mode 100755 index 0000000000000..6f44e5fd67aa4 --- /dev/null +++ b/extensions/wechat/tests/config-schema.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; +import { WeChatConfigSchema, WeChatAccountConfigSchema } from "../src/config-schema.js"; + +function expectSchemaIssue( + result: ReturnType, + issuePath: string, +) { + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join(".") === issuePath)).toBe(true); + } +} + +describe("WeChatConfigSchema defaults", () => { + it("parses empty config", () => { + const result = WeChatConfigSchema.parse({}); + expect(result.enabled).toBeUndefined(); + expect(result.platform).toBeUndefined(); + expect(result.webhookPath).toBeUndefined(); + expect(result.dmPolicy).toBeUndefined(); + }); + + it("parses full official account config", () => { + const result = WeChatConfigSchema.parse({ + platform: "official", + appId: "wx1234567890", + appSecret: "secret123", + token: "mytoken", + encodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH", + dmPolicy: "pairing", + }); + + expect(result.platform).toBe("official"); + expect(result.appId).toBe("wx1234567890"); + expect(result.appSecret).toBe("secret123"); + expect(result.token).toBe("mytoken"); + expect(result.encodingAESKey).toBe("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"); + expect(result.dmPolicy).toBe("pairing"); + }); + + it("parses full wecom config", () => { + const result = WeChatConfigSchema.parse({ + platform: "wecom", + corpid: "corpid123", + corpsecret: "corpsecret123", + agentid: 1000001, + token: "mytoken", + }); + + expect(result.platform).toBe("wecom"); + expect(result.corpid).toBe("corpid123"); + expect(result.corpsecret).toBe("corpsecret123"); + expect(result.agentid).toBe(1000001); + }); +}); + +describe("WeChatConfigSchema shared fields", () => { + it("accepts dmPolicy values", () => { + for (const policy of ["open", "pairing", "allowlist", "disabled"] as const) { + const result = WeChatConfigSchema.parse({ dmPolicy: policy }); + expect(result.dmPolicy).toBe(policy); + } + }); + + it("accepts groupPolicy values", () => { + for (const policy of ["open", "allowlist", "disabled"] as const) { + const result = WeChatConfigSchema.parse({ groupPolicy: policy }); + expect(result.groupPolicy).toBe(policy); + } + }); + + it("accepts replyToMode values", () => { + for (const mode of ["thread", "flat"] as const) { + const result = WeChatConfigSchema.parse({ replyToMode: mode }); + expect(result.replyToMode).toBe(mode); + } + }); + + it("accepts allowFrom array", () => { + const result = WeChatConfigSchema.parse({ allowFrom: ["user1", "user2"] }); + expect(result.allowFrom).toEqual(["user1", "user2"]); + }); + + it("accepts mediaMaxMb", () => { + const result = WeChatConfigSchema.parse({ mediaMaxMb: 10 }); + expect(result.mediaMaxMb).toBe(10); + }); + + it("accepts webhookPort and webhookPath", () => { + const result = WeChatConfigSchema.parse({ webhookPort: 8080, webhookPath: "/wx/callback" }); + expect(result.webhookPort).toBe(8080); + expect(result.webhookPath).toBe("/wx/callback"); + }); +}); + +describe("WeChatConfigSchema validation", () => { + it("rejects invalid platform", () => { + const result = WeChatConfigSchema.safeParse({ platform: "miniprogram" }); + expect(result.success).toBe(false); + }); + + it("rejects invalid dmPolicy", () => { + const result = WeChatConfigSchema.safeParse({ dmPolicy: "anyone" }); + expect(result.success).toBe(false); + }); + + it("rejects invalid groupPolicy", () => { + const result = WeChatConfigSchema.safeParse({ groupPolicy: "anyone" }); + expect(result.success).toBe(false); + }); + + it("rejects negative mediaMaxMb", () => { + const result = WeChatConfigSchema.safeParse({ mediaMaxMb: -1 }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer webhookPort", () => { + const result = WeChatConfigSchema.safeParse({ webhookPort: 1.5 }); + expect(result.success).toBe(false); + }); + + it("requires appId for official platform at top level", () => { + const result = WeChatConfigSchema.safeParse({ + platform: "official", + appSecret: "secret123", + }); + expectSchemaIssue(result, "appId"); + }); + + it("requires appSecret for official platform at top level", () => { + const result = WeChatConfigSchema.safeParse({ + platform: "official", + appId: "wx1234567890", + }); + expectSchemaIssue(result, "appSecret"); + }); + + it("requires corpid for wecom platform at top level", () => { + const result = WeChatConfigSchema.safeParse({ + platform: "wecom", + }); + expectSchemaIssue(result, "corpid"); + }); + + it("skips credential validation when accounts are defined", () => { + const result = WeChatConfigSchema.safeParse({ + platform: "official", + accounts: { + main: { + appId: "wx123", + appSecret: "secret", + }, + }, + }); + expect(result.success).toBe(true); + }); +}); + +describe("WeChatConfigSchema accounts", () => { + it("parses accounts map", () => { + const result = WeChatConfigSchema.parse({ + accounts: { + main: { + platform: "official", + appId: "wx_main", + appSecret: "secret_main", + token: "token_main", + }, + wecom: { + platform: "wecom", + corpid: "corp1", + corpsecret: "secret1", + agentid: 100, + }, + }, + }); + + expect(result.accounts?.main?.platform).toBe("official"); + expect(result.accounts?.wecom?.platform).toBe("wecom"); + }); + + it("accepts defaultAccount", () => { + const result = WeChatConfigSchema.parse({ + defaultAccount: "main", + accounts: { + main: { appId: "wx_main", appSecret: "secret_main" }, + }, + }); + + expect(result.defaultAccount).toBe("main"); + }); + + it("rejects unknown keys in strict mode", () => { + const result = WeChatConfigSchema.safeParse({ + unknownField: "value", + }); + expect(result.success).toBe(false); + }); +}); + +describe("WeChatAccountConfigSchema", () => { + it("parses valid account config", () => { + const result = WeChatAccountConfigSchema.parse({ + platform: "official", + appId: "wx123", + appSecret: "secret", + token: "mytoken", + enabled: true, + }); + + expect(result.platform).toBe("official"); + expect(result.enabled).toBe(true); + }); + + it("rejects unknown keys", () => { + const result = WeChatAccountConfigSchema.safeParse({ + unknownKey: "value", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/extensions/wechat/tests/index.test.ts b/extensions/wechat/tests/index.test.ts new file mode 100755 index 0000000000000..5bb38db2b0664 --- /dev/null +++ b/extensions/wechat/tests/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +describe("index.ts re-exports", () => { + it("re-exports wechatPlugin from channel module", async () => { + const { wechatPlugin } = await import("../src/channel.js"); + const indexMod = await import("../index.js"); + + expect(indexMod.wechatPlugin).toBe(wechatPlugin); + expect((indexMod.wechatPlugin as any).id).toBe("wechat"); + }); + + it("re-exports setWeChatRuntime from runtime module", async () => { + const indexMod = await import("../index.js"); + expect(typeof indexMod.setWeChatRuntime).toBe("function"); + }); +}); diff --git a/extensions/wechat/tests/outbound.test.ts b/extensions/wechat/tests/outbound.test.ts new file mode 100755 index 0000000000000..2591e56bdc0b7 --- /dev/null +++ b/extensions/wechat/tests/outbound.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { clearClientCache } from "../src/client.js"; +import { wechatOutbound } from "../src/outbound.js"; + +describe("wechatOutbound", () => { + describe("deliveryMode", () => { + it("is 'direct'", () => { + expect(wechatOutbound.deliveryMode).toBe("direct"); + }); + }); + + describe("chunker", () => { + it("returns single chunk for short text", () => { + const chunks = wechatOutbound.chunker("Hello World"); + expect(chunks).toEqual(["Hello World"]); + }); + + it("splits long text at newline boundaries", () => { + const lines = Array(200).fill("Line of text here").join("\n"); + const chunks = wechatOutbound.chunker(lines); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(2000); + } + expect(chunks.join("")).toBe(lines); + }); + + it("splits text without newlines at exact limit", () => { + const longText = "a".repeat(5000); + const chunks = wechatOutbound.chunker(longText); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(2000); + } + expect(chunks.join("")).toBe(longText); + }); + + it("handles custom limit", () => { + const text = "a".repeat(100); + const chunks = wechatOutbound.chunker(text, 50); + expect(chunks.length).toBe(2); + expect(chunks.join("")).toBe(text); + }); + }); +}); diff --git a/extensions/wechat/tests/runtime-api.test.ts b/extensions/wechat/tests/runtime-api.test.ts new file mode 100755 index 0000000000000..fcf14689edb08 --- /dev/null +++ b/extensions/wechat/tests/runtime-api.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +describe("runtime-api", () => { + it("exports PAIRING_APPROVED_MESSAGE", async () => { + const { PAIRING_APPROVED_MESSAGE } = await import("../src/runtime-api.js"); + expect(PAIRING_APPROVED_MESSAGE).toBeTruthy(); + expect(typeof PAIRING_APPROVED_MESSAGE).toBe("string"); + }); + + describe("chunkTextForOutbound", () => { + it("returns single chunk for text under limit", async () => { + const { chunkTextForOutbound } = await import("../src/runtime-api.js"); + const chunks = chunkTextForOutbound("short text"); + expect(chunks).toEqual(["short text"]); + }); + + it("splits text at default 2000 char limit", async () => { + const { chunkTextForOutbound } = await import("../src/runtime-api.js"); + const text = "a".repeat(4000); + const chunks = chunkTextForOutbound(text); + expect(chunks.length).toBe(2); + expect(chunks.every((c: string) => c.length <= 2000)).toBe(true); + expect(chunks.join("")).toBe(text); + }); + + it("respects custom limit", async () => { + const { chunkTextForOutbound } = await import("../src/runtime-api.js"); + const text = "ab".repeat(50); + const chunks = chunkTextForOutbound(text, 50); + expect(chunks.length).toBe(2); + expect(chunks.every((c: string) => c.length <= 50)).toBe(true); + }); + + it("splits at newline boundary when available", async () => { + const { chunkTextForOutbound } = await import("../src/runtime-api.js"); + const header = "a".repeat(40) + "\n"; + const body = "b".repeat(40); + const text = header + body; + const chunks = chunkTextForOutbound(text, 50); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.join("")).toBe(text); + }); + + it("handles empty string", async () => { + const { chunkTextForOutbound } = await import("../src/runtime-api.js"); + const chunks = chunkTextForOutbound(""); + expect(chunks).toEqual([""]); + }); + }); +}); diff --git a/extensions/wechat/tests/runtime.test.ts b/extensions/wechat/tests/runtime.test.ts new file mode 100755 index 0000000000000..580b999792c63 --- /dev/null +++ b/extensions/wechat/tests/runtime.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("openclaw/plugin-sdk/runtime-store", () => ({ + createPluginRuntimeStore: (errorMsg: string) => { + let runtime: any = undefined; + return { + setRuntime: (r: any) => { + runtime = r; + }, + getRuntime: () => { + if (!runtime) throw new Error(errorMsg); + return runtime; + }, + }; + }, +})); + +describe("WeChat runtime store", () => { + it("throws before runtime is set", async () => { + const { getWeChatRuntime } = await import("../src/runtime.js"); + expect(() => getWeChatRuntime()).toThrow(/WeChat runtime not initialized/); + }); + + it("returns runtime after setWeChatRuntime is called", async () => { + const { getWeChatRuntime, setWeChatRuntime } = await import("../src/runtime.js"); + const mockRuntime = { channel: {} }; + setWeChatRuntime(mockRuntime as any); + expect(getWeChatRuntime()).toBe(mockRuntime); + }); +}); diff --git a/extensions/wechat/tests/send.test.ts b/extensions/wechat/tests/send.test.ts new file mode 100755 index 0000000000000..f6b7685366cb7 --- /dev/null +++ b/extensions/wechat/tests/send.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { clearClientCache } from "../src/client.js"; +import { sendMessageWeChat, sendTypingWeChat } from "../src/send.js"; +import type { ResolvedWeChatAccount } from "../src/types.js"; + +const officialAccount: ResolvedWeChatAccount = { + accountId: "default", + selectionSource: "default", + enabled: true, + configured: true, + platform: "official", + official: { + appId: "wx_test", + appSecret: "test_secret", + token: "test_token", + }, + wecom: null, + config: {} as any, +}; + +const wecomAccount: ResolvedWeChatAccount = { + accountId: "wecom_default", + selectionSource: "default", + enabled: true, + configured: true, + platform: "wecom", + official: null, + wecom: { + corpid: "corp_test", + corpsecret: "corp_secret", + agentid: 100, + token: "wecom_token", + }, + config: {} as any, +}; + +describe("sendMessageWeChat", () => { + beforeEach(() => { + clearClientCache(); + }); + + it("sends message via official account client", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ access_token: "tok", expires_in: 7200 }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ msgid: "wx_msg_001" }), + }), + ); + + const result = await sendMessageWeChat({ + account: officialAccount, + to: "oUser123", + text: "Hello from official", + }); + + expect(result.messageId).toBe("wx_msg_001"); + expect(result.chatId).toBe("oUser123"); + vi.restoreAllMocks(); + }); + + it("sends message via wecom client", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ errcode: 0, access_token: "wecom_tok", expires_in: 7200 }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ msgid: "wc_msg_001" }), + }), + ); + + const result = await sendMessageWeChat({ + account: wecomAccount, + to: "UserID", + text: "Hello from wecom", + }); + + expect(result.messageId).toBe("wc_msg_001"); + expect(result.chatId).toBe("UserID"); + vi.restoreAllMocks(); + }); + + it("throws on official account API error", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ access_token: "tok", expires_in: 7200 }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ errcode: 45015, errmsg: "response timeout" }), + }), + ); + + await expect( + sendMessageWeChat({ account: officialAccount, to: "oUser", text: "test" }), + ).rejects.toThrow(/WeChat send error/); + vi.restoreAllMocks(); + }); + + it("throws on wecom API error", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ errcode: 0, access_token: "tok", expires_in: 7200 }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ errcode: 60011, errmsg: "no permission" }), + }), + ); + + await expect( + sendMessageWeChat({ account: wecomAccount, to: "UserID", text: "test" }), + ).rejects.toThrow(/WeCom send error/); + vi.restoreAllMocks(); + }); + + it("throws for unconfigured account", async () => { + const unconfiguredAccount: ResolvedWeChatAccount = { + accountId: "bad", + selectionSource: "default", + enabled: true, + configured: false, + platform: "official", + official: null, + wecom: null, + config: {} as any, + }; + + await expect( + sendMessageWeChat({ account: unconfiguredAccount, to: "user", text: "test" }), + ).rejects.toThrow(/not properly configured/); + }); + + it("generates fallback messageId when API returns no msgid", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + json: () => Promise.resolve({ access_token: "tok", expires_in: 7200 }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ errcode: 0 }), + }), + ); + + const result = await sendMessageWeChat({ + account: officialAccount, + to: "oUser", + text: "test", + }); + + expect(result.messageId).toMatch(/^wx_/); + vi.restoreAllMocks(); + }); +}); + +describe("sendTypingWeChat", () => { + it("does nothing (no-op)", async () => { + await expect( + sendTypingWeChat({ account: officialAccount, to: "oUser" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/extensions/wechat/tests/types.test.ts b/extensions/wechat/tests/types.test.ts new file mode 100755 index 0000000000000..db437a322b412 --- /dev/null +++ b/extensions/wechat/tests/types.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +describe("WeChat types", () => { + it("WeChatPlatform only allows official and wecom", () => { + const platforms: ("official" | "wecom")[] = ["official", "wecom"]; + expect(platforms).toHaveLength(2); + expect(platforms).toContain("official"); + expect(platforms).toContain("wecom"); + }); +}); diff --git a/extensions/wechat/tsconfig.json b/extensions/wechat/tsconfig.json new file mode 100755 index 0000000000000..55d4ec314e77c --- /dev/null +++ b/extensions/wechat/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "index.ts", "setup-entry.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/extensions/wechat/vitest.config.ts b/extensions/wechat/vitest.config.ts new file mode 100755 index 0000000000000..8676010b0cb0e --- /dev/null +++ b/extensions/wechat/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + testTimeout: 30000, + }, +});