@sunnoy/wecom 1.3.0 → 1.4.1
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 +379 -2
- package/dynamic-agent.js +18 -4
- package/package.json +5 -2
- package/wecom/accounts.js +258 -0
- package/wecom/agent-inbound.js +18 -10
- package/wecom/allow-from.js +5 -18
- package/wecom/channel-plugin.js +139 -45
- package/wecom/commands.js +8 -3
- package/wecom/inbound-processor.js +87 -62
- package/wecom/media.js +102 -29
- package/wecom/outbound-delivery.js +321 -12
- package/wecom/state.js +13 -11
|
@@ -14,14 +14,13 @@ import {
|
|
|
14
14
|
isHighPriorityCommand,
|
|
15
15
|
isWecomAdmin,
|
|
16
16
|
} from "./commands.js";
|
|
17
|
-
import {
|
|
17
|
+
import { THINKING_PLACEHOLDER } from "./constants.js";
|
|
18
18
|
import { downloadAndDecryptImage, downloadWecomFile, guessMimeType } from "./media.js";
|
|
19
19
|
import { deliverWecomReply } from "./outbound-delivery.js";
|
|
20
20
|
import {
|
|
21
21
|
dispatchLocks,
|
|
22
22
|
getRuntime,
|
|
23
23
|
messageBuffers,
|
|
24
|
-
resolveAgentConfig,
|
|
25
24
|
responseUrls,
|
|
26
25
|
streamContext,
|
|
27
26
|
streamMeta,
|
|
@@ -148,7 +147,7 @@ export async function processInboundMessage({
|
|
|
148
147
|
// Apply group mention gating rules.
|
|
149
148
|
let rawBody = rawContent;
|
|
150
149
|
if (isGroupChat) {
|
|
151
|
-
if (!shouldTriggerGroupResponse(rawContent, config)) {
|
|
150
|
+
if (!shouldTriggerGroupResponse(rawContent, account.config)) {
|
|
152
151
|
logger.debug("WeCom: group message ignored (no mention)", { chatId, senderId });
|
|
153
152
|
if (streamId) {
|
|
154
153
|
streamManager.replaceIfPlaceholder(streamId, "请@提及我以获取回复。", THINKING_PLACEHOLDER);
|
|
@@ -158,7 +157,7 @@ export async function processInboundMessage({
|
|
|
158
157
|
return;
|
|
159
158
|
}
|
|
160
159
|
// Strip mention markers from the effective prompt.
|
|
161
|
-
rawBody = extractGroupMessageContent(rawContent, config);
|
|
160
|
+
rawBody = extractGroupMessageContent(rawContent, account.config);
|
|
162
161
|
}
|
|
163
162
|
|
|
164
163
|
const commandAuthorized = resolveWecomCommandAuthorized({
|
|
@@ -167,6 +166,19 @@ export async function processInboundMessage({
|
|
|
167
166
|
senderId,
|
|
168
167
|
});
|
|
169
168
|
|
|
169
|
+
// ── Quoted message context ────────────────────────────────────────
|
|
170
|
+
// When the user replies to (quotes) a previous message, prepend the
|
|
171
|
+
// quoted content so the LLM sees the full conversational context.
|
|
172
|
+
const quote = message.quote;
|
|
173
|
+
if (quote && quote.content) {
|
|
174
|
+
const quoteLabel = quote.msgType === "image" ? "[引用图片]" : `> ${quote.content}`;
|
|
175
|
+
rawBody = `${quoteLabel}\n\n${rawBody}`;
|
|
176
|
+
logger.debug("WeCom: prepended quoted message context", {
|
|
177
|
+
quoteType: quote.msgType,
|
|
178
|
+
quotePreview: quote.content.substring(0, 60),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
170
182
|
// Skip empty messages, but allow image/mixed/file messages.
|
|
171
183
|
if (!rawBody.trim() && !imageUrl && imageUrls.length === 0 && !fileUrl) {
|
|
172
184
|
logger.debug("WeCom: empty message, skipping", { msgType });
|
|
@@ -181,12 +193,12 @@ export async function processInboundMessage({
|
|
|
181
193
|
// Command allowlist enforcement
|
|
182
194
|
// Admins bypass the allowlist entirely.
|
|
183
195
|
// ========================================================================
|
|
184
|
-
const senderIsAdmin = isWecomAdmin(senderId, config);
|
|
185
|
-
const commandCheck = checkCommandAllowlist(rawBody, config);
|
|
196
|
+
const senderIsAdmin = isWecomAdmin(senderId, account.config);
|
|
197
|
+
const commandCheck = checkCommandAllowlist(rawBody, account.config);
|
|
186
198
|
|
|
187
199
|
if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
|
|
188
200
|
// Return block message when command is outside the allowlist.
|
|
189
|
-
const cmdConfig = getCommandConfig(config);
|
|
201
|
+
const cmdConfig = getCommandConfig(account.config);
|
|
190
202
|
logger.warn("WeCom: blocked command", {
|
|
191
203
|
command: commandCheck.command,
|
|
192
204
|
from: senderId,
|
|
@@ -225,12 +237,12 @@ export async function processInboundMessage({
|
|
|
225
237
|
// Dynamic agent routing
|
|
226
238
|
// Admins also use dynamic agents; admin flag only affects command allowlist.
|
|
227
239
|
// ========================================================================
|
|
228
|
-
const dynamicConfig = getDynamicAgentConfig(config);
|
|
240
|
+
const dynamicConfig = getDynamicAgentConfig(account.config);
|
|
229
241
|
|
|
230
242
|
// Compute deterministic agent target for this conversation.
|
|
231
243
|
const targetAgentId =
|
|
232
|
-
dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config })
|
|
233
|
-
? generateAgentId(peerKind, peerId)
|
|
244
|
+
dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config: account.config })
|
|
245
|
+
? generateAgentId(peerKind, peerId, account.accountId)
|
|
234
246
|
: null;
|
|
235
247
|
|
|
236
248
|
if (targetAgentId) {
|
|
@@ -395,9 +407,41 @@ export async function processInboundMessage({
|
|
|
395
407
|
});
|
|
396
408
|
|
|
397
409
|
const runDispatch = async () => {
|
|
410
|
+
// --- Stream close coordination ---
|
|
411
|
+
// dispatchReplyWithBufferedBlockDispatcher may return before the LLM
|
|
412
|
+
// actually processes the message (e.g. when the session lane is busy and
|
|
413
|
+
// the message is queued). We therefore track two signals:
|
|
414
|
+
// 1. dispatchDone – the await on the dispatcher has resolved.
|
|
415
|
+
// 2. hadDelivery – at least one deliver callback has fired.
|
|
416
|
+
// We only schedule a stream-close timer when BOTH are true, and we
|
|
417
|
+
// reset the timer on every new delivery so the stream stays open while
|
|
418
|
+
// content keeps arriving.
|
|
419
|
+
let dispatchDone = false;
|
|
420
|
+
let hadDelivery = false;
|
|
421
|
+
let closeTimer = null;
|
|
422
|
+
|
|
423
|
+
const scheduleStreamClose = () => {
|
|
424
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
425
|
+
closeTimer = setTimeout(async () => {
|
|
426
|
+
const s = streamManager.getStream(streamId);
|
|
427
|
+
if (s && !s.finished) {
|
|
428
|
+
logger.info("WeCom: finishing stream after dispatch complete", { streamId });
|
|
429
|
+
try {
|
|
430
|
+
await streamManager.finishStream(streamId);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
logger.error("WeCom: failed to finish stream post-dispatch", {
|
|
433
|
+
streamId,
|
|
434
|
+
error: err.message,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
unregisterActiveStream(streamKey, streamId);
|
|
438
|
+
}
|
|
439
|
+
}, 3000); // 3s grace after last delivery
|
|
440
|
+
};
|
|
441
|
+
|
|
398
442
|
// Dispatch reply with AI processing.
|
|
399
443
|
// Wrap in streamContext so outbound adapters resolve the correct stream.
|
|
400
|
-
await streamContext.run({ streamId, streamKey }, async () => {
|
|
444
|
+
await streamContext.run({ streamId, streamKey, agentId: route.agentId, accountId: account.accountId }, async () => {
|
|
401
445
|
await core.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
402
446
|
ctx: ctxPayload,
|
|
403
447
|
cfg: config,
|
|
@@ -408,44 +452,40 @@ export async function processInboundMessage({
|
|
|
408
452
|
},
|
|
409
453
|
dispatcherOptions: {
|
|
410
454
|
deliver: async (payload, info) => {
|
|
455
|
+
hadDelivery = true;
|
|
456
|
+
|
|
411
457
|
logger.info("Dispatcher deliver called", {
|
|
412
458
|
kind: info.kind,
|
|
413
459
|
hasText: !!(payload.text && payload.text.trim()),
|
|
460
|
+
hasMediaUrl: !!(payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length)),
|
|
414
461
|
textPreview: (payload.text || "").substring(0, 50),
|
|
415
462
|
});
|
|
416
463
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
464
|
+
try {
|
|
465
|
+
await deliverWecomReply({
|
|
466
|
+
payload,
|
|
467
|
+
senderId: streamKey,
|
|
468
|
+
streamId,
|
|
469
|
+
agentId: route.agentId,
|
|
470
|
+
});
|
|
471
|
+
} catch (deliverErr) {
|
|
472
|
+
logger.error("WeCom: deliverWecomReply threw, continuing to finalize stream", {
|
|
473
|
+
streamId,
|
|
474
|
+
error: deliverErr.message,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
422
477
|
|
|
423
478
|
// Mark stream meta when main response is done.
|
|
424
|
-
|
|
425
|
-
// which is driven by WeCom client polling.
|
|
426
|
-
if (streamId && info.kind === "final") {
|
|
479
|
+
if (streamId && (info.kind === "final" || info.kind === "block")) {
|
|
427
480
|
streamMeta.set(streamId, {
|
|
428
481
|
mainResponseDone: true,
|
|
429
482
|
doneAt: Date.now(),
|
|
430
483
|
});
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (agentConfig) {
|
|
437
|
-
setTimeout(async () => {
|
|
438
|
-
const s = streamManager.getStream(streamId);
|
|
439
|
-
if (s && !s.finished) {
|
|
440
|
-
logger.info("WeCom: closing stream early (Agent API available for late messages)", { streamId });
|
|
441
|
-
try {
|
|
442
|
-
await streamManager.finishStream(streamId);
|
|
443
|
-
} catch (err) {
|
|
444
|
-
logger.error("WeCom: failed to close stream early", { streamId, error: err.message });
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
}, 3000);
|
|
448
|
-
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Schedule / reset stream close timer if dispatch already returned.
|
|
487
|
+
if (streamId && dispatchDone) {
|
|
488
|
+
scheduleStreamClose();
|
|
449
489
|
}
|
|
450
490
|
},
|
|
451
491
|
onError: async (err, info) => {
|
|
@@ -456,36 +496,21 @@ export async function processInboundMessage({
|
|
|
456
496
|
});
|
|
457
497
|
});
|
|
458
498
|
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
499
|
+
// Dispatch returned.
|
|
500
|
+
dispatchDone = true;
|
|
501
|
+
|
|
462
502
|
if (streamId) {
|
|
463
503
|
const stream = streamManager.getStream(streamId);
|
|
464
504
|
if (!stream || stream.finished) {
|
|
465
505
|
unregisterActiveStream(streamKey, streamId);
|
|
466
|
-
} else {
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
setTimeout(async () => {
|
|
470
|
-
const checkStream = streamManager.getStream(streamId);
|
|
471
|
-
if (checkStream && !checkStream.finished) {
|
|
472
|
-
const idleMs = Date.now() - checkStream.updatedAt;
|
|
473
|
-
// Extreme fallback only: refresh handler should normally close earlier.
|
|
474
|
-
if (idleMs > SAFETY_NET_IDLE_CLOSE_MS) {
|
|
475
|
-
logger.warn("WeCom safety net: closing idle stream", { streamId, idleMs });
|
|
476
|
-
try {
|
|
477
|
-
await streamManager.finishStream(streamId);
|
|
478
|
-
unregisterActiveStream(streamKey, streamId);
|
|
479
|
-
} catch (err) {
|
|
480
|
-
logger.error("WeCom safety net: failed to close stream", {
|
|
481
|
-
streamId,
|
|
482
|
-
error: err.message,
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}, 35000); // 35s total timeout
|
|
506
|
+
} else if (hadDelivery) {
|
|
507
|
+
// Normal case: content was already delivered, close after grace period.
|
|
508
|
+
scheduleStreamClose();
|
|
488
509
|
}
|
|
510
|
+
// If !hadDelivery, the message was queued and is not yet processed.
|
|
511
|
+
// The deliver callback will fire later and schedule the close (since
|
|
512
|
+
// dispatchDone is now true). The existing stream GC handles the edge
|
|
513
|
+
// case where no delivery ever arrives.
|
|
489
514
|
}
|
|
490
515
|
};
|
|
491
516
|
|
package/wecom/media.js
CHANGED
|
@@ -4,66 +4,134 @@ import { WecomCrypto } from "../crypto.js";
|
|
|
4
4
|
import { logger } from "../logger.js";
|
|
5
5
|
import { MEDIA_CACHE_DIR } from "./constants.js";
|
|
6
6
|
|
|
7
|
+
// ── Magic-byte signatures for common file formats ───────────────────────────
|
|
8
|
+
const MAGIC_SIGNATURES = [
|
|
9
|
+
{ magic: [0xff, 0xd8, 0xff], ext: "jpg" }, // JPEG
|
|
10
|
+
{ magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a], ext: "png" }, // PNG
|
|
11
|
+
{ magic: [0x47, 0x49, 0x46, 0x38], ext: "gif" }, // GIF
|
|
12
|
+
{ magic: [0x25, 0x50, 0x44, 0x46], ext: "pdf" }, // %PDF
|
|
13
|
+
{ magic: [0x50, 0x4b, 0x03, 0x04], ext: "zip" }, // PK (ZIP / DOCX / XLSX / PPTX)
|
|
14
|
+
{ magic: [0x50, 0x4b, 0x05, 0x06], ext: "zip" }, // PK (empty ZIP)
|
|
15
|
+
{ magic: [0xd0, 0xcf, 0x11, 0xe0], ext: "doc" }, // OLE2 (DOC / XLS / PPT)
|
|
16
|
+
{ magic: [0x52, 0x61, 0x72, 0x21], ext: "rar" }, // Rar!
|
|
17
|
+
{ magic: [0x1f, 0x8b], ext: "gz" }, // gzip
|
|
18
|
+
{ magic: [0x42, 0x4d], ext: "bmp" }, // BMP
|
|
19
|
+
{ magic: [0x49, 0x44, 0x33], ext: "mp3" }, // ID3 (MP3)
|
|
20
|
+
{ magic: [0x00, 0x00, 0x00], ext: "mp4" }, // ftyp box (loose)
|
|
21
|
+
{ magic: [0x52, 0x49, 0x46, 0x46], ext: "wav" }, // RIFF (WAV / AVI)
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check whether a buffer looks like a recognisable (plain) file by inspecting
|
|
26
|
+
* its leading magic bytes. Returns the matched extension or `null`.
|
|
27
|
+
*/
|
|
28
|
+
function detectMagic(buf) {
|
|
29
|
+
if (!buf || buf.length < 4) return null;
|
|
30
|
+
for (const sig of MAGIC_SIGNATURES) {
|
|
31
|
+
if (buf.length >= sig.magic.length && sig.magic.every((b, i) => buf[i] === b)) {
|
|
32
|
+
return sig.ext;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Conditionally decrypt a buffer.
|
|
40
|
+
*
|
|
41
|
+
* WeCom encrypts media with AES-256-CBC in Agent mode, but in some Bot (AI
|
|
42
|
+
* 机器人) configurations the file URL returns an **unencrypted** payload.
|
|
43
|
+
* Blindly decrypting an already-plain file corrupts it (#44).
|
|
44
|
+
*
|
|
45
|
+
* Strategy:
|
|
46
|
+
* 1. If raw bytes already match a known file signature → skip decrypt.
|
|
47
|
+
* 2. Otherwise attempt AES-256-CBC decryption.
|
|
48
|
+
* 3. If decryption throws or the result has no recognisable signature,
|
|
49
|
+
* fall back to the original bytes (best-effort).
|
|
50
|
+
*/
|
|
51
|
+
function smartDecrypt(rawBuffer, encodingAesKey, token) {
|
|
52
|
+
const plainExt = detectMagic(rawBuffer);
|
|
53
|
+
if (plainExt) {
|
|
54
|
+
logger.info("Media is already plain (skip decrypt)", { magic: plainExt, size: rawBuffer.length });
|
|
55
|
+
return { buffer: rawBuffer, decrypted: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const crypto = new WecomCrypto(token, encodingAesKey);
|
|
60
|
+
const dec = crypto.decryptMedia(rawBuffer);
|
|
61
|
+
logger.info("Media decrypted", { inputSize: rawBuffer.length, outputSize: dec.length });
|
|
62
|
+
return { buffer: dec, decrypted: true };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
logger.warn("Decrypt failed, using raw bytes", { error: e.message, size: rawBuffer.length });
|
|
65
|
+
return { buffer: rawBuffer, decrypted: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Image helpers ───────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function detectImageExt(buf) {
|
|
72
|
+
if (buf[0] === 0x89 && buf[1] === 0x50) return "png";
|
|
73
|
+
if (buf[0] === 0x47 && buf[1] === 0x49) return "gif";
|
|
74
|
+
return "jpg";
|
|
75
|
+
}
|
|
76
|
+
|
|
7
77
|
/**
|
|
8
|
-
* Download and decrypt a WeCom
|
|
9
|
-
* @param {string} imageUrl -
|
|
78
|
+
* Download and (conditionally) decrypt a WeCom image.
|
|
79
|
+
* @param {string} imageUrl - Image URL from WeCom callback
|
|
10
80
|
* @param {string} encodingAesKey - AES key
|
|
11
81
|
* @param {string} token - Token
|
|
12
|
-
* @returns {Promise<{ localPath: string, mimeType: string }>}
|
|
82
|
+
* @returns {Promise<{ localPath: string, mimeType: string }>}
|
|
13
83
|
*/
|
|
14
84
|
export async function downloadAndDecryptImage(imageUrl, encodingAesKey, token) {
|
|
15
85
|
if (!existsSync(MEDIA_CACHE_DIR)) {
|
|
16
86
|
mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
|
|
17
87
|
}
|
|
18
88
|
|
|
19
|
-
logger.info("Downloading
|
|
89
|
+
logger.info("Downloading image", { url: imageUrl.substring(0, 80) });
|
|
20
90
|
const response = await fetch(imageUrl);
|
|
21
91
|
if (!response.ok) {
|
|
22
92
|
throw new Error(`Failed to download image: ${response.status}`);
|
|
23
93
|
}
|
|
24
|
-
const
|
|
25
|
-
logger.debug("Downloaded
|
|
26
|
-
|
|
27
|
-
const wecomCrypto = new WecomCrypto(token, encodingAesKey);
|
|
28
|
-
const decryptedBuffer = wecomCrypto.decryptMedia(encryptedBuffer);
|
|
29
|
-
|
|
30
|
-
// Detect image type via magic bytes.
|
|
31
|
-
let ext = "jpg";
|
|
32
|
-
if (decryptedBuffer[0] === 0x89 && decryptedBuffer[1] === 0x50) {
|
|
33
|
-
ext = "png";
|
|
34
|
-
} else if (decryptedBuffer[0] === 0x47 && decryptedBuffer[1] === 0x49) {
|
|
35
|
-
ext = "gif";
|
|
36
|
-
}
|
|
94
|
+
const rawBuffer = Buffer.from(await response.arrayBuffer());
|
|
95
|
+
logger.debug("Downloaded image bytes", { size: rawBuffer.length });
|
|
37
96
|
|
|
97
|
+
const { buffer: finalBuffer } = smartDecrypt(rawBuffer, encodingAesKey, token);
|
|
98
|
+
|
|
99
|
+
const ext = detectImageExt(finalBuffer);
|
|
38
100
|
const filename = `wecom_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.${ext}`;
|
|
39
101
|
const localPath = join(MEDIA_CACHE_DIR, filename);
|
|
40
|
-
writeFileSync(localPath,
|
|
102
|
+
writeFileSync(localPath, finalBuffer);
|
|
41
103
|
|
|
42
104
|
const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : "image/jpeg";
|
|
43
|
-
logger.info("Image
|
|
105
|
+
logger.info("Image saved", { path: localPath, size: finalBuffer.length, mimeType });
|
|
44
106
|
return { localPath, mimeType };
|
|
45
107
|
}
|
|
46
108
|
|
|
109
|
+
// ── File helpers ────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
47
111
|
/**
|
|
48
|
-
* Download and decrypt a file from WeCom.
|
|
49
|
-
*
|
|
112
|
+
* Download and (conditionally) decrypt a file from WeCom.
|
|
113
|
+
*
|
|
114
|
+
* In Agent mode WeCom encrypts all media with AES-256-CBC; in Bot mode the
|
|
115
|
+
* file URL may return plain bytes. This function auto-detects the case and
|
|
116
|
+
* only decrypts when necessary, preventing file corruption (#44).
|
|
117
|
+
*
|
|
50
118
|
* @param {string} fileUrl - File download URL
|
|
51
119
|
* @param {string} fileName - Original file name
|
|
52
120
|
* @param {string} encodingAesKey - AES key for decryption
|
|
53
121
|
* @param {string} token - Token for decryption
|
|
54
|
-
* @returns {Promise<{ localPath: string, effectiveFileName: string }>}
|
|
122
|
+
* @returns {Promise<{ localPath: string, effectiveFileName: string }>}
|
|
55
123
|
*/
|
|
56
124
|
export async function downloadWecomFile(fileUrl, fileName, encodingAesKey, token) {
|
|
57
125
|
if (!existsSync(MEDIA_CACHE_DIR)) {
|
|
58
126
|
mkdirSync(MEDIA_CACHE_DIR, { recursive: true });
|
|
59
127
|
}
|
|
60
128
|
|
|
61
|
-
logger.info("Downloading
|
|
129
|
+
logger.info("Downloading file", { url: fileUrl.substring(0, 80), name: fileName });
|
|
62
130
|
const response = await fetch(fileUrl);
|
|
63
131
|
if (!response.ok) {
|
|
64
132
|
throw new Error(`Failed to download file: ${response.status}`);
|
|
65
133
|
}
|
|
66
|
-
const
|
|
134
|
+
const rawBuffer = Buffer.from(await response.arrayBuffer());
|
|
67
135
|
|
|
68
136
|
// Try to extract filename from Content-Disposition header if not provided
|
|
69
137
|
let effectiveFileName = fileName;
|
|
@@ -79,15 +147,20 @@ export async function downloadWecomFile(fileUrl, fileName, encodingAesKey, token
|
|
|
79
147
|
}
|
|
80
148
|
}
|
|
81
149
|
|
|
82
|
-
//
|
|
83
|
-
const
|
|
84
|
-
|
|
150
|
+
// Smart decrypt: only decrypt if the raw bytes are not already a valid file.
|
|
151
|
+
const { buffer: finalBuffer, decrypted } = smartDecrypt(rawBuffer, encodingAesKey, token);
|
|
152
|
+
logger.info("File processed", {
|
|
153
|
+
name: effectiveFileName,
|
|
154
|
+
rawSize: rawBuffer.length,
|
|
155
|
+
finalSize: finalBuffer.length,
|
|
156
|
+
wasDecrypted: decrypted,
|
|
157
|
+
});
|
|
85
158
|
|
|
86
159
|
const safeName = (effectiveFileName || `file_${Date.now()}`).replace(/[/\\:*?"<>|]/g, "_");
|
|
87
160
|
const localPath = join(MEDIA_CACHE_DIR, `${Date.now()}_${safeName}`);
|
|
88
|
-
writeFileSync(localPath,
|
|
161
|
+
writeFileSync(localPath, finalBuffer);
|
|
89
162
|
|
|
90
|
-
logger.info("File
|
|
163
|
+
logger.info("File saved", { path: localPath, size: finalBuffer.length });
|
|
91
164
|
return { localPath, effectiveFileName: effectiveFileName || fileName };
|
|
92
165
|
}
|
|
93
166
|
|