@zhin.js/adapter-weixin-ilink 1.1.10 → 1.1.13
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/CHANGELOG.md +44 -0
- package/README.md +10 -8
- package/package.json +11 -10
- package/src/cdn-upload.test.ts +0 -177
- package/src/mime.test.ts +0 -74
- package/src/silk-transcode.test.ts +0 -58
- package/src/upload.test.ts +0 -308
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# @zhin.js/adapter-weixin-ilink
|
|
2
2
|
|
|
3
|
+
## 1.1.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [db06de8]
|
|
8
|
+
- @zhin.js/core@1.1.48
|
|
9
|
+
|
|
10
|
+
## 1.1.12
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- f30be42: Stop publishing unit test files (`src/**/*.test.ts`, `client/**/*.test.ts`) in the npm tarball.
|
|
15
|
+
- Updated dependencies [5be09ae]
|
|
16
|
+
- Updated dependencies [a3ab153]
|
|
17
|
+
- Updated dependencies [e8f7309]
|
|
18
|
+
- Updated dependencies [5afc18f]
|
|
19
|
+
- Updated dependencies [d3563d9]
|
|
20
|
+
- Updated dependencies [c618eb7]
|
|
21
|
+
- Updated dependencies [fbfefb7]
|
|
22
|
+
- @zhin.js/core@1.1.47
|
|
23
|
+
- @zhin.js/adapter@1.1.21
|
|
24
|
+
- @zhin.js/feature-kit@1.1.7
|
|
25
|
+
- @zhin.js/im-contract@1.1.5
|
|
26
|
+
|
|
27
|
+
## 1.1.11
|
|
28
|
+
|
|
29
|
+
### Patch Changes
|
|
30
|
+
|
|
31
|
+
- 9816c18: 将应用插件装配改为 configVersion: 2:主配置 plugins 映射实例名到包,disable_plugins 控制启停,业务配置使用顶层同名块。package.json 继续保留 features、entry、engine 等包契约,并支持静态 displayName/icon。
|
|
32
|
+
|
|
33
|
+
这是配置格式的破坏性更新:旧配置不能继续运行,请先停止 Bot,运行 `zhin migrate instances` 预览,再使用 `--apply` 转换;提供备份与中断继续/回滚,无法无损转换的嵌套、optional 和别名碰撞需人工迁移。Runtime、Console、CLI 与创建向导统一使用新声明。
|
|
34
|
+
|
|
35
|
+
保留框架顶层 `menuKeyword` / `menuStyle`,通过当前 generation 的 Root 配置视图供 Command Feature 消费;严格校验和配置修改不会丢失菜单设置,子实例配置保持隔离。
|
|
36
|
+
|
|
37
|
+
- Updated dependencies [c407bd2]
|
|
38
|
+
- Updated dependencies [064b06d]
|
|
39
|
+
- Updated dependencies [b58548a]
|
|
40
|
+
- Updated dependencies [e914a93]
|
|
41
|
+
- Updated dependencies [e914a93]
|
|
42
|
+
- Updated dependencies [064b06d]
|
|
43
|
+
- @zhin.js/core@1.1.46
|
|
44
|
+
- @zhin.js/feature-kit@1.1.6
|
|
45
|
+
- @zhin.js/adapter@1.1.20
|
|
46
|
+
|
|
3
47
|
## 1.1.10
|
|
4
48
|
|
|
5
49
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -23,15 +23,17 @@
|
|
|
23
23
|
## 最小配置
|
|
24
24
|
|
|
25
25
|
```yaml
|
|
26
|
+
configVersion: 2
|
|
26
27
|
plugins:
|
|
27
|
-
weixin-ilink:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
weixin-ilink: "@zhin.js/adapter-weixin-ilink"
|
|
29
|
+
weixin-ilink:
|
|
30
|
+
# botAgent: "Zhin.js/1.0.0"
|
|
31
|
+
# longPollTimeoutMs: 35000
|
|
32
|
+
# baseUrl: https://ilinkai.weixin.qq.com
|
|
33
|
+
# dataDir: data/weixin-ilink
|
|
34
|
+
endpoints:
|
|
35
|
+
- id: my-wechat
|
|
36
|
+
# botToken: "..." # 可选;不填则读取状态文件或扫码
|
|
35
37
|
```
|
|
36
38
|
|
|
37
39
|
`AdapterIndex` 会把实例默认值与 endpoint 覆盖值合并后再调用 adapter。协议层只接受这份
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-weixin-ilink",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zhin.js Weixin iLink (ClawBot) adapter for Plugin Runtime (long-poll)",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"plugin.js",
|
|
33
33
|
"schema.json",
|
|
34
34
|
"src",
|
|
35
|
+
"!src/**/*.test.ts",
|
|
35
36
|
"lib",
|
|
36
37
|
"README.md",
|
|
37
38
|
"CHANGELOG.md"
|
|
@@ -47,17 +48,17 @@
|
|
|
47
48
|
}
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
|
-
"@zhin.js/adapter": "1.1.
|
|
51
|
-
"@zhin.js/core": "1.1.
|
|
52
|
-
"@zhin.js/feature-kit": "1.1.
|
|
53
|
-
"@zhin.js/im-contract": "1.1.
|
|
51
|
+
"@zhin.js/adapter": "1.1.21",
|
|
52
|
+
"@zhin.js/core": "1.1.48",
|
|
53
|
+
"@zhin.js/feature-kit": "1.1.7",
|
|
54
|
+
"@zhin.js/im-contract": "1.1.5",
|
|
54
55
|
"@zhin.js/logger": "1.1.2"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
57
|
-
"@zhin.js/adapter": "^1.1.
|
|
58
|
-
"@zhin.js/command": "^1.1.
|
|
59
|
-
"@zhin.js/core": "^1.1.
|
|
60
|
-
"zhin.js": "^1.1.
|
|
58
|
+
"@zhin.js/adapter": "^1.1.21",
|
|
59
|
+
"@zhin.js/command": "^1.1.9",
|
|
60
|
+
"@zhin.js/core": "^1.1.48",
|
|
61
|
+
"zhin.js": "^1.1.13"
|
|
61
62
|
},
|
|
62
63
|
"peerDependenciesMeta": {
|
|
63
64
|
"@zhin.js/command": {
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
"@types/node": "^26.1.2",
|
|
72
73
|
"typescript": "^6.0.3",
|
|
73
74
|
"vitest": "^4.1.10",
|
|
74
|
-
"zhin.js": "1.1.
|
|
75
|
+
"zhin.js": "1.1.13"
|
|
75
76
|
},
|
|
76
77
|
"ilink_appid": "bot",
|
|
77
78
|
"engines": {
|
package/src/cdn-upload.test.ts
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
vi.mock("../util/logger.js", () => ({
|
|
4
|
-
logger: {
|
|
5
|
-
info: vi.fn(),
|
|
6
|
-
debug: vi.fn(),
|
|
7
|
-
warn: vi.fn(),
|
|
8
|
-
error: vi.fn(),
|
|
9
|
-
},
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
const { mockFetch } = vi.hoisted(() => ({
|
|
13
|
-
mockFetch: vi.fn(),
|
|
14
|
-
}));
|
|
15
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
16
|
-
|
|
17
|
-
import { encryptAesEcb, aesEcbPaddedSize } from "./aes-ecb.js";
|
|
18
|
-
import { uploadBufferToCdn } from "./cdn-upload.js";
|
|
19
|
-
import crypto from "node:crypto";
|
|
20
|
-
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
vi.clearAllMocks();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe("aesEcbPaddedSize", () => {
|
|
26
|
-
it("pads to 16-byte boundary", () => {
|
|
27
|
-
expect(aesEcbPaddedSize(0)).toBe(16);
|
|
28
|
-
expect(aesEcbPaddedSize(1)).toBe(16);
|
|
29
|
-
expect(aesEcbPaddedSize(15)).toBe(16);
|
|
30
|
-
expect(aesEcbPaddedSize(16)).toBe(32);
|
|
31
|
-
expect(aesEcbPaddedSize(31)).toBe(32);
|
|
32
|
-
expect(aesEcbPaddedSize(32)).toBe(48);
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("encryptAesEcb", () => {
|
|
37
|
-
it("encrypts and produces correct ciphertext length", () => {
|
|
38
|
-
const key = crypto.randomBytes(16);
|
|
39
|
-
const plaintext = Buffer.from("hello world 1234");
|
|
40
|
-
const ct = encryptAesEcb(plaintext, key);
|
|
41
|
-
// 16 bytes plaintext + PKCS7 padding = 32 bytes ciphertext
|
|
42
|
-
expect(ct.length).toBe(32);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe("uploadBufferToCdn", () => {
|
|
47
|
-
const aeskey = crypto.randomBytes(16);
|
|
48
|
-
|
|
49
|
-
it("uploads successfully and returns downloadParam", async () => {
|
|
50
|
-
mockFetch.mockResolvedValueOnce({
|
|
51
|
-
ok: true,
|
|
52
|
-
status: 200,
|
|
53
|
-
headers: new Headers({ "x-encrypted-param": "dl-param" }),
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const result = await uploadBufferToCdn({
|
|
57
|
-
buf: Buffer.from("data"),
|
|
58
|
-
uploadParam: "up",
|
|
59
|
-
filekey: "fk",
|
|
60
|
-
cdnBaseUrl: "https://cdn.com",
|
|
61
|
-
label: "test",
|
|
62
|
-
aeskey,
|
|
63
|
-
});
|
|
64
|
-
expect(result.downloadParam).toBe("dl-param");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("POSTs to uploadFullUrl when provided", async () => {
|
|
68
|
-
mockFetch.mockResolvedValueOnce({
|
|
69
|
-
ok: true,
|
|
70
|
-
status: 200,
|
|
71
|
-
headers: new Headers({ "x-encrypted-param": "dl-full" }),
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const fullUrl = "http://host/c2c/upload?q=1";
|
|
75
|
-
const result = await uploadBufferToCdn({
|
|
76
|
-
buf: Buffer.from("data"),
|
|
77
|
-
uploadFullUrl: fullUrl,
|
|
78
|
-
filekey: "fk",
|
|
79
|
-
cdnBaseUrl: "https://unused.example",
|
|
80
|
-
label: "test",
|
|
81
|
-
aeskey,
|
|
82
|
-
});
|
|
83
|
-
expect(result.downloadParam).toBe("dl-full");
|
|
84
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
85
|
-
fullUrl,
|
|
86
|
-
expect.objectContaining({ method: "POST" }),
|
|
87
|
-
);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("retries on server error then succeeds", async () => {
|
|
91
|
-
mockFetch
|
|
92
|
-
.mockResolvedValueOnce({
|
|
93
|
-
ok: false,
|
|
94
|
-
status: 500,
|
|
95
|
-
headers: new Headers({ "x-error-message": "busy" }),
|
|
96
|
-
text: () => Promise.resolve("busy"),
|
|
97
|
-
})
|
|
98
|
-
.mockResolvedValueOnce({
|
|
99
|
-
ok: true,
|
|
100
|
-
status: 200,
|
|
101
|
-
headers: new Headers({ "x-encrypted-param": "dl" }),
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const result = await uploadBufferToCdn({
|
|
105
|
-
buf: Buffer.from("data"),
|
|
106
|
-
uploadParam: "up",
|
|
107
|
-
filekey: "fk",
|
|
108
|
-
cdnBaseUrl: "https://cdn.com",
|
|
109
|
-
label: "test",
|
|
110
|
-
aeskey,
|
|
111
|
-
});
|
|
112
|
-
expect(result.downloadParam).toBe("dl");
|
|
113
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("throws immediately on 4xx client error", async () => {
|
|
117
|
-
mockFetch.mockResolvedValueOnce({
|
|
118
|
-
ok: false,
|
|
119
|
-
status: 403,
|
|
120
|
-
headers: new Headers({}),
|
|
121
|
-
text: () => Promise.resolve("forbidden"),
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
await expect(
|
|
125
|
-
uploadBufferToCdn({
|
|
126
|
-
buf: Buffer.from("data"),
|
|
127
|
-
uploadParam: "up",
|
|
128
|
-
filekey: "fk",
|
|
129
|
-
cdnBaseUrl: "https://cdn.com",
|
|
130
|
-
label: "test",
|
|
131
|
-
aeskey,
|
|
132
|
-
}),
|
|
133
|
-
).rejects.toThrow("client error");
|
|
134
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("throws when x-encrypted-param header is missing after all retries", async () => {
|
|
138
|
-
for (let i = 0; i < 3; i++) {
|
|
139
|
-
mockFetch.mockResolvedValueOnce({
|
|
140
|
-
ok: true,
|
|
141
|
-
status: 200,
|
|
142
|
-
headers: new Headers({}),
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
await expect(
|
|
147
|
-
uploadBufferToCdn({
|
|
148
|
-
buf: Buffer.from("data"),
|
|
149
|
-
uploadParam: "up",
|
|
150
|
-
filekey: "fk",
|
|
151
|
-
cdnBaseUrl: "https://cdn.com",
|
|
152
|
-
label: "test",
|
|
153
|
-
aeskey,
|
|
154
|
-
}),
|
|
155
|
-
).rejects.toThrow("x-encrypted-param");
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("uses x-error-message header for 4xx when available", async () => {
|
|
159
|
-
mockFetch.mockResolvedValueOnce({
|
|
160
|
-
ok: false,
|
|
161
|
-
status: 400,
|
|
162
|
-
headers: new Headers({ "x-error-message": "bad request detail" }),
|
|
163
|
-
text: () => Promise.resolve("fallback text"),
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
await expect(
|
|
167
|
-
uploadBufferToCdn({
|
|
168
|
-
buf: Buffer.from("data"),
|
|
169
|
-
uploadParam: "up",
|
|
170
|
-
filekey: "fk",
|
|
171
|
-
cdnBaseUrl: "https://cdn.com",
|
|
172
|
-
label: "test",
|
|
173
|
-
aeskey,
|
|
174
|
-
}),
|
|
175
|
-
).rejects.toThrow("bad request detail");
|
|
176
|
-
});
|
|
177
|
-
});
|
package/src/mime.test.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { getMimeFromFilename, getExtensionFromMime, getExtensionFromContentTypeOrUrl } from "./mime.js";
|
|
3
|
-
|
|
4
|
-
describe("getMimeFromFilename", () => {
|
|
5
|
-
it("returns correct MIME for known extensions", () => {
|
|
6
|
-
expect(getMimeFromFilename("photo.png")).toBe("image/png");
|
|
7
|
-
expect(getMimeFromFilename("doc.pdf")).toBe("application/pdf");
|
|
8
|
-
expect(getMimeFromFilename("video.mp4")).toBe("video/mp4");
|
|
9
|
-
expect(getMimeFromFilename("audio.mp3")).toBe("audio/mpeg");
|
|
10
|
-
expect(getMimeFromFilename("image.jpg")).toBe("image/jpeg");
|
|
11
|
-
expect(getMimeFromFilename("image.jpeg")).toBe("image/jpeg");
|
|
12
|
-
expect(getMimeFromFilename("data.csv")).toBe("text/csv");
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("is case-insensitive", () => {
|
|
16
|
-
expect(getMimeFromFilename("PHOTO.PNG")).toBe("image/png");
|
|
17
|
-
expect(getMimeFromFilename("file.PDF")).toBe("application/pdf");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("returns application/octet-stream for unknown extensions", () => {
|
|
21
|
-
expect(getMimeFromFilename("file.xyz")).toBe("application/octet-stream");
|
|
22
|
-
expect(getMimeFromFilename("noext")).toBe("application/octet-stream");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("handles paths with directories", () => {
|
|
26
|
-
expect(getMimeFromFilename("/path/to/photo.png")).toBe("image/png");
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("getExtensionFromMime", () => {
|
|
31
|
-
it("returns correct extension for known MIME types", () => {
|
|
32
|
-
expect(getExtensionFromMime("image/jpeg")).toBe(".jpg");
|
|
33
|
-
expect(getExtensionFromMime("image/png")).toBe(".png");
|
|
34
|
-
expect(getExtensionFromMime("video/mp4")).toBe(".mp4");
|
|
35
|
-
expect(getExtensionFromMime("application/pdf")).toBe(".pdf");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("handles MIME with parameters", () => {
|
|
39
|
-
expect(getExtensionFromMime("image/jpeg; charset=utf-8")).toBe(".jpg");
|
|
40
|
-
expect(getExtensionFromMime("text/plain; boundary=something")).toBe(".txt");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("returns .bin for unknown MIME types", () => {
|
|
44
|
-
expect(getExtensionFromMime("application/unknown")).toBe(".bin");
|
|
45
|
-
expect(getExtensionFromMime("x-custom/type")).toBe(".bin");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("handles image/jpg alias", () => {
|
|
49
|
-
expect(getExtensionFromMime("image/jpg")).toBe(".jpg");
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
describe("getExtensionFromContentTypeOrUrl", () => {
|
|
54
|
-
it("prefers Content-Type when it resolves to a known extension", () => {
|
|
55
|
-
expect(getExtensionFromContentTypeOrUrl("image/png", "https://example.com/file.jpg")).toBe(".png");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("falls back to URL extension when Content-Type is unknown", () => {
|
|
59
|
-
expect(getExtensionFromContentTypeOrUrl("application/unknown", "https://example.com/file.jpg")).toBe(".jpg");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("falls back to URL extension when Content-Type is null", () => {
|
|
63
|
-
expect(getExtensionFromContentTypeOrUrl(null, "https://example.com/file.mp4")).toBe(".mp4");
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("returns .bin when neither Content-Type nor URL has a known extension", () => {
|
|
67
|
-
expect(getExtensionFromContentTypeOrUrl(null, "https://example.com/file")).toBe(".bin");
|
|
68
|
-
expect(getExtensionFromContentTypeOrUrl("application/unknown", "https://example.com/file.xyz")).toBe(".bin");
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("handles URL with query parameters", () => {
|
|
72
|
-
expect(getExtensionFromContentTypeOrUrl(null, "https://example.com/file.png?token=abc")).toBe(".png");
|
|
73
|
-
});
|
|
74
|
-
});
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
vi.mock("../util/logger.js", () => ({
|
|
4
|
-
logger: {
|
|
5
|
-
info: vi.fn(),
|
|
6
|
-
debug: vi.fn(),
|
|
7
|
-
warn: vi.fn(),
|
|
8
|
-
error: vi.fn(),
|
|
9
|
-
},
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
describe("silkToWav", () => {
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
vi.resetModules();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("returns null when silk-wasm is unavailable", async () => {
|
|
18
|
-
vi.doMock("silk-wasm", () => {
|
|
19
|
-
throw new Error("Cannot find module 'silk-wasm'");
|
|
20
|
-
});
|
|
21
|
-
const { silkToWav } = await import("./silk-transcode.js");
|
|
22
|
-
const result = await silkToWav(Buffer.from("fake-silk"));
|
|
23
|
-
expect(result).toBeNull();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("transcodes silk to WAV successfully", async () => {
|
|
27
|
-
const fakePcm = new Uint8Array(480); // 10ms at 24kHz mono 16-bit
|
|
28
|
-
vi.doMock("silk-wasm", () => ({
|
|
29
|
-
decode: vi.fn().mockResolvedValue({
|
|
30
|
-
data: fakePcm,
|
|
31
|
-
duration: 10,
|
|
32
|
-
}),
|
|
33
|
-
}));
|
|
34
|
-
const { silkToWav } = await import("./silk-transcode.js");
|
|
35
|
-
const result = await silkToWav(Buffer.from("fake-silk"));
|
|
36
|
-
expect(result).not.toBeNull();
|
|
37
|
-
expect(result!.length).toBe(44 + fakePcm.byteLength); // WAV header + PCM data
|
|
38
|
-
|
|
39
|
-
// Verify WAV header
|
|
40
|
-
expect(result!.toString("ascii", 0, 4)).toBe("RIFF");
|
|
41
|
-
expect(result!.toString("ascii", 8, 12)).toBe("WAVE");
|
|
42
|
-
expect(result!.toString("ascii", 12, 16)).toBe("fmt ");
|
|
43
|
-
expect(result!.readUInt16LE(20)).toBe(1); // PCM format
|
|
44
|
-
expect(result!.readUInt16LE(22)).toBe(1); // mono
|
|
45
|
-
expect(result!.readUInt32LE(24)).toBe(24000); // sample rate
|
|
46
|
-
expect(result!.readUInt16LE(34)).toBe(16); // bits per sample
|
|
47
|
-
expect(result!.toString("ascii", 36, 40)).toBe("data");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("returns null when decode fails", async () => {
|
|
51
|
-
vi.doMock("silk-wasm", () => ({
|
|
52
|
-
decode: vi.fn().mockRejectedValue(new Error("decode error")),
|
|
53
|
-
}));
|
|
54
|
-
const { silkToWav } = await import("./silk-transcode.js");
|
|
55
|
-
const result = await silkToWav(Buffer.from("bad-silk"));
|
|
56
|
-
expect(result).toBeNull();
|
|
57
|
-
});
|
|
58
|
-
});
|
package/src/upload.test.ts
DELETED
|
@@ -1,308 +0,0 @@
|
|
|
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
|
-
import { IlinkClientMetadata } from "./ilink-meta.js";
|
|
7
|
-
|
|
8
|
-
vi.mock("./ilink-logger.js", () => ({
|
|
9
|
-
logger: {
|
|
10
|
-
info: vi.fn(),
|
|
11
|
-
debug: vi.fn(),
|
|
12
|
-
warn: vi.fn(),
|
|
13
|
-
error: vi.fn(),
|
|
14
|
-
},
|
|
15
|
-
}));
|
|
16
|
-
|
|
17
|
-
const { mockGetUploadUrl } = vi.hoisted(() => ({
|
|
18
|
-
mockGetUploadUrl: vi.fn(),
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
vi.mock("./ilink-api.js", async (importOriginal) => {
|
|
22
|
-
const actual = await importOriginal<typeof import("./ilink-api.js")>();
|
|
23
|
-
return {
|
|
24
|
-
...actual,
|
|
25
|
-
getUploadUrl: mockGetUploadUrl,
|
|
26
|
-
};
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const { mockFetch } = vi.hoisted(() => ({
|
|
30
|
-
mockFetch: vi.fn(),
|
|
31
|
-
}));
|
|
32
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
33
|
-
|
|
34
|
-
const metadata = new IlinkClientMetadata({ version: "1.2.3" });
|
|
35
|
-
|
|
36
|
-
function mockCdnResponse(params: {
|
|
37
|
-
ok?: boolean;
|
|
38
|
-
status?: number;
|
|
39
|
-
headers?: Record<string, string>;
|
|
40
|
-
body?: string;
|
|
41
|
-
}) {
|
|
42
|
-
const headers = new Headers(params.headers ?? {});
|
|
43
|
-
return {
|
|
44
|
-
ok: params.ok ?? true,
|
|
45
|
-
status: params.status ?? 200,
|
|
46
|
-
headers,
|
|
47
|
-
text: () => Promise.resolve(params.body ?? ""),
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
import { downloadRemoteImageToTemp, uploadFileToWeixin, uploadVideoToWeixin, uploadFileAttachmentToWeixin } from "./upload.js";
|
|
52
|
-
|
|
53
|
-
beforeEach(() => {
|
|
54
|
-
vi.clearAllMocks();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
describe("downloadRemoteImageToTemp", () => {
|
|
58
|
-
it("downloads and saves file to temp dir", async () => {
|
|
59
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-test-"));
|
|
60
|
-
try {
|
|
61
|
-
const imageBytes = Buffer.from("fake-image-data");
|
|
62
|
-
mockFetch.mockResolvedValueOnce({
|
|
63
|
-
ok: true,
|
|
64
|
-
status: 200,
|
|
65
|
-
arrayBuffer: () => Promise.resolve(imageBytes.buffer.slice(imageBytes.byteOffset, imageBytes.byteOffset + imageBytes.byteLength)),
|
|
66
|
-
headers: new Headers({ "content-type": "image/png" }),
|
|
67
|
-
});
|
|
68
|
-
const filePath = await downloadRemoteImageToTemp("https://example.com/photo.png", tmpDir);
|
|
69
|
-
expect(filePath).toContain(tmpDir);
|
|
70
|
-
expect(filePath).toMatch(/\.png$/);
|
|
71
|
-
const content = await fs.readFile(filePath);
|
|
72
|
-
expect(content.toString()).toBe("fake-image-data");
|
|
73
|
-
} finally {
|
|
74
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("throws on non-ok response", async () => {
|
|
79
|
-
mockFetch.mockResolvedValueOnce({
|
|
80
|
-
ok: false,
|
|
81
|
-
status: 404,
|
|
82
|
-
statusText: "Not Found",
|
|
83
|
-
});
|
|
84
|
-
await expect(
|
|
85
|
-
downloadRemoteImageToTemp("https://example.com/missing.png", "/tmp/test"),
|
|
86
|
-
).rejects.toThrow("remote media download failed");
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("uploadFileToWeixin", () => {
|
|
91
|
-
it("uploads image file and returns info", async () => {
|
|
92
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-img-test-"));
|
|
93
|
-
try {
|
|
94
|
-
const filePath = path.join(tmpDir, "test.png");
|
|
95
|
-
await fs.writeFile(filePath, "fake-image-data");
|
|
96
|
-
|
|
97
|
-
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up-param" });
|
|
98
|
-
mockFetch.mockResolvedValueOnce(
|
|
99
|
-
mockCdnResponse({ headers: { "x-encrypted-param": "dl-param" } }),
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
const result = await uploadFileToWeixin({
|
|
103
|
-
filePath,
|
|
104
|
-
toUserId: "user1",
|
|
105
|
-
opts: { baseUrl: "https://api.com", metadata, token: "tok" },
|
|
106
|
-
cdnBaseUrl: "https://cdn.com",
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
expect(result.filekey).toBeDefined();
|
|
110
|
-
expect(result.downloadEncryptedQueryParam).toBe("dl-param");
|
|
111
|
-
expect(result.fileSize).toBe(15);
|
|
112
|
-
expect(result.fileSizeCiphertext).toBe(16);
|
|
113
|
-
} finally {
|
|
114
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("uploads via upload_full_url when upload_param is absent", async () => {
|
|
119
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-fullurl-test-"));
|
|
120
|
-
try {
|
|
121
|
-
const filePath = path.join(tmpDir, "test.png");
|
|
122
|
-
await fs.writeFile(filePath, "fake-image-data");
|
|
123
|
-
|
|
124
|
-
const fullUrl = "http://cdn.example/c2c/upload?encrypted_query_param=x&filekey=y";
|
|
125
|
-
mockGetUploadUrl.mockResolvedValueOnce({ upload_full_url: fullUrl });
|
|
126
|
-
mockFetch.mockResolvedValueOnce(
|
|
127
|
-
mockCdnResponse({ headers: { "x-encrypted-param": "dl-full" } }),
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
const result = await uploadFileToWeixin({
|
|
131
|
-
filePath,
|
|
132
|
-
toUserId: "user1",
|
|
133
|
-
opts: { baseUrl: "https://api.com", metadata, token: "tok" },
|
|
134
|
-
cdnBaseUrl: "https://ignored-cdn.com",
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
expect(result.downloadEncryptedQueryParam).toBe("dl-full");
|
|
138
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
139
|
-
const [calledUrl] = mockFetch.mock.calls[0] as [string, RequestInit];
|
|
140
|
-
expect(calledUrl).toBe(fullUrl);
|
|
141
|
-
} finally {
|
|
142
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("throws when getUploadUrl returns neither upload_full_url nor upload_param", async () => {
|
|
147
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-fail-test-"));
|
|
148
|
-
try {
|
|
149
|
-
const filePath = path.join(tmpDir, "test.txt");
|
|
150
|
-
await fs.writeFile(filePath, "data");
|
|
151
|
-
|
|
152
|
-
mockGetUploadUrl.mockResolvedValueOnce({});
|
|
153
|
-
|
|
154
|
-
await expect(
|
|
155
|
-
uploadFileToWeixin({
|
|
156
|
-
filePath,
|
|
157
|
-
toUserId: "user1",
|
|
158
|
-
opts: { baseUrl: "https://api.com", metadata },
|
|
159
|
-
cdnBaseUrl: "https://cdn.com",
|
|
160
|
-
}),
|
|
161
|
-
).rejects.toThrow("no upload URL");
|
|
162
|
-
} finally {
|
|
163
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("retries CDN upload on server error then succeeds", async () => {
|
|
168
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-retry-test-"));
|
|
169
|
-
try {
|
|
170
|
-
const filePath = path.join(tmpDir, "data.bin");
|
|
171
|
-
await fs.writeFile(filePath, "some data for upload");
|
|
172
|
-
|
|
173
|
-
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up" });
|
|
174
|
-
|
|
175
|
-
mockFetch.mockResolvedValueOnce(
|
|
176
|
-
mockCdnResponse({
|
|
177
|
-
ok: false,
|
|
178
|
-
status: 500,
|
|
179
|
-
headers: { "x-error-message": "server busy" },
|
|
180
|
-
body: "server busy",
|
|
181
|
-
}),
|
|
182
|
-
);
|
|
183
|
-
mockFetch.mockResolvedValueOnce(
|
|
184
|
-
mockCdnResponse({ headers: { "x-encrypted-param": "dl-retry" } }),
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
const result = await uploadFileToWeixin({
|
|
188
|
-
filePath,
|
|
189
|
-
toUserId: "user1",
|
|
190
|
-
opts: { baseUrl: "https://api.com", metadata },
|
|
191
|
-
cdnBaseUrl: "https://cdn.com",
|
|
192
|
-
});
|
|
193
|
-
expect(result.downloadEncryptedQueryParam).toBe("dl-retry");
|
|
194
|
-
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
195
|
-
} finally {
|
|
196
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("throws immediately on CDN client error (4xx)", async () => {
|
|
201
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-4xx-test-"));
|
|
202
|
-
try {
|
|
203
|
-
const filePath = path.join(tmpDir, "data.bin");
|
|
204
|
-
await fs.writeFile(filePath, "data");
|
|
205
|
-
|
|
206
|
-
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up" });
|
|
207
|
-
mockFetch.mockResolvedValueOnce(
|
|
208
|
-
mockCdnResponse({
|
|
209
|
-
ok: false,
|
|
210
|
-
status: 403,
|
|
211
|
-
headers: { "x-error-message": "forbidden" },
|
|
212
|
-
body: "forbidden",
|
|
213
|
-
}),
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
await expect(
|
|
217
|
-
uploadFileToWeixin({
|
|
218
|
-
filePath,
|
|
219
|
-
toUserId: "user1",
|
|
220
|
-
opts: { baseUrl: "https://api.com", metadata },
|
|
221
|
-
cdnBaseUrl: "https://cdn.com",
|
|
222
|
-
}),
|
|
223
|
-
).rejects.toThrow("client error");
|
|
224
|
-
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
225
|
-
} finally {
|
|
226
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
it("throws when CDN returns no x-encrypted-param after all retries", async () => {
|
|
231
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-no-param-test-"));
|
|
232
|
-
try {
|
|
233
|
-
const filePath = path.join(tmpDir, "data.bin");
|
|
234
|
-
await fs.writeFile(filePath, "data");
|
|
235
|
-
|
|
236
|
-
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up" });
|
|
237
|
-
|
|
238
|
-
for (let i = 0; i < 3; i++) {
|
|
239
|
-
mockFetch.mockResolvedValueOnce(mockCdnResponse({ headers: {} }));
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
await expect(
|
|
243
|
-
uploadFileToWeixin({
|
|
244
|
-
filePath,
|
|
245
|
-
toUserId: "user1",
|
|
246
|
-
opts: { baseUrl: "https://api.com", metadata },
|
|
247
|
-
cdnBaseUrl: "https://cdn.com",
|
|
248
|
-
}),
|
|
249
|
-
).rejects.toThrow("x-encrypted-param");
|
|
250
|
-
} finally {
|
|
251
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
252
|
-
}
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
describe("uploadVideoToWeixin", () => {
|
|
257
|
-
it("uploads video file", async () => {
|
|
258
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-vid-test-"));
|
|
259
|
-
try {
|
|
260
|
-
const filePath = path.join(tmpDir, "clip.mp4");
|
|
261
|
-
await fs.writeFile(filePath, "fake-video-content");
|
|
262
|
-
|
|
263
|
-
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up-vid" });
|
|
264
|
-
mockFetch.mockResolvedValueOnce(
|
|
265
|
-
mockCdnResponse({ headers: { "x-encrypted-param": "dl-vid" } }),
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
const result = await uploadVideoToWeixin({
|
|
269
|
-
filePath,
|
|
270
|
-
toUserId: "user1",
|
|
271
|
-
opts: { baseUrl: "https://api.com", metadata },
|
|
272
|
-
cdnBaseUrl: "https://cdn.com",
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
expect(result.filekey).toBeDefined();
|
|
276
|
-
expect(result.downloadEncryptedQueryParam).toBe("dl-vid");
|
|
277
|
-
} finally {
|
|
278
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
279
|
-
}
|
|
280
|
-
});
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
describe("uploadFileAttachmentToWeixin", () => {
|
|
284
|
-
it("uploads file attachment", async () => {
|
|
285
|
-
const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "upload-att-test-"));
|
|
286
|
-
try {
|
|
287
|
-
const filePath = path.join(tmpDir, "doc.pdf");
|
|
288
|
-
await fs.writeFile(filePath, "pdf-content");
|
|
289
|
-
|
|
290
|
-
mockGetUploadUrl.mockResolvedValueOnce({ upload_param: "up" });
|
|
291
|
-
mockFetch.mockResolvedValueOnce(
|
|
292
|
-
mockCdnResponse({ headers: { "x-encrypted-param": "dl" } }),
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
const result = await uploadFileAttachmentToWeixin({
|
|
296
|
-
filePath,
|
|
297
|
-
fileName: "doc.pdf",
|
|
298
|
-
toUserId: "user1",
|
|
299
|
-
opts: { baseUrl: "https://api.com", metadata },
|
|
300
|
-
cdnBaseUrl: "https://cdn.com",
|
|
301
|
-
});
|
|
302
|
-
expect(result.filekey).toBeDefined();
|
|
303
|
-
expect(result.downloadEncryptedQueryParam).toBe("dl");
|
|
304
|
-
} finally {
|
|
305
|
-
fsSync.rmSync(tmpDir, { recursive: true, force: true });
|
|
306
|
-
}
|
|
307
|
-
});
|
|
308
|
-
});
|