@yanhaidao/wecom 2.3.13 → 2.3.14

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.
@@ -1,31 +1,52 @@
1
- import { describe, expect, it, vi, afterEach } from "vitest";
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ import type { WSClient } from "@wecom/aibot-node-sdk";
2
4
 
3
5
  import { createBotWsReplyHandle } from "./reply.js";
4
6
 
5
7
  type ReplyHandleParams = Parameters<typeof createBotWsReplyHandle>[0];
6
8
 
7
9
  describe("createBotWsReplyHandle", () => {
10
+ let mockClient: import("vitest").Mocked<WSClient>;
11
+
12
+ beforeEach(() => {
13
+ vi.useFakeTimers();
14
+ mockClient = {
15
+ replyStream: vi.fn(),
16
+ sendMessage: vi.fn(),
17
+ replyWelcome: vi.fn(),
18
+ } as unknown as import("vitest").Mocked<WSClient>;
19
+ mockClient.replyStream.mockResolvedValue({} as any);
20
+ mockClient.sendMessage.mockResolvedValue({} as any);
21
+ mockClient.replyWelcome.mockResolvedValue({} as any);
22
+ });
23
+
8
24
  afterEach(() => {
25
+ vi.clearAllTimers();
9
26
  vi.useRealTimers();
27
+ vi.restoreAllMocks();
10
28
  });
11
29
 
12
30
  it("uses configured placeholder content for immediate ws ack", async () => {
13
- const replyStream = vi.fn().mockResolvedValue(undefined);
14
31
  createBotWsReplyHandle({
15
- client: {
16
- replyStream,
17
- } as unknown as ReplyHandleParams["client"],
32
+ client: mockClient,
18
33
  frame: {
19
34
  headers: { req_id: "req-1" },
20
- body: {},
35
+ body: { chatid: "123", chattype: "group" },
36
+ cmd: "aibot_msg_callback",
21
37
  } as unknown as ReplyHandleParams["frame"],
22
38
  accountId: "default",
39
+ inboundKind: "text",
23
40
  placeholderContent: "正在思考...",
24
41
  });
25
42
 
26
- expect(replyStream).toHaveBeenCalledWith(
43
+ vi.advanceTimersByTime(3000);
44
+ // Let promises flush
45
+ await Promise.resolve();
46
+
47
+ expect(mockClient.replyStream).toHaveBeenCalledWith(
27
48
  expect.objectContaining({
28
- headers: { req_id: "req-1" },
49
+ headers: { req_id: "req-1" }
29
50
  }),
30
51
  expect.any(String),
31
52
  "正在思考...",
@@ -34,29 +55,30 @@ describe("createBotWsReplyHandle", () => {
34
55
  });
35
56
 
36
57
  it("keeps placeholder alive until the first real ws chunk arrives", async () => {
37
- vi.useFakeTimers();
38
-
39
- const replyStream = vi.fn().mockResolvedValue(undefined);
40
58
  const handle = createBotWsReplyHandle({
41
- client: {
42
- replyStream,
43
- } as unknown as ReplyHandleParams["client"],
59
+ client: mockClient,
44
60
  frame: {
45
61
  headers: { req_id: "req-keepalive" },
46
62
  body: {},
47
63
  } as unknown as ReplyHandleParams["frame"],
48
64
  accountId: "default",
65
+ inboundKind: "text",
49
66
  placeholderContent: "正在思考...",
50
67
  });
51
68
 
52
- await vi.advanceTimersByTimeAsync(3000);
53
- expect(replyStream).toHaveBeenCalledTimes(2);
69
+ vi.advanceTimersByTime(3000);
70
+ // Flush the microtasks so `placeholderInFlight` becomes false
71
+ for (let i = 0; i < 10; i++) await Promise.resolve();
72
+
73
+ // Now trigger the next timer
74
+ vi.advanceTimersByTime(3000);
75
+ for (let i = 0; i < 10; i++) await Promise.resolve();
76
+ expect(mockClient.replyStream).toHaveBeenCalledTimes(2);
54
77
 
55
- await handle.deliver({ text: "最终回复" }, { kind: "final" });
56
- await vi.advanceTimersByTimeAsync(6000);
78
+ handle.deliver({ text: "最终回复", isReasoning: false }, { kind: "final" });
79
+ await Promise.resolve();
57
80
 
58
- expect(replyStream).toHaveBeenCalledTimes(3);
59
- expect(replyStream).toHaveBeenLastCalledWith(
81
+ expect(mockClient.replyStream).toHaveBeenCalledWith(
60
82
  expect.objectContaining({
61
83
  headers: { req_id: "req-keepalive" },
62
84
  }),
@@ -64,66 +86,63 @@ describe("createBotWsReplyHandle", () => {
64
86
  "最终回复",
65
87
  true,
66
88
  );
89
+
90
+ // Ensure interval is cleared
91
+ vi.advanceTimersByTime(6000);
92
+ await Promise.resolve();
93
+ expect(mockClient.replyStream).toHaveBeenCalledTimes(3);
67
94
  });
68
95
 
69
- it("does not auto-send placeholder when disabled", () => {
70
- const replyStream = vi.fn().mockResolvedValue(undefined);
96
+ it("does not auto-send placeholder when disabled", async () => {
71
97
  createBotWsReplyHandle({
72
- client: {
73
- replyStream,
74
- } as unknown as ReplyHandleParams["client"],
98
+ client: mockClient,
75
99
  frame: {
76
100
  headers: { req_id: "req-2" },
77
101
  body: {},
78
102
  } as unknown as ReplyHandleParams["frame"],
79
103
  accountId: "default",
104
+ inboundKind: "text",
80
105
  autoSendPlaceholder: false,
81
106
  });
82
107
 
83
- expect(replyStream).not.toHaveBeenCalled();
108
+ vi.advanceTimersByTime(3000);
109
+ await Promise.resolve();
110
+ expect(mockClient.replyStream).not.toHaveBeenCalled();
84
111
  });
85
112
 
86
113
  it("sends cumulative content for block streaming updates", async () => {
87
- const replyStream = vi.fn().mockResolvedValue(undefined);
88
114
  const handle = createBotWsReplyHandle({
89
- client: {
90
- replyStream,
91
- } as unknown as ReplyHandleParams["client"],
115
+ client: mockClient,
92
116
  frame: {
93
117
  headers: { req_id: "req-blocks" },
94
118
  body: {},
95
119
  } as unknown as ReplyHandleParams["frame"],
96
120
  accountId: "default",
121
+ inboundKind: "text",
97
122
  autoSendPlaceholder: false,
98
123
  });
99
124
 
100
- await handle.deliver({ text: "第一段" }, { kind: "block" });
101
- await handle.deliver({ text: "第二段" }, { kind: "block" });
102
- await handle.deliver({ text: "收尾" }, { kind: "final" });
125
+ await handle.deliver({ text: "第一段", isReasoning: false }, { kind: "block" });
126
+ await handle.deliver({ text: "第二段", isReasoning: false }, { kind: "block" });
127
+ await handle.deliver({ text: "收尾", isReasoning: false }, { kind: "final" });
103
128
 
104
- expect(replyStream).toHaveBeenNthCalledWith(
129
+ expect(mockClient.replyStream).toHaveBeenNthCalledWith(
105
130
  1,
106
- expect.objectContaining({
107
- headers: { req_id: "req-blocks" },
108
- }),
131
+ expect.objectContaining({ headers: { req_id: "req-blocks" } }),
109
132
  expect.any(String),
110
133
  "第一段",
111
134
  false,
112
135
  );
113
- expect(replyStream).toHaveBeenNthCalledWith(
136
+ expect(mockClient.replyStream).toHaveBeenNthCalledWith(
114
137
  2,
115
- expect.objectContaining({
116
- headers: { req_id: "req-blocks" },
117
- }),
138
+ expect.objectContaining({ headers: { req_id: "req-blocks" } }),
118
139
  expect.any(String),
119
140
  "第一段\n第二段",
120
141
  false,
121
142
  );
122
- expect(replyStream).toHaveBeenNthCalledWith(
143
+ expect(mockClient.replyStream).toHaveBeenNthCalledWith(
123
144
  3,
124
- expect.objectContaining({
125
- headers: { req_id: "req-blocks" },
126
- }),
145
+ expect.objectContaining({ headers: { req_id: "req-blocks" } }),
127
146
  expect.any(String),
128
147
  "第一段\n第二段\n收尾",
129
148
  true,
@@ -136,24 +155,24 @@ describe("createBotWsReplyHandle", () => {
136
155
  errcode: 846608,
137
156
  errmsg: "stream message update expired (>6 minutes), cannot update",
138
157
  };
139
- const replyStream = vi.fn().mockRejectedValue(expiredError);
158
+ mockClient.replyStream.mockRejectedValueOnce(expiredError);
140
159
  const onFail = vi.fn();
160
+
141
161
  const handle = createBotWsReplyHandle({
142
- client: {
143
- replyStream,
144
- } as unknown as ReplyHandleParams["client"],
162
+ client: mockClient,
145
163
  frame: {
146
164
  headers: { req_id: "req-expired" },
147
165
  body: {},
148
166
  } as unknown as ReplyHandleParams["frame"],
149
167
  accountId: "default",
168
+ inboundKind: "text",
150
169
  autoSendPlaceholder: false,
151
170
  onFail,
152
171
  });
153
172
 
154
- await expect(handle.deliver({ text: "最终回复" }, { kind: "final" })).resolves.toBeUndefined();
173
+ await handle.deliver({ text: "最终回复", isReasoning: false }, { kind: "final" });
155
174
 
156
- expect(replyStream).toHaveBeenCalledTimes(1);
175
+ expect(mockClient.replyStream).toHaveBeenCalledTimes(1);
157
176
  expect(onFail).toHaveBeenCalledWith(expiredError);
158
177
  });
159
178
 
@@ -161,24 +180,73 @@ describe("createBotWsReplyHandle", () => {
161
180
  [{ headers: { req_id: "req-invalid" }, errcode: 846605, errmsg: "invalid req_id" }],
162
181
  [{ headers: { req_id: "req-expired" }, errcode: 846608, errmsg: "stream message update expired (>6 minutes), cannot update" }],
163
182
  ])("does not retry error reply when the ws reply window is already closed", async (error) => {
164
- const replyStream = vi.fn().mockResolvedValue(undefined);
165
183
  const onFail = vi.fn();
166
184
  const handle = createBotWsReplyHandle({
167
- client: {
168
- replyStream,
169
- } as unknown as ReplyHandleParams["client"],
185
+ client: mockClient,
170
186
  frame: {
171
187
  headers: { req_id: String(error.headers.req_id) },
172
188
  body: {},
173
189
  } as unknown as ReplyHandleParams["frame"],
174
190
  accountId: "default",
191
+ inboundKind: "text",
175
192
  autoSendPlaceholder: false,
176
193
  onFail,
177
194
  });
178
195
 
179
196
  await handle.fail?.(error);
180
197
 
181
- expect(replyStream).not.toHaveBeenCalled();
198
+ expect(mockClient.replyStream).not.toHaveBeenCalled();
182
199
  expect(onFail).toHaveBeenCalledTimes(1);
183
200
  });
201
+
202
+ it("sends simple fallback message for ordinary events without placeholders", async () => {
203
+ const handle = createBotWsReplyHandle({
204
+ client: mockClient,
205
+ frame: {
206
+ headers: { req_id: "event_req" },
207
+ body: { chattype: "single", from: { userid: "alice" } },
208
+ } as unknown as ReplyHandleParams["frame"],
209
+ accountId: "default",
210
+ inboundKind: "event",
211
+ });
212
+
213
+ vi.advanceTimersByTime(3000);
214
+ await Promise.resolve();
215
+ // Events should not send stream placeholders
216
+ expect(mockClient.replyStream).not.toHaveBeenCalled();
217
+
218
+ handle.deliver({ text: "Event Reply", isReasoning: false }, { kind: "final" });
219
+ await Promise.resolve();
220
+
221
+ expect(mockClient.sendMessage).toHaveBeenCalledWith(
222
+ "alice",
223
+ {
224
+ msgtype: "markdown",
225
+ markdown: { content: "Event Reply" },
226
+ }
227
+ );
228
+ });
229
+
230
+ it("sends replyWelcome for welcome events", async () => {
231
+ const handle = createBotWsReplyHandle({
232
+ client: mockClient,
233
+ frame: {
234
+ headers: { req_id: "welcome_req" },
235
+ body: { chattype: "single", from: { userid: "bob" } },
236
+ } as unknown as ReplyHandleParams["frame"],
237
+ accountId: "default",
238
+ inboundKind: "welcome",
239
+ });
240
+
241
+ handle.deliver({ text: "Hello Bob", isReasoning: false }, { kind: "final" });
242
+ await Promise.resolve();
243
+
244
+ expect(mockClient.replyWelcome).toHaveBeenCalledWith(
245
+ expect.objectContaining({ headers: { req_id: "welcome_req" } }),
246
+ {
247
+ msgtype: "text",
248
+ text: { content: "Hello Bob" },
249
+ }
250
+ );
251
+ });
184
252
  });
@@ -4,6 +4,7 @@ import { generateReqId, type WsFrame, type BaseMessage, type EventMessage, type
4
4
  import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
5
5
 
6
6
  const PLACEHOLDER_KEEPALIVE_MS = 3000;
7
+ const MAX_KEEPALIVE_MS = 120 * 1000; // Force stop keepalive after 120s if ignored
7
8
 
8
9
  function isInvalidReqIdError(error: unknown): boolean {
9
10
  if (!error || typeof error !== "object") {
@@ -34,10 +35,18 @@ function isTerminalReplyError(error: unknown): boolean {
34
35
  return isInvalidReqIdError(error) || isExpiredStreamUpdateError(error) || isAckTimeoutError(error);
35
36
  }
36
37
 
38
+ // Global registry to track active keepalives by peerId
39
+ interface ActiveKeepalive {
40
+ reqId: string;
41
+ stop: () => void;
42
+ }
43
+ const activeKeepalivesByPeer = new Map<string, Set<ActiveKeepalive>>();
44
+
37
45
  export function createBotWsReplyHandle(params: {
38
46
  client: WSClient;
39
47
  frame: WsFrame<BaseMessage | EventMessage>;
40
48
  accountId: string;
49
+ inboundKind: string;
41
50
  placeholderContent?: string;
42
51
  autoSendPlaceholder?: boolean;
43
52
  onDeliver?: () => void;
@@ -54,20 +63,49 @@ export function createBotWsReplyHandle(params: {
54
63
  let streamSettled = false;
55
64
  let placeholderInFlight = false;
56
65
  let placeholderKeepalive: ReturnType<typeof setInterval> | undefined;
66
+ let placeholderTimeout: ReturnType<typeof setTimeout> | undefined;
67
+
68
+ // Extract peerId for clustering handles
69
+ const body = params.frame.body as any;
70
+ const peerId = String(
71
+ (body?.chattype === "group" ? body?.chatid || body?.from?.userid : body?.from?.userid) || "unknown"
72
+ );
73
+ const reqId = params.frame.headers.req_id || "unknown";
74
+
75
+ const isEvent = params.inboundKind === "welcome" || params.inboundKind === "event" || params.inboundKind === "template-card-event";
57
76
 
58
77
  const stopPlaceholderKeepalive = () => {
59
- if (!placeholderKeepalive) return;
60
- clearInterval(placeholderKeepalive);
61
- placeholderKeepalive = undefined;
78
+ if (placeholderKeepalive) {
79
+ clearInterval(placeholderKeepalive);
80
+ placeholderKeepalive = undefined;
81
+ }
82
+ if (placeholderTimeout) {
83
+ clearTimeout(placeholderTimeout);
84
+ placeholderTimeout = undefined;
85
+ }
86
+
87
+ // Remove from registry
88
+ const keepalives = activeKeepalivesByPeer.get(peerId);
89
+ if (keepalives) {
90
+ for (const ka of keepalives) {
91
+ if (ka.reqId === reqId) {
92
+ keepalives.delete(ka);
93
+ }
94
+ }
95
+ if (keepalives.size === 0) {
96
+ activeKeepalivesByPeer.delete(peerId);
97
+ }
98
+ }
62
99
  };
63
100
 
64
101
  const settleStream = () => {
102
+ if (streamSettled) return;
65
103
  streamSettled = true;
66
104
  stopPlaceholderKeepalive();
67
105
  };
68
106
 
69
107
  const sendPlaceholder = () => {
70
- if (streamSettled || placeholderInFlight) return;
108
+ if (streamSettled || placeholderInFlight || isEvent) return;
71
109
  placeholderInFlight = true;
72
110
  params.client.replyStream(params.frame, resolveStreamId(), placeholderText, false)
73
111
  .catch((error) => {
@@ -82,11 +120,39 @@ export function createBotWsReplyHandle(params: {
82
120
  });
83
121
  };
84
122
 
85
- if (params.autoSendPlaceholder !== false) {
123
+ const notifyPeerActive = () => {
124
+ // A genuine reply or reasoning is happening on THIS handle.
125
+ // It means the core SDK has chosen this handle to deliver the response.
126
+ // We can safely terminate all other orphaned keepalives for this peer to prevent infinite loops.
127
+ const keepalives = activeKeepalivesByPeer.get(peerId);
128
+ if (keepalives) {
129
+ for (const ka of keepalives) {
130
+ if (ka.reqId !== reqId) {
131
+ ka.stop();
132
+ }
133
+ }
134
+ }
135
+ };
136
+
137
+ if (params.autoSendPlaceholder !== false && !isEvent) {
86
138
  sendPlaceholder();
87
139
  placeholderKeepalive = setInterval(() => {
88
140
  sendPlaceholder();
89
141
  }, PLACEHOLDER_KEEPALIVE_MS);
142
+
143
+ // Safety net: force stop keepalive after MAX_KEEPALIVE_MS
144
+ // in case the message is completely ignored by the core and never triggers deliver/fail
145
+ placeholderTimeout = setTimeout(() => {
146
+ stopPlaceholderKeepalive();
147
+ }, MAX_KEEPALIVE_MS);
148
+
149
+ // Register keepalive
150
+ let keepalives = activeKeepalivesByPeer.get(peerId);
151
+ if (!keepalives) {
152
+ keepalives = new Set();
153
+ activeKeepalivesByPeer.set(peerId, keepalives);
154
+ }
155
+ keepalives.add({ reqId, stop: stopPlaceholderKeepalive });
90
156
  }
91
157
 
92
158
  return {
@@ -103,7 +169,19 @@ export function createBotWsReplyHandle(params: {
103
169
  },
104
170
  },
105
171
  deliver: async (payload: ReplyPayload, info) => {
106
- if (payload.isReasoning) return;
172
+ // Mark this chat as active on this handle
173
+ notifyPeerActive();
174
+
175
+ if (payload.isReasoning) {
176
+ // We reset the safety timeout if reasoning is actively streaming
177
+ if (placeholderTimeout && !isEvent) {
178
+ clearTimeout(placeholderTimeout);
179
+ placeholderTimeout = setTimeout(() => {
180
+ stopPlaceholderKeepalive();
181
+ }, MAX_KEEPALIVE_MS);
182
+ }
183
+ return;
184
+ }
107
185
 
108
186
  const text = payload.text?.trim();
109
187
  if (!text) return;
@@ -121,14 +199,32 @@ export function createBotWsReplyHandle(params: {
121
199
  : text
122
200
  : accumulatedText || text;
123
201
 
202
+ // Event frames do not support streaming chunks
203
+ if (isEvent && info.kind !== "final") {
204
+ return;
205
+ }
206
+
124
207
  settleStream();
125
208
  try {
126
- await params.client.replyStream(
127
- params.frame,
128
- resolveStreamId(),
129
- outboundText,
130
- info.kind === "final",
131
- );
209
+ if (params.inboundKind === "welcome") {
210
+ await params.client.replyWelcome(params.frame, {
211
+ msgtype: "text",
212
+ text: { content: outboundText },
213
+ });
214
+ } else if (isEvent) {
215
+ // Send push message for other events
216
+ await params.client.sendMessage(peerId, {
217
+ msgtype: "markdown",
218
+ markdown: { content: outboundText },
219
+ });
220
+ } else {
221
+ await params.client.replyStream(
222
+ params.frame,
223
+ resolveStreamId(),
224
+ outboundText,
225
+ info.kind === "final",
226
+ );
227
+ }
132
228
  } catch (error) {
133
229
  if (isTerminalReplyError(error)) {
134
230
  params.onFail?.(error);
@@ -139,14 +235,26 @@ export function createBotWsReplyHandle(params: {
139
235
  params.onDeliver?.();
140
236
  },
141
237
  fail: async (error: unknown) => {
238
+ notifyPeerActive();
142
239
  settleStream();
143
240
  if (isTerminalReplyError(error)) {
144
241
  params.onFail?.(error);
145
242
  return;
146
243
  }
147
244
  const message = formatErrorMessage(error);
245
+ const text = `WeCom WS reply failed: ${message}`;
246
+
148
247
  try {
149
- await params.client.replyStream(params.frame, resolveStreamId(), `WeCom WS reply failed: ${message}`, true);
248
+ if (params.inboundKind === "welcome") {
249
+ await params.client.replyWelcome(params.frame, { msgtype: "text", text: { content: text }});
250
+ } else if (isEvent) {
251
+ await params.client.sendMessage(peerId, {
252
+ msgtype: "markdown",
253
+ markdown: { content: text },
254
+ });
255
+ } else {
256
+ await params.client.replyStream(params.frame, resolveStreamId(), text, true);
257
+ }
150
258
  } catch (sendError) {
151
259
  params.onFail?.(sendError);
152
260
  return;
@@ -149,6 +149,7 @@ export class BotWsSdkAdapter {
149
149
  client,
150
150
  frame,
151
151
  accountId: this.runtime.account.accountId,
152
+ inboundKind: event.inboundKind,
152
153
  placeholderContent: botAccount.config.streamPlaceholderContent,
153
154
  autoSendPlaceholder:
154
155
  event.inboundKind === "text" ||
@@ -19,7 +19,10 @@ export type WecomInboundKind =
19
19
  | "image"
20
20
  | "file"
21
21
  | "voice"
22
+ | "video"
22
23
  | "mixed"
24
+ | "location"
25
+ | "link"
23
26
  | "event"
24
27
  | "welcome"
25
28
  | "template-card-event";