@yanhaidao/wecom 2.3.150 → 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.
- package/README.md +238 -385
- package/SKILLS_CAL.md +895 -0
- package/SKILLS_DOC.md +2136 -0
- package/changelog/v2.3.16.md +11 -0
- package/changelog/v2.3.18.md +22 -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 +19 -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 +567 -62
- package/src/capability/doc/schema.ts +419 -318
- package/src/capability/doc/tool.ts +1510 -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/schema.ts +71 -102
- package/src/outbound.test.ts +91 -14
- package/src/outbound.ts +143 -30
- package/src/runtime/reply-orchestrator.test.ts +35 -2
- package/src/runtime/reply-orchestrator.ts +14 -2
- package/src/runtime/session-manager.ts +20 -6
- package/src/runtime/source-registry.ts +165 -0
- package/src/target.ts +7 -4
- package/src/transport/bot-ws/inbound.test.ts +46 -0
- package/src/transport/bot-ws/inbound.ts +23 -5
- package/src/transport/bot-ws/media.ts +269 -0
- package/src/transport/bot-ws/reply.test.ts +85 -17
- package/src/transport/bot-ws/reply.ts +109 -21
- package/src/transport/bot-ws/sdk-adapter.test.ts +64 -1
- package/src/transport/bot-ws/sdk-adapter.ts +88 -12
- package/.claude/settings.local.json +0 -11
- package/docs/update-content-fix.md +0 -135
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
|
|
3
1
|
import type { WSClient } from "@wecom/aibot-node-sdk";
|
|
4
|
-
|
|
2
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
5
3
|
import { createBotWsReplyHandle } from "./reply.js";
|
|
6
4
|
|
|
7
5
|
type ReplyHandleParams = Parameters<typeof createBotWsReplyHandle>[0];
|
|
@@ -43,10 +41,10 @@ describe("createBotWsReplyHandle", () => {
|
|
|
43
41
|
vi.advanceTimersByTime(3000);
|
|
44
42
|
// Let promises flush
|
|
45
43
|
await Promise.resolve();
|
|
46
|
-
|
|
44
|
+
|
|
47
45
|
expect(mockClient.replyStream).toHaveBeenCalledWith(
|
|
48
46
|
expect.objectContaining({
|
|
49
|
-
headers: { req_id: "req-1" }
|
|
47
|
+
headers: { req_id: "req-1" },
|
|
50
48
|
}),
|
|
51
49
|
expect.any(String),
|
|
52
50
|
"正在思考...",
|
|
@@ -69,7 +67,7 @@ describe("createBotWsReplyHandle", () => {
|
|
|
69
67
|
vi.advanceTimersByTime(3000);
|
|
70
68
|
// Flush the microtasks so `placeholderInFlight` becomes false
|
|
71
69
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
72
|
-
|
|
70
|
+
|
|
73
71
|
// Now trigger the next timer
|
|
74
72
|
vi.advanceTimersByTime(3000);
|
|
75
73
|
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
@@ -90,7 +88,7 @@ describe("createBotWsReplyHandle", () => {
|
|
|
90
88
|
// Ensure interval is cleared
|
|
91
89
|
vi.advanceTimersByTime(6000);
|
|
92
90
|
await Promise.resolve();
|
|
93
|
-
expect(mockClient.replyStream).toHaveBeenCalledTimes(3);
|
|
91
|
+
expect(mockClient.replyStream).toHaveBeenCalledTimes(3);
|
|
94
92
|
});
|
|
95
93
|
|
|
96
94
|
it("does not auto-send placeholder when disabled", async () => {
|
|
@@ -149,6 +147,73 @@ describe("createBotWsReplyHandle", () => {
|
|
|
149
147
|
);
|
|
150
148
|
});
|
|
151
149
|
|
|
150
|
+
it("streams block text even when media is deferred to final", async () => {
|
|
151
|
+
const handle = createBotWsReplyHandle({
|
|
152
|
+
client: mockClient,
|
|
153
|
+
frame: {
|
|
154
|
+
headers: { req_id: "req-block-media" },
|
|
155
|
+
body: {},
|
|
156
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
157
|
+
accountId: "default",
|
|
158
|
+
inboundKind: "text",
|
|
159
|
+
autoSendPlaceholder: false,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await handle.deliver(
|
|
163
|
+
{
|
|
164
|
+
text: "正文先发",
|
|
165
|
+
mediaUrls: ["/tmp/a.png", "/tmp/b.png"],
|
|
166
|
+
isReasoning: false,
|
|
167
|
+
},
|
|
168
|
+
{ kind: "block" },
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(mockClient.replyStream).toHaveBeenCalledWith(
|
|
172
|
+
expect.objectContaining({ headers: { req_id: "req-block-media" } }),
|
|
173
|
+
expect.any(String),
|
|
174
|
+
"正文先发",
|
|
175
|
+
false,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("stops placeholder keepalive when the first block contains media", async () => {
|
|
180
|
+
const handle = createBotWsReplyHandle({
|
|
181
|
+
client: mockClient,
|
|
182
|
+
frame: {
|
|
183
|
+
headers: { req_id: "req-placeholder-media" },
|
|
184
|
+
body: {},
|
|
185
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
186
|
+
accountId: "default",
|
|
187
|
+
inboundKind: "text",
|
|
188
|
+
placeholderContent: "正在思考...",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
vi.advanceTimersByTime(3000);
|
|
192
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
193
|
+
expect(mockClient.replyStream).toHaveBeenCalledTimes(1);
|
|
194
|
+
|
|
195
|
+
await handle.deliver(
|
|
196
|
+
{
|
|
197
|
+
text: "正文先发",
|
|
198
|
+
mediaUrls: ["/tmp/a.png"],
|
|
199
|
+
isReasoning: false,
|
|
200
|
+
},
|
|
201
|
+
{ kind: "block" },
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
vi.advanceTimersByTime(6000);
|
|
205
|
+
for (let i = 0; i < 10; i++) await Promise.resolve();
|
|
206
|
+
|
|
207
|
+
expect(mockClient.replyStream).toHaveBeenCalledTimes(2);
|
|
208
|
+
expect(mockClient.replyStream).toHaveBeenNthCalledWith(
|
|
209
|
+
2,
|
|
210
|
+
expect.objectContaining({ headers: { req_id: "req-placeholder-media" } }),
|
|
211
|
+
expect.any(String),
|
|
212
|
+
"正文先发",
|
|
213
|
+
false,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
152
217
|
it("swallows expired stream update errors during delivery", async () => {
|
|
153
218
|
const expiredError = {
|
|
154
219
|
headers: { req_id: "req-expired" },
|
|
@@ -157,7 +222,7 @@ describe("createBotWsReplyHandle", () => {
|
|
|
157
222
|
};
|
|
158
223
|
mockClient.replyStream.mockRejectedValueOnce(expiredError);
|
|
159
224
|
const onFail = vi.fn();
|
|
160
|
-
|
|
225
|
+
|
|
161
226
|
const handle = createBotWsReplyHandle({
|
|
162
227
|
client: mockClient,
|
|
163
228
|
frame: {
|
|
@@ -178,7 +243,13 @@ describe("createBotWsReplyHandle", () => {
|
|
|
178
243
|
|
|
179
244
|
it.each([
|
|
180
245
|
[{ headers: { req_id: "req-invalid" }, errcode: 846605, errmsg: "invalid req_id" }],
|
|
181
|
-
[
|
|
246
|
+
[
|
|
247
|
+
{
|
|
248
|
+
headers: { req_id: "req-expired" },
|
|
249
|
+
errcode: 846608,
|
|
250
|
+
errmsg: "stream message update expired (>6 minutes), cannot update",
|
|
251
|
+
},
|
|
252
|
+
],
|
|
182
253
|
])("does not retry error reply when the ws reply window is already closed", async (error) => {
|
|
183
254
|
const onFail = vi.fn();
|
|
184
255
|
const handle = createBotWsReplyHandle({
|
|
@@ -218,13 +289,10 @@ describe("createBotWsReplyHandle", () => {
|
|
|
218
289
|
handle.deliver({ text: "Event Reply", isReasoning: false }, { kind: "final" });
|
|
219
290
|
await Promise.resolve();
|
|
220
291
|
|
|
221
|
-
expect(mockClient.sendMessage).toHaveBeenCalledWith(
|
|
222
|
-
"
|
|
223
|
-
{
|
|
224
|
-
|
|
225
|
-
markdown: { content: "Event Reply" },
|
|
226
|
-
}
|
|
227
|
-
);
|
|
292
|
+
expect(mockClient.sendMessage).toHaveBeenCalledWith("alice", {
|
|
293
|
+
msgtype: "markdown",
|
|
294
|
+
markdown: { content: "Event Reply" },
|
|
295
|
+
});
|
|
228
296
|
});
|
|
229
297
|
|
|
230
298
|
it("sends replyWelcome for welcome events", async () => {
|
|
@@ -246,7 +314,7 @@ describe("createBotWsReplyHandle", () => {
|
|
|
246
314
|
{
|
|
247
315
|
msgtype: "text",
|
|
248
316
|
text: { content: "Hello Bob" },
|
|
249
|
-
}
|
|
317
|
+
},
|
|
250
318
|
);
|
|
251
319
|
});
|
|
252
320
|
});
|
|
@@ -1,7 +1,13 @@
|
|
|
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
|
-
|
|
4
9
|
import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
|
|
10
|
+
import { uploadAndSendBotWsMedia } from "./media.js";
|
|
5
11
|
|
|
6
12
|
const PLACEHOLDER_KEEPALIVE_MS = 3000;
|
|
7
13
|
const MAX_KEEPALIVE_MS = 120 * 1000; // Force stop keepalive after 120s if ignored
|
|
@@ -32,7 +38,14 @@ function isAckTimeoutError(error: unknown): boolean {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
function isTerminalReplyError(error: unknown): boolean {
|
|
35
|
-
return
|
|
41
|
+
return (
|
|
42
|
+
isInvalidReqIdError(error) || isExpiredStreamUpdateError(error) || isAckTimeoutError(error)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatMediaFailure(mediaUrl: string, error?: string, rejectReason?: string): string {
|
|
47
|
+
const reason = rejectReason || error || "unknown";
|
|
48
|
+
return `媒体发送失败:${mediaUrl} (${reason})`;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
// Global registry to track active keepalives by peerId
|
|
@@ -54,6 +67,7 @@ export function createBotWsReplyHandle(params: {
|
|
|
54
67
|
}): ReplyHandle {
|
|
55
68
|
let streamId: string | undefined;
|
|
56
69
|
let accumulatedText = "";
|
|
70
|
+
let deferredMediaUrls: string[] = [];
|
|
57
71
|
const resolveStreamId = () => {
|
|
58
72
|
streamId ||= generateReqId("stream");
|
|
59
73
|
return streamId;
|
|
@@ -68,11 +82,15 @@ export function createBotWsReplyHandle(params: {
|
|
|
68
82
|
// Extract peerId for clustering handles
|
|
69
83
|
const body = params.frame.body as any;
|
|
70
84
|
const peerId = String(
|
|
71
|
-
(body?.chattype === "group" ? body?.chatid || body?.from?.userid : body?.from?.userid) ||
|
|
85
|
+
(body?.chattype === "group" ? body?.chatid || body?.from?.userid : body?.from?.userid) ||
|
|
86
|
+
"unknown",
|
|
72
87
|
);
|
|
73
88
|
const reqId = params.frame.headers.req_id || "unknown";
|
|
74
89
|
|
|
75
|
-
const isEvent =
|
|
90
|
+
const isEvent =
|
|
91
|
+
params.inboundKind === "welcome" ||
|
|
92
|
+
params.inboundKind === "event" ||
|
|
93
|
+
params.inboundKind === "template-card-event";
|
|
76
94
|
|
|
77
95
|
const stopPlaceholderKeepalive = () => {
|
|
78
96
|
if (placeholderKeepalive) {
|
|
@@ -83,7 +101,7 @@ export function createBotWsReplyHandle(params: {
|
|
|
83
101
|
clearTimeout(placeholderTimeout);
|
|
84
102
|
placeholderTimeout = undefined;
|
|
85
103
|
}
|
|
86
|
-
|
|
104
|
+
|
|
87
105
|
// Remove from registry
|
|
88
106
|
const keepalives = activeKeepalivesByPeer.get(peerId);
|
|
89
107
|
if (keepalives) {
|
|
@@ -107,7 +125,8 @@ export function createBotWsReplyHandle(params: {
|
|
|
107
125
|
const sendPlaceholder = () => {
|
|
108
126
|
if (streamSettled || placeholderInFlight || isEvent) return;
|
|
109
127
|
placeholderInFlight = true;
|
|
110
|
-
params.client
|
|
128
|
+
params.client
|
|
129
|
+
.replyStream(params.frame, resolveStreamId(), placeholderText, false)
|
|
111
130
|
.catch((error) => {
|
|
112
131
|
if (!isTerminalReplyError(error)) {
|
|
113
132
|
return;
|
|
@@ -134,13 +153,27 @@ export function createBotWsReplyHandle(params: {
|
|
|
134
153
|
}
|
|
135
154
|
};
|
|
136
155
|
|
|
156
|
+
const mergeDeferredMediaUrls = (urls: string[]): string[] => {
|
|
157
|
+
if (urls.length === 0) {
|
|
158
|
+
return deferredMediaUrls;
|
|
159
|
+
}
|
|
160
|
+
const merged = [...deferredMediaUrls];
|
|
161
|
+
for (const url of urls) {
|
|
162
|
+
if (!merged.includes(url)) {
|
|
163
|
+
merged.push(url);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
deferredMediaUrls = merged;
|
|
167
|
+
return deferredMediaUrls;
|
|
168
|
+
};
|
|
169
|
+
|
|
137
170
|
if (params.autoSendPlaceholder !== false && !isEvent) {
|
|
138
171
|
sendPlaceholder();
|
|
139
172
|
placeholderKeepalive = setInterval(() => {
|
|
140
173
|
sendPlaceholder();
|
|
141
174
|
}, PLACEHOLDER_KEEPALIVE_MS);
|
|
142
|
-
|
|
143
|
-
// Safety net: force stop keepalive after MAX_KEEPALIVE_MS
|
|
175
|
+
|
|
176
|
+
// Safety net: force stop keepalive after MAX_KEEPALIVE_MS
|
|
144
177
|
// in case the message is completely ignored by the core and never triggers deliver/fail
|
|
145
178
|
placeholderTimeout = setTimeout(() => {
|
|
146
179
|
stopPlaceholderKeepalive();
|
|
@@ -183,10 +216,22 @@ export function createBotWsReplyHandle(params: {
|
|
|
183
216
|
return;
|
|
184
217
|
}
|
|
185
218
|
|
|
186
|
-
const text = payload.text?.trim();
|
|
187
|
-
|
|
219
|
+
const text = payload.text?.trim() || "";
|
|
220
|
+
const incomingMediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
221
|
+
const hasIncomingMedia = incomingMediaUrls.length > 0;
|
|
222
|
+
if (info.kind !== "final" && hasIncomingMedia) {
|
|
223
|
+
mergeDeferredMediaUrls(incomingMediaUrls);
|
|
224
|
+
}
|
|
225
|
+
const mediaUrls =
|
|
226
|
+
info.kind === "final" ? mergeDeferredMediaUrls(incomingMediaUrls) : incomingMediaUrls;
|
|
227
|
+
if (!text && mediaUrls.length === 0) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
188
230
|
|
|
189
231
|
if (info.kind === "block") {
|
|
232
|
+
if (!text) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
190
235
|
accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
|
|
191
236
|
}
|
|
192
237
|
|
|
@@ -199,6 +244,46 @@ export function createBotWsReplyHandle(params: {
|
|
|
199
244
|
: text
|
|
200
245
|
: accumulatedText || text;
|
|
201
246
|
|
|
247
|
+
let finalText = outboundText;
|
|
248
|
+
if (info.kind === "final" && mediaUrls.length > 0) {
|
|
249
|
+
const mediaFailures: string[] = [];
|
|
250
|
+
const mediaNotes: string[] = [];
|
|
251
|
+
let mediaSent = 0;
|
|
252
|
+
for (const mediaUrl of mediaUrls) {
|
|
253
|
+
const result = await uploadAndSendBotWsMedia({
|
|
254
|
+
wsClient: params.client,
|
|
255
|
+
chatId: peerId,
|
|
256
|
+
mediaUrl,
|
|
257
|
+
});
|
|
258
|
+
if (result.ok) {
|
|
259
|
+
mediaSent += 1;
|
|
260
|
+
if (result.downgradeNote) {
|
|
261
|
+
mediaNotes.push(result.downgradeNote);
|
|
262
|
+
}
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
mediaFailures.push(formatMediaFailure(mediaUrl, result.error, result.rejectReason));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!finalText && mediaSent > 0) {
|
|
269
|
+
finalText = "文件已发送。";
|
|
270
|
+
}
|
|
271
|
+
if (mediaFailures.length > 0) {
|
|
272
|
+
finalText = finalText
|
|
273
|
+
? `${finalText}\n\n${mediaFailures.join("\n")}`
|
|
274
|
+
: mediaFailures.join("\n");
|
|
275
|
+
}
|
|
276
|
+
if (mediaNotes.length > 0) {
|
|
277
|
+
finalText = finalText
|
|
278
|
+
? `${finalText}\n\n${mediaNotes.join("\n")}`
|
|
279
|
+
: mediaNotes.join("\n");
|
|
280
|
+
}
|
|
281
|
+
deferredMediaUrls = [];
|
|
282
|
+
}
|
|
283
|
+
if (!finalText) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
202
287
|
// Event frames do not support streaming chunks
|
|
203
288
|
if (isEvent && info.kind !== "final") {
|
|
204
289
|
return;
|
|
@@ -209,19 +294,19 @@ export function createBotWsReplyHandle(params: {
|
|
|
209
294
|
if (params.inboundKind === "welcome") {
|
|
210
295
|
await params.client.replyWelcome(params.frame, {
|
|
211
296
|
msgtype: "text",
|
|
212
|
-
text: { content:
|
|
297
|
+
text: { content: finalText },
|
|
213
298
|
});
|
|
214
299
|
} else if (isEvent) {
|
|
215
300
|
// Send push message for other events
|
|
216
301
|
await params.client.sendMessage(peerId, {
|
|
217
302
|
msgtype: "markdown",
|
|
218
|
-
markdown: { content:
|
|
303
|
+
markdown: { content: finalText },
|
|
219
304
|
});
|
|
220
305
|
} else {
|
|
221
306
|
await params.client.replyStream(
|
|
222
307
|
params.frame,
|
|
223
308
|
resolveStreamId(),
|
|
224
|
-
|
|
309
|
+
finalText,
|
|
225
310
|
info.kind === "final",
|
|
226
311
|
);
|
|
227
312
|
}
|
|
@@ -243,17 +328,20 @@ export function createBotWsReplyHandle(params: {
|
|
|
243
328
|
}
|
|
244
329
|
const message = formatErrorMessage(error);
|
|
245
330
|
const text = `WeCom WS reply failed: ${message}`;
|
|
246
|
-
|
|
331
|
+
|
|
247
332
|
try {
|
|
248
333
|
if (params.inboundKind === "welcome") {
|
|
249
|
-
|
|
334
|
+
await params.client.replyWelcome(params.frame, {
|
|
335
|
+
msgtype: "text",
|
|
336
|
+
text: { content: text },
|
|
337
|
+
});
|
|
250
338
|
} else if (isEvent) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
339
|
+
await params.client.sendMessage(peerId, {
|
|
340
|
+
msgtype: "markdown",
|
|
341
|
+
markdown: { content: text },
|
|
342
|
+
});
|
|
255
343
|
} else {
|
|
256
|
-
|
|
344
|
+
await params.client.replyStream(params.frame, resolveStreamId(), text, true);
|
|
257
345
|
}
|
|
258
346
|
} catch (sendError) {
|
|
259
347
|
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
|
});
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import AiBot, {
|
|
3
|
+
generateReqId,
|
|
4
|
+
type BaseMessage,
|
|
5
|
+
type EventMessage,
|
|
6
|
+
type WsFrame,
|
|
7
|
+
} from "@wecom/aibot-node-sdk";
|
|
8
|
+
import type { WecomAccountRuntime } from "../../app/account-runtime.js";
|
|
6
9
|
import { registerBotWsPushHandle, unregisterBotWsPushHandle } from "../../app/index.js";
|
|
10
|
+
import { clearWecomMcpAccountCache } from "../../capability/mcp/index.js";
|
|
11
|
+
import type { RuntimeLogSink } from "../../types/index.js";
|
|
7
12
|
import { mapBotWsFrameToInboundEvent } from "./inbound.js";
|
|
13
|
+
import { uploadAndSendBotWsMedia } from "./media.js";
|
|
8
14
|
import { createBotWsReplyHandle } from "./reply.js";
|
|
9
15
|
import { createBotWsSessionSnapshot } from "./session.js";
|
|
10
|
-
import type { WecomAccountRuntime } from "../../app/account-runtime.js";
|
|
11
16
|
|
|
12
17
|
export class BotWsSdkAdapter {
|
|
13
18
|
private client?: AiBot.WSClient;
|
|
@@ -32,15 +37,35 @@ export class BotWsSdkAdapter {
|
|
|
32
37
|
botId: bot.ws.botId,
|
|
33
38
|
secret: bot.ws.secret,
|
|
34
39
|
logger: {
|
|
35
|
-
debug: (message, ...args) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
debug: (message, ...args) =>
|
|
41
|
+
this.log.info?.(`[wecom-ws] ${message} ${args.join(" ")}`.trim()),
|
|
42
|
+
info: (message, ...args) =>
|
|
43
|
+
this.log.info?.(`[wecom-ws] ${message} ${args.join(" ")}`.trim()),
|
|
44
|
+
warn: (message, ...args) =>
|
|
45
|
+
this.log.warn?.(`[wecom-ws] ${message} ${args.join(" ")}`.trim()),
|
|
46
|
+
error: (message, ...args) =>
|
|
47
|
+
this.log.error?.(`[wecom-ws] ${message} ${args.join(" ")}`.trim()),
|
|
39
48
|
},
|
|
40
49
|
});
|
|
41
50
|
this.client = client;
|
|
42
51
|
registerBotWsPushHandle(this.runtime.account.accountId, {
|
|
43
52
|
isConnected: () => client.isConnected,
|
|
53
|
+
replyCommand: async ({ cmd, body, headers }) => {
|
|
54
|
+
const result = await client.reply(
|
|
55
|
+
{ headers: headers ?? { req_id: generateReqId("wecom_ws") } },
|
|
56
|
+
body ?? {},
|
|
57
|
+
cmd,
|
|
58
|
+
);
|
|
59
|
+
this.runtime.touchTransportSession("bot-ws", {
|
|
60
|
+
ownerId: this.ownerId,
|
|
61
|
+
running: true,
|
|
62
|
+
connected: client.isConnected,
|
|
63
|
+
authenticated: client.isConnected,
|
|
64
|
+
lastOutboundAt: Date.now(),
|
|
65
|
+
lastError: undefined,
|
|
66
|
+
});
|
|
67
|
+
return result as Record<string, unknown>;
|
|
68
|
+
},
|
|
44
69
|
sendMarkdown: async (chatId, content) => {
|
|
45
70
|
await client.sendMessage(chatId, {
|
|
46
71
|
msgtype: "markdown",
|
|
@@ -55,6 +80,29 @@ export class BotWsSdkAdapter {
|
|
|
55
80
|
lastError: undefined,
|
|
56
81
|
});
|
|
57
82
|
},
|
|
83
|
+
sendMedia: async ({ chatId, mediaUrl, text, mediaLocalRoots }) => {
|
|
84
|
+
const result = await uploadAndSendBotWsMedia({
|
|
85
|
+
wsClient: client,
|
|
86
|
+
chatId,
|
|
87
|
+
mediaUrl,
|
|
88
|
+
mediaLocalRoots,
|
|
89
|
+
});
|
|
90
|
+
if (result.ok && text?.trim()) {
|
|
91
|
+
await client.sendMessage(chatId, {
|
|
92
|
+
msgtype: "markdown",
|
|
93
|
+
markdown: { content: text.trim() },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
this.runtime.touchTransportSession("bot-ws", {
|
|
97
|
+
ownerId: this.ownerId,
|
|
98
|
+
running: true,
|
|
99
|
+
connected: client.isConnected,
|
|
100
|
+
authenticated: client.isConnected,
|
|
101
|
+
lastOutboundAt: Date.now(),
|
|
102
|
+
lastError: result.ok ? undefined : result.error,
|
|
103
|
+
});
|
|
104
|
+
return result;
|
|
105
|
+
},
|
|
58
106
|
});
|
|
59
107
|
|
|
60
108
|
client.on("connected", () => {
|
|
@@ -82,8 +130,12 @@ export class BotWsSdkAdapter {
|
|
|
82
130
|
});
|
|
83
131
|
|
|
84
132
|
client.on("disconnected", (reason) => {
|
|
133
|
+
clearWecomMcpAccountCache(this.runtime.account.accountId);
|
|
85
134
|
const normalizedReason = String(reason ?? "").toLowerCase();
|
|
86
|
-
const kicked =
|
|
135
|
+
const kicked =
|
|
136
|
+
normalizedReason.includes("kick") ||
|
|
137
|
+
normalizedReason.includes("owner") ||
|
|
138
|
+
normalizedReason.includes("replaced");
|
|
87
139
|
this.log.warn?.(
|
|
88
140
|
`[wecom-ws] disconnected account=${this.runtime.account.accountId} kicked=${String(kicked)} reason=${reason ?? "unknown"}`,
|
|
89
141
|
);
|
|
@@ -109,11 +161,15 @@ export class BotWsSdkAdapter {
|
|
|
109
161
|
});
|
|
110
162
|
|
|
111
163
|
client.on("reconnecting", (attempt) => {
|
|
112
|
-
this.log.warn?.(
|
|
164
|
+
this.log.warn?.(
|
|
165
|
+
`[wecom-ws] reconnecting account=${this.runtime.account.accountId} attempt=${attempt}`,
|
|
166
|
+
);
|
|
113
167
|
});
|
|
114
168
|
|
|
115
169
|
client.on("error", (error) => {
|
|
116
|
-
this.log.error?.(
|
|
170
|
+
this.log.error?.(
|
|
171
|
+
`[wecom-ws] error account=${this.runtime.account.accountId} message=${error.message}`,
|
|
172
|
+
);
|
|
117
173
|
this.runtime.updateTransportSession(
|
|
118
174
|
createBotWsSessionSnapshot({
|
|
119
175
|
accountId: this.runtime.account.accountId,
|
|
@@ -176,6 +232,25 @@ export class BotWsSdkAdapter {
|
|
|
176
232
|
});
|
|
177
233
|
},
|
|
178
234
|
});
|
|
235
|
+
|
|
236
|
+
const staticWelcomeText =
|
|
237
|
+
event.inboundKind === "welcome" ? botAccount.config.welcomeText?.trim() : undefined;
|
|
238
|
+
if (staticWelcomeText) {
|
|
239
|
+
this.log.info?.(
|
|
240
|
+
`[wecom-ws] static welcome reply account=${this.runtime.account.accountId} messageId=${event.messageId} peer=${event.conversation.peerKind}:${event.conversation.peerId} len=${staticWelcomeText.length}`,
|
|
241
|
+
);
|
|
242
|
+
await replyHandle.deliver(
|
|
243
|
+
{
|
|
244
|
+
text: staticWelcomeText,
|
|
245
|
+
},
|
|
246
|
+
{ kind: "final" },
|
|
247
|
+
);
|
|
248
|
+
this.log.info?.(
|
|
249
|
+
`[wecom-ws] static welcome delivered account=${this.runtime.account.accountId} messageId=${event.messageId}`,
|
|
250
|
+
);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
179
254
|
await this.runtime.handleEvent(event, replyHandle);
|
|
180
255
|
};
|
|
181
256
|
|
|
@@ -221,6 +296,7 @@ export class BotWsSdkAdapter {
|
|
|
221
296
|
|
|
222
297
|
stop(): void {
|
|
223
298
|
this.log.info?.(`[wecom-ws] stop account=${this.runtime.account.accountId}`);
|
|
299
|
+
clearWecomMcpAccountCache(this.runtime.account.accountId);
|
|
224
300
|
unregisterBotWsPushHandle(this.runtime.account.accountId);
|
|
225
301
|
this.runtime.updateTransportSession(
|
|
226
302
|
createBotWsSessionSnapshot({
|