moltbot-wecom 1.0.2
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 +61 -0
- package/moltbot.plugin.json +27 -0
- package/package.json +72 -0
- package/plugin.js +243 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Moltbot WeCom Channel
|
|
2
|
+
|
|
3
|
+
WeCom (企业微信) 智能机器人的 Moltbot 插件。
|
|
4
|
+
|
|
5
|
+
## 架构
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
企微用户 → 企微云 → wecom-proxy (解密/加密) ←WebSocket→ plugin (Moltbot)
|
|
9
|
+
↑ ↓
|
|
10
|
+
← 加密被动回复 ←─────────────────────── 回复消息
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- **wecom-proxy**: 处理企微消息加解密,支持被动回复
|
|
14
|
+
- **plugin**: 作为 Moltbot 的 channel 插件,处理消息逻辑
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
clawdbot plugins install moltbot-wecom-channel
|
|
20
|
+
# 或
|
|
21
|
+
npm link # 本地开发
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 配置
|
|
25
|
+
|
|
26
|
+
在 `moltbot.json`(或 `clawdbot.json`)的 `channels` 中添加:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"channels": {
|
|
31
|
+
"wecom": {
|
|
32
|
+
"enabled": true,
|
|
33
|
+
"proxyUrl": "wss://your-wecom-proxy.com",
|
|
34
|
+
"proxyToken": "your-secret-token",
|
|
35
|
+
"pingInterval": 30000
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 功能特性
|
|
42
|
+
|
|
43
|
+
- ✅ 通过 WebSocket 连接 wecom-proxy,支持心跳保活
|
|
44
|
+
- ✅ 消息去重(防止重复响应)
|
|
45
|
+
- ✅ 被动回复机制(5秒内响应)
|
|
46
|
+
- ✅ 自动重连
|
|
47
|
+
|
|
48
|
+
## 被动回复流程
|
|
49
|
+
|
|
50
|
+
1. wecom-proxy 接收企微加密消息并解密
|
|
51
|
+
2. 通过 WebSocket 转发给 plugin(带 msgId)
|
|
52
|
+
3. plugin 调用 Moltbot 处理消息
|
|
53
|
+
4. plugin 将回复发回 wecom-proxy(带相同 msgId)
|
|
54
|
+
5. wecom-proxy 加密回复并返回给企微
|
|
55
|
+
6. 企微将回复投递给用户
|
|
56
|
+
|
|
57
|
+
> **注意**: 企微要求 5 秒内响应。如果 AI 处理时间较长,可能需要实现主动回复 API。
|
|
58
|
+
|
|
59
|
+
## 参考
|
|
60
|
+
|
|
61
|
+
本实现参考了 [Feishu Moltbot Bridge](https://github.com/AlexAnys/feishu-moltbot-bridge) 的健壮性模式(去重、重连、心跳、群聊逻辑)。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "wecom",
|
|
3
|
+
"name": "WeCom",
|
|
4
|
+
"description": "WeCom (企业微信) channel plugin — WebSocket proxy connection for Smart Bot",
|
|
5
|
+
"version": "1.0.1",
|
|
6
|
+
"channels": ["wecom"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"proxyUrl": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"description": "WeCom Proxy WebSocket URL (wss://your-domain.com)"
|
|
13
|
+
},
|
|
14
|
+
"proxyToken": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "Authentication token for wecom-proxy connection"
|
|
17
|
+
},
|
|
18
|
+
"pingInterval": {
|
|
19
|
+
"type": "number",
|
|
20
|
+
"default": 30000,
|
|
21
|
+
"description": "WebSocket heartbeat interval in milliseconds"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"required": ["proxyUrl", "proxyToken"],
|
|
25
|
+
"additionalProperties": true
|
|
26
|
+
}
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "moltbot-wecom",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "企业微信机器人插件 - 让 AI 助手接入企业微信智能机器人 | WeCom Smart Bot channel plugin for Moltbot",
|
|
6
|
+
"author": "xiajingan",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"moltbot",
|
|
10
|
+
"clawdbot",
|
|
11
|
+
"wecom",
|
|
12
|
+
"wechat",
|
|
13
|
+
"企业微信",
|
|
14
|
+
"企微机器人",
|
|
15
|
+
"chatbot",
|
|
16
|
+
"ai-agent",
|
|
17
|
+
"ai助手",
|
|
18
|
+
"messaging",
|
|
19
|
+
"channel-plugin",
|
|
20
|
+
"websocket",
|
|
21
|
+
"smart-bot"
|
|
22
|
+
],
|
|
23
|
+
"main": "plugin.js",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"import": "./plugin.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"plugin.js",
|
|
31
|
+
"moltbot.plugin.json",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"moltbot": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./plugin.js"
|
|
38
|
+
],
|
|
39
|
+
"channel": {
|
|
40
|
+
"id": "wecom",
|
|
41
|
+
"label": "WeCom",
|
|
42
|
+
"selectionLabel": "WeCom (企业微信)",
|
|
43
|
+
"docsPath": "/channels/wecom",
|
|
44
|
+
"docsLabel": "wecom",
|
|
45
|
+
"blurb": "WeCom (Enterprise WeChat) Smart Bot with WebSocket proxy.",
|
|
46
|
+
"aliases": [
|
|
47
|
+
"企微",
|
|
48
|
+
"qw"
|
|
49
|
+
],
|
|
50
|
+
"order": 80,
|
|
51
|
+
"quickstartAllowFrom": true
|
|
52
|
+
},
|
|
53
|
+
"install": {
|
|
54
|
+
"npmSpec": "moltbot-wecom",
|
|
55
|
+
"defaultChoice": "npm"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"ws": "^8.16.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"moltbot": ">=1.0.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"moltbot": {
|
|
66
|
+
"optional": false
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">=20.0.0"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Moltbot WeCom Channel Plugin
|
|
5
|
+
*
|
|
6
|
+
* Config in moltbot.json:
|
|
7
|
+
* "channels": {
|
|
8
|
+
* "wecom": {
|
|
9
|
+
* "enabled": true,
|
|
10
|
+
* "proxyUrl": "wss://...",
|
|
11
|
+
* "proxyToken": "...",
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
module.exports = function(ctx) {
|
|
17
|
+
const log = ctx.logger ? ctx.logger('wecom') : console;
|
|
18
|
+
const cfg = ctx.config?.channels?.wecom || {};
|
|
19
|
+
|
|
20
|
+
if (!cfg.enabled) {
|
|
21
|
+
log.info('WeCom channel disabled');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PROXY_URL = cfg.proxyUrl || process.env.PROXY_URL;
|
|
26
|
+
const PROXY_TOKEN = cfg.proxyToken || process.env.PROXY_TOKEN;
|
|
27
|
+
const PING_INTERVAL_MS = cfg.pingInterval || 30000; // 30s heartbeat
|
|
28
|
+
|
|
29
|
+
if (!PROXY_URL) {
|
|
30
|
+
log.error('Missing proxyUrl for WeCom channel');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- State & Dedup (Reference: feishu-moltbot-bridge) ---
|
|
35
|
+
const seen = new Map();
|
|
36
|
+
const SEEN_TTL = 10 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
function isDuplicate(id) {
|
|
39
|
+
if (!id) return false;
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [k, ts] of seen) {
|
|
42
|
+
if (now - ts > SEEN_TTL) seen.delete(k);
|
|
43
|
+
}
|
|
44
|
+
if (seen.has(id)) return true;
|
|
45
|
+
seen.set(id, now);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Group Logic (Reference: feishu-moltbot-bridge) ---
|
|
50
|
+
function shouldRespond(text, isGroup, mentions = []) {
|
|
51
|
+
if (!isGroup) return true; // Always respond to DM
|
|
52
|
+
|
|
53
|
+
// 1. Mentions (Smart Bot usually only receives @bot messages, but check anyway)
|
|
54
|
+
if (mentions.length > 0) return true;
|
|
55
|
+
|
|
56
|
+
// 2. Keywords/Questions
|
|
57
|
+
const t = text.toLowerCase();
|
|
58
|
+
if (/[??]$/.test(text)) return true;
|
|
59
|
+
if (/\b(why|how|what|when|where|who|help)\b/.test(t)) return true;
|
|
60
|
+
|
|
61
|
+
const triggers = ['help', 'bot', '小雀', '助手', 'moltbot'];
|
|
62
|
+
if (triggers.some(k => t.includes(k))) return true;
|
|
63
|
+
|
|
64
|
+
const verbs = ['帮', '请', '查', '分析', '总结', '翻译', '写', '改', '看看'];
|
|
65
|
+
if (verbs.some(k => t.includes(k))) return true;
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Connection ---
|
|
71
|
+
let ws;
|
|
72
|
+
let reconnectTimer;
|
|
73
|
+
let pingTimer;
|
|
74
|
+
let isAlive = false;
|
|
75
|
+
|
|
76
|
+
function connect() {
|
|
77
|
+
const url = `${PROXY_URL}?token=${PROXY_TOKEN || ''}`;
|
|
78
|
+
log.info(`Connecting to WeCom Proxy: ${PROXY_URL}`);
|
|
79
|
+
|
|
80
|
+
ws = new WebSocket(url);
|
|
81
|
+
|
|
82
|
+
ws.on('open', () => {
|
|
83
|
+
log.info('WeCom Proxy connected');
|
|
84
|
+
isAlive = true;
|
|
85
|
+
if (reconnectTimer) {
|
|
86
|
+
clearTimeout(reconnectTimer);
|
|
87
|
+
reconnectTimer = null;
|
|
88
|
+
}
|
|
89
|
+
// Start heartbeat
|
|
90
|
+
startHeartbeat();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
ws.on('message', async (data) => {
|
|
94
|
+
try {
|
|
95
|
+
const event = JSON.parse(data);
|
|
96
|
+
|
|
97
|
+
// Handle pong response
|
|
98
|
+
if (event.kind === 'pong') {
|
|
99
|
+
isAlive = true;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (event.kind !== 'message') return;
|
|
104
|
+
|
|
105
|
+
const msg = event.payload;
|
|
106
|
+
const msgId = event.msgId;
|
|
107
|
+
|
|
108
|
+
// Basic validation
|
|
109
|
+
if (!msg || !msg.FromUserName || !msg.Content) {
|
|
110
|
+
// Send empty reply for non-text messages
|
|
111
|
+
sendReply(msgId, '');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const senderId = msg.FromUserName;
|
|
116
|
+
const text = msg.Content;
|
|
117
|
+
|
|
118
|
+
// Dedup
|
|
119
|
+
if (isDuplicate(msgId)) {
|
|
120
|
+
log.debug(`Duplicate message ignored: ${msgId}`);
|
|
121
|
+
sendReply(msgId, '');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Group logic - Smart Bot usually only receives relevant messages
|
|
126
|
+
// but we keep the logic for safety
|
|
127
|
+
const isGroup = false; // Smart Bot messages are treated as direct
|
|
128
|
+
|
|
129
|
+
if (isGroup && !shouldRespond(text, true)) {
|
|
130
|
+
log.debug('Ignoring group message (no trigger)');
|
|
131
|
+
sendReply(msgId, '');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
log.info(`Rx [${senderId}]: ${text}`);
|
|
136
|
+
|
|
137
|
+
// Process message and get reply
|
|
138
|
+
try {
|
|
139
|
+
const replyText = await processMessage(senderId, text, msgId);
|
|
140
|
+
sendReply(msgId, replyText || '');
|
|
141
|
+
} catch (e) {
|
|
142
|
+
log.error('Error processing message:', e);
|
|
143
|
+
sendReply(msgId, '');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
} catch (e) {
|
|
147
|
+
log.error('Error handling message', e);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
ws.on('close', () => {
|
|
152
|
+
log.warn('WeCom Proxy disconnected');
|
|
153
|
+
isAlive = false;
|
|
154
|
+
stopHeartbeat();
|
|
155
|
+
scheduleReconnect();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
ws.on('error', (e) => {
|
|
159
|
+
log.error('WeCom connection error:', e.message);
|
|
160
|
+
ws.terminate();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Reply to Proxy ---
|
|
165
|
+
function sendReply(msgId, text) {
|
|
166
|
+
if (ws && ws.readyState === WebSocket.OPEN && msgId) {
|
|
167
|
+
ws.send(JSON.stringify({
|
|
168
|
+
kind: 'reply',
|
|
169
|
+
msgId: msgId,
|
|
170
|
+
text: text
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- Process message through Moltbot ---
|
|
176
|
+
async function processMessage(senderId, text, msgId) {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
// Send to Moltbot Pipeline via ctx.receive
|
|
179
|
+
ctx.receive('wecom', {
|
|
180
|
+
from: senderId,
|
|
181
|
+
text: text,
|
|
182
|
+
msgId: msgId,
|
|
183
|
+
// The 'conn' object allows Moltbot to reply
|
|
184
|
+
conn: {
|
|
185
|
+
send: async (replyText) => {
|
|
186
|
+
if (!replyText || replyText === 'NO_REPLY') {
|
|
187
|
+
resolve('');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
log.info(`Tx [${senderId}]: ${replyText.slice(0, 50)}...`);
|
|
191
|
+
resolve(replyText);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}).catch(e => {
|
|
195
|
+
log.error('Pipeline error:', e);
|
|
196
|
+
resolve('');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Timeout fallback (Moltbot should respond quickly for passive reply)
|
|
200
|
+
setTimeout(() => resolve(''), 4000);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- Heartbeat ---
|
|
205
|
+
function startHeartbeat() {
|
|
206
|
+
stopHeartbeat();
|
|
207
|
+
pingTimer = setInterval(() => {
|
|
208
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
209
|
+
if (!isAlive) {
|
|
210
|
+
log.warn('Heartbeat timeout, reconnecting...');
|
|
211
|
+
ws.terminate();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
isAlive = false;
|
|
215
|
+
ws.send(JSON.stringify({ kind: 'ping', ts: Date.now() }));
|
|
216
|
+
}
|
|
217
|
+
}, PING_INTERVAL_MS);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stopHeartbeat() {
|
|
221
|
+
if (pingTimer) {
|
|
222
|
+
clearInterval(pingTimer);
|
|
223
|
+
pingTimer = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function scheduleReconnect() {
|
|
228
|
+
if (reconnectTimer) return;
|
|
229
|
+
reconnectTimer = setTimeout(() => {
|
|
230
|
+
reconnectTimer = null;
|
|
231
|
+
connect();
|
|
232
|
+
}, 5000);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- Lifecycle ---
|
|
236
|
+
ctx.on('start', () => connect());
|
|
237
|
+
|
|
238
|
+
ctx.on('stop', () => {
|
|
239
|
+
stopHeartbeat();
|
|
240
|
+
if (ws) ws.close();
|
|
241
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
242
|
+
});
|
|
243
|
+
};
|