@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.
- package/README.md +294 -379
- package/SKILLS_CAL.md +895 -0
- package/SKILLS_DOC.md +2288 -0
- package/changelog/v2.3.18.md +22 -0
- package/changelog/v2.3.19.md +73 -0
- package/index.ts +39 -3
- package/package.json +2 -3
- package/src/agent/handler.event-filter.test.ts +11 -0
- package/src/agent/handler.ts +732 -643
- package/src/app/account-runtime.ts +46 -20
- package/src/app/index.ts +20 -1
- package/src/capability/bot/stream-orchestrator.ts +1 -1
- package/src/capability/calendar/SKILLS_CHECKLIST.md +251 -0
- package/src/capability/calendar/client.ts +815 -0
- package/src/capability/calendar/index.ts +3 -0
- package/src/capability/calendar/schema.ts +417 -0
- package/src/capability/calendar/tool.ts +417 -0
- package/src/capability/calendar/types.ts +309 -0
- package/src/capability/doc/client.ts +788 -64
- package/src/capability/doc/schema.ts +419 -318
- package/src/capability/doc/tool.ts +1517 -1178
- package/src/capability/doc/types.ts +130 -14
- package/src/capability/mcp/index.ts +10 -0
- package/src/capability/mcp/schema.ts +107 -0
- package/src/capability/mcp/tool.ts +170 -0
- package/src/capability/mcp/transport.ts +394 -0
- package/src/channel.ts +70 -28
- package/src/config/index.ts +7 -1
- package/src/config/media.test.ts +113 -0
- package/src/config/media.ts +133 -6
- package/src/config/schema.ts +74 -102
- package/src/outbound.test.ts +250 -15
- package/src/outbound.ts +155 -30
- package/src/runtime/reply-orchestrator.test.ts +35 -2
- package/src/runtime/reply-orchestrator.ts +14 -2
- package/src/runtime/routing-bridge.test.ts +115 -0
- package/src/runtime/routing-bridge.ts +26 -1
- package/src/runtime/session-manager.ts +20 -6
- package/src/runtime/source-registry.ts +165 -0
- package/src/transport/bot-webhook/inbound-normalizer.ts +4 -4
- package/src/transport/bot-ws/media.test.ts +44 -0
- package/src/transport/bot-ws/media.ts +272 -0
- package/src/transport/bot-ws/reply.test.ts +216 -18
- package/src/transport/bot-ws/reply.ts +116 -21
- package/src/transport/bot-ws/sdk-adapter.test.ts +64 -1
- package/src/transport/bot-ws/sdk-adapter.ts +89 -12
- package/src/types/config.ts +3 -0
- package/.claude/settings.local.json +0 -11
- package/docs/update-content-fix.md +0 -135
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
import
|
|
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
|
-
[
|
|
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
|
-
"
|
|
223
|
-
{
|
|
224
|
-
|
|
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 {
|
|
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
|
|
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) ||
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
310
|
+
markdown: { content: finalText },
|
|
219
311
|
});
|
|
220
312
|
} else {
|
|
221
313
|
await params.client.replyStream(
|
|
222
314
|
params.frame,
|
|
223
315
|
resolveStreamId(),
|
|
224
|
-
|
|
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
|
-
|
|
341
|
+
await params.client.replyWelcome(params.frame, {
|
|
342
|
+
msgtype: "text",
|
|
343
|
+
text: { content: text },
|
|
344
|
+
});
|
|
250
345
|
} else if (isEvent) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
346
|
+
await params.client.sendMessage(peerId, {
|
|
347
|
+
msgtype: "markdown",
|
|
348
|
+
markdown: { content: text },
|
|
349
|
+
});
|
|
255
350
|
} else {
|
|
256
|
-
|
|
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(
|
|
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
|
});
|