@tencent-weixin/openclaw-weixin 2.4.3 → 2.4.4

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 (48) hide show
  1. package/dist/src/api/api.js +44 -3
  2. package/dist/src/api/api.js.map +1 -1
  3. package/dist/src/api/types.js +2 -0
  4. package/dist/src/api/types.js.map +1 -1
  5. package/dist/src/channel.js +7 -1
  6. package/dist/src/channel.js.map +1 -1
  7. package/dist/src/config/config-schema.js +1 -0
  8. package/dist/src/config/config-schema.js.map +1 -1
  9. package/dist/src/config/reply-progress.js +5 -0
  10. package/dist/src/config/reply-progress.js.map +1 -0
  11. package/dist/src/media/voice-outbound.js +98 -1
  12. package/dist/src/media/voice-outbound.js.map +1 -1
  13. package/dist/src/messaging/batch-session.js +271 -0
  14. package/dist/src/messaging/batch-session.js.map +1 -0
  15. package/dist/src/messaging/error-notice.js +1 -0
  16. package/dist/src/messaging/error-notice.js.map +1 -1
  17. package/dist/src/messaging/outbound-hooks.js +2 -0
  18. package/dist/src/messaging/outbound-hooks.js.map +1 -1
  19. package/dist/src/messaging/process-message.js +32 -7
  20. package/dist/src/messaging/process-message.js.map +1 -1
  21. package/dist/src/messaging/reply-progress-sender.js +93 -0
  22. package/dist/src/messaging/reply-progress-sender.js.map +1 -0
  23. package/dist/src/messaging/run-context.js +9 -0
  24. package/dist/src/messaging/run-context.js.map +1 -0
  25. package/dist/src/messaging/run-report-session.js +123 -0
  26. package/dist/src/messaging/run-report-session.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 +3 -0
  30. package/dist/src/monitor/monitor.js.map +1 -1
  31. package/dist/src/streaming/batch-session.js +271 -0
  32. package/dist/src/streaming/batch-session.js.map +1 -0
  33. package/dist/src/streaming/stream-piece.js +54 -0
  34. package/dist/src/streaming/stream-piece.js.map +1 -0
  35. package/openclaw.plugin.json +1 -1
  36. package/package.json +1 -1
  37. package/src/api/api.ts +52 -3
  38. package/src/api/types.ts +16 -0
  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 +3 -0
package/src/api/api.ts CHANGED
@@ -295,11 +295,42 @@ export async function apiGetFetch(params: {
295
295
  }
296
296
  }
297
297
 
298
+ /**
299
+ * Combine an internal timeout controller with an optional external abort signal.
300
+ * This lets gateway channel-stop aborts cancel in-flight long-poll requests
301
+ * immediately while preserving the existing timeout-driven AbortError path.
302
+ */
303
+ function combineAbortSignals(params: {
304
+ internal?: AbortController;
305
+ external?: AbortSignal;
306
+ }): { signal?: AbortSignal; cleanup: () => void } {
307
+ const { internal, external } = params;
308
+ if (!external) {
309
+ return { signal: internal?.signal, cleanup: () => {} };
310
+ }
311
+ if (!internal) {
312
+ return { signal: external, cleanup: () => {} };
313
+ }
314
+ if (external.aborted) {
315
+ internal.abort();
316
+ return { signal: internal.signal, cleanup: () => {} };
317
+ }
318
+ const onExternalAbort = () => internal.abort();
319
+ external.addEventListener("abort", onExternalAbort, { once: true });
320
+ return {
321
+ signal: internal.signal,
322
+ cleanup: () => external.removeEventListener("abort", onExternalAbort),
323
+ };
324
+ }
325
+
298
326
  /**
299
327
  * Common fetch wrapper: POST JSON to a Weixin API endpoint.
300
328
  * When `timeoutMs` is provided, the request is aborted after that many milliseconds.
301
329
  * When omitted, no client-side timeout is applied (relies on OS/TCP stack).
302
330
  * Returns the raw response text on success; throws on HTTP error or timeout.
331
+ *
332
+ * When `abortSignal` is provided, an external abort (e.g. gateway channel stop)
333
+ * cancels the in-flight request immediately instead of waiting for `timeoutMs`.
303
334
  */
304
335
  export async function apiPostFetch(params: {
305
336
  baseUrl: string;
@@ -308,6 +339,7 @@ export async function apiPostFetch(params: {
308
339
  token?: string;
309
340
  timeoutMs?: number;
310
341
  label: string;
342
+ abortSignal?: AbortSignal;
311
343
  }): Promise<string> {
312
344
  const base = ensureTrailingSlash(params.baseUrl);
313
345
  const url = new URL(params.endpoint, base);
@@ -320,12 +352,16 @@ export async function apiPostFetch(params: {
320
352
  controller != null && params.timeoutMs !== undefined
321
353
  ? setTimeout(() => controller.abort(), params.timeoutMs)
322
354
  : undefined;
355
+ const { signal, cleanup } = combineAbortSignals({
356
+ internal: controller,
357
+ external: params.abortSignal,
358
+ });
323
359
  try {
324
360
  const res = await fetch(url.toString(), {
325
361
  method: "POST",
326
362
  headers: hdrs,
327
363
  body: params.body,
328
- ...(controller ? { signal: controller.signal } : {}),
364
+ ...(signal ? { signal } : {}),
329
365
  });
330
366
  if (t !== undefined) clearTimeout(t);
331
367
  const rawText = await res.text();
@@ -337,6 +373,8 @@ export async function apiPostFetch(params: {
337
373
  } catch (err) {
338
374
  if (t !== undefined) clearTimeout(t);
339
375
  throw err;
376
+ } finally {
377
+ cleanup();
340
378
  }
341
379
  }
342
380
 
@@ -351,6 +389,11 @@ export async function getUpdates(
351
389
  baseUrl: string;
352
390
  token?: string;
353
391
  timeoutMs?: number;
392
+ /**
393
+ * Optional external abort signal from the gateway. When stopping the channel,
394
+ * this aborts the in-flight long-poll immediately so hot reload can restart.
395
+ */
396
+ abortSignal?: AbortSignal;
354
397
  },
355
398
  ): Promise<GetUpdatesResp> {
356
399
  const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
@@ -365,13 +408,19 @@ export async function getUpdates(
365
408
  token: params.token,
366
409
  timeoutMs: timeout,
367
410
  label: "getUpdates",
411
+ abortSignal: params.abortSignal,
368
412
  });
369
413
  const resp: GetUpdatesResp = JSON.parse(rawText);
370
414
  return resp;
371
415
  } catch (err) {
372
- // Long-poll timeout is normal; return empty response so caller can retry
416
+ // Long-poll timeout or external abort are both normal control-flow exits.
417
+ // The monitor loop checks abortSignal after return and exits when needed.
373
418
  if (err instanceof Error && err.name === "AbortError") {
374
- logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
419
+ if (params.abortSignal?.aborted) {
420
+ logger.debug(`getUpdates: aborted by external signal`);
421
+ } else {
422
+ logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
423
+ }
375
424
  return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
376
425
  }
377
426
  throw err;
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. */
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) {
@@ -0,0 +1,122 @@
1
+ import type { WeixinApiOptions } from "../api/api.js";
2
+ import type { MessageItem } from "../api/types.js";
3
+ import { MessageItemType } from "../api/types.js";
4
+ import { logger } from "../util/logger.js";
5
+
6
+ import { sendMessageItemWeixin } from "./send.js";
7
+
8
+ export type WeixinReplyProgressSenderDeps = {
9
+ runId: string;
10
+ to: string;
11
+ accountId: string;
12
+ opts: WeixinApiOptions & {
13
+ contextToken?: string;
14
+ runId?: string;
15
+ };
16
+ };
17
+
18
+ type ToolItemEventPayload = {
19
+ itemId?: string;
20
+ kind?: string;
21
+ title?: string;
22
+ name?: string;
23
+ phase?: string;
24
+ status?: string;
25
+ };
26
+
27
+ function normalizeToolStatus(status?: string): string {
28
+ if (status === "completed") return "completed";
29
+ if (status === "failed") return "failed";
30
+ if (status === "blocked") return "blocked";
31
+ return "unknown";
32
+ }
33
+
34
+ export class WeixinReplyProgressSender {
35
+ readonly runId: string;
36
+
37
+ private readonly to: string;
38
+ private readonly accountId: string;
39
+ private readonly opts: WeixinReplyProgressSenderDeps["opts"];
40
+ private finalized = false;
41
+ private sendChain: Promise<void> = Promise.resolve();
42
+
43
+ constructor(deps: WeixinReplyProgressSenderDeps) {
44
+ this.runId = deps.runId;
45
+ this.to = deps.to;
46
+ this.accountId = deps.accountId;
47
+ this.opts = { ...deps.opts, runId: deps.runId };
48
+ }
49
+
50
+ get replyOptions() {
51
+ return {
52
+ runId: this.runId,
53
+ onItemEvent: (payload: ToolItemEventPayload) => this.handleToolItemEvent(payload),
54
+ };
55
+ }
56
+
57
+ private enqueueMessage(item: MessageItem, label: string): void {
58
+ if (this.finalized) return;
59
+ this.sendChain = this.sendChain
60
+ .then(async () => {
61
+ await sendMessageItemWeixin({
62
+ to: this.to,
63
+ item,
64
+ opts: this.opts,
65
+ label,
66
+ });
67
+ })
68
+ .catch((err) => {
69
+ logger.warn(`${label}: failed to=${this.to} accountId=${this.accountId} runId=${this.runId} err=${String(err)}`);
70
+ });
71
+ }
72
+
73
+ private handleToolItemEvent(payload: ToolItemEventPayload): void {
74
+ if (this.finalized) return;
75
+ if (payload.kind !== "tool") return;
76
+ if (payload.phase !== "start" && payload.phase !== "end") return;
77
+
78
+ const now = Date.now();
79
+ const toolName = payload.name?.trim() || payload.title?.trim() || "tool";
80
+ const toolCallId = payload.itemId?.trim() || undefined;
81
+
82
+ if (payload.phase === "start") {
83
+ this.enqueueMessage(
84
+ {
85
+ type: MessageItemType.TOOL_CALL_START,
86
+ create_time_ms: now,
87
+ is_completed: false,
88
+ tool_call_start_item: {
89
+ tool_name: toolName,
90
+ tool_call_id: toolCallId,
91
+ },
92
+ },
93
+ "sendToolCallStartMessage",
94
+ );
95
+ return;
96
+ }
97
+
98
+ this.enqueueMessage(
99
+ {
100
+ type: MessageItemType.TOOL_CALL_RESULT,
101
+ create_time_ms: now,
102
+ is_completed: true,
103
+ tool_call_result_item: {
104
+ tool_name: toolName,
105
+ tool_call_id: toolCallId,
106
+ status: normalizeToolStatus(payload.status),
107
+ },
108
+ },
109
+ "sendToolCallResultMessage",
110
+ );
111
+ }
112
+
113
+ async finalize(): Promise<void> {
114
+ if (this.finalized) return;
115
+ this.finalized = true;
116
+ try {
117
+ await this.sendChain;
118
+ } catch (err) {
119
+ logger.warn(`WeixinReplyProgressSender.finalize: send drain failed runId=${this.runId} err=${String(err)}`);
120
+ }
121
+ }
122
+ }
@@ -18,7 +18,7 @@ export async function sendWeixinMediaFile(params: {
18
18
  filePath: string;
19
19
  to: string;
20
20
  text: string;
21
- opts: WeixinApiOptions & { contextToken?: string };
21
+ opts: WeixinApiOptions & { contextToken?: string; runId?: string };
22
22
  cdnBaseUrl: string;
23
23
  }): Promise<{ messageId: string }> {
24
24
  const { filePath, to, text, opts, cdnBaseUrl } = params;
@@ -10,6 +10,11 @@ import type { UploadedFileInfo } from "../cdn/upload.js";
10
10
 
11
11
  export { StreamingMarkdownFilter } from "./markdown-filter.js";
12
12
 
13
+ type WeixinMessageSendOptions = WeixinApiOptions & {
14
+ contextToken?: string;
15
+ runId?: string;
16
+ };
17
+
13
18
  function generateClientId(): string {
14
19
  return generateId("openclaw-weixin");
15
20
  }
@@ -19,9 +24,10 @@ function buildTextMessageReq(params: {
19
24
  to: string;
20
25
  text: string;
21
26
  contextToken?: string;
27
+ runId?: string;
22
28
  clientId: string;
23
29
  }): SendMessageReq {
24
- const { to, text, contextToken, clientId } = params;
30
+ const { to, text, contextToken, runId, clientId } = params;
25
31
  const item_list: MessageItem[] = text
26
32
  ? [{ type: MessageItemType.TEXT, text_item: { text } }]
27
33
  : [];
@@ -34,6 +40,7 @@ function buildTextMessageReq(params: {
34
40
  message_state: MessageState.FINISH,
35
41
  item_list: item_list.length ? item_list : undefined,
36
42
  context_token: contextToken ?? undefined,
43
+ run_id: runId ?? undefined,
37
44
  },
38
45
  };
39
46
  }
@@ -42,14 +49,16 @@ function buildTextMessageReq(params: {
42
49
  function buildSendMessageReq(params: {
43
50
  to: string;
44
51
  contextToken?: string;
52
+ runId?: string;
45
53
  payload: ReplyPayload;
46
54
  clientId: string;
47
55
  }): SendMessageReq {
48
- const { to, contextToken, payload, clientId } = params;
56
+ const { to, contextToken, runId, payload, clientId } = params;
49
57
  return buildTextMessageReq({
50
58
  to,
51
59
  text: payload.text ?? "",
52
60
  contextToken,
61
+ runId,
53
62
  clientId,
54
63
  });
55
64
  }
@@ -60,7 +69,7 @@ function buildSendMessageReq(params: {
60
69
  export async function sendMessageWeixin(params: {
61
70
  to: string;
62
71
  text: string;
63
- opts: WeixinApiOptions & { contextToken?: string };
72
+ opts: WeixinMessageSendOptions;
64
73
  }): Promise<{ messageId: string }> {
65
74
  const { to, text, opts } = params;
66
75
  if (!opts.contextToken) {
@@ -70,6 +79,7 @@ export async function sendMessageWeixin(params: {
70
79
  const req = buildSendMessageReq({
71
80
  to,
72
81
  contextToken: opts.contextToken,
82
+ runId: opts.runId,
73
83
  payload: { text },
74
84
  clientId,
75
85
  });
@@ -87,6 +97,47 @@ export async function sendMessageWeixin(params: {
87
97
  return { messageId: clientId };
88
98
  }
89
99
 
100
+ /** Send a single structured MessageItem downstream. */
101
+ export async function sendMessageItemWeixin(params: {
102
+ to: string;
103
+ item: MessageItem;
104
+ opts: WeixinMessageSendOptions;
105
+ clientId?: string;
106
+ label?: string;
107
+ }): Promise<{ messageId: string }> {
108
+ const { to, item, opts } = params;
109
+ if (!opts.contextToken) {
110
+ logger.warn(`sendMessageItemWeixin: contextToken missing for to=${to}, sending without context`);
111
+ }
112
+ const clientId = params.clientId ?? generateClientId();
113
+ const req: SendMessageReq = {
114
+ msg: {
115
+ from_user_id: "",
116
+ to_user_id: to,
117
+ client_id: clientId,
118
+ message_type: MessageType.BOT,
119
+ message_state: MessageState.FINISH,
120
+ item_list: [item],
121
+ context_token: opts.contextToken ?? undefined,
122
+ run_id: opts.runId,
123
+ },
124
+ };
125
+ try {
126
+ await sendMessageApi({
127
+ baseUrl: opts.baseUrl,
128
+ token: opts.token,
129
+ timeoutMs: opts.timeoutMs,
130
+ body: req,
131
+ });
132
+ } catch (err) {
133
+ logger.error(
134
+ `${params.label ?? "sendMessageItemWeixin"}: failed to=${to} clientId=${clientId} err=${String(err)}`,
135
+ );
136
+ throw err;
137
+ }
138
+ return { messageId: clientId };
139
+ }
140
+
90
141
  /**
91
142
  * Send one or more MessageItems (optionally preceded by a text caption) downstream.
92
143
  * Each item is sent as its own request so that item_list always has exactly one entry.
@@ -95,10 +146,11 @@ async function sendMediaItems(params: {
95
146
  to: string;
96
147
  text: string;
97
148
  mediaItem: MessageItem;
98
- opts: WeixinApiOptions & { contextToken?: string };
149
+ opts: WeixinMessageSendOptions;
99
150
  label: string;
100
151
  }): Promise<{ messageId: string }> {
101
152
  const { to, text, mediaItem, opts, label } = params;
153
+ const runId = opts.runId;
102
154
 
103
155
  const items: MessageItem[] = [];
104
156
  if (text) {
@@ -118,6 +170,7 @@ async function sendMediaItems(params: {
118
170
  message_state: MessageState.FINISH,
119
171
  item_list: [item],
120
172
  context_token: opts.contextToken ?? undefined,
173
+ run_id: runId,
121
174
  },
122
175
  };
123
176
  try {
@@ -152,7 +205,7 @@ export async function sendImageMessageWeixin(params: {
152
205
  to: string;
153
206
  text: string;
154
207
  uploaded: UploadedFileInfo;
155
- opts: WeixinApiOptions & { contextToken?: string };
208
+ opts: WeixinMessageSendOptions;
156
209
  }): Promise<{ messageId: string }> {
157
210
  const { to, text, uploaded, opts } = params;
158
211
  if (!opts.contextToken) {
@@ -186,7 +239,7 @@ export async function sendVideoMessageWeixin(params: {
186
239
  to: string;
187
240
  text: string;
188
241
  uploaded: UploadedFileInfo;
189
- opts: WeixinApiOptions & { contextToken?: string };
242
+ opts: WeixinMessageSendOptions;
190
243
  }): Promise<{ messageId: string }> {
191
244
  const { to, text, uploaded, opts } = params;
192
245
  if (!opts.contextToken) {
@@ -218,7 +271,7 @@ export async function sendFileMessageWeixin(params: {
218
271
  text: string;
219
272
  fileName: string;
220
273
  uploaded: UploadedFileInfo;
221
- opts: WeixinApiOptions & { contextToken?: string };
274
+ opts: WeixinMessageSendOptions;
222
275
  }): Promise<{ messageId: string }> {
223
276
  const { to, text, fileName, uploaded, opts } = params;
224
277
  if (!opts.contextToken) {
@@ -96,6 +96,9 @@ export async function monitorWeixinProvider(opts: MonitorWeixinOpts): Promise<vo
96
96
  token,
97
97
  get_updates_buf: getUpdatesBuf,
98
98
  timeoutMs: nextTimeoutMs,
99
+ // Stop/hot-reload should cancel the in-flight long-poll immediately
100
+ // instead of waiting for the server-side long-poll timeout.
101
+ abortSignal,
99
102
  });
100
103
  aLog.debug(
101
104
  `getUpdates response: ret=${resp.ret}, msgs=${resp.msgs?.length ?? 0}, get_updates_buf_length=${resp.get_updates_buf?.length ?? 0}`,