@tencent-weixin/openclaw-weixin 2.1.7 → 2.1.9
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/CHANGELOG.md +16 -0
- package/CHANGELOG.zh_CN.md +16 -0
- package/LICENSE +23 -17
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/channel.ts +66 -24
- package/src/messaging/markdown-filter.ts +62 -92
- package/src/messaging/outbound-hooks.ts +84 -0
- package/src/messaging/process-message.ts +18 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
This project follows the [Keep a Changelog](https://keepachangelog.com/) format.
|
|
6
6
|
|
|
7
|
+
## [2.1.9] - 2026-04-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Outbound hook support:** Add `message_sending` (pre-send interception/modification) and `message_sent` (post-send notification) hook integration for all outbound paths — `sendText`, `sendMedia`, and the inbound-reply `deliver` in `process-message`. Hook logic is extracted into a shared `src/messaging/outbound-hooks.ts` module.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **Cleanup:** Remove unused `mediaUrl` parameter from `sendWeixinOutbound` signature.
|
|
16
|
+
|
|
17
|
+
## [2.1.8] - 2026-04-07
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Markdown filter:** `StreamingMarkdownFilter` now preserves more Markdown constructs in outbound text.
|
|
22
|
+
|
|
7
23
|
## [2.1.7] - 2026-04-07
|
|
8
24
|
|
|
9
25
|
### Fixed
|
package/CHANGELOG.zh_CN.md
CHANGED
|
@@ -4,6 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
本项目遵循 [Keep a Changelog](https://keepachangelog.com/) 格式。
|
|
6
6
|
|
|
7
|
+
## [2.1.9] - 2026-04-20
|
|
8
|
+
|
|
9
|
+
### 新增
|
|
10
|
+
|
|
11
|
+
- **外发 hook 支持:** 为所有外发路径(`sendText`、`sendMedia`、`process-message` 中的入站回复 `deliver`)接入 `message_sending`(发送前拦截/修改)和 `message_sent`(发送后通知)hook。hook 逻辑抽取至共享模块 `src/messaging/outbound-hooks.ts`。
|
|
12
|
+
|
|
13
|
+
### 变更
|
|
14
|
+
|
|
15
|
+
- **清理:** 移除 `sendWeixinOutbound` 签名中未使用的 `mediaUrl` 参数。
|
|
16
|
+
|
|
17
|
+
## [2.1.8] - 2026-04-07
|
|
18
|
+
|
|
19
|
+
### 变更
|
|
20
|
+
|
|
21
|
+
- **Markdown 过滤器:** `StreamingMarkdownFilter` 放开了更多 Markdown 格式的保留。
|
|
22
|
+
|
|
7
23
|
## [2.1.7] - 2026-04-07
|
|
8
24
|
|
|
9
25
|
### 修复
|
package/LICENSE
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
Tencent is pleased to support the open source community by making openclaw-weixin available.
|
|
2
2
|
|
|
3
|
-
Copyright (
|
|
3
|
+
Copyright (C) 2026 Tencent. All rights reserved.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
5
|
+
openclaw-weixin is licensed under the MIT.
|
|
11
6
|
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
7
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
8
|
+
Terms of the MIT:
|
|
9
|
+
--------------------------------------------------------------------
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
11
|
+
a copy of this software and associated documentation files (the
|
|
12
|
+
"Software"), to deal in the Software without restriction, including
|
|
13
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
14
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
15
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
16
|
+
the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be
|
|
19
|
+
included in all copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
22
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
23
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
24
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
25
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
26
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
27
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -27,6 +27,7 @@ import type { WeixinQrStartResult, WeixinQrWaitResult } from "./auth/login-qr.js
|
|
|
27
27
|
// Lazy-imported inside startAccount to avoid pulling in the monitor -> process-message ->
|
|
28
28
|
// command-auth chain during plugin registration, which can re-enter plugin/provider registry
|
|
29
29
|
// resolution before the account actually starts.
|
|
30
|
+
import { applyWeixinMessageSendingHook, emitWeixinMessageSent } from "./messaging/outbound-hooks.js";
|
|
30
31
|
import { sendWeixinMediaFile } from "./messaging/send-media.js";
|
|
31
32
|
import { sendMessageWeixin, StreamingMarkdownFilter } from "./messaging/send.js";
|
|
32
33
|
import { downloadRemoteImageToTemp } from "./cdn/upload.js";
|
|
@@ -109,7 +110,6 @@ async function sendWeixinOutbound(params: {
|
|
|
109
110
|
text: string;
|
|
110
111
|
accountId?: string | null;
|
|
111
112
|
contextToken?: string;
|
|
112
|
-
mediaUrl?: string;
|
|
113
113
|
}): Promise<{ channel: string; messageId: string }> {
|
|
114
114
|
const account = resolveWeixinAccount(params.cfg, params.accountId);
|
|
115
115
|
const aLog = logger.withAccount(account.accountId);
|
|
@@ -123,13 +123,31 @@ async function sendWeixinOutbound(params: {
|
|
|
123
123
|
}
|
|
124
124
|
const f = new StreamingMarkdownFilter();
|
|
125
125
|
const rawText = params.text ?? "";
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
126
|
+
let filteredText = f.feed(rawText) + f.flush();
|
|
127
|
+
|
|
128
|
+
const sendingResult = await applyWeixinMessageSendingHook({
|
|
129
|
+
to: params.to,
|
|
130
|
+
text: filteredText,
|
|
131
|
+
accountId: account.accountId,
|
|
132
|
+
});
|
|
133
|
+
if (sendingResult.cancelled) {
|
|
134
|
+
aLog.info(`sendWeixinOutbound: cancelled by message_sending hook to=${params.to}`);
|
|
135
|
+
return { channel: "openclaw-weixin", messageId: "" };
|
|
136
|
+
}
|
|
137
|
+
filteredText = sendingResult.text;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const result = await sendMessageWeixin({ to: params.to, text: filteredText, opts: {
|
|
141
|
+
baseUrl: account.baseUrl,
|
|
142
|
+
token: account.token,
|
|
143
|
+
contextToken: params.contextToken,
|
|
144
|
+
}});
|
|
145
|
+
emitWeixinMessageSent({ to: params.to, content: filteredText, success: true, accountId: account.accountId });
|
|
146
|
+
return { channel: "openclaw-weixin", messageId: result.messageId };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
emitWeixinMessageSent({ to: params.to, content: filteredText, success: false, error: String(err), accountId: account.accountId });
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
133
151
|
}
|
|
134
152
|
|
|
135
153
|
export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
|
|
@@ -215,6 +233,19 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
|
|
|
215
233
|
}
|
|
216
234
|
|
|
217
235
|
const mediaUrl = ctx.mediaUrl;
|
|
236
|
+
let text = ctx.text ?? "";
|
|
237
|
+
|
|
238
|
+
const sendingResult = await applyWeixinMessageSendingHook({
|
|
239
|
+
to: ctx.to,
|
|
240
|
+
text,
|
|
241
|
+
accountId: account.accountId,
|
|
242
|
+
mediaUrl,
|
|
243
|
+
});
|
|
244
|
+
if (sendingResult.cancelled) {
|
|
245
|
+
aLog.info(`sendMedia: cancelled by message_sending hook to=${ctx.to}`);
|
|
246
|
+
return { channel: "openclaw-weixin", messageId: "" };
|
|
247
|
+
}
|
|
248
|
+
text = sendingResult.text;
|
|
218
249
|
|
|
219
250
|
if (mediaUrl && (isLocalFilePath(mediaUrl) || isRemoteUrl(mediaUrl))) {
|
|
220
251
|
let filePath: string;
|
|
@@ -227,24 +258,35 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
|
|
|
227
258
|
aLog.debug(`sendMedia: remote image downloaded to ${filePath}`);
|
|
228
259
|
}
|
|
229
260
|
const contextToken = getContextToken(account.accountId, ctx.to);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
261
|
+
try {
|
|
262
|
+
const result = await sendWeixinMediaFile({
|
|
263
|
+
filePath,
|
|
264
|
+
to: ctx.to,
|
|
265
|
+
text,
|
|
266
|
+
opts: { baseUrl: account.baseUrl, token: account.token, contextToken },
|
|
267
|
+
cdnBaseUrl: account.cdnBaseUrl,
|
|
268
|
+
});
|
|
269
|
+
emitWeixinMessageSent({ to: ctx.to, content: text, success: true, accountId: account.accountId });
|
|
270
|
+
return { channel: "openclaw-weixin", messageId: result.messageId };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
emitWeixinMessageSent({ to: ctx.to, content: text, success: false, error: String(err), accountId: account.accountId });
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
238
275
|
}
|
|
239
276
|
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
to: ctx.to,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
277
|
+
const contextToken = getContextToken(account.accountId, ctx.to);
|
|
278
|
+
try {
|
|
279
|
+
const result = await sendMessageWeixin({ to: ctx.to, text, opts: {
|
|
280
|
+
baseUrl: account.baseUrl,
|
|
281
|
+
token: account.token,
|
|
282
|
+
contextToken,
|
|
283
|
+
}});
|
|
284
|
+
emitWeixinMessageSent({ to: ctx.to, content: text, success: true, accountId: account.accountId });
|
|
285
|
+
return { channel: "openclaw-weixin", messageId: result.messageId };
|
|
286
|
+
} catch (err) {
|
|
287
|
+
emitWeixinMessageSent({ to: ctx.to, content: text, success: false, error: String(err), accountId: account.accountId });
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
248
290
|
},
|
|
249
291
|
},
|
|
250
292
|
status: {
|
|
@@ -6,9 +6,22 @@
|
|
|
6
6
|
* holding back the minimum characters needed for pattern disambiguation
|
|
7
7
|
* (e.g. a trailing `*` that might become `***`).
|
|
8
8
|
*
|
|
9
|
+
* Constructs passed through (not filtered):
|
|
10
|
+
* - Code fences (```)
|
|
11
|
+
* - Inline code (`)
|
|
12
|
+
* - Tables (|...|)
|
|
13
|
+
* - Horizontal rules (---, ***, ___)
|
|
14
|
+
* - Bold (**)
|
|
15
|
+
* - Italic/bold-italic wrapping non-CJK content
|
|
16
|
+
*
|
|
17
|
+
* Constructs filtered (markers stripped, content kept):
|
|
18
|
+
* - Italic/bold-italic wrapping CJK content
|
|
19
|
+
* - Headings H5/H6 (#####, ######)
|
|
20
|
+
* - Images () — removed entirely
|
|
21
|
+
*
|
|
9
22
|
* States:
|
|
10
23
|
* - **sol** (start-of-line): checks for line-start patterns (```, >, #####, indent)
|
|
11
|
-
* - **body**: scans for inline patterns (
|
|
24
|
+
* - **body**: scans for inline patterns (![, ~~, ***) and outputs safe chars
|
|
12
25
|
* - **fence**: inside a fenced code block, passes through until closing ```
|
|
13
26
|
* - **inline**: accumulating content inside an inline marker pair
|
|
14
27
|
*/
|
|
@@ -16,7 +29,7 @@ export class StreamingMarkdownFilter {
|
|
|
16
29
|
private buf = "";
|
|
17
30
|
private fence = false;
|
|
18
31
|
private sol = true;
|
|
19
|
-
private inl: { type: "
|
|
32
|
+
private inl: { type: "image" | "bold3" | "italic" | "ubold3" | "uitalic"; acc: string } | null = null;
|
|
20
33
|
|
|
21
34
|
feed(delta: string): string {
|
|
22
35
|
this.buf += delta;
|
|
@@ -45,26 +58,32 @@ export class StreamingMarkdownFilter {
|
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
if (eof && this.inl) {
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
}
|
|
61
|
+
const markers: Record<string, string> = { image: "![", bold3: "***", italic: "*", ubold3: "___", uitalic: "_" };
|
|
62
|
+
out += (markers[this.inl.type] ?? "") + this.inl.acc;
|
|
54
63
|
this.inl = null;
|
|
55
64
|
}
|
|
56
65
|
return out;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
/** Inside a code fence: pass content
|
|
68
|
+
/** Inside a code fence: pass content and markers through verbatim. */
|
|
60
69
|
private pumpFence(eof: boolean): string {
|
|
61
70
|
if (this.sol) {
|
|
62
71
|
if (this.buf.length < 3 && !eof) return "";
|
|
63
72
|
if (this.buf.startsWith("```")) {
|
|
64
|
-
this.fence = false;
|
|
65
73
|
const nl = this.buf.indexOf("\n", 3);
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
if (nl !== -1) {
|
|
75
|
+
this.fence = false;
|
|
76
|
+
const line = this.buf.slice(0, nl + 1);
|
|
77
|
+
this.buf = this.buf.slice(nl + 1);
|
|
78
|
+
this.sol = true;
|
|
79
|
+
return line;
|
|
80
|
+
}
|
|
81
|
+
if (eof) {
|
|
82
|
+
this.fence = false;
|
|
83
|
+
const line = this.buf;
|
|
84
|
+
this.buf = "";
|
|
85
|
+
return line;
|
|
86
|
+
}
|
|
68
87
|
return "";
|
|
69
88
|
}
|
|
70
89
|
this.sol = false;
|
|
@@ -93,10 +112,18 @@ export class StreamingMarkdownFilter {
|
|
|
93
112
|
if (b[0] === "`") {
|
|
94
113
|
if (b.length < 3 && !eof) return "";
|
|
95
114
|
if (b.startsWith("```")) {
|
|
96
|
-
this.fence = true;
|
|
97
115
|
const nl = b.indexOf("\n", 3);
|
|
98
|
-
|
|
99
|
-
|
|
116
|
+
if (nl !== -1) {
|
|
117
|
+
this.fence = true;
|
|
118
|
+
const line = b.slice(0, nl + 1);
|
|
119
|
+
this.buf = b.slice(nl + 1);
|
|
120
|
+
this.sol = true;
|
|
121
|
+
return line;
|
|
122
|
+
}
|
|
123
|
+
if (eof) {
|
|
124
|
+
this.buf = "";
|
|
125
|
+
return b;
|
|
126
|
+
}
|
|
100
127
|
return "";
|
|
101
128
|
}
|
|
102
129
|
this.sol = false;
|
|
@@ -104,8 +131,6 @@ export class StreamingMarkdownFilter {
|
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
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
134
|
this.sol = false;
|
|
110
135
|
return "";
|
|
111
136
|
}
|
|
@@ -123,13 +148,6 @@ export class StreamingMarkdownFilter {
|
|
|
123
148
|
return "";
|
|
124
149
|
}
|
|
125
150
|
|
|
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
151
|
if (b[0] === " " || b[0] === "\t") {
|
|
134
152
|
if (b.search(/[^ \t]/) === -1 && !eof) return "";
|
|
135
153
|
this.sol = false;
|
|
@@ -145,9 +163,13 @@ export class StreamingMarkdownFilter {
|
|
|
145
163
|
let count = 0;
|
|
146
164
|
for (let k = 0; k < j; k++) if (b[k] === ch) count++;
|
|
147
165
|
if (count >= 3) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
166
|
+
if (j < b.length) {
|
|
167
|
+
this.buf = b.slice(j + 1);
|
|
168
|
+
this.sol = true;
|
|
169
|
+
return b.slice(0, j + 1);
|
|
170
|
+
}
|
|
171
|
+
this.buf = "";
|
|
172
|
+
return b;
|
|
151
173
|
}
|
|
152
174
|
}
|
|
153
175
|
this.sol = false;
|
|
@@ -170,23 +192,15 @@ export class StreamingMarkdownFilter {
|
|
|
170
192
|
this.sol = true;
|
|
171
193
|
return out;
|
|
172
194
|
}
|
|
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
195
|
if (c === "!" && i + 1 < this.buf.length && this.buf[i + 1] === "[") {
|
|
180
196
|
out += this.buf.slice(0, i);
|
|
181
197
|
this.buf = this.buf.slice(i + 2);
|
|
182
198
|
this.inl = { type: "image", acc: "" };
|
|
183
199
|
return out;
|
|
184
200
|
}
|
|
185
|
-
if (c === "~"
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
this.inl = { type: "strike", acc: "" };
|
|
189
|
-
return out;
|
|
201
|
+
if (c === "~") {
|
|
202
|
+
i++;
|
|
203
|
+
continue;
|
|
190
204
|
}
|
|
191
205
|
if (c === "*") {
|
|
192
206
|
if (i + 2 < this.buf.length && this.buf[i + 1] === "*" && this.buf[i + 2] === "*") {
|
|
@@ -237,7 +251,6 @@ export class StreamingMarkdownFilter {
|
|
|
237
251
|
else if (this.buf.endsWith("__")) hold = 2;
|
|
238
252
|
else if (this.buf.endsWith("*")) hold = 1;
|
|
239
253
|
else if (this.buf.endsWith("_")) hold = 1;
|
|
240
|
-
else if (this.buf.endsWith("~")) hold = 1;
|
|
241
254
|
else if (this.buf.endsWith("!")) hold = 1;
|
|
242
255
|
}
|
|
243
256
|
out += this.buf.slice(0, this.buf.length - hold);
|
|
@@ -252,41 +265,14 @@ export class StreamingMarkdownFilter {
|
|
|
252
265
|
this.buf = "";
|
|
253
266
|
|
|
254
267
|
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
268
|
case "bold3": {
|
|
284
269
|
const idx = this.inl.acc.indexOf("***");
|
|
285
270
|
if (idx !== -1) {
|
|
286
271
|
const content = this.inl.acc.slice(0, idx);
|
|
287
272
|
this.buf = this.inl.acc.slice(idx + 3);
|
|
288
273
|
this.inl = null;
|
|
289
|
-
return content;
|
|
274
|
+
if (StreamingMarkdownFilter.containsCJK(content)) return content;
|
|
275
|
+
return `***${content}***`;
|
|
290
276
|
}
|
|
291
277
|
return "";
|
|
292
278
|
}
|
|
@@ -296,7 +282,8 @@ export class StreamingMarkdownFilter {
|
|
|
296
282
|
const content = this.inl.acc.slice(0, idx);
|
|
297
283
|
this.buf = this.inl.acc.slice(idx + 3);
|
|
298
284
|
this.inl = null;
|
|
299
|
-
return content;
|
|
285
|
+
if (StreamingMarkdownFilter.containsCJK(content)) return content;
|
|
286
|
+
return `___${content}___`;
|
|
300
287
|
}
|
|
301
288
|
return "";
|
|
302
289
|
}
|
|
@@ -317,7 +304,8 @@ export class StreamingMarkdownFilter {
|
|
|
317
304
|
const content = this.inl.acc.slice(0, j);
|
|
318
305
|
this.buf = this.inl.acc.slice(j + 1);
|
|
319
306
|
this.inl = null;
|
|
320
|
-
return content;
|
|
307
|
+
if (StreamingMarkdownFilter.containsCJK(content)) return content;
|
|
308
|
+
return `*${content}*`;
|
|
321
309
|
}
|
|
322
310
|
}
|
|
323
311
|
return "";
|
|
@@ -339,7 +327,8 @@ export class StreamingMarkdownFilter {
|
|
|
339
327
|
const content = this.inl.acc.slice(0, j);
|
|
340
328
|
this.buf = this.inl.acc.slice(j + 1);
|
|
341
329
|
this.inl = null;
|
|
342
|
-
return content;
|
|
330
|
+
if (StreamingMarkdownFilter.containsCJK(content)) return content;
|
|
331
|
+
return `_${content}_`;
|
|
343
332
|
}
|
|
344
333
|
}
|
|
345
334
|
return "";
|
|
@@ -362,30 +351,11 @@ export class StreamingMarkdownFilter {
|
|
|
362
351
|
}
|
|
363
352
|
return "";
|
|
364
353
|
}
|
|
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
354
|
}
|
|
378
355
|
return "";
|
|
379
356
|
}
|
|
380
357
|
|
|
381
|
-
|
|
382
|
-
|
|
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");
|
|
358
|
+
private static containsCJK(text: string): boolean {
|
|
359
|
+
return /[\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/.test(text);
|
|
390
360
|
}
|
|
391
361
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fireAndForgetHook,
|
|
3
|
+
buildCanonicalSentMessageHookContext,
|
|
4
|
+
toPluginMessageContext,
|
|
5
|
+
toPluginMessageSentEvent,
|
|
6
|
+
} from "openclaw/plugin-sdk/hook-runtime";
|
|
7
|
+
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
|
8
|
+
|
|
9
|
+
import { logger } from "../util/logger.js";
|
|
10
|
+
|
|
11
|
+
const CHANNEL_ID = "openclaw-weixin";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run message_sending hook before sending.
|
|
15
|
+
* Returns the (possibly modified) text content plus a cancelled flag.
|
|
16
|
+
* Hook errors are caught and logged — sending proceeds regardless.
|
|
17
|
+
*/
|
|
18
|
+
export async function applyWeixinMessageSendingHook(params: {
|
|
19
|
+
to: string;
|
|
20
|
+
text: string;
|
|
21
|
+
accountId?: string;
|
|
22
|
+
mediaUrl?: string;
|
|
23
|
+
}): Promise<{ cancelled: boolean; text: string }> {
|
|
24
|
+
const hookRunner = getGlobalHookRunner();
|
|
25
|
+
if (!hookRunner?.hasHooks("message_sending")) {
|
|
26
|
+
return { cancelled: false, text: params.text };
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const hookResult = await hookRunner.runMessageSending(
|
|
30
|
+
{
|
|
31
|
+
to: params.to,
|
|
32
|
+
content: params.text,
|
|
33
|
+
metadata: {
|
|
34
|
+
channel: CHANNEL_ID,
|
|
35
|
+
accountId: params.accountId,
|
|
36
|
+
...(params.mediaUrl ? { mediaUrls: [params.mediaUrl] } : {}),
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{ channelId: CHANNEL_ID, accountId: params.accountId },
|
|
40
|
+
);
|
|
41
|
+
if (hookResult?.cancel) {
|
|
42
|
+
return { cancelled: true, text: params.text };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
cancelled: false,
|
|
46
|
+
text: hookResult?.content ?? params.text,
|
|
47
|
+
};
|
|
48
|
+
} catch (err) {
|
|
49
|
+
logger.warn(`message_sending hook error, proceeding with send: ${String(err)}`);
|
|
50
|
+
return { cancelled: false, text: params.text };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fire message_sent hook (fire-and-forget) after a send attempt.
|
|
56
|
+
*/
|
|
57
|
+
export function emitWeixinMessageSent(params: {
|
|
58
|
+
to: string;
|
|
59
|
+
content: string;
|
|
60
|
+
success: boolean;
|
|
61
|
+
error?: string;
|
|
62
|
+
accountId?: string;
|
|
63
|
+
}): void {
|
|
64
|
+
const hookRunner = getGlobalHookRunner();
|
|
65
|
+
if (!hookRunner?.hasHooks("message_sent")) return;
|
|
66
|
+
const canonical = buildCanonicalSentMessageHookContext({
|
|
67
|
+
to: params.to,
|
|
68
|
+
content: params.content,
|
|
69
|
+
success: params.success,
|
|
70
|
+
error: params.error,
|
|
71
|
+
channelId: CHANNEL_ID,
|
|
72
|
+
accountId: params.accountId,
|
|
73
|
+
conversationId: params.to,
|
|
74
|
+
});
|
|
75
|
+
fireAndForgetHook(
|
|
76
|
+
Promise.resolve(
|
|
77
|
+
hookRunner!.runMessageSent(
|
|
78
|
+
toPluginMessageSentEvent(canonical),
|
|
79
|
+
toPluginMessageContext(canonical),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
"weixin: message_sent plugin hook failed",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -20,6 +20,7 @@ import { redactBody, redactToken } from "../util/redact.js";
|
|
|
20
20
|
|
|
21
21
|
import { isDebugMode } from "./debug-mode.js";
|
|
22
22
|
import { sendWeixinErrorNotice } from "./error-notice.js";
|
|
23
|
+
import { applyWeixinMessageSendingHook, emitWeixinMessageSent } from "./outbound-hooks.js";
|
|
23
24
|
import {
|
|
24
25
|
setContextToken,
|
|
25
26
|
weixinMessageToMsgContext,
|
|
@@ -311,7 +312,7 @@ export async function processOneMessage(
|
|
|
311
312
|
typingCallbacks,
|
|
312
313
|
deliver: async (payload) => {
|
|
313
314
|
const rawText = payload.text ?? "";
|
|
314
|
-
|
|
315
|
+
let text = (() => {
|
|
315
316
|
const f = new StreamingMarkdownFilter();
|
|
316
317
|
return f.feed(rawText) + f.flush();
|
|
317
318
|
})();
|
|
@@ -330,11 +331,22 @@ export async function processOneMessage(
|
|
|
330
331
|
});
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
const sendingResult = await applyWeixinMessageSendingHook({
|
|
335
|
+
to: ctx.To,
|
|
336
|
+
text,
|
|
337
|
+
accountId: deps.accountId,
|
|
338
|
+
mediaUrl,
|
|
339
|
+
});
|
|
340
|
+
if (sendingResult.cancelled) {
|
|
341
|
+
logger.info(`outbound: cancelled by message_sending hook to=${ctx.To}`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
text = sendingResult.text;
|
|
345
|
+
|
|
333
346
|
try {
|
|
334
347
|
if (mediaUrl) {
|
|
335
348
|
let filePath: string;
|
|
336
349
|
if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) {
|
|
337
|
-
// Local path: absolute, relative, or file:// URL
|
|
338
350
|
if (mediaUrl.startsWith("file://")) {
|
|
339
351
|
filePath = new URL(mediaUrl).pathname;
|
|
340
352
|
} else if (!path.isAbsolute(mediaUrl)) {
|
|
@@ -357,6 +369,7 @@ export async function processOneMessage(
|
|
|
357
369
|
token: deps.token,
|
|
358
370
|
contextToken,
|
|
359
371
|
}});
|
|
372
|
+
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
|
|
360
373
|
logger.info(`outbound: text sent to=${ctx.To}`);
|
|
361
374
|
return;
|
|
362
375
|
}
|
|
@@ -367,6 +380,7 @@ export async function processOneMessage(
|
|
|
367
380
|
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
|
|
368
381
|
cdnBaseUrl: deps.cdnBaseUrl,
|
|
369
382
|
});
|
|
383
|
+
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
|
|
370
384
|
logger.info(`outbound: media sent OK to=${ctx.To}`);
|
|
371
385
|
} else {
|
|
372
386
|
logger.debug(`outbound: sending text message to=${ctx.To}`);
|
|
@@ -375,9 +389,11 @@ export async function processOneMessage(
|
|
|
375
389
|
token: deps.token,
|
|
376
390
|
contextToken,
|
|
377
391
|
}});
|
|
392
|
+
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
|
|
378
393
|
logger.info(`outbound: text sent OK to=${ctx.To}`);
|
|
379
394
|
}
|
|
380
395
|
} catch (err) {
|
|
396
|
+
emitWeixinMessageSent({ to: ctx.To, content: text, success: false, error: String(err), accountId: deps.accountId });
|
|
381
397
|
logger.error(
|
|
382
398
|
`outbound: FAILED to=${ctx.To} mediaUrl=${mediaUrl ?? "none"} err=${String(err)} stack=${(err as Error).stack ?? ""}`,
|
|
383
399
|
);
|