@tencent-weixin/openclaw-weixin 2.1.8 → 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
  ### 修复
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-weixin",
3
- "version": "2.1.8",
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.8",
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: {
@@ -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
  );