@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.
- package/MENU_EVENT_CONF.md +500 -0
- package/MENU_EVENT_PLAN.md +440 -0
- package/README.md +90 -8
- package/UPSTREAM_CONFIG.md +170 -0
- package/UPSTREAM_PLAN.md +175 -0
- package/changelog/v2.3.27.md +33 -0
- package/changelog/v2.4.12.md +37 -0
- package/index.test.ts +5 -1
- package/package.json +17 -17
- package/scripts/wecom/README.md +123 -0
- package/scripts/wecom/menu-click-help.js +59 -0
- package/scripts/wecom/menu-click-help.py +55 -0
- package/src/agent/event-router.test.ts +421 -0
- package/src/agent/event-router.ts +272 -0
- package/src/agent/handler.event-filter.test.ts +65 -1
- package/src/agent/handler.ts +375 -21
- package/src/agent/script-runner.ts +186 -0
- package/src/agent/test-fixtures/invalid-json-script.mjs +1 -0
- package/src/agent/test-fixtures/reply-event-script.mjs +29 -0
- package/src/agent/test-fixtures/reply-event-script.py +17 -0
- package/src/app/account-runtime.ts +1 -1
- package/src/app/index.ts +6 -3
- 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/capability/mcp/tool.ts +7 -3
- package/src/channel.config.test.ts +33 -0
- package/src/channel.meta.test.ts +14 -0
- package/src/channel.ts +33 -60
- package/src/config/accounts.ts +16 -0
- package/src/config/schema.ts +58 -0
- package/src/context-store.ts +41 -8
- package/src/onboarding.test.ts +42 -24
- package/src/onboarding.ts +598 -553
- package/src/outbound.test.ts +211 -2
- package/src/outbound.ts +340 -81
- 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-ws/media.test.ts +8 -8
- package/src/transport/bot-ws/media.ts +51 -2
- package/src/transport/bot-ws/sdk-adapter.ts +6 -6
- package/src/types/account.ts +2 -0
- package/src/types/config.ts +74 -0
- package/src/types/message.ts +2 -0
- package/src/upstream/index.ts +150 -0
- package/src/upstream.test.ts +84 -0
- 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(
|
|
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
|
+
}
|