@dingxiang-me/openclaw-wechat 2.1.0 → 2.3.0

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 (77) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.en.md +181 -14
  3. package/README.md +201 -16
  4. package/docs/channels/wecom.md +137 -1
  5. package/openclaw.plugin.json +688 -6
  6. package/package.json +204 -4
  7. package/scripts/wecom-agent-selfcheck.mjs +775 -0
  8. package/scripts/wecom-bot-longconn-probe.mjs +582 -0
  9. package/scripts/wecom-bot-selfcheck.mjs +952 -0
  10. package/scripts/wecom-callback-matrix.mjs +224 -0
  11. package/scripts/wecom-doctor.mjs +1407 -0
  12. package/scripts/wecom-e2e-scenario.mjs +333 -0
  13. package/scripts/wecom-migrate.mjs +261 -0
  14. package/scripts/wecom-quickstart.mjs +1824 -0
  15. package/scripts/wecom-release-check.mjs +232 -0
  16. package/scripts/wecom-remote-e2e.mjs +310 -0
  17. package/scripts/wecom-selfcheck.mjs +1255 -0
  18. package/scripts/wecom-smoke.sh +74 -0
  19. package/src/core/delivery-router.js +21 -0
  20. package/src/core.js +619 -30
  21. package/src/wecom/account-config-core.js +27 -1
  22. package/src/wecom/account-config.js +19 -2
  23. package/src/wecom/agent-dispatch-executor.js +11 -0
  24. package/src/wecom/agent-dispatch-handlers.js +61 -8
  25. package/src/wecom/agent-inbound-guards.js +24 -0
  26. package/src/wecom/agent-inbound-processor.js +34 -2
  27. package/src/wecom/agent-late-reply-runtime.js +30 -2
  28. package/src/wecom/agent-text-sender.js +2 -0
  29. package/src/wecom/api-client-core.js +27 -19
  30. package/src/wecom/api-client-media.js +16 -7
  31. package/src/wecom/api-client-send-text.js +4 -0
  32. package/src/wecom/api-client-send-typed.js +4 -1
  33. package/src/wecom/api-client-senders.js +41 -3
  34. package/src/wecom/api-client.js +1 -0
  35. package/src/wecom/bot-dispatch-fallback.js +18 -3
  36. package/src/wecom/bot-dispatch-handlers.js +47 -10
  37. package/src/wecom/bot-inbound-dispatch-runtime.js +3 -0
  38. package/src/wecom/bot-inbound-executor-helpers.js +11 -1
  39. package/src/wecom/bot-inbound-executor.js +24 -0
  40. package/src/wecom/bot-inbound-guards.js +31 -1
  41. package/src/wecom/channel-config-schema.js +132 -0
  42. package/src/wecom/channel-plugin.js +348 -7
  43. package/src/wecom/command-handlers.js +102 -11
  44. package/src/wecom/command-status-text.js +206 -0
  45. package/src/wecom/doc-client.js +7 -1
  46. package/src/wecom/inbound-content-handler-file-video-link.js +4 -0
  47. package/src/wecom/inbound-content-handler-image-voice.js +6 -0
  48. package/src/wecom/inbound-content.js +5 -0
  49. package/src/wecom/installer-api.js +910 -0
  50. package/src/wecom/media-download.js +2 -2
  51. package/src/wecom/migration-diagnostics.js +816 -0
  52. package/src/wecom/network-config.js +91 -0
  53. package/src/wecom/observability-metrics.js +9 -3
  54. package/src/wecom/outbound-agent-delivery.js +313 -0
  55. package/src/wecom/outbound-agent-media-sender.js +37 -7
  56. package/src/wecom/outbound-agent-push.js +1 -0
  57. package/src/wecom/outbound-delivery.js +129 -12
  58. package/src/wecom/outbound-stream-msg-item.js +25 -2
  59. package/src/wecom/outbound-webhook-delivery.js +19 -0
  60. package/src/wecom/outbound-webhook-media.js +30 -6
  61. package/src/wecom/pending-reply-manager.js +143 -0
  62. package/src/wecom/plugin-account-policy-services.js +26 -0
  63. package/src/wecom/plugin-base-services.js +58 -0
  64. package/src/wecom/plugin-constants.js +1 -1
  65. package/src/wecom/plugin-delivery-inbound-services.js +25 -0
  66. package/src/wecom/plugin-processing-deps.js +7 -0
  67. package/src/wecom/plugin-route-runtime-deps.js +1 -0
  68. package/src/wecom/plugin-services.js +87 -0
  69. package/src/wecom/policy-resolvers.js +93 -20
  70. package/src/wecom/quickstart-metadata.js +1247 -0
  71. package/src/wecom/reasoning-visibility.js +104 -0
  72. package/src/wecom/register-runtime.js +10 -0
  73. package/src/wecom/reliable-delivery-persistence.js +138 -0
  74. package/src/wecom/reliable-delivery.js +642 -0
  75. package/src/wecom/reply-output-policy.js +171 -0
  76. package/src/wecom/text-inbound-scheduler.js +6 -1
  77. package/src/wecom/workspace-auto-sender.js +2 -0
@@ -18,6 +18,14 @@ import {
18
18
  webhookSendText,
19
19
  } from "./webhook-bot.js";
20
20
  import { stat } from "node:fs/promises";
21
+ import { inferWecomDeliveryStatus } from "./reliable-delivery.js";
22
+ import { applyWecomReasoningPolicy } from "./reasoning-visibility.js";
23
+ import {
24
+ extractWecomReplyDirectives,
25
+ mergeWecomReplyMediaItems,
26
+ resolveWecomReplyDirectiveMediaItems,
27
+ } from "./reply-output-policy.js";
28
+ import { parseThinkingContent } from "./thinking-parser.js";
21
29
 
22
30
  function assertFunction(name, fn) {
23
31
  if (typeof fn !== "function") {
@@ -30,6 +38,16 @@ export function createWecomBotReplyDeliverer({
30
38
  resolveWecomDeliveryFallbackPolicy,
31
39
  resolveWecomWebhookBotDeliveryPolicy,
32
40
  resolveWecomObservabilityPolicy,
41
+ resolveWecomReasoningPolicy = () => ({
42
+ mode: "separate",
43
+ sendThinkingMessage: true,
44
+ includeInFinalAnswer: false,
45
+ title: "思考过程",
46
+ maxChars: 1200,
47
+ }),
48
+ resolveWecomReplyFormatPolicy = () => ({
49
+ mode: "auto",
50
+ }),
33
51
  resolveWecomBotProxyConfig,
34
52
  resolveWecomBotConfig,
35
53
  resolveWecomBotLongConnectionReplyContext,
@@ -53,6 +71,8 @@ export function createWecomBotReplyDeliverer({
53
71
  extractWorkspacePathsFromText = () => [],
54
72
  resolveWorkspacePathToHost = () => "",
55
73
  recordDeliveryMetric = () => {},
74
+ recordReliableDeliveryOutcome = () => {},
75
+ enqueuePendingReply = () => null,
56
76
  statImpl = stat,
57
77
  fetchImpl = fetch,
58
78
  } = {}) {
@@ -60,6 +80,8 @@ export function createWecomBotReplyDeliverer({
60
80
  assertFunction("resolveWecomDeliveryFallbackPolicy", resolveWecomDeliveryFallbackPolicy);
61
81
  assertFunction("resolveWecomWebhookBotDeliveryPolicy", resolveWecomWebhookBotDeliveryPolicy);
62
82
  assertFunction("resolveWecomObservabilityPolicy", resolveWecomObservabilityPolicy);
83
+ assertFunction("resolveWecomReasoningPolicy", resolveWecomReasoningPolicy);
84
+ assertFunction("resolveWecomReplyFormatPolicy", resolveWecomReplyFormatPolicy);
63
85
  assertFunction("resolveWecomBotProxyConfig", resolveWecomBotProxyConfig);
64
86
  assertFunction("resolveWecomBotConfig", resolveWecomBotConfig);
65
87
  assertFunction("resolveWecomBotLongConnectionReplyContext", resolveWecomBotLongConnectionReplyContext);
@@ -83,6 +105,8 @@ export function createWecomBotReplyDeliverer({
83
105
  assertFunction("extractWorkspacePathsFromText", extractWorkspacePathsFromText);
84
106
  assertFunction("resolveWorkspacePathToHost", resolveWorkspacePathToHost);
85
107
  assertFunction("recordDeliveryMetric", recordDeliveryMetric);
108
+ assertFunction("recordReliableDeliveryOutcome", recordReliableDeliveryOutcome);
109
+ assertFunction("enqueuePendingReply", enqueuePendingReply);
86
110
  assertFunction("statImpl", statImpl);
87
111
 
88
112
  const inlineImageExts = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".heif"]);
@@ -167,38 +191,92 @@ export function createWecomBotReplyDeliverer({
167
191
  streamId,
168
192
  responseUrl,
169
193
  text,
194
+ rawText = "",
170
195
  thinkingContent = "",
196
+ rawThinkingContent = "",
171
197
  routeAgentId = "",
172
198
  mediaUrl,
173
199
  mediaUrls,
200
+ mediaItems,
174
201
  mediaType,
175
202
  reason = "reply",
203
+ allowPendingEnqueue = true,
176
204
  } = {}) {
177
205
  const normalizedAccountId = String(accountId ?? "default").trim().toLowerCase() || "default";
178
206
  const fallbackPolicy = resolveWecomDeliveryFallbackPolicy(api);
179
207
  const webhookBotPolicy = resolveWecomWebhookBotDeliveryPolicy(api);
180
208
  const observabilityPolicy = resolveWecomObservabilityPolicy(api);
209
+ const reasoningPolicy = resolveWecomReasoningPolicy(api);
210
+ const replyFormatPolicy = resolveWecomReplyFormatPolicy(api);
181
211
  const botProxyUrl = resolveWecomBotProxyConfig(api, normalizedAccountId);
182
212
  const botModeConfig = resolveWecomBotConfig(api, normalizedAccountId);
183
213
  const normalizedText = String(text ?? "").trim();
214
+ const normalizedRawText = String(rawText ?? normalizedText).trim();
215
+ const parsedText =
216
+ String(thinkingContent ?? "").trim().length > 0
217
+ ? {
218
+ visibleContent: normalizedText,
219
+ thinkingContent: String(thinkingContent ?? "").trim(),
220
+ }
221
+ : parseThinkingContent(normalizedText);
222
+ const parsedRawText =
223
+ String(rawThinkingContent ?? "").trim().length > 0
224
+ ? {
225
+ visibleContent: normalizedRawText,
226
+ thinkingContent: String(rawThinkingContent ?? "").trim(),
227
+ }
228
+ : parseThinkingContent(normalizedRawText);
229
+ const reasoningPayload = applyWecomReasoningPolicy({
230
+ text: parsedText.visibleContent,
231
+ thinkingContent: parsedText.thinkingContent,
232
+ policy: reasoningPolicy,
233
+ transport: "bot",
234
+ phase: "final",
235
+ });
236
+ const richReasoningPayload = applyWecomReasoningPolicy({
237
+ text: parsedRawText.visibleContent,
238
+ thinkingContent: parsedRawText.thinkingContent,
239
+ policy: reasoningPolicy,
240
+ transport: "bot",
241
+ phase: "final",
242
+ });
243
+ const plainDirectivePayload = extractWecomReplyDirectives(reasoningPayload.text);
244
+ const richDirectivePayload = extractWecomReplyDirectives(richReasoningPayload.text);
245
+ const effectiveText = String(plainDirectivePayload.text ?? "").trim();
246
+ const richEffectiveText = String(richDirectivePayload.text ?? "").trim();
247
+ const effectiveThinkingContent = String(reasoningPayload.thinkingContent ?? "").trim();
248
+ const directiveMediaItems = resolveWecomReplyDirectiveMediaItems({
249
+ mediaItems: richDirectivePayload.mediaItems,
250
+ routeAgentId,
251
+ resolveWorkspacePathToHost,
252
+ });
184
253
  const inlineWorkspaceMediaUrls = await collectInlineWorkspaceImageMediaUrls({
185
- text: normalizedText,
254
+ text: richEffectiveText || effectiveText,
186
255
  routeAgentId,
187
256
  });
188
- const normalizedMediaUrls = normalizeWecomBotOutboundMediaUrls({
257
+ const normalizedMediaItems = mergeWecomReplyMediaItems({
189
258
  mediaUrl,
190
259
  mediaUrls: [...(Array.isArray(mediaUrls) ? mediaUrls : []), ...inlineWorkspaceMediaUrls],
260
+ mediaItems,
261
+ mediaType,
262
+ extraMediaItems: directiveMediaItems,
263
+ });
264
+ const normalizedMediaUrls = normalizeWecomBotOutboundMediaUrls({
265
+ mediaUrls: normalizedMediaItems.map((item) => item.url),
191
266
  });
192
267
  const mixedPayload =
193
268
  normalizedMediaUrls.length > 0
194
269
  ? buildWecomBotMixedPayload({
195
- text: normalizedText,
270
+ text: effectiveText,
196
271
  mediaUrls: normalizedMediaUrls,
197
272
  })
198
273
  : null;
199
- const fallbackText = normalizedText || "已收到模型返回的媒体结果,请查看以下链接。";
274
+ const fallbackText = effectiveText || "已收到模型返回的媒体结果,请查看以下链接。";
200
275
  const cardPayload = buildWecomBotCardPayload({
201
- text: normalizedText || fallbackText,
276
+ text:
277
+ String(replyFormatPolicy?.mode ?? "").trim().toLowerCase() === "markdown" && richEffectiveText
278
+ ? richEffectiveText
279
+ : effectiveText || fallbackText,
202
280
  cardPolicy: botModeConfig?.card,
203
281
  hasMedia: normalizedMediaUrls.length > 0,
204
282
  });
@@ -231,6 +309,7 @@ export function createWecomBotReplyDeliverer({
231
309
  if (normalizedMediaUrls.length > 0) {
232
310
  const processed = await buildActiveStreamMsgItems({
233
311
  mediaUrls: normalizedMediaUrls,
312
+ mediaItems: normalizedMediaItems,
234
313
  mediaType,
235
314
  fetchMediaFromUrl,
236
315
  proxyUrl: botProxyUrl,
@@ -246,7 +325,7 @@ export function createWecomBotReplyDeliverer({
246
325
  if (fallbackMediaUrls.length > 0) {
247
326
  streamContent = `${streamContent}\n\n媒体链接:\n${fallbackMediaUrls.join("\n")}`.trim();
248
327
  }
249
- if (!streamContent && !streamMsgItem.length && !String(thinkingContent ?? "").trim()) {
328
+ if (!streamContent && !streamMsgItem.length && !effectiveThinkingContent) {
250
329
  streamContent = fallbackText;
251
330
  }
252
331
  return pushWecomBotLongConnectionStreamUpdate({
@@ -256,7 +335,7 @@ export function createWecomBotReplyDeliverer({
256
335
  content: streamContent,
257
336
  finish: true,
258
337
  msgItem: streamMsgItem,
259
- thinkingContent,
338
+ thinkingContent: effectiveThinkingContent,
260
339
  });
261
340
  },
262
341
  active_stream: async ({ text: content }) => {
@@ -264,10 +343,10 @@ export function createWecomBotReplyDeliverer({
264
343
  streamId,
265
344
  sessionId: normalizedSessionId,
266
345
  content,
267
- thinkingContent,
346
+ thinkingContent: effectiveThinkingContent,
268
347
  normalizedMediaUrls,
269
348
  mediaType,
270
- normalizedText,
349
+ normalizedText: effectiveText,
271
350
  fallbackText,
272
351
  botProxyUrl,
273
352
  logger: api.logger,
@@ -296,9 +375,13 @@ export function createWecomBotReplyDeliverer({
296
375
  webhookBotPolicy,
297
376
  botProxyUrl,
298
377
  content,
378
+ richContent:
379
+ String(replyFormatPolicy?.mode ?? "").trim().toLowerCase() === "markdown" ? richEffectiveText : "",
380
+ replyFormatMode: replyFormatPolicy?.mode || "auto",
299
381
  fallbackText,
300
- normalizedText,
382
+ normalizedText: effectiveText,
301
383
  normalizedMediaUrls,
384
+ normalizedMediaItems,
302
385
  mediaType,
303
386
  cardPayload,
304
387
  cardPolicy: botModeConfig?.card ?? {},
@@ -318,7 +401,7 @@ export function createWecomBotReplyDeliverer({
318
401
  });
319
402
 
320
403
  const deliveryResult = await router.deliverText({
321
- text: normalizedText || fallbackText,
404
+ text: effectiveText || fallbackText,
322
405
  traceId,
323
406
  meta: {
324
407
  reason,
@@ -328,7 +411,7 @@ export function createWecomBotReplyDeliverer({
328
411
  streamId: streamId || "",
329
412
  hasResponseUrl: Boolean(inlineResponseUrl || cachedResponseUrl?.url),
330
413
  mediaCount: normalizedMediaUrls.length,
331
- hasThinkingContent: Boolean(String(thinkingContent ?? "").trim()),
414
+ hasThinkingContent: Boolean(effectiveThinkingContent),
332
415
  botCardMode: botModeConfig?.card?.enabled ? botModeConfig.card.mode : "off",
333
416
  },
334
417
  });
@@ -336,9 +419,43 @@ export function createWecomBotReplyDeliverer({
336
419
  layer: deliveryResult?.layer || "",
337
420
  ok: deliveryResult?.ok === true,
338
421
  finalStatus: deliveryResult?.finalStatus || "",
422
+ deliveryStatus: deliveryResult?.deliveryStatus || "",
339
423
  accountId: normalizedAccountId,
340
424
  attempts: deliveryResult?.attempts,
341
425
  });
426
+ recordReliableDeliveryOutcome({
427
+ mode: "bot",
428
+ accountId: normalizedAccountId,
429
+ sessionId: normalizedSessionId,
430
+ fromUser,
431
+ deliveryStatus:
432
+ deliveryResult?.deliveryStatus ||
433
+ (deliveryResult?.ok === true
434
+ ? "delivered"
435
+ : inferWecomDeliveryStatus({
436
+ reason: deliveryResult?.error || deliveryResult?.attempts?.slice?.(-1)?.[0]?.reason || "delivery-failed",
437
+ layer: deliveryResult?.layer || deliveryResult?.attempts?.slice?.(-1)?.[0]?.layer || "",
438
+ })),
439
+ layer: deliveryResult?.layer || deliveryResult?.attempts?.slice?.(-1)?.[0]?.layer || "",
440
+ reason: deliveryResult?.error || deliveryResult?.attempts?.slice?.(-1)?.[0]?.reason || reason,
441
+ });
442
+ if (deliveryResult?.ok !== true && allowPendingEnqueue) {
443
+ enqueuePendingReply(api, {
444
+ mode: "bot",
445
+ accountId: normalizedAccountId,
446
+ sessionId: normalizedSessionId,
447
+ fromUser,
448
+ payload: {
449
+ text: effectiveText || fallbackText,
450
+ thinkingContent: effectiveThinkingContent,
451
+ mediaUrls: normalizedMediaUrls,
452
+ mediaItems: normalizedMediaItems,
453
+ mediaType,
454
+ },
455
+ reason: deliveryResult?.error || deliveryResult?.attempts?.slice?.(-1)?.[0]?.reason || reason,
456
+ deliveryStatus: deliveryResult?.deliveryStatus || "rejected_unknown",
457
+ });
458
+ }
342
459
  return deliveryResult;
343
460
  }
344
461
 
@@ -16,6 +16,7 @@ function isSupportedActiveStreamMsgItemImage(buffer) {
16
16
 
17
17
  export async function buildActiveStreamMsgItems({
18
18
  mediaUrls,
19
+ mediaItems,
19
20
  mediaType,
20
21
  fetchMediaFromUrl,
21
22
  proxyUrl,
@@ -23,13 +24,35 @@ export async function buildActiveStreamMsgItems({
23
24
  }) {
24
25
  const msgItem = [];
25
26
  const fallbackUrls = [];
27
+ const rawItems = [
28
+ ...(Array.isArray(mediaUrls) ? mediaUrls : []).map((url) => ({
29
+ url,
30
+ mediaType,
31
+ })),
32
+ ...(Array.isArray(mediaItems) ? mediaItems : []),
33
+ ];
34
+ const dedupe = new Set();
35
+ const candidates = [];
36
+ for (const item of rawItems) {
37
+ const normalizedUrl = String(item?.url ?? "").trim();
38
+ const normalizedType = String(item?.mediaType ?? "").trim().toLowerCase() || undefined;
39
+ if (!normalizedUrl) continue;
40
+ const dedupeKey = `${normalizedType || ""}:${normalizedUrl}`;
41
+ if (dedupe.has(dedupeKey)) continue;
42
+ dedupe.add(dedupeKey);
43
+ candidates.push({
44
+ url: normalizedUrl,
45
+ mediaType: normalizedType,
46
+ });
47
+ }
26
48
 
27
- for (const mediaUrl of mediaUrls) {
49
+ for (const mediaItem of candidates) {
50
+ const mediaUrl = mediaItem.url;
28
51
  if (msgItem.length >= ACTIVE_STREAM_MSG_ITEM_LIMIT) {
29
52
  fallbackUrls.push(mediaUrl);
30
53
  continue;
31
54
  }
32
- const target = resolveWecomOutboundMediaTarget({ mediaUrl, mediaType });
55
+ const target = resolveWecomOutboundMediaTarget({ mediaUrl, mediaType: mediaItem.mediaType ?? mediaType });
33
56
  if (target.type !== "image") {
34
57
  fallbackUrls.push(mediaUrl);
35
58
  continue;
@@ -26,9 +26,12 @@ export function createWecomWebhookBotDeliverer({
26
26
  webhookBotPolicy,
27
27
  botProxyUrl = "",
28
28
  content = "",
29
+ richContent = "",
30
+ replyFormatMode = "auto",
29
31
  fallbackText = "",
30
32
  normalizedText = "",
31
33
  normalizedMediaUrls = [],
34
+ normalizedMediaItems = [],
32
35
  mediaType,
33
36
  cardPayload = null,
34
37
  cardPolicy = {},
@@ -46,6 +49,7 @@ export function createWecomWebhookBotDeliverer({
46
49
 
47
50
  const dispatcher = attachWecomProxyDispatcher(sendUrl, {}, { proxyUrl: botProxyUrl, logger: api?.logger })?.dispatcher;
48
51
  const textPayload = `${content || fallbackText}`.trim();
52
+ const markdownPayload = String(richContent || content || fallbackText).trim();
49
53
  let sentAny = false;
50
54
  let usedCardMode = "";
51
55
 
@@ -82,6 +86,20 @@ export function createWecomWebhookBotDeliverer({
82
86
  }
83
87
  }
84
88
 
89
+ const preferMarkdown = String(replyFormatMode ?? "").trim().toLowerCase() === "markdown";
90
+ if (!sentAny && preferMarkdown && markdownPayload && normalizedMediaUrls.length === 0) {
91
+ await webhookSendMarkdown({
92
+ url: webhookBotPolicy?.url,
93
+ key: webhookBotPolicy?.key,
94
+ content: markdownPayload,
95
+ timeoutMs: webhookBotPolicy?.timeoutMs,
96
+ dispatcher,
97
+ fetchImpl,
98
+ });
99
+ sentAny = true;
100
+ usedCardMode = "markdown";
101
+ }
102
+
85
103
  if (!sentAny && textPayload && (normalizedText || normalizedMediaUrls.length === 0)) {
86
104
  await webhookSendText({
87
105
  url: webhookBotPolicy?.url,
@@ -101,6 +119,7 @@ export function createWecomWebhookBotDeliverer({
101
119
  webhookBotPolicy,
102
120
  proxyUrl: botProxyUrl,
103
121
  mediaUrls: normalizedMediaUrls,
122
+ mediaItems: normalizedMediaItems,
104
123
  mediaType,
105
124
  });
106
125
  sentAny = sentAny || mediaMeta.sentCount > 0;
@@ -32,6 +32,7 @@ export function createWecomWebhookBotMediaSender({
32
32
  webhookBotPolicy,
33
33
  proxyUrl,
34
34
  mediaUrls,
35
+ mediaItems,
35
36
  mediaType,
36
37
  }) {
37
38
  const sendUrl = resolveWebhookBotSendUrl({
@@ -51,12 +52,35 @@ export function createWecomWebhookBotMediaSender({
51
52
  const dispatcher = resolveWebhookDispatcher(attachWecomProxyDispatcher, sendUrl, proxyUrl, logger);
52
53
  let sentCount = 0;
53
54
  const failedUrls = [];
54
- const candidateMediaUrls = Array.isArray(mediaUrls) ? mediaUrls : [];
55
+ const rawItems = [
56
+ ...(Array.isArray(mediaUrls) ? mediaUrls : []).map((url) => ({
57
+ url,
58
+ mediaType,
59
+ })),
60
+ ...(Array.isArray(mediaItems) ? mediaItems : []),
61
+ ];
62
+ const dedupe = new Set();
63
+ const candidateMediaItems = [];
64
+ for (const item of rawItems) {
65
+ const normalizedUrl = String(item?.url ?? "").trim();
66
+ const normalizedType = String(item?.mediaType ?? "").trim().toLowerCase() || undefined;
67
+ if (!normalizedUrl) continue;
68
+ const dedupeKey = `${normalizedType || ""}:${normalizedUrl}`;
69
+ if (dedupe.has(dedupeKey)) continue;
70
+ dedupe.add(dedupeKey);
71
+ candidateMediaItems.push({
72
+ url: normalizedUrl,
73
+ mediaType: normalizedType,
74
+ });
75
+ }
55
76
 
56
- for (const mediaUrl of candidateMediaUrls) {
57
- const target = resolveWecomOutboundMediaTarget({ mediaUrl, mediaType });
77
+ for (const mediaItem of candidateMediaItems) {
78
+ const target = resolveWecomOutboundMediaTarget({
79
+ mediaUrl: mediaItem.url,
80
+ mediaType: mediaItem.mediaType ?? mediaType,
81
+ });
58
82
  try {
59
- const { buffer } = await fetchMediaFromUrl(mediaUrl, {
83
+ const { buffer } = await fetchMediaFromUrl(mediaItem.url, {
60
84
  proxyUrl,
61
85
  logger,
62
86
  forceProxy: Boolean(proxyUrl),
@@ -91,9 +115,9 @@ export function createWecomWebhookBotMediaSender({
91
115
  }
92
116
  sentCount += 1;
93
117
  } catch (err) {
94
- failedUrls.push(mediaUrl);
118
+ failedUrls.push(mediaItem.url);
95
119
  logger?.warn?.(
96
- `wecom(bot): webhook media send failed target=${mediaUrl} type=${target.type} reason=${String(err?.message || err)}`,
120
+ `wecom(bot): webhook media send failed target=${mediaItem.url} type=${target.type} reason=${String(err?.message || err)}`,
97
121
  );
98
122
  }
99
123
  }
@@ -0,0 +1,143 @@
1
+ function assertFunction(name, value) {
2
+ if (typeof value !== "function") {
3
+ throw new Error(`createWecomPendingReplyManager: ${name} is required`);
4
+ }
5
+ }
6
+
7
+ export function createWecomPendingReplyManager({
8
+ reliableDeliveryStore,
9
+ resolveWecomPendingReplyPolicy,
10
+ deliverPendingReply,
11
+ ensurePersistenceLoaded = async () => true,
12
+ schedulePersistenceFlush = () => {},
13
+ logger,
14
+ now = () => Date.now(),
15
+ sweepIntervalMs = 15000,
16
+ } = {}) {
17
+ if (!reliableDeliveryStore || typeof reliableDeliveryStore !== "object") {
18
+ throw new Error("createWecomPendingReplyManager: reliableDeliveryStore is required");
19
+ }
20
+ assertFunction("resolveWecomPendingReplyPolicy", resolveWecomPendingReplyPolicy);
21
+ assertFunction("deliverPendingReply", deliverPendingReply);
22
+ assertFunction("ensurePersistenceLoaded", ensurePersistenceLoaded);
23
+ assertFunction("schedulePersistenceFlush", schedulePersistenceFlush);
24
+ assertFunction("now", now);
25
+
26
+ let sweepTimer = null;
27
+ let sweepPromise = Promise.resolve();
28
+
29
+ function ensureSweepTimer() {
30
+ if (sweepTimer) return;
31
+ sweepTimer = setInterval(() => {
32
+ void flushDuePendingReplies("timer");
33
+ }, Math.max(5000, Number(sweepIntervalMs) || 15000));
34
+ sweepTimer.unref?.();
35
+ }
36
+
37
+ async function initialize(api) {
38
+ await ensurePersistenceLoaded(api);
39
+ if (reliableDeliveryStore.countPendingReplies() > 0) {
40
+ ensureSweepTimer();
41
+ }
42
+ return true;
43
+ }
44
+
45
+ function enqueuePendingReply(api, payload = {}) {
46
+ const policy = resolveWecomPendingReplyPolicy(api);
47
+ if (policy?.enabled !== true) return null;
48
+ const entry = reliableDeliveryStore.enqueuePendingReply({
49
+ ...payload,
50
+ maxRetries: policy.maxRetries,
51
+ retryBackoffMs: policy.retryBackoffMs,
52
+ expireMs: policy.expireMs,
53
+ });
54
+ if (entry) {
55
+ ensureSweepTimer();
56
+ schedulePersistenceFlush("pending-enqueue", api);
57
+ }
58
+ return entry;
59
+ }
60
+
61
+ async function attemptPendingEntry(entry, trigger = "timer") {
62
+ try {
63
+ const result = await deliverPendingReply(entry, trigger);
64
+ if (result?.ok === true) {
65
+ reliableDeliveryStore.markPendingDelivered({ id: entry.id, at: now() });
66
+ schedulePersistenceFlush("pending-delivered");
67
+ return { delivered: true, result };
68
+ }
69
+ const reason = String(result?.deliveryStatus || result?.finalStatus || result?.error || "pending-retry-failed");
70
+ return {
71
+ delivered: false,
72
+ result,
73
+ rescheduled: reliableDeliveryStore.reschedulePendingReply({
74
+ id: entry.id,
75
+ reason,
76
+ at: now(),
77
+ }),
78
+ };
79
+ } catch (err) {
80
+ const reason = String(err?.message || err || "pending-retry-failed");
81
+ logger?.warn?.(`wecom: pending reply retry failed id=${entry.id} trigger=${trigger} reason=${reason}`);
82
+ return {
83
+ delivered: false,
84
+ error: reason,
85
+ rescheduled: reliableDeliveryStore.reschedulePendingReply({
86
+ id: entry.id,
87
+ reason,
88
+ at: now(),
89
+ }),
90
+ };
91
+ }
92
+ }
93
+
94
+ async function flushEntries(entries = [], trigger = "timer") {
95
+ const list = Array.isArray(entries) ? entries : [];
96
+ for (const entry of list) {
97
+ // Keep retries ordered and deterministic.
98
+ // eslint-disable-next-line no-await-in-loop
99
+ await attemptPendingEntry(entry, trigger);
100
+ }
101
+ }
102
+
103
+ function flushDuePendingReplies(trigger = "timer") {
104
+ sweepPromise = sweepPromise
105
+ .then(async () => {
106
+ await initialize();
107
+ reliableDeliveryStore.dropExpiredPendingReplies({ at: now() });
108
+ const entries = reliableDeliveryStore.listDuePendingReplies({ at: now(), limit: 20 });
109
+ await flushEntries(entries, trigger);
110
+ schedulePersistenceFlush(`pending-flush:${trigger}`);
111
+ })
112
+ .catch((err) => {
113
+ logger?.warn?.(`wecom: pending reply sweep failed: ${String(err?.message || err)}`);
114
+ });
115
+ return sweepPromise;
116
+ }
117
+
118
+ function flushSessionPendingReplies({ mode = "agent", accountId = "default", sessionId = "" } = {}) {
119
+ return ensurePersistenceLoaded()
120
+ .then(() =>
121
+ reliableDeliveryStore.listPendingRepliesForSession({
122
+ mode,
123
+ accountId,
124
+ sessionId,
125
+ }),
126
+ )
127
+ .then((entries) => {
128
+ if (entries.length === 0) return null;
129
+ return flushEntries(entries, "session-inbound");
130
+ })
131
+ .then(() => {
132
+ schedulePersistenceFlush("pending-session-flush");
133
+ });
134
+ }
135
+
136
+ return {
137
+ initialize,
138
+ enqueuePendingReply,
139
+ flushDuePendingReplies,
140
+ flushSessionPendingReplies,
141
+ ensureSweepTimer,
142
+ };
143
+ }
@@ -6,6 +6,7 @@ import { createWecomCommandHandlers } from "./command-handlers.js";
6
6
  import { createWecomPolicyResolvers } from "./policy-resolvers.js";
7
7
  import { createWecomVoiceTranscriber } from "./voice-transcription.js";
8
8
  import {
9
+ buildWecomSessionId,
9
10
  isLocalVoiceInputTypeDirectlySupported,
10
11
  normalizeAudioContentType,
11
12
  normalizeWecomWebhookTargetMap,
@@ -19,6 +20,10 @@ import {
19
20
  resolveWecomDmPolicyConfig,
20
21
  resolveWecomEventPolicyConfig,
21
22
  resolveWecomDeliveryFallbackConfig,
23
+ resolveWecomPendingReplyConfig,
24
+ resolveWecomQuotaTrackingConfig,
25
+ resolveWecomReasoningConfig,
26
+ resolveWecomReplyFormatConfig,
22
27
  resolveWecomDynamicAgentConfig,
23
28
  resolveWecomGroupChatConfig,
24
29
  resolveWecomObservabilityConfig,
@@ -27,11 +32,13 @@ import {
27
32
  resolveWecomStreamingConfig,
28
33
  resolveWecomWebhookBotDeliveryConfig,
29
34
  } from "../core.js";
35
+ import { buildWecomBotSessionId } from "./runtime-utils.js";
30
36
 
31
37
  export function createWecomPluginAccountPolicyServices({
32
38
  processEnv = process.env,
33
39
  getGatewayRuntime,
34
40
  getWecomObservabilityMetrics = () => ({}),
41
+ getWecomReliableDeliverySnapshot = () => null,
35
42
  normalizeWecomResolvedTarget,
36
43
  formatWecomTargetForLog,
37
44
  sendWecomWebhookText,
@@ -82,6 +89,10 @@ export function createWecomPluginAccountPolicyServices({
82
89
  resolveWecomTextDebouncePolicy,
83
90
  resolveWecomReplyStreamingPolicy,
84
91
  resolveWecomDeliveryFallbackPolicy,
92
+ resolveWecomPendingReplyPolicy,
93
+ resolveWecomQuotaTrackingPolicy,
94
+ resolveWecomReasoningPolicy,
95
+ resolveWecomReplyFormatPolicy,
85
96
  resolveWecomWebhookBotDeliveryPolicy,
86
97
  resolveWecomStreamManagerPolicy,
87
98
  resolveWecomObservabilityPolicy,
@@ -100,6 +111,10 @@ export function createWecomPluginAccountPolicyServices({
100
111
  resolveWecomDebounceConfig,
101
112
  resolveWecomStreamingConfig,
102
113
  resolveWecomDeliveryFallbackConfig,
114
+ resolveWecomPendingReplyConfig,
115
+ resolveWecomQuotaTrackingConfig,
116
+ resolveWecomReasoningConfig,
117
+ resolveWecomReplyFormatConfig,
103
118
  resolveWecomWebhookBotDeliveryConfig,
104
119
  resolveWecomStreamManagerConfig,
105
120
  resolveWecomObservabilityConfig,
@@ -134,10 +149,17 @@ export function createWecomPluginAccountPolicyServices({
134
149
  resolveWecomTextDebouncePolicy,
135
150
  resolveWecomReplyStreamingPolicy,
136
151
  resolveWecomDeliveryFallbackPolicy,
152
+ resolveWecomPendingReplyPolicy,
153
+ resolveWecomQuotaTrackingPolicy,
154
+ resolveWecomReasoningPolicy,
155
+ resolveWecomReplyFormatPolicy,
137
156
  resolveWecomStreamManagerPolicy,
138
157
  resolveWecomWebhookBotDeliveryPolicy,
139
158
  resolveWecomDynamicAgentPolicy,
140
159
  resolveWecomBotConfig,
160
+ buildWecomSessionId,
161
+ buildWecomBotSessionId,
162
+ getWecomReliableDeliverySnapshot,
141
163
  getWecomObservabilityMetrics,
142
164
  pluginVersion: PLUGIN_VERSION,
143
165
  });
@@ -162,6 +184,10 @@ export function createWecomPluginAccountPolicyServices({
162
184
  resolveWecomTextDebouncePolicy,
163
185
  resolveWecomReplyStreamingPolicy,
164
186
  resolveWecomDeliveryFallbackPolicy,
187
+ resolveWecomPendingReplyPolicy,
188
+ resolveWecomQuotaTrackingPolicy,
189
+ resolveWecomReasoningPolicy,
190
+ resolveWecomReplyFormatPolicy,
165
191
  resolveWecomWebhookBotDeliveryPolicy,
166
192
  resolveWecomStreamManagerPolicy,
167
193
  resolveWecomObservabilityPolicy,