@yanhaidao/wecom 2.3.160 → 2.3.190
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +294 -379
- package/SKILLS_CAL.md +895 -0
- package/SKILLS_DOC.md +2288 -0
- package/changelog/v2.3.18.md +22 -0
- package/changelog/v2.3.19.md +73 -0
- package/index.ts +39 -3
- package/package.json +2 -3
- package/src/agent/handler.event-filter.test.ts +11 -0
- package/src/agent/handler.ts +732 -643
- package/src/app/account-runtime.ts +46 -20
- package/src/app/index.ts +20 -1
- package/src/capability/bot/stream-orchestrator.ts +1 -1
- package/src/capability/calendar/SKILLS_CHECKLIST.md +251 -0
- package/src/capability/calendar/client.ts +815 -0
- package/src/capability/calendar/index.ts +3 -0
- package/src/capability/calendar/schema.ts +417 -0
- package/src/capability/calendar/tool.ts +417 -0
- package/src/capability/calendar/types.ts +309 -0
- package/src/capability/doc/client.ts +788 -64
- package/src/capability/doc/schema.ts +419 -318
- package/src/capability/doc/tool.ts +1517 -1178
- package/src/capability/doc/types.ts +130 -14
- package/src/capability/mcp/index.ts +10 -0
- package/src/capability/mcp/schema.ts +107 -0
- package/src/capability/mcp/tool.ts +170 -0
- package/src/capability/mcp/transport.ts +394 -0
- package/src/channel.ts +70 -28
- package/src/config/index.ts +7 -1
- package/src/config/media.test.ts +113 -0
- package/src/config/media.ts +133 -6
- package/src/config/schema.ts +74 -102
- package/src/outbound.test.ts +250 -15
- package/src/outbound.ts +155 -30
- package/src/runtime/reply-orchestrator.test.ts +35 -2
- package/src/runtime/reply-orchestrator.ts +14 -2
- package/src/runtime/routing-bridge.test.ts +115 -0
- package/src/runtime/routing-bridge.ts +26 -1
- package/src/runtime/session-manager.ts +20 -6
- package/src/runtime/source-registry.ts +165 -0
- package/src/transport/bot-webhook/inbound-normalizer.ts +4 -4
- package/src/transport/bot-ws/media.test.ts +44 -0
- package/src/transport/bot-ws/media.ts +272 -0
- package/src/transport/bot-ws/reply.test.ts +216 -18
- package/src/transport/bot-ws/reply.ts +116 -21
- package/src/transport/bot-ws/sdk-adapter.test.ts +64 -1
- package/src/transport/bot-ws/sdk-adapter.ts +89 -12
- package/src/types/config.ts +3 -0
- package/.claude/settings.local.json +0 -11
- package/docs/update-content-fix.md +0 -135
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|