@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
@@ -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
+ }
@@ -19,7 +19,7 @@ function truncateForLog(raw: string, maxChars = 180): string {
19
19
  return `${compact.slice(0, maxChars)}...(truncated)`;
20
20
  }
21
21
 
22
- function normalizeUploadFilename(filename: string): string {
22
+ export function normalizeUploadFilename(filename: string): string {
23
23
  const trimmed = filename.trim();
24
24
  if (!trimmed) return "file.bin";
25
25
  const ext = trimmed.includes(".") ? `.${trimmed.split(".").pop()!.toLowerCase()}` : "";
@@ -35,7 +35,7 @@ function normalizeUploadFilename(filename: string): string {
35
35
  return `${safeBase}${safeExt || ".bin"}`;
36
36
  }
37
37
 
38
- function guessUploadContentType(filename: string): string {
38
+ export function guessUploadContentType(filename: string): string {
39
39
  const ext = filename.split(".").pop()?.toLowerCase() || "";
40
40
  const contentTypeMap: Record<string, string> = {
41
41
  jpg: "image/jpg",
@@ -83,6 +83,10 @@ function requireAgentId(agent: ResolvedAgentAccount): number {
83
83
  throw new Error(`wecom agent account=${agent.accountId} missing agentId; sending via cgi-bin/message/send requires agentId`);
84
84
  }
85
85
 
86
+ /**
87
+ * 获取主企业的 access_token
88
+ * 使用 corpid + corpsecret
89
+ */
86
90
  export async function getAccessToken(agent: ResolvedAgentAccount): Promise<string> {
87
91
  const cacheKey = `${agent.corpId}:${String(agent.agentId ?? "na")}`;
88
92
  let cache = tokenCaches.get(cacheKey);
@@ -104,6 +108,7 @@ export async function getAccessToken(agent: ResolvedAgentAccount): Promise<strin
104
108
  cache.refreshPromise = (async () => {
105
109
  try {
106
110
  const url = `${API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
111
+
107
112
  const res = await wecomFetch(url, undefined, {
108
113
  proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network),
109
114
  timeoutMs: LIMITS.REQUEST_TIMEOUT_MS,
@@ -125,6 +130,97 @@ export async function getAccessToken(agent: ResolvedAgentAccount): Promise<strin
125
130
  return cache.refreshPromise;
126
131
  }
127
132
 
133
+ /**
134
+ * 获取下游企业的 access_token
135
+ *
136
+ * 根据企业微信文档:https://developer.work.weixin.qq.com/document/path/95816
137
+ *
138
+ * 请求方式:POST(HTTPS)
139
+ * 请求地址:https://qyapi.weixin.qq.com/cgi-bin/corpgroup/corp/gettoken?access_token=ACCESS_TOKEN
140
+ *
141
+ * 请求体:
142
+ * {
143
+ * "corpid": "下游企业corpid",
144
+ * "business_type": 1, // 1 表示上下游企业
145
+ * "agentid": 下游企业应用ID
146
+ * }
147
+ *
148
+ * 注意:需要使用上游企业的 access_token 作为调用凭证
149
+ */
150
+ export async function getUpstreamAccessToken(params: {
151
+ primaryAgent: ResolvedAgentAccount;
152
+ upstreamCorpId: string;
153
+ upstreamAgentId: number;
154
+ }): Promise<string> {
155
+ const { primaryAgent, upstreamCorpId, upstreamAgentId } = params;
156
+
157
+ // 缓存 key 增加 primaryCorpId 维度,避免多主企业之间碰撞
158
+ const cacheKey = `upstream:${primaryAgent.corpId}:${upstreamCorpId}:${upstreamAgentId}`;
159
+ let cache = tokenCaches.get(cacheKey);
160
+
161
+ if (!cache) {
162
+ cache = { token: "", expiresAt: 0, refreshPromise: null };
163
+ tokenCaches.set(cacheKey, cache);
164
+ }
165
+
166
+ const now = Date.now();
167
+ if (cache.token && cache.expiresAt > now + LIMITS.TOKEN_REFRESH_BUFFER_MS) {
168
+ return cache.token;
169
+ }
170
+
171
+ if (cache.refreshPromise) {
172
+ return cache.refreshPromise;
173
+ }
174
+
175
+ cache.refreshPromise = (async () => {
176
+ try {
177
+ // 1. 先获取上游企业的 access_token
178
+ const primaryToken = await getAccessToken(primaryAgent);
179
+
180
+ // 2. 调用 corpgroup/corp/gettoken 获取下游企业的 access_token
181
+ const url = `https://qyapi.weixin.qq.com/cgi-bin/corpgroup/corp/gettoken?access_token=${encodeURIComponent(primaryToken)}`;
182
+
183
+ const requestBody = {
184
+ corpid: upstreamCorpId,
185
+ business_type: 1, // 1 表示上下游企业
186
+ agentid: upstreamAgentId,
187
+ };
188
+
189
+ const res = await wecomFetch(
190
+ url,
191
+ {
192
+ method: "POST",
193
+ headers: { "Content-Type": "application/json" },
194
+ body: JSON.stringify(requestBody),
195
+ },
196
+ {
197
+ proxyUrl: resolveWecomEgressProxyUrlFromNetwork(primaryAgent.network),
198
+ timeoutMs: LIMITS.REQUEST_TIMEOUT_MS,
199
+ },
200
+ );
201
+
202
+ const json = (await res.json()) as {
203
+ access_token?: string;
204
+ expires_in?: number;
205
+ errcode?: number;
206
+ errmsg?: string
207
+ };
208
+
209
+ if (!json?.access_token) {
210
+ throw new Error(`get upstream token failed: ${json?.errcode} ${json?.errmsg}`);
211
+ }
212
+
213
+ cache!.token = json.access_token;
214
+ cache!.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000;
215
+ return cache!.token;
216
+ } finally {
217
+ cache!.refreshPromise = null;
218
+ }
219
+ })();
220
+
221
+ return cache.refreshPromise;
222
+ }
223
+
128
224
  export async function sendText(params: {
129
225
  agent: ResolvedAgentAccount;
130
226
  toUser?: string;
@@ -135,7 +231,7 @@ export async function sendText(params: {
135
231
  }): Promise<void> {
136
232
  const { agent, toUser, toParty, toTag, chatId, text } = params;
137
233
  console.log(
138
- `[wecom-agent-api] sendText request account=${agent.accountId} agentId=${String(agent.agentId ?? "N/A")} ` +
234
+ `[wecom-agent-api] sendText request account=${agent.accountId} agentId=${String(agent.agentId ?? "N/A")} corpId=${agent.corpId} ` +
139
235
  `toUser=${toUser ?? ""} toParty=${toParty ?? ""} toTag=${toTag ?? ""} chatId=${chatId ?? ""} ` +
140
236
  `textLen=${text.length} textPreview=${JSON.stringify(truncateForLog(text))}`,
141
237
  );
@@ -175,7 +271,7 @@ export async function sendText(params: {
175
271
  };
176
272
 
177
273
  console.log(
178
- `[wecom-agent-api] sendText response account=${agent.accountId} agentId=${String(agent.agentId ?? "N/A")} ` +
274
+ `[wecom-agent-api] sendText response account=${agent.accountId} agentId=${String(agent.agentId ?? "N/A")} corpId=${agent.corpId} ` +
179
275
  `toUser=${toUser ?? ""} toParty=${toParty ?? ""} toTag=${toTag ?? ""} chatId=${chatId ?? ""} ` +
180
276
  `errcode=${String(json?.errcode ?? "N/A")} errmsg=${json?.errmsg ?? ""} ` +
181
277
  `invaliduser=${json?.invaliduser ?? ""} invalidparty=${json?.invalidparty ?? ""} invalidtag=${json?.invalidtag ?? ""}`,
@@ -209,7 +305,7 @@ export async function uploadMedia(params: {
209
305
  const proxyUrl = resolveWecomEgressProxyUrlFromNetwork(agent.network);
210
306
  const url = `${API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}&debug=1`;
211
307
 
212
- console.log(`[wecom-upload] Uploading media: type=${type}, filename=${safeFilename}, size=${buffer.length} bytes`);
308
+ console.log(`[wecom-upload] Uploading media: type=${type}, filename=${safeFilename}, size=${buffer.length} bytes, corpId=${agent.corpId}`);
213
309
 
214
310
  const uploadOnce = async (fileContentType: string) => {
215
311
  const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;