@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.
Files changed (49) hide show
  1. package/README.md +294 -379
  2. package/SKILLS_CAL.md +895 -0
  3. package/SKILLS_DOC.md +2288 -0
  4. package/changelog/v2.3.18.md +22 -0
  5. package/changelog/v2.3.19.md +73 -0
  6. package/index.ts +39 -3
  7. package/package.json +2 -3
  8. package/src/agent/handler.event-filter.test.ts +11 -0
  9. package/src/agent/handler.ts +732 -643
  10. package/src/app/account-runtime.ts +46 -20
  11. package/src/app/index.ts +20 -1
  12. package/src/capability/bot/stream-orchestrator.ts +1 -1
  13. package/src/capability/calendar/SKILLS_CHECKLIST.md +251 -0
  14. package/src/capability/calendar/client.ts +815 -0
  15. package/src/capability/calendar/index.ts +3 -0
  16. package/src/capability/calendar/schema.ts +417 -0
  17. package/src/capability/calendar/tool.ts +417 -0
  18. package/src/capability/calendar/types.ts +309 -0
  19. package/src/capability/doc/client.ts +788 -64
  20. package/src/capability/doc/schema.ts +419 -318
  21. package/src/capability/doc/tool.ts +1517 -1178
  22. package/src/capability/doc/types.ts +130 -14
  23. package/src/capability/mcp/index.ts +10 -0
  24. package/src/capability/mcp/schema.ts +107 -0
  25. package/src/capability/mcp/tool.ts +170 -0
  26. package/src/capability/mcp/transport.ts +394 -0
  27. package/src/channel.ts +70 -28
  28. package/src/config/index.ts +7 -1
  29. package/src/config/media.test.ts +113 -0
  30. package/src/config/media.ts +133 -6
  31. package/src/config/schema.ts +74 -102
  32. package/src/outbound.test.ts +250 -15
  33. package/src/outbound.ts +155 -30
  34. package/src/runtime/reply-orchestrator.test.ts +35 -2
  35. package/src/runtime/reply-orchestrator.ts +14 -2
  36. package/src/runtime/routing-bridge.test.ts +115 -0
  37. package/src/runtime/routing-bridge.ts +26 -1
  38. package/src/runtime/session-manager.ts +20 -6
  39. package/src/runtime/source-registry.ts +165 -0
  40. package/src/transport/bot-webhook/inbound-normalizer.ts +4 -4
  41. package/src/transport/bot-ws/media.test.ts +44 -0
  42. package/src/transport/bot-ws/media.ts +272 -0
  43. package/src/transport/bot-ws/reply.test.ts +216 -18
  44. package/src/transport/bot-ws/reply.ts +116 -21
  45. package/src/transport/bot-ws/sdk-adapter.test.ts +64 -1
  46. package/src/transport/bot-ws/sdk-adapter.ts +89 -12
  47. package/src/types/config.ts +3 -0
  48. package/.claude/settings.local.json +0 -11
  49. package/docs/update-content-fix.md +0 -135
@@ -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
+ }
@@ -257,7 +257,7 @@ export async function processBotInboundMessage(params: {
257
257
  const { target, msg, recordOperationalIssue } = params;
258
258
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
259
259
  const aesKey = target.account.encodingAESKey;
260
- const maxBytes = resolveWecomMediaMaxBytes(target.config);
260
+ const maxBytes = resolveWecomMediaMaxBytes(target.config, target.account.accountId);
261
261
  const proxyUrl = resolveWecomEgressProxyUrl(target.config);
262
262
 
263
263
  if (msgtype === "image") {
@@ -275,7 +275,7 @@ export async function processBotInboundMessage(params: {
275
275
  });
276
276
  return { body: "[image]", media: { buffer: decrypted.buffer, contentType: inferred.contentType, filename: inferred.filename } };
277
277
  } catch (err) {
278
- target.runtime.error?.(`图片解密失败: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
278
+ target.runtime.error?.(`图片解密失败: ${String(err)}; 可调大 channels.wecom.mediaMaxMb(当前=${Math.round(maxBytes / (1024 * 1024))}MB)例如:openclaw config set channels.wecom.mediaMaxMb 50`);
279
279
  recordOperationalIssue({
280
280
  category: "media-decrypt-failed",
281
281
  messageId: msg.msgid ? String(msg.msgid) : undefined,
@@ -304,7 +304,7 @@ export async function processBotInboundMessage(params: {
304
304
  });
305
305
  return { body: "[file]", media: { buffer: decrypted.buffer, contentType: inferred.contentType, filename: inferred.filename } };
306
306
  } catch (err) {
307
- target.runtime.error?.(`Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
307
+ target.runtime.error?.(`Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.mediaMaxMb(当前=${Math.round(maxBytes / (1024 * 1024))}MB)例如:openclaw config set channels.wecom.mediaMaxMb 50`);
308
308
  recordOperationalIssue({
309
309
  category: "media-decrypt-failed",
310
310
  messageId: msg.msgid ? String(msg.msgid) : undefined,
@@ -347,7 +347,7 @@ export async function processBotInboundMessage(params: {
347
347
  bodyParts.push(`[${t}]`);
348
348
  continue;
349
349
  } catch (err) {
350
- target.runtime.error?.(`Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`);
350
+ target.runtime.error?.(`Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.mediaMaxMb(当前=${Math.round(maxBytes / (1024 * 1024))}MB)例如:openclaw config set channels.wecom.mediaMaxMb 50`);
351
351
  recordOperationalIssue({
352
352
  category: "media-decrypt-failed",
353
353
  messageId: msg.msgid ? String(msg.msgid) : undefined,
@@ -0,0 +1,44 @@
1
+ import type { WSClient } from "@wecom/aibot-node-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { uploadAndSendBotWsMedia } from "./media.js";
5
+ import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk";
6
+
7
+ vi.mock("openclaw/plugin-sdk", () => ({
8
+ detectMime: vi.fn(),
9
+ loadOutboundMediaFromUrl: vi.fn(),
10
+ }));
11
+
12
+ describe("uploadAndSendBotWsMedia", () => {
13
+ const loadOutboundMediaFromUrlMock = vi.mocked(loadOutboundMediaFromUrl);
14
+
15
+ beforeEach(() => {
16
+ loadOutboundMediaFromUrlMock.mockReset();
17
+ loadOutboundMediaFromUrlMock.mockResolvedValue({
18
+ buffer: Buffer.from("png"),
19
+ contentType: "image/png",
20
+ fileName: "sample.png",
21
+ } as never);
22
+ });
23
+
24
+ it("passes the configured maxBytes to outbound media loading", async () => {
25
+ const wsClient = {
26
+ uploadMedia: vi.fn().mockResolvedValue({ media_id: "media-1" }),
27
+ sendMediaMessage: vi.fn().mockResolvedValue({ headers: { req_id: "req-1" } }),
28
+ } as unknown as WSClient;
29
+
30
+ await uploadAndSendBotWsMedia({
31
+ wsClient,
32
+ chatId: "hidao",
33
+ mediaUrl: "https://example.com/sample.png",
34
+ maxBytes: 42 * 1024 * 1024,
35
+ });
36
+
37
+ expect(loadOutboundMediaFromUrlMock).toHaveBeenCalledWith(
38
+ "https://example.com/sample.png",
39
+ expect.objectContaining({
40
+ maxBytes: 42 * 1024 * 1024,
41
+ }),
42
+ );
43
+ });
44
+ });
@@ -0,0 +1,272 @@
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
+ maxBytes?: number,
162
+ ): Promise<ResolvedMediaFile> {
163
+ const result = await loadOutboundMediaFromUrl(mediaUrl, {
164
+ maxBytes: maxBytes ?? FILE_MAX_BYTES,
165
+ mediaLocalRoots,
166
+ });
167
+ let contentType = result.contentType || "application/octet-stream";
168
+ if (contentType === "application/octet-stream" || contentType === "text/plain") {
169
+ const detected = await detectMime({
170
+ buffer: result.buffer,
171
+ filePath: result.fileName ?? mediaUrl,
172
+ });
173
+ if (detected) {
174
+ contentType = detected;
175
+ }
176
+ }
177
+ return {
178
+ buffer: result.buffer,
179
+ contentType,
180
+ fileName: extractFileName(mediaUrl, result.fileName, contentType),
181
+ };
182
+ }
183
+
184
+ export async function uploadAndSendBotWsMedia(params: {
185
+ wsClient: WSClient;
186
+ mediaUrl: string;
187
+ chatId: string;
188
+ mediaLocalRoots?: readonly string[];
189
+ maxBytes?: number;
190
+ }): Promise<BotWsMediaSendResult> {
191
+ try {
192
+ const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots, params.maxBytes);
193
+ const detectedType = detectWeComMediaType(media.contentType);
194
+ const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
195
+ if (sizeCheck.shouldReject) {
196
+ return {
197
+ ok: false,
198
+ rejected: true,
199
+ rejectReason: sizeCheck.rejectReason,
200
+ finalType: sizeCheck.finalType,
201
+ };
202
+ }
203
+
204
+ const uploadResult = await params.wsClient.uploadMedia(media.buffer, {
205
+ type: sizeCheck.finalType,
206
+ filename: media.fileName,
207
+ });
208
+ const sendResult = await params.wsClient.sendMediaMessage(
209
+ params.chatId,
210
+ sizeCheck.finalType,
211
+ uploadResult.media_id,
212
+ );
213
+
214
+ return {
215
+ ok: true,
216
+ messageId: sendResult?.headers?.req_id ?? `wecom-media-${Date.now()}`,
217
+ finalType: sizeCheck.finalType,
218
+ downgraded: sizeCheck.downgraded,
219
+ downgradeNote: sizeCheck.downgradeNote,
220
+ };
221
+ } catch (error) {
222
+ return {
223
+ ok: false,
224
+ error: error instanceof Error ? error.message : String(error),
225
+ };
226
+ }
227
+ }
228
+
229
+ export async function uploadAndReplyBotWsMedia(params: {
230
+ wsClient: WSClient;
231
+ frame: WsFrameHeaders;
232
+ mediaUrl: string;
233
+ mediaLocalRoots?: readonly string[];
234
+ maxBytes?: number;
235
+ }): Promise<BotWsMediaSendResult> {
236
+ try {
237
+ const media = await resolveMediaFile(params.mediaUrl, params.mediaLocalRoots, params.maxBytes);
238
+ const detectedType = detectWeComMediaType(media.contentType);
239
+ const sizeCheck = applyFileSizeLimits(media.buffer.length, detectedType, media.contentType);
240
+ if (sizeCheck.shouldReject) {
241
+ return {
242
+ ok: false,
243
+ rejected: true,
244
+ rejectReason: sizeCheck.rejectReason,
245
+ finalType: sizeCheck.finalType,
246
+ };
247
+ }
248
+
249
+ const uploadResult = await params.wsClient.uploadMedia(media.buffer, {
250
+ type: sizeCheck.finalType,
251
+ filename: media.fileName,
252
+ });
253
+ const replyResult = await params.wsClient.replyMedia(
254
+ params.frame,
255
+ sizeCheck.finalType,
256
+ uploadResult.media_id,
257
+ );
258
+
259
+ return {
260
+ ok: true,
261
+ messageId: replyResult?.headers?.req_id ?? `wecom-reply-media-${Date.now()}`,
262
+ finalType: sizeCheck.finalType,
263
+ downgraded: sizeCheck.downgraded,
264
+ downgradeNote: sizeCheck.downgradeNote,
265
+ };
266
+ } catch (error) {
267
+ return {
268
+ ok: false,
269
+ error: error instanceof Error ? error.message : String(error),
270
+ };
271
+ }
272
+ }