@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/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
+ });
@@ -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
+ }