@mocrane/wecom 2026.2.5 → 2026.3.4

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.
Files changed (58) hide show
  1. package/LICENSE +4 -18
  2. package/README.md +572 -0
  3. package/assets/01.bot-add.png +0 -0
  4. package/assets/01.bot-setp2.png +0 -0
  5. package/assets/01.image.jpg +0 -0
  6. package/assets/02.agent.add.png +0 -0
  7. package/assets/02.agent.api-set.png +0 -0
  8. package/assets/02.image.jpg +0 -0
  9. package/assets/03.agent.page.png +0 -0
  10. package/assets/03.bot.page.png +0 -0
  11. package/assets/link-me.jpg +0 -0
  12. package/assets/register.png +0 -0
  13. package/changelog/v2.2.28.md +70 -0
  14. package/changelog/v2.3.2.md +28 -0
  15. package/changelog/v2.3.4.md +20 -0
  16. package/index.ts +11 -3
  17. package/package.json +4 -2
  18. package/src/accounts.ts +17 -55
  19. package/src/agent/api-client.ts +84 -37
  20. package/src/agent/api-client.upload.test.ts +110 -0
  21. package/src/agent/handler.event-filter.test.ts +50 -0
  22. package/src/agent/handler.ts +166 -143
  23. package/src/channel.config.test.ts +147 -0
  24. package/src/channel.lifecycle.test.ts +252 -0
  25. package/src/channel.ts +95 -140
  26. package/src/config/accounts.resolve.test.ts +38 -0
  27. package/src/config/accounts.ts +257 -22
  28. package/src/config/index.ts +6 -0
  29. package/src/config/network.ts +9 -5
  30. package/src/config/routing.test.ts +88 -0
  31. package/src/config/routing.ts +26 -0
  32. package/src/config/schema.ts +52 -4
  33. package/src/config-schema.ts +5 -41
  34. package/src/dynamic-agent.account-scope.test.ts +17 -0
  35. package/src/dynamic-agent.ts +178 -0
  36. package/src/gateway-monitor.ts +238 -0
  37. package/src/http.ts +16 -2
  38. package/src/media.test.ts +28 -1
  39. package/src/media.ts +59 -1
  40. package/src/monitor/state.queue.test.ts +1 -1
  41. package/src/monitor/state.ts +1 -1
  42. package/src/monitor/types.ts +1 -1
  43. package/src/monitor.active.test.ts +15 -9
  44. package/src/monitor.inbound-filter.test.ts +63 -0
  45. package/src/monitor.integration.test.ts +4 -2
  46. package/src/monitor.ts +988 -125
  47. package/src/monitor.webhook.test.ts +381 -3
  48. package/src/onboarding.ts +229 -53
  49. package/src/outbound.test.ts +130 -0
  50. package/src/outbound.ts +44 -9
  51. package/src/shared/command-auth.ts +4 -2
  52. package/src/shared/xml-parser.test.ts +21 -1
  53. package/src/shared/xml-parser.ts +18 -0
  54. package/src/types/account.ts +43 -14
  55. package/src/types/config.ts +51 -2
  56. package/src/types/constants.ts +7 -3
  57. package/src/types/index.ts +3 -0
  58. package/src/types.ts +29 -147
@@ -8,15 +8,22 @@ import path from "node:path";
8
8
  import type { IncomingMessage, ServerResponse } from "node:http";
9
9
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
10
10
  import type { ResolvedAgentAccount } from "../types/index.js";
11
- import { LIMITS } from "../types/constants.js";
12
- import { decryptWecomEncrypted, verifyWecomSignature, computeWecomMsgSignature, encryptWecomPlaintext } from "../crypto/index.js";
13
- import { extractEncryptFromXml, buildEncryptedXmlResponse } from "../crypto/xml.js";
14
- import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId, extractMsgId, extractFileName } from "../shared/xml-parser.js";
11
+ import {
12
+ extractMsgType,
13
+ extractFromUser,
14
+ extractContent,
15
+ extractChatId,
16
+ extractMediaId,
17
+ extractMsgId,
18
+ extractFileName,
19
+ extractAgentId,
20
+ } from "../shared/xml-parser.js";
15
21
  import { sendText, downloadMedia } from "./api-client.js";
16
22
  import { getWecomRuntime } from "../runtime.js";
17
23
  import type { WecomAgentInboundMessage } from "../types/index.js";
18
24
  import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
19
- import { resolveWecomMediaMaxBytes } from "../config/index.js";
25
+ import { resolveWecomMediaMaxBytes, shouldRejectWecomDefaultRoute } from "../config/index.js";
26
+ import { generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "../dynamic-agent.js";
20
27
 
21
28
  /** 错误提示信息 */
22
29
  const ERROR_HELP = "";
@@ -98,6 +105,18 @@ function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefi
98
105
  export type AgentWebhookParams = {
99
106
  req: IncomingMessage;
100
107
  res: ServerResponse;
108
+ /**
109
+ * 上游已完成验签/解密时传入,避免重复协议处理。
110
+ * 仅用于 POST 消息回调流程。
111
+ */
112
+ verifiedPost?: {
113
+ timestamp: string;
114
+ nonce: string;
115
+ signature: string;
116
+ encrypted: string;
117
+ decrypted: string;
118
+ parsed: WecomAgentInboundMessage;
119
+ };
101
120
  agent: ResolvedAgentAccount;
102
121
  config: OpenClawConfig;
103
122
  core: PluginRuntime;
@@ -105,157 +124,119 @@ export type AgentWebhookParams = {
105
124
  error?: (msg: string) => void;
106
125
  };
107
126
 
108
- /**
109
- * **resolveQueryParams (解析查询参数)**
110
- *
111
- * 辅助函数:从 IncomingMessage 中解析 URL 查询字符串,用于获取签名、时间戳等参数。
112
- */
113
- function resolveQueryParams(req: IncomingMessage): URLSearchParams {
114
- const url = new URL(req.url ?? "/", "http://localhost");
115
- return url.searchParams;
116
- }
127
+ export type AgentInboundProcessDecision = {
128
+ shouldProcess: boolean;
129
+ reason: string;
130
+ };
117
131
 
118
132
  /**
119
- * **readRawBody (读取原始请求体)**
120
- *
121
- * 异步读取 HTTP POST 请求的原始 BODY 数据(XML 字符串)。
122
- * 包含最大体积限制检查,防止内存溢出攻击。
133
+ * 仅允许“用户意图消息”进入 AI 会话。
134
+ * - event 回调(如 enter_agent/subscribe)不应触发会话与自动回复
135
+ * - 系统发送者(sys)不应触发会话与自动回复
136
+ * - 缺失发送者时默认丢弃,避免写入异常会话
123
137
  */
124
- async function readRawBody(req: IncomingMessage, maxSize: number = LIMITS.MAX_REQUEST_BODY_SIZE): Promise<string> {
125
- return new Promise((resolve, reject) => {
126
- const chunks: Buffer[] = [];
127
- let size = 0;
128
-
129
- req.on("data", (chunk: Buffer) => {
130
- size += chunk.length;
131
- if (size > maxSize) {
132
- reject(new Error("Request body too large"));
133
- req.destroy();
134
- return;
135
- }
136
- chunks.push(chunk);
137
- });
138
+ export function shouldProcessAgentInboundMessage(params: {
139
+ msgType: string;
140
+ fromUser: string;
141
+ eventType?: string;
142
+ }): AgentInboundProcessDecision {
143
+ const msgType = String(params.msgType ?? "").trim().toLowerCase();
144
+ const fromUser = String(params.fromUser ?? "").trim();
145
+ const normalizedFromUser = fromUser.toLowerCase();
146
+ const eventType = String(params.eventType ?? "").trim().toLowerCase();
147
+
148
+ if (msgType === "event") {
149
+ return {
150
+ shouldProcess: false,
151
+ reason: `event:${eventType || "unknown"}`,
152
+ };
153
+ }
138
154
 
139
- req.on("end", () => {
140
- resolve(Buffer.concat(chunks).toString("utf8"));
141
- });
155
+ if (!fromUser) {
156
+ return {
157
+ shouldProcess: false,
158
+ reason: "missing_sender",
159
+ };
160
+ }
142
161
 
143
- req.on("error", reject);
144
- });
162
+ if (normalizedFromUser === "sys") {
163
+ return {
164
+ shouldProcess: false,
165
+ reason: "system_sender",
166
+ };
167
+ }
168
+
169
+ return {
170
+ shouldProcess: true,
171
+ reason: "user_message",
172
+ };
173
+ }
174
+
175
+ function normalizeAgentId(value: unknown): number | undefined {
176
+ if (typeof value === "number" && Number.isFinite(value)) return value;
177
+ const raw = String(value ?? "").trim();
178
+ if (!raw) return undefined;
179
+ const parsed = Number(raw);
180
+ return Number.isFinite(parsed) ? parsed : undefined;
145
181
  }
146
182
 
147
183
  /**
148
- * **handleUrlVerification (处理 URL 验证)**
184
+ * **resolveQueryParams (解析查询参数)**
149
185
  *
150
- * 处理企业微信 Agent 配置时的 GET 请求验证。
151
- * 流程:
152
- * 1. 验证 msg_signature 签名。
153
- * 2. 解密 echostr 参数。
154
- * 3. 返回解密后的明文 echostr。
186
+ * 辅助函数:从 IncomingMessage 中解析 URL 查询字符串,用于获取签名、时间戳等参数。
155
187
  */
156
- async function handleUrlVerification(
157
- req: IncomingMessage,
158
- res: ServerResponse,
159
- agent: ResolvedAgentAccount,
160
- ): Promise<boolean> {
161
- const query = resolveQueryParams(req);
162
- const timestamp = query.get("timestamp") ?? "";
163
- const nonce = query.get("nonce") ?? "";
164
- const echostr = query.get("echostr") ?? "";
165
- const signature = query.get("msg_signature") ?? "";
166
- const remote = req.socket?.remoteAddress ?? "unknown";
167
-
168
- // 不输出敏感参数内容,仅输出存在性
169
- // 用于排查:是否有请求打到 /wecom/agent
170
- // 以及是否带齐 timestamp/nonce/msg_signature/echostr
171
- // eslint-disable-next-line no-unused-vars
172
- const _debug = { remote, hasTimestamp: Boolean(timestamp), hasNonce: Boolean(nonce), hasSig: Boolean(signature), hasEchostr: Boolean(echostr) };
173
-
174
- const valid = verifyWecomSignature({
175
- token: agent.token,
176
- timestamp,
177
- nonce,
178
- encrypt: echostr,
179
- signature,
180
- });
181
-
182
- if (!valid) {
183
- res.statusCode = 401;
184
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
185
- res.end(`unauthorized - 签名验证失败,请检查 Token 配置${ERROR_HELP}`);
186
- return true;
187
- }
188
-
189
- try {
190
- const plain = decryptWecomEncrypted({
191
- encodingAESKey: agent.encodingAESKey,
192
- receiveId: agent.corpId,
193
- encrypt: echostr,
194
- });
195
- res.statusCode = 200;
196
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
197
- res.end(plain);
198
- return true;
199
- } catch {
200
- res.statusCode = 400;
201
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
202
- res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey 配置${ERROR_HELP}`);
203
- return true;
204
- }
188
+ function resolveQueryParams(req: IncomingMessage): URLSearchParams {
189
+ const url = new URL(req.url ?? "/", "http://localhost");
190
+ return url.searchParams;
205
191
  }
206
192
 
207
193
  /**
208
194
  * 处理消息回调 (POST)
209
195
  */
210
196
  async function handleMessageCallback(params: AgentWebhookParams): Promise<boolean> {
211
- const { req, res, agent, config, core, log, error } = params;
197
+ const { req, res, verifiedPost, agent, config, core, log, error } = params;
212
198
 
213
199
  try {
214
- log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
215
- const rawXml = await readRawBody(req);
216
- log?.(`[wecom-agent] inbound: rawXmlBytes=${Buffer.byteLength(rawXml, "utf8")}`);
217
- const encrypted = extractEncryptFromXml(rawXml);
218
- log?.(`[wecom-agent] inbound: hasEncrypt=${Boolean(encrypted)} encryptLen=${encrypted ? String(encrypted).length : 0}`);
200
+ if (!verifiedPost) {
201
+ error?.("[wecom-agent] inbound: missing preverified envelope");
202
+ res.statusCode = 400;
203
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
204
+ res.end(`invalid request - 缺少上游验签结果${ERROR_HELP}`);
205
+ return true;
206
+ }
219
207
 
208
+ log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
220
209
  const query = resolveQueryParams(req);
221
- const timestamp = query.get("timestamp") ?? "";
222
- const nonce = query.get("nonce") ?? "";
223
- const signature = query.get("msg_signature") ?? "";
210
+ const querySignature = query.get("msg_signature") ?? "";
211
+
212
+ const encrypted = verifiedPost.encrypted;
213
+ const decrypted = verifiedPost.decrypted;
214
+ const msg = verifiedPost.parsed;
215
+ const timestamp = verifiedPost.timestamp;
216
+ const nonce = verifiedPost.nonce;
217
+ const signature = verifiedPost.signature || querySignature;
224
218
  log?.(
225
- `[wecom-agent] inbound: query timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${signature ? "yes" : "no"}`,
219
+ `[wecom-agent] inbound: using preverified envelope timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${signature ? "yes" : "no"} encryptLen=${encrypted.length}`,
226
220
  );
227
221
 
228
- // 验证签名
229
- const valid = verifyWecomSignature({
230
- token: agent.token,
231
- timestamp,
232
- nonce,
233
- encrypt: encrypted,
234
- signature,
235
- });
236
-
237
- if (!valid) {
238
- error?.(`[wecom-agent] inbound: signature invalid`);
239
- res.statusCode = 401;
240
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
241
- res.end(`unauthorized - 签名验证失败${ERROR_HELP}`);
242
- return true;
243
- }
244
-
245
- // 解密
246
- const decrypted = decryptWecomEncrypted({
247
- encodingAESKey: agent.encodingAESKey,
248
- receiveId: agent.corpId,
249
- encrypt: encrypted,
250
- });
251
222
  log?.(`[wecom-agent] inbound: decryptedBytes=${Buffer.byteLength(decrypted, "utf8")}`);
252
223
 
253
- // 解析 XML
254
- const msg = parseXml(decrypted);
224
+ const inboundAgentId = normalizeAgentId(extractAgentId(msg));
225
+ if (
226
+ inboundAgentId !== undefined &&
227
+ typeof agent.agentId === "number" &&
228
+ Number.isFinite(agent.agentId) &&
229
+ inboundAgentId !== agent.agentId
230
+ ) {
231
+ error?.(
232
+ `[wecom-agent] inbound: agentId mismatch ignored expectedAgentId=${agent.agentId} actualAgentId=${String(extractAgentId(msg) ?? "")}`,
233
+ );
234
+ }
255
235
  const msgType = extractMsgType(msg);
256
236
  const fromUser = extractFromUser(msg);
257
237
  const chatId = extractChatId(msg);
258
238
  const msgId = extractMsgId(msg);
239
+ const eventType = String((msg as Record<string, unknown>).Event ?? "").trim().toLowerCase();
259
240
  if (msgId) {
260
241
  const ok = rememberAgentMsgId(msgId);
261
242
  if (!ok) {
@@ -276,6 +257,18 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
276
257
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
277
258
  res.end("success");
278
259
 
260
+ const decision = shouldProcessAgentInboundMessage({
261
+ msgType,
262
+ fromUser,
263
+ eventType,
264
+ });
265
+ if (!decision.shouldProcess) {
266
+ log?.(
267
+ `[wecom-agent] skip processing: type=${msgType || "unknown"} event=${eventType || "N/A"} from=${fromUser || "N/A"} reason=${decision.reason}`,
268
+ );
269
+ return true;
270
+ }
271
+
279
272
  // 异步处理消息
280
273
  processAgentMessage({
281
274
  agent,
@@ -439,9 +432,46 @@ async function processAgentMessage(params: {
439
432
  cfg: config,
440
433
  channel: "wecom",
441
434
  accountId: agent.accountId,
442
- peer: { kind: isGroup ? "group" : "dm", id: peerId },
435
+ peer: { kind: isGroup ? "group" : "direct", id: peerId },
443
436
  });
444
437
 
438
+ // ===== 动态 Agent 路由注入 =====
439
+ const useDynamicAgent = shouldUseDynamicAgent({
440
+ chatType: isGroup ? "group" : "dm",
441
+ senderId: fromUser,
442
+ config,
443
+ });
444
+
445
+ if (shouldRejectWecomDefaultRoute({ cfg: config, matchedBy: route.matchedBy, useDynamicAgent })) {
446
+ const prompt =
447
+ `当前账号(${agent.accountId})未绑定 OpenClaw Agent,已拒绝回退到默认主智能体。` +
448
+ `请在 bindings 中添加:{"agentId":"你的Agent","match":{"channel":"wecom","accountId":"${agent.accountId}"}}`;
449
+ error?.(
450
+ `[wecom-agent] routing guard: blocked default fallback accountId=${agent.accountId} matchedBy=${route.matchedBy} from=${fromUser}`,
451
+ );
452
+ try {
453
+ await sendText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
454
+ log?.(`[wecom-agent] routing guard prompt delivered to ${fromUser}`);
455
+ } catch (err: unknown) {
456
+ error?.(`[wecom-agent] routing guard prompt failed: ${String(err)}`);
457
+ }
458
+ return;
459
+ }
460
+
461
+ if (useDynamicAgent) {
462
+ const targetAgentId = generateAgentId(
463
+ isGroup ? "group" : "dm",
464
+ peerId,
465
+ agent.accountId,
466
+ );
467
+ route.agentId = targetAgentId;
468
+ route.sessionKey = `agent:${targetAgentId}:wecom:${agent.accountId}:${isGroup ? "group" : "dm"}:${peerId}`;
469
+ // 异步添加到 agents.list(不阻塞)
470
+ ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
471
+ log?.(`[wecom-agent] dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
472
+ }
473
+ // ===== 动态 Agent 路由注入结束 =====
474
+
445
475
  // 构建上下文
446
476
  const fromLabel = isGroup ? `group:${peerId}` : `user:${fromUser}`;
447
477
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
@@ -464,7 +494,7 @@ async function processAgentMessage(params: {
464
494
  core,
465
495
  cfg: config,
466
496
  // Agent 门禁应读取 channels.wecom.agent.dm(即 agent.config.dm),而不是 channels.wecom.dm(不存在)
467
- accountConfig: agent.config as any,
497
+ accountConfig: agent.config,
468
498
  rawBody: finalContent,
469
499
  senderUserId: fromUser,
470
500
  });
@@ -496,7 +526,7 @@ async function processAgentMessage(params: {
496
526
  SenderName: fromUser,
497
527
  SenderId: fromUser,
498
528
  Provider: "wecom",
499
- Surface: "wecom",
529
+ Surface: "webchat",
500
530
  OriginatingChannel: "wecom",
501
531
  // 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
502
532
  // - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
@@ -532,32 +562,25 @@ async function processAgentMessage(params: {
532
562
  await sendText({ agent, toUser: fromUser, chatId: undefined, text });
533
563
  log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
534
564
  } catch (err: unknown) {
535
- error?.(`[wecom-agent] reply failed: ${String(err)}`);
536
- }
537
- },
565
+ const message = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}` : String(err);
566
+ error?.(`[wecom-agent] reply failed: ${message}`);
567
+ } },
538
568
  onError: (err: unknown, info: { kind: string }) => {
539
569
  error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
540
570
  },
541
- },
542
- replyOptions: {
543
- disableBlockStreaming: true,
544
- },
571
+ }
545
572
  });
546
573
  }
547
574
 
548
575
  /**
549
576
  * **handleAgentWebhook (Agent Webhook 入口)**
550
577
  *
551
- * 统一处理 Agent 模式的 Webhook 请求。
552
- * 根据 HTTP 方法分发到 URL 验证 (GET) 或 消息处理 (POST)。
578
+ * 统一处理 Agent 模式的 POST 消息回调请求。
579
+ * URL 验证与验签/解密由 monitor 层统一处理后再调用本函数。
553
580
  */
554
581
  export async function handleAgentWebhook(params: AgentWebhookParams): Promise<boolean> {
555
582
  const { req } = params;
556
583
 
557
- if (req.method === "GET") {
558
- return handleUrlVerification(req, params.res, params.agent);
559
- }
560
-
561
584
  if (req.method === "POST") {
562
585
  return handleMessageCallback(params);
563
586
  }
@@ -0,0 +1,147 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { wecomPlugin } from "./channel.js";
5
+
6
+ describe("wecomPlugin config.deleteAccount", () => {
7
+ it("removes only the target matrix account", () => {
8
+ const cfg: OpenClawConfig = {
9
+ channels: {
10
+ wecom: {
11
+ enabled: true,
12
+ accounts: {
13
+ "acct-a": {
14
+ enabled: true,
15
+ bot: {
16
+ token: "token-a",
17
+ encodingAESKey: "aes-a",
18
+ },
19
+ },
20
+ "acct-b": {
21
+ enabled: true,
22
+ bot: {
23
+ token: "token-b",
24
+ encodingAESKey: "aes-b",
25
+ },
26
+ },
27
+ },
28
+ },
29
+ },
30
+ } as OpenClawConfig;
31
+
32
+ const next = wecomPlugin.config.deleteAccount!({ cfg, accountId: "acct-a" });
33
+ const accounts = (next.channels?.wecom as { accounts?: Record<string, unknown> } | undefined)
34
+ ?.accounts;
35
+
36
+ expect(accounts?.["acct-a"]).toBeUndefined();
37
+ expect(accounts?.["acct-b"]).toBeDefined();
38
+ expect(next.channels?.wecom).toBeDefined();
39
+ });
40
+
41
+ it("removes legacy wecom section when deleting default account", () => {
42
+ const cfg: OpenClawConfig = {
43
+ channels: {
44
+ wecom: {
45
+ enabled: true,
46
+ bot: {
47
+ token: "token",
48
+ encodingAESKey: "aes",
49
+ },
50
+ },
51
+ },
52
+ } as OpenClawConfig;
53
+
54
+ const next = wecomPlugin.config.deleteAccount!({ cfg, accountId: "default" });
55
+ expect(next.channels?.wecom).toBeUndefined();
56
+ });
57
+ });
58
+
59
+ describe("wecomPlugin account conflict guards", () => {
60
+ it("marks duplicate bot token account as unconfigured", async () => {
61
+ const cfg: OpenClawConfig = {
62
+ channels: {
63
+ wecom: {
64
+ enabled: true,
65
+ accounts: {
66
+ "acct-a": {
67
+ enabled: true,
68
+ bot: { token: "token-shared", encodingAESKey: "aes-a" },
69
+ },
70
+ "acct-b": {
71
+ enabled: true,
72
+ bot: { token: "token-shared", encodingAESKey: "aes-b" },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ } as OpenClawConfig;
78
+
79
+ const accountA = wecomPlugin.config.resolveAccount(cfg, "acct-a");
80
+ const accountB = wecomPlugin.config.resolveAccount(cfg, "acct-b");
81
+ expect(await wecomPlugin.config.isConfigured!(accountA, cfg)).toBe(true);
82
+ expect(await wecomPlugin.config.isConfigured!(accountB, cfg)).toBe(false);
83
+ expect(wecomPlugin.config.unconfiguredReason?.(accountB, cfg)).toContain("Duplicate WeCom bot token");
84
+ });
85
+
86
+ it("marks duplicate bot aibotid account as unconfigured", async () => {
87
+ const cfg: OpenClawConfig = {
88
+ channels: {
89
+ wecom: {
90
+ enabled: true,
91
+ accounts: {
92
+ "acct-a": {
93
+ enabled: true,
94
+ bot: { token: "token-a", encodingAESKey: "aes-a", aibotid: "BOT_001" },
95
+ },
96
+ "acct-b": {
97
+ enabled: true,
98
+ bot: { token: "token-b", encodingAESKey: "aes-b", aibotid: "BOT_001" },
99
+ },
100
+ },
101
+ },
102
+ },
103
+ } as OpenClawConfig;
104
+
105
+ const accountB = wecomPlugin.config.resolveAccount(cfg, "acct-b");
106
+ expect(await wecomPlugin.config.isConfigured!(accountB, cfg)).toBe(false);
107
+ expect(wecomPlugin.config.unconfiguredReason?.(accountB, cfg)).toContain("Duplicate WeCom bot aibotid");
108
+ });
109
+
110
+ it("marks duplicate corpId/agentId account as unconfigured", async () => {
111
+ const cfg: OpenClawConfig = {
112
+ channels: {
113
+ wecom: {
114
+ enabled: true,
115
+ accounts: {
116
+ "acct-a": {
117
+ enabled: true,
118
+ agent: {
119
+ corpId: "corp-1",
120
+ corpSecret: "secret-a",
121
+ agentId: 1001,
122
+ token: "token-a",
123
+ encodingAESKey: "aes-a",
124
+ },
125
+ },
126
+ "acct-b": {
127
+ enabled: true,
128
+ agent: {
129
+ corpId: "corp-1",
130
+ corpSecret: "secret-b",
131
+ agentId: 1001,
132
+ token: "token-b",
133
+ encodingAESKey: "aes-b",
134
+ },
135
+ },
136
+ },
137
+ },
138
+ },
139
+ } as OpenClawConfig;
140
+
141
+ const accountB = wecomPlugin.config.resolveAccount(cfg, "acct-b");
142
+ expect(await wecomPlugin.config.isConfigured!(accountB, cfg)).toBe(false);
143
+ expect(wecomPlugin.config.unconfiguredReason?.(accountB, cfg)).toContain(
144
+ "Duplicate WeCom agent identity",
145
+ );
146
+ });
147
+ });