@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,16 +1,24 @@
1
- import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
-
1
+ import os from "node:os";
2
+ import path from "node:path";
3
3
  import type { WSClient } from "@wecom/aibot-node-sdk";
4
-
4
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
5
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
6
+ import { uploadAndSendBotWsMedia } from "./media.js";
5
7
  import { createBotWsReplyHandle } from "./reply.js";
6
8
 
9
+ vi.mock("./media.js", () => ({
10
+ uploadAndSendBotWsMedia: vi.fn(),
11
+ }));
12
+
7
13
  type ReplyHandleParams = Parameters<typeof createBotWsReplyHandle>[0];
8
14
 
9
15
  describe("createBotWsReplyHandle", () => {
10
16
  let mockClient: import("vitest").Mocked<WSClient>;
17
+ const uploadAndSendBotWsMediaMock = vi.mocked(uploadAndSendBotWsMedia);
11
18
 
12
- beforeEach(() => {
19
+ beforeEach(async () => {
13
20
  vi.useFakeTimers();
21
+ vi.stubEnv("OPENCLAW_STATE_DIR", "/tmp/wecom-reply-state");
14
22
  mockClient = {
15
23
  replyStream: vi.fn(),
16
24
  sendMessage: vi.fn(),
@@ -19,12 +27,25 @@ describe("createBotWsReplyHandle", () => {
19
27
  mockClient.replyStream.mockResolvedValue({} as any);
20
28
  mockClient.sendMessage.mockResolvedValue({} as any);
21
29
  mockClient.replyWelcome.mockResolvedValue({} as any);
30
+ uploadAndSendBotWsMediaMock.mockReset();
31
+ uploadAndSendBotWsMediaMock.mockResolvedValue({ ok: true, messageId: "media-1" } as any);
32
+ const runtime = await import("../../runtime.js");
33
+ runtime.setWecomRuntime({
34
+ config: {
35
+ loadConfig: () => ({
36
+ channels: {
37
+ wecom: {},
38
+ },
39
+ }),
40
+ },
41
+ } as any);
22
42
  });
23
43
 
24
44
  afterEach(() => {
25
45
  vi.clearAllTimers();
26
46
  vi.useRealTimers();
27
47
  vi.restoreAllMocks();
48
+ vi.unstubAllEnvs();
28
49
  });
29
50
 
30
51
  it("uses configured placeholder content for immediate ws ack", async () => {
@@ -43,10 +64,10 @@ describe("createBotWsReplyHandle", () => {
43
64
  vi.advanceTimersByTime(3000);
44
65
  // Let promises flush
45
66
  await Promise.resolve();
46
-
67
+
47
68
  expect(mockClient.replyStream).toHaveBeenCalledWith(
48
69
  expect.objectContaining({
49
- headers: { req_id: "req-1" }
70
+ headers: { req_id: "req-1" },
50
71
  }),
51
72
  expect.any(String),
52
73
  "正在思考...",
@@ -69,7 +90,7 @@ describe("createBotWsReplyHandle", () => {
69
90
  vi.advanceTimersByTime(3000);
70
91
  // Flush the microtasks so `placeholderInFlight` becomes false
71
92
  for (let i = 0; i < 10; i++) await Promise.resolve();
72
-
93
+
73
94
  // Now trigger the next timer
74
95
  vi.advanceTimersByTime(3000);
75
96
  for (let i = 0; i < 10; i++) await Promise.resolve();
@@ -90,7 +111,7 @@ describe("createBotWsReplyHandle", () => {
90
111
  // Ensure interval is cleared
91
112
  vi.advanceTimersByTime(6000);
92
113
  await Promise.resolve();
93
- expect(mockClient.replyStream).toHaveBeenCalledTimes(3);
114
+ expect(mockClient.replyStream).toHaveBeenCalledTimes(3);
94
115
  });
95
116
 
96
117
  it("does not auto-send placeholder when disabled", async () => {
@@ -149,6 +170,180 @@ describe("createBotWsReplyHandle", () => {
149
170
  );
150
171
  });
151
172
 
173
+ it("streams block text even when media is deferred to final", async () => {
174
+ const handle = createBotWsReplyHandle({
175
+ client: mockClient,
176
+ frame: {
177
+ headers: { req_id: "req-block-media" },
178
+ body: {},
179
+ } as unknown as ReplyHandleParams["frame"],
180
+ accountId: "default",
181
+ inboundKind: "text",
182
+ autoSendPlaceholder: false,
183
+ });
184
+
185
+ await handle.deliver(
186
+ {
187
+ text: "正文先发",
188
+ mediaUrls: ["/tmp/a.png", "/tmp/b.png"],
189
+ isReasoning: false,
190
+ },
191
+ { kind: "block" },
192
+ );
193
+
194
+ expect(mockClient.replyStream).toHaveBeenCalledWith(
195
+ expect.objectContaining({ headers: { req_id: "req-block-media" } }),
196
+ expect.any(String),
197
+ "正文先发",
198
+ false,
199
+ );
200
+ });
201
+
202
+ it("includes default global media local roots for final media sends", async () => {
203
+ const runtime = await import("../../runtime.js");
204
+ runtime.setWecomRuntime({
205
+ config: {
206
+ loadConfig: () => ({}),
207
+ },
208
+ } as any);
209
+
210
+ const handle = createBotWsReplyHandle({
211
+ client: mockClient,
212
+ frame: {
213
+ headers: { req_id: "req-final-media-roots" },
214
+ body: {
215
+ from: { userid: "hidao" },
216
+ chattype: "single",
217
+ },
218
+ } as unknown as ReplyHandleParams["frame"],
219
+ accountId: "default",
220
+ inboundKind: "text",
221
+ autoSendPlaceholder: false,
222
+ });
223
+
224
+ await handle.deliver(
225
+ {
226
+ mediaUrls: ["/Users/YanHaidao/Downloads/01.png"],
227
+ isReasoning: false,
228
+ },
229
+ { kind: "final" },
230
+ );
231
+
232
+ expect(uploadAndSendBotWsMediaMock).toHaveBeenCalledWith(
233
+ expect.objectContaining({
234
+ chatId: "hidao",
235
+ maxBytes: 80 * 1024 * 1024,
236
+ mediaUrl: "/Users/YanHaidao/Downloads/01.png",
237
+ mediaLocalRoots: expect.arrayContaining([
238
+ path.resolve(resolvePreferredOpenClawTmpDir()),
239
+ "/tmp/wecom-reply-state",
240
+ "/tmp/wecom-reply-state/media",
241
+ path.resolve(os.homedir(), "Desktop"),
242
+ path.resolve(os.homedir(), "Documents"),
243
+ path.resolve(os.homedir(), "Downloads"),
244
+ ]),
245
+ }),
246
+ );
247
+ expect(mockClient.replyStream).toHaveBeenCalledWith(
248
+ expect.objectContaining({ headers: { req_id: "req-final-media-roots" } }),
249
+ expect.any(String),
250
+ "文件已发送。",
251
+ true,
252
+ );
253
+ });
254
+
255
+ it("passes configured mediaMaxMb to final media sends", async () => {
256
+ const runtime = await import("../../runtime.js");
257
+ runtime.setWecomRuntime({
258
+ config: {
259
+ loadConfig: () => ({
260
+ agents: {
261
+ defaults: {
262
+ mediaMaxMb: 12,
263
+ },
264
+ },
265
+ channels: {
266
+ wecom: {
267
+ mediaMaxMb: 24,
268
+ accounts: {
269
+ default: {
270
+ mediaMaxMb: 40,
271
+ },
272
+ },
273
+ },
274
+ },
275
+ }),
276
+ },
277
+ } as any);
278
+
279
+ const handle = createBotWsReplyHandle({
280
+ client: mockClient,
281
+ frame: {
282
+ headers: { req_id: "req-final-media-max-bytes" },
283
+ body: {
284
+ from: { userid: "hidao" },
285
+ chattype: "single",
286
+ },
287
+ } as unknown as ReplyHandleParams["frame"],
288
+ accountId: "default",
289
+ inboundKind: "text",
290
+ autoSendPlaceholder: false,
291
+ });
292
+
293
+ await handle.deliver(
294
+ {
295
+ mediaUrls: ["/Users/YanHaidao/Downloads/01.png"],
296
+ isReasoning: false,
297
+ },
298
+ { kind: "final" },
299
+ );
300
+
301
+ expect(uploadAndSendBotWsMediaMock).toHaveBeenCalledWith(
302
+ expect.objectContaining({
303
+ chatId: "hidao",
304
+ maxBytes: 40 * 1024 * 1024,
305
+ }),
306
+ );
307
+ });
308
+
309
+ it("stops placeholder keepalive when the first block contains media", async () => {
310
+ const handle = createBotWsReplyHandle({
311
+ client: mockClient,
312
+ frame: {
313
+ headers: { req_id: "req-placeholder-media" },
314
+ body: {},
315
+ } as unknown as ReplyHandleParams["frame"],
316
+ accountId: "default",
317
+ inboundKind: "text",
318
+ placeholderContent: "正在思考...",
319
+ });
320
+
321
+ vi.advanceTimersByTime(3000);
322
+ for (let i = 0; i < 10; i++) await Promise.resolve();
323
+ expect(mockClient.replyStream).toHaveBeenCalledTimes(1);
324
+
325
+ await handle.deliver(
326
+ {
327
+ text: "正文先发",
328
+ mediaUrls: ["/tmp/a.png"],
329
+ isReasoning: false,
330
+ },
331
+ { kind: "block" },
332
+ );
333
+
334
+ vi.advanceTimersByTime(6000);
335
+ for (let i = 0; i < 10; i++) await Promise.resolve();
336
+
337
+ expect(mockClient.replyStream).toHaveBeenCalledTimes(2);
338
+ expect(mockClient.replyStream).toHaveBeenNthCalledWith(
339
+ 2,
340
+ expect.objectContaining({ headers: { req_id: "req-placeholder-media" } }),
341
+ expect.any(String),
342
+ "正文先发",
343
+ false,
344
+ );
345
+ });
346
+
152
347
  it("swallows expired stream update errors during delivery", async () => {
153
348
  const expiredError = {
154
349
  headers: { req_id: "req-expired" },
@@ -157,7 +352,7 @@ describe("createBotWsReplyHandle", () => {
157
352
  };
158
353
  mockClient.replyStream.mockRejectedValueOnce(expiredError);
159
354
  const onFail = vi.fn();
160
-
355
+
161
356
  const handle = createBotWsReplyHandle({
162
357
  client: mockClient,
163
358
  frame: {
@@ -178,7 +373,13 @@ describe("createBotWsReplyHandle", () => {
178
373
 
179
374
  it.each([
180
375
  [{ headers: { req_id: "req-invalid" }, errcode: 846605, errmsg: "invalid req_id" }],
181
- [{ headers: { req_id: "req-expired" }, errcode: 846608, errmsg: "stream message update expired (>6 minutes), cannot update" }],
376
+ [
377
+ {
378
+ headers: { req_id: "req-expired" },
379
+ errcode: 846608,
380
+ errmsg: "stream message update expired (>6 minutes), cannot update",
381
+ },
382
+ ],
182
383
  ])("does not retry error reply when the ws reply window is already closed", async (error) => {
183
384
  const onFail = vi.fn();
184
385
  const handle = createBotWsReplyHandle({
@@ -218,13 +419,10 @@ describe("createBotWsReplyHandle", () => {
218
419
  handle.deliver({ text: "Event Reply", isReasoning: false }, { kind: "final" });
219
420
  await Promise.resolve();
220
421
 
221
- expect(mockClient.sendMessage).toHaveBeenCalledWith(
222
- "alice",
223
- {
224
- msgtype: "markdown",
225
- markdown: { content: "Event Reply" },
226
- }
227
- );
422
+ expect(mockClient.sendMessage).toHaveBeenCalledWith("alice", {
423
+ msgtype: "markdown",
424
+ markdown: { content: "Event Reply" },
425
+ });
228
426
  });
229
427
 
230
428
  it("sends replyWelcome for welcome events", async () => {
@@ -246,7 +444,7 @@ describe("createBotWsReplyHandle", () => {
246
444
  {
247
445
  msgtype: "text",
248
446
  text: { content: "Hello Bob" },
249
- }
447
+ },
250
448
  );
251
449
  });
252
450
  });
@@ -1,7 +1,15 @@
1
+ import {
2
+ generateReqId,
3
+ type WsFrame,
4
+ type BaseMessage,
5
+ type EventMessage,
6
+ type WSClient,
7
+ } from "@wecom/aibot-node-sdk";
1
8
  import { formatErrorMessage } from "openclaw/plugin-sdk";
2
- import { generateReqId, type WsFrame, type BaseMessage, type EventMessage, type WSClient } from "@wecom/aibot-node-sdk";
3
-
9
+ import { resolveWecomMediaMaxBytes, resolveWecomMergedMediaLocalRoots } from "../../config/index.js";
10
+ import { getWecomRuntime } from "../../runtime.js";
4
11
  import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
12
+ import { uploadAndSendBotWsMedia } from "./media.js";
5
13
 
6
14
  const PLACEHOLDER_KEEPALIVE_MS = 3000;
7
15
  const MAX_KEEPALIVE_MS = 120 * 1000; // Force stop keepalive after 120s if ignored
@@ -32,7 +40,14 @@ function isAckTimeoutError(error: unknown): boolean {
32
40
  }
33
41
 
34
42
  function isTerminalReplyError(error: unknown): boolean {
35
- return isInvalidReqIdError(error) || isExpiredStreamUpdateError(error) || isAckTimeoutError(error);
43
+ return (
44
+ isInvalidReqIdError(error) || isExpiredStreamUpdateError(error) || isAckTimeoutError(error)
45
+ );
46
+ }
47
+
48
+ function formatMediaFailure(mediaUrl: string, error?: string, rejectReason?: string): string {
49
+ const reason = rejectReason || error || "unknown";
50
+ return `媒体发送失败:${mediaUrl} (${reason})`;
36
51
  }
37
52
 
38
53
  // Global registry to track active keepalives by peerId
@@ -54,6 +69,7 @@ export function createBotWsReplyHandle(params: {
54
69
  }): ReplyHandle {
55
70
  let streamId: string | undefined;
56
71
  let accumulatedText = "";
72
+ let deferredMediaUrls: string[] = [];
57
73
  const resolveStreamId = () => {
58
74
  streamId ||= generateReqId("stream");
59
75
  return streamId;
@@ -68,11 +84,15 @@ export function createBotWsReplyHandle(params: {
68
84
  // Extract peerId for clustering handles
69
85
  const body = params.frame.body as any;
70
86
  const peerId = String(
71
- (body?.chattype === "group" ? body?.chatid || body?.from?.userid : body?.from?.userid) || "unknown"
87
+ (body?.chattype === "group" ? body?.chatid || body?.from?.userid : body?.from?.userid) ||
88
+ "unknown",
72
89
  );
73
90
  const reqId = params.frame.headers.req_id || "unknown";
74
91
 
75
- const isEvent = params.inboundKind === "welcome" || params.inboundKind === "event" || params.inboundKind === "template-card-event";
92
+ const isEvent =
93
+ params.inboundKind === "welcome" ||
94
+ params.inboundKind === "event" ||
95
+ params.inboundKind === "template-card-event";
76
96
 
77
97
  const stopPlaceholderKeepalive = () => {
78
98
  if (placeholderKeepalive) {
@@ -83,7 +103,7 @@ export function createBotWsReplyHandle(params: {
83
103
  clearTimeout(placeholderTimeout);
84
104
  placeholderTimeout = undefined;
85
105
  }
86
-
106
+
87
107
  // Remove from registry
88
108
  const keepalives = activeKeepalivesByPeer.get(peerId);
89
109
  if (keepalives) {
@@ -107,7 +127,8 @@ export function createBotWsReplyHandle(params: {
107
127
  const sendPlaceholder = () => {
108
128
  if (streamSettled || placeholderInFlight || isEvent) return;
109
129
  placeholderInFlight = true;
110
- params.client.replyStream(params.frame, resolveStreamId(), placeholderText, false)
130
+ params.client
131
+ .replyStream(params.frame, resolveStreamId(), placeholderText, false)
111
132
  .catch((error) => {
112
133
  if (!isTerminalReplyError(error)) {
113
134
  return;
@@ -134,13 +155,27 @@ export function createBotWsReplyHandle(params: {
134
155
  }
135
156
  };
136
157
 
158
+ const mergeDeferredMediaUrls = (urls: string[]): string[] => {
159
+ if (urls.length === 0) {
160
+ return deferredMediaUrls;
161
+ }
162
+ const merged = [...deferredMediaUrls];
163
+ for (const url of urls) {
164
+ if (!merged.includes(url)) {
165
+ merged.push(url);
166
+ }
167
+ }
168
+ deferredMediaUrls = merged;
169
+ return deferredMediaUrls;
170
+ };
171
+
137
172
  if (params.autoSendPlaceholder !== false && !isEvent) {
138
173
  sendPlaceholder();
139
174
  placeholderKeepalive = setInterval(() => {
140
175
  sendPlaceholder();
141
176
  }, PLACEHOLDER_KEEPALIVE_MS);
142
-
143
- // Safety net: force stop keepalive after MAX_KEEPALIVE_MS
177
+
178
+ // Safety net: force stop keepalive after MAX_KEEPALIVE_MS
144
179
  // in case the message is completely ignored by the core and never triggers deliver/fail
145
180
  placeholderTimeout = setTimeout(() => {
146
181
  stopPlaceholderKeepalive();
@@ -183,10 +218,22 @@ export function createBotWsReplyHandle(params: {
183
218
  return;
184
219
  }
185
220
 
186
- const text = payload.text?.trim();
187
- if (!text) return;
221
+ const text = payload.text?.trim() || "";
222
+ const incomingMediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
223
+ const hasIncomingMedia = incomingMediaUrls.length > 0;
224
+ if (info.kind !== "final" && hasIncomingMedia) {
225
+ mergeDeferredMediaUrls(incomingMediaUrls);
226
+ }
227
+ const mediaUrls =
228
+ info.kind === "final" ? mergeDeferredMediaUrls(incomingMediaUrls) : incomingMediaUrls;
229
+ if (!text && mediaUrls.length === 0) {
230
+ return;
231
+ }
188
232
 
189
233
  if (info.kind === "block") {
234
+ if (!text) {
235
+ return;
236
+ }
190
237
  accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
191
238
  }
192
239
 
@@ -199,6 +246,51 @@ export function createBotWsReplyHandle(params: {
199
246
  : text
200
247
  : accumulatedText || text;
201
248
 
249
+ let finalText = outboundText;
250
+ if (info.kind === "final" && mediaUrls.length > 0) {
251
+ const cfg = getWecomRuntime().config.loadConfig();
252
+ const mediaLocalRoots = resolveWecomMergedMediaLocalRoots({ cfg });
253
+ const mediaMaxBytes = resolveWecomMediaMaxBytes(cfg, params.accountId);
254
+ const mediaFailures: string[] = [];
255
+ const mediaNotes: string[] = [];
256
+ let mediaSent = 0;
257
+ for (const mediaUrl of mediaUrls) {
258
+ const result = await uploadAndSendBotWsMedia({
259
+ wsClient: params.client,
260
+ chatId: peerId,
261
+ mediaUrl,
262
+ mediaLocalRoots,
263
+ maxBytes: mediaMaxBytes,
264
+ });
265
+ if (result.ok) {
266
+ mediaSent += 1;
267
+ if (result.downgradeNote) {
268
+ mediaNotes.push(result.downgradeNote);
269
+ }
270
+ continue;
271
+ }
272
+ mediaFailures.push(formatMediaFailure(mediaUrl, result.error, result.rejectReason));
273
+ }
274
+
275
+ if (!finalText && mediaSent > 0) {
276
+ finalText = "文件已发送。";
277
+ }
278
+ if (mediaFailures.length > 0) {
279
+ finalText = finalText
280
+ ? `${finalText}\n\n${mediaFailures.join("\n")}`
281
+ : mediaFailures.join("\n");
282
+ }
283
+ if (mediaNotes.length > 0) {
284
+ finalText = finalText
285
+ ? `${finalText}\n\n${mediaNotes.join("\n")}`
286
+ : mediaNotes.join("\n");
287
+ }
288
+ deferredMediaUrls = [];
289
+ }
290
+ if (!finalText) {
291
+ return;
292
+ }
293
+
202
294
  // Event frames do not support streaming chunks
203
295
  if (isEvent && info.kind !== "final") {
204
296
  return;
@@ -209,19 +301,19 @@ export function createBotWsReplyHandle(params: {
209
301
  if (params.inboundKind === "welcome") {
210
302
  await params.client.replyWelcome(params.frame, {
211
303
  msgtype: "text",
212
- text: { content: outboundText },
304
+ text: { content: finalText },
213
305
  });
214
306
  } else if (isEvent) {
215
307
  // Send push message for other events
216
308
  await params.client.sendMessage(peerId, {
217
309
  msgtype: "markdown",
218
- markdown: { content: outboundText },
310
+ markdown: { content: finalText },
219
311
  });
220
312
  } else {
221
313
  await params.client.replyStream(
222
314
  params.frame,
223
315
  resolveStreamId(),
224
- outboundText,
316
+ finalText,
225
317
  info.kind === "final",
226
318
  );
227
319
  }
@@ -243,17 +335,20 @@ export function createBotWsReplyHandle(params: {
243
335
  }
244
336
  const message = formatErrorMessage(error);
245
337
  const text = `WeCom WS reply failed: ${message}`;
246
-
338
+
247
339
  try {
248
340
  if (params.inboundKind === "welcome") {
249
- await params.client.replyWelcome(params.frame, { msgtype: "text", text: { content: text }});
341
+ await params.client.replyWelcome(params.frame, {
342
+ msgtype: "text",
343
+ text: { content: text },
344
+ });
250
345
  } else if (isEvent) {
251
- await params.client.sendMessage(peerId, {
252
- msgtype: "markdown",
253
- markdown: { content: text },
254
- });
346
+ await params.client.sendMessage(peerId, {
347
+ msgtype: "markdown",
348
+ markdown: { content: text },
349
+ });
255
350
  } else {
256
- await params.client.replyStream(params.frame, resolveStreamId(), text, true);
351
+ await params.client.replyStream(params.frame, resolveStreamId(), text, true);
257
352
  }
258
353
  } catch (sendError) {
259
354
  params.onFail?.(sendError);
@@ -5,6 +5,7 @@ const sdkMockState = vi.hoisted(() => {
5
5
  readonly handlers = new Map<string, Array<(payload: any) => void>>();
6
6
  readonly isConnected = true;
7
7
  readonly replyStream = vi.fn().mockResolvedValue(undefined);
8
+ readonly replyWelcome = vi.fn().mockResolvedValue(undefined);
8
9
 
9
10
  constructor(_options: unknown) {
10
11
  sdkMockState.client = this;
@@ -117,7 +118,69 @@ describe("BotWsSdkAdapter", () => {
117
118
  }),
118
119
  );
119
120
  expect(log.error).toHaveBeenCalledWith(
120
- expect.stringContaining("frame handler failed account=acc-1 reqId=req-1 message=frame exploded"),
121
+ expect.stringContaining(
122
+ "frame handler failed account=acc-1 reqId=req-1 message=frame exploded",
123
+ ),
124
+ );
125
+ expect(unhandledRejections).toHaveLength(0);
126
+ });
127
+
128
+ it("short-circuits enter_chat welcome events to a static ws welcome reply", async () => {
129
+ process.on("unhandledRejection", onUnhandledRejection);
130
+
131
+ const runtime = {
132
+ account: {
133
+ accountId: "acc-1",
134
+ bot: {
135
+ wsConfigured: true,
136
+ ws: {
137
+ botId: "bot-1",
138
+ secret: "secret-1",
139
+ },
140
+ config: {
141
+ welcomeText: "欢迎来到 WeCom",
142
+ },
143
+ },
144
+ },
145
+ handleEvent: vi.fn().mockResolvedValue(undefined),
146
+ updateTransportSession: vi.fn(),
147
+ touchTransportSession: vi.fn(),
148
+ recordOperationalIssue: vi.fn(),
149
+ };
150
+ const log = {
151
+ info: vi.fn(),
152
+ warn: vi.fn(),
153
+ error: vi.fn(),
154
+ };
155
+
156
+ new BotWsSdkAdapter(runtime as any, log as any).start();
157
+
158
+ sdkMockState.client?.emit("event", {
159
+ cmd: "aibot_event_callback",
160
+ headers: { req_id: "req-welcome" },
161
+ body: {
162
+ msgid: "msg-welcome",
163
+ msgtype: "event",
164
+ chattype: "single",
165
+ from: { userid: "user-1" },
166
+ event: { eventtype: "enter_chat" },
167
+ },
168
+ });
169
+
170
+ await waitForAsyncCallbacks();
171
+
172
+ expect(runtime.handleEvent).not.toHaveBeenCalled();
173
+ expect(sdkMockState.client?.replyWelcome).toHaveBeenCalledWith(
174
+ expect.objectContaining({
175
+ headers: { req_id: "req-welcome" },
176
+ }),
177
+ {
178
+ msgtype: "text",
179
+ text: { content: "欢迎来到 WeCom" },
180
+ },
181
+ );
182
+ expect(log.info).toHaveBeenCalledWith(
183
+ expect.stringContaining("static welcome delivered account=acc-1 messageId=msg-welcome"),
121
184
  );
122
185
  expect(unhandledRejections).toHaveLength(0);
123
186
  });