@yanhaidao/wecom 2.3.270 → 2.4.160

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 (44) hide show
  1. package/README.md +79 -3
  2. package/UPSTREAM_CONFIG.md +170 -0
  3. package/UPSTREAM_PLAN.md +175 -0
  4. package/changelog/v2.4.12.md +37 -0
  5. package/changelog/v2.4.16.md +19 -0
  6. package/package.json +1 -1
  7. package/src/agent/handler.event-filter.test.ts +30 -1
  8. package/src/agent/handler.ts +226 -17
  9. package/src/app/account-runtime.ts +1 -1
  10. package/src/capability/agent/upstream-delivery-service.ts +96 -0
  11. package/src/capability/bot/sandbox-media.test.ts +221 -0
  12. package/src/capability/bot/sandbox-media.ts +176 -0
  13. package/src/capability/bot/stream-orchestrator.ts +19 -0
  14. package/src/channel.meta.test.ts +10 -0
  15. package/src/channel.ts +4 -1
  16. package/src/config/index.ts +5 -1
  17. package/src/config/network.ts +33 -0
  18. package/src/config/schema.ts +4 -0
  19. package/src/context-store.ts +41 -8
  20. package/src/http.ts +9 -1
  21. package/src/outbound.test.ts +211 -2
  22. package/src/outbound.ts +323 -70
  23. package/src/runtime/session-manager.test.ts +39 -0
  24. package/src/runtime/session-manager.ts +17 -0
  25. package/src/runtime/source-registry.ts +5 -0
  26. package/src/shared/media-asset.ts +78 -0
  27. package/src/shared/media-service.test.ts +111 -0
  28. package/src/shared/media-service.ts +42 -14
  29. package/src/target.ts +40 -0
  30. package/src/transport/agent-api/client.ts +233 -0
  31. package/src/transport/agent-api/core.ts +101 -5
  32. package/src/transport/agent-api/upstream-delivery.ts +45 -0
  33. package/src/transport/agent-api/upstream-media-upload.ts +70 -0
  34. package/src/transport/agent-api/upstream-reply.ts +43 -0
  35. package/src/transport/bot-webhook/inbound-normalizer.test.ts +433 -0
  36. package/src/transport/bot-webhook/inbound-normalizer.ts +240 -53
  37. package/src/transport/bot-webhook/message-shape.ts +3 -0
  38. package/src/transport/bot-ws/inbound.test.ts +195 -1
  39. package/src/transport/bot-ws/inbound.ts +57 -10
  40. package/src/types/config.ts +22 -0
  41. package/src/types/message.ts +11 -7
  42. package/src/upstream/index.ts +150 -0
  43. package/src/upstream.test.ts +84 -0
  44. package/vitest.config.ts +15 -4
@@ -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,27 @@ 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 { resolveOutboundMediaAsset } from "../shared/media-asset.js";
37
+ import {
38
+ downloadAgentApiMedia,
39
+ downloadUpstreamAgentApiMedia,
40
+ sendAgentApiText,
41
+ sendUpstreamAgentApiText,
42
+ } from "../transport/agent-api/client.js";
43
+ import { deliverAgentApiMedia } from "../transport/agent-api/delivery.js";
44
+ import { deliverUpstreamAgentApiMedia } from "../transport/agent-api/upstream-delivery.js";
35
45
  import type {
36
46
  ResolvedAgentAccount,
47
+ ReplyPayload,
37
48
  UnifiedInboundEvent,
38
49
  WecomInboundKind,
39
50
  } from "../types/index.js";
40
51
  import type { WecomAgentInboundMessage } from "../types/index.js";
41
52
  import type { TransportSessionPatch } from "../types/index.js";
42
53
  import type { WecomRuntimeAuditEvent } from "../types/runtime-context.js";
54
+ import { detectUpstreamUser, createUpstreamAgentConfig, resolveUpstreamCorpConfig } from "../upstream/index.js";
43
55
 
44
56
  /** 错误提示信息 */
45
57
  const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
@@ -123,6 +135,11 @@ function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefi
123
135
  return truncated;
124
136
  }
125
137
 
138
+ function readContextSessionId(ctx: { SessionId?: string } | Record<string, unknown>): string | undefined {
139
+ const sessionId = "SessionId" in ctx ? ctx.SessionId : undefined;
140
+ return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
141
+ }
142
+
126
143
  /**
127
144
  * **AgentWebhookParams (Webhook 处理器参数)**
128
145
  *
@@ -246,6 +263,13 @@ export function shouldProcessAgentInboundMessage(params: {
246
263
  };
247
264
  }
248
265
 
266
+ export function shouldSuppressAgentReplyText(params: {
267
+ text: string;
268
+ mediaReplySeen: boolean;
269
+ }): boolean {
270
+ return params.mediaReplySeen && Boolean(params.text.trim());
271
+ }
272
+
249
273
  function normalizeAgentId(value: unknown): number | undefined {
250
274
  if (typeof value === "number" && Number.isFinite(value)) return value;
251
275
  const raw = String(value ?? "").trim();
@@ -448,10 +472,48 @@ async function processAgentMessage(params: {
448
472
  const replyTarget = isGroup
449
473
  ? ({ toUser: undefined, chatId: peerId } as const)
450
474
  : ({ toUser: fromUser, chatId: undefined } as const);
475
+ let upstreamAgent: typeof agent | undefined;
476
+ let upstreamReplyTarget: typeof replyTarget | undefined;
477
+ let primaryAgentForUpstream: typeof agent | undefined;
451
478
  const eventType = String(msg.Event ?? "")
452
479
  .trim()
453
480
  .toLowerCase();
454
481
 
482
+ // 检测是否是上下游用户
483
+ const toUserName = extractToUser(msg);
484
+ const isUpstreamUser = detectUpstreamUser({
485
+ messageToUserName: toUserName,
486
+ primaryCorpId: agent.corpId,
487
+ });
488
+
489
+ if (isUpstreamUser) {
490
+ log?.(
491
+ `[wecom-agent] detected upstream user: from=${fromUser} toCorpId=${toUserName}`,
492
+ );
493
+
494
+ // 查找上下游配置,构建上游 Agent 配置
495
+ const upstreamConfig = resolveUpstreamCorpConfig({
496
+ upstreamCorpId: toUserName,
497
+ upstreamCorps: agent.config.upstreamCorps,
498
+ });
499
+ if (upstreamConfig) {
500
+ upstreamAgent = createUpstreamAgentConfig({
501
+ baseAgent: agent,
502
+ upstreamCorpId: toUserName,
503
+ upstreamAgentId: upstreamConfig.agentId,
504
+ });
505
+ primaryAgentForUpstream = agent;
506
+ // 上下游的 replyTarget 与普通 DM 一致(toUser = fromUser)
507
+ upstreamReplyTarget = isGroup
508
+ ? ({ toUser: undefined, chatId: peerId } as const)
509
+ : ({ toUser: fromUser, chatId: undefined } as const);
510
+ } else {
511
+ error?.(
512
+ `[wecom-agent] upstream user detected but no upstream config for corpId=${toUserName}; fallback to primary agent target`,
513
+ );
514
+ }
515
+ }
516
+
455
517
  const resolveInboundKind = (): WecomInboundKind => {
456
518
  if (msgType === "event") {
457
519
  if (eventType === "subscribe" || eventType === "enter_agent") return "welcome";
@@ -497,7 +559,15 @@ async function processAgentMessage(params: {
497
559
  buffer,
498
560
  contentType,
499
561
  filename: headerFileName,
500
- } = await downloadAgentApiMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
562
+ } =
563
+ upstreamAgent && primaryAgentForUpstream
564
+ ? await downloadUpstreamAgentApiMedia({
565
+ upstreamAgent,
566
+ primaryAgent: primaryAgentForUpstream,
567
+ mediaId,
568
+ maxBytes: mediaMaxBytes,
569
+ })
570
+ : await downloadAgentApiMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
501
571
  const xmlFileName = extractFileName(msg);
502
572
  const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
503
573
  const heuristic = analyzeTextHeuristic(buffer);
@@ -633,7 +703,16 @@ async function processAgentMessage(params: {
633
703
  `[wecom-agent] routing guard: blocked default fallback accountId=${agent.accountId} matchedBy=${route.matchedBy} from=${fromUser}`,
634
704
  );
635
705
  try {
636
- await sendAgentApiText({ agent, ...replyTarget, text: prompt });
706
+ if (upstreamAgent) {
707
+ await sendUpstreamAgentApiText({
708
+ upstreamAgent,
709
+ primaryAgent: primaryAgentForUpstream!,
710
+ ...(upstreamReplyTarget ?? replyTarget),
711
+ text: prompt,
712
+ });
713
+ } else {
714
+ await sendAgentApiText({ agent, ...replyTarget, text: prompt });
715
+ }
637
716
  touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
638
717
  log?.(`[wecom-agent] routing guard prompt delivered to ${fromUser}`);
639
718
  } catch (err: unknown) {
@@ -710,7 +789,16 @@ async function processAgentMessage(params: {
710
789
  scope: "agent",
711
790
  });
712
791
  try {
713
- await sendAgentApiText({ agent, ...replyTarget, text: prompt });
792
+ if (upstreamAgent) {
793
+ await sendUpstreamAgentApiText({
794
+ upstreamAgent,
795
+ primaryAgent: primaryAgentForUpstream!,
796
+ ...(upstreamReplyTarget ?? replyTarget),
797
+ text: prompt,
798
+ });
799
+ } else {
800
+ await sendAgentApiText({ agent, ...replyTarget, text: prompt });
801
+ }
714
802
  touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
715
803
  log?.(
716
804
  `[wecom-agent] unauthorized command: replied to ${isGroup ? `chat:${peerId}` : fromUser}`,
@@ -756,6 +844,27 @@ async function processAgentMessage(params: {
756
844
  MediaType: mediaType,
757
845
  MediaUrl: mediaPath,
758
846
  });
847
+ const sessionId = readContextSessionId(ctxPayload);
848
+
849
+ log?.(
850
+ `[wecom-agent] session bound: sessionKey=${ctxPayload.SessionKey ?? route.sessionKey} sessionId=${sessionId ?? "N/A"} peer=${peerId} upstream=${String(Boolean(upstreamAgent))}`,
851
+ );
852
+
853
+ registerWecomSourceSnapshot({
854
+ accountId: agent.accountId,
855
+ source: "agent-callback",
856
+ messageId: extractMsgId(msg) ?? undefined,
857
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
858
+ sessionId,
859
+ peerKind: isGroup ? "group" : "direct",
860
+ peerId,
861
+ upstreamCorpId: upstreamAgent?.corpId,
862
+ });
863
+ setPeerContext(agent.accountId, peerId, {
864
+ peerKind: isGroup ? "group" : "direct",
865
+ lastSeen: Date.now(),
866
+ upstreamCorpId: upstreamAgent?.corpId,
867
+ });
759
868
 
760
869
  // 记录会话
761
870
  await core.channel.session.recordInboundSession({
@@ -769,14 +878,25 @@ async function processAgentMessage(params: {
769
878
 
770
879
  // 5秒无响应自动回复进度提示
771
880
  let hasResponseSent = false;
881
+ const effectiveAgent = upstreamAgent ?? agent;
882
+ const effectiveReplyTarget = upstreamReplyTarget ?? replyTarget;
772
883
  const processingTimer = setTimeout(async () => {
773
884
  if (hasResponseSent) return;
774
885
  try {
775
- await sendAgentApiText({
776
- agent,
777
- ...replyTarget,
778
- text: "正在处理中,请稍候...",
779
- });
886
+ if (upstreamAgent && primaryAgentForUpstream) {
887
+ await sendUpstreamAgentApiText({
888
+ upstreamAgent,
889
+ primaryAgent: primaryAgentForUpstream,
890
+ ...effectiveReplyTarget,
891
+ text: "正在处理中,请稍候...",
892
+ });
893
+ } else {
894
+ await sendAgentApiText({
895
+ agent: effectiveAgent,
896
+ ...effectiveReplyTarget,
897
+ text: "正在处理中,请稍候...",
898
+ });
899
+ }
780
900
  log?.(
781
901
  `[wecom-agent] sent processing notification to ${isGroup ? `chat:${peerId}` : fromUser}`,
782
902
  );
@@ -787,6 +907,25 @@ async function processAgentMessage(params: {
787
907
 
788
908
  // 发送队列锁:确保所有 deliver 调用(以及内部的分片发送)严格串行执行
789
909
  let messageSendQueue = Promise.resolve();
910
+ let deferredMediaUrls: string[] = [];
911
+
912
+ const mergeDeferredMediaUrls = (mediaUrls: string[]): string[] => {
913
+ if (mediaUrls.length === 0) {
914
+ return deferredMediaUrls;
915
+ }
916
+ const merged = [...deferredMediaUrls];
917
+ for (const mediaUrl of mediaUrls) {
918
+ if (!merged.includes(mediaUrl)) {
919
+ merged.push(mediaUrl);
920
+ }
921
+ }
922
+ deferredMediaUrls = merged;
923
+ return deferredMediaUrls;
924
+ };
925
+
926
+ const replyWecomTarget = effectiveReplyTarget.chatId
927
+ ? ({ chatid: effectiveReplyTarget.chatId } as const)
928
+ : ({ touser: effectiveReplyTarget.toUser } as const);
790
929
 
791
930
  try {
792
931
  // 调度回复
@@ -797,10 +936,20 @@ async function processAgentMessage(params: {
797
936
  disableBlockStreaming: false,
798
937
  },
799
938
  dispatcherOptions: {
800
- deliver: async (payload: { text?: string }, info: { kind: string }) => {
939
+ deliver: async (payload: ReplyPayload, info: { kind: string }) => {
801
940
  const text = payload.text ?? "";
802
- // 忽略空文本消息
803
- if (!text || !text.trim()) {
941
+ const incomingMediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
942
+ if (info.kind !== "final" && incomingMediaUrls.length > 0) {
943
+ mergeDeferredMediaUrls(incomingMediaUrls);
944
+ }
945
+ const mediaUrls =
946
+ info.kind === "final"
947
+ ? mergeDeferredMediaUrls(incomingMediaUrls)
948
+ : incomingMediaUrls;
949
+
950
+ const outboundText = text;
951
+
952
+ if ((!outboundText || !outboundText.trim()) && mediaUrls.length === 0) {
804
953
  return;
805
954
  }
806
955
 
@@ -813,18 +962,27 @@ async function processAgentMessage(params: {
813
962
  const currentTask = async () => {
814
963
  const MAX_CHUNK_SIZE = 600;
815
964
  // 确保分片顺序发送
816
- for (let i = 0; i < text.length; i += MAX_CHUNK_SIZE) {
817
- const chunk = text.slice(i, i + MAX_CHUNK_SIZE);
965
+ for (let i = 0; i < outboundText.length; i += MAX_CHUNK_SIZE) {
966
+ const chunk = outboundText.slice(i, i + MAX_CHUNK_SIZE);
818
967
 
819
968
  try {
820
- await sendAgentApiText({ agent, ...replyTarget, text: chunk });
969
+ if (upstreamAgent) {
970
+ await sendUpstreamAgentApiText({
971
+ upstreamAgent,
972
+ primaryAgent: primaryAgentForUpstream!,
973
+ ...effectiveReplyTarget,
974
+ text: chunk,
975
+ });
976
+ } else {
977
+ await sendAgentApiText({ agent: effectiveAgent, ...effectiveReplyTarget, text: chunk });
978
+ }
821
979
  touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
822
980
  log?.(
823
- `[wecom-agent] reply chunk delivered (${info.kind}) to ${isGroup ? `chat:${peerId}` : fromUser}, len=${chunk.length}`,
981
+ `[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
982
  );
825
983
 
826
984
  // 强制延时:确保企业微信有足够时间处理顺序(优化:200ms → 50ms)
827
- if (i + MAX_CHUNK_SIZE < text.length) {
985
+ if (i + MAX_CHUNK_SIZE < outboundText.length) {
828
986
  await new Promise((resolve) => setTimeout(resolve, 50));
829
987
  }
830
988
  } catch (err: unknown) {
@@ -847,6 +1005,57 @@ async function processAgentMessage(params: {
847
1005
  }
848
1006
  }
849
1007
 
1008
+ if (info.kind === "final") {
1009
+ for (const mediaUrl of mediaUrls) {
1010
+ try {
1011
+ const media = await resolveOutboundMediaAsset({
1012
+ mediaUrl,
1013
+ network: effectiveAgent.network,
1014
+ });
1015
+ if (upstreamAgent) {
1016
+ await deliverUpstreamAgentApiMedia({
1017
+ upstreamAgent,
1018
+ primaryAgent: primaryAgentForUpstream!,
1019
+ target: replyWecomTarget,
1020
+ buffer: media.buffer,
1021
+ filename: media.filename,
1022
+ contentType: media.contentType,
1023
+ });
1024
+ } else {
1025
+ await deliverAgentApiMedia({
1026
+ agent: effectiveAgent,
1027
+ target: replyWecomTarget,
1028
+ buffer: media.buffer,
1029
+ filename: media.filename,
1030
+ contentType: media.contentType,
1031
+ });
1032
+ }
1033
+ touchTransportSession?.({ lastOutboundAt: Date.now(), running: true });
1034
+ log?.(
1035
+ `[wecom-agent] reply media delivered (${info.kind}) to ${isGroup ? `chat:${peerId}` : fromUser}, media=${media.filename}, sessionKey=${ctxPayload.SessionKey ?? route.sessionKey}, sessionId=${sessionId ?? "N/A"}`,
1036
+ );
1037
+ } catch (err: unknown) {
1038
+ const message =
1039
+ err instanceof Error
1040
+ ? `${err.message}${err.cause ? ` (cause: ${String(err.cause)})` : ""}`
1041
+ : String(err);
1042
+ error?.(`[wecom-agent] media reply failed: ${message}`);
1043
+ auditSink?.({
1044
+ transport: "agent-callback",
1045
+ category: "fallback-delivery-failed",
1046
+ summary: `agent callback media reply failed user=${fromUser} kind=${info.kind}`,
1047
+ raw: {
1048
+ transport: "agent-callback",
1049
+ envelopeType: "xml",
1050
+ body: msg,
1051
+ },
1052
+ error: message,
1053
+ });
1054
+ }
1055
+ }
1056
+ deferredMediaUrls = [];
1057
+ }
1058
+
850
1059
  // 不同 Block 之间也增加一点间隔(优化:200ms → 50ms)
851
1060
  if (info.kind !== "final") {
852
1061
  await new Promise((resolve) => setTimeout(resolve, 50));
@@ -37,7 +37,7 @@ export class WecomAccountRuntime {
37
37
  } = {},
38
38
  private readonly statusSink?: (snapshot: Record<string, unknown>) => void,
39
39
  ) {
40
- this.mediaService = new WecomMediaService(core);
40
+ this.mediaService = new WecomMediaService(core, cfg);
41
41
  this.runtimeStatus = {
42
42
  accountId: resolved.account.accountId,
43
43
  health: "idle",
@@ -0,0 +1,96 @@
1
+ import type { ResolvedAgentAccount } from "../../types/index.js";
2
+ import { resolveScopedWecomTarget } from "../../target.js";
3
+ import { deliverUpstreamAgentApiMedia, deliverUpstreamAgentApiText } from "../../transport/agent-api/upstream-delivery.js";
4
+ import { canUseAgentApiDelivery } from "./fallback-policy.js";
5
+ import { getWecomRuntime } from "../../runtime.js";
6
+
7
+ /**
8
+ * 上下游企业消息发送服务
9
+ *
10
+ * 使用下游企业的 access_token 和 agentId 发送消息
11
+ */
12
+ export class WecomUpstreamAgentDeliveryService {
13
+ constructor(
14
+ private readonly upstreamAgent: ResolvedAgentAccount,
15
+ private readonly primaryAgent: ResolvedAgentAccount,
16
+ ) { }
17
+
18
+ assertAvailable(): void {
19
+ if (!canUseAgentApiDelivery(this.upstreamAgent)) {
20
+ throw new Error(
21
+ `WeCom upstream outbound requires channels.wecom.accounts.<accountId>.agent.agentId for upstream corp=${this.upstreamAgent.corpId}.`,
22
+ );
23
+ }
24
+ }
25
+
26
+ resolveTargetOrThrow(to: string | undefined) {
27
+ const scoped = resolveScopedWecomTarget(to, this.upstreamAgent.accountId);
28
+ if (!scoped) {
29
+ console.error(`[wecom-upstream-delivery] missing target account=${this.upstreamAgent.accountId}`);
30
+ throw new Error("WeCom upstream outbound requires a target (userid, partyid, tagid or chatid).");
31
+ }
32
+ if (scoped.accountId && scoped.accountId !== this.upstreamAgent.accountId) {
33
+ console.error(
34
+ `[wecom-upstream-delivery] account mismatch current=${this.upstreamAgent.accountId} targetAccount=${scoped.accountId} raw=${String(to ?? "")}`,
35
+ );
36
+ throw new Error(
37
+ `WeCom upstream outbound account mismatch: target belongs to account=${scoped.accountId}, current account=${this.upstreamAgent.accountId}.`,
38
+ );
39
+ }
40
+ const target = scoped.target;
41
+ if (target.chatid) {
42
+ console.warn(
43
+ `[wecom-upstream-delivery] blocked chat target account=${this.upstreamAgent.accountId} chatId=${target.chatid}`,
44
+ );
45
+ throw new Error(
46
+ `企业微信(WeCom)上下游 Agent 主动发送不支持向群 chatId 发送(chatId=${target.chatid})。` +
47
+ `请改为发送给用户(userid / user:xxx)。`,
48
+ );
49
+ }
50
+ return target;
51
+ }
52
+
53
+ async sendText(params: { to: string | undefined; text: string }): Promise<void> {
54
+ this.assertAvailable();
55
+ const target = this.resolveTargetOrThrow(params.to);
56
+ console.log(
57
+ `[wecom-upstream-delivery] sendText account=${this.upstreamAgent.accountId} corpId=${this.upstreamAgent.corpId} to=${String(params.to ?? "")} len=${params.text.length}`,
58
+ );
59
+
60
+ const runtime = getWecomRuntime();
61
+ const chunks = runtime.channel.text.chunkText(params.text, 2048);
62
+
63
+ for (const chunk of chunks) {
64
+ if (!chunk.trim()) continue;
65
+ await deliverUpstreamAgentApiText({
66
+ upstreamAgent: this.upstreamAgent,
67
+ primaryAgent: this.primaryAgent,
68
+ target,
69
+ text: chunk,
70
+ });
71
+ }
72
+ }
73
+
74
+ async sendMedia(params: {
75
+ to: string | undefined;
76
+ text?: string;
77
+ buffer: Buffer;
78
+ filename: string;
79
+ contentType: string;
80
+ }): Promise<void> {
81
+ this.assertAvailable();
82
+ const target = this.resolveTargetOrThrow(params.to);
83
+ console.log(
84
+ `[wecom-upstream-delivery] sendMedia account=${this.upstreamAgent.accountId} corpId=${this.upstreamAgent.corpId} to=${String(params.to ?? "")} filename=${params.filename} contentType=${params.contentType}`,
85
+ );
86
+ await deliverUpstreamAgentApiMedia({
87
+ upstreamAgent: this.upstreamAgent,
88
+ primaryAgent: this.primaryAgent,
89
+ target,
90
+ buffer: params.buffer,
91
+ filename: params.filename,
92
+ contentType: params.contentType,
93
+ text: params.text,
94
+ });
95
+ }
96
+ }
@@ -0,0 +1,221 @@
1
+ import path from "node:path";
2
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { stageWecomInboundMediaForSession } from "./sandbox-media.js";
7
+
8
+ describe("stageWecomInboundMediaForSession", () => {
9
+ const root = path.join("/tmp", `wecom-sandbox-stage-${process.pid}`);
10
+
11
+ beforeEach(async () => {
12
+ await mkdir(root, { recursive: true });
13
+ });
14
+
15
+ afterEach(async () => {
16
+ vi.unstubAllEnvs();
17
+ await rm(root, { recursive: true, force: true });
18
+ });
19
+
20
+ it("stages inbound media into the session sandbox workspace using channels.wecom.mediaMaxMb", async () => {
21
+ const mediaPath = path.join(root, "openclaw-media", "inbound", "big.bin");
22
+ const agentWorkspace = path.join(root, "agent-workspace");
23
+ const sandboxRoot = path.join(root, "sandboxes");
24
+
25
+ await mkdir(path.dirname(mediaPath), { recursive: true });
26
+ await mkdir(agentWorkspace, { recursive: true });
27
+ await writeFile(mediaPath, Buffer.alloc(6 * 1024 * 1024, 7));
28
+
29
+ const staged = await stageWecomInboundMediaForSession({
30
+ cfg: {
31
+ channels: {
32
+ wecom: {
33
+ mediaMaxMb: 8,
34
+ },
35
+ },
36
+ agents: {
37
+ list: [
38
+ {
39
+ id: "ops",
40
+ workspace: agentWorkspace,
41
+ sandbox: {
42
+ mode: "non-main",
43
+ scope: "session",
44
+ workspaceRoot: sandboxRoot,
45
+ workspaceAccess: "ro",
46
+ docker: {
47
+ workdir: "/workspace",
48
+ },
49
+ },
50
+ },
51
+ ],
52
+ },
53
+ } as any,
54
+ accountId: "default",
55
+ agentId: "ops",
56
+ sessionKey: "agent:ops:wecom:default:dm:zhangsan",
57
+ mediaPath,
58
+ filename: "big.bin",
59
+ });
60
+
61
+ expect(staged).toMatch(/^media\/inbound\/big\.bin$/);
62
+ const sandboxEntries = await readdir(sandboxRoot);
63
+ const stagedBuffer = await readFile(
64
+ path.join(sandboxRoot, sandboxEntries[0]!, "media", "inbound", "big.bin"),
65
+ );
66
+ expect(stagedBuffer.byteLength).toBe(6 * 1024 * 1024);
67
+ });
68
+
69
+ it("uses the default sandbox workspace root when workspaceRoot is omitted", async () => {
70
+ vi.stubEnv("OPENCLAW_STATE_DIR", root);
71
+ const mediaPath = path.join(root, "openclaw-media", "inbound", "default-root.bin");
72
+ const agentWorkspace = path.join(root, "agent-workspace");
73
+
74
+ await mkdir(path.dirname(mediaPath), { recursive: true });
75
+ await mkdir(agentWorkspace, { recursive: true });
76
+ await writeFile(mediaPath, Buffer.alloc(2 * 1024 * 1024, 5));
77
+
78
+ const staged = await stageWecomInboundMediaForSession({
79
+ cfg: {
80
+ channels: {
81
+ wecom: {
82
+ mediaMaxMb: 8,
83
+ },
84
+ },
85
+ agents: {
86
+ list: [
87
+ {
88
+ id: "ops",
89
+ workspace: agentWorkspace,
90
+ sandbox: {
91
+ mode: "non-main",
92
+ scope: "session",
93
+ workspaceAccess: "ro",
94
+ docker: {
95
+ workdir: "/workspace",
96
+ },
97
+ },
98
+ },
99
+ ],
100
+ },
101
+ } as any,
102
+ accountId: "default",
103
+ agentId: "ops",
104
+ sessionKey: "agent:ops:wecom:default:dm:lisi",
105
+ mediaPath,
106
+ filename: "default-root.bin",
107
+ });
108
+
109
+ expect(staged).toMatch(/^media\/inbound\/default-root\.bin$/);
110
+ const sandboxEntries = await readdir(path.join(root, "sandboxes"));
111
+ const stagedBuffer = await readFile(
112
+ path.join(root, "sandboxes", sandboxEntries[0]!, "media", "inbound", "default-root.bin"),
113
+ );
114
+ expect(stagedBuffer.byteLength).toBe(2 * 1024 * 1024);
115
+ });
116
+
117
+ it("stages inbound media into the agent workspace for non-sandbox sessions", async () => {
118
+ const mediaPath = path.join(root, "openclaw-media", "inbound", "small.bin");
119
+ const agentWorkspace = path.join(root, "agent-workspace");
120
+
121
+ await mkdir(path.dirname(mediaPath), { recursive: true });
122
+ await mkdir(agentWorkspace, { recursive: true });
123
+ await writeFile(mediaPath, Buffer.alloc(1024, 1));
124
+
125
+ const staged = await stageWecomInboundMediaForSession({
126
+ cfg: {
127
+ channels: {
128
+ wecom: {
129
+ mediaMaxMb: 8,
130
+ },
131
+ },
132
+ agents: {
133
+ list: [
134
+ {
135
+ id: "ops",
136
+ workspace: agentWorkspace,
137
+ sandbox: {
138
+ mode: "off",
139
+ scope: "session",
140
+ workspaceRoot: path.join(root, "sandboxes"),
141
+ workspaceAccess: "ro",
142
+ docker: {
143
+ workdir: "/workspace",
144
+ },
145
+ },
146
+ },
147
+ ],
148
+ },
149
+ } as any,
150
+ accountId: "default",
151
+ agentId: "ops",
152
+ sessionKey: "agent:ops:wecom:default:dm:zhangsan",
153
+ mediaPath,
154
+ filename: "small.bin",
155
+ });
156
+
157
+ expect(staged).toBe(path.join(agentWorkspace, "media", "inbound", "small.bin"));
158
+ const stagedBuffer = await readFile(staged);
159
+ expect(stagedBuffer.byteLength).toBe(1024);
160
+ });
161
+
162
+ it("allocates distinct staged filenames for concurrent same-name uploads", async () => {
163
+ const mediaPathA = path.join(root, "openclaw-media", "inbound", "dup-a.bin");
164
+ const mediaPathB = path.join(root, "openclaw-media", "inbound", "dup-b.bin");
165
+ const agentWorkspace = path.join(root, "agent-workspace");
166
+
167
+ await mkdir(path.dirname(mediaPathA), { recursive: true });
168
+ await mkdir(agentWorkspace, { recursive: true });
169
+ await writeFile(mediaPathA, Buffer.from("first"));
170
+ await writeFile(mediaPathB, Buffer.from("second"));
171
+
172
+ const cfg = {
173
+ channels: {
174
+ wecom: {
175
+ mediaMaxMb: 8,
176
+ },
177
+ },
178
+ agents: {
179
+ list: [
180
+ {
181
+ id: "ops",
182
+ workspace: agentWorkspace,
183
+ sandbox: {
184
+ mode: "off",
185
+ scope: "session",
186
+ workspaceAccess: "ro",
187
+ docker: {
188
+ workdir: "/workspace",
189
+ },
190
+ },
191
+ },
192
+ ],
193
+ },
194
+ } as any;
195
+
196
+ const [stagedA, stagedB] = await Promise.all([
197
+ stageWecomInboundMediaForSession({
198
+ cfg,
199
+ accountId: "default",
200
+ agentId: "ops",
201
+ sessionKey: "agent:ops:wecom:default:dm:zhangsan",
202
+ mediaPath: mediaPathA,
203
+ filename: "dup.bin",
204
+ }),
205
+ stageWecomInboundMediaForSession({
206
+ cfg,
207
+ accountId: "default",
208
+ agentId: "ops",
209
+ sessionKey: "agent:ops:wecom:default:dm:lisi",
210
+ mediaPath: mediaPathB,
211
+ filename: "dup.bin",
212
+ }),
213
+ ]);
214
+
215
+ expect(stagedA).not.toBe(stagedB);
216
+ expect([path.basename(stagedA), path.basename(stagedB)].sort()).toEqual(["dup-1.bin", "dup.bin"]);
217
+ expect((await readFile(stagedA)).toString()).toMatch(/first|second/);
218
+ expect((await readFile(stagedB)).toString()).toMatch(/first|second/);
219
+ expect((await readFile(stagedA)).toString()).not.toBe((await readFile(stagedB)).toString());
220
+ });
221
+ });