@sunnoy/wecom 1.4.0 β†’ 1.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,11 +8,18 @@ import { listAccountIds, resolveAccount, detectAccountConflicts } from "./accoun
8
8
  import { DEFAULT_ACCOUNT_ID, THINKING_PLACEHOLDER } from "./constants.js";
9
9
  import { parseResponseUrlResult } from "./response-url.js";
10
10
  import { messageBuffers, resolveAgentConfig, resolveWebhookUrl, responseUrls, streamContext } from "./state.js";
11
- import { resolveActiveStream } from "./stream-utils.js";
11
+ import { resolveRecoverableStream, unregisterActiveStream } from "./stream-utils.js";
12
12
  import { resolveWecomTarget } from "./target.js";
13
13
  import { webhookSendImage, webhookSendText, webhookUploadFile, webhookSendFile } from "./webhook-bot.js";
14
14
  import { registerWebhookTarget } from "./webhook-targets.js";
15
15
 
16
+ const AGENT_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "bmp"]);
17
+
18
+ export function resolveAgentMediaTypeFromFilename(filename) {
19
+ const ext = filename.split(".").pop()?.toLowerCase() || "";
20
+ return AGENT_IMAGE_EXTS.has(ext) ? "image" : "file";
21
+ }
22
+
16
23
  export const wecomChannelPlugin = {
17
24
  id: "wecom",
18
25
  meta: {
@@ -259,7 +266,7 @@ export const wecomChannelPlugin = {
259
266
 
260
267
  // Prefer stream from async context (correct for concurrent processing).
261
268
  const ctx = streamContext.getStore();
262
- const streamId = ctx?.streamId ?? resolveActiveStream(userId);
269
+ const streamId = ctx?.streamId ?? resolveRecoverableStream(userId);
263
270
 
264
271
  // Layer 1: Active stream (normal path)
265
272
  if (streamId && streamManager.hasStream(streamId) && !streamManager.getStream(streamId)?.finished) {
@@ -377,7 +384,7 @@ export const wecomChannelPlugin = {
377
384
 
378
385
  // Prefer stream from async context (correct for concurrent processing).
379
386
  const ctx = streamContext.getStore();
380
- const streamId = ctx?.streamId ?? resolveActiveStream(userId);
387
+ const streamId = ctx?.streamId ?? resolveRecoverableStream(userId);
381
388
 
382
389
  if (streamId && streamManager.hasStream(streamId)) {
383
390
  // Check if mediaUrl is a local path (sandbox: prefix or absolute path)
@@ -575,6 +582,7 @@ export const wecomChannelPlugin = {
575
582
  if (agentConfig) {
576
583
  try {
577
584
  const agentTarget = (target && !target.webhook) ? target : resolveWecomTarget(to) || { toUser: userId };
585
+ let deliveredFilename = "file";
578
586
 
579
587
  // Determine if mediaUrl is a local file path.
580
588
  let absolutePath = mediaUrl;
@@ -587,9 +595,11 @@ export const wecomChannelPlugin = {
587
595
  // Upload local file then send via Agent API.
588
596
  const buffer = await readFile(absolutePath);
589
597
  const filename = basename(absolutePath);
598
+ deliveredFilename = filename;
599
+ const uploadType = resolveAgentMediaTypeFromFilename(filename);
590
600
  const mediaId = await agentUploadMedia({
591
601
  agent: agentConfig,
592
- type: "image",
602
+ type: uploadType,
593
603
  buffer,
594
604
  filename,
595
605
  });
@@ -597,16 +607,25 @@ export const wecomChannelPlugin = {
597
607
  agent: agentConfig,
598
608
  ...agentTarget,
599
609
  mediaId,
600
- mediaType: "image",
610
+ mediaType: uploadType,
601
611
  });
602
612
  } else {
603
613
  // For external URLs, download first then upload.
604
614
  const res = await fetch(mediaUrl);
615
+ if (!res.ok) {
616
+ throw new Error(`download media failed: ${res.status}`);
617
+ }
605
618
  const buffer = Buffer.from(await res.arrayBuffer());
606
- const filename = basename(new URL(mediaUrl).pathname) || "image.png";
619
+ const filename = basename(new URL(mediaUrl).pathname) || "file";
620
+ deliveredFilename = filename;
621
+ let uploadType = resolveAgentMediaTypeFromFilename(filename);
622
+ const contentType = res.headers.get("content-type") || "";
623
+ if (uploadType === "file" && contentType.toLowerCase().startsWith("image/")) {
624
+ uploadType = "image";
625
+ }
607
626
  const mediaId = await agentUploadMedia({
608
627
  agent: agentConfig,
609
- type: "image",
628
+ type: uploadType,
610
629
  buffer,
611
630
  filename,
612
631
  });
@@ -614,7 +633,7 @@ export const wecomChannelPlugin = {
614
633
  agent: agentConfig,
615
634
  ...agentTarget,
616
635
  mediaId,
617
- mediaType: "image",
636
+ mediaType: uploadType,
618
637
  });
619
638
  }
620
639
 
@@ -623,6 +642,30 @@ export const wecomChannelPlugin = {
623
642
  await agentSendText({ agent: agentConfig, ...agentTarget, text });
624
643
  }
625
644
 
645
+ // Best-effort stream recovery: when async context is missing and the
646
+ // active stream mapping was already cleaned, still clear "thinking..."
647
+ // in the most recent stream for this user.
648
+ const recoverStreamId = resolveRecoverableStream(userId);
649
+ if (recoverStreamId && streamManager.hasStream(recoverStreamId)) {
650
+ const recoverStream = streamManager.getStream(recoverStreamId);
651
+ if (recoverStream && !recoverStream.finished) {
652
+ const deliveryHint = text
653
+ ? `${text}\n\nπŸ“Ž ζ–‡δ»Άε·²ι€šθΏ‡η§δΏ‘ε‘ι€η»™ζ‚¨οΌš${deliveredFilename}`
654
+ : `πŸ“Ž ζ–‡δ»Άε·²ι€šθΏ‡η§δΏ‘ε‘ι€η»™ζ‚¨οΌš${deliveredFilename}`;
655
+ streamManager.replaceIfPlaceholder(
656
+ recoverStreamId,
657
+ deliveryHint,
658
+ THINKING_PLACEHOLDER,
659
+ );
660
+ await streamManager.finishStream(recoverStreamId);
661
+ unregisterActiveStream(userId, recoverStreamId);
662
+ logger.info("WeCom: recovered and finished stream after media fallback", {
663
+ userId,
664
+ streamId: recoverStreamId,
665
+ });
666
+ }
667
+ }
668
+
626
669
  logger.info("WeCom: sent media via Agent API fallback (sendMedia)", {
627
670
  userId,
628
671
  to,
@@ -166,6 +166,19 @@ export async function processInboundMessage({
166
166
  senderId,
167
167
  });
168
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
+
169
182
  // Skip empty messages, but allow image/mixed/file messages.
170
183
  if (!rawBody.trim() && !imageUrl && imageUrls.length === 0 && !fileUrl) {
171
184
  logger.debug("WeCom: empty message, skipping", { msgType });
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
 
package/wecom/state.js CHANGED
@@ -14,6 +14,7 @@ export const messageBuffers = new Map();
14
14
  export const webhookTargets = new Map();
15
15
  export const activeStreams = new Map();
16
16
  export const activeStreamHistory = new Map();
17
+ export const lastStreamByKey = new Map();
17
18
  export const streamMeta = new Map();
18
19
  export const responseUrls = new Map();
19
20
  export const streamContext = new AsyncLocalStorage();
@@ -1,7 +1,7 @@
1
1
  import { logger } from "../logger.js";
2
2
  import { streamManager } from "../stream-manager.js";
3
3
  import { THINKING_PLACEHOLDER } from "./constants.js";
4
- import { activeStreamHistory, activeStreams, messageBuffers } from "./state.js";
4
+ import { activeStreamHistory, activeStreams, lastStreamByKey, messageBuffers } from "./state.js";
5
5
 
6
6
  export function getMessageStreamKey(message) {
7
7
  if (!message || typeof message !== "object") {
@@ -25,6 +25,7 @@ export function registerActiveStream(streamKey, streamId) {
25
25
  deduped.push(streamId);
26
26
  activeStreamHistory.set(streamKey, deduped);
27
27
  activeStreams.set(streamKey, streamId);
28
+ lastStreamByKey.set(streamKey, streamId);
28
29
  }
29
30
 
30
31
  export function unregisterActiveStream(streamKey, streamId) {
@@ -72,9 +73,33 @@ export function resolveActiveStream(streamKey) {
72
73
  activeStreamHistory.set(streamKey, remaining);
73
74
  const latest = remaining[remaining.length - 1];
74
75
  activeStreams.set(streamKey, latest);
76
+ lastStreamByKey.set(streamKey, latest);
75
77
  return latest;
76
78
  }
77
79
 
80
+ /**
81
+ * Resolve a usable stream id for a sender/group.
82
+ * Prefer active history; if that is temporarily empty, fall back to the latest
83
+ * known stream id for the same key (when it still exists).
84
+ */
85
+ export function resolveRecoverableStream(streamKey) {
86
+ const activeId = resolveActiveStream(streamKey);
87
+ if (activeId) {
88
+ return activeId;
89
+ }
90
+ if (!streamKey) {
91
+ return null;
92
+ }
93
+ const recentId = lastStreamByKey.get(streamKey);
94
+ if (!recentId) {
95
+ return null;
96
+ }
97
+ if (!streamManager.hasStream(recentId)) {
98
+ return null;
99
+ }
100
+ return recentId;
101
+ }
102
+
78
103
  export function clearBufferedMessagesForStream(streamKey, reason) {
79
104
  const buffer = messageBuffers.get(streamKey);
80
105
  if (!buffer) {