@yanhaidao/wecom 2.3.270 → 2.4.160
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 +79 -3
- package/UPSTREAM_CONFIG.md +170 -0
- package/UPSTREAM_PLAN.md +175 -0
- package/changelog/v2.4.12.md +37 -0
- package/changelog/v2.4.16.md +19 -0
- package/package.json +1 -1
- package/src/agent/handler.event-filter.test.ts +30 -1
- package/src/agent/handler.ts +226 -17
- package/src/app/account-runtime.ts +1 -1
- package/src/capability/agent/upstream-delivery-service.ts +96 -0
- package/src/capability/bot/sandbox-media.test.ts +221 -0
- package/src/capability/bot/sandbox-media.ts +176 -0
- package/src/capability/bot/stream-orchestrator.ts +19 -0
- package/src/channel.meta.test.ts +10 -0
- package/src/channel.ts +4 -1
- package/src/config/index.ts +5 -1
- package/src/config/network.ts +33 -0
- package/src/config/schema.ts +4 -0
- package/src/context-store.ts +41 -8
- package/src/http.ts +9 -1
- package/src/outbound.test.ts +211 -2
- package/src/outbound.ts +323 -70
- package/src/runtime/session-manager.test.ts +39 -0
- package/src/runtime/session-manager.ts +17 -0
- package/src/runtime/source-registry.ts +5 -0
- package/src/shared/media-asset.ts +78 -0
- package/src/shared/media-service.test.ts +111 -0
- package/src/shared/media-service.ts +42 -14
- package/src/target.ts +40 -0
- package/src/transport/agent-api/client.ts +233 -0
- package/src/transport/agent-api/core.ts +101 -5
- package/src/transport/agent-api/upstream-delivery.ts +45 -0
- package/src/transport/agent-api/upstream-media-upload.ts +70 -0
- package/src/transport/agent-api/upstream-reply.ts +43 -0
- package/src/transport/bot-webhook/inbound-normalizer.test.ts +433 -0
- package/src/transport/bot-webhook/inbound-normalizer.ts +240 -53
- package/src/transport/bot-webhook/message-shape.ts +3 -0
- package/src/transport/bot-ws/inbound.test.ts +195 -1
- package/src/transport/bot-ws/inbound.ts +57 -10
- package/src/types/config.ts +22 -0
- package/src/types/message.ts +11 -7
- package/src/upstream/index.ts +150 -0
- package/src/upstream.test.ts +84 -0
- package/vitest.config.ts +15 -4
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ResolvedAgentAccount } from "../../types/index.js";
|
|
2
|
+
import type { WecomTarget } from "../../target.js";
|
|
3
|
+
import { sendUpstreamAgentApiMediaReply, sendUpstreamAgentApiTextReply } from "./upstream-reply.js";
|
|
4
|
+
|
|
5
|
+
export async function deliverUpstreamAgentApiText(params: {
|
|
6
|
+
upstreamAgent: ResolvedAgentAccount;
|
|
7
|
+
primaryAgent: ResolvedAgentAccount;
|
|
8
|
+
target: WecomTarget;
|
|
9
|
+
text: string;
|
|
10
|
+
}): Promise<void> {
|
|
11
|
+
await sendUpstreamAgentApiTextReply(params);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function deliverUpstreamAgentApiMedia(params: {
|
|
15
|
+
upstreamAgent: ResolvedAgentAccount;
|
|
16
|
+
primaryAgent: ResolvedAgentAccount;
|
|
17
|
+
target: WecomTarget;
|
|
18
|
+
buffer: Buffer;
|
|
19
|
+
filename: string;
|
|
20
|
+
contentType: string;
|
|
21
|
+
text?: string;
|
|
22
|
+
}): Promise<void> {
|
|
23
|
+
let mediaType: "image" | "voice" | "video" | "file" = "file";
|
|
24
|
+
if (params.contentType.startsWith("image/")) mediaType = "image";
|
|
25
|
+
else if (params.contentType.startsWith("audio/")) mediaType = "voice";
|
|
26
|
+
else if (params.contentType.startsWith("video/")) mediaType = "video";
|
|
27
|
+
|
|
28
|
+
const { uploadUpstreamAgentApiMedia } = await import("./upstream-media-upload.js");
|
|
29
|
+
const mediaId = await uploadUpstreamAgentApiMedia({
|
|
30
|
+
upstreamAgent: params.upstreamAgent,
|
|
31
|
+
primaryAgent: params.primaryAgent,
|
|
32
|
+
type: mediaType,
|
|
33
|
+
buffer: params.buffer,
|
|
34
|
+
filename: params.filename,
|
|
35
|
+
});
|
|
36
|
+
await sendUpstreamAgentApiMediaReply({
|
|
37
|
+
upstreamAgent: params.upstreamAgent,
|
|
38
|
+
primaryAgent: params.primaryAgent,
|
|
39
|
+
target: params.target,
|
|
40
|
+
mediaId,
|
|
41
|
+
mediaType,
|
|
42
|
+
title: mediaType === "video" ? params.text?.trim().slice(0, 64) : undefined,
|
|
43
|
+
description: mediaType === "video" ? params.text?.trim().slice(0, 512) : undefined,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { resolveWecomEgressProxyUrlFromNetwork } from "../../config/index.js";
|
|
4
|
+
import { LIMITS } from "../../types/constants.js";
|
|
5
|
+
import { wecomFetch } from "../../http.js";
|
|
6
|
+
import type { ResolvedAgentAccount } from "../../types/index.js";
|
|
7
|
+
import { guessUploadContentType, normalizeUploadFilename } from "./core.js";
|
|
8
|
+
import { getUpstreamAgentApiAccessToken } from "./client.js";
|
|
9
|
+
|
|
10
|
+
export async function uploadUpstreamAgentApiMedia(params: {
|
|
11
|
+
upstreamAgent: ResolvedAgentAccount;
|
|
12
|
+
primaryAgent: ResolvedAgentAccount;
|
|
13
|
+
type: "image" | "voice" | "video" | "file";
|
|
14
|
+
buffer: Buffer;
|
|
15
|
+
filename: string;
|
|
16
|
+
}): Promise<string> {
|
|
17
|
+
const { upstreamAgent, primaryAgent, type, buffer, filename } = params;
|
|
18
|
+
const safeFilename = normalizeUploadFilename(filename);
|
|
19
|
+
|
|
20
|
+
// 使用下游企业的 access_token
|
|
21
|
+
const token = await getUpstreamAgentApiAccessToken({
|
|
22
|
+
primaryAgent,
|
|
23
|
+
upstreamCorpId: upstreamAgent.corpId,
|
|
24
|
+
upstreamAgentId: upstreamAgent.agentId!,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const proxyUrl = resolveWecomEgressProxyUrlFromNetwork(upstreamAgent.network);
|
|
28
|
+
const url = `https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}&debug=1`;
|
|
29
|
+
|
|
30
|
+
const uploadOnce = async (fileContentType: string) => {
|
|
31
|
+
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
|
|
32
|
+
const header = Buffer.from(
|
|
33
|
+
`--${boundary}\r\n` +
|
|
34
|
+
`Content-Disposition: form-data; name="media"; filename="${safeFilename}"; filelength=${buffer.length}\r\n` +
|
|
35
|
+
`Content-Type: ${fileContentType}\r\n\r\n`,
|
|
36
|
+
);
|
|
37
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
38
|
+
const body = Buffer.concat([header, buffer, footer]);
|
|
39
|
+
|
|
40
|
+
const res = await wecomFetch(
|
|
41
|
+
url,
|
|
42
|
+
{
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
46
|
+
"Content-Length": String(body.length),
|
|
47
|
+
},
|
|
48
|
+
body,
|
|
49
|
+
},
|
|
50
|
+
{ proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
|
|
51
|
+
);
|
|
52
|
+
const json = (await res.json()) as { media_id?: string; errcode?: number; errmsg?: string };
|
|
53
|
+
return json;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const preferredContentType = guessUploadContentType(safeFilename);
|
|
57
|
+
let json = await uploadOnce(preferredContentType);
|
|
58
|
+
|
|
59
|
+
if (!json?.media_id && preferredContentType !== "application/octet-stream") {
|
|
60
|
+
console.warn(
|
|
61
|
+
`[wecom-upstream-upload] Upload failed with ${preferredContentType}, retrying as application/octet-stream: ${json?.errcode} ${json?.errmsg}`,
|
|
62
|
+
);
|
|
63
|
+
json = await uploadOnce("application/octet-stream");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!json?.media_id) {
|
|
67
|
+
throw new Error(`upload failed: ${json?.errcode} ${json?.errmsg}`);
|
|
68
|
+
}
|
|
69
|
+
return json.media_id;
|
|
70
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ResolvedAgentAccount } from "../../types/index.js";
|
|
2
|
+
import type { WecomTarget } from "../../target.js";
|
|
3
|
+
import { sendUpstreamAgentApiMedia, sendUpstreamAgentApiText } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export async function sendUpstreamAgentApiTextReply(params: {
|
|
6
|
+
upstreamAgent: ResolvedAgentAccount;
|
|
7
|
+
primaryAgent: ResolvedAgentAccount;
|
|
8
|
+
target: WecomTarget;
|
|
9
|
+
text: string;
|
|
10
|
+
}): Promise<void> {
|
|
11
|
+
await sendUpstreamAgentApiText({
|
|
12
|
+
upstreamAgent: params.upstreamAgent,
|
|
13
|
+
primaryAgent: params.primaryAgent,
|
|
14
|
+
toUser: params.target.touser,
|
|
15
|
+
toParty: params.target.toparty,
|
|
16
|
+
toTag: params.target.totag,
|
|
17
|
+
chatId: params.target.chatid,
|
|
18
|
+
text: params.text,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function sendUpstreamAgentApiMediaReply(params: {
|
|
23
|
+
upstreamAgent: ResolvedAgentAccount;
|
|
24
|
+
primaryAgent: ResolvedAgentAccount;
|
|
25
|
+
target: WecomTarget;
|
|
26
|
+
mediaId: string;
|
|
27
|
+
mediaType: "image" | "voice" | "video" | "file";
|
|
28
|
+
title?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
}): Promise<void> {
|
|
31
|
+
await sendUpstreamAgentApiMedia({
|
|
32
|
+
upstreamAgent: params.upstreamAgent,
|
|
33
|
+
primaryAgent: params.primaryAgent,
|
|
34
|
+
toUser: params.target.touser,
|
|
35
|
+
toParty: params.target.toparty,
|
|
36
|
+
toTag: params.target.totag,
|
|
37
|
+
chatId: params.target.chatid,
|
|
38
|
+
mediaId: params.mediaId,
|
|
39
|
+
mediaType: params.mediaType,
|
|
40
|
+
title: params.title,
|
|
41
|
+
description: params.description,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { processBotInboundMessage } from "./inbound-normalizer.js";
|
|
4
|
+
import { decryptWecomMediaWithMeta } from "../../media.js";
|
|
5
|
+
|
|
6
|
+
vi.mock("../../media.js", () => ({
|
|
7
|
+
decryptWecomMediaWithMeta: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe("processBotInboundMessage quote media", () => {
|
|
11
|
+
const recordOperationalIssue = vi.fn();
|
|
12
|
+
const logError = vi.fn();
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("downloads quote.file for text messages", async () => {
|
|
19
|
+
vi.mocked(decryptWecomMediaWithMeta).mockResolvedValue({
|
|
20
|
+
buffer: Buffer.from("%PDF-1.7 test"),
|
|
21
|
+
sourceContentType: "application/pdf",
|
|
22
|
+
sourceFilename: "quoted.pdf",
|
|
23
|
+
sourceUrl: "https://example.com/quoted.pdf",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const result = await processBotInboundMessage({
|
|
27
|
+
target: {
|
|
28
|
+
account: {
|
|
29
|
+
accountId: "purple",
|
|
30
|
+
encodingAESKey: "account-aes-key",
|
|
31
|
+
},
|
|
32
|
+
config: {
|
|
33
|
+
channels: {
|
|
34
|
+
wecom: {
|
|
35
|
+
mediaMaxMb: 24,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
runtime: {
|
|
40
|
+
error: logError,
|
|
41
|
+
},
|
|
42
|
+
} as never,
|
|
43
|
+
msg: {
|
|
44
|
+
msgid: "msg-quote-file",
|
|
45
|
+
msgtype: "text",
|
|
46
|
+
text: { content: "看这个引用" },
|
|
47
|
+
quote: {
|
|
48
|
+
msgtype: "file",
|
|
49
|
+
file: {
|
|
50
|
+
url: "https://example.com/quoted.pdf",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
} as never,
|
|
54
|
+
recordOperationalIssue,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(decryptWecomMediaWithMeta).toHaveBeenCalledWith(
|
|
58
|
+
"https://example.com/quoted.pdf",
|
|
59
|
+
"account-aes-key",
|
|
60
|
+
expect.objectContaining({
|
|
61
|
+
maxBytes: 24 * 1024 * 1024,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
expect(result.media).toBeDefined();
|
|
65
|
+
expect(result.media?.contentType).toBe("application/pdf");
|
|
66
|
+
expect(result.body).toContain("[引用: 文件]");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keeps quote.voice as text only without media decryption", async () => {
|
|
70
|
+
const result = await processBotInboundMessage({
|
|
71
|
+
target: {
|
|
72
|
+
account: {
|
|
73
|
+
accountId: "purple",
|
|
74
|
+
encodingAESKey: "account-aes-key",
|
|
75
|
+
},
|
|
76
|
+
config: {
|
|
77
|
+
channels: {
|
|
78
|
+
wecom: {
|
|
79
|
+
mediaMaxMb: 24,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
runtime: {
|
|
84
|
+
error: logError,
|
|
85
|
+
},
|
|
86
|
+
} as never,
|
|
87
|
+
msg: {
|
|
88
|
+
msgid: "msg-quote-voice",
|
|
89
|
+
msgtype: "text",
|
|
90
|
+
text: { content: "这个语音引用是什么意思" },
|
|
91
|
+
quote: {
|
|
92
|
+
msgtype: "voice",
|
|
93
|
+
voice: { content: "这里是语音转写文本" },
|
|
94
|
+
},
|
|
95
|
+
} as never,
|
|
96
|
+
recordOperationalIssue,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.media).toBeUndefined();
|
|
100
|
+
expect(result.body).toContain("[引用: 语音] 这里是语音转写文本");
|
|
101
|
+
expect(decryptWecomMediaWithMeta).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("extracts first image from quote.mixed", async () => {
|
|
105
|
+
vi.mocked(decryptWecomMediaWithMeta).mockResolvedValue({
|
|
106
|
+
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
107
|
+
sourceContentType: "image/png",
|
|
108
|
+
sourceFilename: "quoted.png",
|
|
109
|
+
sourceUrl: "https://example.com/quoted.png",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const result = await processBotInboundMessage({
|
|
113
|
+
target: {
|
|
114
|
+
account: {
|
|
115
|
+
accountId: "purple",
|
|
116
|
+
encodingAESKey: "account-aes-key",
|
|
117
|
+
},
|
|
118
|
+
config: {
|
|
119
|
+
channels: {
|
|
120
|
+
wecom: {
|
|
121
|
+
mediaMaxMb: 24,
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
runtime: {
|
|
126
|
+
error: logError,
|
|
127
|
+
},
|
|
128
|
+
} as never,
|
|
129
|
+
msg: {
|
|
130
|
+
msgid: "msg-quote-mixed",
|
|
131
|
+
msgtype: "text",
|
|
132
|
+
text: { content: "看这个图文引用" },
|
|
133
|
+
quote: {
|
|
134
|
+
msgtype: "mixed",
|
|
135
|
+
mixed: {
|
|
136
|
+
msg_item: [
|
|
137
|
+
{ msgtype: "text", text: { content: "正文" } },
|
|
138
|
+
{ msgtype: "image", image: { url: "https://example.com/quoted.png", aeskey: "item-aes-key" } },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
} as never,
|
|
143
|
+
recordOperationalIssue,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result.media).toBeDefined();
|
|
147
|
+
expect(result.media?.contentType).toBe("image/png");
|
|
148
|
+
expect(decryptWecomMediaWithMeta).toHaveBeenCalledWith(
|
|
149
|
+
"https://example.com/quoted.png",
|
|
150
|
+
"item-aes-key",
|
|
151
|
+
expect.any(Object),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("records expired_or_forbidden reason for quote media decrypt failure", async () => {
|
|
156
|
+
vi.mocked(decryptWecomMediaWithMeta).mockRejectedValue(new Error("HTTP 403 forbidden"));
|
|
157
|
+
|
|
158
|
+
const result = await processBotInboundMessage({
|
|
159
|
+
target: {
|
|
160
|
+
account: {
|
|
161
|
+
accountId: "purple",
|
|
162
|
+
encodingAESKey: "account-aes-key",
|
|
163
|
+
},
|
|
164
|
+
config: {
|
|
165
|
+
channels: {
|
|
166
|
+
wecom: {
|
|
167
|
+
mediaMaxMb: 24,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
runtime: {
|
|
172
|
+
error: logError,
|
|
173
|
+
},
|
|
174
|
+
} as never,
|
|
175
|
+
msg: {
|
|
176
|
+
msgid: "msg-quote-fail",
|
|
177
|
+
msgtype: "text",
|
|
178
|
+
text: { content: "读取这个引用" },
|
|
179
|
+
quote: {
|
|
180
|
+
msgtype: "file",
|
|
181
|
+
file: { url: "https://example.com/expired.pdf" },
|
|
182
|
+
},
|
|
183
|
+
} as never,
|
|
184
|
+
recordOperationalIssue,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(result.media).toBeUndefined();
|
|
188
|
+
expect(result.body).toContain("[quote:file]");
|
|
189
|
+
expect(recordOperationalIssue).toHaveBeenCalledWith(
|
|
190
|
+
expect.objectContaining({
|
|
191
|
+
summary: expect.stringContaining("reason=expired_or_forbidden"),
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("downloads quote.image for text messages", async () => {
|
|
197
|
+
vi.mocked(decryptWecomMediaWithMeta).mockResolvedValue({
|
|
198
|
+
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
199
|
+
sourceContentType: "image/png",
|
|
200
|
+
sourceFilename: "quoted.png",
|
|
201
|
+
sourceUrl: "https://example.com/quoted.png",
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const result = await processBotInboundMessage({
|
|
205
|
+
target: {
|
|
206
|
+
account: {
|
|
207
|
+
accountId: "purple",
|
|
208
|
+
encodingAESKey: "account-aes-key",
|
|
209
|
+
},
|
|
210
|
+
config: {
|
|
211
|
+
channels: {
|
|
212
|
+
wecom: {
|
|
213
|
+
mediaMaxMb: 24,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
runtime: {
|
|
218
|
+
error: logError,
|
|
219
|
+
},
|
|
220
|
+
} as never,
|
|
221
|
+
msg: {
|
|
222
|
+
msgid: "msg-quote-image",
|
|
223
|
+
msgtype: "text",
|
|
224
|
+
text: { content: "看这个引用图片" },
|
|
225
|
+
quote: {
|
|
226
|
+
msgtype: "image",
|
|
227
|
+
image: {
|
|
228
|
+
url: "https://example.com/quoted.png",
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
} as never,
|
|
232
|
+
recordOperationalIssue,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(decryptWecomMediaWithMeta).toHaveBeenCalledWith(
|
|
236
|
+
"https://example.com/quoted.png",
|
|
237
|
+
"account-aes-key",
|
|
238
|
+
expect.objectContaining({
|
|
239
|
+
maxBytes: 24 * 1024 * 1024,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
expect(result.media).toBeDefined();
|
|
243
|
+
expect(result.media?.contentType).toBe("image/png");
|
|
244
|
+
expect(result.body).toContain("[引用: 图片]");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("downloads quote.video for text messages", async () => {
|
|
248
|
+
vi.mocked(decryptWecomMediaWithMeta).mockResolvedValue({
|
|
249
|
+
buffer: Buffer.from([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]),
|
|
250
|
+
sourceContentType: "video/mp4",
|
|
251
|
+
sourceFilename: "quoted.mp4",
|
|
252
|
+
sourceUrl: "https://example.com/quoted.mp4",
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const result = await processBotInboundMessage({
|
|
256
|
+
target: {
|
|
257
|
+
account: {
|
|
258
|
+
accountId: "purple",
|
|
259
|
+
encodingAESKey: "account-aes-key",
|
|
260
|
+
},
|
|
261
|
+
config: {
|
|
262
|
+
channels: {
|
|
263
|
+
wecom: {
|
|
264
|
+
mediaMaxMb: 24,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
runtime: {
|
|
269
|
+
error: logError,
|
|
270
|
+
},
|
|
271
|
+
} as never,
|
|
272
|
+
msg: {
|
|
273
|
+
msgid: "msg-quote-video",
|
|
274
|
+
msgtype: "text",
|
|
275
|
+
text: { content: "看这个引用视频" },
|
|
276
|
+
quote: {
|
|
277
|
+
msgtype: "video",
|
|
278
|
+
video: {
|
|
279
|
+
url: "https://example.com/quoted.mp4",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
} as never,
|
|
283
|
+
recordOperationalIssue,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(decryptWecomMediaWithMeta).toHaveBeenCalledWith(
|
|
287
|
+
"https://example.com/quoted.mp4",
|
|
288
|
+
"account-aes-key",
|
|
289
|
+
expect.objectContaining({
|
|
290
|
+
maxBytes: 24 * 1024 * 1024,
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
expect(result.media).toBeDefined();
|
|
294
|
+
expect(result.media?.contentType).toBe("video/mp4");
|
|
295
|
+
expect(result.body).toContain("[引用: 视频]");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("prioritizes top-level media over quote media", async () => {
|
|
299
|
+
vi.mocked(decryptWecomMediaWithMeta).mockResolvedValue({
|
|
300
|
+
buffer: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
301
|
+
sourceContentType: "image/png",
|
|
302
|
+
sourceFilename: "top-level.png",
|
|
303
|
+
sourceUrl: "https://example.com/top-level.png",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const result = await processBotInboundMessage({
|
|
307
|
+
target: {
|
|
308
|
+
account: {
|
|
309
|
+
accountId: "purple",
|
|
310
|
+
encodingAESKey: "account-aes-key",
|
|
311
|
+
},
|
|
312
|
+
config: {
|
|
313
|
+
channels: {
|
|
314
|
+
wecom: {
|
|
315
|
+
mediaMaxMb: 24,
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
runtime: {
|
|
320
|
+
error: logError,
|
|
321
|
+
},
|
|
322
|
+
} as never,
|
|
323
|
+
msg: {
|
|
324
|
+
msgid: "msg-both-media",
|
|
325
|
+
msgtype: "image",
|
|
326
|
+
image: {
|
|
327
|
+
url: "https://example.com/top-level.png",
|
|
328
|
+
},
|
|
329
|
+
quote: {
|
|
330
|
+
msgtype: "file",
|
|
331
|
+
file: {
|
|
332
|
+
url: "https://example.com/quoted.pdf",
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
} as never,
|
|
336
|
+
recordOperationalIssue,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Should have downloaded top-level image, not quote file
|
|
340
|
+
expect(decryptWecomMediaWithMeta).toHaveBeenCalledWith(
|
|
341
|
+
"https://example.com/top-level.png",
|
|
342
|
+
"account-aes-key",
|
|
343
|
+
expect.any(Object),
|
|
344
|
+
);
|
|
345
|
+
expect(decryptWecomMediaWithMeta).not.toHaveBeenCalledWith(
|
|
346
|
+
expect.stringContaining("quoted.pdf"),
|
|
347
|
+
expect.any(String),
|
|
348
|
+
expect.any(Object),
|
|
349
|
+
);
|
|
350
|
+
expect(result.media?.contentType).toBe("image/png");
|
|
351
|
+
expect(result.body).toBe("[image]");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("classifies timeout error for quote media", async () => {
|
|
355
|
+
vi.mocked(decryptWecomMediaWithMeta).mockRejectedValue(new Error("Request timeout after 15000ms"));
|
|
356
|
+
|
|
357
|
+
const result = await processBotInboundMessage({
|
|
358
|
+
target: {
|
|
359
|
+
account: {
|
|
360
|
+
accountId: "purple",
|
|
361
|
+
encodingAESKey: "account-aes-key",
|
|
362
|
+
},
|
|
363
|
+
config: {
|
|
364
|
+
channels: {
|
|
365
|
+
wecom: {
|
|
366
|
+
mediaMaxMb: 24,
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
runtime: {
|
|
371
|
+
error: logError,
|
|
372
|
+
},
|
|
373
|
+
} as never,
|
|
374
|
+
msg: {
|
|
375
|
+
msgid: "msg-quote-timeout",
|
|
376
|
+
msgtype: "text",
|
|
377
|
+
text: { content: "下载这个文件" },
|
|
378
|
+
quote: {
|
|
379
|
+
msgtype: "file",
|
|
380
|
+
file: { url: "https://example.com/slow.pdf" },
|
|
381
|
+
},
|
|
382
|
+
} as never,
|
|
383
|
+
recordOperationalIssue,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(result.media).toBeUndefined();
|
|
387
|
+
expect(recordOperationalIssue).toHaveBeenCalledWith(
|
|
388
|
+
expect.objectContaining({
|
|
389
|
+
summary: expect.stringContaining("reason=timeout"),
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("classifies decrypt error for quote media", async () => {
|
|
395
|
+
vi.mocked(decryptWecomMediaWithMeta).mockRejectedValue(new Error("Bad decrypt"));
|
|
396
|
+
|
|
397
|
+
const result = await processBotInboundMessage({
|
|
398
|
+
target: {
|
|
399
|
+
account: {
|
|
400
|
+
accountId: "purple",
|
|
401
|
+
encodingAESKey: "account-aes-key",
|
|
402
|
+
},
|
|
403
|
+
config: {
|
|
404
|
+
channels: {
|
|
405
|
+
wecom: {
|
|
406
|
+
mediaMaxMb: 24,
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
runtime: {
|
|
411
|
+
error: logError,
|
|
412
|
+
},
|
|
413
|
+
} as never,
|
|
414
|
+
msg: {
|
|
415
|
+
msgid: "msg-quote-decrypt-fail",
|
|
416
|
+
msgtype: "voice",
|
|
417
|
+
voice: { content: "这是语音转写" },
|
|
418
|
+
quote: {
|
|
419
|
+
msgtype: "image",
|
|
420
|
+
image: { url: "https://example.com/bad.png" },
|
|
421
|
+
},
|
|
422
|
+
} as never,
|
|
423
|
+
recordOperationalIssue,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(result.media).toBeUndefined();
|
|
427
|
+
expect(recordOperationalIssue).toHaveBeenCalledWith(
|
|
428
|
+
expect.objectContaining({
|
|
429
|
+
summary: expect.stringContaining("reason=decrypt"),
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
});
|