@sunnoy/wecom 1.0.0 → 1.1.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 CHANGED
@@ -9,6 +9,7 @@
9
9
  - 🌊 **Streaming Output**: Built on WeCom's latest AI bot streaming mechanism for smooth typewriter-style responses.
10
10
  - 🤖 **Dynamic Agent Management**: Automatically creates isolated agents per direct message user or group chat, with independent workspaces and conversation contexts.
11
11
  - 👥 **Deep Group Chat Integration**: Supports group message parsing with @mention triggering.
12
+ - 🎤 **Voice Message Support**: Automatically processes voice messages transcribed by WeCom into text for AI interaction (direct messages only).
12
13
  - 🖼️ **Image Support**: Automatic base64 encoding and sending of local images (screenshots, generated images) without requiring additional configuration.
13
14
  - 🛠️ **Command Enhancement**: Built-in commands (e.g., `/new` for new sessions, `/status` for status) with allowlist configuration.
14
15
  - 🔒 **Security & Authentication**: Full support for WeCom message encryption/decryption, URL verification, and sender validation.
@@ -216,6 +217,10 @@ AI: [Takes screenshot] → Image displays properly in WeCom ✅
216
217
 
217
218
  If an image fails to process (size limit, invalid format), the text response will still be delivered and an error will be logged.
218
219
 
220
+ ### Q: Does the bot support voice messages?
221
+
222
+ **A:** Yes! Voice messages in direct chats are automatically transcribed by WeCom and processed as text. No additional configuration needed.
223
+
219
224
  ### Q: How to configure auth token for public-facing OpenClaw with WeCom callbacks?
220
225
 
221
226
  **A:** WeCom bot **does not need** OpenClaw's Gateway Auth Token.
package/README_ZH.md CHANGED
@@ -9,6 +9,7 @@
9
9
  - 🌊 **流式输出 (Streaming)**: 基于企业微信最新的 AI 机器人流式分片机制,实现流畅的打字机式回复体验。
10
10
  - 🤖 **动态 Agent 管理**: 默认按"每个私聊用户 / 每个群聊"自动创建独立 Agent。每个 Agent 拥有独立的工作区与对话上下文,实现更强的数据隔离。
11
11
  - 👥 **群聊深度集成**: 支持群聊消息解析,可通过 @提及(At-mention)精准触发机器人响应。
12
+ - 🎤 **语音消息支持**: 自动处理企业微信转录后的语音消息,转换为文本进行 AI 交互(仅限私聊)。
12
13
  - 🖼️ **图片支持**: 自动将本地图片(截图、生成的图像)进行 base64 编码并发送,无需额外配置。
13
14
  - 🛠️ **指令增强**: 内置常用指令支持(如 `/new` 开启新会话、`/status` 查看状态等),并提供指令白名单配置功能。
14
15
  - 🔒 **安全与认证**: 完整支持企业微信消息加解密、URL 验证及发送者身份校验。
@@ -216,6 +217,10 @@ AI:[执行截图] → 图片在企业微信中正常显示 ✅
216
217
 
217
218
  如果图片处理失败(超出大小限制、格式不支持等),文本回复仍会正常发送,错误信息会记录在日志中。
218
219
 
220
+ ### Q: 机器人支持语音消息吗?
221
+
222
+ **A:** 支持!私聊中的语音消息会被企业微信自动转录为文字并作为文本处理,无需额外配置。
223
+
219
224
  ### Q: OpenClaw 开放公网需要 auth token,企业微信回调如何配置?
220
225
 
221
226
  **A:** 企业微信机器人**不需要**配置 OpenClaw 的 Gateway Auth Token。
package/index.js CHANGED
@@ -80,6 +80,8 @@ function checkCommandAllowlist(message, config) {
80
80
  // Runtime state (module-level singleton)
81
81
  let _runtime = null;
82
82
  let _openclawConfig = null;
83
+ const ensuredDynamicAgentIds = new Set();
84
+ let ensureDynamicAgentWriteQueue = Promise.resolve();
83
85
 
84
86
  /**
85
87
  * Set the plugin runtime (called during plugin registration)
@@ -95,6 +97,82 @@ function getRuntime() {
95
97
  return _runtime;
96
98
  }
97
99
 
100
+ function upsertAgentIdOnlyEntry(cfg, agentId) {
101
+ const normalizedId = String(agentId || "").trim().toLowerCase();
102
+ if (!normalizedId) return false;
103
+
104
+ if (!cfg.agents || typeof cfg.agents !== "object") {
105
+ cfg.agents = {};
106
+ }
107
+
108
+ const currentList = Array.isArray(cfg.agents.list) ? cfg.agents.list : [];
109
+ const existingIds = new Set(
110
+ currentList
111
+ .map((entry) => (entry && typeof entry.id === "string" ? entry.id.trim().toLowerCase() : ""))
112
+ .filter(Boolean),
113
+ );
114
+
115
+ let changed = false;
116
+ const nextList = [...currentList];
117
+
118
+ // Keep "main" as the explicit default when creating agents.list for the first time.
119
+ if (nextList.length === 0) {
120
+ nextList.push({ id: "main" });
121
+ existingIds.add("main");
122
+ changed = true;
123
+ }
124
+
125
+ if (!existingIds.has(normalizedId)) {
126
+ nextList.push({ id: normalizedId });
127
+ changed = true;
128
+ }
129
+
130
+ if (changed) {
131
+ cfg.agents.list = nextList;
132
+ }
133
+
134
+ return changed;
135
+ }
136
+
137
+ async function ensureDynamicAgentListed(agentId) {
138
+ const normalizedId = String(agentId || "").trim().toLowerCase();
139
+ if (!normalizedId) return;
140
+ if (ensuredDynamicAgentIds.has(normalizedId)) return;
141
+
142
+ const runtime = getRuntime();
143
+ const configRuntime = runtime?.config;
144
+ if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) return;
145
+
146
+ ensureDynamicAgentWriteQueue = ensureDynamicAgentWriteQueue
147
+ .then(async () => {
148
+ if (ensuredDynamicAgentIds.has(normalizedId)) return;
149
+
150
+ const latestConfig = configRuntime.loadConfig();
151
+ if (!latestConfig || typeof latestConfig !== "object") return;
152
+
153
+ const changed = upsertAgentIdOnlyEntry(latestConfig, normalizedId);
154
+ if (changed) {
155
+ await configRuntime.writeConfigFile(latestConfig);
156
+ logger.info("WeCom: dynamic agent added to agents.list", { agentId: normalizedId });
157
+ }
158
+
159
+ // Keep runtime in-memory config aligned to avoid stale reads in this process.
160
+ if (_openclawConfig && typeof _openclawConfig === "object") {
161
+ upsertAgentIdOnlyEntry(_openclawConfig, normalizedId);
162
+ }
163
+
164
+ ensuredDynamicAgentIds.add(normalizedId);
165
+ })
166
+ .catch((err) => {
167
+ logger.warn("WeCom: failed to sync dynamic agent into agents.list", {
168
+ agentId: normalizedId,
169
+ error: err?.message || String(err),
170
+ });
171
+ });
172
+
173
+ await ensureDynamicAgentWriteQueue;
174
+ }
175
+
98
176
  // Webhook targets registry (similar to Google Chat)
99
177
  const webhookTargets = new Map();
100
178
 
@@ -189,6 +267,102 @@ const wecomChannelPlugin = {
189
267
  blockStreaming: true, // WeCom AI Bot uses stream response format
190
268
  },
191
269
  reload: { configPrefixes: ["channels.wecom"] },
270
+ configSchema: {
271
+ schema: {
272
+ "$schema": "http://json-schema.org/draft-07/schema#",
273
+ "type": "object",
274
+ "additionalProperties": false,
275
+ "properties": {
276
+ "enabled": {
277
+ "type": "boolean",
278
+ "description": "Enable WeCom channel",
279
+ "default": true
280
+ },
281
+ "token": {
282
+ "type": "string",
283
+ "description": "WeCom bot token from admin console"
284
+ },
285
+ "encodingAesKey": {
286
+ "type": "string",
287
+ "description": "WeCom message encryption key (43 characters)",
288
+ "minLength": 43,
289
+ "maxLength": 43
290
+ },
291
+ "commands": {
292
+ "type": "object",
293
+ "description": "Command whitelist configuration",
294
+ "additionalProperties": false,
295
+ "properties": {
296
+ "enabled": {
297
+ "type": "boolean",
298
+ "description": "Enable command whitelist filtering",
299
+ "default": true
300
+ },
301
+ "allowlist": {
302
+ "type": "array",
303
+ "description": "Allowed commands (e.g., /new, /status, /help)",
304
+ "items": {
305
+ "type": "string"
306
+ },
307
+ "default": ["/new", "/status", "/help", "/compact"]
308
+ }
309
+ }
310
+ },
311
+ "dynamicAgents": {
312
+ "type": "object",
313
+ "description": "Dynamic agent routing configuration",
314
+ "additionalProperties": false,
315
+ "properties": {
316
+ "enabled": {
317
+ "type": "boolean",
318
+ "description": "Enable per-user/per-group agent isolation",
319
+ "default": true
320
+ }
321
+ }
322
+ },
323
+ "dm": {
324
+ "type": "object",
325
+ "description": "Direct message (private chat) configuration",
326
+ "additionalProperties": false,
327
+ "properties": {
328
+ "createAgentOnFirstMessage": {
329
+ "type": "boolean",
330
+ "description": "Create separate agent for each user",
331
+ "default": true
332
+ }
333
+ }
334
+ },
335
+ "groupChat": {
336
+ "type": "object",
337
+ "description": "Group chat configuration",
338
+ "additionalProperties": false,
339
+ "properties": {
340
+ "enabled": {
341
+ "type": "boolean",
342
+ "description": "Enable group chat support",
343
+ "default": true
344
+ },
345
+ "requireMention": {
346
+ "type": "boolean",
347
+ "description": "Only respond when @mentioned in groups",
348
+ "default": true
349
+ }
350
+ }
351
+ }
352
+ }
353
+ },
354
+ uiHints: {
355
+ "token": {
356
+ "sensitive": true,
357
+ "label": "Bot Token"
358
+ },
359
+ "encodingAesKey": {
360
+ "sensitive": true,
361
+ "label": "Encoding AES Key",
362
+ "help": "43-character encryption key from WeCom admin console"
363
+ }
364
+ }
365
+ },
192
366
  config: {
193
367
  listAccountIds: (cfg) => {
194
368
  const wecom = cfg?.channels?.wecom;
@@ -674,6 +848,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
674
848
  const targetAgentId = dynamicConfig.enabled ? generateAgentId(peerKind, peerId) : null;
675
849
 
676
850
  if (targetAgentId) {
851
+ await ensureDynamicAgentListed(targetAgentId);
677
852
  logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
678
853
  }
679
854
 
@@ -805,9 +980,45 @@ async function deliverWecomReply({ payload, account, responseUrl, senderId, stre
805
980
  senderId,
806
981
  });
807
982
 
983
+ // 处理绝对路径的 MEDIA: 行(OpenClaw 会拒绝它们,所以我们需要手动处理)
984
+ const mediaRegex = /^MEDIA:\s*(.+)$/gm;
985
+ const mediaMatches = [];
986
+ let match;
987
+ while ((match = mediaRegex.exec(text)) !== null) {
988
+ const mediaPath = match[1].trim();
989
+ // 检查是否是绝对路径(以 / 开头)
990
+ if (mediaPath.startsWith("/")) {
991
+ mediaMatches.push({
992
+ fullMatch: match[0],
993
+ path: mediaPath
994
+ });
995
+ logger.debug("Detected absolute path MEDIA line", {
996
+ streamId,
997
+ mediaPath,
998
+ line: match[0]
999
+ });
1000
+ }
1001
+ }
1002
+
1003
+ // 如果检测到绝对路径的 MEDIA 行,将图片加入队列并从文本中移除
1004
+ let processedText = text;
1005
+ if (mediaMatches.length > 0 && streamId) {
1006
+ for (const media of mediaMatches) {
1007
+ const queued = streamManager.queueImage(streamId, media.path);
1008
+ if (queued) {
1009
+ // 从文本中移除这行
1010
+ processedText = processedText.replace(media.fullMatch, "").trim();
1011
+ logger.info("Queued absolute path image for stream", {
1012
+ streamId,
1013
+ imagePath: media.path
1014
+ });
1015
+ }
1016
+ }
1017
+ }
1018
+
808
1019
  // 所有消息都通过流式发送
809
- if (!text.trim()) {
810
- logger.debug("WeCom: empty block, skipping stream update");
1020
+ if (!processedText.trim()) {
1021
+ logger.debug("WeCom: empty block after processing, skipping stream update");
811
1022
  return;
812
1023
  }
813
1024
 
@@ -834,10 +1045,10 @@ async function deliverWecomReply({ payload, account, responseUrl, senderId, stre
834
1045
  // 尝试从 activeStreams 获取
835
1046
  const activeStreamId = activeStreams.get(senderId);
836
1047
  if (activeStreamId && streamManager.hasStream(activeStreamId)) {
837
- appendToStream(activeStreamId, text);
1048
+ appendToStream(activeStreamId, processedText);
838
1049
  logger.debug("WeCom stream appended (via activeStreams)", {
839
1050
  streamId: activeStreamId,
840
- contentLength: text.length,
1051
+ contentLength: processedText.length,
841
1052
  });
842
1053
  return;
843
1054
  }
@@ -850,10 +1061,10 @@ async function deliverWecomReply({ payload, account, responseUrl, senderId, stre
850
1061
  return;
851
1062
  }
852
1063
 
853
- appendToStream(streamId, text);
1064
+ appendToStream(streamId, processedText);
854
1065
  logger.debug("WeCom stream appended", {
855
1066
  streamId,
856
- contentLength: text.length,
1067
+ contentLength: processedText.length,
857
1068
  to: senderId
858
1069
  });
859
1070
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/webhook.js CHANGED
@@ -209,6 +209,50 @@ export class WecomWebhook {
209
209
  query: { timestamp, nonce },
210
210
  };
211
211
  }
212
+ else if (msgtype === "voice") {
213
+ // Voice message (single chat only) - WeCom automatically transcribes to text
214
+ const content = data.voice?.content || "";
215
+ const msgId = data.msgid || `msg_${Date.now()}`;
216
+ const fromUser = data.from?.userid || "";
217
+ const responseUrl = data.response_url || "";
218
+ const chatType = data.chattype || "single";
219
+ const chatId = data.chatid || "";
220
+
221
+ // Check for duplicates
222
+ if (this.deduplicator.isDuplicate(msgId)) {
223
+ logger.debug("Duplicate voice message ignored", { msgId });
224
+ return null;
225
+ }
226
+
227
+ // Validate content
228
+ if (!content.trim()) {
229
+ logger.warn("Empty voice message received", { msgId, fromUser });
230
+ return null;
231
+ }
232
+
233
+ logger.info("Received voice message (auto-transcribed by WeCom)", {
234
+ fromUser,
235
+ chatType,
236
+ chatId: chatId || "(private)",
237
+ originalType: "voice",
238
+ transcribedLength: content.length,
239
+ preview: content.substring(0, 50)
240
+ });
241
+
242
+ // Treat voice as text since WeCom already transcribed it
243
+ return {
244
+ message: {
245
+ msgId,
246
+ msgType: "text",
247
+ content,
248
+ fromUser,
249
+ chatType,
250
+ chatId,
251
+ responseUrl,
252
+ },
253
+ query: { timestamp, nonce },
254
+ };
255
+ }
212
256
  else if (msgtype === "event") {
213
257
  logger.info("Received event", { event: data.event });
214
258
  return {