@sunnoy/wecom 1.4.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/package.json +1 -1
- package/wecom/inbound-processor.js +13 -0
- package/wecom/media.js +102 -29
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|