@tencent-weixin/openclaw-weixin 2.1.8 → 2.1.10

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.10",
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.10",
4
4
  "description": "OpenClaw Weixin channel",
5
5
  "license": "MIT",
6
6
  "author": "Tencent",
package/src/api/api.ts CHANGED
@@ -13,6 +13,8 @@ import type {
13
13
  GetUploadUrlResp,
14
14
  GetUpdatesReq,
15
15
  GetUpdatesResp,
16
+ NotifyStopResp,
17
+ NotifyStartResp,
16
18
  SendMessageReq,
17
19
  SendTypingReq,
18
20
  GetConfigResp,
@@ -316,3 +318,35 @@ export async function sendTyping(
316
318
  label: "sendTyping",
317
319
  });
318
320
  }
321
+
322
+ /**
323
+ * Notify Weixin that this channel client is stopping (gateway shutdown / channel stop).
324
+ * Uses a standalone timeout (not the gateway abort signal) so the request can finish
325
+ * after OpenClaw has already aborted the long-poll.
326
+ */
327
+ export async function notifyStop(params: WeixinApiOptions): Promise<NotifyStopResp> {
328
+ const rawText = await apiPostFetch({
329
+ baseUrl: params.baseUrl,
330
+ endpoint: "ilink/bot/msg/notifystop",
331
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
332
+ token: params.token,
333
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
334
+ label: "notifyStop",
335
+ });
336
+ return JSON.parse(rawText) as NotifyStopResp;
337
+ }
338
+
339
+ /**
340
+ * Notify Weixin that this channel client is starting (gateway startup / channel start).
341
+ */
342
+ export async function notifyStart(params: WeixinApiOptions): Promise<NotifyStartResp> {
343
+ const rawText = await apiPostFetch({
344
+ baseUrl: params.baseUrl,
345
+ endpoint: "ilink/bot/msg/notifystart",
346
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
347
+ token: params.token,
348
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
349
+ label: "notifyStart",
350
+ });
351
+ return JSON.parse(rawText) as NotifyStartResp;
352
+ }
package/src/api/types.ts CHANGED
@@ -224,3 +224,25 @@ export interface GetConfigResp {
224
224
  /** Base64-encoded typing ticket for sendTyping. */
225
225
  typing_ticket?: string;
226
226
  }
227
+
228
+ /** proto: NotifyStopReq — notify server when the channel client is stopping. */
229
+ export interface NotifyStopReq {
230
+ base_info?: BaseInfo;
231
+ }
232
+
233
+ /** proto: NotifyStopResp */
234
+ export interface NotifyStopResp {
235
+ ret?: number;
236
+ errmsg?: string;
237
+ }
238
+
239
+ /** proto: NotifyStartReq — notify server when the channel client is starting. */
240
+ export interface NotifyStartReq {
241
+ base_info?: BaseInfo;
242
+ }
243
+
244
+ /** proto: NotifyStartResp */
245
+ export interface NotifyStartResp {
246
+ ret?: number;
247
+ errmsg?: string;
248
+ }
package/src/channel.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  DEFAULT_BASE_URL,
16
16
  } from "./auth/accounts.js";
17
17
  import type { ResolvedWeixinAccount } from "./auth/accounts.js";
18
+ import { notifyStop, notifyStart } from "./api/api.js";
18
19
  import { assertSessionActive } from "./api/session-guard.js";
19
20
  import { getContextToken, findAccountIdsByContextToken, restoreContextTokens, clearContextTokensForAccount } from "./messaging/inbound.js";
20
21
  import { logger } from "./util/logger.js";
@@ -27,6 +28,7 @@ import type { WeixinQrStartResult, WeixinQrWaitResult } from "./auth/login-qr.js
27
28
  // Lazy-imported inside startAccount to avoid pulling in the monitor -> process-message ->
28
29
  // command-auth chain during plugin registration, which can re-enter plugin/provider registry
29
30
  // resolution before the account actually starts.
31
+ import { applyWeixinMessageSendingHook, emitWeixinMessageSent } from "./messaging/outbound-hooks.js";
30
32
  import { sendWeixinMediaFile } from "./messaging/send-media.js";
31
33
  import { sendMessageWeixin, StreamingMarkdownFilter } from "./messaging/send.js";
32
34
  import { downloadRemoteImageToTemp } from "./cdn/upload.js";
@@ -109,7 +111,6 @@ async function sendWeixinOutbound(params: {
109
111
  text: string;
110
112
  accountId?: string | null;
111
113
  contextToken?: string;
112
- mediaUrl?: string;
113
114
  }): Promise<{ channel: string; messageId: string }> {
114
115
  const account = resolveWeixinAccount(params.cfg, params.accountId);
115
116
  const aLog = logger.withAccount(account.accountId);
@@ -123,13 +124,31 @@ async function sendWeixinOutbound(params: {
123
124
  }
124
125
  const f = new StreamingMarkdownFilter();
125
126
  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 };
127
+ let filteredText = f.feed(rawText) + f.flush();
128
+
129
+ const sendingResult = await applyWeixinMessageSendingHook({
130
+ to: params.to,
131
+ text: filteredText,
132
+ accountId: account.accountId,
133
+ });
134
+ if (sendingResult.cancelled) {
135
+ aLog.info(`sendWeixinOutbound: cancelled by message_sending hook to=${params.to}`);
136
+ return { channel: "openclaw-weixin", messageId: "" };
137
+ }
138
+ filteredText = sendingResult.text;
139
+
140
+ try {
141
+ const result = await sendMessageWeixin({ to: params.to, text: filteredText, opts: {
142
+ baseUrl: account.baseUrl,
143
+ token: account.token,
144
+ contextToken: params.contextToken,
145
+ }});
146
+ emitWeixinMessageSent({ to: params.to, content: filteredText, success: true, accountId: account.accountId });
147
+ return { channel: "openclaw-weixin", messageId: result.messageId };
148
+ } catch (err) {
149
+ emitWeixinMessageSent({ to: params.to, content: filteredText, success: false, error: String(err), accountId: account.accountId });
150
+ throw err;
151
+ }
133
152
  }
134
153
 
135
154
  export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
@@ -215,6 +234,19 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
215
234
  }
216
235
 
217
236
  const mediaUrl = ctx.mediaUrl;
237
+ let text = ctx.text ?? "";
238
+
239
+ const sendingResult = await applyWeixinMessageSendingHook({
240
+ to: ctx.to,
241
+ text,
242
+ accountId: account.accountId,
243
+ mediaUrl,
244
+ });
245
+ if (sendingResult.cancelled) {
246
+ aLog.info(`sendMedia: cancelled by message_sending hook to=${ctx.to}`);
247
+ return { channel: "openclaw-weixin", messageId: "" };
248
+ }
249
+ text = sendingResult.text;
218
250
 
219
251
  if (mediaUrl && (isLocalFilePath(mediaUrl) || isRemoteUrl(mediaUrl))) {
220
252
  let filePath: string;
@@ -227,24 +259,35 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
227
259
  aLog.debug(`sendMedia: remote image downloaded to ${filePath}`);
228
260
  }
229
261
  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 };
262
+ try {
263
+ const result = await sendWeixinMediaFile({
264
+ filePath,
265
+ to: ctx.to,
266
+ text,
267
+ opts: { baseUrl: account.baseUrl, token: account.token, contextToken },
268
+ cdnBaseUrl: account.cdnBaseUrl,
269
+ });
270
+ emitWeixinMessageSent({ to: ctx.to, content: text, success: true, accountId: account.accountId });
271
+ return { channel: "openclaw-weixin", messageId: result.messageId };
272
+ } catch (err) {
273
+ emitWeixinMessageSent({ to: ctx.to, content: text, success: false, error: String(err), accountId: account.accountId });
274
+ throw err;
275
+ }
238
276
  }
239
277
 
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;
278
+ const contextToken = getContextToken(account.accountId, ctx.to);
279
+ try {
280
+ const result = await sendMessageWeixin({ to: ctx.to, text, opts: {
281
+ baseUrl: account.baseUrl,
282
+ token: account.token,
283
+ contextToken,
284
+ }});
285
+ emitWeixinMessageSent({ to: ctx.to, content: text, success: true, accountId: account.accountId });
286
+ return { channel: "openclaw-weixin", messageId: result.messageId };
287
+ } catch (err) {
288
+ emitWeixinMessageSent({ to: ctx.to, content: text, success: false, error: String(err), accountId: account.accountId });
289
+ throw err;
290
+ }
248
291
  },
249
292
  },
250
293
  status: {
@@ -385,6 +428,18 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
385
428
 
386
429
  ctx.log?.info?.(`[${account.accountId}] starting weixin provider (${DEFAULT_BASE_URL})`);
387
430
 
431
+ try {
432
+ const resp = await notifyStart({
433
+ baseUrl: account.baseUrl,
434
+ token: account.token,
435
+ });
436
+ if (resp.ret !== undefined && resp.ret !== 0) {
437
+ aLog.warn(`notifyStart: ret=${resp.ret} errmsg=${resp.errmsg ?? ""}`);
438
+ }
439
+ } catch (err) {
440
+ aLog.warn(`notifyStart failed during startup (ignored): ${String(err)}`);
441
+ }
442
+
388
443
  const logPath = aLog.getLogFilePath();
389
444
  ctx.log?.info?.(`[${account.accountId}] weixin logs: ${logPath}`);
390
445
 
@@ -400,6 +455,25 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
400
455
  setStatus: ctx.setStatus,
401
456
  });
402
457
  },
458
+ stopAccount: async (ctx) => {
459
+ const account = ctx.account;
460
+ const aLog = logger.withAccount(account.accountId);
461
+ if (!account.configured || !account.token?.trim()) {
462
+ aLog.debug(`gateway.stopAccount: skip notifyStop (not configured or no token)`);
463
+ return;
464
+ }
465
+ try {
466
+ const resp = await notifyStop({
467
+ baseUrl: account.baseUrl,
468
+ token: account.token,
469
+ });
470
+ if (resp.ret !== undefined && resp.ret !== 0) {
471
+ aLog.warn(`notifyStop: ret=${resp.ret} errmsg=${resp.errmsg ?? ""}`);
472
+ }
473
+ } catch (err) {
474
+ aLog.warn(`notifyStop failed during shutdown (ignored): ${String(err)}`);
475
+ }
476
+ },
403
477
  loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => {
404
478
  // For re-login: use saved baseUrl from account data; fall back to default for new accounts.
405
479
  const savedBaseUrl = accountId ? loadWeixinAccount(accountId)?.baseUrl?.trim() : "";
@@ -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
  );