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 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
+ };