@yanhaidao/wecom 2.3.270 → 2.4.120

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 (49) hide show
  1. package/MENU_EVENT_CONF.md +500 -0
  2. package/MENU_EVENT_PLAN.md +440 -0
  3. package/README.md +80 -3
  4. package/UPSTREAM_CONFIG.md +170 -0
  5. package/UPSTREAM_PLAN.md +175 -0
  6. package/changelog/v2.4.12.md +37 -0
  7. package/package.json +1 -1
  8. package/scripts/wecom/README.md +123 -0
  9. package/scripts/wecom/menu-click-help.js +59 -0
  10. package/scripts/wecom/menu-click-help.py +55 -0
  11. package/src/agent/event-router.test.ts +421 -0
  12. package/src/agent/event-router.ts +272 -0
  13. package/src/agent/handler.event-filter.test.ts +65 -1
  14. package/src/agent/handler.ts +375 -21
  15. package/src/agent/script-runner.ts +186 -0
  16. package/src/agent/test-fixtures/invalid-json-script.mjs +1 -0
  17. package/src/agent/test-fixtures/reply-event-script.mjs +29 -0
  18. package/src/agent/test-fixtures/reply-event-script.py +17 -0
  19. package/src/app/account-runtime.ts +1 -1
  20. package/src/capability/agent/upstream-delivery-service.ts +96 -0
  21. package/src/capability/bot/sandbox-media.test.ts +221 -0
  22. package/src/capability/bot/sandbox-media.ts +176 -0
  23. package/src/capability/bot/stream-orchestrator.ts +19 -0
  24. package/src/channel.config.test.ts +33 -0
  25. package/src/channel.meta.test.ts +10 -0
  26. package/src/channel.ts +4 -1
  27. package/src/config/accounts.ts +16 -0
  28. package/src/config/schema.ts +58 -0
  29. package/src/context-store.ts +41 -8
  30. package/src/outbound.test.ts +211 -2
  31. package/src/outbound.ts +323 -70
  32. package/src/runtime/session-manager.test.ts +39 -0
  33. package/src/runtime/session-manager.ts +17 -0
  34. package/src/runtime/source-registry.ts +5 -0
  35. package/src/shared/media-asset.ts +78 -0
  36. package/src/shared/media-service.test.ts +111 -0
  37. package/src/shared/media-service.ts +42 -14
  38. package/src/target.ts +40 -0
  39. package/src/transport/agent-api/client.ts +233 -0
  40. package/src/transport/agent-api/core.ts +101 -5
  41. package/src/transport/agent-api/upstream-delivery.ts +45 -0
  42. package/src/transport/agent-api/upstream-media-upload.ts +70 -0
  43. package/src/transport/agent-api/upstream-reply.ts +43 -0
  44. package/src/types/account.ts +2 -0
  45. package/src/types/config.ts +74 -0
  46. package/src/types/message.ts +2 -0
  47. package/src/upstream/index.ts +150 -0
  48. package/src/upstream.test.ts +84 -0
  49. package/vitest.config.ts +15 -4
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { shouldProcessAgentInboundMessage } from "./handler.js";
3
+ import { shouldProcessAgentInboundMessage, shouldSuppressAgentReplyText } from "./handler.js";
4
4
 
5
5
  describe("shouldProcessAgentInboundMessage", () => {
6
6
  it("allows enter_agent/subscribe through the filter (handled earlier by static welcome)", () => {
@@ -31,6 +31,41 @@ describe("shouldProcessAgentInboundMessage", () => {
31
31
  expect(unknown.reason).toBe("event:some_random_event");
32
32
  });
33
33
 
34
+ it("blocks event processing when eventEnabled is false", () => {
35
+ const disabled = shouldProcessAgentInboundMessage({
36
+ msgType: "event",
37
+ eventType: "click",
38
+ fromUser: "zhangsan",
39
+ eventEnabled: false,
40
+ });
41
+ expect(disabled.shouldProcess).toBe(false);
42
+ expect(disabled.reason).toBe("event_disabled");
43
+ });
44
+
45
+ it("allows configured custom event types", () => {
46
+ const custom = shouldProcessAgentInboundMessage({
47
+ msgType: "event",
48
+ eventType: "click",
49
+ fromUser: "zhangsan",
50
+ eventEnabled: true,
51
+ allowedEventTypes: ["click"],
52
+ });
53
+ expect(custom.shouldProcess).toBe(true);
54
+ expect(custom.reason).toBe("allowed_event:click");
55
+ });
56
+
57
+ it("normalizes configured event type values before matching", () => {
58
+ const custom = shouldProcessAgentInboundMessage({
59
+ msgType: "event",
60
+ eventType: "view_miniprogram",
61
+ fromUser: "zhangsan",
62
+ eventEnabled: true,
63
+ allowedEventTypes: [" VIEW_MINIPROGRAM "],
64
+ });
65
+ expect(custom.shouldProcess).toBe(true);
66
+ expect(custom.reason).toBe("allowed_event:view_miniprogram");
67
+ });
68
+
34
69
  it("skips system sender callbacks", () => {
35
70
  const systemSender = shouldProcessAgentInboundMessage({
36
71
  msgType: "text",
@@ -69,3 +104,32 @@ describe("shouldProcessAgentInboundMessage", () => {
69
104
  expect(normalMessage.reason).toBe("user_message");
70
105
  });
71
106
  });
107
+
108
+ describe("shouldSuppressAgentReplyText", () => {
109
+ it("keeps plain text replies when no media reply has been seen", () => {
110
+ expect(
111
+ shouldSuppressAgentReplyText({
112
+ text: "这里是正常文本",
113
+ mediaReplySeen: false,
114
+ }),
115
+ ).toBe(false);
116
+ });
117
+
118
+ it("suppresses companion text once the reply flow includes media", () => {
119
+ expect(
120
+ shouldSuppressAgentReplyText({
121
+ text: "文件已发送,请查收",
122
+ mediaReplySeen: true,
123
+ }),
124
+ ).toBe(true);
125
+ });
126
+
127
+ it("does not suppress empty text even after media replies", () => {
128
+ expect(
129
+ shouldSuppressAgentReplyText({
130
+ text: " ",
131
+ mediaReplySeen: true,
132
+ }),
133
+ ).toBe(false);
134
+ });
135
+ });
@@ -15,6 +15,7 @@ import {
15
15
  shouldUseDynamicAgent,
16
16
  ensureDynamicAgentListed,
17
17
  } from "../dynamic-agent.js";
18
+ import { setPeerContext } from "../context-store.js";
18
19
  import { getWecomRuntime } from "../runtime.js";
19
20
  import { registerWecomSourceSnapshot } from "../runtime/source-registry.js";
20
21
  import {
@@ -30,16 +31,28 @@ import {
30
31
  extractMsgId,
31
32
  extractFileName,
32
33
  extractAgentId,
34
+ extractToUser,
33
35
  } from "../shared/xml-parser.js";
34
- import { downloadAgentApiMedia, sendAgentApiText } from "../transport/agent-api/client.js";
36
+ import { routeAgentInboundEvent } from "./event-router.js";
37
+ import { resolveOutboundMediaAsset } from "../shared/media-asset.js";
38
+ import {
39
+ downloadAgentApiMedia,
40
+ downloadUpstreamAgentApiMedia,
41
+ sendAgentApiText,
42
+ sendUpstreamAgentApiText,
43
+ } from "../transport/agent-api/client.js";
44
+ import { deliverAgentApiMedia } from "../transport/agent-api/delivery.js";
45
+ import { deliverUpstreamAgentApiMedia } from "../transport/agent-api/upstream-delivery.js";
35
46
  import type {
36
47
  ResolvedAgentAccount,
48
+ ReplyPayload,
37
49
  UnifiedInboundEvent,
38
50
  WecomInboundKind,
39
51
  } from "../types/index.js";
40
52
  import type { WecomAgentInboundMessage } from "../types/index.js";
41
53
  import type { TransportSessionPatch } from "../types/index.js";
42
54
  import type { WecomRuntimeAuditEvent } from "../types/runtime-context.js";
55
+ import { detectUpstreamUser, createUpstreamAgentConfig, resolveUpstreamCorpConfig } from "../upstream/index.js";
43
56
 
44
57
  /** 错误提示信息 */
45
58
  const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
@@ -123,6 +136,11 @@ function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefi
123
136
  return truncated;
124
137
  }
125
138
 
139
+ function readContextSessionId(ctx: { SessionId?: string } | Record<string, unknown>): string | undefined {
140
+ const sessionId = "SessionId" in ctx ? ctx.SessionId : undefined;
141
+ return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
142
+ }
143
+
126
144
  /**
127
145
  * **AgentWebhookParams (Webhook 处理器参数)**
128
146
  *
@@ -175,6 +193,8 @@ export function shouldProcessAgentInboundMessage(params: {
175
193
  fromUser: string;
176
194
  chatId?: string;
177
195
  eventType?: string;
196
+ eventEnabled?: boolean;
197
+ allowedEventTypes?: string[];
178
198
  }): AgentInboundProcessDecision {
179
199
  const msgType = String(params.msgType ?? "")
180
200
  .trim()
@@ -187,7 +207,8 @@ export function shouldProcessAgentInboundMessage(params: {
187
207
  .toLowerCase();
188
208
 
189
209
  if (msgType === "event") {
190
- const allowedEvents = [
210
+ // 兼容旧行为:未配置 event 策略时,继续沿用历史白名单
211
+ const compatibilityAllowedEvents = [
191
212
  "subscribe",
192
213
  "enter_agent",
193
214
  "batch_job_result",
@@ -203,6 +224,26 @@ export function shouldProcessAgentInboundMessage(params: {
203
224
  "smartsheet_field_change",
204
225
  "smartsheet_view_change",
205
226
  ];
227
+ const configuredAllowedEvents = Array.isArray(params.allowedEventTypes)
228
+ ? params.allowedEventTypes
229
+ .map((entry) => String(entry ?? "").trim().toLowerCase())
230
+ .filter(Boolean)
231
+ : [];
232
+ const hasEventConfig = params.eventEnabled !== undefined || configuredAllowedEvents.length > 0;
233
+
234
+ // 显式关闭 event 时直接拒绝,优先级最高
235
+ if (params.eventEnabled === false) {
236
+ return {
237
+ shouldProcess: false,
238
+ reason: "event_disabled",
239
+ };
240
+ }
241
+
242
+ // 配置存在时:历史白名单 + 配置白名单并集,保证平滑迁移
243
+ const allowedEvents = hasEventConfig
244
+ ? Array.from(new Set([...compatibilityAllowedEvents, ...configuredAllowedEvents]))
245
+ : compatibilityAllowedEvents;
246
+
206
247
  if (
207
248
  allowedEvents.includes(eventType) ||
208
249
  eventType.startsWith("doc_") ||
@@ -246,6 +287,13 @@ export function shouldProcessAgentInboundMessage(params: {
246
287
  };
247
288
  }
248
289
 
290
+ export function shouldSuppressAgentReplyText(params: {
291
+ text: string;
292
+ mediaReplySeen: boolean;
293
+ }): boolean {
294
+ return params.mediaReplySeen && Boolean(params.text.trim());
295
+ }
296
+
249
297
  function normalizeAgentId(value: unknown): number | undefined {
250
298
  if (typeof value === "number" && Number.isFinite(value)) return value;
251
299
  const raw = String(value ?? "").trim();
@@ -254,6 +302,72 @@ function normalizeAgentId(value: unknown): number | undefined {
254
302
  return Number.isFinite(parsed) ? parsed : undefined;
255
303
  }
256
304
 
305
+ function resolveAgentReplyTransportContext(params: {
306
+ agent: ResolvedAgentAccount;
307
+ msg: WecomAgentInboundMessage;
308
+ fromUser: string;
309
+ chatId?: string;
310
+ log?: (msg: string) => void;
311
+ error?: (msg: string) => void;
312
+ }): {
313
+ upstreamAgent?: ResolvedAgentAccount;
314
+ primaryAgentForUpstream?: ResolvedAgentAccount;
315
+ upstreamReplyTarget?: { toUser: string | undefined; chatId: string | undefined };
316
+ effectiveReplyTarget: { toUser: string | undefined; chatId: string | undefined };
317
+ } {
318
+ // 事件路由也可能需要即时回包,这里复用普通消息的上下游目标判定逻辑
319
+ const { agent, msg, fromUser, chatId, log, error } = params;
320
+ const isGroup = Boolean(chatId);
321
+ const peerId = isGroup ? chatId! : fromUser;
322
+ const replyTarget = isGroup
323
+ ? ({ toUser: undefined, chatId: peerId } as const)
324
+ : ({ toUser: fromUser, chatId: undefined } as const);
325
+ const toUserName = extractToUser(msg);
326
+ const isUpstreamUser = detectUpstreamUser({
327
+ messageToUserName: toUserName,
328
+ primaryCorpId: agent.corpId,
329
+ });
330
+
331
+ if (!isUpstreamUser) {
332
+ return {
333
+ upstreamAgent: undefined,
334
+ primaryAgentForUpstream: undefined,
335
+ upstreamReplyTarget: undefined,
336
+ effectiveReplyTarget: replyTarget,
337
+ };
338
+ }
339
+
340
+ log?.(`[wecom-agent] detected upstream user during event routing: from=${fromUser} toCorpId=${toUserName}`);
341
+ const upstreamConfig = resolveUpstreamCorpConfig({
342
+ upstreamCorpId: toUserName,
343
+ upstreamCorps: agent.config.upstreamCorps,
344
+ });
345
+ if (!upstreamConfig) {
346
+ error?.(
347
+ `[wecom-agent] upstream event detected but no upstream config for corpId=${toUserName}; fallback to primary agent target`,
348
+ );
349
+ return {
350
+ upstreamAgent: undefined,
351
+ primaryAgentForUpstream: undefined,
352
+ upstreamReplyTarget: undefined,
353
+ effectiveReplyTarget: replyTarget,
354
+ };
355
+ }
356
+
357
+ const upstreamAgent = createUpstreamAgentConfig({
358
+ baseAgent: agent,
359
+ upstreamCorpId: toUserName,
360
+ upstreamAgentId: upstreamConfig.agentId,
361
+ });
362
+
363
+ return {
364
+ upstreamAgent,
365
+ primaryAgentForUpstream: agent,
366
+ upstreamReplyTarget: replyTarget,
367
+ effectiveReplyTarget: replyTarget,
368
+ };
369
+ }
370
+
257
371
  /**
258
372
  * **resolveQueryParams (解析查询参数)**
259
373
  *
@@ -367,6 +481,8 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
367
481
  fromUser,
368
482
  chatId,
369
483
  eventType,
484
+ eventEnabled: agent.eventEnabled,
485
+ allowedEventTypes: agent.allowedEventTypes,
370
486
  });
371
487
  if (!decision.shouldProcess) {
372
488
  log?.(
@@ -375,6 +491,57 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
375
491
  return true;
376
492
  }
377
493
 
494
+ const routedEvent = await routeAgentInboundEvent({
495
+ agent,
496
+ msgType,
497
+ eventType,
498
+ fromUser,
499
+ chatId,
500
+ msg,
501
+ log,
502
+ auditSink,
503
+ });
504
+ // 路由器返回文本时,先即时回包给用户/群,再决定是否进入默认 AI 流程
505
+ if (routedEvent.handled && routedEvent.replyText?.trim()) {
506
+ const replyContext = resolveAgentReplyTransportContext({
507
+ agent,
508
+ msg,
509
+ fromUser,
510
+ chatId,
511
+ log,
512
+ error,
513
+ });
514
+ try {
515
+ if (replyContext.upstreamAgent && replyContext.primaryAgentForUpstream) {
516
+ await sendUpstreamAgentApiText({
517
+ upstreamAgent: replyContext.upstreamAgent,
518
+ primaryAgent: replyContext.primaryAgentForUpstream,
519
+ ...(replyContext.upstreamReplyTarget ?? replyContext.effectiveReplyTarget),
520
+ text: routedEvent.replyText,
521
+ });
522
+ } else {
523
+ await sendAgentApiText({
524
+ agent,
525
+ ...replyContext.effectiveReplyTarget,
526
+ text: routedEvent.replyText,
527
+ });
528
+ }
529
+ params.touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
530
+ log?.(
531
+ `[wecom-agent] event route reply delivered routeId=${routedEvent.matchedRouteId ?? "N/A"} to=${chatId ? `chat:${chatId}` : fromUser}`,
532
+ );
533
+ } catch (err) {
534
+ error?.(`[wecom-agent] event route reply failed: ${String(err)}`);
535
+ }
536
+ }
537
+ // routedEvent 已完全消费该事件时,终止后续默认处理链
538
+ if (routedEvent.handled && !routedEvent.chainToAgent) {
539
+ log?.(
540
+ `[wecom-agent] event route handled routeId=${routedEvent.matchedRouteId ?? "N/A"} reason=${routedEvent.reason}`,
541
+ );
542
+ return true;
543
+ }
544
+
378
545
  // 异步处理消息
379
546
  processAgentMessage({
380
547
  agent,
@@ -396,9 +563,11 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
396
563
  return true;
397
564
  } catch (err) {
398
565
  error?.(`[wecom-agent] callback failed: ${String(err)}`);
399
- res.statusCode = 400;
400
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
401
- res.end(`error - 回调处理失败${ERROR_HELP}`);
566
+ if (!res.headersSent && !res.writableEnded) {
567
+ res.statusCode = 400;
568
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
569
+ res.end(`error - 回调处理失败${ERROR_HELP}`);
570
+ }
402
571
  return true;
403
572
  }
404
573
  }
@@ -448,10 +617,48 @@ async function processAgentMessage(params: {
448
617
  const replyTarget = isGroup
449
618
  ? ({ toUser: undefined, chatId: peerId } as const)
450
619
  : ({ toUser: fromUser, chatId: undefined } as const);
620
+ let upstreamAgent: typeof agent | undefined;
621
+ let upstreamReplyTarget: typeof replyTarget | undefined;
622
+ let primaryAgentForUpstream: typeof agent | undefined;
451
623
  const eventType = String(msg.Event ?? "")
452
624
  .trim()
453
625
  .toLowerCase();
454
626
 
627
+ // 检测是否是上下游用户
628
+ const toUserName = extractToUser(msg);
629
+ const isUpstreamUser = detectUpstreamUser({
630
+ messageToUserName: toUserName,
631
+ primaryCorpId: agent.corpId,
632
+ });
633
+
634
+ if (isUpstreamUser) {
635
+ log?.(
636
+ `[wecom-agent] detected upstream user: from=${fromUser} toCorpId=${toUserName}`,
637
+ );
638
+
639
+ // 查找上下游配置,构建上游 Agent 配置
640
+ const upstreamConfig = resolveUpstreamCorpConfig({
641
+ upstreamCorpId: toUserName,
642
+ upstreamCorps: agent.config.upstreamCorps,
643
+ });
644
+ if (upstreamConfig) {
645
+ upstreamAgent = createUpstreamAgentConfig({
646
+ baseAgent: agent,
647
+ upstreamCorpId: toUserName,
648
+ upstreamAgentId: upstreamConfig.agentId,
649
+ });
650
+ primaryAgentForUpstream = agent;
651
+ // 上下游的 replyTarget 与普通 DM 一致(toUser = fromUser)
652
+ upstreamReplyTarget = isGroup
653
+ ? ({ toUser: undefined, chatId: peerId } as const)
654
+ : ({ toUser: fromUser, chatId: undefined } as const);
655
+ } else {
656
+ error?.(
657
+ `[wecom-agent] upstream user detected but no upstream config for corpId=${toUserName}; fallback to primary agent target`,
658
+ );
659
+ }
660
+ }
661
+
455
662
  const resolveInboundKind = (): WecomInboundKind => {
456
663
  if (msgType === "event") {
457
664
  if (eventType === "subscribe" || eventType === "enter_agent") return "welcome";
@@ -497,7 +704,15 @@ async function processAgentMessage(params: {
497
704
  buffer,
498
705
  contentType,
499
706
  filename: headerFileName,
500
- } = await downloadAgentApiMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
707
+ } =
708
+ upstreamAgent && primaryAgentForUpstream
709
+ ? await downloadUpstreamAgentApiMedia({
710
+ upstreamAgent,
711
+ primaryAgent: primaryAgentForUpstream,
712
+ mediaId,
713
+ maxBytes: mediaMaxBytes,
714
+ })
715
+ : await downloadAgentApiMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
501
716
  const xmlFileName = extractFileName(msg);
502
717
  const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
503
718
  const heuristic = analyzeTextHeuristic(buffer);
@@ -633,7 +848,16 @@ async function processAgentMessage(params: {
633
848
  `[wecom-agent] routing guard: blocked default fallback accountId=${agent.accountId} matchedBy=${route.matchedBy} from=${fromUser}`,
634
849
  );
635
850
  try {
636
- await sendAgentApiText({ agent, ...replyTarget, text: prompt });
851
+ if (upstreamAgent) {
852
+ await sendUpstreamAgentApiText({
853
+ upstreamAgent,
854
+ primaryAgent: primaryAgentForUpstream!,
855
+ ...(upstreamReplyTarget ?? replyTarget),
856
+ text: prompt,
857
+ });
858
+ } else {
859
+ await sendAgentApiText({ agent, ...replyTarget, text: prompt });
860
+ }
637
861
  touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
638
862
  log?.(`[wecom-agent] routing guard prompt delivered to ${fromUser}`);
639
863
  } catch (err: unknown) {
@@ -710,7 +934,16 @@ async function processAgentMessage(params: {
710
934
  scope: "agent",
711
935
  });
712
936
  try {
713
- await sendAgentApiText({ agent, ...replyTarget, text: prompt });
937
+ if (upstreamAgent) {
938
+ await sendUpstreamAgentApiText({
939
+ upstreamAgent,
940
+ primaryAgent: primaryAgentForUpstream!,
941
+ ...(upstreamReplyTarget ?? replyTarget),
942
+ text: prompt,
943
+ });
944
+ } else {
945
+ await sendAgentApiText({ agent, ...replyTarget, text: prompt });
946
+ }
714
947
  touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
715
948
  log?.(
716
949
  `[wecom-agent] unauthorized command: replied to ${isGroup ? `chat:${peerId}` : fromUser}`,
@@ -756,6 +989,27 @@ async function processAgentMessage(params: {
756
989
  MediaType: mediaType,
757
990
  MediaUrl: mediaPath,
758
991
  });
992
+ const sessionId = readContextSessionId(ctxPayload);
993
+
994
+ log?.(
995
+ `[wecom-agent] session bound: sessionKey=${ctxPayload.SessionKey ?? route.sessionKey} sessionId=${sessionId ?? "N/A"} peer=${peerId} upstream=${String(Boolean(upstreamAgent))}`,
996
+ );
997
+
998
+ registerWecomSourceSnapshot({
999
+ accountId: agent.accountId,
1000
+ source: "agent-callback",
1001
+ messageId: extractMsgId(msg) ?? undefined,
1002
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
1003
+ sessionId,
1004
+ peerKind: isGroup ? "group" : "direct",
1005
+ peerId,
1006
+ upstreamCorpId: upstreamAgent?.corpId,
1007
+ });
1008
+ setPeerContext(agent.accountId, peerId, {
1009
+ peerKind: isGroup ? "group" : "direct",
1010
+ lastSeen: Date.now(),
1011
+ upstreamCorpId: upstreamAgent?.corpId,
1012
+ });
759
1013
 
760
1014
  // 记录会话
761
1015
  await core.channel.session.recordInboundSession({
@@ -769,14 +1023,25 @@ async function processAgentMessage(params: {
769
1023
 
770
1024
  // 5秒无响应自动回复进度提示
771
1025
  let hasResponseSent = false;
1026
+ const effectiveAgent = upstreamAgent ?? agent;
1027
+ const effectiveReplyTarget = upstreamReplyTarget ?? replyTarget;
772
1028
  const processingTimer = setTimeout(async () => {
773
1029
  if (hasResponseSent) return;
774
1030
  try {
775
- await sendAgentApiText({
776
- agent,
777
- ...replyTarget,
778
- text: "正在处理中,请稍候...",
779
- });
1031
+ if (upstreamAgent && primaryAgentForUpstream) {
1032
+ await sendUpstreamAgentApiText({
1033
+ upstreamAgent,
1034
+ primaryAgent: primaryAgentForUpstream,
1035
+ ...effectiveReplyTarget,
1036
+ text: "正在处理中,请稍候...",
1037
+ });
1038
+ } else {
1039
+ await sendAgentApiText({
1040
+ agent: effectiveAgent,
1041
+ ...effectiveReplyTarget,
1042
+ text: "正在处理中,请稍候...",
1043
+ });
1044
+ }
780
1045
  log?.(
781
1046
  `[wecom-agent] sent processing notification to ${isGroup ? `chat:${peerId}` : fromUser}`,
782
1047
  );
@@ -787,6 +1052,25 @@ async function processAgentMessage(params: {
787
1052
 
788
1053
  // 发送队列锁:确保所有 deliver 调用(以及内部的分片发送)严格串行执行
789
1054
  let messageSendQueue = Promise.resolve();
1055
+ let deferredMediaUrls: string[] = [];
1056
+
1057
+ const mergeDeferredMediaUrls = (mediaUrls: string[]): string[] => {
1058
+ if (mediaUrls.length === 0) {
1059
+ return deferredMediaUrls;
1060
+ }
1061
+ const merged = [...deferredMediaUrls];
1062
+ for (const mediaUrl of mediaUrls) {
1063
+ if (!merged.includes(mediaUrl)) {
1064
+ merged.push(mediaUrl);
1065
+ }
1066
+ }
1067
+ deferredMediaUrls = merged;
1068
+ return deferredMediaUrls;
1069
+ };
1070
+
1071
+ const replyWecomTarget = effectiveReplyTarget.chatId
1072
+ ? ({ chatid: effectiveReplyTarget.chatId } as const)
1073
+ : ({ touser: effectiveReplyTarget.toUser } as const);
790
1074
 
791
1075
  try {
792
1076
  // 调度回复
@@ -797,10 +1081,20 @@ async function processAgentMessage(params: {
797
1081
  disableBlockStreaming: false,
798
1082
  },
799
1083
  dispatcherOptions: {
800
- deliver: async (payload: { text?: string }, info: { kind: string }) => {
1084
+ deliver: async (payload: ReplyPayload, info: { kind: string }) => {
801
1085
  const text = payload.text ?? "";
802
- // 忽略空文本消息
803
- if (!text || !text.trim()) {
1086
+ const incomingMediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
1087
+ if (info.kind !== "final" && incomingMediaUrls.length > 0) {
1088
+ mergeDeferredMediaUrls(incomingMediaUrls);
1089
+ }
1090
+ const mediaUrls =
1091
+ info.kind === "final"
1092
+ ? mergeDeferredMediaUrls(incomingMediaUrls)
1093
+ : incomingMediaUrls;
1094
+
1095
+ const outboundText = text;
1096
+
1097
+ if ((!outboundText || !outboundText.trim()) && mediaUrls.length === 0) {
804
1098
  return;
805
1099
  }
806
1100
 
@@ -813,18 +1107,27 @@ async function processAgentMessage(params: {
813
1107
  const currentTask = async () => {
814
1108
  const MAX_CHUNK_SIZE = 600;
815
1109
  // 确保分片顺序发送
816
- for (let i = 0; i < text.length; i += MAX_CHUNK_SIZE) {
817
- const chunk = text.slice(i, i + MAX_CHUNK_SIZE);
1110
+ for (let i = 0; i < outboundText.length; i += MAX_CHUNK_SIZE) {
1111
+ const chunk = outboundText.slice(i, i + MAX_CHUNK_SIZE);
818
1112
 
819
1113
  try {
820
- await sendAgentApiText({ agent, ...replyTarget, text: chunk });
1114
+ if (upstreamAgent) {
1115
+ await sendUpstreamAgentApiText({
1116
+ upstreamAgent,
1117
+ primaryAgent: primaryAgentForUpstream!,
1118
+ ...effectiveReplyTarget,
1119
+ text: chunk,
1120
+ });
1121
+ } else {
1122
+ await sendAgentApiText({ agent: effectiveAgent, ...effectiveReplyTarget, text: chunk });
1123
+ }
821
1124
  touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
822
1125
  log?.(
823
- `[wecom-agent] reply chunk delivered (${info.kind}) to ${isGroup ? `chat:${peerId}` : fromUser}, len=${chunk.length}`,
1126
+ `[wecom-agent] reply chunk delivered (${info.kind}) to ${isGroup ? `chat:${peerId}` : fromUser}, len=${chunk.length}, sessionKey=${ctxPayload.SessionKey ?? route.sessionKey}, sessionId=${sessionId ?? "N/A"}`,
824
1127
  );
825
1128
 
826
1129
  // 强制延时:确保企业微信有足够时间处理顺序(优化:200ms → 50ms)
827
- if (i + MAX_CHUNK_SIZE < text.length) {
1130
+ if (i + MAX_CHUNK_SIZE < outboundText.length) {
828
1131
  await new Promise((resolve) => setTimeout(resolve, 50));
829
1132
  }
830
1133
  } catch (err: unknown) {
@@ -847,6 +1150,57 @@ async function processAgentMessage(params: {
847
1150
  }
848
1151
  }
849
1152
 
1153
+ if (info.kind === "final") {
1154
+ for (const mediaUrl of mediaUrls) {
1155
+ try {
1156
+ const media = await resolveOutboundMediaAsset({
1157
+ mediaUrl,
1158
+ network: effectiveAgent.network,
1159
+ });
1160
+ if (upstreamAgent) {
1161
+ await deliverUpstreamAgentApiMedia({
1162
+ upstreamAgent,
1163
+ primaryAgent: primaryAgentForUpstream!,
1164
+ target: replyWecomTarget,
1165
+ buffer: media.buffer,
1166
+ filename: media.filename,
1167
+ contentType: media.contentType,
1168
+ });
1169
+ } else {
1170
+ await deliverAgentApiMedia({
1171
+ agent: effectiveAgent,
1172
+ target: replyWecomTarget,
1173
+ buffer: media.buffer,
1174
+ filename: media.filename,
1175
+ contentType: media.contentType,
1176
+ });
1177
+ }
1178
+ touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
1179
+ log?.(
1180
+ `[wecom-agent] reply media delivered (${info.kind}) to ${isGroup ? `chat:${peerId}` : fromUser}, media=${media.filename}, sessionKey=${ctxPayload.SessionKey ?? route.sessionKey}, sessionId=${sessionId ?? "N/A"}`,
1181
+ );
1182
+ } catch (err: unknown) {
1183
+ const message =
1184
+ err instanceof Error
1185
+ ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}`
1186
+ : String(err);
1187
+ error?.(`[wecom-agent] media reply failed: ${message}`);
1188
+ auditSink?.({
1189
+ transport: "agent-callback",
1190
+ category: "fallback-delivery-failed",
1191
+ summary: `agent callback media reply failed user=${fromUser} kind=${info.kind}`,
1192
+ raw: {
1193
+ transport: "agent-callback",
1194
+ envelopeType: "xml",
1195
+ body: msg,
1196
+ },
1197
+ error: message,
1198
+ });
1199
+ }
1200
+ }
1201
+ deferredMediaUrls = [];
1202
+ }
1203
+
850
1204
  // 不同 Block 之间也增加一点间隔(优化:200ms → 50ms)
851
1205
  if (info.kind !== "final") {
852
1206
  await new Promise((resolve) => setTimeout(resolve, 50));