openclaw-plugin-wecom 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -78
- package/README_ZH.md +124 -69
- package/client.js +4 -4
- package/crypto.js +2 -2
- package/dynamic-agent.js +11 -11
- package/index.js +85 -85
- package/logger.js +2 -2
- package/openclaw.plugin.json +6 -4
- package/package.json +5 -5
- package/utils.js +3 -3
- package/webhook.js +6 -6
package/README.md
CHANGED
|
@@ -1,135 +1,190 @@
|
|
|
1
|
-
# OpenClaw WeCom AI Bot Plugin
|
|
1
|
+
# OpenClaw WeCom (Enterprise WeChat) AI Bot Plugin
|
|
2
2
|
|
|
3
3
|
[简体中文](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README_ZH.md) | [English](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README.md)
|
|
4
4
|
|
|
5
|
-
`openclaw-plugin-wecom` is
|
|
5
|
+
`openclaw-plugin-wecom` is a WeCom (Enterprise WeChat) integration plugin developed for the [OpenClaw](https://github.com/openclaw/openclaw) framework. It enables seamless integration of powerful AI capabilities into WeCom with advanced features.
|
|
6
6
|
|
|
7
7
|
## ✨ Key Features
|
|
8
8
|
|
|
9
|
-
- 🌊 **Streaming Output**:
|
|
10
|
-
- 🤖 **Dynamic Agent Management**: Automatically creates
|
|
11
|
-
- 👥 **
|
|
12
|
-
- 🛠️ **
|
|
13
|
-
- 🔒 **Security
|
|
14
|
-
- ⚡ **
|
|
9
|
+
- 🌊 **Streaming Output**: Smooth typewriter-style responses using WeCom's latest AI bot streaming mechanism.
|
|
10
|
+
- 🤖 **Dynamic Agent Management**: Automatically creates independent Agents per user/group chat with isolated workspaces and conversation contexts.
|
|
11
|
+
- 👥 **Group Chat Integration**: Full support for group messages with @mention triggering.
|
|
12
|
+
- 🛠️ **Command Support**: Built-in commands (`/new`, `/status`, `/help`, `/compact`) with configurable whitelist.
|
|
13
|
+
- 🔒 **Security**: Complete support for WeCom message encryption/decryption and sender verification.
|
|
14
|
+
- ⚡ **Async Processing**: High-performance async architecture ensures gateway responsiveness during AI inference.
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## 🚀 Quick Start
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
### Option 1: Docker Deployment (Recommended)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
- DM: `wxwork-dm-<userId>`
|
|
22
|
-
- Group: `wxwork-group-<chatId>`
|
|
23
|
-
2. The plugin routes the message by setting `SessionKey` to:
|
|
24
|
-
- `agent:<agentId>:<peerKind>:<peerId>`
|
|
25
|
-
3. OpenClaw extracts `<agentId>` from `SessionKey` and will automatically create / reuse the Agent workspace (typically under `~/.openclaw/workspace-<agentId>` for non-default agents).
|
|
20
|
+
This repository provides a complete Docker deployment solution that **deploys OpenClaw + WeCom plugin in one step**, with automated installation and configuration.
|
|
26
21
|
|
|
27
|
-
|
|
22
|
+
```bash
|
|
23
|
+
# 1. Clone the repository
|
|
24
|
+
git clone https://github.com/sunnoy/openclaw-plugin-wecom.git
|
|
25
|
+
cd openclaw-plugin-wecom/deploy
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
# 2. Copy environment configuration
|
|
28
|
+
cp .env.example .env
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
# 3. Edit .env file with your settings
|
|
31
|
+
vim .env
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
# 4. Run deployment script
|
|
34
|
+
./deploy.sh
|
|
35
|
+
```
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
The deployment script automatically:
|
|
38
|
+
- Creates data directories and sets permissions
|
|
39
|
+
- Generates configuration files
|
|
40
|
+
- Starts Docker containers
|
|
41
|
+
- Installs the WeCom plugin
|
|
42
|
+
- Configures and restarts services
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
- `dm.createAgentOnFirstMessage` (boolean, default: `true`): whether DMs should use dynamic agents.
|
|
40
|
-
- `groupChat.enabled` (boolean, default: `true`): enable group chat handling.
|
|
41
|
-
- `groupChat.createAgentOnFirstMessage` (boolean, default: `true`): whether group chats should use dynamic agents.
|
|
42
|
-
- `groupChat.requireMention` (boolean, default: `true`): require an @mention to respond in groups.
|
|
43
|
-
- `groupChat.mentionPatterns` (string[], default: `["@"]`): patterns treated as “mention”.
|
|
44
|
+
#### 🌟 Deployment Highlights
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
**Custom Data Directory & Agent Workspace Paths**
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
48
|
+
The core advantage of this deployment is unified data storage in a custom path, effectively utilizing data disks:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# .env configuration example
|
|
52
|
+
OPENCLAW_DATA_DIR=/data/openclaw # Custom data directory
|
|
55
53
|
```
|
|
56
54
|
|
|
57
|
-
|
|
55
|
+
- **OpenClaw State Directory**: `/data/openclaw/`
|
|
56
|
+
- **Dynamic Agent Workspace**: `/data/openclaw/.openclaw/`
|
|
57
|
+
- **Plugin Directory**: `/data/openclaw/extensions/`
|
|
58
|
+
- **Canvas Data**: `/data/openclaw/canvas/`
|
|
59
|
+
|
|
60
|
+
Benefits:
|
|
61
|
+
- ✅ All Agent workspace data stored on data disk, avoiding system disk usage
|
|
62
|
+
- ✅ Independent Agent files for each user/group managed under unified path
|
|
63
|
+
- ✅ Easy backup, migration, and expansion
|
|
64
|
+
- ✅ Enterprise-ready deployment with independently mountable data disks
|
|
58
65
|
|
|
59
|
-
###
|
|
66
|
+
### Option 2: Manual Plugin Installation
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
Install in an existing OpenClaw environment:
|
|
62
69
|
|
|
63
70
|
```bash
|
|
64
71
|
openclaw plugins install openclaw-plugin-wecom
|
|
65
72
|
```
|
|
66
73
|
|
|
67
|
-
|
|
74
|
+
Or via npm:
|
|
68
75
|
|
|
69
76
|
```bash
|
|
70
77
|
npm install openclaw-plugin-wecom
|
|
71
78
|
```
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
Add the plugin configuration to your OpenClaw configuration file (e.g., `config.json`):
|
|
80
|
+
Then add to your OpenClaw configuration:
|
|
76
81
|
|
|
77
82
|
```json
|
|
78
83
|
{
|
|
79
84
|
"plugins": {
|
|
80
85
|
"entries": {
|
|
81
|
-
"
|
|
86
|
+
"wecom": { "enabled": true }
|
|
82
87
|
}
|
|
83
88
|
},
|
|
84
89
|
"channels": {
|
|
85
|
-
"
|
|
90
|
+
"wecom": {
|
|
86
91
|
"enabled": true,
|
|
87
|
-
"token": "
|
|
88
|
-
"encodingAesKey": "
|
|
89
|
-
"webhookPath": "/webhooks/wxwork",
|
|
90
|
-
"accounts": {
|
|
91
|
-
"default": {
|
|
92
|
-
"allowFrom": ["*"]
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
"commands": {
|
|
96
|
-
"enabled": true,
|
|
97
|
-
"allowlist": ["/new", "/status", "/help", "/compact"]
|
|
98
|
-
},
|
|
99
|
-
"dynamicAgents": {
|
|
100
|
-
"enabled": true
|
|
101
|
-
}
|
|
92
|
+
"token": "Your Token",
|
|
93
|
+
"encodingAesKey": "Your EncodingAESKey"
|
|
102
94
|
}
|
|
103
95
|
}
|
|
104
96
|
}
|
|
105
97
|
```
|
|
106
98
|
|
|
107
|
-
###
|
|
99
|
+
### WeCom Backend Setup
|
|
108
100
|
|
|
109
|
-
1. Create an "
|
|
110
|
-
2. Set the "Message
|
|
111
|
-
3.
|
|
101
|
+
1. Create an "Intelligent Bot" in WeCom Admin Console.
|
|
102
|
+
2. Set the "Receive Message" URL to your service address (e.g., `https://your-domain.com/webhooks/wecom`).
|
|
103
|
+
3. Enter the corresponding Token and EncodingAESKey.
|
|
112
104
|
|
|
113
|
-
##
|
|
105
|
+
## 📂 Project Structure
|
|
114
106
|
|
|
115
|
-
|
|
107
|
+
```
|
|
108
|
+
openclaw-plugin-wecom/
|
|
109
|
+
├── deploy/ # Deployment files
|
|
110
|
+
│ ├── deploy.sh # One-click deployment script
|
|
111
|
+
│ ├── docker-compose.yml # Docker Compose configuration
|
|
112
|
+
│ ├── .env.example # Environment variables template
|
|
113
|
+
│ ├── openclaw.json.base # Base configuration template
|
|
114
|
+
│ └── openclaw.json.template # Full configuration template
|
|
115
|
+
├── Dockerfile # OpenClaw image build file
|
|
116
|
+
├── local.sh # Local image build script
|
|
117
|
+
├── index.js # Plugin entry point
|
|
118
|
+
├── webhook.js # WeCom HTTP communication
|
|
119
|
+
├── dynamic-agent.js # Dynamic Agent routing
|
|
120
|
+
├── stream-manager.js # Streaming response management
|
|
121
|
+
├── crypto.js # WeCom encryption
|
|
122
|
+
└── client.js # Client logic
|
|
123
|
+
```
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
- `/compact`: Compact session context, keeping key summaries to save tokens.
|
|
119
|
-
- `/help`: View help information.
|
|
120
|
-
- `/status`: View current Agent and plugin status.
|
|
125
|
+
## 🤖 Dynamic Agent Routing
|
|
121
126
|
|
|
122
|
-
|
|
127
|
+
The plugin implements per-user/per-group isolation:
|
|
128
|
+
|
|
129
|
+
1. On message arrival, generates a deterministic `agentId`:
|
|
130
|
+
- DM: `wecom-dm-<userId>`
|
|
131
|
+
- Group: `wecom-group-<chatId>`
|
|
132
|
+
2. OpenClaw automatically creates/reuses the corresponding Agent workspace.
|
|
133
|
+
|
|
134
|
+
### Configuration Options
|
|
135
|
+
|
|
136
|
+
Under `channels.wecom`:
|
|
137
|
+
|
|
138
|
+
| Option | Type | Default | Description |
|
|
139
|
+
|--------|------|---------|-------------|
|
|
140
|
+
| `dynamicAgents.enabled` | boolean | `true` | Enable dynamic Agents |
|
|
141
|
+
| `dm.createAgentOnFirstMessage` | boolean | `true` | Use dynamic Agent for DMs |
|
|
142
|
+
| `groupChat.enabled` | boolean | `true` | Enable group chat handling |
|
|
143
|
+
| `groupChat.requireMention` | boolean | `true` | Require @mention in groups |
|
|
144
|
+
|
|
145
|
+
To route all messages to the default Agent:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"channels": {
|
|
150
|
+
"wecom": {
|
|
151
|
+
"dynamicAgents": { "enabled": false }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 🛠️ Command Whitelist
|
|
158
|
+
|
|
159
|
+
To prevent regular users from executing sensitive Gateway management commands via WeCom messages, this plugin supports a **command whitelist** mechanism. Only commands in the whitelist will be executed; others are ignored.
|
|
160
|
+
|
|
161
|
+
> 💡 **Note**: This configuration is already included in `deploy/openclaw.json.template` and takes effect automatically upon deployment.
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"channels": {
|
|
166
|
+
"wecom": {
|
|
167
|
+
"commands": {
|
|
168
|
+
"enabled": true,
|
|
169
|
+
"allowlist": ["/new", "/status", "/help", "/compact"]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| Command | Description | Security Level |
|
|
177
|
+
|---------|-------------|----------------|
|
|
178
|
+
| `/new` | Reset conversation, start fresh | ✅ User-level |
|
|
179
|
+
| `/compact` | Compress conversation context | ✅ User-level |
|
|
180
|
+
| `/help` | Show help information | ✅ User-level |
|
|
181
|
+
| `/status` | Show Agent status | ✅ User-level |
|
|
123
182
|
|
|
124
|
-
|
|
125
|
-
- `webhook.js`: Handles WeCom HTTP communication, encryption/decryption, and message parsing.
|
|
126
|
-
- `dynamic-agent.js`: Dynamic Agent allocation logic.
|
|
127
|
-
- `stream-manager.js`: Manages the state and data partitioning of streaming responses.
|
|
128
|
-
- `crypto.js`: Implementation of WeCom encryption algorithms.
|
|
183
|
+
> ⚠️ **Security Note**: Do not add `/gateway`, `/plugins`, or other management commands to the whitelist to prevent regular users from gaining Gateway instance admin privileges.
|
|
129
184
|
|
|
130
185
|
## 🤝 Contributing
|
|
131
186
|
|
|
132
|
-
We welcome contributions!
|
|
187
|
+
We welcome contributions! Please submit Issues or Pull Requests for bugs or feature suggestions.
|
|
133
188
|
|
|
134
189
|
## 📄 License
|
|
135
190
|
|
package/README_ZH.md
CHANGED
|
@@ -2,130 +2,185 @@
|
|
|
2
2
|
|
|
3
3
|
[简体中文](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README_ZH.md) | [English](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README.md)
|
|
4
4
|
|
|
5
|
-
`openclaw-plugin-wecom` 是一个专为 [OpenClaw](https://github.com/
|
|
5
|
+
`openclaw-plugin-wecom` 是一个专为 [OpenClaw](https://github.com/openclaw/openclaw) 框架开发的企业微信(WeCom)集成插件。它允许你将强大的 AI 能力无缝接入企业微信,并支持多项高级功能。
|
|
6
6
|
|
|
7
7
|
## ✨ 核心特性
|
|
8
8
|
|
|
9
9
|
- 🌊 **流式输出 (Streaming)**: 基于企业微信最新的 AI 机器人流式分片机制,实现流畅的打字机式回复体验。
|
|
10
|
-
- 🤖 **动态 Agent 管理**:
|
|
10
|
+
- 🤖 **动态 Agent 管理**: 默认按"每个私聊用户 / 每个群聊"自动创建独立 Agent。每个 Agent 拥有独立的工作区与对话上下文,实现更强的数据隔离。
|
|
11
11
|
- 👥 **群聊深度集成**: 支持群聊消息解析,可通过 @提及(At-mention)精准触发机器人响应。
|
|
12
12
|
- 🛠️ **指令增强**: 内置常用指令支持(如 `/new` 开启新会话、`/status` 查看状态等),并提供指令白名单配置功能。
|
|
13
13
|
- 🔒 **安全与认证**: 完整支持企业微信消息加解密、URL 验证及发送者身份校验。
|
|
14
14
|
- ⚡ **高性能异步处理**: 采用异步消息处理架构,确保即使在长耗时 AI 推理过程中,企业微信网关也能保持高响应性。
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## 🚀 快速开始
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
### 方式一:Docker 一键部署(推荐)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
- 私聊:`wxwork-dm-<userId>`
|
|
22
|
-
- 群聊:`wxwork-group-<chatId>`
|
|
23
|
-
2. 插件将消息路由到该 Agent:把 `SessionKey` 设为
|
|
24
|
-
- `agent:<agentId>:<peerKind>:<peerId>`
|
|
25
|
-
3. OpenClaw 从 `SessionKey` 中提取 `<agentId>`,并自动创建/复用对应的 Agent 工作区(非默认 Agent 通常落在 `~/.openclaw/workspace-<agentId>`)。
|
|
20
|
+
本仓库提供了完整的 Docker 部署方案,**一键部署 OpenClaw + 企业微信插件**,包含自动化安装和配置。
|
|
26
21
|
|
|
27
|
-
|
|
22
|
+
```bash
|
|
23
|
+
# 1. 克隆仓库
|
|
24
|
+
git clone https://github.com/sunnoy/openclaw-plugin-wecom.git
|
|
25
|
+
cd openclaw-plugin-wecom/deploy
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
# 2. 复制环境变量配置
|
|
28
|
+
cp .env.example .env
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
# 3. 编辑 .env 文件,填写实际配置
|
|
31
|
+
vim .env
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
# 4. 运行部署脚本
|
|
34
|
+
./deploy.sh
|
|
35
|
+
```
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
部署脚本会自动执行:
|
|
38
|
+
- 创建数据目录和设置权限
|
|
39
|
+
- 生成配置文件
|
|
40
|
+
- 启动 Docker 容器
|
|
41
|
+
- 安装企业微信插件
|
|
42
|
+
- 配置并重启服务
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
- `dm.createAgentOnFirstMessage`(boolean,默认:`true`):私聊是否使用动态 Agent。
|
|
40
|
-
- `groupChat.enabled`(boolean,默认:`true`):是否启用群聊处理。
|
|
41
|
-
- `groupChat.createAgentOnFirstMessage`(boolean,默认:`true`):群聊是否使用动态 Agent。
|
|
42
|
-
- `groupChat.requireMention`(boolean,默认:`true`):群聊是否必须 @ 提及才响应。
|
|
43
|
-
- `groupChat.mentionPatterns`(string[],默认:`["@"]`):哪些字符串算作“提及”。
|
|
44
|
+
#### 🌟 部署亮点
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
**自定义数据目录 & Agent Workspace 路径**
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
48
|
+
本部署方案的核心优势是将所有数据统一存储到自定义路径,有效利用数据盘:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# .env 配置示例
|
|
52
|
+
OPENCLAW_DATA_DIR=/data/openclaw # 自定义数据目录
|
|
55
53
|
```
|
|
56
54
|
|
|
57
|
-
|
|
55
|
+
- **OpenClaw 状态目录**:`/data/openclaw/`
|
|
56
|
+
- **动态 Agent Workspace**:`/data/openclaw/.openclaw/`
|
|
57
|
+
- **插件目录**:`/data/openclaw/extensions/`
|
|
58
|
+
- **Canvas 数据**:`/data/openclaw/canvas/`
|
|
59
|
+
|
|
60
|
+
这意味着:
|
|
61
|
+
- ✅ 所有 Agent 工作区数据存储在数据盘,避免占用系统盘空间
|
|
62
|
+
- ✅ 每个用户/群聊的独立 Agent 文件都在统一路径下管理
|
|
63
|
+
- ✅ 方便备份、迁移和扩容
|
|
64
|
+
- ✅ 适合企业级部署,数据盘可独立挂载和扩展
|
|
58
65
|
|
|
59
|
-
###
|
|
66
|
+
### 方式二:手动安装插件
|
|
60
67
|
|
|
61
|
-
|
|
68
|
+
在已有的 OpenClaw 环境中安装:
|
|
62
69
|
|
|
63
70
|
```bash
|
|
64
71
|
openclaw plugins install openclaw-plugin-wecom
|
|
65
72
|
```
|
|
66
73
|
|
|
67
|
-
|
|
74
|
+
或通过 npm:
|
|
68
75
|
|
|
69
76
|
```bash
|
|
70
77
|
npm install openclaw-plugin-wecom
|
|
71
78
|
```
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
在 OpenClaw 的配置文件(如 `config.json`)中添加插件配置:
|
|
80
|
+
然后在 OpenClaw 配置文件中添加:
|
|
76
81
|
|
|
77
82
|
```json
|
|
78
83
|
{
|
|
79
84
|
"plugins": {
|
|
80
85
|
"entries": {
|
|
81
|
-
"
|
|
86
|
+
"wecom": { "enabled": true }
|
|
82
87
|
}
|
|
83
88
|
},
|
|
84
89
|
"channels": {
|
|
85
|
-
"
|
|
90
|
+
"wecom": {
|
|
86
91
|
"enabled": true,
|
|
87
92
|
"token": "你的 Token",
|
|
88
|
-
"encodingAesKey": "你的 EncodingAESKey"
|
|
89
|
-
"webhookPath": "/webhooks/wxwork",
|
|
90
|
-
"accounts": {
|
|
91
|
-
"default": {
|
|
92
|
-
"allowFrom": ["*"]
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
"commands": {
|
|
96
|
-
"enabled": true,
|
|
97
|
-
"allowlist": ["/new", "/status", "/help", "/compact"]
|
|
98
|
-
},
|
|
99
|
-
"dynamicAgents": {
|
|
100
|
-
"enabled": true
|
|
101
|
-
}
|
|
93
|
+
"encodingAesKey": "你的 EncodingAESKey"
|
|
102
94
|
}
|
|
103
95
|
}
|
|
104
96
|
}
|
|
105
97
|
```
|
|
106
98
|
|
|
107
|
-
###
|
|
99
|
+
### 企业微信后台设置
|
|
108
100
|
|
|
109
|
-
1.
|
|
110
|
-
2.
|
|
101
|
+
1. 在企业微信管理后台创建一个"智能机器人"。
|
|
102
|
+
2. 将机器人的"接收消息配置"中的 URL 设置为你的服务地址(例如:`https://your-domain.com/webhooks/wecom`)。
|
|
111
103
|
3. 填入对应的 Token 和 EncodingAESKey。
|
|
112
104
|
|
|
113
|
-
##
|
|
105
|
+
## 📂 项目结构
|
|
114
106
|
|
|
115
|
-
|
|
107
|
+
```
|
|
108
|
+
openclaw-plugin-wecom/
|
|
109
|
+
├── deploy/ # 部署相关文件
|
|
110
|
+
│ ├── deploy.sh # 一键部署脚本
|
|
111
|
+
│ ├── docker-compose.yml # Docker Compose 配置
|
|
112
|
+
│ ├── .env.example # 环境变量示例
|
|
113
|
+
│ ├── openclaw.json.base # 基础配置模板
|
|
114
|
+
│ └── openclaw.json.template # 完整配置模板
|
|
115
|
+
├── Dockerfile # OpenClaw 镜像构建文件
|
|
116
|
+
├── local.sh # 本地镜像构建脚本
|
|
117
|
+
├── index.js # 插件入口
|
|
118
|
+
├── webhook.js # 企业微信 HTTP 通信处理
|
|
119
|
+
├── dynamic-agent.js # 动态 Agent 分配逻辑
|
|
120
|
+
├── stream-manager.js # 流式回复管理
|
|
121
|
+
├── crypto.js # 企业微信加密算法
|
|
122
|
+
└── client.js # 客户端逻辑
|
|
123
|
+
```
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
- `/compact`: 压缩当前会话上下文,保留关键摘要以节省 Token。
|
|
119
|
-
- `/help`: 查看帮助信息。
|
|
120
|
-
- `/status`: 查看当前 Agent 及插件状态。
|
|
125
|
+
## 🤖 动态 Agent 路由
|
|
121
126
|
|
|
122
|
-
|
|
127
|
+
OpenClaw 会通过解析 `SessionKey` 来决定本次消息由哪个 Agent 处理。本插件实现"按人/按群隔离":
|
|
128
|
+
|
|
129
|
+
1. 企业微信消息到达后,插件生成确定性的 `agentId`:
|
|
130
|
+
- 私聊:`wecom-dm-<userId>`
|
|
131
|
+
- 群聊:`wecom-group-<chatId>`
|
|
132
|
+
2. OpenClaw 自动创建/复用对应的 Agent 工作区。
|
|
133
|
+
|
|
134
|
+
### 配置选项
|
|
135
|
+
|
|
136
|
+
配置在 `channels.wecom` 下:
|
|
137
|
+
|
|
138
|
+
| 配置项 | 类型 | 默认值 | 说明 |
|
|
139
|
+
|--------|------|--------|------|
|
|
140
|
+
| `dynamicAgents.enabled` | boolean | `true` | 是否启用动态 Agent |
|
|
141
|
+
| `dm.createAgentOnFirstMessage` | boolean | `true` | 私聊使用动态 Agent |
|
|
142
|
+
| `groupChat.enabled` | boolean | `true` | 启用群聊处理 |
|
|
143
|
+
| `groupChat.requireMention` | boolean | `true` | 群聊必须 @ 提及才响应 |
|
|
144
|
+
|
|
145
|
+
如果需要所有消息进入默认 Agent:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"channels": {
|
|
150
|
+
"wecom": {
|
|
151
|
+
"dynamicAgents": { "enabled": false }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 🛠️ 指令白名单
|
|
158
|
+
|
|
159
|
+
为防止普通用户通过企业微信消息执行敏感的 Gateway 管理指令,本插件支持**指令白名单**机制。只有配置在白名单中的指令才会被执行,其他指令将被忽略。
|
|
160
|
+
|
|
161
|
+
> 💡 **提示**:此配置已包含在 `deploy/openclaw.json.template` 中,部署时会自动生效。
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"channels": {
|
|
166
|
+
"wecom": {
|
|
167
|
+
"commands": {
|
|
168
|
+
"enabled": true,
|
|
169
|
+
"allowlist": ["/new", "/status", "/help", "/compact"]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| 指令 | 说明 | 安全级别 |
|
|
177
|
+
|------|------|----------|
|
|
178
|
+
| `/new` | 重置当前对话,开启全新会话 | ✅ 用户级 |
|
|
179
|
+
| `/compact` | 压缩当前会话上下文 | ✅ 用户级 |
|
|
180
|
+
| `/help` | 查看帮助信息 | ✅ 用户级 |
|
|
181
|
+
| `/status` | 查看当前 Agent 状态 | ✅ 用户级 |
|
|
123
182
|
|
|
124
|
-
|
|
125
|
-
- `webhook.js`: 处理企业微信 HTTP 通信、加解密及消息解析。
|
|
126
|
-
- `dynamic-agent.js`: 动态 Agent 分配逻辑。
|
|
127
|
-
- `stream-manager.js`: 管理流式回复的状态与数据分片。
|
|
128
|
-
- `crypto.js`: 企业微信加密算法实现。
|
|
183
|
+
> ⚠️ **安全提示**:不要将 `/gateway`、`/plugins` 等管理指令添加到白名单,避免普通用户获得 Gateway 实例的管理权限。
|
|
129
184
|
|
|
130
185
|
## 🤝 贡献规范
|
|
131
186
|
|
package/client.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* WeCom AI Bot Client
|
|
3
3
|
* 智能机器人专用 - 只使用 response_url 回复,不需要 access_token
|
|
4
4
|
* https://developer.work.weixin.qq.com/document/path/101039
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { logger } from "./logger.js";
|
|
8
|
-
import { withRetry,
|
|
8
|
+
import { withRetry, parseWecomError, CONSTANTS } from "./utils.js";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* 通过 response_url 主动回复消息
|
|
@@ -32,7 +32,7 @@ export async function sendReplyMessage(responseUrl, message) {
|
|
|
32
32
|
|
|
33
33
|
const data = await res.json();
|
|
34
34
|
if (data.errcode !== 0) {
|
|
35
|
-
const errorInfo =
|
|
35
|
+
const errorInfo = parseWecomError(data.errcode, data.errmsg);
|
|
36
36
|
throw new Error(`Response failed: [${data.errcode}] ${errorInfo.message}`);
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -108,7 +108,7 @@ export async function sendStreamChunk(responseUrl, streamId, content, isFinished
|
|
|
108
108
|
|
|
109
109
|
const data = await res.json();
|
|
110
110
|
if (data.errcode !== 0) {
|
|
111
|
-
const errorInfo =
|
|
111
|
+
const errorInfo = parseWecomError(data.errcode, data.errmsg);
|
|
112
112
|
throw new Error(`Stream response failed: [${data.errcode}] ${errorInfo.message}`);
|
|
113
113
|
}
|
|
114
114
|
|
package/crypto.js
CHANGED
|
@@ -7,7 +7,7 @@ import { logger } from "./logger.js";
|
|
|
7
7
|
* Enterprise WeChat Intelligent Robot Crypto Implementation
|
|
8
8
|
* Simplified for AI Bot mode (no corpId validation)
|
|
9
9
|
*/
|
|
10
|
-
export class
|
|
10
|
+
export class WecomCrypto {
|
|
11
11
|
token;
|
|
12
12
|
encodingAesKey;
|
|
13
13
|
aesKey;
|
|
@@ -24,7 +24,7 @@ export class WxWorkCrypto {
|
|
|
24
24
|
this.encodingAesKey = encodingAesKey;
|
|
25
25
|
this.aesKey = Buffer.from(encodingAesKey + "=", "base64");
|
|
26
26
|
this.iv = this.aesKey.subarray(0, 16);
|
|
27
|
-
logger.debug("
|
|
27
|
+
logger.debug("WecomCrypto initialized (AI Bot mode)");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
getSignature(timestamp, nonce, encrypt) {
|
package/dynamic-agent.js
CHANGED
|
@@ -16,7 +16,7 @@ import { logger } from "./logger.js";
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* 生成 AgentId
|
|
19
|
-
* 规范:
|
|
19
|
+
* 规范:wecom-dm-{userId} 或 wecom-group-{groupId}
|
|
20
20
|
*
|
|
21
21
|
* @param {string} chatType - "dm" 或 "group"
|
|
22
22
|
* @param {string} peerId - userId 或 groupId
|
|
@@ -25,28 +25,28 @@ import { logger } from "./logger.js";
|
|
|
25
25
|
export function generateAgentId(chatType, peerId) {
|
|
26
26
|
const sanitizedId = String(peerId).toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
|
27
27
|
if (chatType === "group") {
|
|
28
|
-
return `
|
|
28
|
+
return `wecom-group-${sanitizedId}`;
|
|
29
29
|
}
|
|
30
|
-
return `
|
|
30
|
+
return `wecom-dm-${sanitizedId}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* 获取动态 Agent 配置
|
|
35
35
|
*/
|
|
36
36
|
export function getDynamicAgentConfig(config) {
|
|
37
|
-
const
|
|
37
|
+
const wecom = config?.channels?.wecom || {};
|
|
38
38
|
return {
|
|
39
|
-
enabled:
|
|
39
|
+
enabled: wecom.dynamicAgents?.enabled !== false,
|
|
40
40
|
|
|
41
41
|
// 私聊配置
|
|
42
|
-
dmCreateAgent:
|
|
42
|
+
dmCreateAgent: wecom.dm?.createAgentOnFirstMessage !== false,
|
|
43
43
|
|
|
44
44
|
// 群聊配置
|
|
45
|
-
groupEnabled:
|
|
46
|
-
groupRequireMention:
|
|
47
|
-
groupMentionPatterns:
|
|
48
|
-
groupCreateAgent:
|
|
49
|
-
groupHistoryLimit:
|
|
45
|
+
groupEnabled: wecom.groupChat?.enabled !== false,
|
|
46
|
+
groupRequireMention: wecom.groupChat?.requireMention !== false,
|
|
47
|
+
groupMentionPatterns: wecom.groupChat?.mentionPatterns || ["@"],
|
|
48
|
+
groupCreateAgent: wecom.groupChat?.createAgentOnFirstMessage !== false,
|
|
49
|
+
groupHistoryLimit: wecom.groupChat?.historyLimit || 10,
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
|
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { WecomWebhook } from "./webhook.js";
|
|
2
2
|
import { logger } from "./logger.js";
|
|
3
3
|
import { streamManager } from "./stream-manager.js";
|
|
4
4
|
import {
|
|
@@ -36,8 +36,8 @@ const DEFAULT_COMMAND_BLOCK_MESSAGE = `⚠️ 该命令不可用。
|
|
|
36
36
|
* 获取命令白名单配置
|
|
37
37
|
*/
|
|
38
38
|
function getCommandConfig(config) {
|
|
39
|
-
const
|
|
40
|
-
const commands =
|
|
39
|
+
const wecom = config?.channels?.wecom || {};
|
|
40
|
+
const commands = wecom.commands || {};
|
|
41
41
|
return {
|
|
42
42
|
allowlist: commands.allowlist || DEFAULT_COMMAND_ALLOWLIST,
|
|
43
43
|
blockMessage: commands.blockMessage || DEFAULT_COMMAND_BLOCK_MESSAGE,
|
|
@@ -90,7 +90,7 @@ function setRuntime(runtime) {
|
|
|
90
90
|
|
|
91
91
|
function getRuntime() {
|
|
92
92
|
if (!_runtime) {
|
|
93
|
-
throw new Error("[
|
|
93
|
+
throw new Error("[wecom] Runtime not initialized");
|
|
94
94
|
}
|
|
95
95
|
return _runtime;
|
|
96
96
|
}
|
|
@@ -102,19 +102,19 @@ const webhookTargets = new Map();
|
|
|
102
102
|
// can be added to the correct stream instead of using response_url
|
|
103
103
|
const activeStreams = new Map();
|
|
104
104
|
|
|
105
|
-
function
|
|
105
|
+
function normalizeWecomAllowFromEntry(raw) {
|
|
106
106
|
const trimmed = String(raw ?? "").trim();
|
|
107
107
|
if (!trimmed) return null;
|
|
108
108
|
if (trimmed === "*") return "*";
|
|
109
|
-
return trimmed.replace(/^(
|
|
109
|
+
return trimmed.replace(/^(wecom|wework):/i, "").replace(/^user:/i, "").toLowerCase();
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function
|
|
113
|
-
const
|
|
114
|
-
if (!
|
|
112
|
+
function resolveWecomAllowFrom(cfg, accountId) {
|
|
113
|
+
const wecom = cfg?.channels?.wecom;
|
|
114
|
+
if (!wecom) return [];
|
|
115
115
|
|
|
116
116
|
const normalizedAccountId = String(accountId || DEFAULT_ACCOUNT_ID).trim().toLowerCase();
|
|
117
|
-
const accounts =
|
|
117
|
+
const accounts = wecom.accounts;
|
|
118
118
|
const account =
|
|
119
119
|
accounts && typeof accounts === "object"
|
|
120
120
|
? accounts[accountId] ??
|
|
@@ -124,20 +124,20 @@ function resolveWxWorkAllowFrom(cfg, accountId) {
|
|
|
124
124
|
: undefined;
|
|
125
125
|
|
|
126
126
|
const allowFromRaw =
|
|
127
|
-
account?.dm?.allowFrom ?? account?.allowFrom ??
|
|
127
|
+
account?.dm?.allowFrom ?? account?.allowFrom ?? wecom.dm?.allowFrom ?? wecom.allowFrom ?? [];
|
|
128
128
|
|
|
129
129
|
if (!Array.isArray(allowFromRaw)) return [];
|
|
130
130
|
|
|
131
131
|
return allowFromRaw
|
|
132
|
-
.map(
|
|
132
|
+
.map(normalizeWecomAllowFromEntry)
|
|
133
133
|
.filter((entry) => Boolean(entry));
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
function
|
|
136
|
+
function resolveWecomCommandAuthorized({ cfg, accountId, senderId }) {
|
|
137
137
|
const sender = String(senderId ?? "").trim().toLowerCase();
|
|
138
138
|
if (!sender) return false;
|
|
139
139
|
|
|
140
|
-
const allowFrom =
|
|
140
|
+
const allowFrom = resolveWecomAllowFrom(cfg, accountId);
|
|
141
141
|
if (allowFrom.includes("*") || allowFrom.length === 0) return true;
|
|
142
142
|
return allowFrom.includes(sender);
|
|
143
143
|
}
|
|
@@ -170,13 +170,13 @@ function registerWebhookTarget(target) {
|
|
|
170
170
|
// Channel Plugin Definition
|
|
171
171
|
// =============================================================================
|
|
172
172
|
|
|
173
|
-
const
|
|
174
|
-
id: "
|
|
173
|
+
const wecomChannelPlugin = {
|
|
174
|
+
id: "wecom",
|
|
175
175
|
meta: {
|
|
176
|
-
id: "
|
|
176
|
+
id: "wecom",
|
|
177
177
|
label: "Enterprise WeChat",
|
|
178
178
|
selectionLabel: "Enterprise WeChat (AI Bot)",
|
|
179
|
-
docsPath: "/channels/
|
|
179
|
+
docsPath: "/channels/wecom",
|
|
180
180
|
blurb: "Enterprise WeChat AI Bot channel plugin.",
|
|
181
181
|
aliases: ["wecom", "wework"],
|
|
182
182
|
},
|
|
@@ -186,41 +186,41 @@ const wxworkChannelPlugin = {
|
|
|
186
186
|
threads: false,
|
|
187
187
|
media: false,
|
|
188
188
|
nativeCommands: false,
|
|
189
|
-
blockStreaming: true, //
|
|
189
|
+
blockStreaming: true, // WeCom AI Bot uses stream response format
|
|
190
190
|
},
|
|
191
|
-
reload: { configPrefixes: ["channels.
|
|
191
|
+
reload: { configPrefixes: ["channels.wecom"] },
|
|
192
192
|
config: {
|
|
193
193
|
listAccountIds: (cfg) => {
|
|
194
|
-
const
|
|
195
|
-
if (!
|
|
194
|
+
const wecom = cfg?.channels?.wecom;
|
|
195
|
+
if (!wecom || !wecom.enabled) return [];
|
|
196
196
|
return [DEFAULT_ACCOUNT_ID];
|
|
197
197
|
},
|
|
198
198
|
resolveAccount: (cfg, accountId) => {
|
|
199
|
-
const
|
|
200
|
-
if (!
|
|
199
|
+
const wecom = cfg?.channels?.wecom;
|
|
200
|
+
if (!wecom) return null;
|
|
201
201
|
return {
|
|
202
202
|
id: accountId || DEFAULT_ACCOUNT_ID,
|
|
203
203
|
accountId: accountId || DEFAULT_ACCOUNT_ID,
|
|
204
|
-
enabled:
|
|
205
|
-
token:
|
|
206
|
-
encodingAesKey:
|
|
207
|
-
webhookPath:
|
|
208
|
-
config:
|
|
204
|
+
enabled: wecom.enabled !== false,
|
|
205
|
+
token: wecom.token || "",
|
|
206
|
+
encodingAesKey: wecom.encodingAesKey || "",
|
|
207
|
+
webhookPath: wecom.webhookPath || "/webhooks/wecom",
|
|
208
|
+
config: wecom,
|
|
209
209
|
};
|
|
210
210
|
},
|
|
211
211
|
defaultAccountId: (cfg) => {
|
|
212
|
-
const
|
|
213
|
-
if (!
|
|
212
|
+
const wecom = cfg?.channels?.wecom;
|
|
213
|
+
if (!wecom || !wecom.enabled) return null;
|
|
214
214
|
return DEFAULT_ACCOUNT_ID;
|
|
215
215
|
},
|
|
216
216
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
|
217
217
|
if (!cfg.channels) cfg.channels = {};
|
|
218
|
-
if (!cfg.channels.
|
|
219
|
-
cfg.channels.
|
|
218
|
+
if (!cfg.channels.wecom) cfg.channels.wecom = {};
|
|
219
|
+
cfg.channels.wecom.enabled = enabled;
|
|
220
220
|
return cfg;
|
|
221
221
|
},
|
|
222
222
|
deleteAccount: ({ cfg, accountId }) => {
|
|
223
|
-
if (cfg.channels?.
|
|
223
|
+
if (cfg.channels?.wecom) delete cfg.channels.wecom;
|
|
224
224
|
return cfg;
|
|
225
225
|
},
|
|
226
226
|
},
|
|
@@ -232,8 +232,8 @@ const wxworkChannelPlugin = {
|
|
|
232
232
|
// Outbound adapter: Send messages via stream (all messages go through stream now)
|
|
233
233
|
outbound: {
|
|
234
234
|
sendText: async ({ cfg, to, text, accountId }) => {
|
|
235
|
-
// to格式: "
|
|
236
|
-
const userId = to.replace(/^
|
|
235
|
+
// to格式: \"wecom:userid\" 或 \"userid\"
|
|
236
|
+
const userId = to.replace(/^wecom:/, "");
|
|
237
237
|
|
|
238
238
|
// 获取该用户当前活跃的 streamId
|
|
239
239
|
const streamId = activeStreams.get(userId);
|
|
@@ -246,21 +246,21 @@ const wxworkChannelPlugin = {
|
|
|
246
246
|
streamManager.appendStream(streamId, separator + text);
|
|
247
247
|
|
|
248
248
|
return {
|
|
249
|
-
channel: "
|
|
249
|
+
channel: "wecom",
|
|
250
250
|
messageId: `msg_stream_${Date.now()}`,
|
|
251
251
|
};
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
// 如果没有活跃的流,记录警告
|
|
255
|
-
logger.warn("
|
|
255
|
+
logger.warn("WeCom outbound: no active stream for user", { userId });
|
|
256
256
|
|
|
257
257
|
return {
|
|
258
|
-
channel: "
|
|
258
|
+
channel: "wecom",
|
|
259
259
|
messageId: `fake_${Date.now()}`,
|
|
260
260
|
};
|
|
261
261
|
},
|
|
262
262
|
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
|
263
|
-
const userId = to.replace(/^
|
|
263
|
+
const userId = to.replace(/^wecom:/, "");
|
|
264
264
|
const streamId = activeStreams.get(userId);
|
|
265
265
|
|
|
266
266
|
if (streamId && streamManager.hasStream(streamId)) {
|
|
@@ -272,15 +272,15 @@ const wxworkChannelPlugin = {
|
|
|
272
272
|
streamManager.appendStream(streamId, separator + content);
|
|
273
273
|
|
|
274
274
|
return {
|
|
275
|
-
channel: "
|
|
275
|
+
channel: "wecom",
|
|
276
276
|
messageId: `msg_stream_${Date.now()}`,
|
|
277
277
|
};
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
logger.warn("
|
|
280
|
+
logger.warn("WeCom outbound sendMedia: no active stream", { userId });
|
|
281
281
|
|
|
282
282
|
return {
|
|
283
|
-
channel: "
|
|
283
|
+
channel: "wecom",
|
|
284
284
|
messageId: `fake_${Date.now()}`,
|
|
285
285
|
};
|
|
286
286
|
},
|
|
@@ -288,17 +288,17 @@ const wxworkChannelPlugin = {
|
|
|
288
288
|
gateway: {
|
|
289
289
|
startAccount: async (ctx) => {
|
|
290
290
|
const account = ctx.account;
|
|
291
|
-
logger.info("
|
|
291
|
+
logger.info("WeCom gateway starting", { accountId: account.accountId, webhookPath: account.webhookPath });
|
|
292
292
|
|
|
293
293
|
const unregister = registerWebhookTarget({
|
|
294
|
-
path: account.webhookPath || "/webhooks/
|
|
294
|
+
path: account.webhookPath || "/webhooks/wecom",
|
|
295
295
|
account,
|
|
296
296
|
config: ctx.cfg,
|
|
297
297
|
});
|
|
298
298
|
|
|
299
299
|
return {
|
|
300
300
|
shutdown: async () => {
|
|
301
|
-
logger.info("
|
|
301
|
+
logger.info("WeCom gateway shutting down");
|
|
302
302
|
unregister();
|
|
303
303
|
},
|
|
304
304
|
};
|
|
@@ -310,7 +310,7 @@ const wxworkChannelPlugin = {
|
|
|
310
310
|
// HTTP Webhook Handler
|
|
311
311
|
// =============================================================================
|
|
312
312
|
|
|
313
|
-
async function
|
|
313
|
+
async function wecomHttpHandler(req, res) {
|
|
314
314
|
const url = new URL(req.url || "", "http://localhost");
|
|
315
315
|
const path = normalizeWebhookPath(url.pathname);
|
|
316
316
|
const targets = webhookTargets.get(path);
|
|
@@ -320,7 +320,7 @@ async function wxworkHttpHandler(req, res) {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
const query = Object.fromEntries(url.searchParams);
|
|
323
|
-
logger.debug("
|
|
323
|
+
logger.debug("WeCom HTTP request", { method: req.method, path });
|
|
324
324
|
|
|
325
325
|
// GET: URL Verification
|
|
326
326
|
if (req.method === "GET") {
|
|
@@ -331,7 +331,7 @@ async function wxworkHttpHandler(req, res) {
|
|
|
331
331
|
return true;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
-
const webhook = new
|
|
334
|
+
const webhook = new WecomWebhook({
|
|
335
335
|
token: target.account.token,
|
|
336
336
|
encodingAesKey: target.account.encodingAesKey,
|
|
337
337
|
});
|
|
@@ -340,13 +340,13 @@ async function wxworkHttpHandler(req, res) {
|
|
|
340
340
|
if (echo) {
|
|
341
341
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
342
342
|
res.end(echo);
|
|
343
|
-
logger.info("
|
|
343
|
+
logger.info("WeCom URL verification successful");
|
|
344
344
|
return true;
|
|
345
345
|
}
|
|
346
346
|
|
|
347
347
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
348
348
|
res.end("Verification failed");
|
|
349
|
-
logger.warn("
|
|
349
|
+
logger.warn("WeCom URL verification failed");
|
|
350
350
|
return true;
|
|
351
351
|
}
|
|
352
352
|
|
|
@@ -365,9 +365,9 @@ async function wxworkHttpHandler(req, res) {
|
|
|
365
365
|
chunks.push(chunk);
|
|
366
366
|
}
|
|
367
367
|
const body = Buffer.concat(chunks).toString("utf-8");
|
|
368
|
-
logger.debug("
|
|
368
|
+
logger.debug("WeCom message received", { bodyLength: body.length });
|
|
369
369
|
|
|
370
|
-
const webhook = new
|
|
370
|
+
const webhook = new WecomWebhook({
|
|
371
371
|
token: target.account.token,
|
|
372
372
|
encodingAesKey: target.account.encodingAesKey,
|
|
373
373
|
});
|
|
@@ -413,7 +413,7 @@ async function wxworkHttpHandler(req, res) {
|
|
|
413
413
|
account: target.account,
|
|
414
414
|
config: target.config,
|
|
415
415
|
}).catch((err) => {
|
|
416
|
-
logger.error("
|
|
416
|
+
logger.error("WeCom message processing failed", { error: err.message });
|
|
417
417
|
// 即使失败也要标记流为完成
|
|
418
418
|
streamManager.finishStream(streamId);
|
|
419
419
|
});
|
|
@@ -474,7 +474,7 @@ async function wxworkHttpHandler(req, res) {
|
|
|
474
474
|
|
|
475
475
|
// Handle event
|
|
476
476
|
if (result.event) {
|
|
477
|
-
logger.info("
|
|
477
|
+
logger.info("WeCom event received", { event: result.event });
|
|
478
478
|
|
|
479
479
|
// 处理进入会话事件 - 发送欢迎语
|
|
480
480
|
if (result.event?.event_type === "enter_chat") {
|
|
@@ -544,7 +544,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
544
544
|
// 确定 peerId:群聊用 chatId,私聊用 senderId
|
|
545
545
|
const peerId = isGroupChat ? chatId : senderId;
|
|
546
546
|
const peerKind = isGroupChat ? "group" : "dm";
|
|
547
|
-
const conversationId = isGroupChat ? `
|
|
547
|
+
const conversationId = isGroupChat ? `wecom:group:${chatId}` : `wecom:${senderId}`;
|
|
548
548
|
|
|
549
549
|
// 设置用户当前活跃的 streamId,供 outbound.sendText 使用
|
|
550
550
|
// 群聊时用 chatId 作为 key
|
|
@@ -557,21 +557,21 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
557
557
|
let rawBody = rawContent;
|
|
558
558
|
if (isGroupChat) {
|
|
559
559
|
if (!shouldTriggerGroupResponse(rawContent, config)) {
|
|
560
|
-
logger.debug("
|
|
560
|
+
logger.debug("WeCom: group message ignored (no mention)", { chatId, senderId });
|
|
561
561
|
return;
|
|
562
562
|
}
|
|
563
563
|
// 提取实际内容(移除 @提及)
|
|
564
564
|
rawBody = extractGroupMessageContent(rawContent, config);
|
|
565
565
|
}
|
|
566
566
|
|
|
567
|
-
const commandAuthorized =
|
|
567
|
+
const commandAuthorized = resolveWecomCommandAuthorized({
|
|
568
568
|
cfg: config,
|
|
569
569
|
accountId: account.accountId,
|
|
570
570
|
senderId,
|
|
571
571
|
});
|
|
572
572
|
|
|
573
573
|
if (!rawBody.trim()) {
|
|
574
|
-
logger.debug("
|
|
574
|
+
logger.debug("WeCom: empty message, skipping");
|
|
575
575
|
return;
|
|
576
576
|
}
|
|
577
577
|
|
|
@@ -583,7 +583,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
583
583
|
if (commandCheck.isCommand && !commandCheck.allowed) {
|
|
584
584
|
// 命令不在白名单中,返回拒绝消息
|
|
585
585
|
const cmdConfig = getCommandConfig(config);
|
|
586
|
-
logger.warn("
|
|
586
|
+
logger.warn("WeCom: blocked command", {
|
|
587
587
|
command: commandCheck.command,
|
|
588
588
|
from: senderId,
|
|
589
589
|
chatType: peerKind
|
|
@@ -598,7 +598,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
598
598
|
return;
|
|
599
599
|
}
|
|
600
600
|
|
|
601
|
-
logger.info("
|
|
601
|
+
logger.info("WeCom processing message", {
|
|
602
602
|
from: senderId,
|
|
603
603
|
chatType: peerKind,
|
|
604
604
|
peerId,
|
|
@@ -626,7 +626,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
626
626
|
// ========================================================================
|
|
627
627
|
const route = core.routing.resolveAgentRoute({
|
|
628
628
|
cfg: config,
|
|
629
|
-
channel: "
|
|
629
|
+
channel: "wecom",
|
|
630
630
|
accountId: account.accountId,
|
|
631
631
|
peer: {
|
|
632
632
|
kind: peerKind,
|
|
@@ -666,7 +666,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
666
666
|
Body: body,
|
|
667
667
|
RawBody: rawBody,
|
|
668
668
|
CommandBody: rawBody,
|
|
669
|
-
From: `
|
|
669
|
+
From: `wecom:${senderId}`,
|
|
670
670
|
To: conversationId,
|
|
671
671
|
SessionKey: route.sessionKey,
|
|
672
672
|
AccountId: route.accountId,
|
|
@@ -675,9 +675,9 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
675
675
|
SenderName: senderId,
|
|
676
676
|
SenderId: senderId,
|
|
677
677
|
GroupId: isGroupChat ? chatId : undefined,
|
|
678
|
-
Provider: "
|
|
679
|
-
Surface: "
|
|
680
|
-
OriginatingChannel: "
|
|
678
|
+
Provider: "wecom",
|
|
679
|
+
Surface: "wecom",
|
|
680
|
+
OriginatingChannel: "wecom",
|
|
681
681
|
OriginatingTo: conversationId,
|
|
682
682
|
CommandAuthorized: commandAuthorized,
|
|
683
683
|
});
|
|
@@ -688,7 +688,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
688
688
|
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
689
689
|
ctx: ctxPayload,
|
|
690
690
|
}).catch((err) => {
|
|
691
|
-
logger.error("
|
|
691
|
+
logger.error("WeCom: failed updating session meta", { error: err.message });
|
|
692
692
|
});
|
|
693
693
|
|
|
694
694
|
// Dispatch reply with AI processing
|
|
@@ -703,7 +703,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
703
703
|
textPreview: (payload.text || "").substring(0, 50),
|
|
704
704
|
});
|
|
705
705
|
|
|
706
|
-
await
|
|
706
|
+
await deliverWecomReply({
|
|
707
707
|
payload,
|
|
708
708
|
account,
|
|
709
709
|
responseUrl,
|
|
@@ -714,11 +714,11 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
714
714
|
// 如果是最终回复,标记流为完成
|
|
715
715
|
if (streamId && info.kind === "final") {
|
|
716
716
|
streamManager.finishStream(streamId);
|
|
717
|
-
logger.info("
|
|
717
|
+
logger.info("WeCom stream finished", { streamId });
|
|
718
718
|
}
|
|
719
719
|
},
|
|
720
720
|
onError: (err, info) => {
|
|
721
|
-
logger.error("
|
|
721
|
+
logger.error("WeCom reply failed", { error: err.message, kind: info.kind });
|
|
722
722
|
// 发生错误时也标记流为完成
|
|
723
723
|
if (streamId) {
|
|
724
724
|
streamManager.finishStream(streamId);
|
|
@@ -731,7 +731,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
731
731
|
if (streamId) {
|
|
732
732
|
streamManager.finishStream(streamId);
|
|
733
733
|
activeStreams.delete(streamKey); // 清理活跃流映射
|
|
734
|
-
logger.info("
|
|
734
|
+
logger.info("WeCom stream finished (dispatch complete)", { streamId });
|
|
735
735
|
}
|
|
736
736
|
}
|
|
737
737
|
|
|
@@ -739,10 +739,10 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
|
|
|
739
739
|
// Outbound Reply Delivery (Stream-only mode)
|
|
740
740
|
// =============================================================================
|
|
741
741
|
|
|
742
|
-
async function
|
|
742
|
+
async function deliverWecomReply({ payload, account, responseUrl, senderId, streamId }) {
|
|
743
743
|
const text = payload.text || "";
|
|
744
744
|
|
|
745
|
-
logger.debug("
|
|
745
|
+
logger.debug("deliverWecomReply called", {
|
|
746
746
|
hasText: !!text.trim(),
|
|
747
747
|
textPreview: text.substring(0, 50),
|
|
748
748
|
streamId,
|
|
@@ -751,7 +751,7 @@ async function deliverWxWorkReply({ payload, account, responseUrl, senderId, str
|
|
|
751
751
|
|
|
752
752
|
// 所有消息都通过流式发送
|
|
753
753
|
if (!text.trim()) {
|
|
754
|
-
logger.debug("
|
|
754
|
+
logger.debug("WeCom: empty block, skipping stream update");
|
|
755
755
|
return;
|
|
756
756
|
}
|
|
757
757
|
|
|
@@ -762,7 +762,7 @@ async function deliverWxWorkReply({ payload, account, responseUrl, senderId, str
|
|
|
762
762
|
|
|
763
763
|
// 去重:检查流内容是否已包含此消息(避免 block + final 重复)
|
|
764
764
|
if (stream.content.includes(content.trim())) {
|
|
765
|
-
logger.debug("
|
|
765
|
+
logger.debug("WeCom: duplicate content, skipping", {
|
|
766
766
|
streamId: targetStreamId,
|
|
767
767
|
contentPreview: content.substring(0, 30)
|
|
768
768
|
});
|
|
@@ -779,23 +779,23 @@ async function deliverWxWorkReply({ payload, account, responseUrl, senderId, str
|
|
|
779
779
|
const activeStreamId = activeStreams.get(senderId);
|
|
780
780
|
if (activeStreamId && streamManager.hasStream(activeStreamId)) {
|
|
781
781
|
appendToStream(activeStreamId, text);
|
|
782
|
-
logger.debug("
|
|
782
|
+
logger.debug("WeCom stream appended (via activeStreams)", {
|
|
783
783
|
streamId: activeStreamId,
|
|
784
784
|
contentLength: text.length,
|
|
785
785
|
});
|
|
786
786
|
return;
|
|
787
787
|
}
|
|
788
|
-
logger.warn("
|
|
788
|
+
logger.warn("WeCom: no active stream for this message", { senderId });
|
|
789
789
|
return;
|
|
790
790
|
}
|
|
791
791
|
|
|
792
792
|
if (!streamManager.hasStream(streamId)) {
|
|
793
|
-
logger.warn("
|
|
793
|
+
logger.warn("WeCom: stream not found, cannot update", { streamId });
|
|
794
794
|
return;
|
|
795
795
|
}
|
|
796
796
|
|
|
797
797
|
appendToStream(streamId, text);
|
|
798
|
-
logger.debug("
|
|
798
|
+
logger.debug("WeCom stream appended", {
|
|
799
799
|
streamId,
|
|
800
800
|
contentLength: text.length,
|
|
801
801
|
to: senderId
|
|
@@ -807,24 +807,24 @@ async function deliverWxWorkReply({ payload, account, responseUrl, senderId, str
|
|
|
807
807
|
// =============================================================================
|
|
808
808
|
|
|
809
809
|
const plugin = {
|
|
810
|
-
id: "
|
|
810
|
+
id: "wecom",
|
|
811
811
|
name: "Enterprise WeChat",
|
|
812
812
|
description: "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
813
813
|
configSchema: { type: "object", additionalProperties: false, properties: {} },
|
|
814
814
|
register(api) {
|
|
815
|
-
logger.info("
|
|
815
|
+
logger.info("WeCom plugin registering...");
|
|
816
816
|
|
|
817
817
|
// Save runtime for message processing
|
|
818
818
|
setRuntime(api.runtime);
|
|
819
819
|
_openclawConfig = api.config;
|
|
820
820
|
|
|
821
821
|
// Register channel
|
|
822
|
-
api.registerChannel({ plugin:
|
|
823
|
-
logger.info("
|
|
822
|
+
api.registerChannel({ plugin: wecomChannelPlugin });
|
|
823
|
+
logger.info("WeCom channel registered");
|
|
824
824
|
|
|
825
825
|
// Register HTTP handler for webhooks
|
|
826
|
-
api.registerHttpHandler(
|
|
827
|
-
logger.info("
|
|
826
|
+
api.registerHttpHandler(wecomHttpHandler);
|
|
827
|
+
logger.info("WeCom HTTP handler registered");
|
|
828
828
|
},
|
|
829
829
|
};
|
|
830
830
|
|
package/logger.js
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-plugin-wecom",
|
|
3
|
-
"name": "OpenClaw WeCom
|
|
4
|
-
"description": "Enterprise WeChat (WeCom
|
|
5
|
-
"channels": [
|
|
3
|
+
"name": "OpenClaw WeCom",
|
|
4
|
+
"description": "Enterprise WeChat (WeCom) messaging channel plugin for OpenClaw",
|
|
5
|
+
"channels": [
|
|
6
|
+
"wecom"
|
|
7
|
+
],
|
|
6
8
|
"configSchema": {
|
|
7
9
|
"type": "object",
|
|
8
10
|
"additionalProperties": false,
|
|
9
11
|
"properties": {}
|
|
10
12
|
}
|
|
11
|
-
}
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-plugin-wecom",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"./index.js"
|
|
28
28
|
],
|
|
29
29
|
"channel": {
|
|
30
|
-
"id": "
|
|
30
|
+
"id": "wecom",
|
|
31
31
|
"label": "Enterprise WeChat",
|
|
32
32
|
"selectionLabel": "Enterprise WeChat (AI Bot)",
|
|
33
|
-
"docsPath": "/channels/
|
|
34
|
-
"docsLabel": "
|
|
33
|
+
"docsPath": "/channels/wecom",
|
|
34
|
+
"docsLabel": "wecom",
|
|
35
35
|
"blurb": "Support for Enterprise WeChat (WeCom) AI Bot integration",
|
|
36
36
|
"order": 90,
|
|
37
37
|
"aliases": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"keywords": [
|
|
48
48
|
"openclaw",
|
|
49
|
-
"
|
|
49
|
+
"wecom",
|
|
50
50
|
"wecom",
|
|
51
51
|
"chat",
|
|
52
52
|
"plugin"
|
package/utils.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Utility functions and helpers for
|
|
2
|
+
* Utility functions and helpers for WeCom plugin
|
|
3
3
|
*/
|
|
4
4
|
export class TTLCache {
|
|
5
5
|
options;
|
|
@@ -137,7 +137,7 @@ export class MessageDeduplicator {
|
|
|
137
137
|
this.seen.set(msgId, true);
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
|
-
export function
|
|
140
|
+
export function parseWecomError(errcode, errmsg) {
|
|
141
141
|
// Reference: https://developer.work.weixin.qq.com/document/path/96213
|
|
142
142
|
switch (errcode) {
|
|
143
143
|
case -1:
|
|
@@ -198,7 +198,7 @@ export function parseWxWorkError(errcode, errmsg) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
export function shouldRetryError(errcode) {
|
|
201
|
-
const info =
|
|
201
|
+
const info = parseWecomError(errcode, "");
|
|
202
202
|
return info.retryable;
|
|
203
203
|
}
|
|
204
204
|
// ============================================================================
|
package/webhook.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { WecomCrypto } from "./crypto.js";
|
|
2
2
|
import { logger } from "./logger.js";
|
|
3
3
|
import { MessageDeduplicator, randomString } from "./utils.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* WeCom AI Bot Webhook Handler
|
|
7
7
|
* Based on official demo: https://developer.work.weixin.qq.com/document/path/101039
|
|
8
8
|
*
|
|
9
9
|
* Key differences from legacy mode:
|
|
@@ -11,15 +11,15 @@ import { MessageDeduplicator, randomString } from "./utils.js";
|
|
|
11
11
|
* - receiveid is empty string for AI Bot
|
|
12
12
|
* - Response uses stream message format
|
|
13
13
|
*/
|
|
14
|
-
export class
|
|
14
|
+
export class WecomWebhook {
|
|
15
15
|
config;
|
|
16
16
|
crypto;
|
|
17
17
|
deduplicator = new MessageDeduplicator();
|
|
18
18
|
|
|
19
19
|
constructor(config) {
|
|
20
20
|
this.config = config;
|
|
21
|
-
this.crypto = new
|
|
22
|
-
logger.debug("
|
|
21
|
+
this.crypto = new WecomCrypto(config.token, config.encodingAesKey);
|
|
22
|
+
logger.debug("WecomWebhook initialized (AI Bot mode)");
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
// =========================================================================
|
|
@@ -180,7 +180,7 @@ export class WxWorkWebhook {
|
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
182
|
else if (msgtype === "stream") {
|
|
183
|
-
// Stream continuation request from
|
|
183
|
+
// Stream continuation request from WeCom
|
|
184
184
|
const streamId = data.stream?.id;
|
|
185
185
|
logger.debug("Received stream refresh request", { streamId });
|
|
186
186
|
return {
|