@yanhaidao/wecom 2.3.160 → 2.3.180

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 (39) hide show
  1. package/README.md +235 -399
  2. package/SKILLS_CAL.md +895 -0
  3. package/SKILLS_DOC.md +2136 -0
  4. package/changelog/v2.3.18.md +22 -0
  5. package/index.ts +39 -3
  6. package/package.json +2 -3
  7. package/src/agent/handler.event-filter.test.ts +11 -0
  8. package/src/agent/handler.ts +732 -643
  9. package/src/app/account-runtime.ts +46 -20
  10. package/src/app/index.ts +19 -1
  11. package/src/capability/calendar/SKILLS_CHECKLIST.md +251 -0
  12. package/src/capability/calendar/client.ts +815 -0
  13. package/src/capability/calendar/index.ts +3 -0
  14. package/src/capability/calendar/schema.ts +417 -0
  15. package/src/capability/calendar/tool.ts +417 -0
  16. package/src/capability/calendar/types.ts +309 -0
  17. package/src/capability/doc/client.ts +567 -62
  18. package/src/capability/doc/schema.ts +419 -318
  19. package/src/capability/doc/tool.ts +1510 -1178
  20. package/src/capability/doc/types.ts +130 -14
  21. package/src/capability/mcp/index.ts +10 -0
  22. package/src/capability/mcp/schema.ts +107 -0
  23. package/src/capability/mcp/tool.ts +170 -0
  24. package/src/capability/mcp/transport.ts +394 -0
  25. package/src/channel.ts +70 -28
  26. package/src/config/schema.ts +71 -102
  27. package/src/outbound.test.ts +91 -14
  28. package/src/outbound.ts +143 -30
  29. package/src/runtime/reply-orchestrator.test.ts +35 -2
  30. package/src/runtime/reply-orchestrator.ts +14 -2
  31. package/src/runtime/session-manager.ts +20 -6
  32. package/src/runtime/source-registry.ts +165 -0
  33. package/src/transport/bot-ws/media.ts +269 -0
  34. package/src/transport/bot-ws/reply.test.ts +85 -17
  35. package/src/transport/bot-ws/reply.ts +109 -21
  36. package/src/transport/bot-ws/sdk-adapter.test.ts +64 -1
  37. package/src/transport/bot-ws/sdk-adapter.ts +88 -12
  38. package/.claude/settings.local.json +0 -11
  39. package/docs/update-content-fix.md +0 -135
@@ -1,114 +1,83 @@
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
+ }
26
12
 
27
- const networkSchema = z
28
- .object({
29
- egressProxyUrl: z.string().optional(),
30
- })
31
- .optional();
13
+ export interface NetworkConfig {
14
+ egressProxyUrl?: string;
15
+ }
32
16
 
33
- const routingSchema = z
34
- .object({
35
- failClosedOnDefaultRoute: z.boolean().optional(),
36
- })
37
- .optional();
17
+ export interface RoutingConfig {
18
+ failClosedOnDefaultRoute?: boolean;
19
+ }
38
20
 
39
- const botWsSchema = z
40
- .object({
41
- botId: z.string(),
42
- secret: z.string(),
43
- })
44
- .optional();
21
+ export interface BotWsConfig {
22
+ botId: string;
23
+ secret: string;
24
+ }
45
25
 
46
- const botWebhookSchema = z
47
- .object({
48
- token: z.string(),
49
- encodingAESKey: z.string(),
50
- receiveId: z.string().optional(),
51
- })
52
- .optional();
26
+ export interface BotWebhookConfig {
27
+ token: string;
28
+ encodingAESKey: string;
29
+ receiveId?: string;
30
+ }
53
31
 
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();
32
+ export interface BotConfig {
33
+ primaryTransport?: "ws" | "webhook";
34
+ streamPlaceholderContent?: string;
35
+ welcomeText?: string;
36
+ dm?: DmConfig;
37
+ aibotid?: string;
38
+ botIds?: string[];
39
+ ws?: BotWsConfig;
40
+ webhook?: BotWebhookConfig;
41
+ }
66
42
 
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();
43
+ export interface AgentConfig {
44
+ corpId: string;
45
+ agentSecret?: string;
46
+ corpSecret?: string;
47
+ agentId?: number | string;
48
+ token: string;
49
+ encodingAESKey: string;
50
+ welcomeText?: string;
51
+ dm?: DmConfig;
52
+ }
83
53
 
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();
54
+ export interface DynamicAgentsConfig {
55
+ enabled?: boolean;
56
+ dmCreateAgent?: boolean;
57
+ groupEnabled?: boolean;
58
+ adminUsers?: string[];
59
+ }
92
60
 
93
- const accountSchema = z.object({
94
- enabled: z.boolean().optional(),
95
- name: z.string().optional(),
96
- bot: botSchema,
97
- agent: agentSchema,
98
- });
61
+ export interface AccountConfig {
62
+ enabled?: boolean;
63
+ name?: string;
64
+ bot?: BotConfig;
65
+ agent?: AgentConfig;
66
+ }
99
67
 
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
- );
68
+ export interface WecomConfigInput {
69
+ enabled?: boolean;
70
+ bot?: BotConfig;
71
+ agent?: AgentConfig;
72
+ accounts?: Record<string, AccountConfig>;
73
+ defaultAccount?: string;
74
+ media?: MediaConfig;
75
+ network?: NetworkConfig;
76
+ routing?: RoutingConfig;
77
+ dynamicAgents?: DynamicAgentsConfig;
78
+ }
113
79
 
114
- export type WecomConfigInput = z.infer<typeof WecomConfigSchema>;
80
+ /**
81
+ * @deprecated No longer a Zod schema. Kept as a type-only export for backward compatibility.
82
+ */
83
+ 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,70 @@ 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
+ mediaUrl: "https://example.com/media.png",
343
+ mediaLocalRoots: undefined,
344
+ text: "caption",
282
345
  });
346
+ expect(api.sendMedia).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it("falls back to Agent media when Bot WS media delivery returns a non-fatal failure", async () => {
350
+ const { wecomOutbound } = await import("./outbound.js");
351
+ const runtime = await import("./runtime.js");
352
+ const api = await import("./transport/agent-api/core.js");
353
+ const sendMedia = vi.fn().mockResolvedValue({ ok: false, error: "upload failed" });
354
+ runtime.registerBotWsPushHandle(
355
+ "default",
356
+ createBotWsHandle({
357
+ sendMedia,
358
+ }),
359
+ );
283
360
  (api.uploadMedia as any).mockResolvedValue("media-1");
284
361
  (api.sendMedia as any).mockResolvedValue(undefined);
285
362
  (api.sendMedia as any).mockClear();
@@ -321,8 +398,8 @@ describe("wecomOutbound", () => {
321
398
  mediaUrl: "https://example.com/media.png",
322
399
  } as any);
323
400
 
401
+ expect(sendMedia).toHaveBeenCalledTimes(1);
324
402
  expect(api.sendMedia).toHaveBeenCalledTimes(1);
325
- expect(sendMarkdown).not.toHaveBeenCalled();
326
403
  });
327
404
 
328
405
  it("uses account-scoped agent config in matrix mode", async () => {
package/src/outbound.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/plugin-sdk";
2
-
3
- import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
4
2
  import { WecomAgentDeliveryService } from "./capability/agent/index.js";
3
+ import {
4
+ resolveWecomAccount,
5
+ resolveWecomAccountConflict,
6
+ resolveWecomAccounts,
7
+ } from "./config/index.js";
5
8
  import { getAccountRuntime, getBotWsPushHandle, getWecomRuntime } from "./runtime.js";
6
9
  import { resolveScopedWecomTarget } from "./target.js";
7
10
 
@@ -49,7 +52,9 @@ function resolveAgentConfigOrThrow(params: {
49
52
  );
50
53
  }
51
54
  // 注意:不要在日志里输出 corpSecret 等敏感信息
52
- getAccountRuntime(account.accountId)?.log.info?.(`[wecom-outbound] Using agent config: accountId=${account.accountId}, corpId=${account.corpId}, agentId=${account.agentId}`);
55
+ getAccountRuntime(account.accountId)?.log.info?.(
56
+ `[wecom-outbound] Using agent config: accountId=${account.accountId}, corpId=${account.corpId}, agentId=${account.agentId}`,
57
+ );
53
58
  return account;
54
59
  }
55
60
 
@@ -89,7 +94,13 @@ function shouldPreferBotWsOutbound(params: {
89
94
  accountId: params.accountId,
90
95
  });
91
96
  return {
92
- preferred: !isExplicitAgentTarget(params.to) && Boolean(account.bot?.configured && account.bot.primaryTransport === "ws" && account.bot.wsConfigured),
97
+ preferred:
98
+ !isExplicitAgentTarget(params.to) &&
99
+ Boolean(
100
+ account.bot?.configured &&
101
+ account.bot.primaryTransport === "ws" &&
102
+ account.bot.wsConfigured,
103
+ ),
93
104
  accountId: account.accountId,
94
105
  };
95
106
  }
@@ -122,12 +133,66 @@ async function sendTextViaBotWs(params: {
122
133
  `WeCom outbound account=${accountId} is configured for Bot WS active push, but the WS transport is not connected.`,
123
134
  );
124
135
  }
125
- console.log(`[wecom-outbound] Sending Bot WS active message to target=${String(params.to ?? "")} chatId=${chatId} (len=${params.text.length})`);
136
+ console.log(
137
+ `[wecom-outbound] Sending Bot WS active message to target=${String(params.to ?? "")} chatId=${chatId} (len=${params.text.length})`,
138
+ );
126
139
  await handle.sendMarkdown(chatId, params.text);
127
140
  console.log(`[wecom-outbound] Successfully sent Bot WS active message to ${chatId}`);
128
141
  return true;
129
142
  }
130
143
 
144
+ async function sendMediaViaBotWs(params: {
145
+ cfg: ChannelOutboundContext["cfg"];
146
+ accountId?: string | null;
147
+ to: string | undefined;
148
+ mediaUrl: string;
149
+ text?: string;
150
+ mediaLocalRoots?: readonly string[];
151
+ }): Promise<{
152
+ attempted: boolean;
153
+ sent: boolean;
154
+ reason?: string;
155
+ }> {
156
+ const { preferred, accountId } = shouldPreferBotWsOutbound(params);
157
+ if (!preferred) {
158
+ return { attempted: false, sent: false };
159
+ }
160
+ const chatId = resolveBotWsChatTarget({
161
+ to: params.to,
162
+ accountId,
163
+ });
164
+ if (!chatId) {
165
+ return { attempted: false, sent: false };
166
+ }
167
+ const handle = getBotWsPushHandle(accountId);
168
+ if (!handle) {
169
+ throw new Error(
170
+ `WeCom outbound account=${accountId} is configured for Bot WS active push, but no live WS runtime is registered.`,
171
+ );
172
+ }
173
+ if (!handle.isConnected()) {
174
+ throw new Error(
175
+ `WeCom outbound account=${accountId} is configured for Bot WS active push, but the WS transport is not connected.`,
176
+ );
177
+ }
178
+ console.log(
179
+ `[wecom-outbound] Sending Bot WS media to target=${String(params.to ?? "")} chatId=${chatId} media=${params.mediaUrl}`,
180
+ );
181
+ const result = await handle.sendMedia({
182
+ chatId,
183
+ mediaUrl: params.mediaUrl,
184
+ text: params.text,
185
+ mediaLocalRoots: params.mediaLocalRoots,
186
+ });
187
+ if (result.ok) {
188
+ console.log(`[wecom-outbound] Successfully sent Bot WS media to ${chatId}`);
189
+ return { attempted: true, sent: true };
190
+ }
191
+ const reason = result.rejectReason || result.error || "unknown";
192
+ console.warn(`[wecom-outbound] Bot WS media failed for ${chatId}: ${reason}`);
193
+ return { attempted: true, sent: false, reason };
194
+ }
195
+
131
196
  export const wecomOutbound: ChannelOutboundAdapter = {
132
197
  deliveryMode: "direct",
133
198
  chunkerMode: "text",
@@ -155,8 +220,7 @@ export const wecomOutbound: ChannelOutboundAdapter = {
155
220
  const trimmed = String(outgoingText ?? "").trim();
156
221
  const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
157
222
  const isAgentSessionTarget = rawTo.startsWith("wecom-agent:");
158
- const looksLikeNewSessionAck =
159
- /new session started/i.test(trimmed) && /model:/i.test(trimmed);
223
+ const looksLikeNewSessionAck = /new session started/i.test(trimmed) && /model:/i.test(trimmed);
160
224
 
161
225
  if (looksLikeNewSessionAck) {
162
226
  if (!isAgentSessionTarget) {
@@ -174,7 +238,7 @@ export const wecomOutbound: ChannelOutboundAdapter = {
174
238
 
175
239
  let sentViaBotWs = false;
176
240
  let agent: any = null;
177
-
241
+
178
242
  try {
179
243
  sentViaBotWs = await sendTextViaBotWs({
180
244
  cfg,
@@ -185,7 +249,9 @@ export const wecomOutbound: ChannelOutboundAdapter = {
185
249
  if (!sentViaBotWs) {
186
250
  // Defer Agent resolution until needed for fallback
187
251
  agent = resolveAgentConfigOrThrow({ cfg, accountId });
188
- getAccountRuntime(agent.accountId)?.log.info?.(`[wecom-outbound] Sending text to target=${String(to ?? "")} (len=${outgoingText.length})`);
252
+ getAccountRuntime(agent.accountId)?.log.info?.(
253
+ `[wecom-outbound] Sending text to target=${String(to ?? "")} (len=${outgoingText.length})`,
254
+ );
189
255
  const deliveryService = new WecomAgentDeliveryService(agent);
190
256
  await deliveryService.sendText({
191
257
  to,
@@ -195,7 +261,9 @@ export const wecomOutbound: ChannelOutboundAdapter = {
195
261
  }
196
262
  } catch (err) {
197
263
  if (agent) {
198
- getAccountRuntime(agent.accountId)?.log.error?.(`[wecom-outbound] Failed to send text to ${String(to ?? "")}: ${err instanceof Error ? err.message : String(err)}`);
264
+ getAccountRuntime(agent.accountId)?.log.error?.(
265
+ `[wecom-outbound] Failed to send text to ${String(to ?? "")}: ${err instanceof Error ? err.message : String(err)}`,
266
+ );
199
267
  }
200
268
  throw err;
201
269
  }
@@ -206,18 +274,37 @@ export const wecomOutbound: ChannelOutboundAdapter = {
206
274
  timestamp: Date.now(),
207
275
  };
208
276
  },
209
- sendMedia: async ({ cfg, to, text, mediaUrl, accountId }: ChannelOutboundContext) => {
277
+ sendMedia: async ({
278
+ cfg,
279
+ to,
280
+ text,
281
+ mediaUrl,
282
+ accountId,
283
+ mediaLocalRoots,
284
+ }: ChannelOutboundContext) => {
210
285
  // signal removed - not supported in current SDK
286
+ if (!mediaUrl) {
287
+ throw new Error("WeCom outbound requires mediaUrl.");
288
+ }
211
289
 
212
- const { preferred } = shouldPreferBotWsOutbound({ cfg, accountId, to });
213
- if (preferred) {
214
- console.log(`[wecom-outbound] Bot WS active push does not support outbound media; falling back to Agent for target=${String(to ?? "")}`);
290
+ const botWs = await sendMediaViaBotWs({
291
+ cfg,
292
+ accountId,
293
+ to,
294
+ text,
295
+ mediaUrl,
296
+ mediaLocalRoots,
297
+ });
298
+ if (botWs.sent) {
299
+ return {
300
+ channel: "wecom",
301
+ messageId: `bot-ws-media-${Date.now()}`,
302
+ timestamp: Date.now(),
303
+ };
215
304
  }
305
+
216
306
  const agent = resolveAgentConfigOrThrow({ cfg, accountId });
217
307
  const deliveryService = new WecomAgentDeliveryService(agent);
218
- if (!mediaUrl) {
219
- throw new Error("WeCom outbound requires mediaUrl.");
220
- }
221
308
 
222
309
  let buffer: Buffer;
223
310
  let contentType: string;
@@ -246,23 +333,49 @@ export const wecomOutbound: ChannelOutboundAdapter = {
246
333
  // 根据扩展名推断 content-type
247
334
  const ext = path.extname(mediaUrl).slice(1).toLowerCase();
248
335
  const mimeTypes: Record<string, string> = {
249
- jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
250
- webp: "image/webp", bmp: "image/bmp", mp3: "audio/mpeg", wav: "audio/wav",
251
- amr: "audio/amr", mp4: "video/mp4", pdf: "application/pdf", doc: "application/msword",
336
+ jpg: "image/jpeg",
337
+ jpeg: "image/jpeg",
338
+ png: "image/png",
339
+ gif: "image/gif",
340
+ webp: "image/webp",
341
+ bmp: "image/bmp",
342
+ mp3: "audio/mpeg",
343
+ wav: "audio/wav",
344
+ amr: "audio/amr",
345
+ mp4: "video/mp4",
346
+ pdf: "application/pdf",
347
+ doc: "application/msword",
252
348
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
253
- xls: "application/vnd.ms-excel", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
254
- ppt: "application/vnd.ms-powerpoint", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
255
- txt: "text/plain", csv: "text/csv", tsv: "text/tab-separated-values", md: "text/markdown", json: "application/json",
256
- xml: "application/xml", yaml: "application/yaml", yml: "application/yaml",
257
- zip: "application/zip", rar: "application/vnd.rar", "7z": "application/x-7z-compressed",
258
- tar: "application/x-tar", gz: "application/gzip", tgz: "application/gzip",
259
- rtf: "application/rtf", odt: "application/vnd.oasis.opendocument.text",
349
+ xls: "application/vnd.ms-excel",
350
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
351
+ ppt: "application/vnd.ms-powerpoint",
352
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
353
+ txt: "text/plain",
354
+ csv: "text/csv",
355
+ tsv: "text/tab-separated-values",
356
+ md: "text/markdown",
357
+ json: "application/json",
358
+ xml: "application/xml",
359
+ yaml: "application/yaml",
360
+ yml: "application/yaml",
361
+ zip: "application/zip",
362
+ rar: "application/vnd.rar",
363
+ "7z": "application/x-7z-compressed",
364
+ tar: "application/x-tar",
365
+ gz: "application/gzip",
366
+ tgz: "application/gzip",
367
+ rtf: "application/rtf",
368
+ odt: "application/vnd.oasis.opendocument.text",
260
369
  };
261
370
  contentType = mimeTypes[ext] || "application/octet-stream";
262
- console.log(`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`);
371
+ console.log(
372
+ `[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`,
373
+ );
263
374
  }
264
375
 
265
- console.log(`[wecom-outbound] Sending media to ${String(to ?? "")} (filename=${filename}, contentType=${contentType})`);
376
+ console.log(
377
+ `[wecom-outbound] Sending media to ${String(to ?? "")} (filename=${filename}, contentType=${contentType})`,
378
+ );
266
379
 
267
380
  try {
268
381
  await deliveryService.sendMedia({
@@ -280,7 +393,7 @@ export const wecomOutbound: ChannelOutboundAdapter = {
280
393
 
281
394
  return {
282
395
  channel: "wecom",
283
- messageId: `agent-media-${Date.now()}`,
396
+ messageId: `${botWs.attempted ? "agent-fallback-media" : "agent-media"}-${Date.now()}`,
284
397
  timestamp: Date.now(),
285
398
  };
286
399
  },
@@ -1,10 +1,11 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
-
3
2
  import { dispatchRuntimeReply } from "./reply-orchestrator.js";
4
3
 
5
4
  describe("dispatchRuntimeReply", () => {
6
5
  it("enables block streaming for bot-ws replies", async () => {
7
- const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue(undefined);
6
+ const dispatchReplyWithBufferedBlockDispatcher = vi
7
+ .fn()
8
+ .mockResolvedValue({ queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } });
8
9
  const core = {
9
10
  channel: {
10
11
  reply: {
@@ -35,4 +36,36 @@ describe("dispatchRuntimeReply", () => {
35
36
  }),
36
37
  );
37
38
  });
39
+
40
+ it("synthesizes a final close for bot-ws when only block replies were queued", async () => {
41
+ const dispatchReplyWithBufferedBlockDispatcher = vi.fn().mockResolvedValue({
42
+ queuedFinal: false,
43
+ counts: { block: 1, final: 0, tool: 0 },
44
+ });
45
+ const deliver = vi.fn().mockResolvedValue(undefined);
46
+ const core = {
47
+ channel: {
48
+ reply: {
49
+ dispatchReplyWithBufferedBlockDispatcher,
50
+ },
51
+ },
52
+ } as any;
53
+
54
+ await dispatchRuntimeReply({
55
+ core,
56
+ cfg: {} as any,
57
+ session: { ctx: { SessionKey: "session-a" } } as any,
58
+ replyHandle: {
59
+ context: {
60
+ transport: "bot-ws",
61
+ accountId: "default",
62
+ raw: { transport: "bot-ws", envelopeType: "ws", body: {} },
63
+ },
64
+ deliver,
65
+ } as any,
66
+ });
67
+
68
+ expect(deliver).toHaveBeenCalledTimes(1);
69
+ expect(deliver).toHaveBeenCalledWith({ text: "" }, { kind: "final" });
70
+ });
38
71
  });
@@ -1,5 +1,4 @@
1
1
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
-
3
2
  import type { ReplyHandle } from "../types/index.js";
4
3
  import type { PreparedSession } from "./session-manager.js";
5
4
 
@@ -29,7 +28,7 @@ export async function dispatchRuntimeReply(params: {
29
28
  replyHandle: ReplyHandle;
30
29
  }): Promise<void> {
31
30
  const { core, cfg, session, replyHandle } = params;
32
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
31
+ const result = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
33
32
  ctx: session.ctx,
34
33
  cfg,
35
34
  replyOptions:
@@ -52,4 +51,17 @@ export async function dispatchRuntimeReply(params: {
52
51
  },
53
52
  },
54
53
  });
54
+
55
+ if (
56
+ replyHandle.context.transport === "bot-ws" &&
57
+ result &&
58
+ result.queuedFinal !== true &&
59
+ (result.counts?.block ?? 0) > 0
60
+ ) {
61
+ await dispatchReplyPayload({
62
+ replyHandle,
63
+ payload: { text: "" },
64
+ kind: "final",
65
+ });
66
+ }
55
67
  }