@sunnoy/wecom 1.0.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/CONTRIBUTING.md +25 -0
- package/LICENSE +7 -0
- package/README.md +284 -0
- package/README_ZH.md +284 -0
- package/client.js +127 -0
- package/crypto.js +108 -0
- package/dynamic-agent.js +120 -0
- package/image-processor.js +179 -0
- package/index.js +889 -0
- package/logger.js +64 -0
- package/openclaw.plugin.json +13 -0
- package/package.json +60 -0
- package/stream-manager.js +307 -0
- package/utils.js +251 -0
- package/webhook.js +273 -0
package/client.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom AI Bot Client
|
|
3
|
+
* 智能机器人专用 - 只使用 response_url 回复,不需要 access_token
|
|
4
|
+
* https://developer.work.weixin.qq.com/document/path/101039
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { logger } from "./logger.js";
|
|
8
|
+
import { withRetry, parseWecomError, CONSTANTS } from "./utils.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 通过 response_url 主动回复消息
|
|
12
|
+
* https://developer.work.weixin.qq.com/document/path/101138
|
|
13
|
+
*/
|
|
14
|
+
export async function sendReplyMessage(responseUrl, message) {
|
|
15
|
+
if (!responseUrl) {
|
|
16
|
+
throw new Error("response_url is required");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
logger.debug("Sending reply via response_url", { msgtype: message.msgtype });
|
|
20
|
+
|
|
21
|
+
return await withRetry(async () => {
|
|
22
|
+
const res = await fetch(responseUrl, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify(message),
|
|
26
|
+
signal: AbortSignal.timeout(CONSTANTS.WEBHOOK_RESPONSE_TIMEOUT_MS),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const data = await res.json();
|
|
34
|
+
if (data.errcode !== 0) {
|
|
35
|
+
const errorInfo = parseWecomError(data.errcode, data.errmsg);
|
|
36
|
+
throw new Error(`Response failed: [${data.errcode}] ${errorInfo.message}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
logger.info("Reply sent successfully via response_url");
|
|
40
|
+
return data;
|
|
41
|
+
}, {
|
|
42
|
+
retries: 2,
|
|
43
|
+
minTimeout: 500,
|
|
44
|
+
maxTimeout: 2000,
|
|
45
|
+
onRetry: (error, attempt) => {
|
|
46
|
+
logger.warn(`Reply retry ${attempt}/2`, { error: error.message });
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 发送 Markdown 消息
|
|
53
|
+
*/
|
|
54
|
+
export async function sendMarkdownReply(responseUrl, content) {
|
|
55
|
+
return sendReplyMessage(responseUrl, {
|
|
56
|
+
msgtype: "markdown",
|
|
57
|
+
markdown: { content },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 发送文本消息
|
|
63
|
+
*/
|
|
64
|
+
export async function sendTextReply(responseUrl, content) {
|
|
65
|
+
return sendReplyMessage(responseUrl, {
|
|
66
|
+
msgtype: "text",
|
|
67
|
+
text: { content },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 发送流式响应片段
|
|
73
|
+
* https://developer.work.weixin.qq.com/document/path/101031#流式消息回复
|
|
74
|
+
*
|
|
75
|
+
* @param responseUrl - 回调中返回的 response_url
|
|
76
|
+
* @param streamId - 流ID,同一轮对话保持一致
|
|
77
|
+
* @param content - 本次消息内容 (markdown 格式)
|
|
78
|
+
* @param isFinished - 是否结束流式响应
|
|
79
|
+
*/
|
|
80
|
+
export async function sendStreamChunk(responseUrl, streamId, content, isFinished = false) {
|
|
81
|
+
if (!responseUrl) {
|
|
82
|
+
throw new Error("response_url is required for streaming");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const message = {
|
|
86
|
+
msgtype: "stream",
|
|
87
|
+
stream: {
|
|
88
|
+
id: streamId,
|
|
89
|
+
finish: isFinished,
|
|
90
|
+
content: content,
|
|
91
|
+
// msg_item: [], // 可选:图片等
|
|
92
|
+
// feedback: { id: "feedid" } // 可选:反馈ID
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
logger.debug("Sending stream chunk", { streamId, isFinished, length: content.length });
|
|
97
|
+
|
|
98
|
+
const res = await fetch(responseUrl, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify(message),
|
|
102
|
+
signal: AbortSignal.timeout(CONSTANTS.WEBHOOK_RESPONSE_TIMEOUT_MS),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = await res.json();
|
|
110
|
+
if (data.errcode !== 0) {
|
|
111
|
+
const errorInfo = parseWecomError(data.errcode, data.errmsg);
|
|
112
|
+
throw new Error(`Stream response failed: [${data.errcode}] ${errorInfo.message}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return data;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 发送模板卡片消息
|
|
120
|
+
* https://developer.work.weixin.qq.com/document/path/101061
|
|
121
|
+
*/
|
|
122
|
+
export async function sendTemplateCardReply(responseUrl, card) {
|
|
123
|
+
return sendReplyMessage(responseUrl, {
|
|
124
|
+
msgtype: "template_card",
|
|
125
|
+
template_card: card,
|
|
126
|
+
});
|
|
127
|
+
}
|
package/crypto.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
|
2
|
+
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
|
3
|
+
import { CONSTANTS } from "./utils.js";
|
|
4
|
+
import { logger } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Enterprise WeChat Intelligent Robot Crypto Implementation
|
|
8
|
+
* Simplified for AI Bot mode (no corpId validation)
|
|
9
|
+
*/
|
|
10
|
+
export class WecomCrypto {
|
|
11
|
+
token;
|
|
12
|
+
encodingAesKey;
|
|
13
|
+
aesKey;
|
|
14
|
+
iv;
|
|
15
|
+
|
|
16
|
+
constructor(token, encodingAesKey) {
|
|
17
|
+
if (!encodingAesKey || encodingAesKey.length !== CONSTANTS.AES_KEY_LENGTH) {
|
|
18
|
+
throw new Error(`EncodingAESKey invalid: length must be ${CONSTANTS.AES_KEY_LENGTH}`);
|
|
19
|
+
}
|
|
20
|
+
if (!token) {
|
|
21
|
+
throw new Error("Token is required");
|
|
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)");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getSignature(timestamp, nonce, encrypt) {
|
|
31
|
+
const shasum = createHash("sha1");
|
|
32
|
+
const arr = [this.token, timestamp, nonce, encrypt].sort();
|
|
33
|
+
shasum.update(arr.join(""));
|
|
34
|
+
return shasum.digest("hex");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
decrypt(text) {
|
|
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
|
+
}
|
|
45
|
+
|
|
46
|
+
let deciphered = Buffer.concat([
|
|
47
|
+
decipher.update(text, "base64"),
|
|
48
|
+
decipher.final(),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
deciphered = this.decodePkcs7(deciphered);
|
|
52
|
+
|
|
53
|
+
// Format: 16 random bytes | 4 bytes msg_len | msg_content | appid
|
|
54
|
+
const content = deciphered.subarray(16);
|
|
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
|
+
|
|
60
|
+
return { message: xmlContent };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
encrypt(text) {
|
|
64
|
+
// For AI Bot mode, corpId is empty
|
|
65
|
+
const random16 = randomBytes(16);
|
|
66
|
+
const msgBuffer = Buffer.from(text);
|
|
67
|
+
const lenBuffer = Buffer.alloc(4);
|
|
68
|
+
lenBuffer.writeUInt32BE(msgBuffer.length, 0);
|
|
69
|
+
|
|
70
|
+
const rawMsg = Buffer.concat([random16, lenBuffer, msgBuffer]);
|
|
71
|
+
const encoded = this.encodePkcs7(rawMsg);
|
|
72
|
+
|
|
73
|
+
const cipher = createCipheriv("aes-256-cbc", this.aesKey, this.iv);
|
|
74
|
+
cipher.setAutoPadding(false);
|
|
75
|
+
const ciphered = Buffer.concat([cipher.update(encoded), cipher.final()]);
|
|
76
|
+
return ciphered.toString("base64");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
encodePkcs7(buff) {
|
|
80
|
+
const blockSize = CONSTANTS.AES_BLOCK_SIZE;
|
|
81
|
+
const amountToPad = blockSize - (buff.length % blockSize);
|
|
82
|
+
const pad = Buffer.alloc(amountToPad, amountToPad);
|
|
83
|
+
return Buffer.concat([buff, pad]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
decodePkcs7(buff) {
|
|
87
|
+
const pad = buff[buff.length - 1];
|
|
88
|
+
if (pad < 1 || pad > CONSTANTS.AES_BLOCK_SIZE) {
|
|
89
|
+
throw new Error(`Invalid PKCS7 padding: ${pad}`);
|
|
90
|
+
}
|
|
91
|
+
for (let i = buff.length - pad; i < buff.length; i++) {
|
|
92
|
+
if (buff[i] !== pad) {
|
|
93
|
+
throw new Error("Invalid PKCS7 padding: inconsistent padding bytes");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return buff.subarray(0, buff.length - pad);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const xmlParser = new XMLParser({
|
|
101
|
+
ignoreAttributes: true,
|
|
102
|
+
parseTagValue: false
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const xmlBuilder = new XMLBuilder({
|
|
106
|
+
format: false,
|
|
107
|
+
ignoreAttributes: true
|
|
108
|
+
});
|
package/dynamic-agent.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dynamic Agent Manager (Minimal Version)
|
|
5
|
+
*
|
|
6
|
+
* 极简版:插件只负责生成 AgentId
|
|
7
|
+
* 所有 Workspace 创建和 Bootstrap 文件由 OpenClaw 主程序自动处理
|
|
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 文件
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 生成 AgentId
|
|
19
|
+
* 规范:wecom-dm-{userId} 或 wecom-group-{groupId}
|
|
20
|
+
*
|
|
21
|
+
* @param {string} chatType - "dm" 或 "group"
|
|
22
|
+
* @param {string} peerId - userId 或 groupId
|
|
23
|
+
* @returns {string} agentId
|
|
24
|
+
*/
|
|
25
|
+
export function generateAgentId(chatType, peerId) {
|
|
26
|
+
const sanitizedId = String(peerId).toLowerCase().replace(/[^a-z0-9_-]/g, "_");
|
|
27
|
+
if (chatType === "group") {
|
|
28
|
+
return `wecom-group-${sanitizedId}`;
|
|
29
|
+
}
|
|
30
|
+
return `wecom-dm-${sanitizedId}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 获取动态 Agent 配置
|
|
35
|
+
*/
|
|
36
|
+
export function getDynamicAgentConfig(config) {
|
|
37
|
+
const wecom = config?.channels?.wecom || {};
|
|
38
|
+
return {
|
|
39
|
+
enabled: wecom.dynamicAgents?.enabled !== false,
|
|
40
|
+
|
|
41
|
+
// 私聊配置
|
|
42
|
+
dmCreateAgent: wecom.dm?.createAgentOnFirstMessage !== false,
|
|
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
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 检查是否应该为此消息创建/使用动态 Agent
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} options
|
|
57
|
+
* @param {string} options.chatType - "dm" 或 "group"
|
|
58
|
+
* @param {Object} options.config - openclaw 配置
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function shouldUseDynamicAgent({ chatType, config }) {
|
|
62
|
+
const dynamicConfig = getDynamicAgentConfig(config);
|
|
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
|
+
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 检查群聊消息是否满足触发条件(@提及)
|
|
81
|
+
*/
|
|
82
|
+
export function shouldTriggerGroupResponse(content, config) {
|
|
83
|
+
const dynamicConfig = getDynamicAgentConfig(config);
|
|
84
|
+
|
|
85
|
+
if (!dynamicConfig.groupEnabled) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!dynamicConfig.groupRequireMention) {
|
|
90
|
+
return true; // 不需要 @,所有消息都触发
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 检查是否包含 @提及
|
|
94
|
+
const patterns = dynamicConfig.groupMentionPatterns;
|
|
95
|
+
for (const pattern of patterns) {
|
|
96
|
+
if (content.includes(pattern)) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 从群聊消息中提取实际内容(移除 @提及)
|
|
106
|
+
*/
|
|
107
|
+
export function extractGroupMessageContent(content, config) {
|
|
108
|
+
const dynamicConfig = getDynamicAgentConfig(config);
|
|
109
|
+
let cleanContent = content;
|
|
110
|
+
|
|
111
|
+
// 移除 @提及 pattern
|
|
112
|
+
const patterns = dynamicConfig.groupMentionPatterns;
|
|
113
|
+
for (const pattern of patterns) {
|
|
114
|
+
// 移除 @xxx 格式的提及(包括后面可能的空格)
|
|
115
|
+
const regex = new RegExp(`${pattern}\\S*\\s*`, "g");
|
|
116
|
+
cleanContent = cleanContent.replace(regex, "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return cleanContent.trim();
|
|
120
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Image Processing Module for WeCom
|
|
7
|
+
*
|
|
8
|
+
* Handles loading, validating, and encoding images for WeCom msg_item
|
|
9
|
+
* Supports JPG and PNG formats up to 2MB
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Image format signatures (magic bytes)
|
|
13
|
+
const IMAGE_SIGNATURES = {
|
|
14
|
+
JPG: [0xFF, 0xD8, 0xFF],
|
|
15
|
+
PNG: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// 2MB size limit (before base64 encoding)
|
|
19
|
+
const MAX_IMAGE_SIZE = 2 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load image file from filesystem
|
|
23
|
+
* @param {string} filePath - Absolute path to image file
|
|
24
|
+
* @returns {Promise<Buffer>} Image data buffer
|
|
25
|
+
* @throws {Error} If file not found or cannot be read
|
|
26
|
+
*/
|
|
27
|
+
export async function loadImageFromPath(filePath) {
|
|
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}`);
|
|
39
|
+
} else if (error.code === "EACCES") {
|
|
40
|
+
throw new Error(`Permission denied reading image: ${filePath}`);
|
|
41
|
+
} else {
|
|
42
|
+
throw new Error(`Failed to read image file: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convert buffer to base64 string
|
|
49
|
+
* @param {Buffer} buffer - Image data buffer
|
|
50
|
+
* @returns {string} Base64-encoded string
|
|
51
|
+
*/
|
|
52
|
+
export function encodeImageToBase64(buffer) {
|
|
53
|
+
return buffer.toString("base64");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Calculate MD5 checksum of buffer
|
|
58
|
+
* @param {Buffer} buffer - Image data buffer
|
|
59
|
+
* @returns {string} MD5 hash in hexadecimal
|
|
60
|
+
*/
|
|
61
|
+
export function calculateMD5(buffer) {
|
|
62
|
+
return createHash("md5").update(buffer).digest("hex");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Validate image size is within limits
|
|
67
|
+
* @param {Buffer} buffer - Image data buffer
|
|
68
|
+
* @throws {Error} If size exceeds 2MB limit
|
|
69
|
+
*/
|
|
70
|
+
export function validateImageSize(buffer) {
|
|
71
|
+
const sizeBytes = buffer.length;
|
|
72
|
+
const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
|
|
73
|
+
|
|
74
|
+
if (sizeBytes > MAX_IMAGE_SIZE) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Image size ${sizeMB}MB exceeds 2MB limit (actual: ${sizeBytes} bytes)`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
logger.debug("Image size validated", { sizeBytes, sizeMB });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Detect image format from magic bytes
|
|
85
|
+
* @param {Buffer} buffer - Image data buffer
|
|
86
|
+
* @returns {string} Format: "JPG" or "PNG"
|
|
87
|
+
* @throws {Error} If format is not supported
|
|
88
|
+
*/
|
|
89
|
+
export function detectImageFormat(buffer) {
|
|
90
|
+
// Check PNG signature
|
|
91
|
+
if (buffer.length >= IMAGE_SIGNATURES.PNG.length) {
|
|
92
|
+
const isPNG = IMAGE_SIGNATURES.PNG.every(
|
|
93
|
+
(byte, index) => buffer[index] === byte
|
|
94
|
+
);
|
|
95
|
+
if (isPNG) {
|
|
96
|
+
logger.debug("Image format detected: PNG");
|
|
97
|
+
return "PNG";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check JPG signature
|
|
102
|
+
if (buffer.length >= IMAGE_SIGNATURES.JPG.length) {
|
|
103
|
+
const isJPG = IMAGE_SIGNATURES.JPG.every(
|
|
104
|
+
(byte, index) => buffer[index] === byte
|
|
105
|
+
);
|
|
106
|
+
if (isJPG) {
|
|
107
|
+
logger.debug("Image format detected: JPG");
|
|
108
|
+
return "JPG";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Unknown format
|
|
113
|
+
const header = buffer.slice(0, 16).toString("hex");
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Unsupported image format. Only JPG and PNG are supported. ` +
|
|
116
|
+
`File header: ${header}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Complete image processing pipeline
|
|
122
|
+
*
|
|
123
|
+
* Loads image from filesystem, validates format and size,
|
|
124
|
+
* then encodes to base64 and calculates MD5 checksum.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} filePath - Absolute path to image file
|
|
127
|
+
* @returns {Promise<Object>} Processed image data
|
|
128
|
+
* @returns {string} return.base64 - Base64-encoded image data
|
|
129
|
+
* @returns {string} return.md5 - MD5 checksum
|
|
130
|
+
* @returns {string} return.format - Image format (JPG or PNG)
|
|
131
|
+
* @returns {number} return.size - Original size in bytes
|
|
132
|
+
*
|
|
133
|
+
* @throws {Error} If any step fails (file not found, invalid format, size exceeded, etc.)
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* const result = await prepareImageForMsgItem('/path/to/image.jpg');
|
|
137
|
+
* // Returns: { base64: "...", md5: "...", format: "JPG", size: 123456 }
|
|
138
|
+
*/
|
|
139
|
+
export async function prepareImageForMsgItem(filePath) {
|
|
140
|
+
logger.debug("Starting image processing pipeline", { filePath });
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// Step 1: Load image
|
|
144
|
+
const buffer = await loadImageFromPath(filePath);
|
|
145
|
+
|
|
146
|
+
// Step 2: Validate size
|
|
147
|
+
validateImageSize(buffer);
|
|
148
|
+
|
|
149
|
+
// Step 3: Detect format
|
|
150
|
+
const format = detectImageFormat(buffer);
|
|
151
|
+
|
|
152
|
+
// Step 4: Encode to base64
|
|
153
|
+
const base64 = encodeImageToBase64(buffer);
|
|
154
|
+
|
|
155
|
+
// Step 5: Calculate MD5
|
|
156
|
+
const md5 = calculateMD5(buffer);
|
|
157
|
+
|
|
158
|
+
logger.info("Image processed successfully", {
|
|
159
|
+
filePath,
|
|
160
|
+
format,
|
|
161
|
+
size: buffer.length,
|
|
162
|
+
md5,
|
|
163
|
+
base64Length: base64.length
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
base64,
|
|
168
|
+
md5,
|
|
169
|
+
format,
|
|
170
|
+
size: buffer.length
|
|
171
|
+
};
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.error("Image processing failed", {
|
|
174
|
+
filePath,
|
|
175
|
+
error: error.message
|
|
176
|
+
});
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|