@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
|
@@ -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(
|
|
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: {
|
|
11
|
-
|
|
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: {
|
|
26
|
-
|
|
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(
|
|
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
|
-
|
|
62
|
+
maxBytes,
|
|
40
63
|
attachment.filename,
|
|
41
64
|
);
|
|
42
65
|
return saved.path;
|
|
43
66
|
}
|
|
44
67
|
|
|
45
|
-
async normalizeFirstAttachment(
|
|
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")}`;
|