@yanhaidao/wecom 2.3.160 → 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.
Files changed (39) hide show
  1. package/README.md +235 -399
  2. package/SKILLS_CAL.md +895 -0
  3. package/SKILLS_DOC.md +2136 -0
  4. package/changelog/v2.3.18.md +22 -0
  5. package/index.ts +39 -3
  6. package/package.json +2 -3
  7. package/src/agent/handler.event-filter.test.ts +11 -0
  8. package/src/agent/handler.ts +732 -643
  9. package/src/app/account-runtime.ts +46 -20
  10. package/src/app/index.ts +19 -1
  11. package/src/capability/calendar/SKILLS_CHECKLIST.md +251 -0
  12. package/src/capability/calendar/client.ts +815 -0
  13. package/src/capability/calendar/index.ts +3 -0
  14. package/src/capability/calendar/schema.ts +417 -0
  15. package/src/capability/calendar/tool.ts +417 -0
  16. package/src/capability/calendar/types.ts +309 -0
  17. package/src/capability/doc/client.ts +567 -62
  18. package/src/capability/doc/schema.ts +419 -318
  19. package/src/capability/doc/tool.ts +1510 -1178
  20. package/src/capability/doc/types.ts +130 -14
  21. package/src/capability/mcp/index.ts +10 -0
  22. package/src/capability/mcp/schema.ts +107 -0
  23. package/src/capability/mcp/tool.ts +170 -0
  24. package/src/capability/mcp/transport.ts +394 -0
  25. package/src/channel.ts +70 -28
  26. package/src/config/schema.ts +71 -102
  27. package/src/outbound.test.ts +91 -14
  28. package/src/outbound.ts +143 -30
  29. package/src/runtime/reply-orchestrator.test.ts +35 -2
  30. package/src/runtime/reply-orchestrator.ts +14 -2
  31. package/src/runtime/session-manager.ts +20 -6
  32. package/src/runtime/source-registry.ts +165 -0
  33. package/src/transport/bot-ws/media.ts +269 -0
  34. package/src/transport/bot-ws/reply.test.ts +85 -17
  35. package/src/transport/bot-ws/reply.ts +109 -21
  36. package/src/transport/bot-ws/sdk-adapter.test.ts +64 -1
  37. package/src/transport/bot-ws/sdk-adapter.ts +88 -12
  38. package/.claude/settings.local.json +0 -11
  39. package/docs/update-content-fix.md +0 -135
@@ -1,8 +1,8 @@
1
1
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
-
3
- import { resolveRuntimeRoute } from "./routing-bridge.js";
4
- import type { UnifiedInboundEvent } from "../types/index.js";
5
2
  import type { WecomMediaService } from "../shared/media-service.js";
3
+ import type { UnifiedInboundEvent } from "../types/index.js";
4
+ import { resolveRuntimeRoute } from "./routing-bridge.js";
5
+ import { registerWecomSourceSnapshot } from "./source-registry.js";
6
6
 
7
7
  export type PreparedSession = {
8
8
  route: ReturnType<typeof resolveRuntimeRoute>;
@@ -18,6 +18,14 @@ export async function prepareInboundSession(params: {
18
18
  }): Promise<PreparedSession> {
19
19
  const { core, cfg, event, mediaService } = params;
20
20
  const route = resolveRuntimeRoute({ core, cfg, event });
21
+ if (event.transport === "bot-ws") {
22
+ registerWecomSourceSnapshot({
23
+ accountId: event.accountId,
24
+ source: "bot-ws",
25
+ messageId: event.messageId,
26
+ sessionKey: route.sessionKey,
27
+ });
28
+ }
21
29
  const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
22
30
  agentId: route.agentId,
23
31
  });
@@ -47,7 +55,10 @@ export async function prepareInboundSession(params: {
47
55
  event.conversation.peerKind === "group"
48
56
  ? `wecom:group:${event.conversation.peerId}`
49
57
  : `wecom:user:${event.conversation.senderId}`,
50
- To: event.conversation.peerKind === "group" ? `wecom:group:${event.conversation.peerId}` : `wecom:user:${event.conversation.peerId}`,
58
+ To:
59
+ event.conversation.peerKind === "group"
60
+ ? `wecom:group:${event.conversation.peerId}`
61
+ : `wecom:user:${event.conversation.peerId}`,
51
62
  SessionKey: route.sessionKey,
52
63
  AccountId: route.accountId,
53
64
  ChatType: event.conversation.peerKind,
@@ -57,7 +68,10 @@ export async function prepareInboundSession(params: {
57
68
  Provider: "wecom",
58
69
  Surface: "wecom",
59
70
  OriginatingChannel: "wecom",
60
- OriginatingTo: event.conversation.peerKind === "group" ? `wecom:group:${event.conversation.peerId}` : `wecom:user:${event.conversation.peerId}`,
71
+ OriginatingTo:
72
+ event.conversation.peerKind === "group"
73
+ ? `wecom:group:${event.conversation.peerId}`
74
+ : `wecom:user:${event.conversation.peerId}`,
61
75
  MessageSid: event.messageId,
62
76
  CommandAuthorized: true,
63
77
  MediaPath: mediaPath,
@@ -69,7 +83,7 @@ export async function prepareInboundSession(params: {
69
83
  storePath,
70
84
  sessionKey: ctx.SessionKey ?? route.sessionKey,
71
85
  ctx,
72
- onRecordError: () => { },
86
+ onRecordError: () => {},
73
87
  });
74
88
 
75
89
  return { route, ctx, storePath };
@@ -0,0 +1,165 @@
1
+ export type WecomSourcePlane = "bot-ws" | "agent-callback";
2
+
3
+ export type WecomSourceSnapshot = {
4
+ accountId: string;
5
+ source: WecomSourcePlane;
6
+ recordedAt: number;
7
+ messageId?: string;
8
+ sessionKey?: string;
9
+ sessionId?: string;
10
+ };
11
+
12
+ const MAX_MESSAGE_FACTS = 2048;
13
+ const MAX_SESSION_SNAPSHOTS = 1024;
14
+
15
+ const messageFacts = new Map<string, WecomSourceSnapshot>();
16
+ const sessionSnapshotsByAccountKey = new Map<string, WecomSourceSnapshot>();
17
+ const sessionSnapshotsByLooseKey = new Map<string, WecomSourceSnapshot>();
18
+
19
+ function normalizeOptional(value: string | null | undefined): string | undefined {
20
+ const trimmed = String(value ?? "").trim();
21
+ return trimmed || undefined;
22
+ }
23
+
24
+ function messageFactKey(accountId: string, messageId: string): string {
25
+ return `${accountId}::${messageId}`;
26
+ }
27
+
28
+ function accountScopedSessionKey(
29
+ accountId: string,
30
+ kind: "sessionKey" | "sessionId",
31
+ value: string,
32
+ ): string {
33
+ return `${accountId}::${kind}::${value}`;
34
+ }
35
+
36
+ function pruneOldest<T>(map: Map<string, T>, maxSize: number): void {
37
+ while (map.size > maxSize) {
38
+ const oldestKey = map.keys().next().value;
39
+ if (!oldestKey) return;
40
+ map.delete(oldestKey);
41
+ }
42
+ }
43
+
44
+ function writeSessionSnapshot(snapshot: WecomSourceSnapshot): void {
45
+ const sessionKey = normalizeOptional(snapshot.sessionKey);
46
+ const sessionId = normalizeOptional(snapshot.sessionId);
47
+ if (sessionKey) {
48
+ sessionSnapshotsByAccountKey.set(
49
+ accountScopedSessionKey(snapshot.accountId, "sessionKey", sessionKey),
50
+ snapshot,
51
+ );
52
+ sessionSnapshotsByLooseKey.set(`sessionKey::${sessionKey}`, snapshot);
53
+ }
54
+ if (sessionId) {
55
+ sessionSnapshotsByAccountKey.set(
56
+ accountScopedSessionKey(snapshot.accountId, "sessionId", sessionId),
57
+ snapshot,
58
+ );
59
+ sessionSnapshotsByLooseKey.set(`sessionId::${sessionId}`, snapshot);
60
+ }
61
+ pruneOldest(sessionSnapshotsByAccountKey, MAX_SESSION_SNAPSHOTS * 2);
62
+ pruneOldest(sessionSnapshotsByLooseKey, MAX_SESSION_SNAPSHOTS * 2);
63
+ }
64
+
65
+ export function registerWecomSourceSnapshot(params: {
66
+ accountId: string;
67
+ source: WecomSourcePlane;
68
+ messageId?: string | null;
69
+ sessionKey?: string | null;
70
+ sessionId?: string | null;
71
+ }): void {
72
+ const accountId = normalizeOptional(params.accountId);
73
+ if (!accountId) return;
74
+
75
+ const snapshot: WecomSourceSnapshot = {
76
+ accountId,
77
+ source: params.source,
78
+ recordedAt: Date.now(),
79
+ ...(normalizeOptional(params.messageId)
80
+ ? { messageId: normalizeOptional(params.messageId) }
81
+ : {}),
82
+ ...(normalizeOptional(params.sessionKey)
83
+ ? { sessionKey: normalizeOptional(params.sessionKey) }
84
+ : {}),
85
+ ...(normalizeOptional(params.sessionId)
86
+ ? { sessionId: normalizeOptional(params.sessionId) }
87
+ : {}),
88
+ };
89
+
90
+ if (snapshot.messageId) {
91
+ messageFacts.set(messageFactKey(accountId, snapshot.messageId), snapshot);
92
+ pruneOldest(messageFacts, MAX_MESSAGE_FACTS);
93
+ }
94
+
95
+ writeSessionSnapshot(snapshot);
96
+ }
97
+
98
+ export function resolveWecomSourceSnapshot(params: {
99
+ accountId?: string | null;
100
+ sessionKey?: string | null;
101
+ sessionId?: string | null;
102
+ }): WecomSourceSnapshot | undefined {
103
+ const accountId = normalizeOptional(params.accountId);
104
+ const sessionKey = normalizeOptional(params.sessionKey);
105
+ const sessionId = normalizeOptional(params.sessionId);
106
+
107
+ if (accountId && sessionKey) {
108
+ const scoped = sessionSnapshotsByAccountKey.get(
109
+ accountScopedSessionKey(accountId, "sessionKey", sessionKey),
110
+ );
111
+ if (scoped) return scoped;
112
+ }
113
+ if (accountId && sessionId) {
114
+ const scoped = sessionSnapshotsByAccountKey.get(
115
+ accountScopedSessionKey(accountId, "sessionId", sessionId),
116
+ );
117
+ if (scoped) return scoped;
118
+ }
119
+ if (sessionKey) {
120
+ const loose = sessionSnapshotsByLooseKey.get(`sessionKey::${sessionKey}`);
121
+ if (loose) return loose;
122
+ }
123
+ if (sessionId) {
124
+ const loose = sessionSnapshotsByLooseKey.get(`sessionId::${sessionId}`);
125
+ if (loose) return loose;
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ export function clearWecomSourceAccount(accountId: string): void {
131
+ const normalized = normalizeOptional(accountId);
132
+ if (!normalized) return;
133
+
134
+ for (const [key, value] of messageFacts) {
135
+ if (value.accountId === normalized || key.startsWith(`${normalized}::`)) {
136
+ messageFacts.delete(key);
137
+ }
138
+ }
139
+ for (const [key, value] of sessionSnapshotsByAccountKey) {
140
+ if (value.accountId === normalized || key.startsWith(`${normalized}::`)) {
141
+ sessionSnapshotsByAccountKey.delete(key);
142
+ }
143
+ }
144
+ for (const [key, value] of sessionSnapshotsByLooseKey) {
145
+ if (value.accountId === normalized) {
146
+ sessionSnapshotsByLooseKey.delete(key);
147
+ }
148
+ }
149
+ }
150
+
151
+ export function isWecomBotWsSource(params: {
152
+ accountId?: string | null;
153
+ sessionKey?: string | null;
154
+ sessionId?: string | null;
155
+ }): boolean {
156
+ return resolveWecomSourceSnapshot(params)?.source === "bot-ws";
157
+ }
158
+
159
+ export function isWecomAgentSource(params: {
160
+ accountId?: string | null;
161
+ sessionKey?: string | null;
162
+ sessionId?: string | null;
163
+ }): boolean {
164
+ return resolveWecomSourceSnapshot(params)?.source === "agent-callback";
165
+ }
@@ -0,0 +1,269 @@
1
+ import type { WeComMediaType, WsFrameHeaders, WSClient } from "@wecom/aibot-node-sdk";
2
+ import { detectMime, loadOutboundMediaFromUrl } from "openclaw/plugin-sdk";
3
+
4
+ const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
5
+ const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
6
+ const VOICE_MAX_BYTES = 2 * 1024 * 1024;
7
+ const FILE_MAX_BYTES = 20 * 1024 * 1024;
8
+
9
+ type FileSizeCheckResult = {
10
+ finalType: WeComMediaType;
11
+ shouldReject: boolean;
12
+ rejectReason?: string;
13
+ downgraded: boolean;
14
+ downgradeNote?: string;
15
+ };
16
+
17
+ export type BotWsMediaSendResult = {
18
+ ok: boolean;
19
+ messageId?: string;
20
+ finalType?: WeComMediaType;
21
+ rejected?: boolean;
22
+ rejectReason?: string;
23
+ downgraded?: boolean;
24
+ downgradeNote?: string;
25
+ error?: string;
26
+ };
27
+
28
+ type ResolvedMediaFile = {
29
+ buffer: Buffer;
30
+ contentType: string;
31
+ fileName: string;
32
+ };
33
+
34
+ const VOICE_SUPPORTED_MIMES = new Set(["audio/amr"]);
35
+
36
+ function detectWeComMediaType(mimeType: string): WeComMediaType {
37
+ const mime = mimeType.toLowerCase();
38
+ if (mime.startsWith("image/")) return "image";
39
+ if (mime.startsWith("video/")) return "video";
40
+ if (mime.startsWith("audio/") || mime === "application/ogg") return "voice";
41
+ return "file";
42
+ }
43
+
44
+ function mimeToExtension(mime: string): string {
45
+ const map: Record<string, string> = {
46
+ "image/jpeg": ".jpg",
47
+ "image/png": ".png",
48
+ "image/gif": ".gif",
49
+ "image/webp": ".webp",
50
+ "image/bmp": ".bmp",
51
+ "image/svg+xml": ".svg",
52
+ "video/mp4": ".mp4",
53
+ "video/quicktime": ".mov",
54
+ "video/x-msvideo": ".avi",
55
+ "video/webm": ".webm",
56
+ "audio/mpeg": ".mp3",
57
+ "audio/ogg": ".ogg",
58
+ "audio/wav": ".wav",
59
+ "audio/amr": ".amr",
60
+ "audio/aac": ".aac",
61
+ "application/pdf": ".pdf",
62
+ "application/zip": ".zip",
63
+ "application/msword": ".doc",
64
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
65
+ "application/vnd.ms-excel": ".xls",
66
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
67
+ "text/plain": ".txt",
68
+ };
69
+ return map[mime] || ".bin";
70
+ }
71
+
72
+ function extractFileName(
73
+ mediaUrl: string,
74
+ providedFileName?: string,
75
+ contentType?: string,
76
+ ): string {
77
+ if (providedFileName) return providedFileName;
78
+ try {
79
+ const url = new URL(mediaUrl, "file://");
80
+ const lastPart = url.pathname.split("/").filter(Boolean).pop();
81
+ if (lastPart && lastPart.includes(".")) {
82
+ return decodeURIComponent(lastPart);
83
+ }
84
+ } catch {
85
+ const lastPart = mediaUrl.split("/").filter(Boolean).pop();
86
+ if (lastPart && lastPart.includes(".")) {
87
+ return lastPart;
88
+ }
89
+ }
90
+ return `media_${Date.now()}${mimeToExtension(contentType || "application/octet-stream")}`;
91
+ }
92
+
93
+ function applyFileSizeLimits(
94
+ fileSize: number,
95
+ detectedType: WeComMediaType,
96
+ contentType?: string,
97
+ ): FileSizeCheckResult {
98
+ const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2);
99
+ if (fileSize > FILE_MAX_BYTES) {
100
+ return {
101
+ finalType: detectedType,
102
+ shouldReject: true,
103
+ rejectReason: `文件大小 ${fileSizeMB}MB 超过了企业微信允许的最大限制 20MB,无法发送。`,
104
+ downgraded: false,
105
+ };
106
+ }
107
+
108
+ switch (detectedType) {
109
+ case "image":
110
+ if (fileSize > IMAGE_MAX_BYTES) {
111
+ return {
112
+ finalType: "file",
113
+ shouldReject: false,
114
+ downgraded: true,
115
+ downgradeNote: `图片大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
116
+ };
117
+ }
118
+ break;
119
+ case "video":
120
+ if (fileSize > VIDEO_MAX_BYTES) {
121
+ return {
122
+ finalType: "file",
123
+ shouldReject: false,
124
+ downgraded: true,
125
+ downgradeNote: `视频大小 ${fileSizeMB}MB 超过 10MB 限制,已转为文件格式发送`,
126
+ };
127
+ }
128
+ break;
129
+ case "voice":
130
+ if (contentType && !VOICE_SUPPORTED_MIMES.has(contentType.toLowerCase())) {
131
+ return {
132
+ finalType: "file",
133
+ shouldReject: false,
134
+ downgraded: true,
135
+ downgradeNote: `语音格式 ${contentType} 不支持,企微仅支持 AMR 格式,已转为文件格式发送`,
136
+ };
137
+ }
138
+ if (fileSize > VOICE_MAX_BYTES) {
139
+ return {
140
+ finalType: "file",
141
+ shouldReject: false,
142
+ downgraded: true,
143
+ downgradeNote: `语音大小 ${fileSizeMB}MB 超过 2MB 限制,已转为文件格式发送`,
144
+ };
145
+ }
146
+ break;
147
+ default:
148
+ break;
149
+ }
150
+
151
+ return {
152
+ finalType: detectedType,
153
+ shouldReject: false,
154
+ downgraded: false,
155
+ };
156
+ }
157
+
158
+ async function resolveMediaFile(
159
+ mediaUrl: string,
160
+ mediaLocalRoots?: readonly string[],
161
+ ): Promise<ResolvedMediaFile> {
162
+ const result = await loadOutboundMediaFromUrl(mediaUrl, {
163
+ maxBytes: FILE_MAX_BYTES,
164
+ mediaLocalRoots,
165
+ });
166
+ let contentType = result.contentType || "application/octet-stream";
167
+ if (contentType === "application/octet-stream" || contentType === "text/plain") {
168
+ const detected = await detectMime({
169
+ buffer: result.buffer,
170
+ filePath: result.fileName ?? mediaUrl,
171
+ });
172
+ if (detected) {
173
+ contentType = detected;
174
+ }
175
+ }
176
+ return {
177
+ buffer: result.buffer,
178
+ contentType,
179
+ fileName: extractFileName(mediaUrl, result.fileName, contentType),
180
+ };
181
+ }
182
+
183
+ export async function uploadAndSendBotWsMedia(params: {
184
+ wsClient: WSClient;
185
+ mediaUrl: string;
186
+ chatId: string;
187
+ mediaLocalRoots?: readonly string[];
188
+ }): Promise<BotWsMediaSendResult> {
189
+ try {
190
+ const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots);
191
+ const detectedType = detectWeComMediaType(media.contentType);
192
+ const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
193
+ if (sizeCheck.shouldReject) {
194
+ return {
195
+ ok: false,
196
+ rejected: true,
197
+ rejectReason: sizeCheck.rejectReason,
198
+ finalType: sizeCheck.finalType,
199
+ };
200
+ }
201
+
202
+ const uploadResult = await params.wsClient.uploadMedia(media.buffer, {
203
+ type: sizeCheck.finalType,
204
+ filename: media.fileName,
205
+ });
206
+ const sendResult = await params.wsClient.sendMediaMessage(
207
+ params.chatId,
208
+ sizeCheck.finalType,
209
+ uploadResult.media_id,
210
+ );
211
+
212
+ return {
213
+ ok: true,
214
+ messageId: sendResult?.headers?.req_id ?? `wecom-media-${Date.now()}`,
215
+ finalType: sizeCheck.finalType,
216
+ downgraded: sizeCheck.downgraded,
217
+ downgradeNote: sizeCheck.downgradeNote,
218
+ };
219
+ } catch (error) {
220
+ return {
221
+ ok: false,
222
+ error: error instanceof Error ? error.message : String(error),
223
+ };
224
+ }
225
+ }
226
+
227
+ export async function uploadAndReplyBotWsMedia(params: {
228
+ wsClient: WSClient;
229
+ frame: WsFrameHeaders;
230
+ mediaUrl: string;
231
+ mediaLocalRoots?: readonly string[];
232
+ }): Promise<BotWsMediaSendResult> {
233
+ try {
234
+ const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots);
235
+ const detectedType = detectWeComMediaType(media.contentType);
236
+ const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
237
+ if (sizeCheck.shouldReject) {
238
+ return {
239
+ ok: false,
240
+ rejected: true,
241
+ rejectReason: sizeCheck.rejectReason,
242
+ finalType: sizeCheck.finalType,
243
+ };
244
+ }
245
+
246
+ const uploadResult = await params.wsClient.uploadMedia(media.buffer, {
247
+ type: sizeCheck.finalType,
248
+ filename: media.fileName,
249
+ });
250
+ const replyResult = await params.wsClient.replyMedia(
251
+ params.frame,
252
+ sizeCheck.finalType,
253
+ uploadResult.media_id,
254
+ );
255
+
256
+ return {
257
+ ok: true,
258
+ messageId: replyResult?.headers?.req_id ?? `wecom-reply-media-${Date.now()}`,
259
+ finalType: sizeCheck.finalType,
260
+ downgraded: sizeCheck.downgraded,
261
+ downgradeNote: sizeCheck.downgradeNote,
262
+ };
263
+ } catch (error) {
264
+ return {
265
+ ok: false,
266
+ error: error instanceof Error ? error.message : String(error),
267
+ };
268
+ }
269
+ }
@@ -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
- [{ headers: { req_id: "req-expired" }, errcode: 846608, errmsg: "stream message update expired (>6 minutes), cannot update" }],
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
- "alice",
223
- {
224
- msgtype: "markdown",
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
  });