@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.
@@ -14,14 +14,13 @@ import {
14
14
  isHighPriorityCommand,
15
15
  isWecomAdmin,
16
16
  } from "./commands.js";
17
- import { SAFETY_NET_IDLE_CLOSE_MS, THINKING_PLACEHOLDER } from "./constants.js";
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
- await deliverWecomReply({
418
- payload,
419
- senderId: streamKey,
420
- streamId,
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
- // Actual stream finish is deferred to stream refresh handler,
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
- logger.info("WeCom main response complete, keeping stream open for late messages", { streamId });
432
-
433
- // When Agent API is configured, late messages can be delivered
434
- // via the Agent channel — no need to keep the stream open long.
435
- const agentConfig = resolveAgentConfig();
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
- // Safety net: ensure stream finishes after dispatch.
460
- // Note: Stream closing is now handled by stream refresh handler via WeCom polling.
461
- // This safety net only cleans up if refresh handler never fires (edge case).
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
- // Stream is still open; refresh handler will close it when idle.
468
- // Add a safety timeout to prevent leaks if refresh never fires.
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 encrypted image.
9
- * @param {string} imageUrl - Encrypted image URL from WeCom
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 }>} Local path and mime type
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 encrypted image", { url: imageUrl.substring(0, 80) });
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 encryptedBuffer = Buffer.from(await response.arrayBuffer());
25
- logger.debug("Downloaded encrypted image", { size: encryptedBuffer.length });
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, decryptedBuffer);
102
+ writeFileSync(localPath, finalBuffer);
41
103
 
42
104
  const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : "image/jpeg";
43
- logger.info("Image decrypted and saved", { path: localPath, size: decryptedBuffer.length, mimeType });
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
- * Note: WeCom encrypts ALL media files (not just images) with AES-256-CBC.
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 }>} Local path and resolved filename
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 encrypted file", { url: fileUrl.substring(0, 80), name: fileName });
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 encryptedBuffer = Buffer.from(await response.arrayBuffer());
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
- // Decrypt the file (WeCom encrypts all media the same way as images)
83
- const wecomCrypto = new WecomCrypto(token, encodingAesKey);
84
- const decryptedBuffer = wecomCrypto.decryptMedia(encryptedBuffer);
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, decryptedBuffer);
161
+ writeFileSync(localPath, finalBuffer);
89
162
 
90
- logger.info("File decrypted and saved", { path: localPath, size: decryptedBuffer.length });
163
+ logger.info("File saved", { path: localPath, size: finalBuffer.length });
91
164
  return { localPath, effectiveFileName: effectiveFileName || fileName };
92
165
  }
93
166