@sunnoy/wecom 1.6.0 → 1.6.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
@@ -21,7 +21,7 @@
21
21
  ### 动态 Agent 与隔离
22
22
  - **动态 Agent 管理**: 默认按"每个私聊用户 / 每个群聊"自动创建独立 Agent。每个 Agent 拥有独立的工作区与对话上下文,实现更强的数据隔离。
23
23
  - **群聊深度集成**: 支持群聊消息解析,可通过 @提及(At-mention)精准触发机器人响应。
24
- - **管理员用户**: 可配置管理员列表,绕过指令白名单和动态 Agent 路由限制。
24
+ - **管理员用户**: 可配置管理员列表,默认绕过指令白名单;可选开启“绕过动态 Agent 路由”。
25
25
  - **指令白名单**: 内置常用指令支持(如 `/new`、`/status`),并提供指令白名单配置功能。
26
26
 
27
27
  ### 多媒体支持
@@ -152,7 +152,7 @@ npm run test:e2e
152
152
  | `plugins.entries.wecom.enabled` | boolean | 是 | 启用插件 |
153
153
  | `channels.wecom.token` | string | 是* | 企业微信机器人 Token (*Bot 模式必填) |
154
154
  | `channels.wecom.encodingAesKey` | string | 是* | 消息加密密钥(43 位)(*Bot 模式必填) |
155
- | `channels.wecom.adminUsers` | array | 否 | 管理员用户 ID 列表(绕过指令白名单和动态路由) |
155
+ | `channels.wecom.adminUsers` | array | 否 | 管理员用户 ID 列表(绕过指令白名单) |
156
156
  | `channels.wecom.commands.enabled` | boolean | 否 | 是否启用指令白名单过滤(默认 true) |
157
157
  | `channels.wecom.commands.allowlist` | array | 否 | 允许的指令白名单 |
158
158
 
@@ -163,6 +163,7 @@ npm run test:e2e
163
163
  | 配置项 | 类型 | 必填 | 说明 |
164
164
  |--------|------|------|------|
165
165
  | `channels.wecom.dynamicAgents.enabled` | boolean | 否 | 是否启用动态 Agent(默认 true) |
166
+ | `channels.wecom.dynamicAgents.adminBypass` | boolean | 否 | 管理员是否跳过动态 Agent 路由(默认 false) |
166
167
  | `channels.wecom.dm.createAgentOnFirstMessage` | boolean | 否 | 私聊时为每个用户创建独立 Agent(默认 true) |
167
168
  | `channels.wecom.groupChat.enabled` | boolean | 否 | 是否启用群聊处理(默认 true) |
168
169
  | `channels.wecom.groupChat.requireMention` | boolean | 否 | 群聊是否必须 @ 提及才响应(默认 true) |
@@ -295,13 +296,16 @@ Webhook Bot 用于向群聊发送通知消息。
295
296
 
296
297
  ## 管理员用户
297
298
 
298
- 管理员用户可以绕过指令白名单限制,并跳过动态 Agent 路由(直接路由到主 Agent)。
299
+ 管理员用户默认可以绕过指令白名单限制。若希望管理员用户同时跳过动态 Agent 路由(直接路由到主 Agent),可开启 `dynamicAgents.adminBypass`。
299
300
 
300
301
  ```json
301
302
  {
302
303
  "channels": {
303
304
  "wecom": {
304
- "adminUsers": ["user1", "user2"]
305
+ "adminUsers": ["user1", "user2"],
306
+ "dynamicAgents": {
307
+ "adminBypass": true
308
+ }
305
309
  }
306
310
  }
307
311
  }
@@ -322,7 +326,7 @@ Webhook Bot 用于向群聊发送通知消息。
322
326
  - **多账号群聊**: `wecom-<accountId>-group-<chatId>`
323
327
  2. OpenClaw 自动创建/复用对应的 Agent 工作区
324
328
  3. 每个用户/群聊拥有独立的对话历史和上下文
325
- 4. **管理员用户**跳过动态路由,直接使用主 Agent
329
+ 4. 管理员用户默认参与动态路由;当 `dynamicAgents.adminBypass=true` 时跳过动态路由,直接使用主 Agent
326
330
 
327
331
  ### 高级配置
328
332
 
@@ -350,6 +354,7 @@ Webhook Bot 用于向群聊发送通知消息。
350
354
  | 配置项 | 类型 | 默认值 | 说明 |
351
355
  |--------|------|--------|------|
352
356
  | `dynamicAgents.enabled` | boolean | `true` | 是否启用动态 Agent |
357
+ | `dynamicAgents.adminBypass` | boolean | `false` | 管理员是否跳过动态 Agent 路由 |
353
358
  | `dm.createAgentOnFirstMessage` | boolean | `true` | 私聊使用动态 Agent |
354
359
  | `groupChat.enabled` | boolean | `true` | 启用群聊处理 |
355
360
  | `groupChat.requireMention` | boolean | `true` | 群聊必须 @ 提及才响应 |
@@ -384,6 +389,7 @@ Webhook Bot 用于向群聊发送通知消息。
384
389
  "token": "Bot1 的 Token",
385
390
  "encodingAesKey": "Bot1 的 EncodingAESKey",
386
391
  "adminUsers": ["admin1"],
392
+ "workspaceTemplate": "/path/to/bot1-template",
387
393
  "agent": {
388
394
  "corpId": "企业 CorpID",
389
395
  "corpSecret": "Bot1 应用 Secret",
@@ -417,6 +423,7 @@ Webhook Bot 用于向群聊发送通知消息。
417
423
  | 完全兼容 | 旧的单账号配置(`token` 直接写在 `wecom` 下)自动识别为 `default` 账号,无需修改 |
418
424
  | Webhook 路径 | 自动按账号分配:`/webhooks/wecom/bot1`、`/webhooks/wecom/bot2` |
419
425
  | Agent 回调路径 | 自动按账号分配:`/webhooks/app/bot1`、`/webhooks/app/bot2` |
426
+ | 工作区模板 | 支持按账号自定义:`channels.wecom.<accountId>.workspaceTemplate`(覆盖全局配置) |
420
427
  | 动态 Agent ID | 按账号隔离:`wecom-bot1-dm-{userId}`、`wecom-bot2-group-{chatId}` |
421
428
  | 冲突检测 | 启动时自动检测重复的 Token 或 Agent ID,避免消息路由错乱 |
422
429
 
package/dynamic-agent.js CHANGED
@@ -47,17 +47,21 @@ export function getDynamicAgentConfig(config) {
47
47
  groupEnabled: wecom.groupChat?.enabled !== false,
48
48
  groupRequireMention: wecom.groupChat?.requireMention !== false,
49
49
  groupMentionPatterns: wecom.groupChat?.mentionPatterns || ["@"],
50
+ adminBypass: wecom.dynamicAgents?.adminBypass === true,
50
51
  };
51
52
  }
52
53
 
53
54
  /**
54
55
  * Decide whether this message context should route to a dynamic agent.
55
56
  */
56
- export function shouldUseDynamicAgent({ chatType, config }) {
57
+ export function shouldUseDynamicAgent({ chatType, config, senderIsAdmin = false }) {
57
58
  const dynamicConfig = getDynamicAgentConfig(config);
58
59
  if (!dynamicConfig.enabled) {
59
60
  return false;
60
61
  }
62
+ if (senderIsAdmin && dynamicConfig.adminBypass) {
63
+ return false;
64
+ }
61
65
  if (chatType === "group") {
62
66
  return dynamicConfig.groupEnabled;
63
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/wecom/accounts.js CHANGED
@@ -79,16 +79,17 @@ function buildAccount(accountId, accountCfg) {
79
79
  agent?.corpId && agent?.corpSecret && agent?.agentId && agent?.token && agent?.encodingAesKey,
80
80
  );
81
81
 
82
+ const hasBotTokens = Boolean(accountCfg?.token && accountCfg?.encodingAesKey);
83
+ const defaultPath = accountId === DEFAULT_ACCOUNT_ID ? "/webhooks/wecom" : `/webhooks/wecom/${accountId}`;
84
+
82
85
  return {
83
86
  accountId,
84
87
  name: accountCfg?.name || accountId,
85
88
  enabled: accountCfg?.enabled !== false,
86
- configured: Boolean(accountCfg?.token && accountCfg?.encodingAesKey) || agentConfigured,
89
+ configured: hasBotTokens || agentConfigured,
87
90
  token: accountCfg?.token || "",
88
91
  encodingAesKey: accountCfg?.encodingAesKey || "",
89
- webhookPath:
90
- accountCfg?.webhookPath ||
91
- (accountId === DEFAULT_ACCOUNT_ID ? "/webhooks/wecom" : `/webhooks/wecom/${accountId}`),
92
+ webhookPath: accountCfg?.webhookPath || (hasBotTokens ? defaultPath : ""),
92
93
  config: accountCfg || {},
93
94
  agentConfigured,
94
95
  agentInboundConfigured,
@@ -292,12 +292,18 @@ async function processAgentMessage({
292
292
 
293
293
  const dynamicConfig = getDynamicAgentConfig(accountCfg);
294
294
  const targetAgentId =
295
- dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config: accountCfg })
295
+ dynamicConfig.enabled
296
+ && shouldUseDynamicAgent({
297
+ chatType: peerKind,
298
+ config: accountCfg,
299
+ senderIsAdmin,
300
+ })
296
301
  ? generateAgentId(peerKind, peerId, accountId)
297
302
  : null;
298
303
 
304
+ const templateDir = accountCfg?.workspaceTemplate || config?.channels?.wecom?.workspaceTemplate;
299
305
  if (targetAgentId) {
300
- await ensureDynamicAgentListed(targetAgentId);
306
+ await ensureDynamicAgentListed(targetAgentId, templateDir);
301
307
  logger.debug("[agent-inbound] dynamic agent", { agentId: targetAgentId, peerId });
302
308
  }
303
309
 
@@ -92,6 +92,11 @@ export const wecomChannelPlugin = {
92
92
  description: "Enable per-user/per-group agent isolation",
93
93
  default: true,
94
94
  },
95
+ adminBypass: {
96
+ type: "boolean",
97
+ description: "When true, adminUsers bypass dynamic agent routing and use the default route",
98
+ default: false,
99
+ },
95
100
  },
96
101
  },
97
102
  dm: {
@@ -720,18 +725,18 @@ export const wecomChannelPlugin = {
720
725
  });
721
726
  }
722
727
 
723
- const unregister = registerWebhookTarget({
724
- path: account.webhookPath || "/webhooks/wecom",
725
- account,
726
- config: ctx.cfg,
727
- });
728
-
729
- // HTTP routing is handled by the wildcard handler registered in
730
- // index.js via api.registerHttpHandler. That handler bypasses gateway
731
- // auth, which is required for WeCom webhook callbacks (they carry
732
- // msg_signature, not Bearer tokens).
733
- const botPath = account.webhookPath || "/webhooks/wecom";
734
- logger.info("WeCom Bot webhook path active", { path: botPath });
728
+ let unregister;
729
+ const botPath = account.webhookPath;
730
+ if (botPath) {
731
+ unregister = registerWebhookTarget({
732
+ path: botPath,
733
+ account,
734
+ config: ctx.cfg,
735
+ });
736
+ logger.info("WeCom Bot webhook path active", { path: botPath });
737
+ } else {
738
+ logger.debug("No Bot webhook path for this account, skipping", { accountId: account.accountId });
739
+ }
735
740
 
736
741
  // Register Agent inbound webhook if agent inbound is fully configured.
737
742
  let unregisterAgent;
@@ -773,7 +778,7 @@ export const wecomChannelPlugin = {
773
778
  clearTimeout(buf.timer);
774
779
  }
775
780
  messageBuffers.clear();
776
- unregister();
781
+ if (unregister) unregister();
777
782
  if (unregisterAgent) unregisterAgent();
778
783
  };
779
784
 
@@ -241,12 +241,19 @@ export async function processInboundMessage({
241
241
 
242
242
  // Compute deterministic agent target for this conversation.
243
243
  const targetAgentId =
244
- dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config: account.config })
244
+ dynamicConfig.enabled
245
+ && shouldUseDynamicAgent({
246
+ chatType: peerKind,
247
+ config: account.config,
248
+ senderIsAdmin,
249
+ })
245
250
  ? generateAgentId(peerKind, peerId, account.accountId)
246
251
  : null;
247
252
 
253
+ // Resolve template directory: per-account or global.
254
+ const templateDir = account.config?.workspaceTemplate || config?.channels?.wecom?.workspaceTemplate;
248
255
  if (targetAgentId) {
249
- await ensureDynamicAgentListed(targetAgentId);
256
+ await ensureDynamicAgentListed(targetAgentId, templateDir);
250
257
  logger.debug("Using dynamic agent", { agentId: targetAgentId, chatType: peerKind, peerId });
251
258
  } else if (senderIsAdmin) {
252
259
  logger.debug("Admin user, dynamic agent disabled for this chat type; falling back to default route", {
@@ -1,5 +1,5 @@
1
- import { readFile, access, stat } from "node:fs/promises";
2
- import { basename, join } from "node:path";
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
3
3
  import { logger } from "../logger.js";
4
4
  import { streamManager } from "../stream-manager.js";
5
5
  import { agentSendText, agentUploadMedia, agentSendMedia } from "./agent-api.js";
@@ -19,10 +19,36 @@ const WECOM_MIN_FILE_SIZE = 5;
19
19
  * ~/.openclaw/workspace-{agentId} on the host. Any path starting with
20
20
  * /workspace/ is transparently rewritten when an agentId is available.
21
21
  */
22
+ export function resolveWorkspaceHostPathSafe({ workspaceDir, workspacePath }) {
23
+ const relativePath = String(workspacePath || "").replace(/^\/workspace\/?/, "");
24
+ if (!relativePath) {
25
+ return null;
26
+ }
27
+
28
+ const hostPath = resolve(workspaceDir, relativePath);
29
+ const rel = relative(workspaceDir, hostPath);
30
+ const escapesWorkspace = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
31
+ if (escapesWorkspace) {
32
+ return null;
33
+ }
34
+ return hostPath;
35
+ }
36
+
22
37
  function resolveHostPath(filePath, effectiveAgentId) {
23
38
  if (effectiveAgentId && filePath.startsWith("/workspace/")) {
24
- const relative = filePath.slice("/workspace/".length);
25
- const hostPath = join(resolveAgentWorkspaceDirLocal(effectiveAgentId), relative);
39
+ const workspaceDir = resolveAgentWorkspaceDirLocal(effectiveAgentId);
40
+ const hostPath = resolveWorkspaceHostPathSafe({
41
+ workspaceDir,
42
+ workspacePath: filePath,
43
+ });
44
+ if (!hostPath) {
45
+ logger.warn("Rejected unsafe /workspace/ path outside workspace", {
46
+ sandbox: filePath,
47
+ workspaceDir,
48
+ agentId: effectiveAgentId,
49
+ });
50
+ return null;
51
+ }
26
52
  logger.debug("Resolved sandbox path to host path", { sandbox: filePath, host: hostPath });
27
53
  return hostPath;
28
54
  }
@@ -110,6 +136,12 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
110
136
  for (const media of mediaMatches) {
111
137
  // Resolve /workspace/ sandbox paths to host-side paths.
112
138
  const resolvedMediaPath = resolveHostPath(media.path, effectiveAgentId);
139
+ if (!resolvedMediaPath) {
140
+ processedText = processedText
141
+ .replace(media.fullMatch, "⚠️ 检测到不安全的 /workspace/ 路径,已拒绝发送")
142
+ .trim();
143
+ continue;
144
+ }
113
145
  const mediaExt = resolvedMediaPath.split(".").pop()?.toLowerCase() || "";
114
146
  if (mediaImageExts.has(mediaExt)) {
115
147
  // Image: queue for delivery when stream finishes.
@@ -177,6 +209,15 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
177
209
  }
178
210
  // Resolve /workspace/ sandbox paths to host-side paths.
179
211
  absPath = resolveHostPath(absPath, effectiveAgentId);
212
+ if (!absPath) {
213
+ const unsafeHint = "⚠️ 检测到不安全的 /workspace/ 路径,已拒绝发送";
214
+ if (streamId && streamManager.hasStream(streamId)) {
215
+ streamManager.appendStream(streamId, `\n\n${unsafeHint}`);
216
+ } else {
217
+ processedText = processedText ? `${processedText}\n\n${unsafeHint}` : unsafeHint;
218
+ }
219
+ continue;
220
+ }
180
221
 
181
222
  const isLocal = absPath.startsWith("/");
182
223
  const mediaFilename = isLocal ? basename(absPath) : (basename(new URL(mediaPath).pathname) || "file");
@@ -298,19 +339,36 @@ export async function deliverWecomReply({ payload, senderId, streamId, agentId }
298
339
  const imageExtsAuto = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
299
340
 
300
341
  for (const wsPath of detectedPaths) {
301
- // /workspace/foo.pdf → hostDir/foo.pdf
302
- const relativePath = wsPath.replace(/^\/workspace\/?/, "");
303
- if (!relativePath) continue;
304
- const hostPath = join(workspaceDir, relativePath);
342
+ // /workspace/foo.pdf → hostDir/foo.pdf (with traversal guard)
343
+ const hostPath = resolveWorkspaceHostPathSafe({
344
+ workspaceDir,
345
+ workspacePath: wsPath,
346
+ });
347
+ if (!hostPath) {
348
+ processedText = processedText.replace(wsPath, "⚠️ 检测到不安全的 /workspace/ 路径,已拒绝发送");
349
+ logger.warn("Auto-detect: rejected unsafe /workspace/ path", {
350
+ streamId,
351
+ wsPath,
352
+ workspaceDir,
353
+ });
354
+ continue;
355
+ }
305
356
  const filename = basename(hostPath);
306
357
  const ext = filename.split(".").pop()?.toLowerCase() || "";
307
358
 
308
359
  // Skip image files — they are handled by the stream msg_item mechanism.
309
360
  if (imageExtsAuto.has(ext)) continue;
310
361
 
311
- // Check file existence on host.
362
+ // Check the path exists on host and is a regular file.
312
363
  try {
313
- await access(hostPath);
364
+ const st = await stat(hostPath);
365
+ if (!st.isFile()) {
366
+ logger.debug("Auto-detect: path is not a regular file, skipping", {
367
+ wsPath,
368
+ hostPath,
369
+ });
370
+ continue;
371
+ }
314
372
  } catch {
315
373
  logger.debug("Auto-detect: workspace file not found on host, skipping", {
316
374
  wsPath,
@@ -36,9 +36,13 @@ export function getWorkspaceTemplateDir(config) {
36
36
  * Copy template files into a newly created agent's workspace directory.
37
37
  * Only copies files that don't already exist (writeFileIfMissing semantics).
38
38
  * Silently skips if workspaceTemplate is not configured or directory is missing.
39
+ *
40
+ * @param {string} agentId
41
+ * @param {object} config - OpenClaw config
42
+ * @param {string} [overrideTemplateDir] - Optional per-account template directory
39
43
  */
40
- export function seedAgentWorkspace(agentId, config) {
41
- const templateDir = getWorkspaceTemplateDir(config);
44
+ export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
45
+ const templateDir = overrideTemplateDir || getWorkspaceTemplateDir(config);
42
46
  if (!templateDir) {
43
47
  return;
44
48
  }
@@ -114,7 +118,7 @@ export function upsertAgentIdOnlyEntry(cfg, agentId) {
114
118
  return changed;
115
119
  }
116
120
 
117
- export async function ensureDynamicAgentListed(agentId) {
121
+ export async function ensureDynamicAgentListed(agentId, templateDir) {
118
122
  const normalizedId = String(agentId || "")
119
123
  .trim()
120
124
  .toLowerCase();
@@ -128,28 +132,22 @@ export async function ensureDynamicAgentListed(agentId) {
128
132
  return;
129
133
  }
130
134
 
131
- const queue = getEnsureDynamicAgentWriteQueue()
135
+ const queue = (getEnsureDynamicAgentWriteQueue() || Promise.resolve())
132
136
  .then(async () => {
133
- const latestConfig = configRuntime.loadConfig();
134
- if (!latestConfig || typeof latestConfig !== "object") {
137
+ const openclawConfig = getOpenclawConfig();
138
+ if (!openclawConfig || typeof openclawConfig !== "object") {
135
139
  return;
136
140
  }
137
141
 
138
- const changed = upsertAgentIdOnlyEntry(latestConfig, normalizedId);
142
+ // Upsert into memory only. Writing to config file is dangerous and can wipe user settings.
143
+ const changed = upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
139
144
  if (changed) {
140
- await configRuntime.writeConfigFile(latestConfig);
141
- logger.info("WeCom: dynamic agent added to agents.list", { agentId: normalizedId });
145
+ logger.info("WeCom: dynamic agent added to in-memory agents.list", { agentId: normalizedId });
142
146
  }
147
+
143
148
  // Always attempt seeding so recreated/cleaned dynamic agents can recover
144
- // template files even when the id already exists in agents.list.
145
- seedAgentWorkspace(normalizedId, latestConfig);
146
-
147
- // Keep runtime in-memory config aligned to avoid stale reads in this process.
148
- const openclawConfig = getOpenclawConfig();
149
- if (openclawConfig && typeof openclawConfig === "object") {
150
- upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
151
- setOpenclawConfig(openclawConfig);
152
- }
149
+ // template files.
150
+ seedAgentWorkspace(normalizedId, openclawConfig, templateDir);
153
151
 
154
152
  getEnsuredDynamicAgentIds().add(normalizedId);
155
153
  })
@@ -1,23 +0,0 @@
1
- /**
2
- * Shared flag that tracks whether the legacy wildcard HTTP handler was
3
- * successfully registered via api.registerHttpHandler().
4
- *
5
- * When true, gateway.startAccount must NOT also register via
6
- * registerPluginHttpRoute — the latter places the path into
7
- * registry.httpRoutes which causes shouldEnforceGatewayAuthForPluginPath
8
- * → isRegisteredPluginHttpRoutePath to return true → gateway auth
9
- * enforcement runs → WeCom webhook callbacks (which carry msg_signature,
10
- * not Bearer tokens) get blocked with 401.
11
- *
12
- * This module is intentionally separate from index.js to avoid circular
13
- * ESM imports (index.js ↔ wecom/channel-plugin.js).
14
- */
15
- let _wildcardHttpHandlerRegistered = false;
16
-
17
- export function markWildcardHttpHandlerRegistered() {
18
- _wildcardHttpHandlerRegistered = true;
19
- }
20
-
21
- export function isWildcardHttpHandlerRegistered() {
22
- return _wildcardHttpHandlerRegistered;
23
- }