@imweapp/openclaw-imwe 2026.4.12-alpha.1
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/LICENSE +21 -0
- package/README.md +172 -0
- package/index.ts +16 -0
- package/openclaw.plugin.json +58 -0
- package/package.json +73 -0
- package/proto/PbBoxPullProto.proto +43 -0
- package/proto/PbChatAudioContent.proto +23 -0
- package/proto/PbChatDeliverMsg.proto +38 -0
- package/proto/PbChatFileMeta.proto +34 -0
- package/proto/PbChatMsg.proto +93 -0
- package/proto/PbChatRichMediaContent.proto +31 -0
- package/proto/PbChatTextContent.proto +38 -0
- package/proto/PbMarkdownContent.proto +18 -0
- package/proto/PbMsgReadStampContent.proto +11 -0
- package/proto/PbPacket.proto +61 -0
- package/proto/PbSingleChatMsg.proto +60 -0
- package/setup-entry.ts +17 -0
- package/src/accounts.ts +109 -0
- package/src/api-client.ts +740 -0
- package/src/bot-info-cache.ts +49 -0
- package/src/channel.runtime.ts +29 -0
- package/src/channel.ts +456 -0
- package/src/config-schema.ts +26 -0
- package/src/e2ee/api.ts +261 -0
- package/src/e2ee/canonical.ts +59 -0
- package/src/e2ee/errors.ts +103 -0
- package/src/e2ee/index.ts +8 -0
- package/src/e2ee/proper-lockfile.d.ts +61 -0
- package/src/e2ee/service.ts +1273 -0
- package/src/e2ee/store.ts +174 -0
- package/src/e2ee/types.ts +113 -0
- package/src/e2ee/vodozemac.ts +373 -0
- package/src/file-transfer/api.ts +364 -0
- package/src/file-transfer/concurrency.ts +77 -0
- package/src/file-transfer/download.ts +261 -0
- package/src/file-transfer/file-crypto.ts +93 -0
- package/src/file-transfer/index.ts +18 -0
- package/src/file-transfer/scheduler.ts +185 -0
- package/src/file-transfer/types.ts +195 -0
- package/src/file-transfer/upload.ts +656 -0
- package/src/markdown-detect.ts +119 -0
- package/src/media-upload.ts +338 -0
- package/src/media-utils.ts +110 -0
- package/src/monitor.ts +838 -0
- package/src/proto/codec.ts +54 -0
- package/src/proto/inbound.codec.ts +624 -0
- package/src/proto/proto-types.ts +291 -0
- package/src/proto/registry.ts +226 -0
- package/src/proto/send.codec.ts +535 -0
- package/src/recent-message-cache.ts +350 -0
- package/src/send.ts +792 -0
- package/src/setup-core.ts +62 -0
- package/src/types.ts +153 -0
- package/src/vodozemackit/index.ts +297 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm.d.ts +109 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* api.ts — 文件传输专用 API 封装
|
|
3
|
+
*
|
|
4
|
+
* 与 iOS LargeAttachmentUploadOperation 对齐的后端接口:
|
|
5
|
+
* - initMultipartUpload:初始化分片上传(文档 §八)
|
|
6
|
+
* - batchGetChunkPreSignedUrl:刷新过期的分片预签名 URL(文档 §九)
|
|
7
|
+
* - batchCompleteChunks:确认所有分片上传完成(文档 §十)
|
|
8
|
+
* - resolveShortLink:解析短链接获取实际下载地址(文档 §十三)
|
|
9
|
+
* - downloadCallback:下载完成回调(文档 §十二)
|
|
10
|
+
*
|
|
11
|
+
* 使用 api-client.ts 的 postJson 和 buildSignature 实现 HMAC-SHA256 签名。
|
|
12
|
+
* 所有接口 Base URL 为 /api/im/open/bot。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { postJson, buildSignature } from '../api-client.js';
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// 服务端响应原始类型(与接口文档对齐)
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** 服务端 ChunkUrlVO 结构 */
|
|
23
|
+
interface ChunkUrlVO {
|
|
24
|
+
chunkIndex: number;
|
|
25
|
+
preSignedUrl: string;
|
|
26
|
+
expireTime: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
// 导出响应类型(对调用方友好的归一化结构)
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** initMultipartUpload 响应 */
|
|
34
|
+
export interface InitMultipartUploadResult {
|
|
35
|
+
/** 服务端分配的文件 ID */
|
|
36
|
+
fileId: string;
|
|
37
|
+
/** 各分片的预签名上传 URL 列表(按 chunkIndex 顺序) */
|
|
38
|
+
chunkUrlList: string[];
|
|
39
|
+
/** 总分片数 */
|
|
40
|
+
totalChunks: number;
|
|
41
|
+
/** 上传会话过期时间(毫秒时间戳),用于填充 PbChatFileMeta.expireTime */
|
|
42
|
+
expireTime: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** batchGetChunkPreSignedUrl 响应 */
|
|
46
|
+
export interface BatchChunkUrlResult {
|
|
47
|
+
/** 刷新后的分片 URL 列表(按 chunkIndex 对应) */
|
|
48
|
+
chunkUrlList: { index: number; url: string }[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** batchCompleteChunks 响应 */
|
|
52
|
+
export interface BatchCompleteResult {
|
|
53
|
+
/** 文件访问 URL */
|
|
54
|
+
fileUrl: string;
|
|
55
|
+
/** 操作凭证(下载回调用) */
|
|
56
|
+
opCreds: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 短链接解析结果 */
|
|
60
|
+
export interface ResolveShortLinkResult {
|
|
61
|
+
/** 实际下载 URL(302 重定向目标) */
|
|
62
|
+
downloadUrl: string;
|
|
63
|
+
/** 文件 ETag(从响应头获取) */
|
|
64
|
+
etag: string;
|
|
65
|
+
/** 文件大小(字节,从 Content-Length 响应头获取) */
|
|
66
|
+
contentLength: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// API 路径常量(对齐文档 Base URL: /api/im/open/bot)
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const PATH_INIT_MULTIPART = '/api/im/open/bot/initMultipartUpload';
|
|
74
|
+
const PATH_BATCH_CHUNK_URL = '/api/im/open/bot/batchGetChunkPreSignedUrl';
|
|
75
|
+
const PATH_BATCH_COMPLETE = '/api/im/open/bot/batchCompleteChunks';
|
|
76
|
+
const PATH_SHORT_LINK = '/api/im/open/bot/file/shorLink';
|
|
77
|
+
const PATH_DOWNLOAD_CALLBACK = '/api/im/open/bot/downloadCallback';
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
// 内部工具:GET 请求(带签名,用于短链接解析)
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 发送带签名的 GET 请求,手动跟随 302 重定向以获取 Location 头。
|
|
85
|
+
* 签名规则与 POST 一致:body 为空时对空字符串 "" 做 SHA-256。
|
|
86
|
+
*/
|
|
87
|
+
async function getWithRedirect(
|
|
88
|
+
apiBaseUrl: string,
|
|
89
|
+
path: string,
|
|
90
|
+
auth: { apiKey: string; apiSecret: string },
|
|
91
|
+
queryParams?: Record<string, string>,
|
|
92
|
+
): Promise<Response> {
|
|
93
|
+
const qs = queryParams ? '?' + new URLSearchParams(queryParams).toString() : '';
|
|
94
|
+
const url = `${apiBaseUrl.replace(/\/$/, '')}${path}${qs}`;
|
|
95
|
+
|
|
96
|
+
const emptyBody = new Uint8Array(0);
|
|
97
|
+
const timestamp = String(Date.now());
|
|
98
|
+
const signature = buildSignature({
|
|
99
|
+
apiSecret: auth.apiSecret,
|
|
100
|
+
method: 'GET',
|
|
101
|
+
path,
|
|
102
|
+
timestamp,
|
|
103
|
+
body: emptyBody,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const res = await fetch(url, {
|
|
107
|
+
method: 'GET',
|
|
108
|
+
headers: {
|
|
109
|
+
'X-Api-Key': auth.apiKey,
|
|
110
|
+
'X-Timestamp': timestamp,
|
|
111
|
+
'X-Signature': signature,
|
|
112
|
+
reqid: randomUUID().replaceAll('-', ''),
|
|
113
|
+
},
|
|
114
|
+
redirect: 'manual', // 不自动跟随重定向,手动获取 Location
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return res;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
// 公共 API
|
|
122
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 初始化分片上传。
|
|
126
|
+
* POST /api/im/open/bot/initMultipartUpload(文档 §八)
|
|
127
|
+
*
|
|
128
|
+
* 服务端返回 chunkUrlList 为 ChunkUrlVO[] 对象数组,
|
|
129
|
+
* 本函数归一化为按 chunkIndex 排序的 preSignedUrl 字符串数组。
|
|
130
|
+
*/
|
|
131
|
+
export async function initMultipartUpload(
|
|
132
|
+
apiBaseUrl: string,
|
|
133
|
+
auth: { apiKey: string; apiSecret: string },
|
|
134
|
+
params: {
|
|
135
|
+
/** IM 主账号 ID(必填) */
|
|
136
|
+
imMainAccId: string;
|
|
137
|
+
fileName: string;
|
|
138
|
+
fileSize: number;
|
|
139
|
+
chunkSize: number;
|
|
140
|
+
totalChunks: number;
|
|
141
|
+
mimeType: string;
|
|
142
|
+
},
|
|
143
|
+
): Promise<InitMultipartUploadResult> {
|
|
144
|
+
const raw = await postJson<{
|
|
145
|
+
fileId: string;
|
|
146
|
+
totalChunks: number;
|
|
147
|
+
chunkSize: number;
|
|
148
|
+
expireTime: number;
|
|
149
|
+
chunkUrlList: ChunkUrlVO[];
|
|
150
|
+
}>(
|
|
151
|
+
apiBaseUrl,
|
|
152
|
+
PATH_INIT_MULTIPART,
|
|
153
|
+
{
|
|
154
|
+
imMainAccId: params.imMainAccId,
|
|
155
|
+
fileName: params.fileName,
|
|
156
|
+
fileSize: params.fileSize,
|
|
157
|
+
mimeType: params.mimeType,
|
|
158
|
+
chunkSize: params.chunkSize,
|
|
159
|
+
totalChunks: params.totalChunks,
|
|
160
|
+
firstBatchSize: params.totalChunks, // 一次性获取所有分片 URL
|
|
161
|
+
scene: 'AI_GC',
|
|
162
|
+
},
|
|
163
|
+
auth,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// 归一化:ChunkUrlVO[] → 按内部 0-based index 排序的 URL 字符串数组
|
|
167
|
+
// 服务端 chunkIndex 从 1 开始,内部从 0 开始
|
|
168
|
+
const urlArray = new Array<string>(raw.totalChunks).fill('');
|
|
169
|
+
for (const item of raw.chunkUrlList ?? []) {
|
|
170
|
+
urlArray[item.chunkIndex - 1] = item.preSignedUrl;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
fileId: raw.fileId,
|
|
175
|
+
chunkUrlList: urlArray,
|
|
176
|
+
totalChunks: raw.totalChunks,
|
|
177
|
+
expireTime: raw.expireTime,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 批量刷新分片预签名 URL(URL 过期时调用)。
|
|
183
|
+
* POST /api/im/open/bot/batchGetChunkPreSignedUrl(文档 §九)
|
|
184
|
+
*/
|
|
185
|
+
export async function batchGetChunkPreSignedUrl(
|
|
186
|
+
apiBaseUrl: string,
|
|
187
|
+
auth: { apiKey: string; apiSecret: string },
|
|
188
|
+
params: {
|
|
189
|
+
/** IM 主账号 ID(必填) */
|
|
190
|
+
imMainAccId: string;
|
|
191
|
+
fileId: string;
|
|
192
|
+
chunkIndices: number[];
|
|
193
|
+
},
|
|
194
|
+
): Promise<BatchChunkUrlResult> {
|
|
195
|
+
console.log(
|
|
196
|
+
`batchCompleteChunks: ${JSON.stringify({
|
|
197
|
+
imMainAccId: params.imMainAccId,
|
|
198
|
+
fileId: params.fileId,
|
|
199
|
+
chunkIndexList: params.chunkIndices.map((i) => i + 1),
|
|
200
|
+
scene: 'AI_GC',
|
|
201
|
+
})}`,
|
|
202
|
+
);
|
|
203
|
+
const raw = await postJson<{
|
|
204
|
+
fileId: string;
|
|
205
|
+
totalChunks: number;
|
|
206
|
+
chunkUrlList: ChunkUrlVO[];
|
|
207
|
+
}>(
|
|
208
|
+
apiBaseUrl,
|
|
209
|
+
PATH_BATCH_CHUNK_URL,
|
|
210
|
+
{
|
|
211
|
+
imMainAccId: params.imMainAccId,
|
|
212
|
+
fileId: params.fileId,
|
|
213
|
+
chunkIndexList: params.chunkIndices.map((i) => i + 1), // 内部 0-based → 服务端 1-based
|
|
214
|
+
scene: 'AI_GC',
|
|
215
|
+
},
|
|
216
|
+
auth,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// 归一化:ChunkUrlVO[] → { index, url }[],服务端 1-based → 内部 0-based
|
|
220
|
+
const chunkUrlList = (raw.chunkUrlList ?? []).map((item) => ({
|
|
221
|
+
index: item.chunkIndex - 1,
|
|
222
|
+
url: item.preSignedUrl,
|
|
223
|
+
}));
|
|
224
|
+
|
|
225
|
+
return { chunkUrlList };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 确认所有分片上传完成。
|
|
230
|
+
* POST /api/im/open/bot/batchCompleteChunks(文档 §十)
|
|
231
|
+
*/
|
|
232
|
+
export async function batchCompleteChunks(
|
|
233
|
+
apiBaseUrl: string,
|
|
234
|
+
auth: { apiKey: string; apiSecret: string },
|
|
235
|
+
params: {
|
|
236
|
+
/** IM 主账号 ID(必填) */
|
|
237
|
+
imMainAccId: string;
|
|
238
|
+
fileId: string;
|
|
239
|
+
completedChunks: { index: number; etag: string }[];
|
|
240
|
+
},
|
|
241
|
+
): Promise<BatchCompleteResult> {
|
|
242
|
+
console.log(
|
|
243
|
+
`batchCompleteChunks: ${JSON.stringify({
|
|
244
|
+
imMainAccId: params.imMainAccId,
|
|
245
|
+
fileId: params.fileId,
|
|
246
|
+
completedChunkList: params.completedChunks.map((c) => ({
|
|
247
|
+
chunkIndex: c.index + 1,
|
|
248
|
+
etag: c.etag,
|
|
249
|
+
})),
|
|
250
|
+
needOpCreds: true,
|
|
251
|
+
scene: 'AI_GC',
|
|
252
|
+
})}`,
|
|
253
|
+
);
|
|
254
|
+
const raw = await postJson<{
|
|
255
|
+
totalCompletedChunks: number;
|
|
256
|
+
totalChunks: number;
|
|
257
|
+
fileUrl: string;
|
|
258
|
+
expireTime: number;
|
|
259
|
+
opCreds?: string;
|
|
260
|
+
}>(
|
|
261
|
+
apiBaseUrl,
|
|
262
|
+
PATH_BATCH_COMPLETE,
|
|
263
|
+
{
|
|
264
|
+
imMainAccId: params.imMainAccId,
|
|
265
|
+
fileId: params.fileId,
|
|
266
|
+
// 文档字段名为 completedChunkList,子字段为 chunkIndex / eTag
|
|
267
|
+
// 内部 0-based → 服务端 1-based
|
|
268
|
+
completedChunkList: params.completedChunks.map((c) => ({
|
|
269
|
+
chunkIndex: c.index + 1,
|
|
270
|
+
etag: c.etag,
|
|
271
|
+
})),
|
|
272
|
+
needOpCreds: true,
|
|
273
|
+
scene: 'AI_GC',
|
|
274
|
+
},
|
|
275
|
+
auth,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
fileUrl: raw.fileUrl,
|
|
280
|
+
opCreds: raw.opCreds ?? '',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 解析短链接为实际下载地址。
|
|
286
|
+
* GET /api/im/open/bot/file/shorLink?shortCode=xxx&scene=CHAT_MEDIA(文档 §十三)
|
|
287
|
+
*
|
|
288
|
+
* @param queryParams 从短链接 URL 中提取的查询参数,会与 scene 合并后发送
|
|
289
|
+
*
|
|
290
|
+
* 服务端返回 302 重定向到签名下载 URL。
|
|
291
|
+
* 本函数从 Location 头获取实际下载 URL,再 HEAD 请求获取 ETag 和 Content-Length。
|
|
292
|
+
*/
|
|
293
|
+
export async function resolveShortLink(
|
|
294
|
+
apiBaseUrl: string,
|
|
295
|
+
auth: { apiKey: string; apiSecret: string },
|
|
296
|
+
queryParams: Record<string, string>,
|
|
297
|
+
): Promise<ResolveShortLinkResult> {
|
|
298
|
+
const res = await getWithRedirect(apiBaseUrl, PATH_SHORT_LINK, auth, queryParams);
|
|
299
|
+
|
|
300
|
+
// 期望 302 重定向
|
|
301
|
+
if (res.status !== 302 && res.status !== 301) {
|
|
302
|
+
if (!res.ok) {
|
|
303
|
+
const errText = await res.text().catch(() => '');
|
|
304
|
+
throw new Error(`imwe API ${PATH_SHORT_LINK} 返回 ${res.status}: ${errText}`);
|
|
305
|
+
}
|
|
306
|
+
// 某些情况服务端可能直接返回 200 + JSON(兼容处理)
|
|
307
|
+
const json = (await res.json()) as {
|
|
308
|
+
downloadUrl?: string;
|
|
309
|
+
url?: string;
|
|
310
|
+
etag?: string;
|
|
311
|
+
contentLength?: number;
|
|
312
|
+
};
|
|
313
|
+
return {
|
|
314
|
+
downloadUrl: json.downloadUrl ?? json.url ?? '',
|
|
315
|
+
etag: json.etag ?? '',
|
|
316
|
+
contentLength: json.contentLength ?? 0,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 从 Location 头获取实际下载 URL
|
|
321
|
+
const downloadUrl = res.headers.get('location') ?? '';
|
|
322
|
+
if (!downloadUrl) {
|
|
323
|
+
throw new Error(`imwe API ${PATH_SHORT_LINK} 返回 302 但缺少 Location 头`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// HEAD 请求获取 ETag 和 Content-Length
|
|
327
|
+
let etag = '';
|
|
328
|
+
let contentLength = 0;
|
|
329
|
+
try {
|
|
330
|
+
const headRes = await fetch(downloadUrl, { method: 'HEAD' });
|
|
331
|
+
etag = headRes.headers.get('etag') ?? '';
|
|
332
|
+
contentLength = parseInt(headRes.headers.get('content-length') ?? '0', 10) || 0;
|
|
333
|
+
} catch {
|
|
334
|
+
// HEAD 失败时不阻断,调用方可通过实际下载获取大小
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { downloadUrl, etag, contentLength };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 下载完成回调(通知服务端可删除临时文件)。
|
|
342
|
+
* POST /api/im/open/bot/downloadCallback(文档 §十二)
|
|
343
|
+
*/
|
|
344
|
+
export async function downloadCallback(
|
|
345
|
+
apiBaseUrl: string,
|
|
346
|
+
auth: { apiKey: string; apiSecret: string },
|
|
347
|
+
params: {
|
|
348
|
+
/** IM 主账号 ID(必填) */
|
|
349
|
+
imMainAccId: string;
|
|
350
|
+
/** 操作凭证 */
|
|
351
|
+
opCreds: string;
|
|
352
|
+
},
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
await postJson<unknown>(
|
|
355
|
+
apiBaseUrl,
|
|
356
|
+
PATH_DOWNLOAD_CALLBACK,
|
|
357
|
+
{
|
|
358
|
+
imMainAccId: params.imMainAccId,
|
|
359
|
+
opCredsList: [params.opCreds], // 文档要求数组格式
|
|
360
|
+
scene: 'AI_GC',
|
|
361
|
+
},
|
|
362
|
+
auth,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* concurrency.ts — 滑动窗口并发任务执行器
|
|
3
|
+
*
|
|
4
|
+
* 提供可配置并发数的 Promise 池模式执行器,上传和下载共用。
|
|
5
|
+
* 与 iOS LargeAttachmentUploadOperation 的并发上传策略对齐。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 任务定义 */
|
|
9
|
+
export interface ConcurrentTask<T> {
|
|
10
|
+
/** 任务唯一标识(用于日志和状态追踪) */
|
|
11
|
+
id: string | number;
|
|
12
|
+
/** 任务执行函数 */
|
|
13
|
+
execute: () => Promise<T>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 并发执行结果 */
|
|
17
|
+
export interface ConcurrentResult<T> {
|
|
18
|
+
id: string | number;
|
|
19
|
+
success: boolean;
|
|
20
|
+
value?: T;
|
|
21
|
+
error?: Error;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 滑动窗口并发执行器。
|
|
26
|
+
*
|
|
27
|
+
* 同时最多执行 `limit` 个任务,一个完成后立即启动下一个(滑动窗口)。
|
|
28
|
+
* 与 iOS LargeAttachmentUploadOperation 的并发上传策略对齐。
|
|
29
|
+
*
|
|
30
|
+
* @param tasks 任务列表
|
|
31
|
+
* @param limit 最大并发数
|
|
32
|
+
* @param onProgress 单个任务完成时的回调(可选,用于更新持久化状态)
|
|
33
|
+
* @returns 所有任务的执行结果(保持原始顺序)
|
|
34
|
+
*/
|
|
35
|
+
export async function runConcurrent<T>(
|
|
36
|
+
tasks: ConcurrentTask<T>[],
|
|
37
|
+
limit: number,
|
|
38
|
+
onProgress?: (result: ConcurrentResult<T>) => void,
|
|
39
|
+
): Promise<ConcurrentResult<T>[]> {
|
|
40
|
+
if (tasks.length === 0) return [];
|
|
41
|
+
|
|
42
|
+
const results: ConcurrentResult<T>[] = new Array(tasks.length);
|
|
43
|
+
let nextIndex = 0;
|
|
44
|
+
|
|
45
|
+
async function runTask(taskIndex: number): Promise<void> {
|
|
46
|
+
const task = tasks[taskIndex];
|
|
47
|
+
try {
|
|
48
|
+
const value = await task.execute();
|
|
49
|
+
results[taskIndex] = { id: task.id, success: true, value };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
results[taskIndex] = {
|
|
52
|
+
id: task.id,
|
|
53
|
+
success: false,
|
|
54
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
onProgress?.(results[taskIndex]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 启动一个 worker:执行当前任务,完成后从队列取下一个
|
|
61
|
+
async function worker(): Promise<void> {
|
|
62
|
+
while (nextIndex < tasks.length) {
|
|
63
|
+
const idx = nextIndex++;
|
|
64
|
+
await runTask(idx);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 启动 min(limit, tasks.length) 个 worker 并行执行
|
|
69
|
+
const workerCount = Math.min(limit, tasks.length);
|
|
70
|
+
const workers: Promise<void>[] = [];
|
|
71
|
+
for (let i = 0; i < workerCount; i++) {
|
|
72
|
+
workers.push(worker());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await Promise.all(workers);
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* download.ts — 分片下载 + 解密服务
|
|
3
|
+
*
|
|
4
|
+
* 与 iOS 对齐的分片下载流程:
|
|
5
|
+
* 1. 解析短链接获取实际下载 URL + ETag + contentLength
|
|
6
|
+
* 2. 加载/创建 .partial.meta 断点状态
|
|
7
|
+
* 3. ETag 不匹配时丢弃旧数据重新下载
|
|
8
|
+
* 4. 并发 Range GET 下载缺失分片(滑动窗口)
|
|
9
|
+
* 5. 每个分片下载后即时 AES-CTR 解密并写入 .partial 文件对应 offset
|
|
10
|
+
* 6. SHA256 完整性校验
|
|
11
|
+
* 7. rename .partial → 最终文件
|
|
12
|
+
* 8. downloadCallback 通知服务端
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import { DownloadAndDecryptParams, DownloadResult, DownloadState } from './types.js';
|
|
18
|
+
import { aesCtrXor } from './file-crypto.js';
|
|
19
|
+
import { runConcurrent, ConcurrentTask } from './concurrency.js';
|
|
20
|
+
import { resolveShortLink, downloadCallback } from './api.js';
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
// 断点续传状态管理
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 加载下载断点状态。
|
|
28
|
+
* @param savePath 最终文件保存路径(.partial.meta 与之同目录)
|
|
29
|
+
* @returns 已有的 DownloadState 或 null
|
|
30
|
+
*/
|
|
31
|
+
export function loadDownloadState(savePath: string): DownloadState | null {
|
|
32
|
+
const metaPath = `${savePath}.partial.meta`;
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(metaPath, 'utf-8');
|
|
35
|
+
return JSON.parse(raw) as DownloadState;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 持久化下载断点状态(原子写入)。
|
|
43
|
+
*/
|
|
44
|
+
export function saveDownloadState(savePath: string, state: DownloadState): void {
|
|
45
|
+
const metaPath = `${savePath}.partial.meta`;
|
|
46
|
+
const tmpPath = `${metaPath}.tmp`;
|
|
47
|
+
fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), 'utf-8');
|
|
48
|
+
fs.renameSync(tmpPath, metaPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 清理下载临时文件(.partial + .partial.meta)。
|
|
53
|
+
*/
|
|
54
|
+
export function clearDownloadState(savePath: string): void {
|
|
55
|
+
const partialPath = `${savePath}.partial`;
|
|
56
|
+
const metaPath = `${savePath}.partial.meta`;
|
|
57
|
+
try {
|
|
58
|
+
fs.unlinkSync(partialPath);
|
|
59
|
+
} catch {
|
|
60
|
+
// 文件不存在时忽略
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
fs.unlinkSync(metaPath);
|
|
64
|
+
} catch {
|
|
65
|
+
// 文件不存在时忽略
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
// 内部工具
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** 计算分片计划 */
|
|
74
|
+
function computeChunkPlan(fileSize: number, chunkSize: number) {
|
|
75
|
+
const totalChunks = Math.ceil(fileSize / chunkSize);
|
|
76
|
+
return { totalChunks, chunkSize, fileSize };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 下载单个分片(HTTP Range GET) */
|
|
80
|
+
async function downloadChunk(
|
|
81
|
+
url: string,
|
|
82
|
+
chunkIndex: number,
|
|
83
|
+
chunkSize: number,
|
|
84
|
+
fileSize: number,
|
|
85
|
+
): Promise<Uint8Array> {
|
|
86
|
+
const start = chunkIndex * chunkSize;
|
|
87
|
+
const end = Math.min(start + chunkSize - 1, fileSize - 1);
|
|
88
|
+
|
|
89
|
+
const res = await fetch(url, {
|
|
90
|
+
headers: { Range: `bytes=${start}-${end}` },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!res.ok && res.status !== 206) {
|
|
94
|
+
throw new Error(`下载分片 ${chunkIndex} 失败: HTTP ${res.status}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const buf = await res.arrayBuffer();
|
|
98
|
+
return new Uint8Array(buf);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 确保目录存在 */
|
|
102
|
+
function ensureDir(filePath: string): void {
|
|
103
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
104
|
+
if (dir && !fs.existsSync(dir)) {
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
// 主入口
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 下载加密文件、分片解密、SHA256 完整性校验。
|
|
115
|
+
*
|
|
116
|
+
* @throws 完整性校验失败时抛出 Error(.partial 文件保留以供重试)
|
|
117
|
+
*/
|
|
118
|
+
export async function downloadAndDecryptFile(
|
|
119
|
+
params: DownloadAndDecryptParams,
|
|
120
|
+
auth: { apiKey: string; apiSecret: string },
|
|
121
|
+
apiBaseUrl: string,
|
|
122
|
+
): Promise<DownloadResult> {
|
|
123
|
+
const { savePath, key, iv, digest, plaintextLength, opCreds, concurrency = 3 } = params;
|
|
124
|
+
|
|
125
|
+
// 幂等:文件已存在且大小匹配时直接返回
|
|
126
|
+
try {
|
|
127
|
+
const stat = fs.statSync(savePath);
|
|
128
|
+
if (stat.isFile() && stat.size === plaintextLength) {
|
|
129
|
+
return { localPath: savePath, digest };
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// 文件不存在,继续下载
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 1. 解析短链接获取实际下载 URL + ETag + contentLength
|
|
136
|
+
const urlObj = new URL(params.url);
|
|
137
|
+
const queryParams: Record<string, string> = {};
|
|
138
|
+
urlObj.searchParams.forEach((value, key) => {
|
|
139
|
+
queryParams[key] = value;
|
|
140
|
+
});
|
|
141
|
+
const resolved = await resolveShortLink(apiBaseUrl, auth, queryParams);
|
|
142
|
+
const { downloadUrl, etag, contentLength } = resolved;
|
|
143
|
+
|
|
144
|
+
// 使用 plaintextLength 作为文件大小(CTR 模式密文长度 === 明文长度)
|
|
145
|
+
// 注意:优先使用 proto 中的 plaintextLength 作为权威值,
|
|
146
|
+
// contentLength 仅作为辅助验证(HEAD 请求可能失败或返回不准确)
|
|
147
|
+
const fileSize = plaintextLength;
|
|
148
|
+
const chunkSize = 2 * 1024 * 1024; // 2MB,与 iOS 对齐
|
|
149
|
+
const plan = computeChunkPlan(fileSize, chunkSize);
|
|
150
|
+
|
|
151
|
+
// 2. 加载/创建断点状态
|
|
152
|
+
const partialPath = `${savePath}.partial`;
|
|
153
|
+
let state = loadDownloadState(savePath);
|
|
154
|
+
|
|
155
|
+
if (state) {
|
|
156
|
+
// 3. ETag 不匹配时丢弃旧数据重新下载
|
|
157
|
+
if (state.etag !== etag) {
|
|
158
|
+
clearDownloadState(savePath);
|
|
159
|
+
state = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!state) {
|
|
164
|
+
// 创建新的下载状态
|
|
165
|
+
state = {
|
|
166
|
+
etag,
|
|
167
|
+
chunkSize: plan.chunkSize,
|
|
168
|
+
totalChunks: plan.totalChunks,
|
|
169
|
+
fileSize: plan.fileSize,
|
|
170
|
+
downloadedChunks: [],
|
|
171
|
+
messageContext: params.messageContext,
|
|
172
|
+
createdAt: Date.now(),
|
|
173
|
+
updatedAt: Date.now(),
|
|
174
|
+
};
|
|
175
|
+
ensureDir(partialPath);
|
|
176
|
+
// 创建 .partial 文件(预分配大小)
|
|
177
|
+
const fd = fs.openSync(partialPath, 'w');
|
|
178
|
+
if (fileSize > 0) {
|
|
179
|
+
// 稀疏文件:写入最后一个字节以设置文件大小
|
|
180
|
+
const buf = Buffer.alloc(1);
|
|
181
|
+
fs.writeSync(fd, buf, 0, 1, fileSize - 1);
|
|
182
|
+
}
|
|
183
|
+
fs.closeSync(fd);
|
|
184
|
+
saveDownloadState(savePath, state);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 4. 计算缺失的分片
|
|
188
|
+
const downloadedSet = new Set(state.downloadedChunks);
|
|
189
|
+
const missingChunks: number[] = [];
|
|
190
|
+
for (let i = 0; i < plan.totalChunks; i++) {
|
|
191
|
+
if (!downloadedSet.has(i)) {
|
|
192
|
+
missingChunks.push(i);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 5. 并发下载缺失分片
|
|
197
|
+
if (missingChunks.length > 0) {
|
|
198
|
+
const tasks: ConcurrentTask<number>[] = missingChunks.map((chunkIndex) => ({
|
|
199
|
+
id: chunkIndex,
|
|
200
|
+
execute: async () => {
|
|
201
|
+
// 下载密文分片
|
|
202
|
+
const ciphertext = await downloadChunk(downloadUrl, chunkIndex, chunkSize, fileSize);
|
|
203
|
+
|
|
204
|
+
// 即时解密
|
|
205
|
+
const offset = chunkIndex * chunkSize;
|
|
206
|
+
const plaintext = aesCtrXor(ciphertext, key, iv, offset);
|
|
207
|
+
|
|
208
|
+
// 写入 .partial 文件对应 offset
|
|
209
|
+
const fd = fs.openSync(partialPath, 'r+');
|
|
210
|
+
try {
|
|
211
|
+
fs.writeSync(fd, plaintext, 0, plaintext.length, offset);
|
|
212
|
+
} finally {
|
|
213
|
+
fs.closeSync(fd);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return chunkIndex;
|
|
217
|
+
},
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
await runConcurrent(tasks, concurrency, (result) => {
|
|
221
|
+
// 6. 每个分片完成后更新 .partial.meta
|
|
222
|
+
if (result.success && result.value !== undefined) {
|
|
223
|
+
state!.downloadedChunks.push(result.value);
|
|
224
|
+
state!.updatedAt = Date.now();
|
|
225
|
+
saveDownloadState(savePath, state!);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 7. SHA256 完整性校验
|
|
231
|
+
const fileData = fs.readFileSync(partialPath);
|
|
232
|
+
const hash = crypto.createHash('sha256').update(fileData).digest('hex');
|
|
233
|
+
|
|
234
|
+
if (hash !== digest) {
|
|
235
|
+
// 校验失败:抛出错误,.partial 保留以供重试
|
|
236
|
+
throw new Error(`完整性校验失败: 期望 ${digest}, 实际 ${hash}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 8. rename .partial → 最终文件
|
|
240
|
+
ensureDir(savePath);
|
|
241
|
+
fs.renameSync(partialPath, savePath);
|
|
242
|
+
|
|
243
|
+
// 9. 删除 .partial.meta
|
|
244
|
+
const metaPath = `${savePath}.partial.meta`;
|
|
245
|
+
try {
|
|
246
|
+
fs.unlinkSync(metaPath);
|
|
247
|
+
} catch {
|
|
248
|
+
// 忽略
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 10. downloadCallback 通知服务端(失败时静默忽略)
|
|
252
|
+
if (opCreds) {
|
|
253
|
+
try {
|
|
254
|
+
await downloadCallback(apiBaseUrl, auth, { imMainAccId: params.imMainAccId ?? '', opCreds });
|
|
255
|
+
} catch {
|
|
256
|
+
// 静默忽略,不影响本地文件使用
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { localPath: savePath, digest };
|
|
261
|
+
}
|