@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
@@ -29,23 +29,23 @@ export function getDynamicAgentConfig(config: OpenClawConfig): DynamicAgentConfi
29
29
  };
30
30
  }
31
31
 
32
+ function sanitizeDynamicIdPart(value: string): string {
33
+ return String(value)
34
+ .trim()
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9_-]/g, "_");
37
+ }
38
+
32
39
  /**
33
40
  * **generateAgentId (生成动态 Agent ID)**
34
41
  *
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"
42
+ * 根据账号 + 聊天类型 + 对端 ID 生成确定性的 Agent ID,避免多账号串会话。
43
+ * 格式: wecom-{accountId}-{type}-{sanitizedPeerId}
43
44
  */
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}`;
45
+ export function generateAgentId(chatType: "dm" | "group", peerId: string, accountId?: string): string {
46
+ const sanitizedPeer = sanitizeDynamicIdPart(peerId) || "unknown";
47
+ const sanitizedAccountId = sanitizeDynamicIdPart(accountId ?? "default") || "default";
48
+ return `wecom-${sanitizedAccountId}-${chatType}-${sanitizedPeer}`;
49
49
  }
50
50
 
51
51
  /**
@@ -0,0 +1,200 @@
1
+ import type {
2
+ ChannelGatewayContext,
3
+ OpenClawConfig,
4
+ PluginRuntime,
5
+ } from "openclaw/plugin-sdk";
6
+
7
+ import {
8
+ detectMode,
9
+ listWecomAccountIds,
10
+ resolveWecomAccount,
11
+ resolveWecomAccountConflict,
12
+ } from "./config/index.js";
13
+ import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./monitor.js";
14
+ import type { ResolvedWecomAccount, WecomConfig } from "./types/index.js";
15
+
16
+ type AccountRouteRegistryItem = {
17
+ botPaths: string[];
18
+ agentPath?: string;
19
+ };
20
+
21
+ const accountRouteRegistry = new Map<string, AccountRouteRegistryItem>();
22
+
23
+ function logRegisteredRouteSummary(
24
+ ctx: ChannelGatewayContext<ResolvedWecomAccount>,
25
+ preferredOrder: string[],
26
+ ): void {
27
+ const seen = new Set<string>();
28
+ const orderedAccountIds = [
29
+ ...preferredOrder.filter((accountId) => accountRouteRegistry.has(accountId)),
30
+ ...Array.from(accountRouteRegistry.keys())
31
+ .filter((accountId) => !seen.has(accountId))
32
+ .sort((a, b) => a.localeCompare(b)),
33
+ ].filter((accountId) => {
34
+ if (seen.has(accountId)) return false;
35
+ seen.add(accountId);
36
+ return true;
37
+ });
38
+
39
+ const entries = orderedAccountIds
40
+ .map((accountId) => {
41
+ const routes = accountRouteRegistry.get(accountId);
42
+ if (!routes) return undefined;
43
+ const botText = routes.botPaths.length > 0 ? routes.botPaths.join(", ") : "未启用";
44
+ const agentText = routes.agentPath ?? "未启用";
45
+ return `accountId=${accountId}(Bot: ${botText};Agent: ${agentText})`;
46
+ })
47
+ .filter((entry): entry is string => Boolean(entry));
48
+ const summary = entries.length > 0 ? entries.join("; ") : "无";
49
+ ctx.log?.info(`[${ctx.account.accountId}] 已注册账号路由汇总:${summary}`);
50
+ }
51
+
52
+ function resolveExpectedRouteSummaryAccountIds(cfg: OpenClawConfig): string[] {
53
+ return listWecomAccountIds(cfg)
54
+ .filter((accountId) => {
55
+ const conflict = resolveWecomAccountConflict({ cfg, accountId });
56
+ if (conflict) return false;
57
+ const account = resolveWecomAccount({ cfg, accountId });
58
+ if (!account.enabled || !account.configured) return false;
59
+ return Boolean(account.bot?.configured || account.agent?.configured);
60
+ })
61
+ .sort((a, b) => a.localeCompare(b));
62
+ }
63
+
64
+ function waitForAbortSignal(abortSignal: AbortSignal): Promise<void> {
65
+ if (abortSignal.aborted) {
66
+ return Promise.resolve();
67
+ }
68
+ return new Promise<void>((resolve) => {
69
+ const onAbort = () => {
70
+ abortSignal.removeEventListener("abort", onAbort);
71
+ resolve();
72
+ };
73
+ abortSignal.addEventListener("abort", onAbort, { once: true });
74
+ });
75
+ }
76
+
77
+ /**
78
+ * Keeps WeCom webhook targets registered for the account lifecycle.
79
+ * The promise only settles after gateway abort/reload signals shutdown.
80
+ */
81
+ export async function monitorWecomProvider(
82
+ ctx: ChannelGatewayContext<ResolvedWecomAccount>,
83
+ ): Promise<void> {
84
+ const account = ctx.account;
85
+ const cfg = ctx.cfg as OpenClawConfig;
86
+ const expectedRouteSummaryAccountIds = resolveExpectedRouteSummaryAccountIds(cfg);
87
+ const conflict = resolveWecomAccountConflict({
88
+ cfg,
89
+ accountId: account.accountId,
90
+ });
91
+ if (conflict) {
92
+ ctx.setStatus({
93
+ accountId: account.accountId,
94
+ running: false,
95
+ configured: false,
96
+ lastError: conflict.message,
97
+ });
98
+ throw new Error(conflict.message);
99
+ }
100
+ const mode = detectMode(cfg.channels?.wecom as WecomConfig | undefined);
101
+ const matrixMode = mode === "matrix";
102
+ const bot = account.bot;
103
+ const agent = account.agent;
104
+ const botConfigured = Boolean(bot?.configured);
105
+ const agentConfigured = Boolean(agent?.configured);
106
+
107
+ if (mode === "legacy" && (botConfigured || agentConfigured)) {
108
+ if (agentConfigured && !botConfigured) {
109
+ ctx.log?.warn(
110
+ `[${account.accountId}] 检测到仍在使用单 Agent 兼容模式。建议尽快升级为多账号模式:` +
111
+ `将 channels.wecom.agent 迁移到 channels.wecom.accounts.<accountId>.agent,` +
112
+ `并设置 channels.wecom.defaultAccount。`,
113
+ );
114
+ } else {
115
+ ctx.log?.warn(
116
+ `[${account.accountId}] 检测到仍在使用单账号兼容模式。建议尽快升级为多账号模式:` +
117
+ `将 channels.wecom.bot/agent 迁移到 channels.wecom.accounts.<accountId>.bot/agent,` +
118
+ `并设置 channels.wecom.defaultAccount。`,
119
+ );
120
+ }
121
+ }
122
+
123
+ if (!botConfigured && !agentConfigured) {
124
+ ctx.log?.warn(`[${account.accountId}] wecom not configured; channel is idle`);
125
+ ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
126
+ await waitForAbortSignal(ctx.abortSignal);
127
+ return;
128
+ }
129
+
130
+ const unregisters: Array<() => void> = [];
131
+ const botPaths: string[] = [];
132
+ let agentPath: string | undefined;
133
+ try {
134
+ if (bot && botConfigured) {
135
+ const paths = matrixMode
136
+ ? [`/wecom/bot/${account.accountId}`]
137
+ : ["/wecom", "/wecom/bot"];
138
+ for (const path of paths) {
139
+ unregisters.push(
140
+ registerWecomWebhookTarget({
141
+ account: bot,
142
+ config: cfg,
143
+ runtime: ctx.runtime,
144
+ // The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
145
+ // The stored target only needs to be decrypt/verify-capable.
146
+ core: {} as PluginRuntime,
147
+ path,
148
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
149
+ }),
150
+ );
151
+ }
152
+ botPaths.push(...paths);
153
+ ctx.log?.info(`[${account.accountId}] wecom bot webhook registered at ${paths.join(", ")}`);
154
+ }
155
+
156
+ if (agent && agentConfigured) {
157
+ const path = matrixMode ? `/wecom/agent/${account.accountId}` : "/wecom/agent";
158
+ unregisters.push(
159
+ registerAgentWebhookTarget({
160
+ agent,
161
+ config: cfg,
162
+ runtime: ctx.runtime,
163
+ path,
164
+ }),
165
+ );
166
+ agentPath = path;
167
+ ctx.log?.info(`[${account.accountId}] wecom agent webhook registered at ${path}`);
168
+ }
169
+
170
+ accountRouteRegistry.set(account.accountId, { botPaths, agentPath });
171
+ const shouldLogSummary =
172
+ expectedRouteSummaryAccountIds.length <= 1 ||
173
+ expectedRouteSummaryAccountIds.every((accountId) => accountRouteRegistry.has(accountId));
174
+ if (shouldLogSummary) {
175
+ logRegisteredRouteSummary(ctx, expectedRouteSummaryAccountIds);
176
+ }
177
+
178
+ ctx.setStatus({
179
+ accountId: account.accountId,
180
+ running: true,
181
+ configured: true,
182
+ webhookPath: botConfigured
183
+ ? (matrixMode ? `/wecom/bot/${account.accountId}` : "/wecom/bot")
184
+ : (matrixMode ? `/wecom/agent/${account.accountId}` : "/wecom/agent"),
185
+ lastStartAt: Date.now(),
186
+ });
187
+
188
+ await waitForAbortSignal(ctx.abortSignal);
189
+ } finally {
190
+ for (const unregister of unregisters) {
191
+ unregister();
192
+ }
193
+ accountRouteRegistry.delete(account.accountId);
194
+ ctx.setStatus({
195
+ accountId: account.accountId,
196
+ running: false,
197
+ lastStopAt: Date.now(),
198
+ });
199
+ }
200
+ }
package/src/http.ts CHANGED
@@ -57,13 +57,28 @@ export async function wecomFetch(input: string | URL, init?: RequestInit, opts?:
57
57
 
58
58
  const initSignal = init?.signal ?? undefined;
59
59
  const signal = mergeAbortSignal({ signal: opts?.signal ?? initSignal, timeoutMs: opts?.timeoutMs });
60
+
61
+ const headers = new Headers(init?.headers ?? {});
62
+ if (!headers.has("User-Agent")) {
63
+ headers.set("User-Agent", "OpenClaw/2.0 (WeCom-Agent)");
64
+ }
65
+
60
66
  const nextInit: RequestInit & { dispatcher?: Dispatcher } = {
61
67
  ...(init ?? {}),
62
68
  ...(signal ? { signal } : {}),
63
69
  ...(dispatcher ? { dispatcher } : {}),
70
+ headers,
64
71
  };
65
72
 
66
- return undiciFetch(input, nextInit as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>;
73
+ try {
74
+ return await undiciFetch(input, nextInit as Parameters<typeof undiciFetch>[1]) as unknown as Response;
75
+ } catch (err: unknown) {
76
+ if (err instanceof Error && err.name === "TypeError" && err.message === "fetch failed") {
77
+ const cause = (err as any).cause;
78
+ console.error(`[wecom-http] fetch failed: ${input} (proxy: ${proxyUrl || "none"})${cause ? ` - cause: ${String(cause)}` : ""}`);
79
+ }
80
+ throw err;
81
+ }
67
82
  }
68
83
 
69
84
  /**
@@ -99,4 +114,3 @@ export async function readResponseBodyAsBuffer(res: Response, maxBytes?: number)
99
114
 
100
115
  return Buffer.concat(chunks.map((c) => Buffer.from(c)));
101
116
  }
102
-
package/src/media.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { decryptWecomMedia } from "./media.js";
2
+ import { decryptWecomMedia, decryptWecomMediaWithMeta } from "./media.js";
3
3
  import { WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
4
4
  import crypto from "node:crypto";
5
5
 
@@ -52,4 +52,31 @@ describe("decryptWecomMedia", () => {
52
52
  it("should fail if key is invalid", async () => {
53
53
  await expect(decryptWecomMedia("http://url", "invalid-key")).rejects.toThrow();
54
54
  });
55
+
56
+ it("should return source metadata when using decryptWecomMediaWithMeta", async () => {
57
+ const aesKeyBase64 = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa=";
58
+ const aesKey = Buffer.from(aesKeyBase64 + "=", "base64");
59
+ const iv = aesKey.subarray(0, 16);
60
+ const originalData = Buffer.from("meta test", "utf8");
61
+ const padded = pkcs7Pad(originalData, WECOM_PKCS7_BLOCK_SIZE);
62
+ const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
63
+ cipher.setAutoPadding(false);
64
+ const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
65
+
66
+ undiciFetch.mockResolvedValue(
67
+ new Response(encrypted, {
68
+ status: 200,
69
+ headers: {
70
+ "content-type": "application/octet-stream; charset=binary",
71
+ "content-disposition": "attachment; filename*=UTF-8''report%20v1.docx",
72
+ },
73
+ }),
74
+ );
75
+
76
+ const decrypted = await decryptWecomMediaWithMeta("http://mock.url/media?id=1", aesKeyBase64);
77
+ expect(decrypted.buffer.toString("utf8")).toBe("meta test");
78
+ expect(decrypted.sourceContentType).toBe("application/octet-stream");
79
+ expect(decrypted.sourceFilename).toBe("report v1.docx");
80
+ expect(decrypted.sourceUrl).toBe("http://mock.url/media?id=1");
81
+ });
55
82
  });
package/src/media.ts CHANGED
@@ -2,6 +2,41 @@ import crypto from "node:crypto";
2
2
  import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
3
3
  import { readResponseBodyAsBuffer, wecomFetch, type WecomHttpOptions } from "./http.js";
4
4
 
5
+ export type DecryptedWecomMedia = {
6
+ buffer: Buffer;
7
+ sourceContentType?: string;
8
+ sourceFilename?: string;
9
+ sourceUrl?: string;
10
+ };
11
+
12
+ function normalizeMime(contentType?: string | null): string | undefined {
13
+ const raw = String(contentType ?? "").trim();
14
+ if (!raw) return undefined;
15
+ return raw.split(";")[0]?.trim().toLowerCase() || undefined;
16
+ }
17
+
18
+ function extractFilenameFromContentDisposition(disposition?: string | null): string | undefined {
19
+ const raw = String(disposition ?? "").trim();
20
+ if (!raw) return undefined;
21
+
22
+ const star = raw.match(/filename\*\s*=\s*([^;]+)/i);
23
+ if (star?.[1]) {
24
+ const v = star[1].trim().replace(/^UTF-8''/i, "").replace(/^"(.*)"$/, "$1");
25
+ try {
26
+ const decoded = decodeURIComponent(v);
27
+ if (decoded.trim()) return decoded.trim();
28
+ } catch { /* ignore */ }
29
+ if (v.trim()) return v.trim();
30
+ }
31
+
32
+ const plain = raw.match(/filename\s*=\s*([^;]+)/i);
33
+ if (plain?.[1]) {
34
+ const v = plain[1].trim().replace(/^"(.*)"$/, "$1").trim();
35
+ if (v) return v;
36
+ }
37
+ return undefined;
38
+ }
39
+
5
40
  /**
6
41
  * **decryptWecomMedia (解密企业微信媒体文件)**
7
42
  *
@@ -28,11 +63,29 @@ export async function decryptWecomMediaWithHttp(
28
63
  encodingAESKey: string,
29
64
  params?: { maxBytes?: number; http?: WecomHttpOptions },
30
65
  ): Promise<Buffer> {
66
+ const decrypted = await decryptWecomMediaWithMeta(url, encodingAESKey, params);
67
+ return decrypted.buffer;
68
+ }
69
+
70
+ /**
71
+ * **decryptWecomMediaWithMeta (解密企业微信媒体并返回源信息)**
72
+ *
73
+ * 在返回解密结果的同时,保留下载响应中的元信息(content-type / filename / final url),
74
+ * 供上层更准确地推断文件后缀和 MIME。
75
+ */
76
+ export async function decryptWecomMediaWithMeta(
77
+ url: string,
78
+ encodingAESKey: string,
79
+ params?: { maxBytes?: number; http?: WecomHttpOptions },
80
+ ): Promise<DecryptedWecomMedia> {
31
81
  // 1. Download encrypted content
32
82
  const res = await wecomFetch(url, undefined, { ...params?.http, timeoutMs: params?.http?.timeoutMs ?? 15_000 });
33
83
  if (!res.ok) {
34
84
  throw new Error(`failed to download media: ${res.status}`);
35
85
  }
86
+ const sourceContentType = normalizeMime(res.headers.get("content-type"));
87
+ const sourceFilename = extractFilenameFromContentDisposition(res.headers.get("content-disposition"));
88
+ const sourceUrl = res.url || url;
36
89
  const encryptedData = await readResponseBodyAsBuffer(res, params?.maxBytes);
37
90
 
38
91
  // 2. Prepare Key and IV
@@ -51,5 +104,10 @@ export async function decryptWecomMediaWithHttp(
51
104
  // Note: Unlike msg bodies, usually removing PKCS#7 padding is enough for media files.
52
105
  // The Python SDK logic: pad_len = decrypted_data[-1]; decrypted_data = decrypted_data[:-pad_len]
53
106
  // Our pkcs7Unpad function does exactly this + validation.
54
- return pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
107
+ return {
108
+ buffer: pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE),
109
+ sourceContentType,
110
+ sourceFilename,
111
+ sourceUrl,
112
+ };
55
113
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test, vi } from "vitest";
2
2
 
3
- import type { WecomInboundMessage } from "../types.js";
3
+ import type { WecomBotInboundMessage as WecomInboundMessage } from "../types/index.js";
4
4
  import type { WecomWebhookTarget } from "./types.js";
5
5
  import { StreamStore } from "./state.js";
6
6
 
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import type { StreamState, PendingInbound, ActiveReplyState, WecomWebhookTarget } from "./types.js";
3
- import type { WecomInboundMessage } from "../types.js";
3
+ import type { WecomBotInboundMessage as WecomInboundMessage } from "../types/index.js";
4
4
 
5
5
  // Constants
6
6
  export const LIMITS = {
@@ -1,7 +1,7 @@
1
1
 
2
2
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
3
3
  import type { ResolvedBotAccount } from "../types/index.js";
4
- import type { WecomInboundMessage } from "../types.js";
4
+ import type { WecomBotInboundMessage as WecomInboundMessage } from "../types/index.js";
5
5
 
6
6
  /**
7
7
  * **WecomRuntimeEnv (运行时环境)**
@@ -45,6 +45,7 @@ function createMockResponse(): ServerResponse {
45
45
 
46
46
  describe("Monitor Active Features", () => {
47
47
  let capturedDeliver: ((payload: { text: string }) => Promise<void>) | undefined;
48
+ let unregisterTarget: (() => void) | undefined;
48
49
  let mockCore: any;
49
50
  let msgSeq = 0;
50
51
  let senderUserId = "";
@@ -109,7 +110,7 @@ describe("Monitor Active Features", () => {
109
110
  return;
110
111
  }
111
112
  },
112
- routing: { resolveAgentRoute: () => ({ agentId: "1", sessionKey: "1", accountId: "1" }) },
113
+ routing: { resolveAgentRoute: () => ({ agentId: "1", sessionKey: "1", accountId: "default" }) },
113
114
  session: {
114
115
  resolveStorePath: () => "",
115
116
  readSessionUpdatedAt: () => 0,
@@ -121,8 +122,8 @@ describe("Monitor Active Features", () => {
121
122
 
122
123
  vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore);
123
124
 
124
- registerWecomWebhookTarget({
125
- account: { accountId: "1", enabled: true, configured: true, token: "T", encodingAESKey: validKey, receiveId: "R", config: {} as any },
125
+ unregisterTarget = registerWecomWebhookTarget({
126
+ account: { accountId: "default", enabled: true, configured: true, token: "T", encodingAESKey: validKey, receiveId: "R", config: {} as any },
126
127
  config: {
127
128
  channels: {
128
129
  wecom: {
@@ -144,6 +145,8 @@ describe("Monitor Active Features", () => {
144
145
  });
145
146
 
146
147
  afterEach(() => {
148
+ unregisterTarget?.();
149
+ unregisterTarget = undefined;
147
150
  vi.useRealTimers();
148
151
  });
149
152
 
@@ -187,14 +190,17 @@ describe("Monitor Active Features", () => {
187
190
  undiciFetch.mockResolvedValue(new Response("ok", { status: 200 }));
188
191
  await sendActiveMessage(streamId, "Active Hello");
189
192
 
190
- expect(undiciFetch).toHaveBeenCalledWith(
191
- "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
193
+ expect(undiciFetch).toHaveBeenCalled();
194
+ const [url, init] = undiciFetch.mock.calls.at(-1)! as [string, RequestInit];
195
+ expect(url).toBe("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key");
196
+ expect(init).toEqual(
192
197
  expect.objectContaining({
193
198
  method: "POST",
194
- headers: expect.objectContaining({ "Content-Type": "application/json" }),
195
199
  body: JSON.stringify({ msgtype: "text", text: { content: "Active Hello" } }),
196
200
  }),
197
201
  );
202
+ const headers = new Headers(init.headers);
203
+ expect(headers.get("content-type")).toBe("application/json");
198
204
  });
199
205
 
200
206
  it("should fallback non-image media to agent DM (and push a Chinese prompt)", async () => {
@@ -234,6 +240,6 @@ describe("Monitor Active Features", () => {
234
240
  expect(undiciFetch).toHaveBeenCalled();
235
241
  });
236
242
 
237
- // 注:本机路径(/Users/... 或 /tmp/...)短路发图逻辑属于运行态特性,
243
+ // 注:本机路径(/Users/...、/tmp/...、/root/...、/home/...)短路发图逻辑属于运行态特性,
238
244
  // 单测在 fake timers + module singleton 状态下容易引入脆弱性;这里优先覆盖更关键的兜底链路与去重逻辑。
239
245
  });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { shouldProcessBotInboundMessage } from "./monitor.js";
4
+
5
+ describe("shouldProcessBotInboundMessage", () => {
6
+ it("skips payloads without sender id", () => {
7
+ const result = shouldProcessBotInboundMessage({
8
+ msgtype: "text",
9
+ from: {},
10
+ text: { content: "hello" },
11
+ });
12
+ expect(result.shouldProcess).toBe(false);
13
+ expect(result.reason).toBe("missing_sender");
14
+ });
15
+
16
+ it("skips system sender payloads", () => {
17
+ const result = shouldProcessBotInboundMessage({
18
+ msgtype: "text",
19
+ from: { userid: "sys" },
20
+ text: { content: "hello" },
21
+ });
22
+ expect(result.shouldProcess).toBe(false);
23
+ expect(result.reason).toBe("system_sender");
24
+ });
25
+
26
+ it("skips group payloads without chatid", () => {
27
+ const result = shouldProcessBotInboundMessage({
28
+ msgtype: "text",
29
+ chattype: "group",
30
+ from: { userid: "zhangsan" },
31
+ text: { content: "hello" },
32
+ });
33
+ expect(result.shouldProcess).toBe(false);
34
+ expect(result.reason).toBe("missing_chatid");
35
+ });
36
+
37
+ it("accepts normal direct-user messages", () => {
38
+ const result = shouldProcessBotInboundMessage({
39
+ msgtype: "text",
40
+ chattype: "single",
41
+ from: { userid: "zhangsan" },
42
+ text: { content: "hello" },
43
+ });
44
+ expect(result.shouldProcess).toBe(true);
45
+ expect(result.reason).toBe("user_message");
46
+ expect(result.senderUserId).toBe("zhangsan");
47
+ expect(result.chatId).toBe("zhangsan");
48
+ });
49
+
50
+ it("accepts normal group messages with chatid", () => {
51
+ const result = shouldProcessBotInboundMessage({
52
+ msgtype: "text",
53
+ chattype: "group",
54
+ chatid: "wr123",
55
+ from: { userid: "zhangsan" },
56
+ text: { content: "hello" },
57
+ });
58
+ expect(result.shouldProcess).toBe(true);
59
+ expect(result.reason).toBe("user_message");
60
+ expect(result.senderUserId).toBe("zhangsan");
61
+ expect(result.chatId).toBe("wr123");
62
+ });
63
+ });