@yanhaidao/wecom 2.3.260 → 2.4.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/MENU_EVENT_CONF.md +500 -0
  2. package/MENU_EVENT_PLAN.md +440 -0
  3. package/README.md +90 -8
  4. package/UPSTREAM_CONFIG.md +170 -0
  5. package/UPSTREAM_PLAN.md +175 -0
  6. package/changelog/v2.3.27.md +33 -0
  7. package/changelog/v2.4.12.md +37 -0
  8. package/index.test.ts +5 -1
  9. package/package.json +17 -17
  10. package/scripts/wecom/README.md +123 -0
  11. package/scripts/wecom/menu-click-help.js +59 -0
  12. package/scripts/wecom/menu-click-help.py +55 -0
  13. package/src/agent/event-router.test.ts +421 -0
  14. package/src/agent/event-router.ts +272 -0
  15. package/src/agent/handler.event-filter.test.ts +65 -1
  16. package/src/agent/handler.ts +375 -21
  17. package/src/agent/script-runner.ts +186 -0
  18. package/src/agent/test-fixtures/invalid-json-script.mjs +1 -0
  19. package/src/agent/test-fixtures/reply-event-script.mjs +29 -0
  20. package/src/agent/test-fixtures/reply-event-script.py +17 -0
  21. package/src/app/account-runtime.ts +1 -1
  22. package/src/app/index.ts +6 -3
  23. package/src/capability/agent/upstream-delivery-service.ts +96 -0
  24. package/src/capability/bot/sandbox-media.test.ts +221 -0
  25. package/src/capability/bot/sandbox-media.ts +176 -0
  26. package/src/capability/bot/stream-orchestrator.ts +19 -0
  27. package/src/capability/mcp/tool.ts +7 -3
  28. package/src/channel.config.test.ts +33 -0
  29. package/src/channel.meta.test.ts +14 -0
  30. package/src/channel.ts +33 -60
  31. package/src/config/accounts.ts +16 -0
  32. package/src/config/schema.ts +58 -0
  33. package/src/context-store.ts +41 -8
  34. package/src/onboarding.test.ts +42 -24
  35. package/src/onboarding.ts +598 -553
  36. package/src/outbound.test.ts +211 -2
  37. package/src/outbound.ts +340 -81
  38. package/src/runtime/session-manager.test.ts +39 -0
  39. package/src/runtime/session-manager.ts +17 -0
  40. package/src/runtime/source-registry.ts +5 -0
  41. package/src/shared/media-asset.ts +78 -0
  42. package/src/shared/media-service.test.ts +111 -0
  43. package/src/shared/media-service.ts +42 -14
  44. package/src/target.ts +40 -0
  45. package/src/transport/agent-api/client.ts +233 -0
  46. package/src/transport/agent-api/core.ts +101 -5
  47. package/src/transport/agent-api/upstream-delivery.ts +45 -0
  48. package/src/transport/agent-api/upstream-media-upload.ts +70 -0
  49. package/src/transport/agent-api/upstream-reply.ts +43 -0
  50. package/src/transport/bot-ws/media.test.ts +8 -8
  51. package/src/transport/bot-ws/media.ts +51 -2
  52. package/src/transport/bot-ws/sdk-adapter.ts +6 -6
  53. package/src/types/account.ts +2 -0
  54. package/src/types/config.ts +74 -0
  55. package/src/types/message.ts +2 -0
  56. package/src/upstream/index.ts +150 -0
  57. package/src/upstream.test.ts +84 -0
  58. package/vitest.config.ts +15 -4
@@ -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
+ }
@@ -0,0 +1,111 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { WecomMediaService } from "./media-service.js";
3
+
4
+ describe("WecomMediaService", () => {
5
+ const fetchRemoteMedia = vi.fn();
6
+ const saveMediaBuffer = vi.fn();
7
+
8
+ beforeEach(() => {
9
+ fetchRemoteMedia.mockReset();
10
+ saveMediaBuffer.mockReset();
11
+ });
12
+
13
+ it("passes configured wecom mediaMaxMb to remote attachment fetches and saves", async () => {
14
+ const service = new WecomMediaService(
15
+ {
16
+ channel: {
17
+ media: {
18
+ fetchRemoteMedia,
19
+ saveMediaBuffer,
20
+ },
21
+ },
22
+ } as never,
23
+ {
24
+ channels: {
25
+ wecom: {
26
+ mediaMaxMb: 24,
27
+ },
28
+ },
29
+ } as never,
30
+ );
31
+
32
+ fetchRemoteMedia.mockResolvedValue({
33
+ buffer: Buffer.from("file"),
34
+ contentType: "application/pdf",
35
+ fileName: "sample.pdf",
36
+ });
37
+ saveMediaBuffer.mockResolvedValue({
38
+ path: "/tmp/sample.pdf",
39
+ });
40
+
41
+ const event = {
42
+ accountId: "default",
43
+ attachments: [{ remoteUrl: "https://example.com/sample.pdf" }],
44
+ } as never;
45
+
46
+ const attachment = await service.normalizeFirstAttachment(event);
47
+
48
+ expect(fetchRemoteMedia).toHaveBeenCalledWith({
49
+ url: "https://example.com/sample.pdf",
50
+ maxBytes: 24 * 1024 * 1024,
51
+ });
52
+
53
+ await service.saveInboundAttachment(event, attachment!);
54
+
55
+ expect(saveMediaBuffer).toHaveBeenCalledWith(
56
+ expect.any(Buffer),
57
+ "application/pdf",
58
+ "inbound",
59
+ 24 * 1024 * 1024,
60
+ "sample.pdf",
61
+ );
62
+ });
63
+
64
+ it("prefers account-specific mediaMaxMb for inbound saves", async () => {
65
+ const service = new WecomMediaService(
66
+ {
67
+ channel: {
68
+ media: {
69
+ fetchRemoteMedia,
70
+ saveMediaBuffer,
71
+ },
72
+ },
73
+ } as never,
74
+ {
75
+ channels: {
76
+ wecom: {
77
+ mediaMaxMb: 24,
78
+ accounts: {
79
+ ops: {
80
+ mediaMaxMb: 36,
81
+ },
82
+ },
83
+ },
84
+ },
85
+ } as never,
86
+ );
87
+
88
+ saveMediaBuffer.mockResolvedValue({
89
+ path: "/tmp/account-specific.pdf",
90
+ });
91
+
92
+ await service.saveInboundAttachment(
93
+ {
94
+ accountId: "ops",
95
+ } as never,
96
+ {
97
+ buffer: Buffer.from("file"),
98
+ contentType: "application/pdf",
99
+ filename: "ops.pdf",
100
+ },
101
+ );
102
+
103
+ expect(saveMediaBuffer).toHaveBeenCalledWith(
104
+ expect.any(Buffer),
105
+ "application/pdf",
106
+ "inbound",
107
+ 36 * 1024 * 1024,
108
+ "ops.pdf",
109
+ );
110
+ });
111
+ });
@@ -1,14 +1,27 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
2
-
3
- import type { NormalizedMediaAttachment } from "./media-types.js";
4
- import type { UnifiedInboundEvent } from "../types/index.js";
1
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
+ import { resolveWecomMediaMaxBytes } from "../config/index.js";
5
3
  import { decryptWecomMediaWithMeta } from "../media.js";
4
+ import type { UnifiedInboundEvent } from "../types/index.js";
5
+ import type { NormalizedMediaAttachment } from "./media-types.js";
6
6
 
7
7
  export class WecomMediaService {
8
- constructor(private readonly core: PluginRuntime) {}
8
+ constructor(
9
+ private readonly core: PluginRuntime,
10
+ private readonly cfg: OpenClawConfig,
11
+ ) {}
12
+
13
+ private resolveInboundMaxBytes(accountId: string): number {
14
+ return resolveWecomMediaMaxBytes(this.cfg, accountId);
15
+ }
9
16
 
10
- async downloadRemoteMedia(params: { url: string }): Promise<NormalizedMediaAttachment> {
11
- const loaded = await this.core.channel.media.fetchRemoteMedia({ url: params.url });
17
+ async downloadRemoteMedia(params: {
18
+ url: string;
19
+ maxBytes: number;
20
+ }): Promise<NormalizedMediaAttachment> {
21
+ const loaded = await this.core.channel.media.fetchRemoteMedia({
22
+ url: params.url,
23
+ maxBytes: params.maxBytes,
24
+ });
12
25
  return {
13
26
  buffer: loaded.buffer,
14
27
  contentType: loaded.contentType,
@@ -22,8 +35,14 @@ export class WecomMediaService {
22
35
  * Bot-webhook: uses the account-level EncodingAESKey.
23
36
  * Both use AES-256-CBC with PKCS#7 padding (32-byte block), IV = key[:16].
24
37
  */
25
- async downloadEncryptedMedia(params: { url: string; aesKey: string }): Promise<NormalizedMediaAttachment> {
26
- const decrypted = await decryptWecomMediaWithMeta(params.url, params.aesKey);
38
+ async downloadEncryptedMedia(params: {
39
+ url: string;
40
+ aesKey: string;
41
+ maxBytes: number;
42
+ }): Promise<NormalizedMediaAttachment> {
43
+ const decrypted = await decryptWecomMediaWithMeta(params.url, params.aesKey, {
44
+ maxBytes: params.maxBytes,
45
+ });
27
46
  return {
28
47
  buffer: decrypted.buffer,
29
48
  contentType: decrypted.sourceContentType,
@@ -31,26 +50,35 @@ export class WecomMediaService {
31
50
  };
32
51
  }
33
52
 
34
- async saveInboundAttachment(event: UnifiedInboundEvent, attachment: NormalizedMediaAttachment): Promise<string> {
53
+ async saveInboundAttachment(
54
+ event: UnifiedInboundEvent,
55
+ attachment: NormalizedMediaAttachment,
56
+ ): Promise<string> {
57
+ const maxBytes = this.resolveInboundMaxBytes(event.accountId);
35
58
  const saved = await this.core.channel.media.saveMediaBuffer(
36
59
  attachment.buffer,
37
60
  attachment.contentType,
38
61
  "inbound",
39
- undefined,
62
+ maxBytes,
40
63
  attachment.filename,
41
64
  );
42
65
  return saved.path;
43
66
  }
44
67
 
45
- async normalizeFirstAttachment(event: UnifiedInboundEvent): Promise<NormalizedMediaAttachment | undefined> {
68
+ async normalizeFirstAttachment(
69
+ event: UnifiedInboundEvent,
70
+ ): Promise<NormalizedMediaAttachment | undefined> {
46
71
  const first = event.attachments?.[0];
47
72
  if (!first?.remoteUrl) {
48
73
  return undefined;
49
74
  }
75
+ // Keep fetch/decrypt/save on the same account-aware limit instead of falling back
76
+ // to the core media store default (5MB).
77
+ const maxBytes = this.resolveInboundMaxBytes(event.accountId);
50
78
  // Bot-ws media is AES-encrypted; use decryption when aesKey is present
51
79
  if (first.aesKey) {
52
- return this.downloadEncryptedMedia({ url: first.remoteUrl, aesKey: first.aesKey });
80
+ return this.downloadEncryptedMedia({ url: first.remoteUrl, aesKey: first.aesKey, maxBytes });
53
81
  }
54
- return this.downloadRemoteMedia({ url: first.remoteUrl });
82
+ return this.downloadRemoteMedia({ url: first.remoteUrl, maxBytes });
55
83
  }
56
84
  }
package/src/target.ts CHANGED
@@ -26,6 +26,35 @@ export interface ScopedWecomTarget {
26
26
  rawTarget: string;
27
27
  }
28
28
 
29
+ function parseUpstreamScopedTarget(raw: string): {
30
+ accountId?: string;
31
+ userId: string;
32
+ } | undefined {
33
+ const legacyScoped = raw.match(/^wecom-agent-upstream:([^:]+):([^:]+):(.+)$/i);
34
+ if (legacyScoped) {
35
+ return {
36
+ accountId: legacyScoped[1]?.trim(),
37
+ userId: legacyScoped[3]?.trim() || "",
38
+ };
39
+ }
40
+
41
+ const queryIndex = raw.indexOf("?upstream_corp=");
42
+ if (queryIndex < 0 || !raw.startsWith("wecom-agent:")) {
43
+ return undefined;
44
+ }
45
+
46
+ const pathPart = raw.slice(0, queryIndex);
47
+ const match = pathPart.match(/^wecom-agent:([^:]+):user:(.+)$/i);
48
+ if (!match) {
49
+ return undefined;
50
+ }
51
+
52
+ return {
53
+ accountId: match[1]?.trim(),
54
+ userId: match[2]?.trim() || "",
55
+ };
56
+ }
57
+
29
58
  export function buildWecomContextTarget(contextToken: string): string {
30
59
  return `wecom:context:${contextToken}`;
31
60
  }
@@ -118,6 +147,17 @@ export function resolveScopedWecomTarget(raw: string | undefined, defaultAccount
118
147
  if (!raw?.trim()) return undefined;
119
148
 
120
149
  const trimmed = raw.trim();
150
+
151
+ const upstreamScoped = parseUpstreamScopedTarget(trimmed);
152
+ if (upstreamScoped) {
153
+ const accountId = upstreamScoped.accountId || defaultAccountId;
154
+ return {
155
+ accountId,
156
+ target: { touser: upstreamScoped.userId },
157
+ rawTarget: upstreamScoped.userId,
158
+ };
159
+ }
160
+
121
161
  const agentScoped = trimmed.match(/^wecom-agent:([^:]+):(.+)$/i);
122
162
  if (agentScoped) {
123
163
  const accountId = agentScoped[1]?.trim() || defaultAccountId;
@@ -1,7 +1,9 @@
1
1
  import type { ResolvedAgentAccount } from "../../types/index.js";
2
+ import { LIMITS } from "../../types/constants.js";
2
3
  import {
3
4
  downloadMedia as downloadLegacyMedia,
4
5
  getAccessToken as getLegacyAccessToken,
6
+ getUpstreamAccessToken as getLegacyUpstreamAccessToken,
5
7
  sendMedia as sendLegacyMedia,
6
8
  sendText as sendLegacyText,
7
9
  } from "./core.js";
@@ -10,6 +12,14 @@ export async function getAgentApiAccessToken(agent: ResolvedAgentAccount): Promi
10
12
  return getLegacyAccessToken(agent);
11
13
  }
12
14
 
15
+ export async function getUpstreamAgentApiAccessToken(params: {
16
+ primaryAgent: ResolvedAgentAccount;
17
+ upstreamCorpId: string;
18
+ upstreamAgentId: number;
19
+ }): Promise<string> {
20
+ return getLegacyUpstreamAccessToken(params);
21
+ }
22
+
13
23
  export async function sendAgentApiText(params: {
14
24
  agent: ResolvedAgentAccount;
15
25
  toUser?: string;
@@ -42,3 +52,226 @@ export async function downloadAgentApiMedia(params: {
42
52
  }): Promise<{ buffer: Buffer; contentType: string; filename?: string }> {
43
53
  return downloadLegacyMedia(params);
44
54
  }
55
+
56
+ export async function downloadUpstreamAgentApiMedia(params: {
57
+ upstreamAgent: ResolvedAgentAccount;
58
+ primaryAgent: ResolvedAgentAccount;
59
+ mediaId: string;
60
+ maxBytes?: number;
61
+ }): Promise<{ buffer: Buffer; contentType: string; filename?: string }> {
62
+ const { upstreamAgent, primaryAgent, mediaId, maxBytes } = params;
63
+
64
+ const token = await getUpstreamAgentApiAccessToken({
65
+ primaryAgent,
66
+ upstreamCorpId: upstreamAgent.corpId,
67
+ upstreamAgentId: upstreamAgent.agentId!,
68
+ });
69
+ const url = `https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
70
+
71
+ const { wecomFetch, readResponseBodyAsBuffer } = await import("../../http.js");
72
+ const { resolveWecomEgressProxyUrlFromNetwork } = await import("../../config/index.js");
73
+
74
+ const res = await wecomFetch(url, undefined, {
75
+ proxyUrl: resolveWecomEgressProxyUrlFromNetwork(upstreamAgent.network),
76
+ timeoutMs: LIMITS.REQUEST_TIMEOUT_MS,
77
+ });
78
+
79
+ if (!res.ok) {
80
+ throw new Error(`download failed: ${res.status}`);
81
+ }
82
+
83
+ const contentType = res.headers.get("content-type") || "application/octet-stream";
84
+ const disposition = res.headers.get("content-disposition") || "";
85
+ const filename = (() => {
86
+ const mStar = disposition.match(/filename\*\s*=\s*([^;]+)/i);
87
+ if (mStar) {
88
+ const raw = mStar[1]!.trim().replace(/^"(.*)"$/, "$1");
89
+ const parts = raw.split("''");
90
+ const encoded = parts.length === 2 ? parts[1]! : raw;
91
+ try {
92
+ return decodeURIComponent(encoded);
93
+ } catch {
94
+ return encoded;
95
+ }
96
+ }
97
+ const m = disposition.match(/filename\s*=\s*([^;]+)/i);
98
+ if (!m) return undefined;
99
+ return m[1]!.trim().replace(/^"(.*)"$/, "$1") || undefined;
100
+ })();
101
+
102
+ if (contentType.includes("application/json")) {
103
+ const json = (await res.json()) as { errcode?: number; errmsg?: string };
104
+ throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`);
105
+ }
106
+
107
+ const buffer = await readResponseBodyAsBuffer(res, maxBytes);
108
+ return { buffer, contentType, filename };
109
+ }
110
+
111
+ /**
112
+ * 发送文本消息给上下游用户
113
+ * 使用下游企业的 access_token 和 agentId
114
+ */
115
+ export async function sendUpstreamAgentApiText(params: {
116
+ upstreamAgent: ResolvedAgentAccount;
117
+ primaryAgent: ResolvedAgentAccount;
118
+ toUser?: string;
119
+ toParty?: string;
120
+ toTag?: string;
121
+ chatId?: string;
122
+ text: string;
123
+ }): Promise<void> {
124
+ const { upstreamAgent, primaryAgent, toUser, toParty, toTag, chatId, text } = params;
125
+
126
+ // 获取下游企业的 access_token
127
+ const token = await getUpstreamAgentApiAccessToken({
128
+ primaryAgent,
129
+ upstreamCorpId: upstreamAgent.corpId,
130
+ upstreamAgentId: upstreamAgent.agentId!,
131
+ });
132
+
133
+ const useChat = Boolean(chatId);
134
+ const url = useChat
135
+ ? `https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=${encodeURIComponent(token)}`
136
+ : `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`;
137
+
138
+ const body = useChat
139
+ ? { chatid: chatId, msgtype: "text", text: { content: text } }
140
+ : {
141
+ touser: toUser,
142
+ toparty: toParty,
143
+ totag: toTag,
144
+ msgtype: "text",
145
+ agentid: upstreamAgent.agentId,
146
+ text: { content: text },
147
+ };
148
+
149
+ const { wecomFetch } = await import("../../http.js");
150
+ const { resolveWecomEgressProxyUrlFromNetwork } = await import("../../config/index.js");
151
+
152
+ const res = await wecomFetch(
153
+ url,
154
+ {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify(body),
158
+ },
159
+ {
160
+ proxyUrl: resolveWecomEgressProxyUrlFromNetwork(upstreamAgent.network),
161
+ timeoutMs: LIMITS.REQUEST_TIMEOUT_MS,
162
+ },
163
+ );
164
+
165
+ const json = (await res.json()) as {
166
+ errcode?: number;
167
+ errmsg?: string;
168
+ invaliduser?: string;
169
+ invalidparty?: string;
170
+ invalidtag?: string;
171
+ };
172
+
173
+ if (json?.errcode !== 0) {
174
+ throw new Error(`send failed: ${json?.errcode} ${json?.errmsg}`);
175
+ }
176
+
177
+ if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
178
+ const details = [
179
+ json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
180
+ json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
181
+ json.invalidtag ? `invalidtag=${json.invalidtag}` : "",
182
+ ]
183
+ .filter(Boolean)
184
+ .join(", ");
185
+ throw new Error(`send partial failure: ${details}`);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 发送媒体消息给上下游用户
191
+ * 使用下游企业的 access_token 和 agentId
192
+ */
193
+ export async function sendUpstreamAgentApiMedia(params: {
194
+ upstreamAgent: ResolvedAgentAccount;
195
+ primaryAgent: ResolvedAgentAccount;
196
+ toUser?: string;
197
+ toParty?: string;
198
+ toTag?: string;
199
+ chatId?: string;
200
+ mediaId: string;
201
+ mediaType: "image" | "voice" | "video" | "file";
202
+ title?: string;
203
+ description?: string;
204
+ }): Promise<void> {
205
+ const { upstreamAgent, primaryAgent, toUser, toParty, toTag, chatId, mediaId, mediaType, title, description } = params;
206
+
207
+ // 获取下游企业的 access_token
208
+ const token = await getUpstreamAgentApiAccessToken({
209
+ primaryAgent,
210
+ upstreamCorpId: upstreamAgent.corpId,
211
+ upstreamAgentId: upstreamAgent.agentId!,
212
+ });
213
+
214
+ console.log(
215
+ `[wecom-upstream-api] sendMedia corpId=${upstreamAgent.corpId} agentId=${upstreamAgent.agentId} ` +
216
+ `toUser=${toUser ?? ""} mediaType=${mediaType}`,
217
+ );
218
+
219
+ const useChat = Boolean(chatId);
220
+ const url = useChat
221
+ ? `https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=${encodeURIComponent(token)}`
222
+ : `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(token)}`;
223
+
224
+ const mediaPayload = mediaType === "video"
225
+ ? { media_id: mediaId, title: title ?? "Video", description: description ?? "" }
226
+ : { media_id: mediaId };
227
+
228
+ const body = useChat
229
+ ? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload }
230
+ : {
231
+ touser: toUser,
232
+ toparty: toParty,
233
+ totag: toTag,
234
+ msgtype: mediaType,
235
+ agentid: upstreamAgent.agentId,
236
+ [mediaType]: mediaPayload,
237
+ };
238
+
239
+ const { wecomFetch } = await import("../../http.js");
240
+ const { resolveWecomEgressProxyUrlFromNetwork } = await import("../../config/index.js");
241
+
242
+ const res = await wecomFetch(
243
+ url,
244
+ {
245
+ method: "POST",
246
+ headers: { "Content-Type": "application/json" },
247
+ body: JSON.stringify(body),
248
+ },
249
+ {
250
+ proxyUrl: resolveWecomEgressProxyUrlFromNetwork(upstreamAgent.network),
251
+ timeoutMs: LIMITS.REQUEST_TIMEOUT_MS,
252
+ },
253
+ );
254
+
255
+ const json = (await res.json()) as {
256
+ errcode?: number;
257
+ errmsg?: string;
258
+ invaliduser?: string;
259
+ invalidparty?: string;
260
+ invalidtag?: string;
261
+ };
262
+
263
+ if (json?.errcode !== 0) {
264
+ throw new Error(`send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
265
+ }
266
+
267
+ if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
268
+ const details = [
269
+ json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
270
+ json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
271
+ json.invalidtag ? `invalidtag=${json.invalidtag}` : "",
272
+ ]
273
+ .filter(Boolean)
274
+ .join(", ");
275
+ throw new Error(`send ${mediaType} partial failure: ${details}`);
276
+ }
277
+ }