@tencent-weixin/openclaw-weixin 2.4.3 → 2.4.5

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/CHANGELOG.zh_CN.md +34 -0
  3. package/dist/src/api/api.js +79 -4
  4. package/dist/src/api/api.js.map +1 -1
  5. package/dist/src/api/session-guard.js +3 -3
  6. package/dist/src/api/session-guard.js.map +1 -1
  7. package/dist/src/api/types.js +2 -0
  8. package/dist/src/api/types.js.map +1 -1
  9. package/dist/src/cdn/cdn-upload.js +3 -2
  10. package/dist/src/cdn/cdn-upload.js.map +1 -1
  11. package/dist/src/cdn/upload.js +12 -3
  12. package/dist/src/cdn/upload.js.map +1 -1
  13. package/dist/src/channel.js +7 -1
  14. package/dist/src/channel.js.map +1 -1
  15. package/dist/src/config/config-schema.js +1 -0
  16. package/dist/src/config/config-schema.js.map +1 -1
  17. package/dist/src/config/reply-progress.js +5 -0
  18. package/dist/src/config/reply-progress.js.map +1 -0
  19. package/dist/src/messaging/error-notice.js +1 -0
  20. package/dist/src/messaging/error-notice.js.map +1 -1
  21. package/dist/src/messaging/outbound-hooks.js +2 -0
  22. package/dist/src/messaging/outbound-hooks.js.map +1 -1
  23. package/dist/src/messaging/process-message.js +32 -7
  24. package/dist/src/messaging/process-message.js.map +1 -1
  25. package/dist/src/messaging/reply-progress-sender.js +93 -0
  26. package/dist/src/messaging/reply-progress-sender.js.map +1 -0
  27. package/dist/src/messaging/send.js +40 -2
  28. package/dist/src/messaging/send.js.map +1 -1
  29. package/dist/src/monitor/monitor.js +11 -8
  30. package/dist/src/monitor/monitor.js.map +1 -1
  31. package/npm-shrinkwrap.json +21848 -0
  32. package/openclaw.plugin.json +1 -1
  33. package/package.json +7 -5
  34. package/src/api/api.ts +102 -4
  35. package/src/api/session-guard.ts +3 -3
  36. package/src/api/types.ts +18 -1
  37. package/src/cdn/cdn-upload.ts +8 -2
  38. package/src/cdn/upload.ts +14 -3
  39. package/src/channel.ts +7 -1
  40. package/src/config/config-schema.ts +1 -0
  41. package/src/config/reply-progress.ts +10 -0
  42. package/src/messaging/error-notice.ts +2 -0
  43. package/src/messaging/outbound-hooks.ts +4 -0
  44. package/src/messaging/process-message.ts +32 -7
  45. package/src/messaging/reply-progress-sender.ts +122 -0
  46. package/src/messaging/send-media.ts +1 -1
  47. package/src/messaging/send.ts +60 -7
  48. package/src/monitor/monitor.ts +12 -11
  49. package/dist/src/media/voice-outbound.js +0 -177
  50. package/dist/src/media/voice-outbound.js.map +0 -1
  51. package/dist/src/messaging/abort-fence.js +0 -70
  52. package/dist/src/messaging/abort-fence.js.map +0 -1
  53. package/dist/src/messaging/buttons.js +0 -117
  54. package/dist/src/messaging/buttons.js.map +0 -1
  55. package/dist/src/messaging/lane-key.js +0 -66
  56. package/dist/src/messaging/lane-key.js.map +0 -1
  57. package/dist/src/messaging/merged-record.js +0 -149
  58. package/dist/src/messaging/merged-record.js.map +0 -1
  59. package/dist/src/messaging/model-buttons.js +0 -182
  60. package/dist/src/messaging/model-buttons.js.map +0 -1
  61. package/dist/src/messaging/model-callback-handler.js +0 -133
  62. package/dist/src/messaging/model-callback-handler.js.map +0 -1
  63. package/dist/src/monitor/lane-scheduler.js +0 -46
  64. package/dist/src/monitor/lane-scheduler.js.map +0 -1
  65. package/dist/src/runtime.js +0 -54
  66. package/dist/src/runtime.js.map +0 -1
  67. package/dist/src/streaming/stream-pipeline.js +0 -431
  68. package/dist/src/streaming/stream-pipeline.js.map +0 -1
  69. package/dist/src/streaming/stream-session.js +0 -260
  70. package/dist/src/streaming/stream-session.js.map +0 -1
  71. package/dist/src/streaming/stream.js +0 -239
  72. package/dist/src/streaming/stream.js.map +0 -1
  73. package/dist/src/util/markdown-fences.js +0 -54
  74. package/dist/src/util/markdown-fences.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-weixin",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
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.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "OpenClaw Weixin channel",
5
5
  "license": "MIT",
6
6
  "author": "Tencent",
@@ -10,6 +10,7 @@
10
10
  "!src/**/*.test.ts",
11
11
  "!src/**/node_modules/",
12
12
  "index.ts",
13
+ "npm-shrinkwrap.json",
13
14
  "openclaw.plugin.json",
14
15
  "dist/",
15
16
  "README.md",
@@ -20,7 +21,8 @@
20
21
  "scripts": {
21
22
  "test": "vitest run --coverage",
22
23
  "typecheck": "tsc --noEmit",
23
- "build": "tsc",
24
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
25
+ "build": "npm run clean && tsc",
24
26
  "prepublishOnly": "npm run typecheck && npm run build"
25
27
  },
26
28
  "engines": {
@@ -31,11 +33,11 @@
31
33
  "zod": "^4.3.6"
32
34
  },
33
35
  "peerDependencies": {
34
- "openclaw": ">=2026.3.22"
36
+ "openclaw": ">=2026.5.12"
35
37
  },
36
38
  "devDependencies": {
37
39
  "@vitest/coverage-v8": "^3.1.0",
38
- "openclaw": "2026.3.23",
40
+ "openclaw": "2026.5.12",
39
41
  "silk-wasm": "^3.7.1",
40
42
  "typescript": "^5.8.0",
41
43
  "vitest": "^3.1.0"
@@ -59,7 +61,7 @@
59
61
  "install": {
60
62
  "npmSpec": "@tencent-weixin/openclaw-weixin",
61
63
  "defaultChoice": "npm",
62
- "minHostVersion": ">=2026.3.22"
64
+ "minHostVersion": ">=2026.5.12"
63
65
  }
64
66
  },
65
67
  "ilink_appid": "bot"
package/src/api/api.ts CHANGED
@@ -16,6 +16,7 @@ import type {
16
16
  NotifyStopResp,
17
17
  NotifyStartResp,
18
18
  SendMessageReq,
19
+ SendMessageResp,
19
20
  SendTypingReq,
20
21
  GetConfigResp,
21
22
  } from "./types.js";
@@ -252,6 +253,40 @@ function buildHeaders(opts: { token?: string }): Record<string, string> {
252
253
  return headers;
253
254
  }
254
255
 
256
+ /**
257
+ * Classify a fetch-level error into a category for logging / diagnostics.
258
+ * This does NOT cover HTTP-level errors (4xx/5xx) — those are thrown separately.
259
+ */
260
+ export function classifyFetchError(err: unknown): {
261
+ type: "dns" | "tcp" | "tls" | "timeout" | "unknown";
262
+ description: string;
263
+ code?: string;
264
+ } {
265
+ if (err instanceof Error && err.name === "AbortError") {
266
+ return { type: "timeout", description: "request timeout" };
267
+ }
268
+
269
+ const cause = (err as NodeJS.ErrnoException)?.cause;
270
+ const causeCode = (cause as any)?.code ?? "";
271
+ const causeStr = String(cause ?? err ?? "") + " " + String(causeCode);
272
+ const matchedCode = causeCode || (typeof cause === "string" ? cause : "");
273
+
274
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(causeStr)) {
275
+ return { type: "dns", description: "DNS resolution failed, check DNS configuration", ...(matchedCode ? { code: matchedCode } : {}) };
276
+ }
277
+ if (/ECONNREFUSED/i.test(causeStr)) {
278
+ return { type: "tcp", description: "TCP connection refused", ...(matchedCode ? { code: matchedCode } : {}) };
279
+ }
280
+ if (/UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH/i.test(causeStr)) {
281
+ return { type: "tcp", description: "TCP connection timeout or unreachable", ...(matchedCode ? { code: matchedCode } : {}) };
282
+ }
283
+ if (/UND_ERR_SOCKET|SSL|TLS|CERT|UNABLE_TO_VERIFY|DEPTH_ZERO/i.test(causeStr)) {
284
+ return { type: "tls", description: "TLS handshake error", ...(matchedCode ? { code: matchedCode } : {}) };
285
+ }
286
+
287
+ return { type: "unknown", description: "network request failed" };
288
+ }
289
+
255
290
  /**
256
291
  * GET fetch wrapper: send a GET request to a Weixin API endpoint.
257
292
  * When `timeoutMs` is set, the request is aborted after that many milliseconds.
@@ -291,15 +326,50 @@ export async function apiGetFetch(params: {
291
326
  return rawText;
292
327
  } catch (err) {
293
328
  if (t !== undefined) clearTimeout(t);
329
+ const classified = classifyFetchError(err);
330
+ logger.error(
331
+ `${params.label}: GET fetch failed url=${redactUrl(url.toString())} timeoutMs=${timeoutMs ?? "none"} type=${classified.type} description=${classified.description}${classified.code ? ` code=${classified.code}` : ""} error=${String(err)}`,
332
+ );
294
333
  throw err;
295
334
  }
296
335
  }
297
336
 
337
+ /**
338
+ * Combine an internal timeout controller with an optional external abort signal.
339
+ * This lets gateway channel-stop aborts cancel in-flight long-poll requests
340
+ * immediately while preserving the existing timeout-driven AbortError path.
341
+ */
342
+ function combineAbortSignals(params: {
343
+ internal?: AbortController;
344
+ external?: AbortSignal;
345
+ }): { signal?: AbortSignal; cleanup: () => void } {
346
+ const { internal, external } = params;
347
+ if (!external) {
348
+ return { signal: internal?.signal, cleanup: () => {} };
349
+ }
350
+ if (!internal) {
351
+ return { signal: external, cleanup: () => {} };
352
+ }
353
+ if (external.aborted) {
354
+ internal.abort();
355
+ return { signal: internal.signal, cleanup: () => {} };
356
+ }
357
+ const onExternalAbort = () => internal.abort();
358
+ external.addEventListener("abort", onExternalAbort, { once: true });
359
+ return {
360
+ signal: internal.signal,
361
+ cleanup: () => external.removeEventListener("abort", onExternalAbort),
362
+ };
363
+ }
364
+
298
365
  /**
299
366
  * Common fetch wrapper: POST JSON to a Weixin API endpoint.
300
367
  * When `timeoutMs` is provided, the request is aborted after that many milliseconds.
301
368
  * When omitted, no client-side timeout is applied (relies on OS/TCP stack).
302
369
  * Returns the raw response text on success; throws on HTTP error or timeout.
370
+ *
371
+ * When `abortSignal` is provided, an external abort (e.g. gateway channel stop)
372
+ * cancels the in-flight request immediately instead of waiting for `timeoutMs`.
303
373
  */
304
374
  export async function apiPostFetch(params: {
305
375
  baseUrl: string;
@@ -308,6 +378,7 @@ export async function apiPostFetch(params: {
308
378
  token?: string;
309
379
  timeoutMs?: number;
310
380
  label: string;
381
+ abortSignal?: AbortSignal;
311
382
  }): Promise<string> {
312
383
  const base = ensureTrailingSlash(params.baseUrl);
313
384
  const url = new URL(params.endpoint, base);
@@ -320,12 +391,16 @@ export async function apiPostFetch(params: {
320
391
  controller != null && params.timeoutMs !== undefined
321
392
  ? setTimeout(() => controller.abort(), params.timeoutMs)
322
393
  : undefined;
394
+ const { signal, cleanup } = combineAbortSignals({
395
+ internal: controller,
396
+ external: params.abortSignal,
397
+ });
323
398
  try {
324
399
  const res = await fetch(url.toString(), {
325
400
  method: "POST",
326
401
  headers: hdrs,
327
402
  body: params.body,
328
- ...(controller ? { signal: controller.signal } : {}),
403
+ ...(signal ? { signal } : {}),
329
404
  });
330
405
  if (t !== undefined) clearTimeout(t);
331
406
  const rawText = await res.text();
@@ -336,7 +411,13 @@ export async function apiPostFetch(params: {
336
411
  return rawText;
337
412
  } catch (err) {
338
413
  if (t !== undefined) clearTimeout(t);
414
+ const classified = classifyFetchError(err);
415
+ logger.error(
416
+ `${params.label}: POST fetch failed url=${redactUrl(url.toString())} timeoutMs=${params.timeoutMs ?? "none"} type=${classified.type} description=${classified.description}${classified.code ? ` code=${classified.code}` : ""} error=${String(err)}`,
417
+ );
339
418
  throw err;
419
+ } finally {
420
+ cleanup();
340
421
  }
341
422
  }
342
423
 
@@ -351,6 +432,11 @@ export async function getUpdates(
351
432
  baseUrl: string;
352
433
  token?: string;
353
434
  timeoutMs?: number;
435
+ /**
436
+ * Optional external abort signal from the gateway. When stopping the channel,
437
+ * this aborts the in-flight long-poll immediately so hot reload can restart.
438
+ */
439
+ abortSignal?: AbortSignal;
354
440
  },
355
441
  ): Promise<GetUpdatesResp> {
356
442
  const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
@@ -365,13 +451,19 @@ export async function getUpdates(
365
451
  token: params.token,
366
452
  timeoutMs: timeout,
367
453
  label: "getUpdates",
454
+ abortSignal: params.abortSignal,
368
455
  });
369
456
  const resp: GetUpdatesResp = JSON.parse(rawText);
370
457
  return resp;
371
458
  } catch (err) {
372
- // Long-poll timeout is normal; return empty response so caller can retry
459
+ // Long-poll timeout or external abort are both normal control-flow exits.
460
+ // The monitor loop checks abortSignal after return and exits when needed.
373
461
  if (err instanceof Error && err.name === "AbortError") {
374
- logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
462
+ if (params.abortSignal?.aborted) {
463
+ logger.debug(`getUpdates: aborted by external signal`);
464
+ } else {
465
+ logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
466
+ }
375
467
  return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
376
468
  }
377
469
  throw err;
@@ -411,7 +503,7 @@ export async function getUploadUrl(
411
503
  export async function sendMessage(
412
504
  params: WeixinApiOptions & { body: SendMessageReq },
413
505
  ): Promise<void> {
414
- await apiPostFetch({
506
+ const rawText = await apiPostFetch({
415
507
  baseUrl: params.baseUrl,
416
508
  endpoint: "ilink/bot/sendmessage",
417
509
  body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
@@ -419,6 +511,12 @@ export async function sendMessage(
419
511
  timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
420
512
  label: "sendMessage",
421
513
  });
514
+ const resp: SendMessageResp = JSON.parse(rawText);
515
+ if (resp.ret && resp.ret !== 0) {
516
+ throw new Error(
517
+ `sendMessage ret=${resp.ret} errmsg=${resp.errmsg ?? "(none)"}`,
518
+ );
519
+ }
422
520
  }
423
521
 
424
522
  /** Fetch bot config (includes typing_ticket) for a given user. */
@@ -2,8 +2,8 @@ import { logger } from "../util/logger.js";
2
2
 
3
3
  const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
4
4
 
5
- /** Error code returned by the server when the bot session has expired. */
6
- export const SESSION_EXPIRED_ERRCODE = -14;
5
+ /** Error code returned by the server when the bot token is stale / expired. */
6
+ export const STALE_TOKEN_ERRCODE = -14;
7
7
 
8
8
  const pauseUntilMap = new Map<string, number>();
9
9
 
@@ -44,7 +44,7 @@ export function assertSessionActive(accountId: string): void {
44
44
  if (isSessionPaused(accountId)) {
45
45
  const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
46
46
  throw new Error(
47
- `session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${SESSION_EXPIRED_ERRCODE})`,
47
+ `session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${STALE_TOKEN_ERRCODE})`,
48
48
  );
49
49
  }
50
50
  }
package/src/api/types.ts CHANGED
@@ -74,6 +74,8 @@ export const MessageItemType = {
74
74
  VOICE: 3,
75
75
  FILE: 4,
76
76
  VIDEO: 5,
77
+ TOOL_CALL_START: 11,
78
+ TOOL_CALL_RESULT: 12,
77
79
  } as const;
78
80
 
79
81
  export const MessageState = {
@@ -147,6 +149,17 @@ export interface RefMessage {
147
149
  title?: string; // 摘要
148
150
  }
149
151
 
152
+ export interface ToolCallStartItem {
153
+ tool_name?: string;
154
+ tool_call_id?: string;
155
+ }
156
+
157
+ export interface ToolCallResultItem {
158
+ tool_name?: string;
159
+ tool_call_id?: string;
160
+ status?: string;
161
+ }
162
+
150
163
  export interface MessageItem {
151
164
  type?: number;
152
165
  create_time_ms?: number;
@@ -159,6 +172,8 @@ export interface MessageItem {
159
172
  voice_item?: VoiceItem;
160
173
  file_item?: FileItem;
161
174
  video_item?: VideoItem;
175
+ tool_call_start_item?: ToolCallStartItem;
176
+ tool_call_result_item?: ToolCallResultItem;
162
177
  }
163
178
 
164
179
  /** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */
@@ -177,6 +192,7 @@ export interface WeixinMessage {
177
192
  message_state?: number;
178
193
  item_list?: MessageItem[];
179
194
  context_token?: string;
195
+ run_id?: string;
180
196
  }
181
197
 
182
198
  /** GetUpdates request: bytes fields are base64 strings in JSON. */
@@ -208,7 +224,8 @@ export interface SendMessageReq {
208
224
  }
209
225
 
210
226
  export interface SendMessageResp {
211
- // empty
227
+ ret?: number;
228
+ errmsg?: string;
212
229
  }
213
230
 
214
231
  /** Typing status: 1 = typing (default), 2 = cancel typing. */
@@ -70,10 +70,16 @@ export async function uploadBufferToCdn(params: {
70
70
  } catch (err) {
71
71
  lastError = err;
72
72
  if (err instanceof Error && err.message.includes("client error")) throw err;
73
+ const cause =
74
+ (err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "";
73
75
  if (attempt < UPLOAD_MAX_RETRIES) {
74
- logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);
76
+ logger.error(
77
+ `${label}: attempt ${attempt} failed, retrying... url=${redactUrl(cdnUrl)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
78
+ );
75
79
  } else {
76
- logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`);
80
+ logger.error(
81
+ `${label}: all ${UPLOAD_MAX_RETRIES} attempts failed url=${redactUrl(cdnUrl)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
82
+ );
77
83
  }
78
84
  }
79
85
  }
package/src/cdn/upload.ts CHANGED
@@ -7,6 +7,7 @@ import type { WeixinApiOptions } from "../api/api.js";
7
7
  import { aesEcbPaddedSize } from "./aes-ecb.js";
8
8
  import { uploadBufferToCdn } from "./cdn-upload.js";
9
9
  import { logger } from "../util/logger.js";
10
+ import { redactUrl } from "../util/redact.js";
10
11
  import { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
11
12
  import { tempFileName } from "../util/random.js";
12
13
  import { UploadMediaType } from "../api/types.js";
@@ -28,10 +29,20 @@ export type UploadedFileInfo = {
28
29
  * Returns the local file path; extension is inferred from Content-Type / URL.
29
30
  */
30
31
  export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
31
- logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
32
- const res = await fetch(url);
32
+ logger.debug(`downloadRemoteImageToTemp: fetching url=${redactUrl(url)}`);
33
+ let res: Response;
34
+ try {
35
+ res = await fetch(url);
36
+ } catch (err) {
37
+ const cause =
38
+ (err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "";
39
+ logger.error(
40
+ `downloadRemoteImageToTemp: fetch network error url=${redactUrl(url)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
41
+ );
42
+ throw err;
43
+ }
33
44
  if (!res.ok) {
34
- const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
45
+ const msg = `remote media download failed: ${res.status} ${res.statusText} url=${redactUrl(url)}`;
35
46
  logger.error(`downloadRemoteImageToTemp: ${msg}`);
36
47
  throw new Error(msg);
37
48
  }
package/src/channel.ts CHANGED
@@ -167,7 +167,13 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
167
167
  schema: {
168
168
  type: "object",
169
169
  additionalProperties: false,
170
- properties: {},
170
+ properties: {
171
+ replyProgressMessages: {
172
+ type: "boolean",
173
+ default: true,
174
+ description: "Send structured tool-call progress messages.",
175
+ },
176
+ },
171
177
  },
172
178
  },
173
179
  capabilities: {
@@ -17,6 +17,7 @@ const weixinAccountSchema = z.object({
17
17
  /** Top-level weixin config schema (token is stored in credentials file, not config). */
18
18
  export const WeixinConfigSchema = weixinAccountSchema.extend({
19
19
  accounts: z.record(z.string(), weixinAccountSchema).optional(),
20
+ replyProgressMessages: z.boolean().default(true),
20
21
  /** ISO 8601; bumped on each successful login to refresh gateway config from disk. */
21
22
  channelConfigUpdatedAt: z.string().optional(),
22
23
  });
@@ -0,0 +1,10 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+
3
+ type WeixinChannelConfig = {
4
+ replyProgressMessages?: boolean;
5
+ };
6
+
7
+ export function resolveReplyProgressMessagesEnabled(cfg: OpenClawConfig): boolean {
8
+ const section = cfg.channels?.["openclaw-weixin"] as WeixinChannelConfig | undefined;
9
+ return section?.replyProgressMessages !== false;
10
+ }
@@ -12,6 +12,7 @@ export async function sendWeixinErrorNotice(params: {
12
12
  message: string;
13
13
  baseUrl: string;
14
14
  token?: string;
15
+ runId?: string;
15
16
  errLog: (m: string) => void;
16
17
  }): Promise<void> {
17
18
  if (!params.contextToken) {
@@ -22,6 +23,7 @@ export async function sendWeixinErrorNotice(params: {
22
23
  baseUrl: params.baseUrl,
23
24
  token: params.token,
24
25
  contextToken: params.contextToken,
26
+ ...(params.runId ? { runId: params.runId } : {}),
25
27
  }});
26
28
  logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
27
29
  } catch (err) {
@@ -20,6 +20,7 @@ export async function applyWeixinMessageSendingHook(params: {
20
20
  text: string;
21
21
  accountId?: string;
22
22
  mediaUrl?: string;
23
+ runId?: string;
23
24
  }): Promise<{ cancelled: boolean; text: string }> {
24
25
  const hookRunner = getGlobalHookRunner();
25
26
  if (!hookRunner?.hasHooks("message_sending")) {
@@ -33,6 +34,7 @@ export async function applyWeixinMessageSendingHook(params: {
33
34
  metadata: {
34
35
  channel: CHANNEL_ID,
35
36
  accountId: params.accountId,
37
+ runId: params.runId,
36
38
  ...(params.mediaUrl ? { mediaUrls: [params.mediaUrl] } : {}),
37
39
  },
38
40
  },
@@ -60,6 +62,7 @@ export function emitWeixinMessageSent(params: {
60
62
  success: boolean;
61
63
  error?: string;
62
64
  accountId?: string;
65
+ runId?: string;
63
66
  }): void {
64
67
  const hookRunner = getGlobalHookRunner();
65
68
  if (!hookRunner?.hasHooks("message_sent")) return;
@@ -71,6 +74,7 @@ export function emitWeixinMessageSent(params: {
71
74
  channelId: CHANNEL_ID,
72
75
  accountId: params.accountId,
73
76
  conversationId: params.to,
77
+ runId: params.runId,
74
78
  });
75
79
  fireAndForgetHook(
76
80
  Promise.resolve(
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { randomUUID } from "node:crypto";
2
3
 
3
4
  import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
4
5
  import {
@@ -14,6 +15,7 @@ import { MessageItemType, TypingStatus } from "../api/types.js";
14
15
  import { loadWeixinAccount } from "../auth/accounts.js";
15
16
  import { readFrameworkAllowFromList } from "../auth/pairing.js";
16
17
  import { downloadRemoteImageToTemp } from "../cdn/upload.js";
18
+ import { resolveReplyProgressMessagesEnabled } from "../config/reply-progress.js";
17
19
  import { downloadMediaFromItem } from "../media/media-download.js";
18
20
  import { logger } from "../util/logger.js";
19
21
  import { redactBody, redactToken } from "../util/redact.js";
@@ -31,6 +33,7 @@ import type { WeixinInboundMediaOpts } from "./inbound.js";
31
33
  import { sendWeixinMediaFile } from "./send-media.js";
32
34
  import { StreamingMarkdownFilter } from "./markdown-filter.js";
33
35
  import { sendMessageWeixin } from "./send.js";
36
+ import { WeixinReplyProgressSender } from "./reply-progress-sender.js";
34
37
  import { handleSlashCommand } from "./slash-commands.js";
35
38
 
36
39
  const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp");
@@ -270,6 +273,19 @@ export async function processOneMessage(
270
273
  if (contextToken) {
271
274
  setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
272
275
  }
276
+ const runId = randomUUID();
277
+ const replyProgressSender = resolveReplyProgressMessagesEnabled(deps.config)
278
+ ? new WeixinReplyProgressSender({
279
+ runId,
280
+ to: ctx.To,
281
+ accountId: deps.accountId,
282
+ opts: {
283
+ baseUrl: deps.baseUrl,
284
+ token: deps.token,
285
+ contextToken,
286
+ },
287
+ })
288
+ : undefined;
273
289
  const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);
274
290
 
275
291
  const hasTypingTicket = Boolean(deps.typingTicket);
@@ -336,6 +352,7 @@ export async function processOneMessage(
336
352
  text,
337
353
  accountId: deps.accountId,
338
354
  mediaUrl,
355
+ runId,
339
356
  });
340
357
  if (sendingResult.cancelled) {
341
358
  logger.info(`outbound: cancelled by message_sending hook to=${ctx.To}`);
@@ -368,8 +385,9 @@ export async function processOneMessage(
368
385
  baseUrl: deps.baseUrl,
369
386
  token: deps.token,
370
387
  contextToken,
388
+ runId,
371
389
  }});
372
- emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
390
+ emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
373
391
  logger.info(`outbound: text sent to=${ctx.To}`);
374
392
  return;
375
393
  }
@@ -377,10 +395,10 @@ export async function processOneMessage(
377
395
  filePath,
378
396
  to: ctx.To,
379
397
  text,
380
- opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
398
+ opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken, runId },
381
399
  cdnBaseUrl: deps.cdnBaseUrl,
382
400
  });
383
- emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
401
+ emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
384
402
  logger.info(`outbound: media sent OK to=${ctx.To}`);
385
403
  } else {
386
404
  logger.debug(`outbound: sending text message to=${ctx.To}`);
@@ -388,12 +406,13 @@ export async function processOneMessage(
388
406
  baseUrl: deps.baseUrl,
389
407
  token: deps.token,
390
408
  contextToken,
409
+ runId,
391
410
  }});
392
- emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
411
+ emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
393
412
  logger.info(`outbound: text sent OK to=${ctx.To}`);
394
413
  }
395
414
  } catch (err) {
396
- emitWeixinMessageSent({ to: ctx.To, content: text, success: false, error: String(err), accountId: deps.accountId });
415
+ emitWeixinMessageSent({ to: ctx.To, content: text, success: false, error: String(err), accountId: deps.accountId, runId });
397
416
  logger.error(
398
417
  `outbound: FAILED to=${ctx.To} mediaUrl=${mediaUrl ?? "none"} err=${String(err)} stack=${(err as Error).stack ?? ""}`,
399
418
  );
@@ -421,6 +440,7 @@ export async function processOneMessage(
421
440
  message: notice,
422
441
  baseUrl: deps.baseUrl,
423
442
  token: deps.token,
443
+ runId,
424
444
  errLog: deps.errLog,
425
445
  });
426
446
  },
@@ -435,7 +455,11 @@ export async function processOneMessage(
435
455
  ctx: finalized,
436
456
  cfg: deps.config,
437
457
  dispatcher,
438
- replyOptions: { ...replyOptions, disableBlockStreaming: true },
458
+ replyOptions: {
459
+ ...replyOptions,
460
+ ...(replyProgressSender?.replyOptions ?? {}),
461
+ disableBlockStreaming: true,
462
+ },
439
463
  }),
440
464
  });
441
465
  logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
@@ -446,6 +470,7 @@ export async function processOneMessage(
446
470
  throw err;
447
471
  } finally {
448
472
  markDispatchIdle();
473
+ await replyProgressSender?.finalize();
449
474
 
450
475
  logger.info(
451
476
  `debug-check: accountId=${deps.accountId} debug=${String(debug)} hasContextToken=${Boolean(contextToken)}`,
@@ -489,7 +514,7 @@ export async function processOneMessage(
489
514
  await sendMessageWeixin({
490
515
  to: ctx.To,
491
516
  text: timingText,
492
- opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
517
+ opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken, runId },
493
518
  });
494
519
  logger.info(`debug-timing: sent OK`);
495
520
  } catch (debugErr) {