@tencent-weixin/openclaw-weixin 2.1.2 → 2.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-weixin/openclaw-weixin",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "OpenClaw Weixin channel",
5
5
  "license": "MIT",
6
6
  "author": "Tencent",
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Streaming markdown filter — character-level state machine that strips
3
+ * unsupported markdown syntax on-the-fly.
4
+ *
5
+ * Outputs as much filtered text as possible on each `feed()` call, only
6
+ * holding back the minimum characters needed for pattern disambiguation
7
+ * (e.g. a trailing `*` that might become `***`).
8
+ *
9
+ * States:
10
+ * - **sol** (start-of-line): checks for line-start patterns (```, >, #####, indent)
11
+ * - **body**: scans for inline patterns (`, ![, ~~, ***) and outputs safe chars
12
+ * - **fence**: inside a fenced code block, passes through until closing ```
13
+ * - **inline**: accumulating content inside an inline marker pair
14
+ */
15
+ export class StreamingMarkdownFilter {
16
+ private buf = "";
17
+ private fence = false;
18
+ private sol = true;
19
+ private inl: { type: "code" | "image" | "strike" | "bold3" | "italic" | "ubold3" | "uitalic" | "table"; acc: string } | null = null;
20
+
21
+ feed(delta: string): string {
22
+ this.buf += delta;
23
+ return this.pump(false);
24
+ }
25
+
26
+ flush(): string {
27
+ return this.pump(true);
28
+ }
29
+
30
+ private pump(eof: boolean): string {
31
+ let out = "";
32
+ while (this.buf) {
33
+ const sLen = this.buf.length;
34
+ const sSol = this.sol;
35
+ const sFence = this.fence;
36
+ const sInl = this.inl;
37
+
38
+ if (this.fence) out += this.pumpFence(eof);
39
+ else if (this.inl) out += this.pumpInline(eof);
40
+ else if (this.sol) out += this.pumpSOL(eof);
41
+ else out += this.pumpBody(eof);
42
+
43
+ if (this.buf.length === sLen && this.sol === sSol &&
44
+ this.fence === sFence && this.inl === sInl) break;
45
+ }
46
+
47
+ if (eof && this.inl) {
48
+ if (this.inl.type === "table") {
49
+ out += StreamingMarkdownFilter.extractTableRow(this.inl.acc);
50
+ } else {
51
+ const markers: Record<string, string> = { code: "`", image: "![", strike: "~~", bold3: "***", italic: "*", ubold3: "___", uitalic: "_" };
52
+ out += (markers[this.inl.type] ?? "") + this.inl.acc;
53
+ }
54
+ this.inl = null;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** Inside a code fence: pass content through, watch for closing ``` at SOL. */
60
+ private pumpFence(eof: boolean): string {
61
+ if (this.sol) {
62
+ if (this.buf.length < 3 && !eof) return "";
63
+ if (this.buf.startsWith("```")) {
64
+ this.fence = false;
65
+ const nl = this.buf.indexOf("\n", 3);
66
+ this.buf = nl !== -1 ? this.buf.slice(nl + 1) : "";
67
+ this.sol = true;
68
+ return "";
69
+ }
70
+ this.sol = false;
71
+ }
72
+ const nl = this.buf.indexOf("\n");
73
+ if (nl !== -1) {
74
+ const chunk = this.buf.slice(0, nl + 1);
75
+ this.buf = this.buf.slice(nl + 1);
76
+ this.sol = true;
77
+ return chunk;
78
+ }
79
+ const chunk = this.buf;
80
+ this.buf = "";
81
+ return chunk;
82
+ }
83
+
84
+ /** At start of line: detect and consume line-start patterns, then transition to body. */
85
+ private pumpSOL(eof: boolean): string {
86
+ const b = this.buf;
87
+
88
+ if (b[0] === "\n") {
89
+ this.buf = b.slice(1);
90
+ return "\n";
91
+ }
92
+
93
+ if (b[0] === "`") {
94
+ if (b.length < 3 && !eof) return "";
95
+ if (b.startsWith("```")) {
96
+ this.fence = true;
97
+ const nl = b.indexOf("\n", 3);
98
+ this.buf = nl !== -1 ? b.slice(nl + 1) : "";
99
+ this.sol = true;
100
+ return "";
101
+ }
102
+ this.sol = false;
103
+ return "";
104
+ }
105
+
106
+ if (b[0] === ">") {
107
+ if (b.length < 2 && !eof) return "";
108
+ this.buf = b.length >= 2 && b[1] === " " ? b.slice(2) : b.slice(1);
109
+ this.sol = false;
110
+ return "";
111
+ }
112
+
113
+ if (b[0] === "#") {
114
+ let n = 0;
115
+ while (n < b.length && b[n] === "#") n++;
116
+ if (n === b.length && !eof) return "";
117
+ if (n >= 5 && n <= 6 && n < b.length && b[n] === " ") {
118
+ this.buf = b.slice(n + 1);
119
+ this.sol = false;
120
+ return "";
121
+ }
122
+ this.sol = false;
123
+ return "";
124
+ }
125
+
126
+ if (b[0] === "|") {
127
+ this.buf = b.slice(1);
128
+ this.inl = { type: "table", acc: "" };
129
+ this.sol = false;
130
+ return "";
131
+ }
132
+
133
+ if (b[0] === " " || b[0] === "\t") {
134
+ if (b.search(/[^ \t]/) === -1 && !eof) return "";
135
+ this.sol = false;
136
+ return "";
137
+ }
138
+
139
+ if (b[0] === "-" || b[0] === "*" || b[0] === "_") {
140
+ const ch = b[0];
141
+ let j = 0;
142
+ while (j < b.length && (b[j] === ch || b[j] === " ")) j++;
143
+ if (j === b.length && !eof) return "";
144
+ if (j === b.length || b[j] === "\n") {
145
+ let count = 0;
146
+ for (let k = 0; k < j; k++) if (b[k] === ch) count++;
147
+ if (count >= 3) {
148
+ this.buf = j < b.length ? b.slice(j + 1) : "";
149
+ this.sol = true;
150
+ return "";
151
+ }
152
+ }
153
+ this.sol = false;
154
+ return "";
155
+ }
156
+
157
+ this.sol = false;
158
+ return "";
159
+ }
160
+
161
+ /** Scan line body for inline pattern triggers; output safe chars eagerly. */
162
+ private pumpBody(eof: boolean): string {
163
+ let out = "";
164
+ let i = 0;
165
+ while (i < this.buf.length) {
166
+ const c = this.buf[i];
167
+ if (c === "\n") {
168
+ out += this.buf.slice(0, i + 1);
169
+ this.buf = this.buf.slice(i + 1);
170
+ this.sol = true;
171
+ return out;
172
+ }
173
+ if (c === "`") {
174
+ out += this.buf.slice(0, i);
175
+ this.buf = this.buf.slice(i + 1);
176
+ this.inl = { type: "code", acc: "" };
177
+ return out;
178
+ }
179
+ if (c === "!" && i + 1 < this.buf.length && this.buf[i + 1] === "[") {
180
+ out += this.buf.slice(0, i);
181
+ this.buf = this.buf.slice(i + 2);
182
+ this.inl = { type: "image", acc: "" };
183
+ return out;
184
+ }
185
+ if (c === "~" && i + 1 < this.buf.length && this.buf[i + 1] === "~") {
186
+ out += this.buf.slice(0, i);
187
+ this.buf = this.buf.slice(i + 2);
188
+ this.inl = { type: "strike", acc: "" };
189
+ return out;
190
+ }
191
+ if (c === "*") {
192
+ if (i + 2 < this.buf.length && this.buf[i + 1] === "*" && this.buf[i + 2] === "*") {
193
+ out += this.buf.slice(0, i);
194
+ this.buf = this.buf.slice(i + 3);
195
+ this.inl = { type: "bold3", acc: "" };
196
+ return out;
197
+ }
198
+ if (i + 1 < this.buf.length && this.buf[i + 1] === "*") {
199
+ i += 2;
200
+ continue;
201
+ }
202
+ if (i + 1 < this.buf.length && this.buf[i + 1] !== " " && this.buf[i + 1] !== "\n") {
203
+ out += this.buf.slice(0, i);
204
+ this.buf = this.buf.slice(i + 1);
205
+ this.inl = { type: "italic", acc: "" };
206
+ return out;
207
+ }
208
+ i++;
209
+ continue;
210
+ }
211
+ if (c === "_") {
212
+ if (i + 2 < this.buf.length && this.buf[i + 1] === "_" && this.buf[i + 2] === "_") {
213
+ out += this.buf.slice(0, i);
214
+ this.buf = this.buf.slice(i + 3);
215
+ this.inl = { type: "ubold3", acc: "" };
216
+ return out;
217
+ }
218
+ if (i + 1 < this.buf.length && this.buf[i + 1] === "_") {
219
+ i += 2;
220
+ continue;
221
+ }
222
+ if (i + 1 < this.buf.length && this.buf[i + 1] !== " " && this.buf[i + 1] !== "\n") {
223
+ out += this.buf.slice(0, i);
224
+ this.buf = this.buf.slice(i + 1);
225
+ this.inl = { type: "uitalic", acc: "" };
226
+ return out;
227
+ }
228
+ i++;
229
+ continue;
230
+ }
231
+ i++;
232
+ }
233
+
234
+ let hold = 0;
235
+ if (!eof) {
236
+ if (this.buf.endsWith("**")) hold = 2;
237
+ else if (this.buf.endsWith("__")) hold = 2;
238
+ else if (this.buf.endsWith("*")) hold = 1;
239
+ else if (this.buf.endsWith("_")) hold = 1;
240
+ else if (this.buf.endsWith("~")) hold = 1;
241
+ else if (this.buf.endsWith("!")) hold = 1;
242
+ }
243
+ out += this.buf.slice(0, this.buf.length - hold);
244
+ this.buf = hold > 0 ? this.buf.slice(-hold) : "";
245
+ return out;
246
+ }
247
+
248
+ /** Accumulate inline content until closing marker is found. */
249
+ private pumpInline(_eof: boolean): string {
250
+ if (!this.inl) return "";
251
+ this.inl.acc += this.buf;
252
+ this.buf = "";
253
+
254
+ switch (this.inl.type) {
255
+ case "code": {
256
+ const idx = this.inl.acc.indexOf("`");
257
+ if (idx !== -1) {
258
+ const content = this.inl.acc.slice(0, idx);
259
+ this.buf = this.inl.acc.slice(idx + 1);
260
+ this.inl = null;
261
+ return content;
262
+ }
263
+ const nl = this.inl.acc.indexOf("\n");
264
+ if (nl !== -1) {
265
+ const r = "`" + this.inl.acc.slice(0, nl + 1);
266
+ this.buf = this.inl.acc.slice(nl + 1);
267
+ this.inl = null;
268
+ this.sol = true;
269
+ return r;
270
+ }
271
+ return "";
272
+ }
273
+ case "strike": {
274
+ const idx = this.inl.acc.indexOf("~~");
275
+ if (idx !== -1) {
276
+ const content = this.inl.acc.slice(0, idx);
277
+ this.buf = this.inl.acc.slice(idx + 2);
278
+ this.inl = null;
279
+ return content;
280
+ }
281
+ return "";
282
+ }
283
+ case "bold3": {
284
+ const idx = this.inl.acc.indexOf("***");
285
+ if (idx !== -1) {
286
+ const content = this.inl.acc.slice(0, idx);
287
+ this.buf = this.inl.acc.slice(idx + 3);
288
+ this.inl = null;
289
+ return content;
290
+ }
291
+ return "";
292
+ }
293
+ case "ubold3": {
294
+ const idx = this.inl.acc.indexOf("___");
295
+ if (idx !== -1) {
296
+ const content = this.inl.acc.slice(0, idx);
297
+ this.buf = this.inl.acc.slice(idx + 3);
298
+ this.inl = null;
299
+ return content;
300
+ }
301
+ return "";
302
+ }
303
+ case "italic": {
304
+ for (let j = 0; j < this.inl.acc.length; j++) {
305
+ if (this.inl.acc[j] === "\n") {
306
+ const r = "*" + this.inl.acc.slice(0, j + 1);
307
+ this.buf = this.inl.acc.slice(j + 1);
308
+ this.inl = null;
309
+ this.sol = true;
310
+ return r;
311
+ }
312
+ if (this.inl.acc[j] === "*") {
313
+ if (j + 1 < this.inl.acc.length && this.inl.acc[j + 1] === "*") {
314
+ j++;
315
+ continue;
316
+ }
317
+ const content = this.inl.acc.slice(0, j);
318
+ this.buf = this.inl.acc.slice(j + 1);
319
+ this.inl = null;
320
+ return content;
321
+ }
322
+ }
323
+ return "";
324
+ }
325
+ case "uitalic": {
326
+ for (let j = 0; j < this.inl.acc.length; j++) {
327
+ if (this.inl.acc[j] === "\n") {
328
+ const r = "_" + this.inl.acc.slice(0, j + 1);
329
+ this.buf = this.inl.acc.slice(j + 1);
330
+ this.inl = null;
331
+ this.sol = true;
332
+ return r;
333
+ }
334
+ if (this.inl.acc[j] === "_") {
335
+ if (j + 1 < this.inl.acc.length && this.inl.acc[j + 1] === "_") {
336
+ j++;
337
+ continue;
338
+ }
339
+ const content = this.inl.acc.slice(0, j);
340
+ this.buf = this.inl.acc.slice(j + 1);
341
+ this.inl = null;
342
+ return content;
343
+ }
344
+ }
345
+ return "";
346
+ }
347
+ case "image": {
348
+ const cb = this.inl.acc.indexOf("]");
349
+ if (cb === -1) return "";
350
+ if (cb + 1 >= this.inl.acc.length) return "";
351
+ if (this.inl.acc[cb + 1] !== "(") {
352
+ const r = "![" + this.inl.acc.slice(0, cb + 1);
353
+ this.buf = this.inl.acc.slice(cb + 1);
354
+ this.inl = null;
355
+ return r;
356
+ }
357
+ const cp = this.inl.acc.indexOf(")", cb + 2);
358
+ if (cp !== -1) {
359
+ this.buf = this.inl.acc.slice(cp + 1);
360
+ this.inl = null;
361
+ return "";
362
+ }
363
+ return "";
364
+ }
365
+ case "table": {
366
+ const nl = this.inl.acc.indexOf("\n");
367
+ if (nl !== -1) {
368
+ const line = this.inl.acc.slice(0, nl);
369
+ this.buf = this.inl.acc.slice(nl + 1);
370
+ this.inl = null;
371
+ this.sol = true;
372
+ const row = StreamingMarkdownFilter.extractTableRow(line);
373
+ return row ? row + "\n" : "";
374
+ }
375
+ return "";
376
+ }
377
+ }
378
+ return "";
379
+ }
380
+
381
+ /** Extract cell contents from a table row, or return "" for separator rows. */
382
+ private static extractTableRow(line: string): string {
383
+ if (/^[\s|:\-]+$/.test(line) && line.includes("-")) return "";
384
+ const parts = line.split("|").map(c => c.trim());
385
+ const cells = parts.slice(
386
+ parts[0] === "" ? 1 : 0,
387
+ parts[parts.length - 1] === "" ? parts.length - 1 : parts.length,
388
+ );
389
+ return cells.join("\t");
390
+ }
391
+ }
@@ -28,7 +28,8 @@ import {
28
28
  } from "./inbound.js";
29
29
  import type { WeixinInboundMediaOpts } from "./inbound.js";
30
30
  import { sendWeixinMediaFile } from "./send-media.js";
31
- import { markdownToPlainText, sendMessageWeixin } from "./send.js";
31
+ import { StreamingMarkdownFilter } from "./markdown-filter.js";
32
+ import { sendMessageWeixin } from "./send.js";
32
33
  import { handleSlashCommand } from "./slash-commands.js";
33
34
 
34
35
  const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp");
@@ -309,7 +310,11 @@ export async function processOneMessage(
309
310
  humanDelay,
310
311
  typingCallbacks,
311
312
  deliver: async (payload) => {
312
- const text = markdownToPlainText(payload.text ?? "");
313
+ const rawText = payload.text ?? "";
314
+ const text = (() => {
315
+ const f = new StreamingMarkdownFilter();
316
+ return f.feed(rawText) + f.flush();
317
+ })();
313
318
  const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
314
319
  logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`);
315
320
  logger.info(
@@ -414,7 +419,7 @@ export async function processOneMessage(
414
419
  ctx: finalized,
415
420
  cfg: deps.config,
416
421
  dispatcher,
417
- replyOptions: { ...replyOptions, disableBlockStreaming: false },
422
+ replyOptions: { ...replyOptions, disableBlockStreaming: true },
418
423
  }),
419
424
  });
420
425
  logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
@@ -1,5 +1,4 @@
1
1
  import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
2
- import { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
3
2
 
4
3
  import { sendMessage as sendMessageApi } from "../api/api.js";
5
4
  import type { WeixinApiOptions } from "../api/api.js";
@@ -9,32 +8,12 @@ import type { MessageItem, SendMessageReq } from "../api/types.js";
9
8
  import { MessageItemType, MessageState, MessageType } from "../api/types.js";
10
9
  import type { UploadedFileInfo } from "../cdn/upload.js";
11
10
 
11
+ export { StreamingMarkdownFilter } from "./markdown-filter.js";
12
+
12
13
  function generateClientId(): string {
13
14
  return generateId("openclaw-weixin");
14
15
  }
15
16
 
16
- /**
17
- * Convert markdown-formatted model reply to plain text for Weixin delivery.
18
- * Preserves newlines; strips markdown syntax.
19
- */
20
- export function markdownToPlainText(text: string): string {
21
- let result = text;
22
- // Code blocks: strip fences, keep code content
23
- result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
24
- // Images: remove entirely
25
- result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
26
- // Links: keep display text only
27
- result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
28
- // Tables: remove separator rows, then strip leading/trailing pipes and convert inner pipes to spaces
29
- result = result.replace(/^\|[\s:|-]+\|$/gm, "");
30
- result = result.replace(/^\|(.+)\|$/gm, (_, inner: string) =>
31
- inner.split("|").map((cell) => cell.trim()).join(" "),
32
- );
33
- result = stripMarkdown(result);
34
- return result;
35
- }
36
-
37
-
38
17
  /** Build a SendMessageReq containing a single text message. */
39
18
  function buildTextMessageReq(params: {
40
19
  to: string;