@zhin.js/adapter-weixin-ilink 0.1.0
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 +96 -0
- package/client/LoginAssistPanel.tsx +200 -0
- package/client/WeixinIlinkManagement.tsx +139 -0
- package/client/index.tsx +11 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/api.ts +30 -0
- package/dist/index.js +29 -0
- package/lib/adapter.d.ts +11 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/adapter.js +14 -0
- package/lib/adapter.js.map +1 -0
- package/lib/aes-ecb.d.ts +7 -0
- package/lib/aes-ecb.d.ts.map +1 -0
- package/lib/aes-ecb.js +19 -0
- package/lib/aes-ecb.js.map +1 -0
- package/lib/bot.d.ts +37 -0
- package/lib/bot.d.ts.map +1 -0
- package/lib/bot.js +383 -0
- package/lib/bot.js.map +1 -0
- package/lib/cdn-upload.d.ts +18 -0
- package/lib/cdn-upload.d.ts.map +1 -0
- package/lib/cdn-upload.js +73 -0
- package/lib/cdn-upload.js.map +1 -0
- package/lib/cdn-url.d.ts +14 -0
- package/lib/cdn-url.d.ts.map +1 -0
- package/lib/cdn-url.js +14 -0
- package/lib/cdn-url.js.map +1 -0
- package/lib/context-store.d.ts +2 -0
- package/lib/context-store.d.ts.map +1 -0
- package/lib/context-store.js +2 -0
- package/lib/context-store.js.map +1 -0
- package/lib/credentials.d.ts +15 -0
- package/lib/credentials.d.ts.map +1 -0
- package/lib/credentials.js +49 -0
- package/lib/credentials.js.map +1 -0
- package/lib/ilink-api.d.ts +81 -0
- package/lib/ilink-api.d.ts.map +1 -0
- package/lib/ilink-api.js +273 -0
- package/lib/ilink-api.js.map +1 -0
- package/lib/ilink-config-cache.d.ts +19 -0
- package/lib/ilink-config-cache.d.ts.map +1 -0
- package/lib/ilink-config-cache.js +64 -0
- package/lib/ilink-config-cache.js.map +1 -0
- package/lib/ilink-logger.d.ts +10 -0
- package/lib/ilink-logger.d.ts.map +1 -0
- package/lib/ilink-logger.js +27 -0
- package/lib/ilink-logger.js.map +1 -0
- package/lib/ilink-meta.d.ts +13 -0
- package/lib/ilink-meta.d.ts.map +1 -0
- package/lib/ilink-meta.js +57 -0
- package/lib/ilink-meta.js.map +1 -0
- package/lib/ilink-random.d.ts +11 -0
- package/lib/ilink-random.d.ts.map +1 -0
- package/lib/ilink-random.js +16 -0
- package/lib/ilink-random.js.map +1 -0
- package/lib/ilink-redact.d.ts +21 -0
- package/lib/ilink-redact.d.ts.map +1 -0
- package/lib/ilink-redact.js +54 -0
- package/lib/ilink-redact.js.map +1 -0
- package/lib/ilink-session-guard.d.ts +16 -0
- package/lib/ilink-session-guard.d.ts.map +1 -0
- package/lib/ilink-session-guard.js +49 -0
- package/lib/ilink-session-guard.js.map +1 -0
- package/lib/ilink-types.d.ts +233 -0
- package/lib/ilink-types.d.ts.map +1 -0
- package/lib/ilink-types.js +35 -0
- package/lib/ilink-types.js.map +1 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +38 -0
- package/lib/index.js.map +1 -0
- package/lib/login-assist-routes.d.ts +7 -0
- package/lib/login-assist-routes.d.ts.map +1 -0
- package/lib/login-assist-routes.js +29 -0
- package/lib/login-assist-routes.js.map +1 -0
- package/lib/login.d.ts +6 -0
- package/lib/login.d.ts.map +1 -0
- package/lib/login.js +121 -0
- package/lib/login.js.map +1 -0
- package/lib/media-download.d.ts +19 -0
- package/lib/media-download.d.ts.map +1 -0
- package/lib/media-download.js +95 -0
- package/lib/media-download.js.map +1 -0
- package/lib/mime.d.ts +9 -0
- package/lib/mime.d.ts.map +1 -0
- package/lib/mime.js +99 -0
- package/lib/mime.js.map +1 -0
- package/lib/outbound-media.d.ts +5 -0
- package/lib/outbound-media.d.ts.map +1 -0
- package/lib/outbound-media.js +122 -0
- package/lib/outbound-media.js.map +1 -0
- package/lib/pic-decrypt.d.ts +10 -0
- package/lib/pic-decrypt.d.ts.map +1 -0
- package/lib/pic-decrypt.js +89 -0
- package/lib/pic-decrypt.js.map +1 -0
- package/lib/routes.d.ts +8 -0
- package/lib/routes.d.ts.map +1 -0
- package/lib/routes.js +29 -0
- package/lib/routes.js.map +1 -0
- package/lib/silk-transcode.d.ts +9 -0
- package/lib/silk-transcode.d.ts.map +1 -0
- package/lib/silk-transcode.js +64 -0
- package/lib/silk-transcode.js.map +1 -0
- package/lib/types.d.ts +26 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/typing-indicator.d.ts +32 -0
- package/lib/typing-indicator.d.ts.map +1 -0
- package/lib/typing-indicator.js +95 -0
- package/lib/typing-indicator.js.map +1 -0
- package/lib/upload.d.ts +43 -0
- package/lib/upload.d.ts.map +1 -0
- package/lib/upload.js +106 -0
- package/lib/upload.js.map +1 -0
- package/lib/weixin-inbound.d.ts +71 -0
- package/lib/weixin-inbound.d.ts.map +1 -0
- package/lib/weixin-inbound.js +201 -0
- package/lib/weixin-inbound.js.map +1 -0
- package/lib/weixin-send-media.d.ts +22 -0
- package/lib/weixin-send-media.d.ts.map +1 -0
- package/lib/weixin-send-media.js +54 -0
- package/lib/weixin-send-media.js.map +1 -0
- package/lib/weixin-send.d.ts +65 -0
- package/lib/weixin-send.d.ts.map +1 -0
- package/lib/weixin-send.js +181 -0
- package/lib/weixin-send.js.map +1 -0
- package/package.json +91 -0
- package/src/adapter.ts +16 -0
- package/src/aes-ecb.ts +21 -0
- package/src/bot.ts +483 -0
- package/src/cdn-upload.test.ts +177 -0
- package/src/cdn-upload.ts +87 -0
- package/src/cdn-url.ts +20 -0
- package/src/context-store.ts +8 -0
- package/src/credentials.ts +64 -0
- package/src/ilink-api.ts +349 -0
- package/src/ilink-config-cache.ts +79 -0
- package/src/ilink-logger.ts +36 -0
- package/src/ilink-meta.ts +60 -0
- package/src/ilink-random.ts +17 -0
- package/src/ilink-redact.ts +54 -0
- package/src/ilink-session-guard.ts +58 -0
- package/src/ilink-types.ts +261 -0
- package/src/index.ts +60 -0
- package/src/login-assist-routes.ts +36 -0
- package/src/login.ts +166 -0
- package/src/media-download.ts +149 -0
- package/src/mime.test.ts +74 -0
- package/src/mime.ts +105 -0
- package/src/outbound-media.ts +141 -0
- package/src/pic-decrypt.ts +101 -0
- package/src/routes.ts +39 -0
- package/src/silk-transcode.test.ts +58 -0
- package/src/silk-transcode.ts +74 -0
- package/src/silk-wasm.d.ts +6 -0
- package/src/types.ts +26 -0
- package/src/typing-indicator.ts +139 -0
- package/src/upload.test.ts +305 -0
- package/src/upload.ts +157 -0
- package/src/weixin-inbound.ts +257 -0
- package/src/weixin-send-media.ts +72 -0
- package/src/weixin-send.ts +237 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import fsSync from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
|
|
7
|
+
vi.mock("./ilink-logger.js", () => ({
|
|
8
|
+
logger: {
|
|
9
|
+
info: vi.fn(),
|
|
10
|
+
debug: vi.fn(),
|
|
11
|
+
warn: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const { mockGetUploadUrl } = vi.hoisted(() => ({
|
|
17
|
+
mockGetUploadUrl: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock("./ilink-api.js", async (importOriginal) => {
|
|
21
|
+
const actual = await importOriginal<typeof import("./ilink-api.js")>();
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
getUploadUrl: mockGetUploadUrl,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { mockFetch } = vi.hoisted(() => ({
|
|
29
|
+
mockFetch: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
32
|
+
|
|
33
|
+
function mockCdnResponse(params: {
|
|
34
|
+
ok?: boolean;
|
|
35
|
+
status?: number;
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
body?: string;
|
|
38
|
+
}) {
|
|
39
|
+
const headers = new Headers(params.headers ?? {});
|
|
40
|
+
return {
|
|
41
|
+
ok: params.ok ?? true,
|
|
42
|
+
status: params.status ?? 200,
|
|
43
|
+
headers,
|
|
44
|
+
text: () => Promise.resolve(params.body ?? ""),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
import { downloadRemoteImageToTemp, uploadFileToWeixin, uploadVideoToWeixin, uploadFileAttachmentToWeixin } from "./upload.js";
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("downloadRemoteImageToTemp", () => {
|
|
55
|
+
it("downloads and saves file to temp dir", async () => {
|
|
56
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-test-"));
|
|
57
|
+
try {
|
|
58
|
+
const imageBytes = Buffer.from("fake-image-data");
|
|
59
|
+
mockFetch.mockResolvedValueOnce({
|
|
60
|
+
ok: true,
|
|
61
|
+
status: 200,
|
|
62
|
+
arrayBuffer: () => Promise.resolve(imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength)),
|
|
63
|
+
headers: new Headers({ "content-type": "image/png" }),
|
|
64
|
+
});
|
|
65
|
+
const filePath = await downloadRemoteImageToTemp("https://example.com/photo.png", tmpDir);
|
|
66
|
+
expect(filePath).toContain(tmpDir);
|
|
67
|
+
expect(filePath).toMatch(/\.png$/);
|
|
68
|
+
const content = await fs.readFile(filePath);
|
|
69
|
+
expect(content.toString()).toBe("fake-image-data");
|
|
70
|
+
} finally {
|
|
71
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("throws on non-ok response", async () => {
|
|
76
|
+
mockFetch.mockResolvedValueOnce({
|
|
77
|
+
ok: false,
|
|
78
|
+
status: 404,
|
|
79
|
+
statusText: "Not Found",
|
|
80
|
+
});
|
|
81
|
+
await expect(
|
|
82
|
+
downloadRemoteImageToTemp("https://example.com/missing.png", "/tmp/test"),
|
|
83
|
+
).rejects.toThrow("remote media download failed");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("uploadFileToWeixin", () => {
|
|
88
|
+
it("uploads image file and returns info", async () => {
|
|
89
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-img-test-"));
|
|
90
|
+
try {
|
|
91
|
+
const filePath = path.join(tmpDir, "test.png");
|
|
92
|
+
await fs.writeFile(filePath, "fake-image-data");
|
|
93
|
+
|
|
94
|
+
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up-param" });
|
|
95
|
+
mockFetch.mockResolvedValueOnce(
|
|
96
|
+
mockCdnResponse({ headers: { "x-encrypted-param": "dl-param" } }),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const result = await uploadFileToWeixin({
|
|
100
|
+
filePath,
|
|
101
|
+
toUserId: "user1",
|
|
102
|
+
opts: { baseUrl: "https://api.com", token: "tok" },
|
|
103
|
+
cdnBaseUrl: "https://cdn.com",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(result.filekey).toBeDefined();
|
|
107
|
+
expect(result.downloadEncryptedQueryParam).toBe("dl-param");
|
|
108
|
+
expect(result.fileSize).toBe(15);
|
|
109
|
+
expect(result.fileSizeCiphertext).toBe(16);
|
|
110
|
+
} finally {
|
|
111
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("uploads via upload_full_url when upload_param is absent", async () => {
|
|
116
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-fullurl-test-"));
|
|
117
|
+
try {
|
|
118
|
+
const filePath = path.join(tmpDir, "test.png");
|
|
119
|
+
await fs.writeFile(filePath, "fake-image-data");
|
|
120
|
+
|
|
121
|
+
const fullUrl = "http://cdn.example/c2c/upload?encrypted_query_param=x&filekey=y";
|
|
122
|
+
mockGetUploadUrl.mockResolvedValueOnce({ upload_full_url: fullUrl });
|
|
123
|
+
mockFetch.mockResolvedValueOnce(
|
|
124
|
+
mockCdnResponse({ headers: { "x-encrypted-param": "dl-full" } }),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const result = await uploadFileToWeixin({
|
|
128
|
+
filePath,
|
|
129
|
+
toUserId: "user1",
|
|
130
|
+
opts: { baseUrl: "https://api.com", token: "tok" },
|
|
131
|
+
cdnBaseUrl: "https://ignored-cdn.com",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.downloadEncryptedQueryParam).toBe("dl-full");
|
|
135
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
136
|
+
const [calledUrl] = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
137
|
+
expect(calledUrl).toBe(fullUrl);
|
|
138
|
+
} finally {
|
|
139
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("throws when getUploadUrl returns neither upload_full_url nor upload_param", async () => {
|
|
144
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-fail-test-"));
|
|
145
|
+
try {
|
|
146
|
+
const filePath = path.join(tmpDir, "test.txt");
|
|
147
|
+
await fs.writeFile(filePath, "data");
|
|
148
|
+
|
|
149
|
+
mockGetUploadUrl.mockResolvedValueOnce({});
|
|
150
|
+
|
|
151
|
+
await expect(
|
|
152
|
+
uploadFileToWeixin({
|
|
153
|
+
filePath,
|
|
154
|
+
toUserId: "user1",
|
|
155
|
+
opts: { baseUrl: "https://api.com" },
|
|
156
|
+
cdnBaseUrl: "https://cdn.com",
|
|
157
|
+
}),
|
|
158
|
+
).rejects.toThrow("no upload URL");
|
|
159
|
+
} finally {
|
|
160
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("retries CDN upload on server error then succeeds", async () => {
|
|
165
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-retry-test-"));
|
|
166
|
+
try {
|
|
167
|
+
const filePath = path.join(tmpDir, "data.bin");
|
|
168
|
+
await fs.writeFile(filePath, "some data for upload");
|
|
169
|
+
|
|
170
|
+
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up" });
|
|
171
|
+
|
|
172
|
+
mockFetch.mockResolvedValueOnce(
|
|
173
|
+
mockCdnResponse({
|
|
174
|
+
ok: false,
|
|
175
|
+
status: 500,
|
|
176
|
+
headers: { "x-error-message": "server busy" },
|
|
177
|
+
body: "server busy",
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
mockFetch.mockResolvedValueOnce(
|
|
181
|
+
mockCdnResponse({ headers: { "x-encrypted-param": "dl-retry" } }),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const result = await uploadFileToWeixin({
|
|
185
|
+
filePath,
|
|
186
|
+
toUserId: "user1",
|
|
187
|
+
opts: { baseUrl: "https://api.com" },
|
|
188
|
+
cdnBaseUrl: "https://cdn.com",
|
|
189
|
+
});
|
|
190
|
+
expect(result.downloadEncryptedQueryParam).toBe("dl-retry");
|
|
191
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
192
|
+
} finally {
|
|
193
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("throws immediately on CDN client error (4xx)", async () => {
|
|
198
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-4xx-test-"));
|
|
199
|
+
try {
|
|
200
|
+
const filePath = path.join(tmpDir, "data.bin");
|
|
201
|
+
await fs.writeFile(filePath, "data");
|
|
202
|
+
|
|
203
|
+
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up" });
|
|
204
|
+
mockFetch.mockResolvedValueOnce(
|
|
205
|
+
mockCdnResponse({
|
|
206
|
+
ok: false,
|
|
207
|
+
status: 403,
|
|
208
|
+
headers: { "x-error-message": "forbidden" },
|
|
209
|
+
body: "forbidden",
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await expect(
|
|
214
|
+
uploadFileToWeixin({
|
|
215
|
+
filePath,
|
|
216
|
+
toUserId: "user1",
|
|
217
|
+
opts: { baseUrl: "https://api.com" },
|
|
218
|
+
cdnBaseUrl: "https://cdn.com",
|
|
219
|
+
}),
|
|
220
|
+
).rejects.toThrow("client error");
|
|
221
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
222
|
+
} finally {
|
|
223
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("throws when CDN returns no x-encrypted-param after all retries", async () => {
|
|
228
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-no-param-test-"));
|
|
229
|
+
try {
|
|
230
|
+
const filePath = path.join(tmpDir, "data.bin");
|
|
231
|
+
await fs.writeFile(filePath, "data");
|
|
232
|
+
|
|
233
|
+
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up" });
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < 3; i++) {
|
|
236
|
+
mockFetch.mockResolvedValueOnce(mockCdnResponse({ headers: {} }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await expect(
|
|
240
|
+
uploadFileToWeixin({
|
|
241
|
+
filePath,
|
|
242
|
+
toUserId: "user1",
|
|
243
|
+
opts: { baseUrl: "https://api.com" },
|
|
244
|
+
cdnBaseUrl: "https://cdn.com",
|
|
245
|
+
}),
|
|
246
|
+
).rejects.toThrow("x-encrypted-param");
|
|
247
|
+
} finally {
|
|
248
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("uploadVideoToWeixin", () => {
|
|
254
|
+
it("uploads video file", async () => {
|
|
255
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-vid-test-"));
|
|
256
|
+
try {
|
|
257
|
+
const filePath = path.join(tmpDir, "clip.mp4");
|
|
258
|
+
await fs.writeFile(filePath, "fake-video-content");
|
|
259
|
+
|
|
260
|
+
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up-vid" });
|
|
261
|
+
mockFetch.mockResolvedValueOnce(
|
|
262
|
+
mockCdnResponse({ headers: { "x-encrypted-param": "dl-vid" } }),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const result = await uploadVideoToWeixin({
|
|
266
|
+
filePath,
|
|
267
|
+
toUserId: "user1",
|
|
268
|
+
opts: { baseUrl: "https://api.com" },
|
|
269
|
+
cdnBaseUrl: "https://cdn.com",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(result.filekey).toBeDefined();
|
|
273
|
+
expect(result.downloadEncryptedQueryParam).toBe("dl-vid");
|
|
274
|
+
} finally {
|
|
275
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("uploadFileAttachmentToWeixin", () => {
|
|
281
|
+
it("uploads file attachment", async () => {
|
|
282
|
+
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-att-test-"));
|
|
283
|
+
try {
|
|
284
|
+
const filePath = path.join(tmpDir, "doc.pdf");
|
|
285
|
+
await fs.writeFile(filePath, "pdf-content");
|
|
286
|
+
|
|
287
|
+
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up" });
|
|
288
|
+
mockFetch.mockResolvedValueOnce(
|
|
289
|
+
mockCdnResponse({ headers: { "x-encrypted-param": "dl" } }),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const result = await uploadFileAttachmentToWeixin({
|
|
293
|
+
filePath,
|
|
294
|
+
fileName: "doc.pdf",
|
|
295
|
+
toUserId: "user1",
|
|
296
|
+
opts: { baseUrl: "https://api.com" },
|
|
297
|
+
cdnBaseUrl: "https://cdn.com",
|
|
298
|
+
});
|
|
299
|
+
expect(result.filekey).toBeDefined();
|
|
300
|
+
expect(result.downloadEncryptedQueryParam).toBe("dl");
|
|
301
|
+
} finally {
|
|
302
|
+
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
package/src/upload.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { getUploadUrl } from "./ilink-api.js";
|
|
6
|
+
import type { WeixinApiOptions } from "./ilink-api.js";
|
|
7
|
+
import { aesEcbPaddedSize } from "./aes-ecb.js";
|
|
8
|
+
import { uploadBufferToCdn } from "./cdn-upload.js";
|
|
9
|
+
import { logger } from "./ilink-logger.js";
|
|
10
|
+
import { getExtensionFromContentTypeOrUrl } from "./mime.js";
|
|
11
|
+
import { tempFileName } from "./ilink-random.js";
|
|
12
|
+
import { UploadMediaType } from "./ilink-types.js";
|
|
13
|
+
|
|
14
|
+
export type UploadedFileInfo = {
|
|
15
|
+
filekey: string;
|
|
16
|
+
/** 由 upload_param 上传后 CDN 返回的下载加密参数; fill into ImageItem.media.encrypt_query_param */
|
|
17
|
+
downloadEncryptedQueryParam: string;
|
|
18
|
+
/** AES-128-ECB key, hex-encoded; convert to base64 for CDNMedia.aes_key */
|
|
19
|
+
aeskey: string;
|
|
20
|
+
/** Plaintext file size in bytes */
|
|
21
|
+
fileSize: number;
|
|
22
|
+
/** Ciphertext file size in bytes (AES-128-ECB with PKCS7 padding); use for ImageItem.hd_size / mid_size */
|
|
23
|
+
fileSizeCiphertext: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Download a remote media URL (image, video, file) to a local temp file in destDir.
|
|
28
|
+
* Returns the local file path; extension is inferred from Content-Type / URL.
|
|
29
|
+
*/
|
|
30
|
+
export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
|
|
31
|
+
logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
|
|
32
|
+
const res = await fetch(url);
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
|
|
35
|
+
logger.error(`downloadRemoteImageToTemp: ${msg}`);
|
|
36
|
+
throw new Error(msg);
|
|
37
|
+
}
|
|
38
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
39
|
+
logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
|
|
40
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
41
|
+
const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
|
|
42
|
+
const name = tempFileName("weixin-remote", ext);
|
|
43
|
+
const filePath = path.join(destDir, name);
|
|
44
|
+
await fs.writeFile(filePath, buf);
|
|
45
|
+
logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
|
|
46
|
+
return filePath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.
|
|
51
|
+
*/
|
|
52
|
+
async function uploadMediaToCdn(params: {
|
|
53
|
+
filePath: string;
|
|
54
|
+
toUserId: string;
|
|
55
|
+
opts: WeixinApiOptions;
|
|
56
|
+
cdnBaseUrl: string;
|
|
57
|
+
mediaType: (typeof UploadMediaType)[keyof typeof UploadMediaType];
|
|
58
|
+
label: string;
|
|
59
|
+
}): Promise<UploadedFileInfo> {
|
|
60
|
+
const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
|
|
61
|
+
|
|
62
|
+
const plaintext = await fs.readFile(filePath);
|
|
63
|
+
const rawsize = plaintext.length;
|
|
64
|
+
const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
|
|
65
|
+
const filesize = aesEcbPaddedSize(rawsize);
|
|
66
|
+
const filekey = crypto.randomBytes(16).toString("hex");
|
|
67
|
+
const aeskey = crypto.randomBytes(16);
|
|
68
|
+
|
|
69
|
+
logger.debug(
|
|
70
|
+
`${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const uploadUrlResp = await getUploadUrl({
|
|
74
|
+
...opts,
|
|
75
|
+
filekey,
|
|
76
|
+
media_type: mediaType,
|
|
77
|
+
to_user_id: toUserId,
|
|
78
|
+
rawsize,
|
|
79
|
+
rawfilemd5,
|
|
80
|
+
filesize,
|
|
81
|
+
no_need_thumb: true,
|
|
82
|
+
aeskey: aeskey.toString("hex"),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const uploadFullUrl = uploadUrlResp.upload_full_url?.trim();
|
|
86
|
+
const uploadParam = uploadUrlResp.upload_param;
|
|
87
|
+
if (!uploadFullUrl && !uploadParam) {
|
|
88
|
+
logger.error(
|
|
89
|
+
`${label}: getUploadUrl returned no upload URL (need upload_full_url or upload_param), resp=${JSON.stringify(uploadUrlResp)}`,
|
|
90
|
+
);
|
|
91
|
+
throw new Error(`${label}: getUploadUrl returned no upload URL`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
|
|
95
|
+
buf: plaintext,
|
|
96
|
+
uploadFullUrl: uploadFullUrl || undefined,
|
|
97
|
+
uploadParam: uploadParam ?? undefined,
|
|
98
|
+
filekey,
|
|
99
|
+
cdnBaseUrl,
|
|
100
|
+
aeskey,
|
|
101
|
+
label: `${label}[orig filekey=${filekey}]`,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
filekey,
|
|
106
|
+
downloadEncryptedQueryParam,
|
|
107
|
+
aeskey: aeskey.toString("hex"),
|
|
108
|
+
fileSize: rawsize,
|
|
109
|
+
fileSizeCiphertext: filesize,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
|
|
114
|
+
export async function uploadFileToWeixin(params: {
|
|
115
|
+
filePath: string;
|
|
116
|
+
toUserId: string;
|
|
117
|
+
opts: WeixinApiOptions;
|
|
118
|
+
cdnBaseUrl: string;
|
|
119
|
+
}): Promise<UploadedFileInfo> {
|
|
120
|
+
return uploadMediaToCdn({
|
|
121
|
+
...params,
|
|
122
|
+
mediaType: UploadMediaType.IMAGE,
|
|
123
|
+
label: "uploadFileToWeixin",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Upload a local video file to the Weixin CDN. */
|
|
128
|
+
export async function uploadVideoToWeixin(params: {
|
|
129
|
+
filePath: string;
|
|
130
|
+
toUserId: string;
|
|
131
|
+
opts: WeixinApiOptions;
|
|
132
|
+
cdnBaseUrl: string;
|
|
133
|
+
}): Promise<UploadedFileInfo> {
|
|
134
|
+
return uploadMediaToCdn({
|
|
135
|
+
...params,
|
|
136
|
+
mediaType: UploadMediaType.VIDEO,
|
|
137
|
+
label: "uploadVideoToWeixin",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Upload a local file attachment (non-image, non-video) to the Weixin CDN.
|
|
143
|
+
* Uses media_type=FILE; no thumbnail required.
|
|
144
|
+
*/
|
|
145
|
+
export async function uploadFileAttachmentToWeixin(params: {
|
|
146
|
+
filePath: string;
|
|
147
|
+
fileName: string;
|
|
148
|
+
toUserId: string;
|
|
149
|
+
opts: WeixinApiOptions;
|
|
150
|
+
cdnBaseUrl: string;
|
|
151
|
+
}): Promise<UploadedFileInfo> {
|
|
152
|
+
return uploadMediaToCdn({
|
|
153
|
+
...params,
|
|
154
|
+
mediaType: UploadMediaType.FILE,
|
|
155
|
+
label: "uploadFileAttachmentToWeixin",
|
|
156
|
+
});
|
|
157
|
+
}
|