@netcat-ai/openclaw-weixin 2.4.7

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 (116) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CHANGELOG.zh_CN.md +156 -0
  3. package/LICENSE +27 -0
  4. package/README.md +355 -0
  5. package/README.zh_CN.md +351 -0
  6. package/dist/index.js +16 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/src/api/api.js +484 -0
  9. package/dist/src/api/api.js.map +1 -0
  10. package/dist/src/api/config-cache.js +64 -0
  11. package/dist/src/api/config-cache.js.map +1 -0
  12. package/dist/src/api/session-guard.js +49 -0
  13. package/dist/src/api/session-guard.js.map +1 -0
  14. package/dist/src/api/types.js +37 -0
  15. package/dist/src/api/types.js.map +1 -0
  16. package/dist/src/auth/accounts.js +328 -0
  17. package/dist/src/auth/accounts.js.map +1 -0
  18. package/dist/src/auth/login-qr.js +331 -0
  19. package/dist/src/auth/login-qr.js.map +1 -0
  20. package/dist/src/auth/pairing.js +104 -0
  21. package/dist/src/auth/pairing.js.map +1 -0
  22. package/dist/src/cdn/aes-ecb.js +19 -0
  23. package/dist/src/cdn/aes-ecb.js.map +1 -0
  24. package/dist/src/cdn/cdn-upload.js +74 -0
  25. package/dist/src/cdn/cdn-upload.js.map +1 -0
  26. package/dist/src/cdn/cdn-url.js +14 -0
  27. package/dist/src/cdn/cdn-url.js.map +1 -0
  28. package/dist/src/cdn/pic-decrypt.js +89 -0
  29. package/dist/src/cdn/pic-decrypt.js.map +1 -0
  30. package/dist/src/cdn/upload.js +115 -0
  31. package/dist/src/cdn/upload.js.map +1 -0
  32. package/dist/src/channel.js +478 -0
  33. package/dist/src/channel.js.map +1 -0
  34. package/dist/src/compat.js +67 -0
  35. package/dist/src/compat.js.map +1 -0
  36. package/dist/src/config/config-schema.js +20 -0
  37. package/dist/src/config/config-schema.js.map +1 -0
  38. package/dist/src/config/reply-progress.js +5 -0
  39. package/dist/src/config/reply-progress.js.map +1 -0
  40. package/dist/src/media/media-download.js +95 -0
  41. package/dist/src/media/media-download.js.map +1 -0
  42. package/dist/src/media/mime.js +73 -0
  43. package/dist/src/media/mime.js.map +1 -0
  44. package/dist/src/media/silk-transcode.js +64 -0
  45. package/dist/src/media/silk-transcode.js.map +1 -0
  46. package/dist/src/messaging/debug-mode.js +63 -0
  47. package/dist/src/messaging/debug-mode.js.map +1 -0
  48. package/dist/src/messaging/error-notice.js +25 -0
  49. package/dist/src/messaging/error-notice.js.map +1 -0
  50. package/dist/src/messaging/inbound.js +201 -0
  51. package/dist/src/messaging/inbound.js.map +1 -0
  52. package/dist/src/messaging/markdown-filter.js +368 -0
  53. package/dist/src/messaging/markdown-filter.js.map +1 -0
  54. package/dist/src/messaging/outbound-hooks.js +58 -0
  55. package/dist/src/messaging/outbound-hooks.js.map +1 -0
  56. package/dist/src/messaging/process-message.js +406 -0
  57. package/dist/src/messaging/process-message.js.map +1 -0
  58. package/dist/src/messaging/reply-progress-sender.js +93 -0
  59. package/dist/src/messaging/reply-progress-sender.js.map +1 -0
  60. package/dist/src/messaging/send-media.js +54 -0
  61. package/dist/src/messaging/send-media.js.map +1 -0
  62. package/dist/src/messaging/send.js +220 -0
  63. package/dist/src/messaging/send.js.map +1 -0
  64. package/dist/src/messaging/slash-commands.js +70 -0
  65. package/dist/src/messaging/slash-commands.js.map +1 -0
  66. package/dist/src/monitor/monitor.js +146 -0
  67. package/dist/src/monitor/monitor.js.map +1 -0
  68. package/dist/src/storage/state-dir.js +9 -0
  69. package/dist/src/storage/state-dir.js.map +1 -0
  70. package/dist/src/storage/sync-buf.js +64 -0
  71. package/dist/src/storage/sync-buf.js.map +1 -0
  72. package/dist/src/util/logger.js +120 -0
  73. package/dist/src/util/logger.js.map +1 -0
  74. package/dist/src/util/random.js +16 -0
  75. package/dist/src/util/random.js.map +1 -0
  76. package/dist/src/util/redact.js +54 -0
  77. package/dist/src/util/redact.js.map +1 -0
  78. package/index.ts +19 -0
  79. package/openclaw.plugin.json +22 -0
  80. package/package.json +75 -0
  81. package/src/api/api.ts +586 -0
  82. package/src/api/config-cache.ts +79 -0
  83. package/src/api/session-guard.ts +58 -0
  84. package/src/api/types.ts +278 -0
  85. package/src/auth/accounts.ts +394 -0
  86. package/src/auth/login-qr.ts +458 -0
  87. package/src/auth/pairing.ts +120 -0
  88. package/src/cdn/aes-ecb.ts +21 -0
  89. package/src/cdn/cdn-upload.ts +93 -0
  90. package/src/cdn/cdn-url.ts +20 -0
  91. package/src/cdn/pic-decrypt.ts +101 -0
  92. package/src/cdn/upload.ts +168 -0
  93. package/src/channel.ts +548 -0
  94. package/src/compat.ts +77 -0
  95. package/src/config/config-schema.ts +23 -0
  96. package/src/config/reply-progress.ts +10 -0
  97. package/src/media/media-download.ts +149 -0
  98. package/src/media/mime.ts +76 -0
  99. package/src/media/silk-transcode.ts +74 -0
  100. package/src/messaging/debug-mode.ts +69 -0
  101. package/src/messaging/error-notice.ts +32 -0
  102. package/src/messaging/inbound.ts +262 -0
  103. package/src/messaging/markdown-filter.ts +361 -0
  104. package/src/messaging/outbound-hooks.ts +88 -0
  105. package/src/messaging/process-message.ts +525 -0
  106. package/src/messaging/reply-progress-sender.ts +122 -0
  107. package/src/messaging/send-media.ts +72 -0
  108. package/src/messaging/send.ts +294 -0
  109. package/src/messaging/slash-commands.ts +110 -0
  110. package/src/monitor/monitor.ts +224 -0
  111. package/src/storage/state-dir.ts +11 -0
  112. package/src/storage/sync-buf.ts +81 -0
  113. package/src/util/logger.ts +145 -0
  114. package/src/util/random.ts +17 -0
  115. package/src/util/redact.ts +54 -0
  116. package/src/vendor.d.ts +25 -0
@@ -0,0 +1,525 @@
1
+ import path from "node:path";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
5
+ import {
6
+ resolveSenderCommandAuthorizationWithRuntime,
7
+ resolveDirectDmAuthorizationOutcome,
8
+ } from "openclaw/plugin-sdk/command-auth";
9
+ import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
10
+ import type { PluginRuntime } from "openclaw/plugin-sdk/core";
11
+
12
+ import { sendTyping } from "../api/api.js";
13
+ import type { WeixinMessage } from "../api/types.js";
14
+ import { MessageItemType, TypingStatus } from "../api/types.js";
15
+ import { loadWeixinAccount } from "../auth/accounts.js";
16
+ import { readFrameworkAllowFromList } from "../auth/pairing.js";
17
+ import { downloadRemoteImageToTemp } from "../cdn/upload.js";
18
+ import { resolveReplyProgressMessagesEnabled } from "../config/reply-progress.js";
19
+ import { downloadMediaFromItem } from "../media/media-download.js";
20
+ import { logger } from "../util/logger.js";
21
+ import { redactBody, redactToken } from "../util/redact.js";
22
+
23
+ import { isDebugMode } from "./debug-mode.js";
24
+ import { sendWeixinErrorNotice } from "./error-notice.js";
25
+ import { applyWeixinMessageSendingHook, emitWeixinMessageSent } from "./outbound-hooks.js";
26
+ import {
27
+ setContextToken,
28
+ weixinMessageToMsgContext,
29
+ getContextTokenFromMsgContext,
30
+ isMediaItem,
31
+ } from "./inbound.js";
32
+ import type { WeixinInboundMediaOpts } from "./inbound.js";
33
+ import { sendWeixinMediaFile } from "./send-media.js";
34
+ import { StreamingMarkdownFilter } from "./markdown-filter.js";
35
+ import { sendMessageWeixin } from "./send.js";
36
+ import { WeixinReplyProgressSender } from "./reply-progress-sender.js";
37
+ import { handleSlashCommand } from "./slash-commands.js";
38
+
39
+ const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp");
40
+
41
+ /** Dependencies for processOneMessage, injected by the monitor loop. */
42
+ export type ProcessMessageDeps = {
43
+ accountId: string;
44
+ config: import("openclaw/plugin-sdk/core").OpenClawConfig;
45
+ channelRuntime: PluginRuntime["channel"];
46
+ baseUrl: string;
47
+ cdnBaseUrl: string;
48
+ token?: string;
49
+ typingTicket?: string;
50
+ log: (msg: string) => void;
51
+ errLog: (m: string) => void;
52
+ };
53
+
54
+ /** Extract text body from item_list (for slash command detection). */
55
+ function extractTextBody(itemList?: import("../api/types.js").MessageItem[]): string {
56
+ if (!itemList?.length) return "";
57
+ for (const item of itemList) {
58
+ if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
59
+ return String(item.text_item.text);
60
+ }
61
+ }
62
+ return "";
63
+ }
64
+
65
+ /**
66
+ * Process a single inbound message: route → download media → dispatch reply.
67
+ * Extracted from the monitor loop to keep monitoring and message handling separate.
68
+ */
69
+ export async function processOneMessage(
70
+ full: WeixinMessage,
71
+ deps: ProcessMessageDeps,
72
+ ): Promise<void> {
73
+ if (!deps?.channelRuntime) {
74
+ logger.error(
75
+ `processOneMessage: channelRuntime is undefined, skipping message from=${full.from_user_id}`,
76
+ );
77
+ deps.errLog("processOneMessage: channelRuntime is undefined, skip");
78
+ return;
79
+ }
80
+
81
+ const receivedAt = Date.now();
82
+ const debug = isDebugMode(deps.accountId);
83
+ const debugTrace: string[] = [];
84
+ const debugTs: Record<string, number> = { received: receivedAt };
85
+
86
+ const textBody = extractTextBody(full.item_list);
87
+ if (textBody.startsWith("/")) {
88
+ const slashResult = await handleSlashCommand(textBody, {
89
+ to: full.from_user_id ?? "",
90
+ contextToken: full.context_token,
91
+ baseUrl: deps.baseUrl,
92
+ token: deps.token,
93
+ accountId: deps.accountId,
94
+ log: deps.log,
95
+ errLog: deps.errLog,
96
+ }, receivedAt, full.create_time_ms);
97
+ if (slashResult.handled) {
98
+ logger.info(`[weixin] Slash command handled, skipping AI pipeline`);
99
+ return;
100
+ }
101
+ }
102
+
103
+ if (debug) {
104
+ const itemTypes = full.item_list?.map((i) => i.type).join(",") ?? "none";
105
+ debugTrace.push(
106
+ "── 收消息 ──",
107
+ `│ seq=${full.seq ?? "?"} msgId=${full.message_id ?? "?"} from=${full.from_user_id ?? "?"}`,
108
+ `│ body="${textBody.slice(0, 40)}${textBody.length > 40 ? "…" : ""}" (len=${textBody.length}) itemTypes=[${itemTypes}]`,
109
+ `│ sessionId=${full.session_id ?? "?"} contextToken=${full.context_token ? "present" : "none"}`,
110
+ );
111
+ }
112
+
113
+ const mediaOpts: WeixinInboundMediaOpts = {};
114
+
115
+ // Find the first downloadable media item (priority: IMAGE > VIDEO > FILE > VOICE).
116
+ // When none found in the main item_list, fall back to media referenced via a quoted message.
117
+ const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
118
+ m?.encrypt_query_param || m?.full_url;
119
+ const mainMediaItem =
120
+ full.item_list?.find(
121
+ (i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media),
122
+ ) ??
123
+ full.item_list?.find(
124
+ (i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media),
125
+ ) ??
126
+ full.item_list?.find(
127
+ (i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media),
128
+ ) ??
129
+ full.item_list?.find(
130
+ (i) =>
131
+ i.type === MessageItemType.VOICE &&
132
+ hasDownloadableMedia(i.voice_item?.media) &&
133
+ !i.voice_item?.text,
134
+ );
135
+ const refMediaItem = !mainMediaItem
136
+ ? full.item_list?.find(
137
+ (i) =>
138
+ i.type === MessageItemType.TEXT &&
139
+ i.ref_msg?.message_item &&
140
+ isMediaItem(i.ref_msg.message_item!),
141
+ )?.ref_msg?.message_item
142
+ : undefined;
143
+
144
+ const mediaDownloadStart = Date.now();
145
+ const mediaItem = mainMediaItem ?? refMediaItem;
146
+ if (mediaItem) {
147
+ const label = refMediaItem ? "ref" : "inbound";
148
+ const downloaded = await downloadMediaFromItem(mediaItem, {
149
+ cdnBaseUrl: deps.cdnBaseUrl,
150
+ saveMedia: deps.channelRuntime.media.saveMediaBuffer,
151
+ log: deps.log,
152
+ errLog: deps.errLog,
153
+ label,
154
+ });
155
+ Object.assign(mediaOpts, downloaded);
156
+ }
157
+ const mediaDownloadMs = Date.now() - mediaDownloadStart;
158
+
159
+ if (debug) {
160
+ debugTrace.push(mediaItem
161
+ ? `│ mediaDownload: type=${mediaItem.type} cost=${mediaDownloadMs}ms`
162
+ : "│ mediaDownload: none",
163
+ );
164
+ }
165
+
166
+ const ctx = weixinMessageToMsgContext(full, deps.accountId, mediaOpts);
167
+
168
+ // --- Framework command authorization ---
169
+ const rawBody = ctx.Body?.trim() ?? "";
170
+ ctx.CommandBody = rawBody;
171
+
172
+ const senderId = full.from_user_id ?? "";
173
+
174
+ const { senderAllowedForCommands, commandAuthorized } =
175
+ await resolveSenderCommandAuthorizationWithRuntime({
176
+ cfg: deps.config,
177
+ rawBody,
178
+ isGroup: false,
179
+ dmPolicy: "pairing",
180
+ configuredAllowFrom: [],
181
+ configuredGroupAllowFrom: [],
182
+ senderId,
183
+ isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id),
184
+ /** Pairing: framework credentials `*-allowFrom.json`, with account `userId` fallback for legacy installs. */
185
+ readAllowFromStore: async () => {
186
+ const fromStore = readFrameworkAllowFromList(deps.accountId);
187
+ if (fromStore.length > 0) return fromStore;
188
+ const uid = loadWeixinAccount(deps.accountId)?.userId?.trim();
189
+ return uid ? [uid] : [];
190
+ },
191
+ runtime: deps.channelRuntime.commands,
192
+ });
193
+
194
+ const directDmOutcome = resolveDirectDmAuthorizationOutcome({
195
+ isGroup: false,
196
+ dmPolicy: "pairing",
197
+ senderAllowedForCommands,
198
+ });
199
+
200
+ if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") {
201
+ logger.info(
202
+ `authorization: dropping message from=${senderId} outcome=${directDmOutcome}`,
203
+ );
204
+ return;
205
+ }
206
+
207
+ ctx.CommandAuthorized = commandAuthorized;
208
+ logger.debug(
209
+ `authorization: senderId=${senderId} commandAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
210
+ );
211
+
212
+ if (debug) {
213
+ debugTrace.push(
214
+ "── 鉴权 & 路由 ──",
215
+ `│ auth: cmdAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
216
+ );
217
+ }
218
+
219
+ const route = deps.channelRuntime.routing.resolveAgentRoute({
220
+ cfg: deps.config,
221
+ channel: "openclaw-weixin",
222
+ accountId: deps.accountId,
223
+ peer: { kind: "direct", id: ctx.To },
224
+ });
225
+ logger.debug(
226
+ `resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`,
227
+ );
228
+ if (!route.agentId) {
229
+ logger.error(
230
+ `resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`,
231
+ );
232
+ }
233
+
234
+ if (debug) {
235
+ debugTrace.push(
236
+ `│ route: agent=${route.agentId ?? "none"} session=${route.sessionKey ?? "none"}`,
237
+ );
238
+ debugTs.preDispatch = Date.now();
239
+ }
240
+ // Propagate the resolved session key into ctx so dispatchReplyFromConfig uses
241
+ // the correct session (matching the dmScope from config) instead of falling back
242
+ // to agent:main:main.
243
+ ctx.SessionKey = route.sessionKey;
244
+ const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
245
+ agentId: route.agentId,
246
+ });
247
+ const finalized = deps.channelRuntime.reply.finalizeInboundContext(
248
+ ctx as Parameters<typeof deps.channelRuntime.reply.finalizeInboundContext>[0],
249
+ );
250
+
251
+ logger.info(
252
+ `inbound: from=${finalized.From} to=${finalized.To} bodyLen=${(finalized.Body ?? "").length} hasMedia=${Boolean(finalized.MediaPath ?? finalized.MediaUrl)}`,
253
+ );
254
+ logger.debug(`inbound context: ${redactBody(JSON.stringify(finalized))}`);
255
+
256
+ await deps.channelRuntime.session.recordInboundSession({
257
+ storePath,
258
+ sessionKey: route.sessionKey,
259
+ ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0]["ctx"],
260
+ updateLastRoute: {
261
+ sessionKey: route.mainSessionKey,
262
+ channel: "openclaw-weixin",
263
+ to: ctx.To,
264
+ accountId: deps.accountId,
265
+ },
266
+ onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
267
+ });
268
+ logger.debug(
269
+ `recordInboundSession: done storePath=${storePath} sessionKey=${route.sessionKey ?? "(none)"}`,
270
+ );
271
+
272
+ const contextToken = getContextTokenFromMsgContext(ctx);
273
+ if (contextToken) {
274
+ setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
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;
289
+ const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);
290
+
291
+ const hasTypingTicket = Boolean(deps.typingTicket);
292
+ const typingCallbacks = createTypingCallbacks({
293
+ start: hasTypingTicket
294
+ ? () =>
295
+ sendTyping({
296
+ baseUrl: deps.baseUrl,
297
+ token: deps.token,
298
+ body: {
299
+ ilink_user_id: ctx.To,
300
+ typing_ticket: deps.typingTicket!,
301
+ status: TypingStatus.TYPING,
302
+ },
303
+ })
304
+ : async () => {},
305
+ stop: hasTypingTicket
306
+ ? () =>
307
+ sendTyping({
308
+ baseUrl: deps.baseUrl,
309
+ token: deps.token,
310
+ body: {
311
+ ilink_user_id: ctx.To,
312
+ typing_ticket: deps.typingTicket!,
313
+ status: TypingStatus.CANCEL,
314
+ },
315
+ })
316
+ : async () => {},
317
+ onStartError: (err) => deps.log(`[weixin] typing send error: ${String(err)}`),
318
+ onStopError: (err) => deps.log(`[weixin] typing cancel error: ${String(err)}`),
319
+ keepaliveIntervalMs: 5000,
320
+ });
321
+
322
+ /** Delivery records populated synchronously at deliver() entry, safe to read in finally. */
323
+ const debugDeliveries: Array<{ textLen: number; media: string; preview: string; ts: number }> = [];
324
+
325
+ const { dispatcher, replyOptions, markDispatchIdle } =
326
+ deps.channelRuntime.reply.createReplyDispatcherWithTyping({
327
+ humanDelay,
328
+ typingCallbacks,
329
+ deliver: async (payload) => {
330
+ const rawText = payload.text ?? "";
331
+ let text = (() => {
332
+ const f = new StreamingMarkdownFilter();
333
+ return f.feed(rawText) + f.flush();
334
+ })();
335
+ const mediaUrl = payload.mediaUrl ?? payload.mediaUrls?.[0];
336
+ logger.debug(`outbound payload: ${redactBody(JSON.stringify(payload))}`);
337
+ logger.info(
338
+ `outbound: to=${ctx.To} contextToken=${redactToken(contextToken)} textLen=${text.length} mediaUrl=${mediaUrl ? "present" : "none"}`,
339
+ );
340
+
341
+ if (debug) {
342
+ debugDeliveries.push({
343
+ textLen: text.length,
344
+ media: mediaUrl ? "present" : "none",
345
+ preview: `${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`,
346
+ ts: Date.now(),
347
+ });
348
+ }
349
+
350
+ const sendingResult = await applyWeixinMessageSendingHook({
351
+ to: ctx.To,
352
+ text,
353
+ accountId: deps.accountId,
354
+ mediaUrl,
355
+ runId,
356
+ });
357
+ if (sendingResult.cancelled) {
358
+ logger.info(`outbound: cancelled by message_sending hook to=${ctx.To}`);
359
+ return;
360
+ }
361
+ text = sendingResult.text;
362
+
363
+ try {
364
+ if (mediaUrl) {
365
+ let filePath: string;
366
+ if (!mediaUrl.includes("://") || mediaUrl.startsWith("file://")) {
367
+ if (mediaUrl.startsWith("file://")) {
368
+ filePath = new URL(mediaUrl).pathname;
369
+ } else if (!path.isAbsolute(mediaUrl)) {
370
+ filePath = path.resolve(mediaUrl);
371
+ logger.debug(`outbound: resolved relative path ${mediaUrl} -> ${filePath}`);
372
+ } else {
373
+ filePath = mediaUrl;
374
+ }
375
+ logger.debug(`outbound: local file path resolved filePath=${filePath}`);
376
+ } else if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) {
377
+ logger.debug(`outbound: downloading remote mediaUrl=${mediaUrl.slice(0, 80)}...`);
378
+ filePath = await downloadRemoteImageToTemp(mediaUrl, MEDIA_OUTBOUND_TEMP_DIR);
379
+ logger.debug(`outbound: remote image downloaded to filePath=${filePath}`);
380
+ } else {
381
+ logger.warn(
382
+ `outbound: unrecognized mediaUrl scheme, sending text only mediaUrl=${mediaUrl.slice(0, 80)}`,
383
+ );
384
+ await sendMessageWeixin({ to: ctx.To, text, opts: {
385
+ baseUrl: deps.baseUrl,
386
+ token: deps.token,
387
+ contextToken,
388
+ runId,
389
+ }});
390
+ emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
391
+ logger.info(`outbound: text sent to=${ctx.To}`);
392
+ return;
393
+ }
394
+ await sendWeixinMediaFile({
395
+ filePath,
396
+ to: ctx.To,
397
+ text,
398
+ opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken, runId },
399
+ cdnBaseUrl: deps.cdnBaseUrl,
400
+ });
401
+ emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
402
+ logger.info(`outbound: media sent OK to=${ctx.To}`);
403
+ } else {
404
+ logger.debug(`outbound: sending text message to=${ctx.To}`);
405
+ await sendMessageWeixin({ to: ctx.To, text, opts: {
406
+ baseUrl: deps.baseUrl,
407
+ token: deps.token,
408
+ contextToken,
409
+ runId,
410
+ }});
411
+ emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
412
+ logger.info(`outbound: text sent OK to=${ctx.To}`);
413
+ }
414
+ } catch (err) {
415
+ emitWeixinMessageSent({ to: ctx.To, content: text, success: false, error: String(err), accountId: deps.accountId, runId });
416
+ logger.error(
417
+ `outbound: FAILED to=${ctx.To} mediaUrl=${mediaUrl ?? "none"} err=${String(err)} stack=${(err as Error).stack ?? ""}`,
418
+ );
419
+ throw err;
420
+ }
421
+ },
422
+ onError: (err, info) => {
423
+ deps.errLog(`weixin reply ${info.kind}: ${String(err)}`);
424
+ const errMsg = err instanceof Error ? err.message : String(err);
425
+ let notice: string;
426
+ if (errMsg.includes("remote media download failed") || errMsg.includes("fetch")) {
427
+ notice = `⚠️ 媒体文件下载失败,请检查链接是否可访问。`;
428
+ } else if (
429
+ errMsg.includes("getUploadUrl") ||
430
+ errMsg.includes("CDN upload") ||
431
+ errMsg.includes("upload_param")
432
+ ) {
433
+ notice = `⚠️ 媒体文件上传失败,请稍后重试。`;
434
+ } else {
435
+ notice = `⚠️ 消息发送失败:${errMsg}`;
436
+ }
437
+ void sendWeixinErrorNotice({
438
+ to: ctx.To,
439
+ contextToken,
440
+ message: notice,
441
+ baseUrl: deps.baseUrl,
442
+ token: deps.token,
443
+ runId,
444
+ errLog: deps.errLog,
445
+ });
446
+ },
447
+ });
448
+
449
+ logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);
450
+ try {
451
+ await deps.channelRuntime.reply.withReplyDispatcher({
452
+ dispatcher,
453
+ run: () =>
454
+ deps.channelRuntime.reply.dispatchReplyFromConfig({
455
+ ctx: finalized,
456
+ cfg: deps.config,
457
+ dispatcher,
458
+ replyOptions: {
459
+ ...replyOptions,
460
+ ...(replyProgressSender?.replyOptions ?? {}),
461
+ disableBlockStreaming: true,
462
+ },
463
+ }),
464
+ });
465
+ logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
466
+ } catch (err) {
467
+ logger.error(
468
+ `dispatchReplyFromConfig: error agentId=${route.agentId ?? "(none)"} err=${String(err)}`,
469
+ );
470
+ throw err;
471
+ } finally {
472
+ markDispatchIdle();
473
+ await replyProgressSender?.finalize();
474
+
475
+ logger.info(
476
+ `debug-check: accountId=${deps.accountId} debug=${String(debug)} hasContextToken=${Boolean(contextToken)}`,
477
+ );
478
+
479
+ if (debug && contextToken) {
480
+ const dispatchDoneAt = Date.now();
481
+ const eventTs = full.create_time_ms ?? 0;
482
+ const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
483
+ const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt;
484
+ const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt);
485
+ const totalTime = eventTs > 0 ? `${dispatchDoneAt - eventTs}ms` : `${dispatchDoneAt - receivedAt}ms`;
486
+
487
+ if (debugDeliveries.length > 0) {
488
+ debugTrace.push("── 回复 ──");
489
+ for (const d of debugDeliveries) {
490
+ debugTrace.push(
491
+ `│ textLen=${d.textLen} media=${d.media}`,
492
+ `│ text="${d.preview}"`,
493
+ );
494
+ }
495
+ const firstTs = debugDeliveries[0].ts;
496
+ debugTrace.push(`│ deliver耗时: ${dispatchDoneAt - firstTs}ms`);
497
+ } else {
498
+ debugTrace.push("── 回复 ──", "│ (deliver未捕获)");
499
+ }
500
+
501
+ debugTrace.push(
502
+ "── 耗时 ──",
503
+ `├ 平台→插件: ${platformDelay}`,
504
+ `├ 入站处理(auth+route+media): ${inboundProcessMs}ms (mediaDownload: ${mediaDownloadMs}ms)`,
505
+ `├ AI生成+回复: ${aiMs}ms`,
506
+ `├ 总耗时: ${totalTime}`,
507
+ `└ eventTime: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
508
+ );
509
+
510
+ const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`;
511
+
512
+ logger.info(`debug-timing: sending to=${ctx.To}`);
513
+ try {
514
+ await sendMessageWeixin({
515
+ to: ctx.To,
516
+ text: timingText,
517
+ opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken, runId },
518
+ });
519
+ logger.info(`debug-timing: sent OK`);
520
+ } catch (debugErr) {
521
+ logger.error(`debug-timing: send FAILED err=${String(debugErr)}`);
522
+ }
523
+ }
524
+ }
525
+ }
@@ -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
+ }