@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
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:/i.test(String(raw ?? "").trim());
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
- const rawTo = typeof to === "string" ? to.trim().toLowerCase() : "";
307
- const isAgentSessionTarget = rawTo.startsWith("wecom-agent:");
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
- console.log(`[wecom-outbound] Successfully sent Agent text to ${String(to ?? "")}`);
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
- let buffer: Buffer;
403
- let contentType: string;
404
- let filename: string;
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
+ }