@sunnoy/wecom 1.9.0 → 2.0.1

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,22 +1,8 @@
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
- function emptyPluginConfigSchema() {
8
- return {
9
- safeParse(value) {
10
- if (value === undefined) return { success: true, data: undefined };
11
- if (!value || typeof value !== "object" || Array.isArray(value)) {
12
- return { success: false, error: { message: "expected config object" } };
13
- }
14
- return { success: true, data: value };
15
- },
16
- };
17
- }
18
-
19
- let cleanupTimer = null;
4
+ import { setOpenclawConfig, setRuntime } from "./wecom/state.js";
5
+ import { buildReplyMediaGuidance } from "./wecom/ws-monitor.js";
20
6
 
21
7
  const plugin = {
22
8
  id: "wecom",
@@ -24,37 +10,18 @@ const plugin = {
24
10
  description: "Enterprise WeChat AI Bot channel plugin for OpenClaw",
25
11
  configSchema: emptyPluginConfigSchema(),
26
12
  register(api) {
27
- logger.info("WeCom plugin registering...");
28
-
13
+ logger.info("Registering WeCom WS plugin");
29
14
  setRuntime(api.runtime);
30
15
  setOpenclawConfig(api.config);
31
-
32
- if (cleanupTimer) clearInterval(cleanupTimer);
33
- cleanupTimer = setInterval(() => {
34
- const now = Date.now();
35
- for (const streamId of streamMeta.keys()) {
36
- if (!streamManager.hasStream(streamId)) {
37
- streamMeta.delete(streamId);
38
- }
39
- }
40
- for (const [key, entry] of responseUrls.entries()) {
41
- if (now > entry.expiresAt) {
42
- responseUrls.delete(key);
43
- }
44
- }
45
- }, 60_000);
46
- cleanupTimer.unref();
47
-
48
16
  api.registerChannel({ plugin: wecomChannelPlugin });
49
- logger.info("WeCom channel registered");
50
17
 
51
- api.registerHttpRoute({
52
- path: "/webhooks",
53
- handler: wecomHttpHandler,
54
- auth: "plugin",
55
- 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 };
56
24
  });
57
- logger.info("WeCom HTTP route registered (auth: plugin, match: prefix)");
58
25
  },
59
26
  };
60
27
 
package/package.json CHANGED
@@ -1,23 +1,20 @@
1
1
  {
2
2
  "name": "@sunnoy/wecom",
3
- "version": "1.9.0",
3
+ "version": "2.0.1",
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": {
@@ -60,5 +57,8 @@
60
57
  "plugin"
61
58
  ],
62
59
  "author": "",
63
- "license": "ISC"
60
+ "license": "ISC",
61
+ "dependencies": {
62
+ "@wecom/aibot-node-sdk": "^1.0.1"
63
+ }
64
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);