@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.
- package/README.md +79 -3
- package/UPSTREAM_CONFIG.md +170 -0
- package/UPSTREAM_PLAN.md +175 -0
- package/changelog/v2.4.12.md +37 -0
- package/changelog/v2.4.16.md +19 -0
- package/package.json +1 -1
- package/src/agent/handler.event-filter.test.ts +30 -1
- package/src/agent/handler.ts +226 -17
- package/src/app/account-runtime.ts +1 -1
- package/src/capability/agent/upstream-delivery-service.ts +96 -0
- package/src/capability/bot/sandbox-media.test.ts +221 -0
- package/src/capability/bot/sandbox-media.ts +176 -0
- package/src/capability/bot/stream-orchestrator.ts +19 -0
- package/src/channel.meta.test.ts +10 -0
- package/src/channel.ts +4 -1
- package/src/config/index.ts +5 -1
- package/src/config/network.ts +33 -0
- package/src/config/schema.ts +4 -0
- package/src/context-store.ts +41 -8
- package/src/http.ts +9 -1
- package/src/outbound.test.ts +211 -2
- package/src/outbound.ts +323 -70
- package/src/runtime/session-manager.test.ts +39 -0
- package/src/runtime/session-manager.ts +17 -0
- package/src/runtime/source-registry.ts +5 -0
- package/src/shared/media-asset.ts +78 -0
- package/src/shared/media-service.test.ts +111 -0
- package/src/shared/media-service.ts +42 -14
- package/src/target.ts +40 -0
- package/src/transport/agent-api/client.ts +233 -0
- package/src/transport/agent-api/core.ts +101 -5
- package/src/transport/agent-api/upstream-delivery.ts +45 -0
- package/src/transport/agent-api/upstream-media-upload.ts +70 -0
- package/src/transport/agent-api/upstream-reply.ts +43 -0
- package/src/transport/bot-webhook/inbound-normalizer.test.ts +433 -0
- package/src/transport/bot-webhook/inbound-normalizer.ts +240 -53
- package/src/transport/bot-webhook/message-shape.ts +3 -0
- package/src/transport/bot-ws/inbound.test.ts +195 -1
- package/src/transport/bot-ws/inbound.ts +57 -10
- package/src/types/config.ts +22 -0
- package/src/types/message.ts +11 -7
- package/src/upstream/index.ts +150 -0
- package/src/upstream.test.ts +84 -0
- package/vitest.config.ts +15 -4
package/src/agent/handler.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
} =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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:
|
|
939
|
+
deliver: async (payload: ReplyPayload, info: { kind: string }) => {
|
|
801
940
|
const text = payload.text ?? "";
|
|
802
|
-
|
|
803
|
-
if (
|
|
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 <
|
|
817
|
-
const chunk =
|
|
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
|
-
|
|
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 <
|
|
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
|
+
});
|