@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.
@@ -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 2MB
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
- // 2MB size limit (before base64 encoding)
19
- const MAX_IMAGE_SIZE = 2 * 1024 * 1024;
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 2MB limit
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 2MB limit (actual: ${sizeBytes} bytes)`);
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: buffer.length,
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 { wecomHttpHandler } from "./wecom/http-handler.js";
5
- import { responseUrls, setOpenclawConfig, setRuntime, streamMeta } from "./wecom/state.js";
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: { type: "object", additionalProperties: true, properties: {} },
11
+ configSchema: emptyPluginConfigSchema(),
30
12
  register(api) {
31
- logger.info("WeCom plugin registering...");
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
- // Register webhook HTTP route with auth: "plugin" so gateway does NOT
42
- // enforce Bearer-token auth. WeCom callbacks use msg_signature verification
43
- // which the plugin handles internally.
44
- // OpenClaw 3.2 removed registerHttpHandler; use registerHttpRoute with
45
- // auth: "plugin" + match: "prefix" to handle all /webhooks/* paths.
46
- api.registerHttpRoute({
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
 
@@ -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": "1.8.0",
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
- * Separates content into visible text and thinking process for WeCom's
5
- * thinking_content stream field. Handles streaming (unclosed tags) and
6
- * ignores tags inside code blocks.
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 (for content field)
52
- * - thinkingContent: concatenated thinking text (for thinking_content field)
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(text)) {
62
- return { visibleContent: text, thinkingContent: "", isThinking: false };
101
+ if (!QUICK_TAG_RE.test(source)) {
102
+ return { visibleContent: source, thinkingContent: "", isThinking: false };
63
103
  }
64
104
 
65
- const codeRegions = findCodeRegions(text);
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 text.matchAll(THINK_TAG_RE)) {
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 = text.slice(lastIndex, idx);
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 = text.slice(lastIndex);
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
  // ============================================================================