@yanhaidao/wecom 2.3.160 → 2.3.190

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 (49) hide show
  1. package/README.md +294 -379
  2. package/SKILLS_CAL.md +895 -0
  3. package/SKILLS_DOC.md +2288 -0
  4. package/changelog/v2.3.18.md +22 -0
  5. package/changelog/v2.3.19.md +73 -0
  6. package/index.ts +39 -3
  7. package/package.json +2 -3
  8. package/src/agent/handler.event-filter.test.ts +11 -0
  9. package/src/agent/handler.ts +732 -643
  10. package/src/app/account-runtime.ts +46 -20
  11. package/src/app/index.ts +20 -1
  12. package/src/capability/bot/stream-orchestrator.ts +1 -1
  13. package/src/capability/calendar/SKILLS_CHECKLIST.md +251 -0
  14. package/src/capability/calendar/client.ts +815 -0
  15. package/src/capability/calendar/index.ts +3 -0
  16. package/src/capability/calendar/schema.ts +417 -0
  17. package/src/capability/calendar/tool.ts +417 -0
  18. package/src/capability/calendar/types.ts +309 -0
  19. package/src/capability/doc/client.ts +788 -64
  20. package/src/capability/doc/schema.ts +419 -318
  21. package/src/capability/doc/tool.ts +1517 -1178
  22. package/src/capability/doc/types.ts +130 -14
  23. package/src/capability/mcp/index.ts +10 -0
  24. package/src/capability/mcp/schema.ts +107 -0
  25. package/src/capability/mcp/tool.ts +170 -0
  26. package/src/capability/mcp/transport.ts +394 -0
  27. package/src/channel.ts +70 -28
  28. package/src/config/index.ts +7 -1
  29. package/src/config/media.test.ts +113 -0
  30. package/src/config/media.ts +133 -6
  31. package/src/config/schema.ts +74 -102
  32. package/src/outbound.test.ts +250 -15
  33. package/src/outbound.ts +155 -30
  34. package/src/runtime/reply-orchestrator.test.ts +35 -2
  35. package/src/runtime/reply-orchestrator.ts +14 -2
  36. package/src/runtime/routing-bridge.test.ts +115 -0
  37. package/src/runtime/routing-bridge.ts +26 -1
  38. package/src/runtime/session-manager.ts +20 -6
  39. package/src/runtime/source-registry.ts +165 -0
  40. package/src/transport/bot-webhook/inbound-normalizer.ts +4 -4
  41. package/src/transport/bot-ws/media.test.ts +44 -0
  42. package/src/transport/bot-ws/media.ts +272 -0
  43. package/src/transport/bot-ws/reply.test.ts +216 -18
  44. package/src/transport/bot-ws/reply.ts +116 -21
  45. package/src/transport/bot-ws/sdk-adapter.test.ts +64 -1
  46. package/src/transport/bot-ws/sdk-adapter.ts +89 -12
  47. package/src/types/config.ts +3 -0
  48. package/.claude/settings.local.json +0 -11
  49. package/docs/update-content-fix.md +0 -135
@@ -1,14 +1,141 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import {
4
+ resolveChannelMediaMaxBytes,
5
+ resolvePreferredOpenClawTmpDir,
6
+ type OpenClawConfig,
7
+ } from "openclaw/plugin-sdk";
2
8
 
3
9
  // 默认给一个相对“够用”的上限(80MB),避免视频/较大文件频繁触发失败。
4
10
  // 仍保留上限以防止恶意大文件把进程内存打爆(下载实现会读入内存再保存)。
5
11
  export const DEFAULT_WECOM_MEDIA_MAX_BYTES = 80 * 1024 * 1024;
6
12
 
7
- export function resolveWecomMediaMaxBytes(cfg: OpenClawConfig): number {
13
+ function parsePositiveNumber(value: unknown): number | undefined {
14
+ const parsed = typeof value === "number" ? value : Number(value);
15
+ if (!Number.isFinite(parsed) || parsed <= 0) {
16
+ return undefined;
17
+ }
18
+ return parsed;
19
+ }
20
+
21
+ function resolveStateDirForWecomMedia(): string {
22
+ const stateOverride =
23
+ process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
24
+ if (stateOverride) {
25
+ return stateOverride;
26
+ }
27
+ return path.join(os.homedir(), ".openclaw");
28
+ }
29
+
30
+ function normalizeWecomLocalRoot(root: string): string | undefined {
31
+ const trimmed = root.trim();
32
+ if (!trimmed) {
33
+ return undefined;
34
+ }
35
+ return path.resolve(trimmed.replace(/^~(?=\/|$)/, os.homedir()));
36
+ }
37
+
38
+ function getWecomCommonUserMediaLocalRoots(): readonly string[] {
39
+ const home = os.homedir();
40
+ return [
41
+ path.join(home, "Desktop"),
42
+ path.join(home, "Documents"),
43
+ path.join(home, "Downloads"),
44
+ path.join(home, "Movies"),
45
+ path.join(home, "Pictures"),
46
+ ];
47
+ }
48
+
49
+ export function getWecomDefaultMediaLocalRoots(): readonly string[] {
50
+ const stateDir = path.resolve(resolveStateDirForWecomMedia());
51
+ return [
52
+ path.resolve(resolvePreferredOpenClawTmpDir()),
53
+ stateDir,
54
+ path.join(stateDir, "media"),
55
+ path.join(stateDir, "agents"),
56
+ path.join(stateDir, "workspace"),
57
+ path.join(stateDir, "sandboxes"),
58
+ ...getWecomCommonUserMediaLocalRoots(),
59
+ ];
60
+ }
61
+
62
+ export function resolveWecomConfiguredMediaLocalRoots(cfg: OpenClawConfig): readonly string[] {
63
+ const rawWecom = cfg.channels?.wecom as
64
+ | {
65
+ media?: { localRoots?: unknown };
66
+ mediaLocalRoots?: unknown;
67
+ }
68
+ | undefined;
69
+ const configured = Array.isArray(rawWecom?.media?.localRoots)
70
+ ? rawWecom.media.localRoots
71
+ : Array.isArray(rawWecom?.mediaLocalRoots)
72
+ ? rawWecom.mediaLocalRoots
73
+ : [];
74
+ return configured
75
+ .filter((root): root is string => typeof root === "string")
76
+ .map(normalizeWecomLocalRoot)
77
+ .filter((root): root is string => Boolean(root));
78
+ }
79
+
80
+ export function resolveWecomMergedMediaLocalRoots(params: {
81
+ cfg: OpenClawConfig;
82
+ baseRoots?: readonly string[];
83
+ }): readonly string[] {
84
+ const merged: string[] = [];
85
+ const seen = new Set<string>();
86
+ const pushRoot = (root: string) => {
87
+ const normalized = normalizeWecomLocalRoot(root);
88
+ if (!normalized || seen.has(normalized)) {
89
+ return;
90
+ }
91
+ seen.add(normalized);
92
+ merged.push(normalized);
93
+ };
94
+
95
+ for (const root of getWecomDefaultMediaLocalRoots()) {
96
+ pushRoot(root);
97
+ }
98
+ for (const root of params.baseRoots ?? []) {
99
+ pushRoot(root);
100
+ }
101
+ for (const root of resolveWecomConfiguredMediaLocalRoots(params.cfg)) {
102
+ pushRoot(root);
103
+ }
104
+ return merged;
105
+ }
106
+
107
+ function resolveLegacyWecomMediaMaxBytes(cfg: OpenClawConfig): number | undefined {
8
108
  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);
109
+ const bytes = parsePositiveNumber(raw);
110
+ if (bytes) {
111
+ return Math.floor(bytes);
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ export function resolveWecomMediaMaxBytes(
117
+ cfg: OpenClawConfig,
118
+ accountId?: string | null,
119
+ ): number {
120
+ const mediaMaxBytes = resolveChannelMediaMaxBytes({
121
+ cfg,
122
+ accountId,
123
+ resolveChannelLimitMb: ({ cfg, accountId }) => {
124
+ const wecom = cfg.channels?.wecom as
125
+ | {
126
+ mediaMaxMb?: unknown;
127
+ accounts?: Record<string, { mediaMaxMb?: unknown }>;
128
+ }
129
+ | undefined;
130
+ const accountLimitMb = parsePositiveNumber(wecom?.accounts?.[accountId]?.mediaMaxMb);
131
+ if (accountLimitMb) {
132
+ return accountLimitMb;
133
+ }
134
+ return parsePositiveNumber(wecom?.mediaMaxMb);
135
+ },
136
+ });
137
+ if (mediaMaxBytes) {
138
+ return mediaMaxBytes;
12
139
  }
13
- return DEFAULT_WECOM_MEDIA_MAX_BYTES;
140
+ return resolveLegacyWecomMediaMaxBytes(cfg) ?? DEFAULT_WECOM_MEDIA_MAX_BYTES;
14
141
  }
@@ -1,114 +1,86 @@
1
- import { z } from "zod";
2
-
3
- function bindToJsonSchema<T extends z.ZodTypeAny>(schema: T): T {
4
- const schemaWithJson = schema as T & { toJSONSchema?: (...args: unknown[]) => unknown };
5
- if (typeof schemaWithJson.toJSONSchema === "function") {
6
- schemaWithJson.toJSONSchema = schemaWithJson.toJSONSchema.bind(schema);
7
- }
8
- return schema;
1
+ export interface DmConfig {
2
+ policy?: "pairing" | "allowlist" | "open" | "disabled";
3
+ allowFrom?: (string | number)[];
9
4
  }
10
5
 
11
- const dmSchema = z
12
- .object({
13
- policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
14
- allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
15
- })
16
- .optional();
17
-
18
- const mediaSchema = z
19
- .object({
20
- tempDir: z.string().optional(),
21
- retentionHours: z.number().optional(),
22
- cleanupOnStart: z.boolean().optional(),
23
- maxBytes: z.number().optional(),
24
- })
25
- .optional();
6
+ export interface MediaConfig {
7
+ tempDir?: string;
8
+ retentionHours?: number;
9
+ cleanupOnStart?: boolean;
10
+ maxBytes?: number;
11
+ localRoots?: string[];
12
+ }
26
13
 
27
- const networkSchema = z
28
- .object({
29
- egressProxyUrl: z.string().optional(),
30
- })
31
- .optional();
14
+ export interface NetworkConfig {
15
+ egressProxyUrl?: string;
16
+ }
32
17
 
33
- const routingSchema = z
34
- .object({
35
- failClosedOnDefaultRoute: z.boolean().optional(),
36
- })
37
- .optional();
18
+ export interface RoutingConfig {
19
+ failClosedOnDefaultRoute?: boolean;
20
+ }
38
21
 
39
- const botWsSchema = z
40
- .object({
41
- botId: z.string(),
42
- secret: z.string(),
43
- })
44
- .optional();
22
+ export interface BotWsConfig {
23
+ botId: string;
24
+ secret: string;
25
+ }
45
26
 
46
- const botWebhookSchema = z
47
- .object({
48
- token: z.string(),
49
- encodingAESKey: z.string(),
50
- receiveId: z.string().optional(),
51
- })
52
- .optional();
27
+ export interface BotWebhookConfig {
28
+ token: string;
29
+ encodingAESKey: string;
30
+ receiveId?: string;
31
+ }
53
32
 
54
- const botSchema = z
55
- .object({
56
- primaryTransport: z.enum(["ws", "webhook"]).optional(),
57
- streamPlaceholderContent: z.string().optional(),
58
- welcomeText: z.string().optional(),
59
- dm: dmSchema,
60
- aibotid: z.string().optional(),
61
- botIds: z.array(z.string()).optional(),
62
- ws: botWsSchema,
63
- webhook: botWebhookSchema,
64
- })
65
- .optional();
33
+ export interface BotConfig {
34
+ primaryTransport?: "ws" | "webhook";
35
+ streamPlaceholderContent?: string;
36
+ welcomeText?: string;
37
+ dm?: DmConfig;
38
+ aibotid?: string;
39
+ botIds?: string[];
40
+ ws?: BotWsConfig;
41
+ webhook?: BotWebhookConfig;
42
+ }
66
43
 
67
- const agentSchema = z
68
- .object({
69
- corpId: z.string(),
70
- agentSecret: z.string().optional(),
71
- corpSecret: z.string().optional(),
72
- agentId: z.union([z.number(), z.string()]).optional(),
73
- token: z.string(),
74
- encodingAESKey: z.string(),
75
- welcomeText: z.string().optional(),
76
- dm: dmSchema,
77
- })
78
- .refine((value) => Boolean(value.agentSecret?.trim() || value.corpSecret?.trim()), {
79
- path: ["agentSecret"],
80
- message: "agentSecret 不能为空",
81
- })
82
- .optional();
44
+ export interface AgentConfig {
45
+ corpId: string;
46
+ agentSecret?: string;
47
+ corpSecret?: string;
48
+ agentId?: number | string;
49
+ token: string;
50
+ encodingAESKey: string;
51
+ welcomeText?: string;
52
+ dm?: DmConfig;
53
+ }
83
54
 
84
- const dynamicAgentsSchema = z
85
- .object({
86
- enabled: z.boolean().optional(),
87
- dmCreateAgent: z.boolean().optional(),
88
- groupEnabled: z.boolean().optional(),
89
- adminUsers: z.array(z.string()).optional(),
90
- })
91
- .optional();
55
+ export interface DynamicAgentsConfig {
56
+ enabled?: boolean;
57
+ dmCreateAgent?: boolean;
58
+ groupEnabled?: boolean;
59
+ adminUsers?: string[];
60
+ }
92
61
 
93
- const accountSchema = z.object({
94
- enabled: z.boolean().optional(),
95
- name: z.string().optional(),
96
- bot: botSchema,
97
- agent: agentSchema,
98
- });
62
+ export interface AccountConfig {
63
+ enabled?: boolean;
64
+ name?: string;
65
+ mediaMaxMb?: number;
66
+ bot?: BotConfig;
67
+ agent?: AgentConfig;
68
+ }
99
69
 
100
- export const WecomConfigSchema = bindToJsonSchema(
101
- z.object({
102
- enabled: z.boolean().optional(),
103
- bot: botSchema,
104
- agent: agentSchema,
105
- accounts: z.record(z.string(), accountSchema).optional(),
106
- defaultAccount: z.string().optional(),
107
- media: mediaSchema,
108
- network: networkSchema,
109
- routing: routingSchema,
110
- dynamicAgents: dynamicAgentsSchema,
111
- }),
112
- );
70
+ export interface WecomConfigInput {
71
+ enabled?: boolean;
72
+ mediaMaxMb?: number;
73
+ bot?: BotConfig;
74
+ agent?: AgentConfig;
75
+ accounts?: Record<string, AccountConfig>;
76
+ defaultAccount?: string;
77
+ media?: MediaConfig;
78
+ network?: NetworkConfig;
79
+ routing?: RoutingConfig;
80
+ dynamicAgents?: DynamicAgentsConfig;
81
+ }
113
82
 
114
- export type WecomConfigInput = z.infer<typeof WecomConfigSchema>;
83
+ /**
84
+ * @deprecated No longer a Zod schema. Kept as a type-only export for backward compatibility.
85
+ */
86
+ export const WecomConfigSchema = undefined;
@@ -1,4 +1,5 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { BotWsPushHandle } from "./app/index.js";
2
3
 
3
4
  vi.mock("./transport/agent-api/core.js", () => ({
4
5
  sendText: vi.fn(),
@@ -7,6 +8,25 @@ vi.mock("./transport/agent-api/core.js", () => ({
7
8
  }));
8
9
 
9
10
  describe("wecomOutbound", () => {
11
+ const createBotWsHandle = (overrides: Partial<BotWsPushHandle> = {}): BotWsPushHandle => ({
12
+ isConnected: () => true,
13
+ sendMarkdown: vi.fn().mockResolvedValue(undefined),
14
+ replyCommand: vi.fn().mockResolvedValue({ errcode: 0 }),
15
+ sendMedia: vi.fn().mockResolvedValue({ ok: true, messageId: "ws-media-1" }),
16
+ ...overrides,
17
+ });
18
+
19
+ beforeEach(async () => {
20
+ const runtime = await import("./runtime.js");
21
+ runtime.setWecomRuntime({
22
+ channel: {
23
+ text: {
24
+ chunkText: (text: string) => [text],
25
+ },
26
+ },
27
+ } as any);
28
+ });
29
+
10
30
  afterEach(async () => {
11
31
  const runtime = await import("./runtime.js");
12
32
  runtime.unregisterBotWsPushHandle("default");
@@ -80,9 +100,9 @@ describe("wecomOutbound", () => {
80
100
  };
81
101
 
82
102
  // Chat ID (wr/wc) is intentionally NOT supported for Agent outbound.
83
- await expect(wecomOutbound.sendText({ cfg, to: "wr123", text: "hello" } as any)).rejects.toThrow(
84
- /不支持向群 chatId 发送/,
85
- );
103
+ await expect(
104
+ wecomOutbound.sendText({ cfg, to: "wr123", text: "hello" } as any),
105
+ ).rejects.toThrow(/不支持向群 chatId 发送/);
86
106
  expect(api.sendText).not.toHaveBeenCalled();
87
107
 
88
108
  // Test: User ID (Default)
@@ -186,10 +206,12 @@ describe("wecomOutbound", () => {
186
206
  const api = await import("./transport/agent-api/core.js");
187
207
  const sendMarkdown = vi.fn().mockResolvedValue(undefined);
188
208
  const now = vi.spyOn(Date, "now").mockReturnValue(789);
189
- runtime.registerBotWsPushHandle("acct-ws", {
190
- isConnected: () => true,
191
- sendMarkdown,
192
- });
209
+ runtime.registerBotWsPushHandle(
210
+ "acct-ws",
211
+ createBotWsHandle({
212
+ sendMarkdown,
213
+ }),
214
+ );
193
215
  (api.sendText as any).mockClear();
194
216
 
195
217
  const cfg = {
@@ -271,15 +293,228 @@ describe("wecomOutbound", () => {
271
293
  expect(api.sendText).not.toHaveBeenCalled();
272
294
  });
273
295
 
274
- it("keeps outbound media on Agent even when Bot WS is active", async () => {
296
+ it("prefers Bot WS for outbound media when ws is the active bot transport", async () => {
275
297
  const { wecomOutbound } = await import("./outbound.js");
276
298
  const runtime = await import("./runtime.js");
277
299
  const api = await import("./transport/agent-api/core.js");
278
- const sendMarkdown = vi.fn().mockResolvedValue(undefined);
279
- runtime.registerBotWsPushHandle("default", {
280
- isConnected: () => true,
281
- sendMarkdown,
300
+ const sendMedia = vi.fn().mockResolvedValue({ ok: true, messageId: "ws-media-1" });
301
+ runtime.registerBotWsPushHandle(
302
+ "default",
303
+ createBotWsHandle({
304
+ sendMedia,
305
+ }),
306
+ );
307
+ (api.uploadMedia as any).mockResolvedValue("media-1");
308
+ (api.sendMedia as any).mockResolvedValue(undefined);
309
+ (api.sendMedia as any).mockClear();
310
+
311
+ const cfg = {
312
+ channels: {
313
+ wecom: {
314
+ enabled: true,
315
+ bot: {
316
+ primaryTransport: "ws",
317
+ ws: {
318
+ botId: "bot-1",
319
+ secret: "secret-1",
320
+ },
321
+ },
322
+ agent: {
323
+ corpId: "corp",
324
+ corpSecret: "secret",
325
+ agentId: 1000002,
326
+ token: "token",
327
+ encodingAESKey: "aes",
328
+ },
329
+ },
330
+ },
331
+ };
332
+
333
+ await wecomOutbound.sendMedia({
334
+ cfg,
335
+ to: "user:zhangsan",
336
+ text: "caption",
337
+ mediaUrl: "https://example.com/media.png",
338
+ } as any);
339
+
340
+ expect(sendMedia).toHaveBeenCalledWith({
341
+ chatId: "zhangsan",
342
+ maxBytes: 80 * 1024 * 1024,
343
+ mediaUrl: "https://example.com/media.png",
344
+ mediaLocalRoots: expect.any(Array),
345
+ text: "caption",
282
346
  });
347
+ expect(api.sendMedia).not.toHaveBeenCalled();
348
+ });
349
+
350
+ it("merges configured media local roots into Bot WS sends", async () => {
351
+ const { wecomOutbound } = await import("./outbound.js");
352
+ const runtime = await import("./runtime.js");
353
+ const sendMedia = vi.fn().mockResolvedValue({ ok: true, messageId: "ws-media-merged" });
354
+ runtime.registerBotWsPushHandle(
355
+ "default",
356
+ createBotWsHandle({
357
+ sendMedia,
358
+ }),
359
+ );
360
+
361
+ const cfg = {
362
+ channels: {
363
+ wecom: {
364
+ enabled: true,
365
+ bot: {
366
+ primaryTransport: "ws",
367
+ ws: {
368
+ botId: "bot-1",
369
+ secret: "secret-1",
370
+ },
371
+ },
372
+ media: {
373
+ localRoots: ["/tmp/downloads"],
374
+ },
375
+ },
376
+ },
377
+ };
378
+
379
+ await wecomOutbound.sendMedia({
380
+ cfg,
381
+ to: "user:zhangsan",
382
+ mediaUrl: "/tmp/workspace-agent/01.png",
383
+ mediaLocalRoots: ["/tmp/workspace-agent"],
384
+ } as any);
385
+
386
+ expect(sendMedia).toHaveBeenCalledWith(
387
+ expect.objectContaining({
388
+ chatId: "zhangsan",
389
+ mediaUrl: "/tmp/workspace-agent/01.png",
390
+ mediaLocalRoots: expect.arrayContaining(["/tmp/workspace-agent", "/tmp/downloads"]),
391
+ text: undefined,
392
+ }),
393
+ );
394
+ });
395
+
396
+ it("passes account-aware mediaMaxMb to Bot WS media sends", async () => {
397
+ const { wecomOutbound } = await import("./outbound.js");
398
+ const runtime = await import("./runtime.js");
399
+ const sendMedia = vi.fn().mockResolvedValue({ ok: true, messageId: "ws-media-limit" });
400
+ runtime.registerBotWsPushHandle(
401
+ "acct-ws",
402
+ createBotWsHandle({
403
+ sendMedia,
404
+ }),
405
+ );
406
+
407
+ const cfg = {
408
+ agents: {
409
+ defaults: {
410
+ mediaMaxMb: 12,
411
+ },
412
+ },
413
+ channels: {
414
+ wecom: {
415
+ enabled: true,
416
+ mediaMaxMb: 24,
417
+ accounts: {
418
+ "acct-ws": {
419
+ enabled: true,
420
+ mediaMaxMb: 36,
421
+ bot: {
422
+ primaryTransport: "ws",
423
+ ws: {
424
+ botId: "bot-1",
425
+ secret: "secret-1",
426
+ },
427
+ },
428
+ },
429
+ },
430
+ },
431
+ },
432
+ };
433
+
434
+ await wecomOutbound.sendMedia({
435
+ cfg,
436
+ accountId: "acct-ws",
437
+ to: "user:zhangsan",
438
+ mediaUrl: "https://example.com/media.png",
439
+ } as any);
440
+
441
+ expect(sendMedia).toHaveBeenCalledWith(
442
+ expect.objectContaining({
443
+ chatId: "zhangsan",
444
+ maxBytes: 36 * 1024 * 1024,
445
+ }),
446
+ );
447
+ });
448
+
449
+ it("does not fall back to Agent media when Bot WS conversation media delivery fails", async () => {
450
+ const { wecomOutbound } = await import("./outbound.js");
451
+ const runtime = await import("./runtime.js");
452
+ const api = await import("./transport/agent-api/core.js");
453
+ const sendMedia = vi.fn().mockResolvedValue({ ok: false, error: "upload failed" });
454
+ runtime.registerBotWsPushHandle(
455
+ "default",
456
+ createBotWsHandle({
457
+ sendMedia,
458
+ }),
459
+ );
460
+ (api.uploadMedia as any).mockResolvedValue("media-1");
461
+ (api.sendMedia as any).mockResolvedValue(undefined);
462
+ (api.sendMedia as any).mockClear();
463
+ vi.stubGlobal(
464
+ "fetch",
465
+ vi.fn().mockResolvedValue({
466
+ ok: true,
467
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
468
+ headers: new Headers({ "content-type": "image/png" }),
469
+ }),
470
+ );
471
+
472
+ const cfg = {
473
+ channels: {
474
+ wecom: {
475
+ enabled: true,
476
+ bot: {
477
+ primaryTransport: "ws",
478
+ ws: {
479
+ botId: "bot-1",
480
+ secret: "secret-1",
481
+ },
482
+ },
483
+ agent: {
484
+ corpId: "corp",
485
+ corpSecret: "secret",
486
+ agentId: 1000002,
487
+ token: "token",
488
+ encodingAESKey: "aes",
489
+ },
490
+ },
491
+ },
492
+ };
493
+
494
+ await expect(
495
+ wecomOutbound.sendMedia({
496
+ cfg,
497
+ to: "user:zhangsan",
498
+ text: "caption",
499
+ mediaUrl: "https://example.com/media.png",
500
+ } as any),
501
+ ).rejects.toThrow(/Bot WS media delivery failed/i);
502
+
503
+ expect(sendMedia).toHaveBeenCalledTimes(1);
504
+ expect(api.sendMedia).not.toHaveBeenCalled();
505
+ });
506
+
507
+ it("keeps explicit agent targets on the Agent media path", async () => {
508
+ const { wecomOutbound } = await import("./outbound.js");
509
+ const runtime = await import("./runtime.js");
510
+ const api = await import("./transport/agent-api/core.js");
511
+ const sendMedia = vi.fn().mockResolvedValue({ ok: true, messageId: "ws-media-1" });
512
+ runtime.registerBotWsPushHandle(
513
+ "default",
514
+ createBotWsHandle({
515
+ sendMedia,
516
+ }),
517
+ );
283
518
  (api.uploadMedia as any).mockResolvedValue("media-1");
284
519
  (api.sendMedia as any).mockResolvedValue(undefined);
285
520
  (api.sendMedia as any).mockClear();
@@ -316,13 +551,13 @@ describe("wecomOutbound", () => {
316
551
 
317
552
  await wecomOutbound.sendMedia({
318
553
  cfg,
319
- to: "user:zhangsan",
554
+ to: "wecom-agent:default:user:zhangsan",
320
555
  text: "caption",
321
556
  mediaUrl: "https://example.com/media.png",
322
557
  } as any);
323
558
 
559
+ expect(sendMedia).not.toHaveBeenCalled();
324
560
  expect(api.sendMedia).toHaveBeenCalledTimes(1);
325
- expect(sendMarkdown).not.toHaveBeenCalled();
326
561
  });
327
562
 
328
563
  it("uses account-scoped agent config in matrix mode", async () => {