@sunnoy/wecom 1.1.2 → 1.2.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/README.md +81 -67
- package/README_ZH.md +81 -67
- package/crypto.js +110 -83
- package/dynamic-agent.js +70 -87
- package/image-processor.js +86 -93
- package/index.js +840 -306
- package/logger.js +48 -49
- package/package.json +1 -5
- package/stream-manager.js +316 -265
- package/utils.js +76 -238
- package/webhook.js +434 -287
- package/client.js +0 -127
package/crypto.js
CHANGED
|
@@ -1,108 +1,135 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
|
2
|
-
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
|
3
|
-
import { CONSTANTS } from "./utils.js";
|
|
4
2
|
import { logger } from "./logger.js";
|
|
3
|
+
import { CONSTANTS } from "./utils.js";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Enterprise WeChat Intelligent Robot Crypto Implementation
|
|
8
7
|
* Simplified for AI Bot mode (no corpId validation)
|
|
9
8
|
*/
|
|
10
9
|
export class WecomCrypto {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
this.token = token;
|
|
24
|
-
this.encodingAesKey = encodingAesKey;
|
|
25
|
-
this.aesKey = Buffer.from(encodingAesKey + "=", "base64");
|
|
26
|
-
this.iv = this.aesKey.subarray(0, 16);
|
|
27
|
-
logger.debug("WecomCrypto initialized (AI Bot mode)");
|
|
10
|
+
token;
|
|
11
|
+
encodingAesKey;
|
|
12
|
+
aesKey;
|
|
13
|
+
iv;
|
|
14
|
+
|
|
15
|
+
constructor(token, encodingAesKey) {
|
|
16
|
+
if (!encodingAesKey || encodingAesKey.length !== CONSTANTS.AES_KEY_LENGTH) {
|
|
17
|
+
throw new Error(`EncodingAESKey invalid: length must be ${CONSTANTS.AES_KEY_LENGTH}`);
|
|
18
|
+
}
|
|
19
|
+
if (!token) {
|
|
20
|
+
throw new Error("Token is required");
|
|
28
21
|
}
|
|
22
|
+
this.token = token;
|
|
23
|
+
this.encodingAesKey = encodingAesKey;
|
|
24
|
+
this.aesKey = Buffer.from(encodingAesKey + "=", "base64");
|
|
25
|
+
this.iv = this.aesKey.subarray(0, 16);
|
|
26
|
+
logger.debug("WecomCrypto initialized (AI Bot mode)");
|
|
27
|
+
}
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
getSignature(timestamp, nonce, encrypt) {
|
|
30
|
+
const shasum = createHash("sha1");
|
|
31
|
+
// WeCom requires plain lexicographic sorting before SHA1; localeCompare is locale-sensitive.
|
|
32
|
+
const sorted = [this.token, timestamp, nonce, encrypt]
|
|
33
|
+
.map((value) => String(value))
|
|
34
|
+
.toSorted();
|
|
35
|
+
shasum.update(sorted.join(""));
|
|
36
|
+
return shasum.digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
decrypt(text) {
|
|
40
|
+
let decipher;
|
|
41
|
+
try {
|
|
42
|
+
decipher = createDecipheriv("aes-256-cbc", this.aesKey, this.iv);
|
|
43
|
+
decipher.setAutoPadding(false);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
throw new Error(`Decrypt init failed: ${String(e)}`, { cause: e });
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
let decipher;
|
|
39
|
-
try {
|
|
40
|
-
decipher = createDecipheriv("aes-256-cbc", this.aesKey, this.iv);
|
|
41
|
-
decipher.setAutoPadding(false);
|
|
42
|
-
} catch (e) {
|
|
43
|
-
throw new Error(`Decrypt init failed: ${String(e)}`);
|
|
44
|
-
}
|
|
48
|
+
let deciphered = Buffer.concat([decipher.update(text, "base64"), decipher.final()]);
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
decipher.update(text, "base64"),
|
|
48
|
-
decipher.final(),
|
|
49
|
-
]);
|
|
50
|
+
deciphered = this.decodePkcs7(deciphered);
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
// Format: 16 random bytes | 4 bytes msg_len | msg_content | appid
|
|
53
|
+
const content = deciphered.subarray(16);
|
|
54
|
+
const lenList = content.subarray(0, 4);
|
|
55
|
+
const xmlLen = lenList.readUInt32BE(0);
|
|
56
|
+
const xmlContent = content.subarray(4, 4 + xmlLen).toString("utf-8");
|
|
57
|
+
// For AI Bot mode, corpId/appid is empty, skip validation
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const lenList = content.subarray(0, 4);
|
|
56
|
-
const xmlLen = lenList.readUInt32BE(0);
|
|
57
|
-
const xmlContent = content.subarray(4, 4 + xmlLen).toString("utf-8");
|
|
58
|
-
// For AI Bot mode, corpId/appid is empty, skip validation
|
|
59
|
+
return { message: xmlContent };
|
|
60
|
+
}
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
encrypt(text) {
|
|
63
|
+
// For AI Bot mode, corpId is empty
|
|
64
|
+
const random16 = randomBytes(16);
|
|
65
|
+
const msgBuffer = Buffer.from(text);
|
|
66
|
+
const lenBuffer = Buffer.alloc(4);
|
|
67
|
+
lenBuffer.writeUInt32BE(msgBuffer.length, 0);
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const random16 = randomBytes(16);
|
|
66
|
-
const msgBuffer = Buffer.from(text);
|
|
67
|
-
const lenBuffer = Buffer.alloc(4);
|
|
68
|
-
lenBuffer.writeUInt32BE(msgBuffer.length, 0);
|
|
69
|
+
const rawMsg = Buffer.concat([random16, lenBuffer, msgBuffer]);
|
|
70
|
+
const encoded = this.encodePkcs7(rawMsg);
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
const cipher = createCipheriv("aes-256-cbc", this.aesKey, this.iv);
|
|
73
|
+
cipher.setAutoPadding(false);
|
|
74
|
+
const ciphered = Buffer.concat([cipher.update(encoded), cipher.final()]);
|
|
75
|
+
return ciphered.toString("base64");
|
|
76
|
+
}
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
encodePkcs7(buff) {
|
|
79
|
+
const blockSize = CONSTANTS.AES_BLOCK_SIZE;
|
|
80
|
+
const amountToPad = blockSize - (buff.length % blockSize);
|
|
81
|
+
const pad = Buffer.alloc(amountToPad, amountToPad);
|
|
82
|
+
return Buffer.concat([buff, pad]);
|
|
83
|
+
}
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return Buffer.concat([buff, pad]);
|
|
85
|
+
decodePkcs7(buff) {
|
|
86
|
+
const pad = buff[buff.length - 1];
|
|
87
|
+
if (pad < 1 || pad > CONSTANTS.AES_BLOCK_SIZE) {
|
|
88
|
+
throw new Error(`Invalid PKCS7 padding: ${pad}`);
|
|
84
89
|
}
|
|
90
|
+
for (let i = buff.length - pad; i < buff.length; i++) {
|
|
91
|
+
if (buff[i] !== pad) {
|
|
92
|
+
throw new Error("Invalid PKCS7 padding: inconsistent padding bytes");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return buff.subarray(0, buff.length - pad);
|
|
96
|
+
}
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Decrypt image/media file from Enterprise WeChat.
|
|
100
|
+
* Images are encrypted with AES-256-CBC using the same key as messages.
|
|
101
|
+
* Note: WeCom uses PKCS7 padding to 32-byte blocks (not standard 16-byte).
|
|
102
|
+
* @param {Buffer} encryptedData - The encrypted image data (raw bytes, not base64)
|
|
103
|
+
* @returns {Buffer} - Decrypted image data
|
|
104
|
+
*/
|
|
105
|
+
decryptMedia(encryptedData) {
|
|
106
|
+
const decipher = createDecipheriv("aes-256-cbc", this.aesKey, this.iv);
|
|
107
|
+
decipher.setAutoPadding(false);
|
|
108
|
+
const decrypted = Buffer.concat([
|
|
109
|
+
decipher.update(encryptedData),
|
|
110
|
+
decipher.final(),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
// Remove PKCS7 padding manually (padded to 32-byte blocks).
|
|
114
|
+
const padLen = decrypted[decrypted.length - 1];
|
|
115
|
+
let unpadded = decrypted;
|
|
116
|
+
if (padLen >= 1 && padLen <= 32) {
|
|
117
|
+
let validPadding = true;
|
|
118
|
+
for (let i = decrypted.length - padLen; i < decrypted.length; i++) {
|
|
119
|
+
if (decrypted[i] !== padLen) {
|
|
120
|
+
validPadding = false;
|
|
121
|
+
break;
|
|
95
122
|
}
|
|
96
|
-
|
|
123
|
+
}
|
|
124
|
+
if (validPadding) {
|
|
125
|
+
unpadded = decrypted.subarray(0, decrypted.length - padLen);
|
|
126
|
+
}
|
|
97
127
|
}
|
|
98
|
-
}
|
|
99
128
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
ignoreAttributes: true
|
|
108
|
-
});
|
|
129
|
+
logger.debug("Media decrypted successfully", {
|
|
130
|
+
inputSize: encryptedData.length,
|
|
131
|
+
outputSize: unpadded.length,
|
|
132
|
+
});
|
|
133
|
+
return unpadded;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dynamic-agent.js
CHANGED
|
@@ -1,120 +1,103 @@
|
|
|
1
|
-
import { logger } from "./logger.js";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* Dynamic
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 流程:
|
|
10
|
-
* 1. 插件收到消息 → generateAgentId() 生成 agentId
|
|
11
|
-
* 2. 插件构造 SessionKey → `agent:{agentId}:{peerKind}:{peerId}`
|
|
12
|
-
* 3. OpenClaw 解析 SessionKey → 提取 agentId
|
|
13
|
-
* 4. OpenClaw 调用 resolveAgentWorkspaceDir() → fallback 到 ~/.openclaw/workspace-{agentId}
|
|
14
|
-
* 5. OpenClaw 调用 ensureAgentWorkspace() → 自动创建目录和 Bootstrap 文件
|
|
2
|
+
* Dynamic agent helpers.
|
|
3
|
+
*
|
|
4
|
+
* This plugin only computes deterministic agent ids/session keys.
|
|
5
|
+
* Workspace/bootstrap creation is handled by OpenClaw core.
|
|
15
6
|
*/
|
|
16
7
|
|
|
17
8
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* @param {string}
|
|
22
|
-
* @param {string} peerId - userId 或 groupId
|
|
9
|
+
* Build a deterministic agent id for dm/group contexts.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} chatType - "dm" or "group"
|
|
12
|
+
* @param {string} peerId - user id or group id
|
|
23
13
|
* @returns {string} agentId
|
|
24
14
|
*/
|
|
25
15
|
export function generateAgentId(chatType, peerId) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return `wecom-
|
|
16
|
+
const sanitizedId = String(peerId)
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9_-]/g, "_");
|
|
19
|
+
if (chatType === "group") {
|
|
20
|
+
return `wecom-group-${sanitizedId}`;
|
|
21
|
+
}
|
|
22
|
+
return `wecom-dm-${sanitizedId}`;
|
|
31
23
|
}
|
|
32
24
|
|
|
33
25
|
/**
|
|
34
|
-
*
|
|
26
|
+
* Resolve runtime dynamic-agent settings from config.
|
|
35
27
|
*/
|
|
36
28
|
export function getDynamicAgentConfig(config) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
groupEnabled: wecom.groupChat?.enabled !== false,
|
|
46
|
-
groupRequireMention: wecom.groupChat?.requireMention !== false,
|
|
47
|
-
groupMentionPatterns: wecom.groupChat?.mentionPatterns || ["@"],
|
|
48
|
-
groupCreateAgent: wecom.groupChat?.createAgentOnFirstMessage !== false,
|
|
49
|
-
groupHistoryLimit: wecom.groupChat?.historyLimit || 10,
|
|
50
|
-
};
|
|
29
|
+
const wecom = config?.channels?.wecom || {};
|
|
30
|
+
return {
|
|
31
|
+
enabled: wecom.dynamicAgents?.enabled !== false,
|
|
32
|
+
dmCreateAgent: wecom.dm?.createAgentOnFirstMessage !== false,
|
|
33
|
+
groupEnabled: wecom.groupChat?.enabled !== false,
|
|
34
|
+
groupRequireMention: wecom.groupChat?.requireMention !== false,
|
|
35
|
+
groupMentionPatterns: wecom.groupChat?.mentionPatterns || ["@"],
|
|
36
|
+
};
|
|
51
37
|
}
|
|
52
38
|
|
|
53
39
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* @param {Object} options
|
|
57
|
-
* @param {string} options.chatType - "dm" 或 "group"
|
|
58
|
-
* @param {Object} options.config - openclaw 配置
|
|
59
|
-
* @returns {boolean}
|
|
40
|
+
* Decide whether this message context should route to a dynamic agent.
|
|
60
41
|
*/
|
|
61
42
|
export function shouldUseDynamicAgent({ chatType, config }) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (!dynamicConfig.enabled) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (chatType === "dm") {
|
|
69
|
-
return dynamicConfig.dmCreateAgent;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (chatType === "group") {
|
|
73
|
-
return dynamicConfig.groupCreateAgent;
|
|
74
|
-
}
|
|
75
|
-
|
|
43
|
+
const dynamicConfig = getDynamicAgentConfig(config);
|
|
44
|
+
if (!dynamicConfig.enabled) {
|
|
76
45
|
return false;
|
|
46
|
+
}
|
|
47
|
+
if (chatType === "group") {
|
|
48
|
+
return dynamicConfig.groupEnabled;
|
|
49
|
+
}
|
|
50
|
+
return dynamicConfig.dmCreateAgent;
|
|
77
51
|
}
|
|
78
52
|
|
|
79
53
|
/**
|
|
80
|
-
*
|
|
54
|
+
* Decide whether a group message should trigger a response.
|
|
81
55
|
*/
|
|
82
56
|
export function shouldTriggerGroupResponse(content, config) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (!dynamicConfig.groupEnabled) {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (!dynamicConfig.groupRequireMention) {
|
|
90
|
-
return true; // 不需要 @,所有消息都触发
|
|
91
|
-
}
|
|
57
|
+
const dynamicConfig = getDynamicAgentConfig(config);
|
|
92
58
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
59
|
+
if (!dynamicConfig.groupEnabled) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!dynamicConfig.groupRequireMention) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Match any configured mention marker in the original message content.
|
|
68
|
+
// Use word-boundary check to avoid false positives on email addresses.
|
|
69
|
+
const patterns = dynamicConfig.groupMentionPatterns;
|
|
70
|
+
for (const pattern of patterns) {
|
|
71
|
+
const escaped = escapeRegExp(pattern);
|
|
72
|
+
// @ must NOT be preceded by a word char (avoids user@domain false matches).
|
|
73
|
+
const re = new RegExp(`(?:^|(?<=\\s|[^\\w]))${escaped}`, "u");
|
|
74
|
+
if (re.test(content)) {
|
|
75
|
+
return true;
|
|
99
76
|
}
|
|
77
|
+
}
|
|
100
78
|
|
|
101
|
-
|
|
79
|
+
return false;
|
|
102
80
|
}
|
|
103
81
|
|
|
104
82
|
/**
|
|
105
|
-
*
|
|
83
|
+
* Remove configured mention markers from group message text.
|
|
106
84
|
*/
|
|
107
85
|
export function extractGroupMessageContent(content, config) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
86
|
+
const dynamicConfig = getDynamicAgentConfig(config);
|
|
87
|
+
let cleanContent = content;
|
|
88
|
+
|
|
89
|
+
const patterns = dynamicConfig.groupMentionPatterns;
|
|
90
|
+
for (const pattern of patterns) {
|
|
91
|
+
const escapedPattern = escapeRegExp(pattern);
|
|
92
|
+
// Only strip @name tokens that are NOT part of email-style addresses.
|
|
93
|
+
// Require the pattern to be preceded by start-of-string or whitespace.
|
|
94
|
+
const regex = new RegExp(`(?:^|(?<=\\s))${escapedPattern}\\S*\\s*`, "gu");
|
|
95
|
+
cleanContent = cleanContent.replace(regex, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return cleanContent.trim();
|
|
99
|
+
}
|
|
118
100
|
|
|
119
|
-
|
|
101
|
+
function escapeRegExp(value) {
|
|
102
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
120
103
|
}
|
package/image-processor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readFile } from "fs/promises";
|
|
2
1
|
import { createHash } from "crypto";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
3
|
import { logger } from "./logger.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -11,8 +11,8 @@ import { logger } from "./logger.js";
|
|
|
11
11
|
|
|
12
12
|
// Image format signatures (magic bytes)
|
|
13
13
|
const IMAGE_SIGNATURES = {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
JPG: [0xff, 0xd8, 0xff],
|
|
15
|
+
PNG: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
// 2MB size limit (before base64 encoding)
|
|
@@ -25,23 +25,23 @@ const MAX_IMAGE_SIZE = 2 * 1024 * 1024;
|
|
|
25
25
|
* @throws {Error} If file not found or cannot be read
|
|
26
26
|
*/
|
|
27
27
|
export async function loadImageFromPath(filePath) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
28
|
+
try {
|
|
29
|
+
logger.debug("Loading image from path", { filePath });
|
|
30
|
+
const buffer = await readFile(filePath);
|
|
31
|
+
logger.debug("Image loaded successfully", {
|
|
32
|
+
filePath,
|
|
33
|
+
size: buffer.length,
|
|
34
|
+
});
|
|
35
|
+
return buffer;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === "ENOENT") {
|
|
38
|
+
throw new Error(`Image file not found: ${filePath}`, { cause: error });
|
|
39
|
+
} else if (error.code === "EACCES") {
|
|
40
|
+
throw new Error(`Permission denied reading image: ${filePath}`, { cause: error });
|
|
41
|
+
} else {
|
|
42
|
+
throw new Error(`Failed to read image file: ${error.message}`, { cause: error });
|
|
44
43
|
}
|
|
44
|
+
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
@@ -50,7 +50,7 @@ export async function loadImageFromPath(filePath) {
|
|
|
50
50
|
* @returns {string} Base64-encoded string
|
|
51
51
|
*/
|
|
52
52
|
export function encodeImageToBase64(buffer) {
|
|
53
|
-
|
|
53
|
+
return buffer.toString("base64");
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -59,7 +59,7 @@ export function encodeImageToBase64(buffer) {
|
|
|
59
59
|
* @returns {string} MD5 hash in hexadecimal
|
|
60
60
|
*/
|
|
61
61
|
export function calculateMD5(buffer) {
|
|
62
|
-
|
|
62
|
+
return createHash("md5").update(buffer).digest("hex");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
@@ -68,16 +68,14 @@ export function calculateMD5(buffer) {
|
|
|
68
68
|
* @throws {Error} If size exceeds 2MB limit
|
|
69
69
|
*/
|
|
70
70
|
export function validateImageSize(buffer) {
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
const sizeBytes = buffer.length;
|
|
72
|
+
const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
);
|
|
78
|
-
}
|
|
74
|
+
if (sizeBytes > MAX_IMAGE_SIZE) {
|
|
75
|
+
throw new Error(`Image size ${sizeMB}MB exceeds 2MB limit (actual: ${sizeBytes} bytes)`);
|
|
76
|
+
}
|
|
79
77
|
|
|
80
|
-
|
|
78
|
+
logger.debug("Image size validated", { sizeBytes, sizeMB });
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
/**
|
|
@@ -87,34 +85,29 @@ export function validateImageSize(buffer) {
|
|
|
87
85
|
* @throws {Error} If format is not supported
|
|
88
86
|
*/
|
|
89
87
|
export function detectImageFormat(buffer) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
logger.debug("Image format detected: PNG");
|
|
97
|
-
return "PNG";
|
|
98
|
-
}
|
|
88
|
+
// Check PNG signature
|
|
89
|
+
if (buffer.length >= IMAGE_SIGNATURES.PNG.length) {
|
|
90
|
+
const isPNG = IMAGE_SIGNATURES.PNG.every((byte, index) => buffer[index] === byte);
|
|
91
|
+
if (isPNG) {
|
|
92
|
+
logger.debug("Image format detected: PNG");
|
|
93
|
+
return "PNG";
|
|
99
94
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return "JPG";
|
|
109
|
-
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check JPG signature
|
|
98
|
+
if (buffer.length >= IMAGE_SIGNATURES.JPG.length) {
|
|
99
|
+
const isJPG = IMAGE_SIGNATURES.JPG.every((byte, index) => buffer[index] === byte);
|
|
100
|
+
if (isJPG) {
|
|
101
|
+
logger.debug("Image format detected: JPG");
|
|
102
|
+
return "JPG";
|
|
110
103
|
}
|
|
104
|
+
}
|
|
111
105
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
106
|
+
// Unknown format
|
|
107
|
+
const header = buffer.subarray(0, 16).toString("hex");
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Unsupported image format. Only JPG and PNG are supported. File header: ${header}`,
|
|
110
|
+
);
|
|
118
111
|
}
|
|
119
112
|
|
|
120
113
|
/**
|
|
@@ -137,43 +130,43 @@ export function detectImageFormat(buffer) {
|
|
|
137
130
|
* // Returns: { base64: "...", md5: "...", format: "JPG", size: 123456 }
|
|
138
131
|
*/
|
|
139
132
|
export async function prepareImageForMsgItem(filePath) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
133
|
+
logger.debug("Starting image processing pipeline", { filePath });
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
// Step 1: Load image
|
|
137
|
+
const buffer = await loadImageFromPath(filePath);
|
|
138
|
+
|
|
139
|
+
// Step 2: Validate size
|
|
140
|
+
validateImageSize(buffer);
|
|
141
|
+
|
|
142
|
+
// Step 3: Detect format
|
|
143
|
+
const format = detectImageFormat(buffer);
|
|
144
|
+
|
|
145
|
+
// Step 4: Encode to base64
|
|
146
|
+
const base64 = encodeImageToBase64(buffer);
|
|
147
|
+
|
|
148
|
+
// Step 5: Calculate MD5
|
|
149
|
+
const md5 = calculateMD5(buffer);
|
|
150
|
+
|
|
151
|
+
logger.info("Image processed successfully", {
|
|
152
|
+
filePath,
|
|
153
|
+
format,
|
|
154
|
+
size: buffer.length,
|
|
155
|
+
md5,
|
|
156
|
+
base64Length: base64.length,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
base64,
|
|
161
|
+
md5,
|
|
162
|
+
format,
|
|
163
|
+
size: buffer.length,
|
|
164
|
+
};
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logger.error("Image processing failed", {
|
|
167
|
+
filePath,
|
|
168
|
+
error: error.message,
|
|
169
|
+
});
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
179
172
|
}
|