@creatoraris/openclaw-wecom 0.1.0-beta
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/LICENSE +21 -0
- package/README.md +304 -0
- package/crypto.js +64 -0
- package/index.js +355 -0
- package/openclaw.plugin.json +73 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CreatorAris
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# OpenClaw WeCom Plugin
|
|
2
|
+
|
|
3
|
+
企业微信(WeCom)智能助手机器人桥接插件,让你的 OpenClaw AI 助手接入企业微信。
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@creatoraris/openclaw-wecom)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
## ✨ 特性
|
|
9
|
+
|
|
10
|
+
- ✅ 支持流式回复(分段发送,体验更流畅)
|
|
11
|
+
- ✅ 支持消息加密(符合企微安全规范)
|
|
12
|
+
- ✅ 支持单聊和群聊
|
|
13
|
+
- ✅ 自动消息去重
|
|
14
|
+
- ✅ 生产环境验证
|
|
15
|
+
|
|
16
|
+
## 📋 前置条件
|
|
17
|
+
|
|
18
|
+
- OpenClaw 已安装并运行
|
|
19
|
+
- Node.js >= 18.0.0
|
|
20
|
+
- 企业微信账号(个人也可注册企业版,1-9 人免费)
|
|
21
|
+
- 一台可部署 Webhook 服务的机器(或使用 ngrok 本地开发)
|
|
22
|
+
|
|
23
|
+
## 🚀 快速开始
|
|
24
|
+
|
|
25
|
+
### 总耗时:约 20 分钟
|
|
26
|
+
|
|
27
|
+
### 步骤 1:安装插件(2 分钟)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
openclaw plugins install @creatoraris/openclaw-wecom
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 步骤 2:申请企微机器人(10 分钟)
|
|
34
|
+
|
|
35
|
+
#### 2.1 注册企业微信
|
|
36
|
+
|
|
37
|
+
如果还没有企业微信账号:
|
|
38
|
+
|
|
39
|
+
1. 访问 [企业微信官网](https://work.weixin.qq.com/)
|
|
40
|
+
2. 点击「注册企业」
|
|
41
|
+
3. 填写信息(可以用个人身份注册)
|
|
42
|
+
4. 验证手机号
|
|
43
|
+
|
|
44
|
+
#### 2.2 创建智能助手机器人
|
|
45
|
+
|
|
46
|
+
1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin/)
|
|
47
|
+
2. 左侧菜单:「应用管理」→「应用」
|
|
48
|
+
3. 找到「智能助手」,点击进入
|
|
49
|
+
4. 点击「创建智能助手」
|
|
50
|
+
5. 填写名称(如:OpenClaw AI 助手)
|
|
51
|
+
6. 点击「创建」
|
|
52
|
+
|
|
53
|
+
#### 2.3 配置回调
|
|
54
|
+
|
|
55
|
+
1. 在智能助手详情页,点击「接收消息」标签
|
|
56
|
+
2. 设置回调 URL:
|
|
57
|
+
```
|
|
58
|
+
https://your-domain.com/wecom/callback
|
|
59
|
+
```
|
|
60
|
+
> 💡 本地开发可以用 [ngrok](https://ngrok.com/):`https://xxx.ngrok.io/wecom/callback`
|
|
61
|
+
|
|
62
|
+
3. 点击「生成 Token 和 EncodingAESKey」
|
|
63
|
+
4. **保存这三个值**(下一步要用):
|
|
64
|
+
- Token: `abc123...`
|
|
65
|
+
- EncodingAESKey: `xyz789...`
|
|
66
|
+
- Webhook URL: `https://your-domain.com/wecom/callback`
|
|
67
|
+
|
|
68
|
+
5. 点击「保存」
|
|
69
|
+
|
|
70
|
+
✅ 机器人申请完成!
|
|
71
|
+
|
|
72
|
+
### 步骤 3:配置 OpenClaw(5 分钟)
|
|
73
|
+
|
|
74
|
+
#### 3.1 编辑配置文件
|
|
75
|
+
|
|
76
|
+
编辑 `~/.openclaw/openclaw.json`:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"channels": {
|
|
81
|
+
"wecom": {
|
|
82
|
+
"enabled": true,
|
|
83
|
+
"webhookUrl": "https://your-domain.com/wecom/callback",
|
|
84
|
+
"token": "你的 Token",
|
|
85
|
+
"encodingAESKey": "你的 EncodingAESKey",
|
|
86
|
+
"port": 8788
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### 3.2 配置环境变量
|
|
93
|
+
|
|
94
|
+
创建 `.env` 文件(或直接在启动脚本中设置):
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
WECOM_TOKEN=你的Token
|
|
98
|
+
WECOM_ENCODING_AES_KEY=你的EncodingAESKey
|
|
99
|
+
OPENCLAW_API=http://localhost:18789/v1/chat/completions
|
|
100
|
+
OPENCLAW_TOKEN=你的OpenClaw_Gateway_Token
|
|
101
|
+
PORT=8788
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
#### 3.3 部署 Webhook 服务
|
|
105
|
+
|
|
106
|
+
**方式 A:使用 systemd(推荐)**
|
|
107
|
+
|
|
108
|
+
创建 `/etc/systemd/system/wecom-bridge.service`:
|
|
109
|
+
|
|
110
|
+
```ini
|
|
111
|
+
[Unit]
|
|
112
|
+
Description=OpenClaw WeCom Bridge
|
|
113
|
+
After=network.target
|
|
114
|
+
|
|
115
|
+
[Service]
|
|
116
|
+
Type=simple
|
|
117
|
+
User=your-user
|
|
118
|
+
WorkingDirectory=/path/to/openclaw-wecom-plugin
|
|
119
|
+
EnvironmentFile=/path/to/.env
|
|
120
|
+
ExecStart=/usr/bin/node index.js
|
|
121
|
+
Restart=always
|
|
122
|
+
RestartSec=10
|
|
123
|
+
|
|
124
|
+
[Install]
|
|
125
|
+
WantedBy=multi-user.target
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
启动服务:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
sudo systemctl daemon-reload
|
|
132
|
+
sudo systemctl enable wecom-bridge
|
|
133
|
+
sudo systemctl start wecom-bridge
|
|
134
|
+
sudo systemctl status wecom-bridge
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**方式 B:使用 PM2**
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pm2 start index.js --name wecom-bridge
|
|
141
|
+
pm2 save
|
|
142
|
+
pm2 startup
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**方式 C:本地开发(ngrok)**
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# 终端 1:启动 webhook 服务
|
|
149
|
+
node index.js
|
|
150
|
+
|
|
151
|
+
# 终端 2:启动 ngrok
|
|
152
|
+
ngrok http 8788
|
|
153
|
+
# 复制 ngrok 生成的 https URL,配置到企微回调中
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 步骤 4:测试(1 分钟)
|
|
157
|
+
|
|
158
|
+
1. 在企业微信中找到你创建的机器人
|
|
159
|
+
2. 发送消息:`你好`
|
|
160
|
+
3. 应该会收到 AI 回复
|
|
161
|
+
|
|
162
|
+
🎉 恭喜!配置完成!
|
|
163
|
+
|
|
164
|
+
## 📖 配置说明
|
|
165
|
+
|
|
166
|
+
### 完整配置选项
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{
|
|
170
|
+
"channels": {
|
|
171
|
+
"wecom": {
|
|
172
|
+
"enabled": true, // 是否启用
|
|
173
|
+
"webhookUrl": "https://...", // 回调 URL(必填)
|
|
174
|
+
"token": "...", // Token(必填)
|
|
175
|
+
"encodingAESKey": "...", // EncodingAESKey(必填)
|
|
176
|
+
"port": 8788, // 监听端口(可选,默认 8788)
|
|
177
|
+
"streamDelay": 2000 // 流式回复延迟(毫秒,可选)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### OpenClaw Gateway 配置
|
|
184
|
+
|
|
185
|
+
确保 OpenClaw Gateway 配置了正确的认证:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"gateway": {
|
|
190
|
+
"auth": {
|
|
191
|
+
"mode": "token",
|
|
192
|
+
"token": "your-gateway-token"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## 🔧 故障排查
|
|
199
|
+
|
|
200
|
+
### 问题:消息发送后没有回复
|
|
201
|
+
|
|
202
|
+
**检查清单:**
|
|
203
|
+
|
|
204
|
+
1. Webhook 服务器是否正常运行?
|
|
205
|
+
```bash
|
|
206
|
+
curl http://localhost:8788/health
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
2. 查看日志:
|
|
210
|
+
```bash
|
|
211
|
+
# systemd
|
|
212
|
+
sudo journalctl -u wecom-bridge -f
|
|
213
|
+
|
|
214
|
+
# pm2
|
|
215
|
+
pm2 logs wecom-bridge
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
3. 验证 OpenClaw Gateway 连接:
|
|
219
|
+
```bash
|
|
220
|
+
curl http://localhost:18789/v1/status \
|
|
221
|
+
-H "Authorization: Bearer your-token"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### 问题:消息乱码
|
|
225
|
+
|
|
226
|
+
检查 `encodingAESKey` 是否正确配置。
|
|
227
|
+
|
|
228
|
+
### 问题:回复速度慢
|
|
229
|
+
|
|
230
|
+
流式回复会分段发送。可以调整 `streamDelay` 参数:
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"wecom": {
|
|
235
|
+
"streamDelay": 1000 // 减小延迟(毫秒)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### 问题:Webhook 服务启动失败
|
|
241
|
+
|
|
242
|
+
1. 检查端口是否被占用:
|
|
243
|
+
```bash
|
|
244
|
+
lsof -i :8788
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
2. 检查环境变量是否正确:
|
|
248
|
+
```bash
|
|
249
|
+
echo $WECOM_TOKEN
|
|
250
|
+
echo $WECOM_ENCODING_AES_KEY
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## 🏗️ 架构说明
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
企微客户端 → 企微服务器 → Webhook 服务器 → OpenClaw Gateway → AI 模型
|
|
257
|
+
↓
|
|
258
|
+
加密/解密、去重、流式处理
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### 消息流程
|
|
262
|
+
|
|
263
|
+
1. 用户在企微发送消息
|
|
264
|
+
2. 企微服务器加密后推送到 Webhook
|
|
265
|
+
3. Webhook 服务解密、验证、去重
|
|
266
|
+
4. 转发到 OpenClaw Gateway
|
|
267
|
+
5. AI 生成回复(流式)
|
|
268
|
+
6. Webhook 服务分段加密发送到企微
|
|
269
|
+
7. 用户收到 AI 回复
|
|
270
|
+
|
|
271
|
+
## 🔒 安全性
|
|
272
|
+
|
|
273
|
+
- ✅ 使用 AES-256-CBC 加密
|
|
274
|
+
- ✅ 消息签名验证
|
|
275
|
+
- ✅ 自动消息去重(防止重放攻击)
|
|
276
|
+
- ✅ 环境变量存储敏感信息
|
|
277
|
+
|
|
278
|
+
## 🤝 贡献
|
|
279
|
+
|
|
280
|
+
欢迎贡献代码、报告问题或提出建议!
|
|
281
|
+
|
|
282
|
+
1. Fork 本仓库
|
|
283
|
+
2. 创建特性分支:`git checkout -b feature/amazing-feature`
|
|
284
|
+
3. 提交更改:`git commit -m 'Add amazing feature'`
|
|
285
|
+
4. 推送分支:`git push origin feature/amazing-feature`
|
|
286
|
+
5. 提交 Pull Request
|
|
287
|
+
|
|
288
|
+
## 📄 License
|
|
289
|
+
|
|
290
|
+
MIT License - 详见 [LICENSE](LICENSE) 文件
|
|
291
|
+
|
|
292
|
+
## 🙏 致谢
|
|
293
|
+
|
|
294
|
+
- [OpenClaw](https://openclaw.ai) - 强大的 AI 助手框架
|
|
295
|
+
- [企业微信](https://work.weixin.qq.com/) - 提供企业级通讯平台
|
|
296
|
+
|
|
297
|
+
## 📞 支持
|
|
298
|
+
|
|
299
|
+
- 提交 Issue:[GitHub Issues](https://github.com/CreatorAris/openclaw-wecom-plugin/issues)
|
|
300
|
+
- 查看文档:[OpenClaw 文档](https://docs.openclaw.ai)
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
**注意:** 本项目为 beta 版本,欢迎反馈和建议!
|
package/crypto.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 企业微信智能机器人加解密模块
|
|
3
|
+
*/
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
export class WeComCrypto {
|
|
7
|
+
constructor(token, encodingAESKey, receiverId = '') {
|
|
8
|
+
this.token = token;
|
|
9
|
+
this.receiverId = receiverId;
|
|
10
|
+
this.aesKey = Buffer.from(encodingAESKey + '=', 'base64');
|
|
11
|
+
this.iv = this.aesKey.subarray(0, 16);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
verifySignature(signature, timestamp, nonce, echostr) {
|
|
15
|
+
const arr = [this.token, timestamp, nonce, echostr].filter(Boolean).sort();
|
|
16
|
+
const hash = crypto.createHash('sha1').update(arr.join('')).digest('hex');
|
|
17
|
+
return hash === signature;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
decrypt(encrypted) {
|
|
21
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', this.aesKey, this.iv);
|
|
22
|
+
decipher.setAutoPadding(false);
|
|
23
|
+
|
|
24
|
+
let decrypted = Buffer.concat([
|
|
25
|
+
decipher.update(encrypted, 'base64'),
|
|
26
|
+
decipher.final(),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const pad = decrypted[decrypted.length - 1];
|
|
30
|
+
decrypted = decrypted.subarray(0, decrypted.length - pad);
|
|
31
|
+
|
|
32
|
+
const msgLen = decrypted.readUInt32BE(16);
|
|
33
|
+
const message = decrypted.subarray(20, 20 + msgLen).toString('utf8');
|
|
34
|
+
const receiverId = decrypted.subarray(20 + msgLen).toString('utf8');
|
|
35
|
+
|
|
36
|
+
return { message, receiverId };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
encrypt(message) {
|
|
40
|
+
const msgBuf = Buffer.from(message, 'utf8');
|
|
41
|
+
const receiverBuf = Buffer.from(this.receiverId, 'utf8');
|
|
42
|
+
|
|
43
|
+
const random = crypto.randomBytes(16);
|
|
44
|
+
const lenBuf = Buffer.alloc(4);
|
|
45
|
+
lenBuf.writeUInt32BE(msgBuf.length, 0);
|
|
46
|
+
|
|
47
|
+
let data = Buffer.concat([random, lenBuf, msgBuf, receiverBuf]);
|
|
48
|
+
|
|
49
|
+
const blockSize = 32;
|
|
50
|
+
const pad = blockSize - (data.length % blockSize);
|
|
51
|
+
data = Buffer.concat([data, Buffer.alloc(pad, pad)]);
|
|
52
|
+
|
|
53
|
+
const cipher = crypto.createCipheriv('aes-256-cbc', this.aesKey, this.iv);
|
|
54
|
+
cipher.setAutoPadding(false);
|
|
55
|
+
|
|
56
|
+
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
57
|
+
return encrypted.toString('base64');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
sign(timestamp, nonce, encrypted) {
|
|
61
|
+
const arr = [this.token, timestamp, nonce, encrypted].sort();
|
|
62
|
+
return crypto.createHash('sha1').update(arr.join('')).digest('hex');
|
|
63
|
+
}
|
|
64
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { URL } from 'node:url';
|
|
5
|
+
import { WeComCrypto } from './crypto.js';
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
WECOM_TOKEN,
|
|
9
|
+
WECOM_ENCODING_AES_KEY,
|
|
10
|
+
OPENCLAW_API,
|
|
11
|
+
OPENCLAW_TOKEN,
|
|
12
|
+
PORT = '8788',
|
|
13
|
+
} = process.env;
|
|
14
|
+
|
|
15
|
+
const botCrypto = new WeComCrypto(WECOM_TOKEN, WECOM_ENCODING_AES_KEY, '');
|
|
16
|
+
|
|
17
|
+
// ── Stream state management ──
|
|
18
|
+
// Map<streamId, { content: string, finished: boolean, responseUrl: string }>
|
|
19
|
+
const streams = new Map();
|
|
20
|
+
|
|
21
|
+
function cleanupStreams() {
|
|
22
|
+
const cutoff = Date.now() - 10 * 60 * 1000; // 10 min
|
|
23
|
+
for (const [k, v] of streams) {
|
|
24
|
+
if (v.createdAt < cutoff) streams.delete(k);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Dedup ──
|
|
29
|
+
const processedMsgIds = new Map();
|
|
30
|
+
function isDuplicate(msgid) {
|
|
31
|
+
if (!msgid) return false;
|
|
32
|
+
if (processedMsgIds.has(msgid)) return true;
|
|
33
|
+
processedMsgIds.set(msgid, Date.now());
|
|
34
|
+
if (processedMsgIds.size > 1000) {
|
|
35
|
+
const cutoff = Date.now() - 600000;
|
|
36
|
+
for (const [k, v] of processedMsgIds) {
|
|
37
|
+
if (v < cutoff) processedMsgIds.delete(k);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Helpers ──
|
|
44
|
+
|
|
45
|
+
function readBody(req) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const chunks = [];
|
|
48
|
+
req.on('data', (c) => chunks.push(c));
|
|
49
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
50
|
+
req.on('error', reject);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeStreamId() {
|
|
55
|
+
return crypto.randomBytes(12).toString('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function encryptReply(jsonObj, nonce) {
|
|
59
|
+
const plaintext = JSON.stringify(jsonObj);
|
|
60
|
+
const encrypted = botCrypto.encrypt(plaintext);
|
|
61
|
+
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
62
|
+
const msgsignature = botCrypto.sign(timestamp, nonce, encrypted);
|
|
63
|
+
return JSON.stringify({ encrypt: encrypted, msgsignature, timestamp: Number(timestamp), nonce });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function extractUserText(msg) {
|
|
67
|
+
if (msg.msgtype === 'text') {
|
|
68
|
+
return msg.text?.content?.replace(/@\S+\s?/g, '').trim() || '';
|
|
69
|
+
}
|
|
70
|
+
if (msg.msgtype === 'voice') {
|
|
71
|
+
return msg.voice?.content?.trim() || '';
|
|
72
|
+
}
|
|
73
|
+
if (msg.msgtype === 'mixed') {
|
|
74
|
+
const parts = msg.mixed?.msg_item || [];
|
|
75
|
+
return parts
|
|
76
|
+
.filter(p => p.msgtype === 'text')
|
|
77
|
+
.map(p => p.text?.content || '')
|
|
78
|
+
.join(' ')
|
|
79
|
+
.replace(/@\S+\s?/g, '')
|
|
80
|
+
.trim();
|
|
81
|
+
}
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── OpenClaw SSE streaming call ──
|
|
86
|
+
|
|
87
|
+
async function callOpenClawStream(text, sessionId, streamId) {
|
|
88
|
+
const state = streams.get(streamId);
|
|
89
|
+
if (!state) return;
|
|
90
|
+
|
|
91
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
92
|
+
if (OPENCLAW_TOKEN) headers['Authorization'] = `Bearer ${OPENCLAW_TOKEN}`;
|
|
93
|
+
|
|
94
|
+
console.log(`[OpenClaw →] session=${sessionId} text=${text.slice(0, 100)}`);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(OPENCLAW_API, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers,
|
|
100
|
+
body: JSON.stringify({ model: 'openclaw', input: text, user: sessionId, stream: true }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const err = await res.text();
|
|
105
|
+
console.error(`[OpenClaw] HTTP ${res.status}: ${err.slice(0, 200)}`);
|
|
106
|
+
state.content = '⚠️ 服务暂时不可用,请稍后再试';
|
|
107
|
+
state.finished = true;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Parse SSE stream
|
|
112
|
+
const reader = res.body.getReader();
|
|
113
|
+
const decoder = new TextDecoder();
|
|
114
|
+
let buffer = '';
|
|
115
|
+
|
|
116
|
+
while (true) {
|
|
117
|
+
const { done, value } = await reader.read();
|
|
118
|
+
if (done) break;
|
|
119
|
+
|
|
120
|
+
buffer += decoder.decode(value, { stream: true });
|
|
121
|
+
const lines = buffer.split('\n');
|
|
122
|
+
buffer = lines.pop(); // keep incomplete line
|
|
123
|
+
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (!line.startsWith('data: ')) continue;
|
|
126
|
+
const data = line.slice(6).trim();
|
|
127
|
+
if (data === '[DONE]') continue;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const event = JSON.parse(data);
|
|
131
|
+
|
|
132
|
+
// OpenResponses streaming: response.output_text.delta / response.completed
|
|
133
|
+
if (event.type === 'response.output_text.delta') {
|
|
134
|
+
state.content += event.delta || '';
|
|
135
|
+
} else if (event.type === 'response.completed') {
|
|
136
|
+
// Extract final text from completed response
|
|
137
|
+
const output = event.response?.output;
|
|
138
|
+
if (output && Array.isArray(output)) {
|
|
139
|
+
const texts = [];
|
|
140
|
+
for (const item of output) {
|
|
141
|
+
if (item.type === 'message' && Array.isArray(item.content)) {
|
|
142
|
+
for (const part of item.content) {
|
|
143
|
+
if (part.type === 'output_text' && part.text) texts.push(part.text);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const fullText = texts.join('\n').trim();
|
|
148
|
+
if (fullText) state.content = fullText;
|
|
149
|
+
}
|
|
150
|
+
state.finished = true;
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// If stream ended without explicit completion
|
|
157
|
+
if (!state.finished) {
|
|
158
|
+
state.finished = true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!state.content) {
|
|
162
|
+
state.content = '(无回复)';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log(`[OpenClaw ←] stream=${streamId} len=${state.content.length} done`);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error(`[OpenClaw] error: ${err.message}`);
|
|
168
|
+
state.content = '⚠️ 请求失败,请稍后再试';
|
|
169
|
+
state.finished = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── HTTP Server ──
|
|
174
|
+
|
|
175
|
+
const server = http.createServer(async (req, res) => {
|
|
176
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
177
|
+
|
|
178
|
+
if (url.pathname !== '/callback') {
|
|
179
|
+
res.writeHead(404);
|
|
180
|
+
res.end('not found');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const msgSignature = url.searchParams.get('msg_signature');
|
|
185
|
+
const timestamp = url.searchParams.get('timestamp');
|
|
186
|
+
const nonce = url.searchParams.get('nonce');
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
// GET: URL verification
|
|
190
|
+
if (req.method === 'GET') {
|
|
191
|
+
const echostr = url.searchParams.get('echostr');
|
|
192
|
+
if (!botCrypto.verifySignature(msgSignature, timestamp, nonce, echostr)) {
|
|
193
|
+
res.writeHead(403);
|
|
194
|
+
res.end('signature mismatch');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const { message } = botCrypto.decrypt(echostr);
|
|
198
|
+
console.log('[Verify] OK');
|
|
199
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
200
|
+
res.end(message);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// POST: message callback
|
|
205
|
+
if (req.method === 'POST') {
|
|
206
|
+
const body = await readBody(req);
|
|
207
|
+
const trimmed = body.trim();
|
|
208
|
+
|
|
209
|
+
let encrypt;
|
|
210
|
+
if (trimmed.startsWith('{')) {
|
|
211
|
+
try { encrypt = JSON.parse(trimmed).encrypt; } catch {}
|
|
212
|
+
}
|
|
213
|
+
if (!encrypt) {
|
|
214
|
+
res.writeHead(400);
|
|
215
|
+
res.end('bad request');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!botCrypto.verifySignature(msgSignature, timestamp, nonce, encrypt)) {
|
|
220
|
+
res.writeHead(403);
|
|
221
|
+
res.end('signature mismatch');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const { message: decrypted } = botCrypto.decrypt(encrypt);
|
|
226
|
+
const msg = JSON.parse(decrypted);
|
|
227
|
+
|
|
228
|
+
const { msgid, chatid, chattype, from, response_url, msgtype } = msg;
|
|
229
|
+
const source = chattype === 'group' ? `group:${chatid}` : `user:${from?.userid}`;
|
|
230
|
+
|
|
231
|
+
// ── Stream refresh callback ──
|
|
232
|
+
if (msgtype === 'stream') {
|
|
233
|
+
const sid = msg.stream?.id;
|
|
234
|
+
const state = streams.get(sid);
|
|
235
|
+
|
|
236
|
+
if (!state) {
|
|
237
|
+
console.log(`[Stream] unknown id=${sid}, reply empty`);
|
|
238
|
+
res.writeHead(200);
|
|
239
|
+
res.end('');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (isDuplicate(msgid)) {
|
|
244
|
+
res.writeHead(200);
|
|
245
|
+
res.end('');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const replyObj = {
|
|
250
|
+
msgtype: 'stream',
|
|
251
|
+
stream: {
|
|
252
|
+
id: sid,
|
|
253
|
+
finish: state.finished,
|
|
254
|
+
content: state.content || '思考中...',
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const encrypted = encryptReply(replyObj, nonce);
|
|
259
|
+
console.log(`[Stream ↻] id=${sid} finish=${state.finished} len=${(state.content || '').length}`);
|
|
260
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
261
|
+
res.end(encrypted);
|
|
262
|
+
|
|
263
|
+
// Cleanup finished streams after final reply
|
|
264
|
+
if (state.finished) {
|
|
265
|
+
setTimeout(() => streams.delete(sid), 30000);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── User message callback ──
|
|
271
|
+
if (isDuplicate(msgid)) {
|
|
272
|
+
res.writeHead(200);
|
|
273
|
+
res.end('');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Handle enter_chat event
|
|
278
|
+
if (msgtype === 'event') {
|
|
279
|
+
console.log(`[Event] ${msg.event?.eventtype} from=${from?.userid}`);
|
|
280
|
+
res.writeHead(200);
|
|
281
|
+
res.end('');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const text = extractUserText(msg);
|
|
286
|
+
console.log(`[← ${source}] (${msgtype}) ${text.slice(0, 100)}`);
|
|
287
|
+
|
|
288
|
+
if (!text) {
|
|
289
|
+
res.writeHead(200);
|
|
290
|
+
res.end('');
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Create stream state and start OpenClaw request
|
|
295
|
+
const streamId = makeStreamId();
|
|
296
|
+
const sessionId = chattype === 'group' ? `wecom_group_${chatid}` : `wecom_bot_${from?.userid}`;
|
|
297
|
+
|
|
298
|
+
streams.set(streamId, {
|
|
299
|
+
content: '',
|
|
300
|
+
finished: false,
|
|
301
|
+
responseUrl: response_url,
|
|
302
|
+
createdAt: Date.now(),
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Reply with initial stream message (encrypted)
|
|
306
|
+
const replyObj = {
|
|
307
|
+
msgtype: 'stream',
|
|
308
|
+
stream: {
|
|
309
|
+
id: streamId,
|
|
310
|
+
finish: false,
|
|
311
|
+
content: '思考中...',
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const encrypted = encryptReply(replyObj, nonce);
|
|
316
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
317
|
+
res.end(encrypted);
|
|
318
|
+
|
|
319
|
+
console.log(`[Stream →] id=${streamId} started`);
|
|
320
|
+
|
|
321
|
+
// Fire off OpenClaw request asynchronously
|
|
322
|
+
callOpenClawStream(text, sessionId, streamId).catch(err => {
|
|
323
|
+
console.error(`[OpenClaw] unhandled: ${err.message}`);
|
|
324
|
+
const state = streams.get(streamId);
|
|
325
|
+
if (state && !state.finished) {
|
|
326
|
+
state.content = '⚠️ 内部错误';
|
|
327
|
+
state.finished = true;
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
cleanupStreams();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
res.writeHead(405);
|
|
336
|
+
res.end('method not allowed');
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error('[Server] error:', err.message);
|
|
339
|
+
res.writeHead(500);
|
|
340
|
+
res.end('internal error');
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
server.listen(Number(PORT), '127.0.0.1', () => {
|
|
345
|
+
console.log(`wecom-bridge v1.0.0 listening on 127.0.0.1:${PORT}`);
|
|
346
|
+
console.log(` Mode: 智能机器人 (流式回复)`);
|
|
347
|
+
console.log(` Callback: /callback`);
|
|
348
|
+
console.log(` OpenClaw: ${OPENCLAW_API}`);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
process.on('SIGINT', () => {
|
|
352
|
+
console.log('\nshutting down...');
|
|
353
|
+
server.close();
|
|
354
|
+
process.exit(0);
|
|
355
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "wecom",
|
|
3
|
+
"version": "0.1.0-beta",
|
|
4
|
+
"name": "企业微信(WeCom)",
|
|
5
|
+
"description": "企业微信智能助手机器人桥接,支持流式回复、加密消息、单聊和群聊",
|
|
6
|
+
"author": "CreatorAris",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/CreatorAris/openclaw-wecom-plugin",
|
|
9
|
+
"extensions": ["./index.js"],
|
|
10
|
+
"channel": {
|
|
11
|
+
"id": "wecom",
|
|
12
|
+
"label": "企业微信",
|
|
13
|
+
"selectionLabel": "企业微信(智能助手机器人)",
|
|
14
|
+
"docsPath": "/channels/wecom",
|
|
15
|
+
"docsLabel": "wecom",
|
|
16
|
+
"blurb": "通过企业微信智能助手机器人接入 OpenClaw,支持流式回复和加密消息",
|
|
17
|
+
"order": 50,
|
|
18
|
+
"aliases": ["wechat-work", "wxwork", "qywx"]
|
|
19
|
+
},
|
|
20
|
+
"install": {
|
|
21
|
+
"npmSpec": "@creatoraris/openclaw-wecom",
|
|
22
|
+
"defaultChoice": "npm"
|
|
23
|
+
},
|
|
24
|
+
"configSchema": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"enabled": {
|
|
28
|
+
"type": "boolean",
|
|
29
|
+
"description": "启用企业微信通道"
|
|
30
|
+
},
|
|
31
|
+
"webhookUrl": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"format": "uri",
|
|
34
|
+
"description": "Webhook 回调 URL"
|
|
35
|
+
},
|
|
36
|
+
"token": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "企业微信机器人 Token"
|
|
39
|
+
},
|
|
40
|
+
"encodingAESKey": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "企业微信消息加密密钥"
|
|
43
|
+
},
|
|
44
|
+
"port": {
|
|
45
|
+
"type": "number",
|
|
46
|
+
"default": 8788,
|
|
47
|
+
"description": "Webhook 服务器监听端口"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"required": ["webhookUrl", "token", "encodingAESKey"]
|
|
51
|
+
},
|
|
52
|
+
"uiHints": {
|
|
53
|
+
"webhookUrl": {
|
|
54
|
+
"label": "Webhook URL",
|
|
55
|
+
"placeholder": "https://your-domain.com/wecom/callback",
|
|
56
|
+
"description": "企业微信回调 URL,需要公网可访问"
|
|
57
|
+
},
|
|
58
|
+
"token": {
|
|
59
|
+
"label": "Token",
|
|
60
|
+
"sensitive": true,
|
|
61
|
+
"description": "在企业微信机器人配置页面生成"
|
|
62
|
+
},
|
|
63
|
+
"encodingAESKey": {
|
|
64
|
+
"label": "EncodingAESKey",
|
|
65
|
+
"sensitive": true,
|
|
66
|
+
"description": "用于消息加密解密"
|
|
67
|
+
},
|
|
68
|
+
"port": {
|
|
69
|
+
"label": "端口",
|
|
70
|
+
"description": "Webhook 服务器监听端口"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@creatoraris/openclaw-wecom",
|
|
3
|
+
"version": "0.1.0-beta",
|
|
4
|
+
"description": "Enterprise WeChat (WeCom) channel plugin for OpenClaw - 企业微信智能助手机器人桥接",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"openclaw",
|
|
9
|
+
"wecom",
|
|
10
|
+
"wechat",
|
|
11
|
+
"enterprise-wechat",
|
|
12
|
+
"chatbot",
|
|
13
|
+
"plugin",
|
|
14
|
+
"ai"
|
|
15
|
+
],
|
|
16
|
+
"author": "CreatorAris <ganjiaxing@example.com>",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/CreatorAris/openclaw-wecom-plugin.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/CreatorAris/openclaw-wecom-plugin/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/CreatorAris/openclaw-wecom-plugin#readme",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "node index.js"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"dotenv": "^16.4.0"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"index.js",
|
|
37
|
+
"crypto.js",
|
|
38
|
+
"openclaw.plugin.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
]
|
|
42
|
+
}
|