@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 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
@@ -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
- MIT License
1
+ Tencent is pleased to support the open source community by making openclaw-weixin available.
2
2
 
3
- Copyright (c) 2026 Tencent Inc.
3
+ Copyright (C) 2026 Tencent. All rights reserved.
4
4
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
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
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-weixin",
3
- "version": "2.1.7",
3
+ "version": "2.1.9",
4
4
  "channels": [
5
5
  "openclaw-weixin"
6
6
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-weixin/openclaw-weixin",
3
- "version": "2.1.7",
3
+ "version": "2.1.9",
4
4
  "description": "OpenClaw Weixin channel",
5
5
  "license": "MIT",
6
6
  "author": "Tencent",
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
- const filteredText = f.feed(rawText) + f.flush();
127
- const result = await sendMessageWeixin({ to: params.to, text: filteredText, opts: {
128
- baseUrl: account.baseUrl,
129
- token: account.token,
130
- contextToken: params.contextToken,
131
- }});
132
- return { channel: "openclaw-weixin", messageId: result.messageId };
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
- const result = await sendWeixinMediaFile({
231
- filePath,
232
- to: ctx.to,
233
- text: ctx.text ?? "",
234
- opts: { baseUrl: account.baseUrl, token: account.token, contextToken },
235
- cdnBaseUrl: account.cdnBaseUrl,
236
- });
237
- return { channel: "openclaw-weixin", messageId: result.messageId };
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 result = await sendWeixinOutbound({
241
- cfg: ctx.cfg,
242
- to: ctx.to,
243
- text: ctx.text ?? "",
244
- accountId,
245
- contextToken: getContextToken(account.accountId, ctx.to),
246
- });
247
- return result;
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 (![alt](url)) — removed entirely
21
+ *
9
22
  * States:
10
23
  * - **sol** (start-of-line): checks for line-start patterns (```, >, #####, indent)
11
- * - **body**: scans for inline patterns (`, ![, ~~, ***) and outputs safe chars
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: "code" | "image" | "strike" | "bold3" | "italic" | "ubold3" | "uitalic" | "table"; acc: string } | null = null;
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
- 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
- }
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 through, watch for closing ``` at SOL. */
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
- this.buf = nl !== -1 ? this.buf.slice(nl + 1) : "";
67
- this.sol = true;
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
- this.buf = nl !== -1 ? b.slice(nl + 1) : "";
99
- this.sol = true;
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
- this.buf = j < b.length ? b.slice(j + 1) : "";
149
- this.sol = true;
150
- return "";
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 === "~" && 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;
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
- /** 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");
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
- const text = (() => {
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
  );