@yanhaidao/wecom 2.2.7 → 2.3.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.
Files changed (54) hide show
  1. package/.github/workflows/release.yml +56 -0
  2. package/CLAUDE.md +1 -1
  3. package/GOVERNANCE.md +26 -0
  4. package/LICENSE +7 -0
  5. package/README.md +275 -91
  6. package/assets/01.bot-add.png +0 -0
  7. package/assets/01.bot-setp2.png +0 -0
  8. package/assets/02.agent.add.png +0 -0
  9. package/assets/02.agent.api-set.png +0 -0
  10. package/assets/register.png +0 -0
  11. package/changelog/v2.2.28.md +70 -0
  12. package/changelog/v2.3.2.md +70 -0
  13. package/compat-single-account.md +118 -0
  14. package/package.json +10 -2
  15. package/src/accounts.ts +17 -55
  16. package/src/agent/api-client.ts +84 -37
  17. package/src/agent/api-client.upload.test.ts +110 -0
  18. package/src/agent/handler.event-filter.test.ts +50 -0
  19. package/src/agent/handler.ts +147 -145
  20. package/src/channel.config.test.ts +147 -0
  21. package/src/channel.lifecycle.test.ts +234 -0
  22. package/src/channel.ts +90 -140
  23. package/src/config/accounts.resolve.test.ts +38 -0
  24. package/src/config/accounts.ts +257 -22
  25. package/src/config/index.ts +6 -0
  26. package/src/config/network.ts +9 -5
  27. package/src/config/routing.test.ts +88 -0
  28. package/src/config/routing.ts +26 -0
  29. package/src/config/schema.ts +35 -4
  30. package/src/config-schema.ts +5 -41
  31. package/src/dynamic-agent.account-scope.test.ts +17 -0
  32. package/src/dynamic-agent.ts +13 -13
  33. package/src/gateway-monitor.ts +200 -0
  34. package/src/http.ts +16 -2
  35. package/src/media.test.ts +28 -1
  36. package/src/media.ts +59 -1
  37. package/src/monitor/state.queue.test.ts +1 -1
  38. package/src/monitor/state.ts +1 -1
  39. package/src/monitor/types.ts +1 -1
  40. package/src/monitor.active.test.ts +13 -7
  41. package/src/monitor.inbound-filter.test.ts +63 -0
  42. package/src/monitor.ts +948 -128
  43. package/src/monitor.webhook.test.ts +288 -3
  44. package/src/outbound.test.ts +130 -0
  45. package/src/outbound.ts +44 -9
  46. package/src/shared/command-auth.ts +4 -2
  47. package/src/shared/xml-parser.test.ts +21 -1
  48. package/src/shared/xml-parser.ts +18 -0
  49. package/src/types/account.ts +43 -14
  50. package/src/types/config.ts +37 -2
  51. package/src/types/index.ts +3 -0
  52. package/src/types.ts +29 -147
  53. package/GEMINI.md +0 -76
  54. package//345/212/250/346/200/201Agent/350/267/257/347/224/261.md +0 -360
@@ -8,15 +8,21 @@ 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";
20
26
  import { generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "../dynamic-agent.js";
21
27
 
22
28
  /** 错误提示信息 */
@@ -99,6 +105,18 @@ function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefi
99
105
  export type AgentWebhookParams = {
100
106
  req: IncomingMessage;
101
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
+ };
102
120
  agent: ResolvedAgentAccount;
103
121
  config: OpenClawConfig;
104
122
  core: PluginRuntime;
@@ -106,157 +124,119 @@ export type AgentWebhookParams = {
106
124
  error?: (msg: string) => void;
107
125
  };
108
126
 
109
- /**
110
- * **resolveQueryParams (解析查询参数)**
111
- *
112
- * 辅助函数:从 IncomingMessage 中解析 URL 查询字符串,用于获取签名、时间戳等参数。
113
- */
114
- function resolveQueryParams(req: IncomingMessage): URLSearchParams {
115
- const url = new URL(req.url ?? "/", "http://localhost");
116
- return url.searchParams;
117
- }
127
+ export type AgentInboundProcessDecision = {
128
+ shouldProcess: boolean;
129
+ reason: string;
130
+ };
118
131
 
119
132
  /**
120
- * **readRawBody (读取原始请求体)**
121
- *
122
- * 异步读取 HTTP POST 请求的原始 BODY 数据(XML 字符串)。
123
- * 包含最大体积限制检查,防止内存溢出攻击。
133
+ * 仅允许“用户意图消息”进入 AI 会话。
134
+ * - event 回调(如 enter_agent/subscribe)不应触发会话与自动回复
135
+ * - 系统发送者(sys)不应触发会话与自动回复
136
+ * - 缺失发送者时默认丢弃,避免写入异常会话
124
137
  */
125
- async function readRawBody(req: IncomingMessage, maxSize: number = LIMITS.MAX_REQUEST_BODY_SIZE): Promise<string> {
126
- return new Promise((resolve, reject) => {
127
- const chunks: Buffer[] = [];
128
- let size = 0;
129
-
130
- req.on("data", (chunk: Buffer) => {
131
- size += chunk.length;
132
- if (size > maxSize) {
133
- reject(new Error("Request body too large"));
134
- req.destroy();
135
- return;
136
- }
137
- chunks.push(chunk);
138
- });
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
+ }
139
154
 
140
- req.on("end", () => {
141
- resolve(Buffer.concat(chunks).toString("utf8"));
142
- });
155
+ if (!fromUser) {
156
+ return {
157
+ shouldProcess: false,
158
+ reason: "missing_sender",
159
+ };
160
+ }
143
161
 
144
- req.on("error", reject);
145
- });
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;
146
181
  }
147
182
 
148
183
  /**
149
- * **handleUrlVerification (处理 URL 验证)**
184
+ * **resolveQueryParams (解析查询参数)**
150
185
  *
151
- * 处理企业微信 Agent 配置时的 GET 请求验证。
152
- * 流程:
153
- * 1. 验证 msg_signature 签名。
154
- * 2. 解密 echostr 参数。
155
- * 3. 返回解密后的明文 echostr。
186
+ * 辅助函数:从 IncomingMessage 中解析 URL 查询字符串,用于获取签名、时间戳等参数。
156
187
  */
157
- async function handleUrlVerification(
158
- req: IncomingMessage,
159
- res: ServerResponse,
160
- agent: ResolvedAgentAccount,
161
- ): Promise<boolean> {
162
- const query = resolveQueryParams(req);
163
- const timestamp = query.get("timestamp") ?? "";
164
- const nonce = query.get("nonce") ?? "";
165
- const echostr = query.get("echostr") ?? "";
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) };
174
-
175
- const valid = verifyWecomSignature({
176
- token: agent.token,
177
- timestamp,
178
- nonce,
179
- encrypt: echostr,
180
- signature,
181
- });
182
-
183
- if (!valid) {
184
- res.statusCode = 401;
185
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
186
- res.end(`unauthorized - 签名验证失败,请检查 Token 配置${ERROR_HELP}`);
187
- return true;
188
- }
189
-
190
- try {
191
- const plain = decryptWecomEncrypted({
192
- encodingAESKey: agent.encodingAESKey,
193
- receiveId: agent.corpId,
194
- encrypt: echostr,
195
- });
196
- res.statusCode = 200;
197
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
198
- res.end(plain);
199
- return true;
200
- } catch {
201
- res.statusCode = 400;
202
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
203
- res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey 配置${ERROR_HELP}`);
204
- return true;
205
- }
188
+ function resolveQueryParams(req: IncomingMessage): URLSearchParams {
189
+ const url = new URL(req.url ?? "/", "http://localhost");
190
+ return url.searchParams;
206
191
  }
207
192
 
208
193
  /**
209
194
  * 处理消息回调 (POST)
210
195
  */
211
196
  async function handleMessageCallback(params: AgentWebhookParams): Promise<boolean> {
212
- const { req, res, agent, config, core, log, error } = params;
197
+ const { req, res, verifiedPost, agent, config, core, log, error } = params;
213
198
 
214
199
  try {
215
- log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
216
- const rawXml = await readRawBody(req);
217
- log?.(`[wecom-agent] inbound: rawXmlBytes=${Buffer.byteLength(rawXml, "utf8")}`);
218
- const encrypted = extractEncryptFromXml(rawXml);
219
- 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
+ }
220
207
 
208
+ log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
221
209
  const query = resolveQueryParams(req);
222
- const timestamp = query.get("timestamp") ?? "";
223
- const nonce = query.get("nonce") ?? "";
224
- 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;
225
218
  log?.(
226
- `[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}`,
227
220
  );
228
221
 
229
- // 验证签名
230
- const valid = verifyWecomSignature({
231
- token: agent.token,
232
- timestamp,
233
- nonce,
234
- encrypt: encrypted,
235
- signature,
236
- });
237
-
238
- if (!valid) {
239
- error?.(`[wecom-agent] inbound: signature invalid`);
240
- res.statusCode = 401;
241
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
242
- res.end(`unauthorized - 签名验证失败${ERROR_HELP}`);
243
- return true;
244
- }
245
-
246
- // 解密
247
- const decrypted = decryptWecomEncrypted({
248
- encodingAESKey: agent.encodingAESKey,
249
- receiveId: agent.corpId,
250
- encrypt: encrypted,
251
- });
252
222
  log?.(`[wecom-agent] inbound: decryptedBytes=${Buffer.byteLength(decrypted, "utf8")}`);
253
223
 
254
- // 解析 XML
255
- 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
+ }
256
235
  const msgType = extractMsgType(msg);
257
236
  const fromUser = extractFromUser(msg);
258
237
  const chatId = extractChatId(msg);
259
238
  const msgId = extractMsgId(msg);
239
+ const eventType = String((msg as Record<string, unknown>).Event ?? "").trim().toLowerCase();
260
240
  if (msgId) {
261
241
  const ok = rememberAgentMsgId(msgId);
262
242
  if (!ok) {
@@ -277,6 +257,18 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
277
257
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
278
258
  res.end("success");
279
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
+
280
272
  // 异步处理消息
281
273
  processAgentMessage({
282
274
  agent,
@@ -440,7 +432,7 @@ async function processAgentMessage(params: {
440
432
  cfg: config,
441
433
  channel: "wecom",
442
434
  accountId: agent.accountId,
443
- peer: { kind: isGroup ? "group" : "dm", id: peerId },
435
+ peer: { kind: isGroup ? "group" : "direct", id: peerId },
444
436
  });
445
437
 
446
438
  // ===== 动态 Agent 路由注入 =====
@@ -450,13 +442,30 @@ async function processAgentMessage(params: {
450
442
  config,
451
443
  });
452
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
+
453
461
  if (useDynamicAgent) {
454
462
  const targetAgentId = generateAgentId(
455
463
  isGroup ? "group" : "dm",
456
- peerId
464
+ peerId,
465
+ agent.accountId,
457
466
  );
458
467
  route.agentId = targetAgentId;
459
- route.sessionKey = `agent:${targetAgentId}:${isGroup ? "group" : "dm"}:${peerId}`;
468
+ route.sessionKey = `agent:${targetAgentId}:wecom:${agent.accountId}:${isGroup ? "group" : "dm"}:${peerId}`;
460
469
  // 异步添加到 agents.list(不阻塞)
461
470
  ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
462
471
  log?.(`[wecom-agent] dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
@@ -485,7 +494,7 @@ async function processAgentMessage(params: {
485
494
  core,
486
495
  cfg: config,
487
496
  // Agent 门禁应读取 channels.wecom.agent.dm(即 agent.config.dm),而不是 channels.wecom.dm(不存在)
488
- accountConfig: agent.config as any,
497
+ accountConfig: agent.config,
489
498
  rawBody: finalContent,
490
499
  senderUserId: fromUser,
491
500
  });
@@ -517,7 +526,7 @@ async function processAgentMessage(params: {
517
526
  SenderName: fromUser,
518
527
  SenderId: fromUser,
519
528
  Provider: "wecom",
520
- Surface: "wecom",
529
+ Surface: "webchat",
521
530
  OriginatingChannel: "wecom",
522
531
  // 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
523
532
  // - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
@@ -553,32 +562,25 @@ async function processAgentMessage(params: {
553
562
  await sendText({ agent, toUser: fromUser, chatId: undefined, text });
554
563
  log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
555
564
  } catch (err: unknown) {
556
- error?.(`[wecom-agent] reply failed: ${String(err)}`);
557
- }
558
- },
565
+ const message = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}` : String(err);
566
+ error?.(`[wecom-agent] reply failed: ${message}`);
567
+ } },
559
568
  onError: (err: unknown, info: { kind: string }) => {
560
569
  error?.(`[wecom-agent] ${info.kind} reply error: ${String(err)}`);
561
570
  },
562
- },
563
- replyOptions: {
564
- disableBlockStreaming: true,
565
- },
571
+ }
566
572
  });
567
573
  }
568
574
 
569
575
  /**
570
576
  * **handleAgentWebhook (Agent Webhook 入口)**
571
577
  *
572
- * 统一处理 Agent 模式的 Webhook 请求。
573
- * 根据 HTTP 方法分发到 URL 验证 (GET) 或 消息处理 (POST)。
578
+ * 统一处理 Agent 模式的 POST 消息回调请求。
579
+ * URL 验证与验签/解密由 monitor 层统一处理后再调用本函数。
574
580
  */
575
581
  export async function handleAgentWebhook(params: AgentWebhookParams): Promise<boolean> {
576
582
  const { req } = params;
577
583
 
578
- if (req.method === "GET") {
579
- return handleUrlVerification(req, params.res, params.agent);
580
- }
581
-
582
584
  if (req.method === "POST") {
583
585
  return handleMessageCallback(params);
584
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
+ });