@yanhaidao/wecom 2.2.4 → 2.2.7

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.
@@ -4,20 +4,86 @@
4
4
  */
5
5
 
6
6
  import { pathToFileURL } from "node:url";
7
+ import path from "node:path";
7
8
  import type { IncomingMessage, ServerResponse } from "node:http";
8
9
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
9
10
  import type { ResolvedAgentAccount } from "../types/index.js";
10
11
  import { LIMITS } from "../types/constants.js";
11
12
  import { decryptWecomEncrypted, verifyWecomSignature, computeWecomMsgSignature, encryptWecomPlaintext } from "../crypto/index.js";
12
13
  import { extractEncryptFromXml, buildEncryptedXmlResponse } from "../crypto/xml.js";
13
- import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId } from "../shared/xml-parser.js";
14
+ import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId, extractMsgId, extractFileName } from "../shared/xml-parser.js";
14
15
  import { sendText, downloadMedia } from "./api-client.js";
15
16
  import { getWecomRuntime } from "../runtime.js";
16
17
  import type { WecomAgentInboundMessage } from "../types/index.js";
18
+ import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
19
+ import { resolveWecomMediaMaxBytes } from "../config/index.js";
20
+ import { generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "../dynamic-agent.js";
17
21
 
18
22
  /** 错误提示信息 */
19
23
  const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
20
24
 
25
+ // Agent webhook 幂等去重池(防止企微回调重试导致重复回复)
26
+ // 注意:这是进程内内存去重,重启会清空;但足以覆盖企微的短周期重试。
27
+ const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
28
+ const recentAgentMsgIds = new Map<string, number>();
29
+
30
+ function rememberAgentMsgId(msgId: string): boolean {
31
+ const now = Date.now();
32
+ const existing = recentAgentMsgIds.get(msgId);
33
+ if (existing && now - existing < RECENT_MSGID_TTL_MS) return false;
34
+ recentAgentMsgIds.set(msgId, now);
35
+ // 简单清理:只在写入时做一次线性 prune,避免无界增长
36
+ for (const [k, ts] of recentAgentMsgIds) {
37
+ if (now - ts >= RECENT_MSGID_TTL_MS) recentAgentMsgIds.delete(k);
38
+ }
39
+ return true;
40
+ }
41
+
42
+ function looksLikeTextFile(buffer: Buffer): boolean {
43
+ const sampleSize = Math.min(buffer.length, 4096);
44
+ if (sampleSize === 0) return true;
45
+ let bad = 0;
46
+ for (let i = 0; i < sampleSize; i++) {
47
+ const b = buffer[i]!;
48
+ const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d; // \t \n \r
49
+ const isPrintable = b >= 0x20 && b !== 0x7f;
50
+ if (!isWhitespace && !isPrintable) bad++;
51
+ }
52
+ // 非可打印字符占比太高,基本可判断为二进制
53
+ return bad / sampleSize <= 0.02;
54
+ }
55
+
56
+ function analyzeTextHeuristic(buffer: Buffer): { sampleSize: number; badCount: number; badRatio: number } {
57
+ const sampleSize = Math.min(buffer.length, 4096);
58
+ if (sampleSize === 0) return { sampleSize: 0, badCount: 0, badRatio: 0 };
59
+ let badCount = 0;
60
+ for (let i = 0; i < sampleSize; i++) {
61
+ const b = buffer[i]!;
62
+ const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d;
63
+ const isPrintable = b >= 0x20 && b !== 0x7f;
64
+ if (!isWhitespace && !isPrintable) badCount++;
65
+ }
66
+ return { sampleSize, badCount, badRatio: badCount / sampleSize };
67
+ }
68
+
69
+ function previewHex(buffer: Buffer, maxBytes = 32): string {
70
+ const n = Math.min(buffer.length, maxBytes);
71
+ if (n <= 0) return "";
72
+ return buffer
73
+ .subarray(0, n)
74
+ .toString("hex")
75
+ .replace(/(..)/g, "$1 ")
76
+ .trim();
77
+ }
78
+
79
+ function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefined {
80
+ if (!looksLikeTextFile(buffer)) return undefined;
81
+ const text = buffer.toString("utf8");
82
+ if (!text.trim()) return undefined;
83
+ const truncated = text.length > maxChars ? `${text.slice(0, maxChars)}\n…(已截断)` : text;
84
+ return truncated;
85
+ }
86
+
21
87
  /**
22
88
  * **AgentWebhookParams (Webhook 处理器参数)**
23
89
  *
@@ -98,6 +164,13 @@ async function handleUrlVerification(
98
164
  const nonce = query.get("nonce") ?? "";
99
165
  const echostr = query.get("echostr") ?? "";
100
166
  const signature = query.get("msg_signature") ?? "";
167
+ const remote = req.socket?.remoteAddress ?? "unknown";
168
+
169
+ // 不输出敏感参数内容,仅输出存在性
170
+ // 用于排查:是否有请求打到 /wecom/agent
171
+ // 以及是否带齐 timestamp/nonce/msg_signature/echostr
172
+ // eslint-disable-next-line no-unused-vars
173
+ const _debug = { remote, hasTimestamp: Boolean(timestamp), hasNonce: Boolean(nonce), hasSig: Boolean(signature), hasEchostr: Boolean(echostr) };
101
174
 
102
175
  const valid = verifyWecomSignature({
103
176
  token: agent.token,
@@ -139,13 +212,19 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
139
212
  const { req, res, agent, config, core, log, error } = params;
140
213
 
141
214
  try {
215
+ log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
142
216
  const rawXml = await readRawBody(req);
217
+ log?.(`[wecom-agent] inbound: rawXmlBytes=${Buffer.byteLength(rawXml, "utf8")}`);
143
218
  const encrypted = extractEncryptFromXml(rawXml);
219
+ log?.(`[wecom-agent] inbound: hasEncrypt=${Boolean(encrypted)} encryptLen=${encrypted ? String(encrypted).length : 0}`);
144
220
 
145
221
  const query = resolveQueryParams(req);
146
222
  const timestamp = query.get("timestamp") ?? "";
147
223
  const nonce = query.get("nonce") ?? "";
148
224
  const signature = query.get("msg_signature") ?? "";
225
+ log?.(
226
+ `[wecom-agent] inbound: query timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${signature ? "yes" : "no"}`,
227
+ );
149
228
 
150
229
  // 验证签名
151
230
  const valid = verifyWecomSignature({
@@ -157,6 +236,7 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
157
236
  });
158
237
 
159
238
  if (!valid) {
239
+ error?.(`[wecom-agent] inbound: signature invalid`);
160
240
  res.statusCode = 401;
161
241
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
162
242
  res.end(`unauthorized - 签名验证失败${ERROR_HELP}`);
@@ -169,15 +249,28 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
169
249
  receiveId: agent.corpId,
170
250
  encrypt: encrypted,
171
251
  });
252
+ log?.(`[wecom-agent] inbound: decryptedBytes=${Buffer.byteLength(decrypted, "utf8")}`);
172
253
 
173
254
  // 解析 XML
174
255
  const msg = parseXml(decrypted);
175
256
  const msgType = extractMsgType(msg);
176
257
  const fromUser = extractFromUser(msg);
177
258
  const chatId = extractChatId(msg);
178
- const content = extractContent(msg);
259
+ const msgId = extractMsgId(msg);
260
+ if (msgId) {
261
+ const ok = rememberAgentMsgId(msgId);
262
+ if (!ok) {
263
+ log?.(`[wecom-agent] duplicate msgId=${msgId} from=${fromUser} chatId=${chatId ?? "N/A"} type=${msgType}; skipped`);
264
+ res.statusCode = 200;
265
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
266
+ res.end("success");
267
+ return true;
268
+ }
269
+ }
270
+ const content = String(extractContent(msg) ?? "");
179
271
 
180
- log?.(`[wecom-agent] ${msgType} from=${fromUser} chatId=${chatId ?? "N/A"} content=${content.slice(0, 100)}`);
272
+ const preview = content.length > 100 ? `${content.slice(0, 100)}…` : content;
273
+ log?.(`[wecom-agent] ${msgType} from=${fromUser} chatId=${chatId ?? "N/A"} msgId=${msgId ?? "N/A"} content=${preview}`);
181
274
 
182
275
  // 先返回 success (Agent 模式使用 API 发送回复,不用被动回复)
183
276
  res.statusCode = 200;
@@ -237,6 +330,7 @@ async function processAgentMessage(params: {
237
330
 
238
331
  const isGroup = Boolean(chatId);
239
332
  const peerId = isGroup ? chatId! : fromUser;
333
+ const mediaMaxBytes = resolveWecomMediaMaxBytes(config);
240
334
 
241
335
  // 处理媒体文件
242
336
  const attachments: any[] = []; // TODO: define specific type
@@ -249,44 +343,95 @@ async function processAgentMessage(params: {
249
343
  if (mediaId) {
250
344
  try {
251
345
  log?.(`[wecom-agent] downloading media: ${mediaId} (${msgType})`);
252
- const { buffer, contentType } = await downloadMedia({ agent, mediaId });
346
+ const { buffer, contentType, filename: headerFileName } = await downloadMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
347
+ const xmlFileName = extractFileName(msg);
348
+ const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
349
+ const heuristic = analyzeTextHeuristic(buffer);
253
350
 
254
351
  // 推断文件名后缀
255
352
  const extMap: Record<string, string> = {
256
353
  "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif",
257
354
  "audio/amr": "amr", "audio/speex": "speex", "video/mp4": "mp4",
258
355
  };
259
- const ext = extMap[contentType] || "bin";
356
+ const textPreview = msgType === "file" ? buildTextFilePreview(buffer, 12_000) : undefined;
357
+ const looksText = Boolean(textPreview);
358
+ const originalExt = path.extname(originalFileName).toLowerCase();
359
+ const normalizedContentType =
360
+ looksText && originalExt === ".md" ? "text/markdown" :
361
+ looksText && (!contentType || contentType === "application/octet-stream")
362
+ ? "text/plain; charset=utf-8"
363
+ : contentType;
364
+
365
+ const ext = extMap[normalizedContentType] || (looksText ? "txt" : "bin");
260
366
  const filename = `${mediaId}.${ext}`;
261
367
 
368
+ log?.(
369
+ `[wecom-agent] file meta: msgType=${msgType} mediaId=${mediaId} size=${buffer.length} maxBytes=${mediaMaxBytes} ` +
370
+ `contentType=${contentType} normalizedContentType=${normalizedContentType} originalFileName=${originalFileName} ` +
371
+ `xmlFileName=${xmlFileName ?? "N/A"} headerFileName=${headerFileName ?? "N/A"} ` +
372
+ `textHeuristic(sample=${heuristic.sampleSize}, bad=${heuristic.badCount}, ratio=${heuristic.badRatio.toFixed(4)}) ` +
373
+ `headHex="${previewHex(buffer)}"`,
374
+ );
375
+
262
376
  // 使用 Core SDK 保存媒体文件
263
377
  const saved = await core.channel.media.saveMediaBuffer(
264
378
  buffer,
265
- contentType,
379
+ normalizedContentType,
266
380
  "inbound", // context/scope
267
- LIMITS.MAX_REQUEST_BODY_SIZE, // limit
268
- filename
381
+ mediaMaxBytes, // limit
382
+ originalFileName
269
383
  );
270
384
 
271
385
  log?.(`[wecom-agent] media saved to: ${saved.path}`);
272
386
  mediaPath = saved.path;
273
- mediaType = contentType;
387
+ mediaType = normalizedContentType;
274
388
 
275
389
  // 构建附件
276
390
  attachments.push({
277
- name: filename,
278
- mimeType: contentType,
391
+ name: originalFileName,
392
+ mimeType: normalizedContentType,
279
393
  url: pathToFileURL(saved.path).href, // 使用跨平台安全的文件 URL
280
394
  });
281
395
 
282
396
  // 更新文本提示
283
- finalContent = `${content} (已下载 ${buffer.length} 字节)`;
397
+ if (textPreview) {
398
+ finalContent = [
399
+ content,
400
+ "",
401
+ "文件内容预览:",
402
+ "```",
403
+ textPreview,
404
+ "```",
405
+ `(已下载 ${buffer.length} 字节)`,
406
+ ].join("\n");
407
+ } else {
408
+ if (msgType === "file") {
409
+ finalContent = [
410
+ content,
411
+ "",
412
+ `已收到文件:${originalFileName}`,
413
+ `文件类型:${normalizedContentType || contentType || "未知"}`,
414
+ "提示:当前仅对文本/Markdown/JSON/CSV/HTML/PDF(可选)做内容抽取;其他二进制格式请转为 PDF 或复制文本内容。",
415
+ `(已下载 ${buffer.length} 字节)`,
416
+ ].join("\n");
417
+ } else {
418
+ finalContent = `${content} (已下载 ${buffer.length} 字节)`;
419
+ }
420
+ }
421
+ log?.(`[wecom-agent] file preview: enabled=${looksText} finalContentLen=${finalContent.length} attachments=${attachments.length}`);
284
422
  } catch (err) {
285
- error?.(`[wecom-agent] media download failed: ${String(err)}`);
286
- finalContent = `${content} (媒体下载失败)`;
423
+ error?.(`[wecom-agent] media processing failed: ${String(err)}`);
424
+ finalContent = [
425
+ content,
426
+ "",
427
+ `媒体处理失败:${String(err)}`,
428
+ `提示:可在 OpenClaw 配置中提高 channels.wecom.media.maxBytes(当前=${mediaMaxBytes})`,
429
+ `例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
430
+ ].join("\n");
287
431
  }
288
432
  } else {
289
- error?.(`[wecom-agent] mediaId not found for ${msgType}`);
433
+ const keys = Object.keys((msg as unknown as Record<string, unknown>) ?? {}).slice(0, 50).join(",");
434
+ error?.(`[wecom-agent] mediaId not found for ${msgType}; keys=${keys}`);
290
435
  }
291
436
  }
292
437
 
@@ -298,6 +443,26 @@ async function processAgentMessage(params: {
298
443
  peer: { kind: isGroup ? "group" : "dm", id: peerId },
299
444
  });
300
445
 
446
+ // ===== 动态 Agent 路由注入 =====
447
+ const useDynamicAgent = shouldUseDynamicAgent({
448
+ chatType: isGroup ? "group" : "dm",
449
+ senderId: fromUser,
450
+ config,
451
+ });
452
+
453
+ if (useDynamicAgent) {
454
+ const targetAgentId = generateAgentId(
455
+ isGroup ? "group" : "dm",
456
+ peerId
457
+ );
458
+ route.agentId = targetAgentId;
459
+ route.sessionKey = `agent:${targetAgentId}:${isGroup ? "group" : "dm"}:${peerId}`;
460
+ // 异步添加到 agents.list(不阻塞)
461
+ ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
462
+ log?.(`[wecom-agent] dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
463
+ }
464
+ // ===== 动态 Agent 路由注入结束 =====
465
+
301
466
  // 构建上下文
302
467
  const fromLabel = isGroup ? `group:${peerId}` : `user:${fromUser}`;
303
468
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
@@ -316,6 +481,28 @@ async function processAgentMessage(params: {
316
481
  body: finalContent,
317
482
  });
318
483
 
484
+ const authz = await resolveWecomCommandAuthorization({
485
+ core,
486
+ cfg: config,
487
+ // Agent 门禁应读取 channels.wecom.agent.dm(即 agent.config.dm),而不是 channels.wecom.dm(不存在)
488
+ accountConfig: agent.config as any,
489
+ rawBody: finalContent,
490
+ senderUserId: fromUser,
491
+ });
492
+ log?.(`[wecom-agent] authz: dmPolicy=${authz.dmPolicy} shouldCompute=${authz.shouldComputeAuth} sender=${fromUser.toLowerCase()} senderAllowed=${authz.senderAllowed} authorizerConfigured=${authz.authorizerConfigured} commandAuthorized=${String(authz.commandAuthorized)}`);
493
+
494
+ // 命令门禁:未授权时必须明确回复(Agent 侧用私信提示)
495
+ if (authz.shouldComputeAuth && authz.commandAuthorized !== true) {
496
+ const prompt = buildWecomUnauthorizedCommandPrompt({ senderUserId: fromUser, dmPolicy: authz.dmPolicy, scope: "agent" });
497
+ try {
498
+ await sendText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
499
+ log?.(`[wecom-agent] unauthorized command: replied via DM to ${fromUser}`);
500
+ } catch (err: unknown) {
501
+ error?.(`[wecom-agent] unauthorized command reply failed: ${String(err)}`);
502
+ }
503
+ return;
504
+ }
505
+
319
506
  const ctxPayload = core.channel.reply.finalizeInboundContext({
320
507
  Body: body,
321
508
  RawBody: finalContent,
@@ -332,8 +519,11 @@ async function processAgentMessage(params: {
332
519
  Provider: "wecom",
333
520
  Surface: "wecom",
334
521
  OriginatingChannel: "wecom",
335
- OriginatingTo: `wecom:${peerId}`,
336
- CommandAuthorized: true, // 已通过 WeCom 签名验证
522
+ // 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
523
+ // - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
524
+ // - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
525
+ OriginatingTo: `wecom-agent:${fromUser}`,
526
+ CommandAuthorized: authz.commandAuthorized ?? true,
337
527
  MediaPath: mediaPath,
338
528
  MediaType: mediaType,
339
529
  MediaUrl: mediaPath,
@@ -359,12 +549,8 @@ async function processAgentMessage(params: {
359
549
  if (!text) return;
360
550
 
361
551
  try {
362
- await sendText({
363
- agent,
364
- toUser: fromUser,
365
- chatId: isGroup ? chatId : undefined,
366
- text,
367
- });
552
+ // 统一策略:Agent 模式在群聊场景默认只私信触发者(避免 wr/wc chatId 86008)
553
+ await sendText({ agent, toUser: fromUser, chatId: undefined, text });
368
554
  log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
369
555
  } catch (err: unknown) {
370
556
  error?.(`[wecom-agent] reply failed: ${String(err)}`);
package/src/channel.ts CHANGED
@@ -31,7 +31,7 @@ const meta = {
31
31
  function normalizeWecomMessagingTarget(raw: string): string | undefined {
32
32
  const trimmed = raw.trim();
33
33
  if (!trimmed) return undefined;
34
- return trimmed.replace(/^(wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
34
+ return trimmed.replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
35
35
  }
36
36
 
37
37
  type ResolvedWecomAccount = {
@@ -9,3 +9,4 @@ export {
9
9
  isWecomEnabled,
10
10
  } from "./accounts.js";
11
11
  export { resolveWecomEgressProxyUrl, resolveWecomEgressProxyUrlFromNetwork } from "./network.js";
12
+ export { DEFAULT_WECOM_MEDIA_MAX_BYTES, resolveWecomMediaMaxBytes } from "./media.js";
@@ -0,0 +1,14 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+
3
+ // 默认给一个相对“够用”的上限(80MB),避免视频/较大文件频繁触发失败。
4
+ // 仍保留上限以防止恶意大文件把进程内存打爆(下载实现会读入内存再保存)。
5
+ export const DEFAULT_WECOM_MEDIA_MAX_BYTES = 80 * 1024 * 1024;
6
+
7
+ export function resolveWecomMediaMaxBytes(cfg: OpenClawConfig): number {
8
+ const raw = (cfg.channels?.wecom as any)?.media?.maxBytes;
9
+ const n = typeof raw === "number" ? raw : Number(raw);
10
+ if (Number.isFinite(n) && n > 0) {
11
+ return Math.floor(n);
12
+ }
13
+ return DEFAULT_WECOM_MEDIA_MAX_BYTES;
14
+ }
@@ -92,6 +92,22 @@ const agentSchema = z.object({
92
92
  dm: dmSchema,
93
93
  }).optional();
94
94
 
95
+ /**
96
+ * **dynamicAgentsSchema (动态 Agent 配置)**
97
+ *
98
+ * 控制是否按用户/群组自动创建独立 Agent 实例。
99
+ * @property enabled - 是否启用动态 Agent
100
+ * @property dmCreateAgent - 私聊是否为每个用户创建独立 Agent
101
+ * @property groupEnabled - 群聊是否启用动态 Agent
102
+ * @property adminUsers - 管理员列表(绕过动态路由)
103
+ */
104
+ const dynamicAgentsSchema = z.object({
105
+ enabled: z.boolean().optional(),
106
+ dmCreateAgent: z.boolean().optional(),
107
+ groupEnabled: z.boolean().optional(),
108
+ adminUsers: z.array(z.string()).optional(),
109
+ }).optional();
110
+
95
111
  /** 顶层 WeCom 配置 Schema */
96
112
  export const WecomConfigSchema = z.object({
97
113
  enabled: z.boolean().optional(),
@@ -99,6 +115,7 @@ export const WecomConfigSchema = z.object({
99
115
  agent: agentSchema,
100
116
  media: mediaSchema,
101
117
  network: networkSchema,
118
+ dynamicAgents: dynamicAgentsSchema,
102
119
  });
103
120
 
104
121
  export type WecomConfigInput = z.infer<typeof WecomConfigSchema>;
@@ -0,0 +1,178 @@
1
+ /**
2
+ * **动态 Agent 路由模块**
3
+ *
4
+ * 为每个用户/群组自动生成独立的 Agent ID,实现会话隔离。
5
+ * 参考: openclaw-plugin-wecom/dynamic-agent.js
6
+ */
7
+
8
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
9
+
10
+ export interface DynamicAgentConfig {
11
+ enabled: boolean;
12
+ dmCreateAgent: boolean;
13
+ groupEnabled: boolean;
14
+ adminUsers: string[];
15
+ }
16
+
17
+ /**
18
+ * **getDynamicAgentConfig (读取动态 Agent 配置)**
19
+ *
20
+ * 从全局配置中读取动态 Agent 配置,提供默认值。
21
+ */
22
+ export function getDynamicAgentConfig(config: OpenClawConfig): DynamicAgentConfig {
23
+ const dynamicAgents = (config as { channels?: { wecom?: { dynamicAgents?: Partial<DynamicAgentConfig> } } })?.channels?.wecom?.dynamicAgents;
24
+ return {
25
+ enabled: dynamicAgents?.enabled ?? false,
26
+ dmCreateAgent: dynamicAgents?.dmCreateAgent ?? true,
27
+ groupEnabled: dynamicAgents?.groupEnabled ?? true,
28
+ adminUsers: dynamicAgents?.adminUsers ?? [],
29
+ };
30
+ }
31
+
32
+ /**
33
+ * **generateAgentId (生成动态 Agent ID)**
34
+ *
35
+ * 根据聊天类型和对端 ID 生成确定性的 Agent ID。
36
+ * 格式: wecom-{type}-{sanitizedPeerId}
37
+ * - type: dm | group
38
+ * - sanitizedPeerId: 小写,非字母数字下划线横线替换为下划线
39
+ *
40
+ * @example
41
+ * generateAgentId("dm", "ZhangSan") // "wecom-dm-zhangsan"
42
+ * generateAgentId("group", "wr123456") // "wecom-group-wr123456"
43
+ */
44
+ export function generateAgentId(chatType: "dm" | "group", peerId: string): string {
45
+ const sanitized = String(peerId)
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9_-]/g, "_");
48
+ return `wecom-${chatType}-${sanitized}`;
49
+ }
50
+
51
+ /**
52
+ * **shouldUseDynamicAgent (检查是否使用动态 Agent)**
53
+ *
54
+ * 根据配置和发送者信息判断是否应使用动态 Agent。
55
+ * 管理员(adminUsers)始终绕过动态路由,使用主 Agent。
56
+ */
57
+ export function shouldUseDynamicAgent(params: {
58
+ chatType: "dm" | "group";
59
+ senderId: string;
60
+ config: OpenClawConfig;
61
+ }): boolean {
62
+ const { chatType, senderId, config } = params;
63
+ const dynamicConfig = getDynamicAgentConfig(config);
64
+
65
+ if (!dynamicConfig.enabled) {
66
+ return false;
67
+ }
68
+
69
+ // 管理员绕过动态路由
70
+ const sender = String(senderId).trim().toLowerCase();
71
+ const isAdmin = dynamicConfig.adminUsers.some(
72
+ (admin) => admin.trim().toLowerCase() === sender
73
+ );
74
+ if (isAdmin) {
75
+ return false;
76
+ }
77
+
78
+ if (chatType === "group") {
79
+ return dynamicConfig.groupEnabled;
80
+ }
81
+ return dynamicConfig.dmCreateAgent;
82
+ }
83
+
84
+ /**
85
+ * 内存中已确保的 Agent ID(避免重复写入)
86
+ */
87
+ const ensuredDynamicAgentIds = new Set<string>();
88
+
89
+ /**
90
+ * 写入队列(避免并发冲突)
91
+ */
92
+ let ensureDynamicAgentWriteQueue: Promise<void> = Promise.resolve();
93
+
94
+ /**
95
+ * 将 Agent ID 插入 agents.list(如果不存在)
96
+ */
97
+ function upsertAgentIdOnlyEntry(cfg: Record<string, unknown>, agentId: string): boolean {
98
+ if (!cfg.agents || typeof cfg.agents !== "object") {
99
+ cfg.agents = {};
100
+ }
101
+
102
+ const agentsObj = cfg.agents as Record<string, unknown>;
103
+ const currentList: Array<{ id: string }> = Array.isArray(agentsObj.list) ? agentsObj.list as Array<{ id: string }> : [];
104
+ const existingIds = new Set(
105
+ currentList
106
+ .map((entry) => entry?.id?.trim().toLowerCase())
107
+ .filter((id): id is string => Boolean(id))
108
+ );
109
+
110
+ let changed = false;
111
+ const nextList = [...currentList];
112
+
113
+ // 首次创建时保留 main 作为默认
114
+ if (nextList.length === 0) {
115
+ nextList.push({ id: "main" });
116
+ existingIds.add("main");
117
+ changed = true;
118
+ }
119
+
120
+ if (!existingIds.has(agentId.toLowerCase())) {
121
+ nextList.push({ id: agentId });
122
+ changed = true;
123
+ }
124
+
125
+ if (changed) {
126
+ agentsObj.list = nextList;
127
+ }
128
+
129
+ return changed;
130
+ }
131
+
132
+ /**
133
+ * **ensureDynamicAgentListed (确保动态 Agent 已添加到 agents.list)**
134
+ *
135
+ * 将动态生成的 Agent ID 添加到 OpenClaw 配置中的 agents.list。
136
+ * 特性:
137
+ * - 幂等:使用内存 Set 避免重复写入
138
+ * - 串行:使用 Promise 队列避免并发冲突
139
+ * - 异步:不阻塞消息处理流程
140
+ */
141
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
142
+ export async function ensureDynamicAgentListed(agentId: string, runtime: any): Promise<void> {
143
+ const normalizedId = String(agentId).trim().toLowerCase();
144
+ if (!normalizedId) return;
145
+ if (ensuredDynamicAgentIds.has(normalizedId)) return;
146
+
147
+ const configRuntime = runtime?.config;
148
+ if (!configRuntime?.loadConfig || !configRuntime?.writeConfigFile) return;
149
+
150
+ ensureDynamicAgentWriteQueue = ensureDynamicAgentWriteQueue
151
+ .then(async () => {
152
+ if (ensuredDynamicAgentIds.has(normalizedId)) return;
153
+
154
+ const latestConfig = configRuntime.loadConfig!();
155
+ if (!latestConfig || typeof latestConfig !== "object") return;
156
+
157
+ const changed = upsertAgentIdOnlyEntry(latestConfig as Record<string, unknown>, normalizedId);
158
+ if (changed) {
159
+ await configRuntime.writeConfigFile!(latestConfig as unknown);
160
+ }
161
+
162
+ ensuredDynamicAgentIds.add(normalizedId);
163
+ })
164
+ .catch((err) => {
165
+ console.warn(`[wecom] 动态 Agent 添加失败: ${normalizedId}`, err);
166
+ });
167
+
168
+ await ensureDynamicAgentWriteQueue;
169
+ }
170
+
171
+ /**
172
+ * **resetEnsuredCache (重置已确保缓存)**
173
+ *
174
+ * 主要用于测试场景,重置内存中的缓存状态。
175
+ */
176
+ export function resetEnsuredCache(): void {
177
+ ensuredDynamicAgentIds.clear();
178
+ }