@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/outbound.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
|
|
2
|
+
import type { ResolvedAgentAccount } from "./types/account.js";
|
|
2
3
|
import { WecomAgentDeliveryService } from "./capability/agent/index.js";
|
|
4
|
+
import { WecomUpstreamAgentDeliveryService } from "./capability/agent/upstream-delivery-service.js";
|
|
3
5
|
import {
|
|
4
6
|
resolveWecomMergedMediaLocalRoots,
|
|
5
7
|
resolveWecomMediaMaxBytes,
|
|
@@ -13,9 +15,12 @@ import {
|
|
|
13
15
|
getBotWsPushHandle,
|
|
14
16
|
getWecomRuntime,
|
|
15
17
|
} from "./runtime.js";
|
|
18
|
+
import { getPeerUpstreamCorpId } from "./context-store.js";
|
|
16
19
|
import { resolveWecomSourceSnapshot } from "./runtime/source-registry.js";
|
|
20
|
+
import { resolveOutboundMediaAsset } from "./shared/media-asset.js";
|
|
17
21
|
import { resolveScopedWecomTarget } from "./target.js";
|
|
18
22
|
import { toWeComMarkdownV2 } from "./wecom_msg_adapter/markdown_adapter.js";
|
|
23
|
+
import { parseUpstreamAgentSessionTarget, createUpstreamAgentConfig, resolveUpstreamCorpConfig } from "./upstream/index.js";
|
|
19
24
|
|
|
20
25
|
type WecomOutboundBaseContext = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0];
|
|
21
26
|
type WecomOutboundContext = WecomOutboundBaseContext & {
|
|
@@ -23,6 +28,72 @@ type WecomOutboundContext = WecomOutboundBaseContext & {
|
|
|
23
28
|
};
|
|
24
29
|
type WecomOutboundConfig = WecomOutboundContext["cfg"];
|
|
25
30
|
|
|
31
|
+
type ResolvedOutboundContext = {
|
|
32
|
+
rawTo: string;
|
|
33
|
+
explicitAgentTarget: boolean;
|
|
34
|
+
scopedAccountId?: string;
|
|
35
|
+
peerKind?: "direct" | "group";
|
|
36
|
+
peerId?: string;
|
|
37
|
+
source?: ReturnType<typeof resolveWecomSourceSnapshot>;
|
|
38
|
+
peerUpstreamCorpId?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function resolveOutboundContext(params: {
|
|
42
|
+
to: string | undefined;
|
|
43
|
+
accountId?: string | null;
|
|
44
|
+
sessionKey?: string | null;
|
|
45
|
+
}): ResolvedOutboundContext {
|
|
46
|
+
const rawTo = String(params.to ?? "").trim();
|
|
47
|
+
const fallbackAccountId = params.accountId?.trim();
|
|
48
|
+
const scoped = resolveScopedWecomTarget(params.to, fallbackAccountId);
|
|
49
|
+
const scopedAccountId = scoped?.accountId?.trim() || fallbackAccountId;
|
|
50
|
+
const peerId = scoped?.target.touser?.trim() || scoped?.target.chatid?.trim();
|
|
51
|
+
const peerKind = scoped?.target.chatid ? "group" : scoped?.target.touser ? "direct" : undefined;
|
|
52
|
+
const source = scopedAccountId
|
|
53
|
+
? resolveWecomSourceSnapshot({
|
|
54
|
+
accountId: scopedAccountId,
|
|
55
|
+
sessionKey: params.sessionKey,
|
|
56
|
+
peerKind,
|
|
57
|
+
peerId,
|
|
58
|
+
})
|
|
59
|
+
: undefined;
|
|
60
|
+
const peerUpstreamCorpId =
|
|
61
|
+
scopedAccountId && peerKind === "direct" && peerId
|
|
62
|
+
? getPeerUpstreamCorpId(scopedAccountId, peerId)?.trim()
|
|
63
|
+
: undefined;
|
|
64
|
+
return {
|
|
65
|
+
rawTo,
|
|
66
|
+
explicitAgentTarget: isExplicitAgentTarget(params.to),
|
|
67
|
+
scopedAccountId,
|
|
68
|
+
peerKind,
|
|
69
|
+
peerId,
|
|
70
|
+
source,
|
|
71
|
+
peerUpstreamCorpId,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function logOutboundDecision(params: {
|
|
76
|
+
phase: string;
|
|
77
|
+
to: string | undefined;
|
|
78
|
+
accountId?: string | null;
|
|
79
|
+
sessionKey?: string | null;
|
|
80
|
+
textLen?: number;
|
|
81
|
+
mediaUrl?: string;
|
|
82
|
+
extra?: string;
|
|
83
|
+
}): void {
|
|
84
|
+
const resolved = resolveOutboundContext(params);
|
|
85
|
+
const runtimeAccountId = resolved.scopedAccountId || params.accountId?.trim();
|
|
86
|
+
const logger = runtimeAccountId ? getAccountRuntime(runtimeAccountId)?.log.info : undefined;
|
|
87
|
+
logger?.(
|
|
88
|
+
`[wecom-outbound] ${params.phase} rawTo=${resolved.rawTo || "N/A"} scopedAccount=${resolved.scopedAccountId ?? "N/A"} ` +
|
|
89
|
+
`peer=${resolved.peerKind && resolved.peerId ? `${resolved.peerKind}:${resolved.peerId}` : "N/A"} ` +
|
|
90
|
+
`explicitAgent=${String(resolved.explicitAgentTarget)} source=${resolved.source?.source ?? "none"} ` +
|
|
91
|
+
`sourceUpstreamCorpId=${resolved.source?.upstreamCorpId ?? "none"} peerUpstreamCorpId=${resolved.peerUpstreamCorpId ?? "none"} ` +
|
|
92
|
+
`sessionKey=${params.sessionKey?.trim() || "N/A"} textLen=${String(params.textLen ?? 0)} ` +
|
|
93
|
+
`mediaUrl=${params.mediaUrl ?? "N/A"}${params.extra ? ` ${params.extra}` : ""}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
26
97
|
function resolveOutboundAccountOrThrow(params: {
|
|
27
98
|
cfg: WecomOutboundConfig;
|
|
28
99
|
accountId?: string | null;
|
|
@@ -74,7 +145,121 @@ function resolveAgentConfigOrThrow(params: {
|
|
|
74
145
|
}
|
|
75
146
|
|
|
76
147
|
function isExplicitAgentTarget(raw: string | undefined): boolean {
|
|
77
|
-
return /^wecom-agent
|
|
148
|
+
return /^wecom-agent(?:-upstream)?:/i.test(String(raw ?? "").trim());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isAgentConversationTarget(params: {
|
|
152
|
+
to: string | undefined;
|
|
153
|
+
accountId?: string | null;
|
|
154
|
+
sessionKey?: string | null;
|
|
155
|
+
}): boolean {
|
|
156
|
+
if (isExplicitAgentTarget(params.to)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const fallbackAccountId = params.accountId?.trim();
|
|
161
|
+
const scoped = resolveScopedWecomTarget(params.to, fallbackAccountId);
|
|
162
|
+
const resolvedAccountId = scoped?.accountId?.trim() || fallbackAccountId;
|
|
163
|
+
if (!resolvedAccountId) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const peerId = scoped?.target.touser?.trim() || scoped?.target.chatid?.trim();
|
|
168
|
+
const peerKind = scoped?.target.chatid ? "group" : scoped?.target.touser ? "direct" : undefined;
|
|
169
|
+
const source = resolveWecomSourceSnapshot({
|
|
170
|
+
accountId: resolvedAccountId,
|
|
171
|
+
sessionKey: params.sessionKey,
|
|
172
|
+
peerKind,
|
|
173
|
+
peerId,
|
|
174
|
+
});
|
|
175
|
+
return source?.source === "agent-callback";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 解析上下游目标,返回解析后的信息或 undefined
|
|
180
|
+
*/
|
|
181
|
+
function resolveUpstreamTarget(params: {
|
|
182
|
+
to: string | undefined;
|
|
183
|
+
cfg: WecomOutboundConfig;
|
|
184
|
+
accountId?: string | null;
|
|
185
|
+
sessionKey?: string | null;
|
|
186
|
+
}): { upstreamAgent: ResolvedAgentAccount; primaryAgent: ResolvedAgentAccount; toUser: string } | undefined {
|
|
187
|
+
const parsedExplicit = parseUpstreamAgentSessionTarget(params.to ?? "");
|
|
188
|
+
const isExplicitUpstreamTarget = Boolean(parsedExplicit);
|
|
189
|
+
|
|
190
|
+
const parsed = (() => {
|
|
191
|
+
if (parsedExplicit) {
|
|
192
|
+
return parsedExplicit;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const fallbackAccountId = params.accountId?.trim();
|
|
196
|
+
const scoped = resolveScopedWecomTarget(params.to, fallbackAccountId);
|
|
197
|
+
const toUser = scoped?.target.touser?.trim();
|
|
198
|
+
const resolvedAccountId = scoped?.accountId?.trim() || fallbackAccountId;
|
|
199
|
+
if (!toUser || !resolvedAccountId) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const source = resolveWecomSourceSnapshot({
|
|
204
|
+
accountId: resolvedAccountId,
|
|
205
|
+
sessionKey: params.sessionKey,
|
|
206
|
+
peerKind: "direct",
|
|
207
|
+
peerId: toUser,
|
|
208
|
+
});
|
|
209
|
+
const upstreamCorpId =
|
|
210
|
+
source?.upstreamCorpId?.trim() || getPeerUpstreamCorpId(resolvedAccountId, toUser)?.trim();
|
|
211
|
+
if (!upstreamCorpId) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
accountId: resolvedAccountId,
|
|
217
|
+
upstreamCorpId,
|
|
218
|
+
userId: toUser,
|
|
219
|
+
};
|
|
220
|
+
})();
|
|
221
|
+
|
|
222
|
+
if (!parsed) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const { accountId, upstreamCorpId, userId } = parsed;
|
|
227
|
+
const account = resolveOutboundAccountOrThrow({ cfg: params.cfg, accountId });
|
|
228
|
+
|
|
229
|
+
if (!account.agent?.apiConfigured) {
|
|
230
|
+
if (isExplicitUpstreamTarget) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`WeCom upstream outbound requires Agent mode for account=${accountId}.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 查找上下游配置
|
|
239
|
+
const upstreamConfig = resolveUpstreamCorpConfig({
|
|
240
|
+
upstreamCorpId,
|
|
241
|
+
upstreamCorps: account.agent.config.upstreamCorps,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!upstreamConfig) {
|
|
245
|
+
if (isExplicitUpstreamTarget) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`WeCom upstream outbound: no upstream corp config found for corpId=${upstreamCorpId}. ` +
|
|
248
|
+
`Please configure channels.wecom.accounts.${accountId}.agent.upstreamCorps with corpId=${upstreamCorpId}.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 创建上下游 Agent 配置
|
|
255
|
+
// 注意:使用下游企业的 corpId 和 agentId,但保持主企业的 corpSecret
|
|
256
|
+
const upstreamAgent = createUpstreamAgentConfig({
|
|
257
|
+
baseAgent: account.agent,
|
|
258
|
+
upstreamCorpId,
|
|
259
|
+
upstreamAgentId: upstreamConfig.agentId,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return { upstreamAgent, primaryAgent: account.agent, toUser: userId };
|
|
78
263
|
}
|
|
79
264
|
|
|
80
265
|
function resolveBotWsChatTarget(params: {
|
|
@@ -303,12 +488,25 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
303
488
|
// - Agent 会话目标(wecom-agent:):允许发送,但改写成中文。
|
|
304
489
|
let outgoingText = text;
|
|
305
490
|
const trimmed = String(outgoingText ?? "").trim();
|
|
306
|
-
|
|
307
|
-
|
|
491
|
+
logOutboundDecision({
|
|
492
|
+
phase: "sendText:start",
|
|
493
|
+
to,
|
|
494
|
+
accountId,
|
|
495
|
+
sessionKey,
|
|
496
|
+
textLen: trimmed.length,
|
|
497
|
+
});
|
|
498
|
+
const isAgentSessionTarget = isAgentConversationTarget({ to, accountId, sessionKey });
|
|
308
499
|
const looksLikeNewSessionAck = /new session started/i.test(trimmed) && /model:/i.test(trimmed);
|
|
309
500
|
|
|
310
501
|
if (looksLikeNewSessionAck) {
|
|
311
502
|
if (!isAgentSessionTarget) {
|
|
503
|
+
logOutboundDecision({
|
|
504
|
+
phase: "sendText:suppress-new-session-ack",
|
|
505
|
+
to,
|
|
506
|
+
accountId,
|
|
507
|
+
sessionKey,
|
|
508
|
+
textLen: trimmed.length,
|
|
509
|
+
});
|
|
312
510
|
// Suppress ack without agent resolution
|
|
313
511
|
return { channel: "wecom", messageId: `suppressed-${Date.now()}`, timestamp: Date.now() };
|
|
314
512
|
}
|
|
@@ -319,12 +517,50 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
319
517
|
})();
|
|
320
518
|
const rewritten = modelLabel ? `✅ 已开启新会话(模型:${modelLabel})` : "✅ 已开启新会话。";
|
|
321
519
|
outgoingText = rewritten;
|
|
520
|
+
logOutboundDecision({
|
|
521
|
+
phase: "sendText:rewrite-new-session-ack",
|
|
522
|
+
to,
|
|
523
|
+
accountId,
|
|
524
|
+
sessionKey,
|
|
525
|
+
textLen: outgoingText.length,
|
|
526
|
+
});
|
|
322
527
|
}
|
|
323
528
|
|
|
324
529
|
let sentViaBotWs = false;
|
|
325
530
|
let agent: ReturnType<typeof resolveAgentConfigOrThrow> | null = null;
|
|
531
|
+
let upstreamTarget: ReturnType<typeof resolveUpstreamTarget> | undefined;
|
|
326
532
|
|
|
327
533
|
try {
|
|
534
|
+
// 首先检查是否是上下游用户
|
|
535
|
+
upstreamTarget = resolveUpstreamTarget({ to, cfg, accountId, sessionKey });
|
|
536
|
+
|
|
537
|
+
if (upstreamTarget) {
|
|
538
|
+
logOutboundDecision({
|
|
539
|
+
phase: "sendText:path-upstream",
|
|
540
|
+
to,
|
|
541
|
+
accountId,
|
|
542
|
+
sessionKey,
|
|
543
|
+
textLen: outgoingText.length,
|
|
544
|
+
extra: `resolvedUser=${upstreamTarget.toUser} corpId=${upstreamTarget.upstreamAgent.corpId}`,
|
|
545
|
+
});
|
|
546
|
+
// 上下游用户使用专门的 DeliveryService 发送
|
|
547
|
+
getAccountRuntime(upstreamTarget.upstreamAgent.accountId)?.log.info?.(
|
|
548
|
+
`[wecom-outbound] Sending text to upstream target corpId=${upstreamTarget.upstreamAgent.corpId} (len=${outgoingText.length})`,
|
|
549
|
+
);
|
|
550
|
+
const deliveryService = new WecomUpstreamAgentDeliveryService(
|
|
551
|
+
upstreamTarget.upstreamAgent,
|
|
552
|
+
upstreamTarget.primaryAgent,
|
|
553
|
+
);
|
|
554
|
+
await deliveryService.sendText({
|
|
555
|
+
to,
|
|
556
|
+
text: outgoingText,
|
|
557
|
+
});
|
|
558
|
+
return {
|
|
559
|
+
channel: "wecom",
|
|
560
|
+
messageId: `upstream-agent-${Date.now()}`,
|
|
561
|
+
timestamp: Date.now(),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
328
564
|
sentViaBotWs = await sendTextViaBotWs({
|
|
329
565
|
cfg,
|
|
330
566
|
accountId,
|
|
@@ -335,6 +571,13 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
335
571
|
if (!sentViaBotWs) {
|
|
336
572
|
// Defer Agent resolution until needed for fallback
|
|
337
573
|
agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
574
|
+
logOutboundDecision({
|
|
575
|
+
phase: "sendText:path-agent",
|
|
576
|
+
to,
|
|
577
|
+
accountId: agent.accountId,
|
|
578
|
+
sessionKey,
|
|
579
|
+
textLen: outgoingText.length,
|
|
580
|
+
});
|
|
338
581
|
getAccountRuntime(agent.accountId)?.log.info?.(
|
|
339
582
|
`[wecom-outbound] Sending text to target=${String(to ?? "")} (len=${outgoingText.length})`,
|
|
340
583
|
);
|
|
@@ -343,9 +586,17 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
343
586
|
to,
|
|
344
587
|
text: outgoingText,
|
|
345
588
|
});
|
|
346
|
-
|
|
589
|
+
} else {
|
|
590
|
+
logOutboundDecision({
|
|
591
|
+
phase: "sendText:path-bot-ws",
|
|
592
|
+
to,
|
|
593
|
+
accountId,
|
|
594
|
+
sessionKey,
|
|
595
|
+
textLen: outgoingText.length,
|
|
596
|
+
});
|
|
347
597
|
}
|
|
348
598
|
} catch (err) {
|
|
599
|
+
console.error(`[wecom-outbound] FAILED to send: ${err instanceof Error ? err.message : String(err)}`);
|
|
349
600
|
if (agent) {
|
|
350
601
|
getAccountRuntime(agent.accountId)?.log.error?.(
|
|
351
602
|
`[wecom-outbound] Failed to send text to ${String(to ?? "")}: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -374,6 +625,54 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
374
625
|
throw new Error("WeCom outbound requires mediaUrl.");
|
|
375
626
|
}
|
|
376
627
|
|
|
628
|
+
logOutboundDecision({
|
|
629
|
+
phase: "sendMedia:start",
|
|
630
|
+
to,
|
|
631
|
+
accountId,
|
|
632
|
+
sessionKey,
|
|
633
|
+
textLen: String(text ?? "").trim().length,
|
|
634
|
+
mediaUrl,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// 首先检查是否是上下游用户
|
|
638
|
+
const upstreamTarget = resolveUpstreamTarget({ to, cfg, accountId, sessionKey });
|
|
639
|
+
if (upstreamTarget) {
|
|
640
|
+
logOutboundDecision({
|
|
641
|
+
phase: "sendMedia:path-upstream",
|
|
642
|
+
to,
|
|
643
|
+
accountId,
|
|
644
|
+
sessionKey,
|
|
645
|
+
textLen: String(text ?? "").trim().length,
|
|
646
|
+
mediaUrl,
|
|
647
|
+
extra: `resolvedUser=${upstreamTarget.toUser} corpId=${upstreamTarget.upstreamAgent.corpId}`,
|
|
648
|
+
});
|
|
649
|
+
getAccountRuntime(upstreamTarget.upstreamAgent.accountId)?.log.info?.(
|
|
650
|
+
`[wecom-outbound] Sending media to upstream target corpId=${upstreamTarget.upstreamAgent.corpId} (filename=${mediaUrl})`,
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const { buffer, contentType, filename } = await resolveOutboundMediaAsset({
|
|
654
|
+
mediaUrl,
|
|
655
|
+
network: upstreamTarget.upstreamAgent.network,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const deliveryService = new WecomUpstreamAgentDeliveryService(
|
|
659
|
+
upstreamTarget.upstreamAgent,
|
|
660
|
+
upstreamTarget.primaryAgent,
|
|
661
|
+
);
|
|
662
|
+
await deliveryService.sendMedia({
|
|
663
|
+
to,
|
|
664
|
+
text,
|
|
665
|
+
buffer,
|
|
666
|
+
filename,
|
|
667
|
+
contentType,
|
|
668
|
+
});
|
|
669
|
+
return {
|
|
670
|
+
channel: "wecom",
|
|
671
|
+
messageId: `upstream-agent-media-${Date.now()}`,
|
|
672
|
+
timestamp: Date.now(),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
377
676
|
const botWs = await sendMediaViaBotWs({
|
|
378
677
|
cfg,
|
|
379
678
|
accountId,
|
|
@@ -384,6 +683,14 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
384
683
|
sessionKey,
|
|
385
684
|
});
|
|
386
685
|
if (botWs.sent) {
|
|
686
|
+
logOutboundDecision({
|
|
687
|
+
phase: "sendMedia:path-bot-ws",
|
|
688
|
+
to,
|
|
689
|
+
accountId,
|
|
690
|
+
sessionKey,
|
|
691
|
+
textLen: String(text ?? "").trim().length,
|
|
692
|
+
mediaUrl,
|
|
693
|
+
});
|
|
387
694
|
return {
|
|
388
695
|
channel: "wecom",
|
|
389
696
|
messageId: `bot-ws-media-${Date.now()}`,
|
|
@@ -397,74 +704,20 @@ export const wecomOutbound: ChannelOutboundAdapter = {
|
|
|
397
704
|
}
|
|
398
705
|
|
|
399
706
|
const agent = resolveAgentConfigOrThrow({ cfg, accountId });
|
|
707
|
+
logOutboundDecision({
|
|
708
|
+
phase: "sendMedia:path-agent",
|
|
709
|
+
to,
|
|
710
|
+
accountId: agent.accountId,
|
|
711
|
+
sessionKey,
|
|
712
|
+
textLen: String(text ?? "").trim().length,
|
|
713
|
+
mediaUrl,
|
|
714
|
+
});
|
|
400
715
|
const deliveryService = new WecomAgentDeliveryService(agent);
|
|
401
716
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
// 判断是 URL 还是本地文件路径
|
|
407
|
-
const isRemoteUrl = /^https?:\/\//i.test(mediaUrl);
|
|
408
|
-
|
|
409
|
-
if (isRemoteUrl) {
|
|
410
|
-
const res = await fetch(mediaUrl, { signal: AbortSignal.timeout(30000) });
|
|
411
|
-
if (!res.ok) {
|
|
412
|
-
throw new Error(`Failed to download media: ${res.status}`);
|
|
413
|
-
}
|
|
414
|
-
buffer = Buffer.from(await res.arrayBuffer());
|
|
415
|
-
contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
416
|
-
const urlPath = new URL(mediaUrl).pathname;
|
|
417
|
-
filename = urlPath.split("/").pop() || "media";
|
|
418
|
-
} else {
|
|
419
|
-
// 本地文件路径
|
|
420
|
-
const fs = await import("node:fs/promises");
|
|
421
|
-
const path = await import("node:path");
|
|
422
|
-
|
|
423
|
-
buffer = await fs.readFile(mediaUrl);
|
|
424
|
-
filename = path.basename(mediaUrl);
|
|
425
|
-
|
|
426
|
-
// 根据扩展名推断 content-type
|
|
427
|
-
const ext = path.extname(mediaUrl).slice(1).toLowerCase();
|
|
428
|
-
const mimeTypes: Record<string, string> = {
|
|
429
|
-
jpg: "image/jpeg",
|
|
430
|
-
jpeg: "image/jpeg",
|
|
431
|
-
png: "image/png",
|
|
432
|
-
gif: "image/gif",
|
|
433
|
-
webp: "image/webp",
|
|
434
|
-
bmp: "image/bmp",
|
|
435
|
-
mp3: "audio/mpeg",
|
|
436
|
-
wav: "audio/wav",
|
|
437
|
-
amr: "audio/amr",
|
|
438
|
-
mp4: "video/mp4",
|
|
439
|
-
pdf: "application/pdf",
|
|
440
|
-
doc: "application/msword",
|
|
441
|
-
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
442
|
-
xls: "application/vnd.ms-excel",
|
|
443
|
-
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
444
|
-
ppt: "application/vnd.ms-powerpoint",
|
|
445
|
-
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
446
|
-
txt: "text/plain",
|
|
447
|
-
csv: "text/csv",
|
|
448
|
-
tsv: "text/tab-separated-values",
|
|
449
|
-
md: "text/markdown",
|
|
450
|
-
json: "application/json",
|
|
451
|
-
xml: "application/xml",
|
|
452
|
-
yaml: "application/yaml",
|
|
453
|
-
yml: "application/yaml",
|
|
454
|
-
zip: "application/zip",
|
|
455
|
-
rar: "application/vnd.rar",
|
|
456
|
-
"7z": "application/x-7z-compressed",
|
|
457
|
-
tar: "application/x-tar",
|
|
458
|
-
gz: "application/gzip",
|
|
459
|
-
tgz: "application/gzip",
|
|
460
|
-
rtf: "application/rtf",
|
|
461
|
-
odt: "application/vnd.oasis.opendocument.text",
|
|
462
|
-
};
|
|
463
|
-
contentType = mimeTypes[ext] || "application/octet-stream";
|
|
464
|
-
console.log(
|
|
465
|
-
`[wecom-outbound] Reading local file: ${mediaUrl}, ext=${ext}, contentType=${contentType}`,
|
|
466
|
-
);
|
|
467
|
-
}
|
|
717
|
+
const { buffer, contentType, filename } = await resolveOutboundMediaAsset({
|
|
718
|
+
mediaUrl,
|
|
719
|
+
network: agent.network,
|
|
720
|
+
});
|
|
468
721
|
|
|
469
722
|
console.log(
|
|
470
723
|
`[wecom-outbound] Sending media to ${String(to ?? "")} (filename=${filename}, contentType=${contentType})`,
|
|
@@ -132,4 +132,43 @@ describe("prepareInboundSession", () => {
|
|
|
132
132
|
expect(result.ctx.Provider).toBe("wecom");
|
|
133
133
|
expect(result.ctx).not.toHaveProperty("Surface");
|
|
134
134
|
});
|
|
135
|
+
|
|
136
|
+
it("registers SessionId for source lookups after context finalization", async () => {
|
|
137
|
+
getPeerContextToken.mockReturnValue(undefined);
|
|
138
|
+
const { core } = createCore();
|
|
139
|
+
core.channel.reply.finalizeInboundContext = vi.fn((ctx) => ({
|
|
140
|
+
...ctx,
|
|
141
|
+
SessionId: "sess-agent-1",
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
await prepareInboundSession({
|
|
145
|
+
core,
|
|
146
|
+
cfg: {} as any,
|
|
147
|
+
event: {
|
|
148
|
+
accountId: "default",
|
|
149
|
+
transport: "agent-callback",
|
|
150
|
+
messageId: "msg-agent-2",
|
|
151
|
+
conversation: {
|
|
152
|
+
peerKind: "direct",
|
|
153
|
+
peerId: "HiDaoMax",
|
|
154
|
+
senderId: "HiDaoMax",
|
|
155
|
+
},
|
|
156
|
+
senderName: "HiDaoMax",
|
|
157
|
+
text: "hello",
|
|
158
|
+
} as any,
|
|
159
|
+
mediaService: createMediaService(),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(registerWecomSourceSnapshot).toHaveBeenLastCalledWith(
|
|
163
|
+
expect.objectContaining({
|
|
164
|
+
accountId: "default",
|
|
165
|
+
source: "agent-callback",
|
|
166
|
+
messageId: "msg-agent-2",
|
|
167
|
+
sessionKey: "agent:ops_bot:wecom:direct:hidaomax",
|
|
168
|
+
sessionId: "sess-agent-1",
|
|
169
|
+
peerKind: "direct",
|
|
170
|
+
peerId: "HiDaoMax",
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
135
174
|
});
|
|
@@ -12,6 +12,11 @@ export type PreparedSession = {
|
|
|
12
12
|
storePath: string;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
function readContextSessionId(ctx: { SessionId?: string } | Record<string, unknown>): string | undefined {
|
|
16
|
+
const sessionId = "SessionId" in ctx ? ctx.SessionId : undefined;
|
|
17
|
+
return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
export async function prepareInboundSession(params: {
|
|
16
21
|
core: PluginRuntime;
|
|
17
22
|
cfg: OpenClawConfig;
|
|
@@ -111,6 +116,18 @@ export async function prepareInboundSession(params: {
|
|
|
111
116
|
MediaType: firstAttachment?.contentType,
|
|
112
117
|
});
|
|
113
118
|
|
|
119
|
+
if (source) {
|
|
120
|
+
registerWecomSourceSnapshot({
|
|
121
|
+
accountId: event.accountId,
|
|
122
|
+
source,
|
|
123
|
+
messageId: event.messageId,
|
|
124
|
+
sessionKey: ctx.SessionKey ?? route.sessionKey,
|
|
125
|
+
sessionId: readContextSessionId(ctx),
|
|
126
|
+
peerKind: event.conversation.peerKind,
|
|
127
|
+
peerId: event.conversation.peerId,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
114
131
|
await core.channel.session.recordInboundSession({
|
|
115
132
|
storePath,
|
|
116
133
|
sessionKey: ctx.SessionKey ?? route.sessionKey,
|
|
@@ -9,6 +9,7 @@ export type WecomSourceSnapshot = {
|
|
|
9
9
|
sessionId?: string;
|
|
10
10
|
peerKind?: "direct" | "group";
|
|
11
11
|
peerId?: string;
|
|
12
|
+
upstreamCorpId?: string;
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
const MAX_MESSAGE_FACTS = 2048;
|
|
@@ -116,6 +117,7 @@ export function registerWecomSourceSnapshot(params: {
|
|
|
116
117
|
sessionId?: string | null;
|
|
117
118
|
peerKind?: "direct" | "group" | null;
|
|
118
119
|
peerId?: string | null;
|
|
120
|
+
upstreamCorpId?: string | null;
|
|
119
121
|
}): void {
|
|
120
122
|
const accountId = normalizeOptional(params.accountId);
|
|
121
123
|
if (!accountId) return;
|
|
@@ -135,6 +137,9 @@ export function registerWecomSourceSnapshot(params: {
|
|
|
135
137
|
: {}),
|
|
136
138
|
...(normalizePeerKind(params.peerKind) ? { peerKind: normalizePeerKind(params.peerKind) } : {}),
|
|
137
139
|
...(normalizePeerId(params.peerId) ? { peerId: normalizePeerId(params.peerId) } : {}),
|
|
140
|
+
...(normalizeOptional(params.upstreamCorpId)
|
|
141
|
+
? { upstreamCorpId: normalizeOptional(params.upstreamCorpId) }
|
|
142
|
+
: {}),
|
|
138
143
|
};
|
|
139
144
|
|
|
140
145
|
if (snapshot.messageId) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { resolveWecomEgressProxyUrlFromNetwork } from "../config/index.js";
|
|
4
|
+
import { wecomFetch } from "../http.js";
|
|
5
|
+
import type { WecomNetworkConfig } from "../types/index.js";
|
|
6
|
+
|
|
7
|
+
function inferContentTypeFromFilePath(filePath: string): string {
|
|
8
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
9
|
+
const mimeTypes: Record<string, string> = {
|
|
10
|
+
jpg: "image/jpeg",
|
|
11
|
+
jpeg: "image/jpeg",
|
|
12
|
+
png: "image/png",
|
|
13
|
+
gif: "image/gif",
|
|
14
|
+
webp: "image/webp",
|
|
15
|
+
bmp: "image/bmp",
|
|
16
|
+
mp3: "audio/mpeg",
|
|
17
|
+
wav: "audio/wav",
|
|
18
|
+
amr: "audio/amr",
|
|
19
|
+
mp4: "video/mp4",
|
|
20
|
+
pdf: "application/pdf",
|
|
21
|
+
doc: "application/msword",
|
|
22
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
23
|
+
xls: "application/vnd.ms-excel",
|
|
24
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
25
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
26
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
27
|
+
txt: "text/plain",
|
|
28
|
+
csv: "text/csv",
|
|
29
|
+
tsv: "text/tab-separated-values",
|
|
30
|
+
md: "text/markdown",
|
|
31
|
+
json: "application/json",
|
|
32
|
+
xml: "application/xml",
|
|
33
|
+
yaml: "application/yaml",
|
|
34
|
+
yml: "application/yaml",
|
|
35
|
+
zip: "application/zip",
|
|
36
|
+
rar: "application/vnd.rar",
|
|
37
|
+
"7z": "application/x-7z-compressed",
|
|
38
|
+
tar: "application/x-tar",
|
|
39
|
+
gz: "application/gzip",
|
|
40
|
+
tgz: "application/gzip",
|
|
41
|
+
rtf: "application/rtf",
|
|
42
|
+
odt: "application/vnd.oasis.opendocument.text",
|
|
43
|
+
};
|
|
44
|
+
return mimeTypes[ext] || "application/octet-stream";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function resolveOutboundMediaAsset(params: {
|
|
48
|
+
mediaUrl: string;
|
|
49
|
+
network?: WecomNetworkConfig;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}): Promise<{ buffer: Buffer; filename: string; contentType: string }> {
|
|
52
|
+
const { mediaUrl, network, timeoutMs = 30000 } = params;
|
|
53
|
+
if (/^https?:\/\//i.test(mediaUrl)) {
|
|
54
|
+
const response = await wecomFetch(
|
|
55
|
+
mediaUrl,
|
|
56
|
+
{ method: "GET" },
|
|
57
|
+
{
|
|
58
|
+
proxyUrl: resolveWecomEgressProxyUrlFromNetwork(network),
|
|
59
|
+
timeoutMs,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(`Failed to download media: ${response.status}`);
|
|
64
|
+
}
|
|
65
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
66
|
+
const contentType = response.headers.get("content-type") || "application/octet-stream";
|
|
67
|
+
const filename = path.basename(new URL(mediaUrl).pathname) || "media";
|
|
68
|
+
return { buffer, filename, contentType };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fs = await import("node:fs/promises");
|
|
72
|
+
const buffer = await fs.readFile(mediaUrl);
|
|
73
|
+
return {
|
|
74
|
+
buffer,
|
|
75
|
+
filename: path.basename(mediaUrl),
|
|
76
|
+
contentType: inferContentTypeFromFilePath(mediaUrl),
|
|
77
|
+
};
|
|
78
|
+
}
|