@sunnoy/wecom 1.8.0 → 2.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/README.md +392 -838
- package/image-processor.js +30 -27
- package/index.js +11 -39
- package/openclaw.plugin.json +4 -4
- package/package.json +6 -8
- package/think-parser.js +51 -11
- package/utils.js +51 -0
- package/wecom/accounts.js +323 -189
- package/wecom/channel-plugin.js +545 -746
- package/wecom/constants.js +57 -47
- package/wecom/dm-policy.js +91 -0
- package/wecom/group-policy.js +85 -0
- package/wecom/onboarding.js +117 -0
- package/wecom/runtime-telemetry.js +330 -0
- package/wecom/sandbox.js +60 -0
- package/wecom/state.js +33 -35
- package/wecom/workspace-template.js +62 -5
- package/wecom/ws-monitor.js +1487 -0
- package/wecom/ws-state.js +160 -0
- package/crypto.js +0 -135
- package/stream-manager.js +0 -358
- package/webhook.js +0 -469
- package/wecom/agent-inbound.js +0 -527
- package/wecom/http-handler-state.js +0 -23
- package/wecom/http-handler.js +0 -381
- package/wecom/inbound-processor.js +0 -550
- package/wecom/media.js +0 -192
- package/wecom/outbound-delivery.js +0 -435
- package/wecom/response-url.js +0 -33
- package/wecom/stream-utils.js +0 -163
- package/wecom/webhook-targets.js +0 -28
- package/wecom/xml-parser.js +0 -126
package/image-processor.js
CHANGED
|
@@ -6,7 +6,7 @@ import { logger } from "./logger.js";
|
|
|
6
6
|
* Image Processing Module for WeCom
|
|
7
7
|
*
|
|
8
8
|
* Handles loading, validating, and encoding images for WeCom msg_item
|
|
9
|
-
* Supports JPG and PNG formats up to
|
|
9
|
+
* Supports JPG and PNG formats up to 10MB
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
// Image format signatures (magic bytes)
|
|
@@ -15,8 +15,27 @@ const IMAGE_SIGNATURES = {
|
|
|
15
15
|
PNG: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
const MAX_IMAGE_SIZE =
|
|
18
|
+
// 10MB size limit (before base64 encoding)
|
|
19
|
+
const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Process a raw image buffer into a WeCom msg_item payload.
|
|
23
|
+
* @param {Buffer} buffer
|
|
24
|
+
* @returns {{ base64: string, md5: string, format: string, size: number }}
|
|
25
|
+
*/
|
|
26
|
+
export function prepareImageBufferForMsgItem(buffer) {
|
|
27
|
+
validateImageSize(buffer);
|
|
28
|
+
const format = detectImageFormat(buffer);
|
|
29
|
+
const base64 = encodeImageToBase64(buffer);
|
|
30
|
+
const md5 = calculateMD5(buffer);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
base64,
|
|
34
|
+
md5,
|
|
35
|
+
format,
|
|
36
|
+
size: buffer.length,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
20
39
|
|
|
21
40
|
/**
|
|
22
41
|
* Load image file from filesystem
|
|
@@ -65,14 +84,14 @@ export function calculateMD5(buffer) {
|
|
|
65
84
|
/**
|
|
66
85
|
* Validate image size is within limits
|
|
67
86
|
* @param {Buffer} buffer - Image data buffer
|
|
68
|
-
* @throws {Error} If size exceeds
|
|
87
|
+
* @throws {Error} If size exceeds 10MB limit
|
|
69
88
|
*/
|
|
70
89
|
export function validateImageSize(buffer) {
|
|
71
90
|
const sizeBytes = buffer.length;
|
|
72
91
|
const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
|
|
73
92
|
|
|
74
93
|
if (sizeBytes > MAX_IMAGE_SIZE) {
|
|
75
|
-
throw new Error(`Image size ${sizeMB}MB exceeds
|
|
94
|
+
throw new Error(`Image size ${sizeMB}MB exceeds 10MB limit (actual: ${sizeBytes} bytes)`);
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
logger.debug("Image size validated", { sizeBytes, sizeMB });
|
|
@@ -135,33 +154,17 @@ export async function prepareImageForMsgItem(filePath) {
|
|
|
135
154
|
try {
|
|
136
155
|
// Step 1: Load image
|
|
137
156
|
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);
|
|
157
|
+
const result = prepareImageBufferForMsgItem(buffer);
|
|
150
158
|
|
|
151
159
|
logger.info("Image processed successfully", {
|
|
152
160
|
filePath,
|
|
153
|
-
format,
|
|
154
|
-
size:
|
|
155
|
-
md5,
|
|
156
|
-
base64Length: base64.length,
|
|
161
|
+
format: result.format,
|
|
162
|
+
size: result.size,
|
|
163
|
+
md5: result.md5,
|
|
164
|
+
base64Length: result.base64.length,
|
|
157
165
|
});
|
|
158
166
|
|
|
159
|
-
return
|
|
160
|
-
base64,
|
|
161
|
-
md5,
|
|
162
|
-
format,
|
|
163
|
-
size: buffer.length,
|
|
164
|
-
};
|
|
167
|
+
return result;
|
|
165
168
|
} catch (error) {
|
|
166
169
|
logger.error("Image processing failed", {
|
|
167
170
|
filePath,
|
package/index.js
CHANGED
|
@@ -1,55 +1,27 @@
|
|
|
1
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
1
2
|
import { logger } from "./logger.js";
|
|
2
|
-
import { streamManager } from "./stream-manager.js";
|
|
3
3
|
import { wecomChannelPlugin } from "./wecom/channel-plugin.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
// Periodic cleanup for streamMeta and expired responseUrls to prevent memory leaks.
|
|
8
|
-
setInterval(() => {
|
|
9
|
-
const now = Date.now();
|
|
10
|
-
// Clean streamMeta entries whose stream no longer exists in streamManager.
|
|
11
|
-
for (const streamId of streamMeta.keys()) {
|
|
12
|
-
if (!streamManager.hasStream(streamId)) {
|
|
13
|
-
streamMeta.delete(streamId);
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
// Clean expired responseUrls (older than 1 hour).
|
|
17
|
-
for (const [key, entry] of responseUrls.entries()) {
|
|
18
|
-
if (now > entry.expiresAt) {
|
|
19
|
-
responseUrls.delete(key);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
}, 60 * 1000).unref();
|
|
4
|
+
import { setOpenclawConfig, setRuntime } from "./wecom/state.js";
|
|
5
|
+
import { buildReplyMediaGuidance } from "./wecom/ws-monitor.js";
|
|
23
6
|
|
|
24
7
|
const plugin = {
|
|
25
|
-
// Plugin id should match `openclaw.plugin.json` id (and config.plugins.entries key).
|
|
26
8
|
id: "wecom",
|
|
27
9
|
name: "Enterprise WeChat",
|
|
28
10
|
description: "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
29
|
-
configSchema:
|
|
11
|
+
configSchema: emptyPluginConfigSchema(),
|
|
30
12
|
register(api) {
|
|
31
|
-
logger.info("WeCom plugin
|
|
32
|
-
|
|
33
|
-
// Save runtime for message processing
|
|
13
|
+
logger.info("Registering WeCom WS plugin");
|
|
34
14
|
setRuntime(api.runtime);
|
|
35
15
|
setOpenclawConfig(api.config);
|
|
36
|
-
|
|
37
|
-
// Register channel
|
|
38
16
|
api.registerChannel({ plugin: wecomChannelPlugin });
|
|
39
|
-
logger.info("WeCom channel registered");
|
|
40
17
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
path: "/webhooks",
|
|
48
|
-
handler: wecomHttpHandler,
|
|
49
|
-
auth: "plugin",
|
|
50
|
-
match: "prefix",
|
|
18
|
+
api.on("before_prompt_build", (_event, ctx) => {
|
|
19
|
+
if (ctx.channelId !== "wecom") {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const guidance = buildReplyMediaGuidance(api.config, ctx.agentId);
|
|
23
|
+
return { appendSystemContext: guidance };
|
|
51
24
|
});
|
|
52
|
-
logger.info("WeCom HTTP route registered (auth: plugin, match: prefix)");
|
|
53
25
|
},
|
|
54
26
|
};
|
|
55
27
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"id": "wecom",
|
|
3
3
|
"name": "OpenClaw WeCom",
|
|
4
4
|
"description": "Enterprise WeChat (WeCom) messaging channel plugin for OpenClaw",
|
|
5
|
-
"channels": [
|
|
6
|
-
"wecom"
|
|
7
|
-
],
|
|
8
5
|
"configSchema": {
|
|
9
6
|
"type": "object",
|
|
10
7
|
"additionalProperties": true,
|
|
11
8
|
"properties": {}
|
|
12
|
-
}
|
|
9
|
+
},
|
|
10
|
+
"channels": [
|
|
11
|
+
"wecom"
|
|
12
|
+
]
|
|
13
13
|
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sunnoy/wecom",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
|
8
8
|
"index.js",
|
|
9
9
|
"wecom",
|
|
10
|
-
"crypto.js",
|
|
11
10
|
"dynamic-agent.js",
|
|
12
11
|
"image-processor.js",
|
|
13
12
|
"logger.js",
|
|
14
13
|
"README.md",
|
|
15
14
|
"LICENSE",
|
|
16
15
|
"CONTRIBUTING.md",
|
|
17
|
-
"stream-manager.js",
|
|
18
16
|
"think-parser.js",
|
|
19
17
|
"utils.js",
|
|
20
|
-
"webhook.js",
|
|
21
18
|
"openclaw.plugin.json"
|
|
22
19
|
],
|
|
23
20
|
"peerDependencies": {
|
|
@@ -28,9 +25,7 @@
|
|
|
28
25
|
},
|
|
29
26
|
"scripts": {
|
|
30
27
|
"test": "npm run test:unit",
|
|
31
|
-
"test:unit": "node --test tests/*.test.js"
|
|
32
|
-
"test:e2e": "node --test tests/e2e/*.e2e.test.js",
|
|
33
|
-
"test:e2e:ali-ai": "bash tests/e2e/run-ali-ai.sh"
|
|
28
|
+
"test:unit": "node --test tests/*.test.js"
|
|
34
29
|
},
|
|
35
30
|
"openclaw": {
|
|
36
31
|
"extensions": [
|
|
@@ -62,5 +57,8 @@
|
|
|
62
57
|
"plugin"
|
|
63
58
|
],
|
|
64
59
|
"author": "",
|
|
65
|
-
"license": "ISC"
|
|
60
|
+
"license": "ISC",
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@wecom/aibot-node-sdk": "^1.0.1"
|
|
63
|
+
}
|
|
66
64
|
}
|
package/think-parser.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parse <think>...</think> tags from LLM output.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Handles WeCom thinking tags in streamed LLM output:
|
|
5
|
+
* - normalize tag variants to <think></think> for outbound WS content
|
|
6
|
+
* - split visible/thinking text for analysis and tests
|
|
7
|
+
* - ignore tags inside code blocks
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
const QUICK_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought)\b/i;
|
|
@@ -43,13 +44,50 @@ function isInsideRegion(pos, regions) {
|
|
|
43
44
|
return false;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Normalize think tag variants to the canonical <think></think> form that
|
|
49
|
+
* WeCom clients recognize, while leaving code blocks untouched.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} text
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeThinkingTags(text) {
|
|
55
|
+
if (!text) {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!QUICK_TAG_RE.test(text)) {
|
|
60
|
+
return String(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const source = String(text);
|
|
64
|
+
const codeRegions = findCodeRegions(source);
|
|
65
|
+
const normalized = [];
|
|
66
|
+
let lastIndex = 0;
|
|
67
|
+
|
|
68
|
+
THINK_TAG_RE.lastIndex = 0;
|
|
69
|
+
for (const match of source.matchAll(THINK_TAG_RE)) {
|
|
70
|
+
const idx = match.index;
|
|
71
|
+
if (isInsideRegion(idx, codeRegions)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
normalized.push(source.slice(lastIndex, idx));
|
|
76
|
+
normalized.push(match[1] === "/" ? "</think>" : "<think>");
|
|
77
|
+
lastIndex = idx + match[0].length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
normalized.push(source.slice(lastIndex));
|
|
81
|
+
return normalized.join("");
|
|
82
|
+
}
|
|
83
|
+
|
|
46
84
|
/**
|
|
47
85
|
* Parse thinking content from text that may contain <think>...</think> tags.
|
|
48
86
|
*
|
|
49
87
|
* @param {string} text - Raw accumulated stream text
|
|
50
88
|
* @returns {{ visibleContent: string, thinkingContent: string, isThinking: boolean }}
|
|
51
|
-
* - visibleContent: text with think blocks removed
|
|
52
|
-
* - thinkingContent: concatenated thinking text
|
|
89
|
+
* - visibleContent: text with think blocks removed
|
|
90
|
+
* - thinkingContent: concatenated thinking text
|
|
53
91
|
* - isThinking: true when an unclosed <think> tag is present (streaming)
|
|
54
92
|
*/
|
|
55
93
|
export function parseThinkingContent(text) {
|
|
@@ -57,12 +95,14 @@ export function parseThinkingContent(text) {
|
|
|
57
95
|
return { visibleContent: "", thinkingContent: "", isThinking: false };
|
|
58
96
|
}
|
|
59
97
|
|
|
98
|
+
const source = String(text);
|
|
99
|
+
|
|
60
100
|
// Fast path: no think tags at all.
|
|
61
|
-
if (!QUICK_TAG_RE.test(
|
|
62
|
-
return { visibleContent:
|
|
101
|
+
if (!QUICK_TAG_RE.test(source)) {
|
|
102
|
+
return { visibleContent: source, thinkingContent: "", isThinking: false };
|
|
63
103
|
}
|
|
64
104
|
|
|
65
|
-
const codeRegions = findCodeRegions(
|
|
105
|
+
const codeRegions = findCodeRegions(source);
|
|
66
106
|
|
|
67
107
|
const visibleParts = [];
|
|
68
108
|
const thinkingParts = [];
|
|
@@ -70,7 +110,7 @@ export function parseThinkingContent(text) {
|
|
|
70
110
|
let inThinking = false;
|
|
71
111
|
|
|
72
112
|
THINK_TAG_RE.lastIndex = 0;
|
|
73
|
-
for (const match of
|
|
113
|
+
for (const match of source.matchAll(THINK_TAG_RE)) {
|
|
74
114
|
const idx = match.index;
|
|
75
115
|
const isClose = match[1] === "/";
|
|
76
116
|
|
|
@@ -79,7 +119,7 @@ export function parseThinkingContent(text) {
|
|
|
79
119
|
continue;
|
|
80
120
|
}
|
|
81
121
|
|
|
82
|
-
const segment =
|
|
122
|
+
const segment = source.slice(lastIndex, idx);
|
|
83
123
|
|
|
84
124
|
if (!inThinking) {
|
|
85
125
|
if (!isClose) {
|
|
@@ -103,7 +143,7 @@ export function parseThinkingContent(text) {
|
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
// Remaining text after the last tag.
|
|
106
|
-
const remaining =
|
|
146
|
+
const remaining = source.slice(lastIndex);
|
|
107
147
|
if (inThinking) {
|
|
108
148
|
// Unclosed <think>: remaining text is part of thinking (streaming state).
|
|
109
149
|
thinkingParts.push(remaining);
|
package/utils.js
CHANGED
|
@@ -79,6 +79,57 @@ export class MessageDeduplicator {
|
|
|
79
79
|
this.seen.set(msgId, true);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Text chunking for WeCom Agent API (2048-byte limit per message)
|
|
84
|
+
// ============================================================================
|
|
85
|
+
const AGENT_TEXT_BYTE_LIMIT = 2000; // safe margin below 2048
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split a string into chunks that each fit within a byte limit (UTF-8).
|
|
89
|
+
* Splits at newline boundaries when possible, otherwise at character boundaries.
|
|
90
|
+
*/
|
|
91
|
+
export function splitTextByByteLimit(text, limit = AGENT_TEXT_BYTE_LIMIT) {
|
|
92
|
+
if (Buffer.byteLength(text, "utf8") <= limit) {
|
|
93
|
+
return [text];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let remaining = text;
|
|
98
|
+
|
|
99
|
+
while (remaining.length > 0) {
|
|
100
|
+
if (Buffer.byteLength(remaining, "utf8") <= limit) {
|
|
101
|
+
chunks.push(remaining);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Binary search for the max char index that fits within the byte limit.
|
|
106
|
+
let lo = 0;
|
|
107
|
+
let hi = remaining.length;
|
|
108
|
+
while (lo < hi) {
|
|
109
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
110
|
+
if (Buffer.byteLength(remaining.slice(0, mid), "utf8") <= limit) {
|
|
111
|
+
lo = mid;
|
|
112
|
+
} else {
|
|
113
|
+
hi = mid - 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let splitAt = lo;
|
|
118
|
+
|
|
119
|
+
// Prefer splitting at a newline boundary within the last 20% of the chunk.
|
|
120
|
+
const searchStart = Math.max(0, Math.floor(splitAt * 0.8));
|
|
121
|
+
const lastNewline = remaining.lastIndexOf("\n", splitAt - 1);
|
|
122
|
+
if (lastNewline >= searchStart) {
|
|
123
|
+
splitAt = lastNewline + 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
127
|
+
remaining = remaining.slice(splitAt);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return chunks;
|
|
131
|
+
}
|
|
132
|
+
|
|
82
133
|
// ============================================================================
|
|
83
134
|
// Constants
|
|
84
135
|
// ============================================================================
|