@yanhaidao/wecom 2.3.9 → 2.3.12
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 +62 -18
- package/changelog/v2.3.10.md +17 -0
- package/changelog/v2.3.11.md +19 -0
- package/changelog/v2.3.12.md +23 -0
- package/compat-single-account.md +2 -2
- package/package.json +1 -1
- package/src/app/account-runtime.ts +5 -4
- package/src/app/index.ts +19 -0
- package/src/config/accounts.resolve.test.ts +39 -2
- package/src/config/accounts.ts +29 -7
- package/src/config/schema.ts +6 -1
- package/src/onboarding.test.ts +102 -3
- package/src/onboarding.ts +18 -15
- package/src/outbound.test.ts +154 -2
- package/src/outbound.ts +105 -10
- package/src/runtime.ts +3 -0
- package/src/transport/bot-ws/inbound.ts +0 -1
- package/src/transport/bot-ws/reply.test.ts +137 -0
- package/src/transport/bot-ws/reply.ts +98 -8
- package/src/transport/bot-ws/sdk-adapter.test.ts +124 -0
- package/src/transport/bot-ws/sdk-adapter.ts +58 -2
- package/src/types/config.ts +6 -1
- package/CLAUDE.md +0 -238
package/src/outbound.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
vi.mock("./transport/agent-api/core.js", () => ({
|
|
4
4
|
sendText: vi.fn(),
|
|
@@ -7,6 +7,13 @@ vi.mock("./transport/agent-api/core.js", () => ({
|
|
|
7
7
|
}));
|
|
8
8
|
|
|
9
9
|
describe("wecomOutbound", () => {
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
const runtime = await import("./runtime.js");
|
|
12
|
+
runtime.unregisterBotWsPushHandle("default");
|
|
13
|
+
runtime.unregisterBotWsPushHandle("acct-ws");
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
});
|
|
16
|
+
|
|
10
17
|
it("does not crash when called with core outbound params", async () => {
|
|
11
18
|
const { wecomOutbound } = await import("./outbound.js");
|
|
12
19
|
await expect(
|
|
@@ -16,7 +23,7 @@ describe("wecomOutbound", () => {
|
|
|
16
23
|
text: "caption",
|
|
17
24
|
mediaUrl: "https://example.com/media.png",
|
|
18
25
|
} as any),
|
|
19
|
-
).rejects.toThrow(/
|
|
26
|
+
).rejects.toThrow(/requires Agent mode for account=default/i);
|
|
20
27
|
});
|
|
21
28
|
|
|
22
29
|
it("throws explicit error when outbound accountId does not exist", async () => {
|
|
@@ -173,6 +180,151 @@ describe("wecomOutbound", () => {
|
|
|
173
180
|
now.mockRestore();
|
|
174
181
|
});
|
|
175
182
|
|
|
183
|
+
it("prefers Bot WS active push for text when ws is the active bot transport", async () => {
|
|
184
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
185
|
+
const runtime = await import("./runtime.js");
|
|
186
|
+
const api = await import("./transport/agent-api/core.js");
|
|
187
|
+
const sendMarkdown = vi.fn().mockResolvedValue(undefined);
|
|
188
|
+
const now = vi.spyOn(Date, "now").mockReturnValue(789);
|
|
189
|
+
runtime.registerBotWsPushHandle("acct-ws", {
|
|
190
|
+
isConnected: () => true,
|
|
191
|
+
sendMarkdown,
|
|
192
|
+
});
|
|
193
|
+
(api.sendText as any).mockClear();
|
|
194
|
+
|
|
195
|
+
const cfg = {
|
|
196
|
+
channels: {
|
|
197
|
+
wecom: {
|
|
198
|
+
enabled: true,
|
|
199
|
+
defaultAccount: "acct-ws",
|
|
200
|
+
accounts: {
|
|
201
|
+
"acct-ws": {
|
|
202
|
+
enabled: true,
|
|
203
|
+
bot: {
|
|
204
|
+
primaryTransport: "ws",
|
|
205
|
+
ws: {
|
|
206
|
+
botId: "bot-1",
|
|
207
|
+
secret: "secret-1",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
agent: {
|
|
211
|
+
corpId: "corp-ws",
|
|
212
|
+
corpSecret: "agent-secret",
|
|
213
|
+
agentId: 10001,
|
|
214
|
+
token: "token-ws",
|
|
215
|
+
encodingAESKey: "aes-ws",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const result = await wecomOutbound.sendText({
|
|
224
|
+
cfg,
|
|
225
|
+
accountId: "acct-ws",
|
|
226
|
+
to: "user:lisi",
|
|
227
|
+
text: "hello ws",
|
|
228
|
+
} as any);
|
|
229
|
+
|
|
230
|
+
expect(sendMarkdown).toHaveBeenCalledWith("lisi", "hello ws");
|
|
231
|
+
expect(api.sendText).not.toHaveBeenCalled();
|
|
232
|
+
expect(result.messageId).toBe("bot-ws-789");
|
|
233
|
+
|
|
234
|
+
now.mockRestore();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("does not silently fall back to Agent when Bot WS active push is configured but unavailable", async () => {
|
|
238
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
239
|
+
const api = await import("./transport/agent-api/core.js");
|
|
240
|
+
(api.sendText as any).mockClear();
|
|
241
|
+
|
|
242
|
+
const cfg = {
|
|
243
|
+
channels: {
|
|
244
|
+
wecom: {
|
|
245
|
+
enabled: true,
|
|
246
|
+
bot: {
|
|
247
|
+
primaryTransport: "ws",
|
|
248
|
+
ws: {
|
|
249
|
+
botId: "bot-1",
|
|
250
|
+
secret: "secret-1",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
agent: {
|
|
254
|
+
corpId: "corp",
|
|
255
|
+
corpSecret: "secret",
|
|
256
|
+
agentId: 1000002,
|
|
257
|
+
token: "token",
|
|
258
|
+
encodingAESKey: "aes",
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
wecomOutbound.sendText({
|
|
266
|
+
cfg,
|
|
267
|
+
to: "user:zhangsan",
|
|
268
|
+
text: "hello",
|
|
269
|
+
} as any),
|
|
270
|
+
).rejects.toThrow(/no live ws runtime is registered/i);
|
|
271
|
+
expect(api.sendText).not.toHaveBeenCalled();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("keeps outbound media on Agent even when Bot WS is active", async () => {
|
|
275
|
+
const { wecomOutbound } = await import("./outbound.js");
|
|
276
|
+
const runtime = await import("./runtime.js");
|
|
277
|
+
const api = await import("./transport/agent-api/core.js");
|
|
278
|
+
const sendMarkdown = vi.fn().mockResolvedValue(undefined);
|
|
279
|
+
runtime.registerBotWsPushHandle("default", {
|
|
280
|
+
isConnected: () => true,
|
|
281
|
+
sendMarkdown,
|
|
282
|
+
});
|
|
283
|
+
(api.uploadMedia as any).mockResolvedValue("media-1");
|
|
284
|
+
(api.sendMedia as any).mockResolvedValue(undefined);
|
|
285
|
+
(api.sendMedia as any).mockClear();
|
|
286
|
+
vi.stubGlobal(
|
|
287
|
+
"fetch",
|
|
288
|
+
vi.fn().mockResolvedValue({
|
|
289
|
+
ok: true,
|
|
290
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
291
|
+
headers: new Headers({ "content-type": "image/png" }),
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const cfg = {
|
|
296
|
+
channels: {
|
|
297
|
+
wecom: {
|
|
298
|
+
enabled: true,
|
|
299
|
+
bot: {
|
|
300
|
+
primaryTransport: "ws",
|
|
301
|
+
ws: {
|
|
302
|
+
botId: "bot-1",
|
|
303
|
+
secret: "secret-1",
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
agent: {
|
|
307
|
+
corpId: "corp",
|
|
308
|
+
corpSecret: "secret",
|
|
309
|
+
agentId: 1000002,
|
|
310
|
+
token: "token",
|
|
311
|
+
encodingAESKey: "aes",
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
await wecomOutbound.sendMedia({
|
|
318
|
+
cfg,
|
|
319
|
+
to: "user:zhangsan",
|
|
320
|
+
text: "caption",
|
|
321
|
+
mediaUrl: "https://example.com/media.png",
|
|
322
|
+
} as any);
|
|
323
|
+
|
|
324
|
+
expect(api.sendMedia).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(sendMarkdown).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
|
|
176
328
|
it("uses account-scoped agent config in matrix mode", async () => {
|
|
177
329
|
const { wecomOutbound } = await import("./outbound.js");
|
|
178
330
|
const api = await import("./transport/agent-api/core.js");
|
package/src/outbound.ts
CHANGED
|
@@ -2,9 +2,10 @@ import type { ChannelOutboundAdapter, ChannelOutboundContext } from "openclaw/pl
|
|
|
2
2
|
|
|
3
3
|
import { resolveWecomAccount, resolveWecomAccountConflict, resolveWecomAccounts } from "./config/index.js";
|
|
4
4
|
import { WecomAgentDeliveryService } from "./capability/agent/index.js";
|
|
5
|
-
import { getWecomRuntime } from "./runtime.js";
|
|
5
|
+
import { getBotWsPushHandle, getWecomRuntime } from "./runtime.js";
|
|
6
|
+
import { resolveScopedWecomTarget } from "./target.js";
|
|
6
7
|
|
|
7
|
-
function
|
|
8
|
+
function resolveOutboundAccountOrThrow(params: {
|
|
8
9
|
cfg: ChannelOutboundContext["cfg"];
|
|
9
10
|
accountId?: string | null;
|
|
10
11
|
}) {
|
|
@@ -26,10 +27,17 @@ function resolveAgentConfigOrThrow(params: {
|
|
|
26
27
|
);
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
+
return resolveWecomAccount({
|
|
30
31
|
cfg: params.cfg,
|
|
31
32
|
accountId: params.accountId,
|
|
32
|
-
})
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveAgentConfigOrThrow(params: {
|
|
37
|
+
cfg: ChannelOutboundContext["cfg"];
|
|
38
|
+
accountId?: string | null;
|
|
39
|
+
}) {
|
|
40
|
+
const account = resolveOutboundAccountOrThrow(params).agent;
|
|
33
41
|
if (!account?.apiConfigured) {
|
|
34
42
|
throw new Error(
|
|
35
43
|
`WeCom outbound requires Agent mode for account=${params.accountId ?? "default"}. Configure channels.wecom.accounts.<accountId>.agent (or legacy channels.wecom.agent).`,
|
|
@@ -45,6 +53,81 @@ function resolveAgentConfigOrThrow(params: {
|
|
|
45
53
|
return account;
|
|
46
54
|
}
|
|
47
55
|
|
|
56
|
+
function isExplicitAgentTarget(raw: string | undefined): boolean {
|
|
57
|
+
return /^wecom-agent:/i.test(String(raw ?? "").trim());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveBotWsChatTarget(params: {
|
|
61
|
+
to: string | undefined;
|
|
62
|
+
accountId: string;
|
|
63
|
+
}): string | undefined {
|
|
64
|
+
const scoped = resolveScopedWecomTarget(params.to, params.accountId);
|
|
65
|
+
if (!scoped) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
if (scoped.accountId && scoped.accountId !== params.accountId) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`WeCom outbound account mismatch: target belongs to account=${scoped.accountId}, current account=${params.accountId}.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (scoped.target.chatid) {
|
|
74
|
+
return scoped.target.chatid;
|
|
75
|
+
}
|
|
76
|
+
if (scoped.target.touser) {
|
|
77
|
+
return scoped.target.touser;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shouldPreferBotWsOutbound(params: {
|
|
83
|
+
cfg: ChannelOutboundContext["cfg"];
|
|
84
|
+
accountId?: string | null;
|
|
85
|
+
to: string | undefined;
|
|
86
|
+
}): { preferred: boolean; accountId: string } {
|
|
87
|
+
const account = resolveOutboundAccountOrThrow({
|
|
88
|
+
cfg: params.cfg,
|
|
89
|
+
accountId: params.accountId,
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
preferred: !isExplicitAgentTarget(params.to) && Boolean(account.bot?.configured && account.bot.primaryTransport === "ws" && account.bot.wsConfigured),
|
|
93
|
+
accountId: account.accountId,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function sendTextViaBotWs(params: {
|
|
98
|
+
cfg: ChannelOutboundContext["cfg"];
|
|
99
|
+
accountId?: string | null;
|
|
100
|
+
to: string | undefined;
|
|
101
|
+
text: string;
|
|
102
|
+
}): Promise<boolean> {
|
|
103
|
+
const { preferred, accountId } = shouldPreferBotWsOutbound(params);
|
|
104
|
+
if (!preferred) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const chatId = resolveBotWsChatTarget({
|
|
108
|
+
to: params.to,
|
|
109
|
+
accountId,
|
|
110
|
+
});
|
|
111
|
+
if (!chatId) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const handle = getBotWsPushHandle(accountId);
|
|
115
|
+
if (!handle) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`WeCom outbound account=${accountId} is configured for Bot WS active push, but no live WS runtime is registered.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!handle.isConnected()) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`WeCom outbound account=${accountId} is configured for Bot WS active push, but the WS transport is not connected.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
console.log(`[wecom-outbound] Sending Bot WS active message to target=${String(params.to ?? "")} chatId=${chatId} (len=${params.text.length})`);
|
|
126
|
+
await handle.sendMarkdown(chatId, params.text);
|
|
127
|
+
console.log(`[wecom-outbound] Successfully sent Bot WS active message to ${chatId}`);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
48
131
|
export const wecomOutbound: ChannelOutboundAdapter = {
|
|
49
132
|
deliveryMode: "direct",
|
|
50
133
|
chunkerMode: "text",
|
|
@@ -59,9 +142,6 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
59
142
|
sendText: async ({ cfg, to, text, accountId }: ChannelOutboundContext) => {
|
|
60
143
|
// signal removed - not supported in current SDK
|
|
61
144
|
|
|
62
|
-
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
63
|
-
const deliveryService = new WecomAgentDeliveryService(agent);
|
|
64
|
-
|
|
65
145
|
// 体验优化:/new /reset 的“New session started”回执在 OpenClaw 核心里是英文固定文案,
|
|
66
146
|
// 且通过 routeReply 走 wecom outbound(Agent 主动发送)。
|
|
67
147
|
// 在 WeCom“双模式”场景下,这会造成:
|
|
@@ -93,12 +173,23 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
93
173
|
|
|
94
174
|
console.log(`[wecom-outbound] Sending text to target=${String(to ?? "")} (len=${outgoingText.length})`);
|
|
95
175
|
|
|
176
|
+
let sentViaBotWs = false;
|
|
96
177
|
try {
|
|
97
|
-
await
|
|
178
|
+
sentViaBotWs = await sendTextViaBotWs({
|
|
179
|
+
cfg,
|
|
180
|
+
accountId,
|
|
98
181
|
to,
|
|
99
182
|
text: outgoingText,
|
|
100
183
|
});
|
|
101
|
-
|
|
184
|
+
if (!sentViaBotWs) {
|
|
185
|
+
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
186
|
+
const deliveryService = new WecomAgentDeliveryService(agent);
|
|
187
|
+
await deliveryService.sendText({
|
|
188
|
+
to,
|
|
189
|
+
text: outgoingText,
|
|
190
|
+
});
|
|
191
|
+
console.log(`[wecom-outbound] Successfully sent Agent text to ${String(to ?? "")}`);
|
|
192
|
+
}
|
|
102
193
|
} catch (err) {
|
|
103
194
|
console.error(`[wecom-outbound] Failed to send text to ${String(to ?? "")}:`, err);
|
|
104
195
|
throw err;
|
|
@@ -106,13 +197,17 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
106
197
|
|
|
107
198
|
return {
|
|
108
199
|
channel: "wecom",
|
|
109
|
-
messageId:
|
|
200
|
+
messageId: `${sentViaBotWs ? "bot-ws" : "agent"}-${Date.now()}`,
|
|
110
201
|
timestamp: Date.now(),
|
|
111
202
|
};
|
|
112
203
|
},
|
|
113
204
|
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }: ChannelOutboundContext) => {
|
|
114
205
|
// signal removed - not supported in current SDK
|
|
115
206
|
|
|
207
|
+
const { preferred } = shouldPreferBotWsOutbound({ cfg, accountId, to });
|
|
208
|
+
if (preferred) {
|
|
209
|
+
console.log(`[wecom-outbound] Bot WS active push does not support outbound media; falling back to Agent for target=${String(to ?? "")}`);
|
|
210
|
+
}
|
|
116
211
|
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
117
212
|
const deliveryService = new WecomAgentDeliveryService(agent);
|
|
118
213
|
if (!mediaUrl) {
|
package/src/runtime.ts
CHANGED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createBotWsReplyHandle } from "./reply.js";
|
|
4
|
+
|
|
5
|
+
type ReplyHandleParams = Parameters<typeof createBotWsReplyHandle>[0];
|
|
6
|
+
|
|
7
|
+
describe("createBotWsReplyHandle", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.useRealTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("uses configured placeholder content for immediate ws ack", async () => {
|
|
13
|
+
const replyStream = vi.fn().mockResolvedValue(undefined);
|
|
14
|
+
createBotWsReplyHandle({
|
|
15
|
+
client: {
|
|
16
|
+
replyStream,
|
|
17
|
+
} as unknown as ReplyHandleParams["client"],
|
|
18
|
+
frame: {
|
|
19
|
+
headers: { req_id: "req-1" },
|
|
20
|
+
body: {},
|
|
21
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
22
|
+
accountId: "default",
|
|
23
|
+
placeholderContent: "正在思考...",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(replyStream).toHaveBeenCalledWith(
|
|
27
|
+
expect.objectContaining({
|
|
28
|
+
headers: { req_id: "req-1" },
|
|
29
|
+
}),
|
|
30
|
+
expect.any(String),
|
|
31
|
+
"正在思考...",
|
|
32
|
+
false,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps placeholder alive until the first real ws chunk arrives", async () => {
|
|
37
|
+
vi.useFakeTimers();
|
|
38
|
+
|
|
39
|
+
const replyStream = vi.fn().mockResolvedValue(undefined);
|
|
40
|
+
const handle = createBotWsReplyHandle({
|
|
41
|
+
client: {
|
|
42
|
+
replyStream,
|
|
43
|
+
} as unknown as ReplyHandleParams["client"],
|
|
44
|
+
frame: {
|
|
45
|
+
headers: { req_id: "req-keepalive" },
|
|
46
|
+
body: {},
|
|
47
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
48
|
+
accountId: "default",
|
|
49
|
+
placeholderContent: "正在思考...",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
53
|
+
expect(replyStream).toHaveBeenCalledTimes(2);
|
|
54
|
+
|
|
55
|
+
await handle.deliver({ text: "最终回复" }, { kind: "final" });
|
|
56
|
+
await vi.advanceTimersByTimeAsync(6000);
|
|
57
|
+
|
|
58
|
+
expect(replyStream).toHaveBeenCalledTimes(3);
|
|
59
|
+
expect(replyStream).toHaveBeenLastCalledWith(
|
|
60
|
+
expect.objectContaining({
|
|
61
|
+
headers: { req_id: "req-keepalive" },
|
|
62
|
+
}),
|
|
63
|
+
expect.any(String),
|
|
64
|
+
"最终回复",
|
|
65
|
+
true,
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not auto-send placeholder when disabled", () => {
|
|
70
|
+
const replyStream = vi.fn().mockResolvedValue(undefined);
|
|
71
|
+
createBotWsReplyHandle({
|
|
72
|
+
client: {
|
|
73
|
+
replyStream,
|
|
74
|
+
} as unknown as ReplyHandleParams["client"],
|
|
75
|
+
frame: {
|
|
76
|
+
headers: { req_id: "req-2" },
|
|
77
|
+
body: {},
|
|
78
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
79
|
+
accountId: "default",
|
|
80
|
+
autoSendPlaceholder: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(replyStream).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("swallows expired stream update errors during delivery", async () => {
|
|
87
|
+
const expiredError = {
|
|
88
|
+
headers: { req_id: "req-expired" },
|
|
89
|
+
errcode: 846608,
|
|
90
|
+
errmsg: "stream message update expired (>6 minutes), cannot update",
|
|
91
|
+
};
|
|
92
|
+
const replyStream = vi.fn().mockRejectedValue(expiredError);
|
|
93
|
+
const onFail = vi.fn();
|
|
94
|
+
const handle = createBotWsReplyHandle({
|
|
95
|
+
client: {
|
|
96
|
+
replyStream,
|
|
97
|
+
} as unknown as ReplyHandleParams["client"],
|
|
98
|
+
frame: {
|
|
99
|
+
headers: { req_id: "req-expired" },
|
|
100
|
+
body: {},
|
|
101
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
102
|
+
accountId: "default",
|
|
103
|
+
autoSendPlaceholder: false,
|
|
104
|
+
onFail,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await expect(handle.deliver({ text: "最终回复" }, { kind: "final" })).resolves.toBeUndefined();
|
|
108
|
+
|
|
109
|
+
expect(replyStream).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(onFail).toHaveBeenCalledWith(expiredError);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it.each([
|
|
114
|
+
[{ headers: { req_id: "req-invalid" }, errcode: 846605, errmsg: "invalid req_id" }],
|
|
115
|
+
[{ headers: { req_id: "req-expired" }, errcode: 846608, errmsg: "stream message update expired (>6 minutes), cannot update" }],
|
|
116
|
+
])("does not retry error reply when the ws reply window is already closed", async (error) => {
|
|
117
|
+
const replyStream = vi.fn().mockResolvedValue(undefined);
|
|
118
|
+
const onFail = vi.fn();
|
|
119
|
+
const handle = createBotWsReplyHandle({
|
|
120
|
+
client: {
|
|
121
|
+
replyStream,
|
|
122
|
+
} as unknown as ReplyHandleParams["client"],
|
|
123
|
+
frame: {
|
|
124
|
+
headers: { req_id: String(error.headers.req_id) },
|
|
125
|
+
body: {},
|
|
126
|
+
} as unknown as ReplyHandleParams["frame"],
|
|
127
|
+
accountId: "default",
|
|
128
|
+
autoSendPlaceholder: false,
|
|
129
|
+
onFail,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await handle.fail?.(error);
|
|
133
|
+
|
|
134
|
+
expect(replyStream).not.toHaveBeenCalled();
|
|
135
|
+
expect(onFail).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1,11 +1,45 @@
|
|
|
1
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk";
|
|
1
2
|
import { generateReqId, type WsFrame, type BaseMessage, type EventMessage, type WSClient } from "@wecom/aibot-node-sdk";
|
|
2
3
|
|
|
3
4
|
import type { ReplyHandle, ReplyPayload } from "../../types/index.js";
|
|
4
5
|
|
|
6
|
+
const PLACEHOLDER_KEEPALIVE_MS = 3000;
|
|
7
|
+
|
|
8
|
+
function isInvalidReqIdError(error: unknown): boolean {
|
|
9
|
+
if (!error || typeof error !== "object") {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const errcode = "errcode" in error ? Number(error.errcode) : undefined;
|
|
13
|
+
const errmsg = "errmsg" in error ? String(error.errmsg ?? "") : "";
|
|
14
|
+
return errcode === 846605 || errmsg.includes("invalid req_id");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isExpiredStreamUpdateError(error: unknown): boolean {
|
|
18
|
+
if (!error || typeof error !== "object") {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const errcode = "errcode" in error ? Number(error.errcode) : undefined;
|
|
22
|
+
const errmsg = "errmsg" in error ? String(error.errmsg ?? "").toLowerCase() : "";
|
|
23
|
+
return errcode === 846608 || errmsg.includes("stream message update expired");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SDK rejects with a plain Error whose message contains "ack timeout" when
|
|
27
|
+
* the WeCom server does not acknowledge a reply within 5 s. Once timed out
|
|
28
|
+
* the reqId slot is released; further replies on the same reqId will fail. */
|
|
29
|
+
function isAckTimeoutError(error: unknown): boolean {
|
|
30
|
+
return error instanceof Error && error.message.includes("ack timeout");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isTerminalReplyError(error: unknown): boolean {
|
|
34
|
+
return isInvalidReqIdError(error) || isExpiredStreamUpdateError(error) || isAckTimeoutError(error);
|
|
35
|
+
}
|
|
36
|
+
|
|
5
37
|
export function createBotWsReplyHandle(params: {
|
|
6
38
|
client: WSClient;
|
|
7
39
|
frame: WsFrame<BaseMessage | EventMessage>;
|
|
8
40
|
accountId: string;
|
|
41
|
+
placeholderContent?: string;
|
|
42
|
+
autoSendPlaceholder?: boolean;
|
|
9
43
|
onDeliver?: () => void;
|
|
10
44
|
onFail?: (error: unknown) => void;
|
|
11
45
|
}): ReplyHandle {
|
|
@@ -15,6 +49,45 @@ export function createBotWsReplyHandle(params: {
|
|
|
15
49
|
return streamId;
|
|
16
50
|
};
|
|
17
51
|
|
|
52
|
+
const placeholderText = params.placeholderContent?.trim() || "⏳ 正在思考中...\n\n";
|
|
53
|
+
let streamSettled = false;
|
|
54
|
+
let placeholderInFlight = false;
|
|
55
|
+
let placeholderKeepalive: ReturnType<typeof setInterval> | undefined;
|
|
56
|
+
|
|
57
|
+
const stopPlaceholderKeepalive = () => {
|
|
58
|
+
if (!placeholderKeepalive) return;
|
|
59
|
+
clearInterval(placeholderKeepalive);
|
|
60
|
+
placeholderKeepalive = undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const settleStream = () => {
|
|
64
|
+
streamSettled = true;
|
|
65
|
+
stopPlaceholderKeepalive();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const sendPlaceholder = () => {
|
|
69
|
+
if (streamSettled || placeholderInFlight) return;
|
|
70
|
+
placeholderInFlight = true;
|
|
71
|
+
params.client.replyStream(params.frame, resolveStreamId(), placeholderText, false)
|
|
72
|
+
.catch((error) => {
|
|
73
|
+
if (!isTerminalReplyError(error)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
settleStream();
|
|
77
|
+
params.onFail?.(error);
|
|
78
|
+
})
|
|
79
|
+
.finally(() => {
|
|
80
|
+
placeholderInFlight = false;
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (params.autoSendPlaceholder !== false) {
|
|
85
|
+
sendPlaceholder();
|
|
86
|
+
placeholderKeepalive = setInterval(() => {
|
|
87
|
+
sendPlaceholder();
|
|
88
|
+
}, PLACEHOLDER_KEEPALIVE_MS);
|
|
89
|
+
}
|
|
90
|
+
|
|
18
91
|
return {
|
|
19
92
|
context: {
|
|
20
93
|
transport: "bot-ws",
|
|
@@ -29,19 +102,36 @@ export function createBotWsReplyHandle(params: {
|
|
|
29
102
|
},
|
|
30
103
|
},
|
|
31
104
|
deliver: async (payload: ReplyPayload, info) => {
|
|
32
|
-
if (payload.isReasoning)
|
|
33
|
-
|
|
34
|
-
}
|
|
105
|
+
if (payload.isReasoning) return;
|
|
106
|
+
|
|
35
107
|
const text = payload.text?.trim();
|
|
36
|
-
if (!text)
|
|
37
|
-
|
|
108
|
+
if (!text) return;
|
|
109
|
+
|
|
110
|
+
settleStream();
|
|
111
|
+
try {
|
|
112
|
+
await params.client.replyStream(params.frame, resolveStreamId(), text, info.kind === "final");
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (isTerminalReplyError(error)) {
|
|
115
|
+
params.onFail?.(error);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
38
119
|
}
|
|
39
|
-
await params.client.replyStream(params.frame, resolveStreamId(), text, info.kind === "final");
|
|
40
120
|
params.onDeliver?.();
|
|
41
121
|
},
|
|
42
122
|
fail: async (error: unknown) => {
|
|
43
|
-
|
|
44
|
-
|
|
123
|
+
settleStream();
|
|
124
|
+
if (isTerminalReplyError(error)) {
|
|
125
|
+
params.onFail?.(error);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const message = formatErrorMessage(error);
|
|
129
|
+
try {
|
|
130
|
+
await params.client.replyStream(params.frame, resolveStreamId(), `WeCom WS reply failed: ${message}`, true);
|
|
131
|
+
} catch (sendError) {
|
|
132
|
+
params.onFail?.(sendError);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
45
135
|
params.onFail?.(error);
|
|
46
136
|
},
|
|
47
137
|
};
|