Skip to content

Commit 3e155d4

Browse files
勾勾的数字生命勾勾的数字生命
authored andcommitted
fix: harden wecom media and voice handling
1 parent 836fa2f commit 3e155d4

20 files changed

Lines changed: 902 additions & 128 deletions

src/wecom/api-client-send-text.js

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ export function createWecomTextSender({
1111
throw new Error("createWecomTextSender: sendWecomTypedMessage is required");
1212
}
1313

14+
const targetSendChains = new Map();
15+
16+
function buildTargetKey({ corpId, agentId, toUser, toParty, toTag, chatId } = {}) {
17+
const accountKey = `${corpId || "corp:unknown"}:${agentId || "agent:unknown"}`;
18+
if (chatId) return `${accountKey}:chat:${chatId}`;
19+
return `${accountKey}:direct:${[toUser, toParty, toTag].filter(Boolean).join("|") || "unknown"}`;
20+
}
21+
22+
async function enqueueTargetSend(targetKey, task) {
23+
const previous = targetSendChains.get(targetKey) || Promise.resolve();
24+
const run = previous.catch(() => {}).then(task);
25+
const tracked = run.finally(() => {
26+
if (targetSendChains.get(targetKey) === tracked) {
27+
targetSendChains.delete(targetKey);
28+
}
29+
});
30+
targetSendChains.set(targetKey, tracked);
31+
return run;
32+
}
33+
1434
async function sendWecomTextSingle({
1535
corpId,
1636
corpSecret,
@@ -57,29 +77,32 @@ export function createWecomTextSender({
5777
logger,
5878
proxyUrl,
5979
}) {
60-
const chunks = splitWecomText(text);
61-
logger?.info?.(`wecom: splitting message into ${chunks.length} chunks, total bytes=${getByteLength(text)}`);
80+
const targetKey = buildTargetKey({ corpId, agentId, toUser, toParty, toTag, chatId });
81+
return enqueueTargetSend(targetKey, async () => {
82+
const chunks = splitWecomText(text);
83+
logger?.info?.(`wecom: splitting message into ${chunks.length} chunks, total bytes=${getByteLength(text)}`);
6284

63-
for (let i = 0; i < chunks.length; i += 1) {
64-
logger?.info?.(`wecom: sending chunk ${i + 1}/${chunks.length}, bytes=${getByteLength(chunks[i])}`);
65-
// eslint-disable-next-line no-await-in-loop
66-
await sendWecomTextSingle({
67-
corpId,
68-
corpSecret,
69-
agentId,
70-
toUser,
71-
toParty,
72-
toTag,
73-
chatId,
74-
text: chunks[i],
75-
logger,
76-
proxyUrl,
77-
});
78-
if (i < chunks.length - 1) {
85+
for (let i = 0; i < chunks.length; i += 1) {
86+
logger?.info?.(`wecom: sending chunk ${i + 1}/${chunks.length}, bytes=${getByteLength(chunks[i])}`);
7987
// eslint-disable-next-line no-await-in-loop
80-
await sleep(300);
88+
await sendWecomTextSingle({
89+
corpId,
90+
corpSecret,
91+
agentId,
92+
toUser,
93+
toParty,
94+
toTag,
95+
chatId,
96+
text: chunks[i],
97+
logger,
98+
proxyUrl,
99+
});
100+
if (i < chunks.length - 1) {
101+
// eslint-disable-next-line no-await-in-loop
102+
await sleep(300);
103+
}
81104
}
82-
}
105+
});
83106
}
84107

85108
return {

src/wecom/bot-inbound-content.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ export function createWecomBotInboundContentBuilder({
4040
botProxyUrl,
4141
msgType = "text",
4242
commandBody = "",
43+
normalizedImageEntries = [],
4344
normalizedImageUrls = [],
4445
normalizedFileUrl = "",
4546
normalizedFileName = "",
47+
normalizedFileAesKey = "",
4648
normalizedVoiceUrl = "",
4749
normalizedVoiceMediaId = "",
4850
normalizedVoiceContentType = "",
@@ -52,12 +54,17 @@ export function createWecomBotInboundContentBuilder({
5254
const tempPathsToCleanup = [];
5355
let messageText = String(commandBody ?? "").trim();
5456

55-
if (normalizedImageUrls.length > 0) {
57+
if (normalizedImageUrls.length > 0 || normalizedImageEntries.length > 0) {
5658
const fetchedImagePaths = [];
57-
const imageUrlsToFetch = normalizedImageUrls.slice(0, 3);
59+
const imageEntriesToFetch =
60+
Array.isArray(normalizedImageEntries) && normalizedImageEntries.length > 0
61+
? normalizedImageEntries.slice(0, 3)
62+
: normalizedImageUrls.slice(0, 3).map((url) => ({ url, aesKey: "" }));
5863
const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
5964
await mkdir(tempDir, { recursive: true });
60-
for (const imageUrl of imageUrlsToFetch) {
65+
for (const imageEntry of imageEntriesToFetch) {
66+
const imageUrl = String(imageEntry?.url ?? "").trim();
67+
const imageAesKey = String(imageEntry?.aesKey ?? "").trim();
6168
try {
6269
const { buffer, contentType } = await fetchMediaFromUrl(imageUrl, {
6370
proxyUrl: botProxyUrl,
@@ -73,10 +80,11 @@ export function createWecomBotInboundContentBuilder({
7380
let effectiveBuffer = buffer;
7481
let effectiveImageType =
7582
normalizedType.startsWith("image/") ? normalizedType : detectImageContentTypeFromBuffer(buffer);
76-
if (!effectiveImageType && botModeConfig?.encodingAesKey) {
83+
const decryptAesKey = imageAesKey || String(botModeConfig?.encodingAesKey ?? "").trim();
84+
if (!effectiveImageType && decryptAesKey) {
7785
try {
7886
const decryptedBuffer = decryptWecomMediaBuffer({
79-
aesKey: botModeConfig.encodingAesKey,
87+
aesKey: decryptAesKey,
8088
encryptedBuffer: buffer,
8189
});
8290
const decryptedImageType = detectImageContentTypeFromBuffer(decryptedBuffer);
@@ -158,7 +166,7 @@ export function createWecomBotInboundContentBuilder({
158166
});
159167
const decrypted = smartDecryptWecomFileBuffer({
160168
buffer: downloaded.buffer,
161-
aesKey: botModeConfig?.encodingAesKey,
169+
aesKey: normalizedFileAesKey || botModeConfig?.encodingAesKey,
162170
contentType: downloaded.contentType,
163171
sourceUrl: downloaded.finalUrl || normalizedFileUrl,
164172
decryptFn: decryptWecomMediaBuffer,

src/wecom/bot-inbound-executor-helpers.js

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@ function assertFunction(name, value) {
44
}
55
}
66

7+
function normalizeBotImageEntries({ imageEntries, imageUrls } = {}) {
8+
const normalized = [];
9+
const seen = new Map();
10+
const sourceEntries = Array.isArray(imageEntries) && imageEntries.length > 0
11+
? imageEntries
12+
: Array.isArray(imageUrls)
13+
? imageUrls.map((url) => ({ url }))
14+
: [];
15+
for (const rawEntry of sourceEntries) {
16+
if (rawEntry == null) continue;
17+
const entry = typeof rawEntry === "string" ? { url: rawEntry } : rawEntry;
18+
const url = String(entry?.url ?? "").trim();
19+
if (!url) continue;
20+
const aesKey = String(entry?.aesKey ?? "").trim();
21+
const existingIndex = seen.get(url);
22+
if (existingIndex == null) {
23+
seen.set(url, normalized.length);
24+
normalized.push({ url, aesKey });
25+
continue;
26+
}
27+
if (!normalized[existingIndex].aesKey && aesKey) {
28+
normalized[existingIndex] = { url, aesKey };
29+
}
30+
}
31+
return normalized;
32+
}
33+
734
const UNSUPPORTED_BOT_GROUP_TRIGGER_WARNED = new Set();
835

936
function warnUnsupportedBotGroupTriggerOnce(triggerMode, logger) {
@@ -91,9 +118,11 @@ export function createWecomBotInboundFlowState({
91118
accountId = "default",
92119
fromUser,
93120
content,
121+
imageEntries,
94122
imageUrls,
95123
fileUrl,
96124
fileName,
125+
fileAesKey,
97126
voiceUrl,
98127
voiceMediaId,
99128
voiceContentType,
@@ -109,6 +138,7 @@ export function createWecomBotInboundFlowState({
109138
const normalizedAccountId = String(accountId ?? "default").trim().toLowerCase() || "default";
110139
const normalizedFromUser = String(fromUser ?? "").trim().toLowerCase();
111140
const baseSessionId = buildWecomBotSessionId(fromUser, normalizedAccountId);
141+
const normalizedImageEntries = normalizeBotImageEntries({ imageEntries, imageUrls });
112142
const state = {
113143
runtime,
114144
cfg,
@@ -127,8 +157,11 @@ export function createWecomBotInboundFlowState({
127157
tempPathsToCleanup: [],
128158
botModeConfig: resolveWecomBotConfig(api, normalizedAccountId),
129159
botProxyUrl: resolveWecomBotProxyConfig(api, normalizedAccountId),
160+
normalizedImageEntries,
161+
normalizedImageUrls: normalizedImageEntries.map((entry) => entry.url),
130162
normalizedFileUrl: String(fileUrl ?? "").trim(),
131163
normalizedFileName: String(fileName ?? "").trim(),
164+
normalizedFileAesKey: String(fileAesKey ?? "").trim(),
132165
normalizedVoiceUrl: String(voiceUrl ?? "").trim(),
133166
normalizedVoiceMediaId: String(voiceMediaId ?? "").trim(),
134167
normalizedVoiceContentType: String(voiceContentType ?? "").trim(),
@@ -139,13 +172,6 @@ export function createWecomBotInboundFlowState({
139172
content: String(quote.content ?? "").trim(),
140173
}
141174
: null,
142-
normalizedImageUrls: Array.from(
143-
new Set(
144-
(Array.isArray(imageUrls) ? imageUrls : [])
145-
.map((item) => String(item ?? "").trim())
146-
.filter(Boolean),
147-
),
148-
),
149175
groupChatPolicy: normalizeWecomBotGroupChatPolicy(resolveWecomGroupChatPolicy(api), api?.logger),
150176
dynamicAgentPolicy: resolveWecomDynamicAgentPolicy(api),
151177
isAdminUser: false,

src/wecom/bot-inbound-executor.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,10 @@ export async function executeWecomBotInboundFlow(payload = {}) {
2121
chatId,
2222
isGroupChat = false,
2323
imageUrls = [],
24+
imageEntries = [],
2425
fileUrl = "",
2526
fileName = "",
27+
fileAesKey = "",
2628
voiceUrl = "",
2729
voiceMediaId = "",
2830
voiceContentType = "",
@@ -76,9 +78,11 @@ export async function executeWecomBotInboundFlow(payload = {}) {
7678
accountId,
7779
fromUser,
7880
content,
81+
imageEntries,
7982
imageUrls,
8083
fileUrl,
8184
fileName,
85+
fileAesKey,
8286
voiceUrl,
8387
voiceMediaId,
8488
voiceContentType,
@@ -175,9 +179,11 @@ export async function executeWecomBotInboundFlow(payload = {}) {
175179
botProxyUrl: state.botProxyUrl,
176180
msgType,
177181
commandBody: state.commandBody,
182+
normalizedImageEntries: state.normalizedImageEntries,
178183
normalizedImageUrls: state.normalizedImageUrls,
179184
normalizedFileUrl: state.normalizedFileUrl,
180185
normalizedFileName: state.normalizedFileName,
186+
normalizedFileAesKey: state.normalizedFileAesKey,
181187
normalizedVoiceUrl: state.normalizedVoiceUrl,
182188
normalizedVoiceMediaId: state.normalizedVoiceMediaId,
183189
normalizedVoiceContentType: state.normalizedVoiceContentType,

src/wecom/bot-long-connection-manager.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,8 +513,10 @@ export function createWecomBotLongConnectionManager({
513513
chatId: parsed.chatId,
514514
isGroupChat: parsed.isGroupChat,
515515
imageUrls: parsed.imageUrls,
516+
imageEntries: parsed.imageEntries,
516517
fileUrl: parsed.fileUrl,
517518
fileName: parsed.fileName,
519+
fileAesKey: parsed.fileAesKey,
518520
quote: parsed.quote,
519521
responseUrl: parsed.responseUrl,
520522
accountId: parsed.accountId,
@@ -691,6 +693,16 @@ export function createWecomBotLongConnectionManager({
691693
? { ...payload.body, msgtype: "event" }
692694
: payload?.body;
693695
const parsed = parseWecomBotInboundMessage(normalizedBody);
696+
if (!parsed) {
697+
const bodyKeys =
698+
normalizedBody && typeof normalizedBody === "object"
699+
? Object.keys(normalizedBody).slice(0, 12).join(",")
700+
: "non-object";
701+
api?.logger?.warn?.(
702+
`wecom(bot-longconn): ignored unparsed callback account=${client.accountId} cmd=${command} bodyKeys=${bodyKeys || "n/a"}`,
703+
);
704+
return;
705+
}
694706
if (parsed && typeof parsed === "object") {
695707
parsed.reqId = reqId || buildRequestId(CMD_CALLBACK);
696708
}

src/wecom/bot-webhook-dispatch.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,10 @@ export function createWecomBotParsedDispatcher({
141141
chatId: parsed.chatId,
142142
isGroupChat: parsed.isGroupChat,
143143
imageUrls: parsed.imageUrls,
144+
imageEntries: parsed.imageEntries,
144145
fileUrl: parsed.fileUrl,
145146
fileName: parsed.fileName,
147+
fileAesKey: parsed.fileAesKey,
146148
quote: parsed.quote,
147149
responseUrl: parsed.responseUrl,
148150
accountId: parsed.accountId,

src/wecom/outbound-delivery.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,6 @@ export function createWecomBotReplyDeliverer({
260260
});
261261
},
262262
active_stream: async ({ text: content }) => {
263-
if (longConnectionContext) {
264-
return { ok: false, reason: "long-connection-context" };
265-
}
266263
return deliverActiveStreamReply({
267264
streamId,
268265
sessionId: normalizedSessionId,

src/wecom/outbound-webhook-sender.js

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ export function createWecomWebhookOutboundSender({
3232
assertFunction("createHash", createHash);
3333
assertFunction("sleep", sleep);
3434

35+
const webhookSendChains = new Map();
36+
37+
function buildWebhookTargetKey({ target, sendUrl }) {
38+
return [String(target?.url ?? "").trim(), String(target?.key ?? "").trim(), String(sendUrl ?? "").trim()]
39+
.filter(Boolean)
40+
.join("|");
41+
}
42+
43+
async function enqueueWebhookSend(targetKey, task) {
44+
const previous = webhookSendChains.get(targetKey) || Promise.resolve();
45+
const run = previous.catch(() => {}).then(task);
46+
const tracked = run.finally(() => {
47+
if (webhookSendChains.get(targetKey) === tracked) {
48+
webhookSendChains.delete(targetKey);
49+
}
50+
});
51+
webhookSendChains.set(targetKey, tracked);
52+
return run;
53+
}
54+
3555
function resolveWebhookSendContext({ webhook, webhookTargets, proxyUrl, logger }) {
3656
const target = resolveWecomWebhookTargetConfig(webhook, webhookTargets);
3757
if (!target) {
@@ -45,31 +65,34 @@ export function createWecomWebhookOutboundSender({
4565
throw new Error("invalid webhook target url/key");
4666
}
4767
const dispatcher = attachWecomProxyDispatcher(sendUrl, {}, { proxyUrl, logger })?.dispatcher;
48-
return { target, dispatcher };
68+
return { target, dispatcher, sendUrl };
4969
}
5070

5171
async function sendWecomWebhookText({ webhook, webhookTargets, text, logger, proxyUrl }) {
52-
const { target, dispatcher } = resolveWebhookSendContext({
72+
const { target, dispatcher, sendUrl } = resolveWebhookSendContext({
5373
webhook,
5474
webhookTargets,
5575
proxyUrl,
5676
logger,
5777
});
58-
const chunks = splitWecomText(String(text ?? ""));
59-
for (let i = 0; i < chunks.length; i += 1) {
60-
await webhookSendText({
61-
url: target.url,
62-
key: target.key,
63-
content: chunks[i],
64-
timeoutMs: 15000,
65-
dispatcher,
66-
fetchImpl,
67-
});
68-
if (i < chunks.length - 1) {
69-
await sleep(200);
78+
const targetKey = buildWebhookTargetKey({ target, sendUrl });
79+
return enqueueWebhookSend(targetKey, async () => {
80+
const chunks = splitWecomText(String(text ?? ""));
81+
for (let i = 0; i < chunks.length; i += 1) {
82+
await webhookSendText({
83+
url: target.url,
84+
key: target.key,
85+
content: chunks[i],
86+
timeoutMs: 15000,
87+
dispatcher,
88+
fetchImpl,
89+
});
90+
if (i < chunks.length - 1) {
91+
await sleep(200);
92+
}
7093
}
71-
}
72-
logger?.info?.(`wecom: webhook text sent chunks=${chunks.length}`);
94+
logger?.info?.(`wecom: webhook text sent chunks=${chunks.length}`);
95+
});
7396
}
7497

7598
async function sendWecomWebhookMediaBatch({

0 commit comments

Comments
 (0)