@openclaw/qqbot 2026.5.2-beta.2 → 2026.5.3-beta.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.
Files changed (203) hide show
  1. package/dist/api.js +646 -0
  2. package/dist/approval-cg0SVahb.js +94 -0
  3. package/dist/channel-N6Y_Rcjp.js +551 -0
  4. package/dist/channel-plugin-api.js +2 -0
  5. package/dist/channel.setup-ByCRAe5H.js +25 -0
  6. package/dist/config-D6545NkC.js +94 -0
  7. package/dist/config-schema-DFcjQw73.js +309 -0
  8. package/dist/exec-approvals-COUsM6wZ.js +138 -0
  9. package/dist/gateway-CmSUJKSt.js +6726 -0
  10. package/dist/handler-runtime-Bqm6N0WG.js +120 -0
  11. package/dist/index.js +29 -0
  12. package/dist/narrowing-BoieBTIU.js +25 -0
  13. package/dist/outbound-BJfhwrPg.js +1357 -0
  14. package/dist/request-context-DXtpwWui.js +921 -0
  15. package/dist/resolve-D_06fV6-.js +284 -0
  16. package/dist/runtime-BJAS3eXW.js +18 -0
  17. package/dist/runtime-api.js +2 -0
  18. package/dist/secret-contract-api.js +56 -0
  19. package/dist/sender-p-B14eLG.js +1987 -0
  20. package/dist/setup-entry.js +15 -0
  21. package/dist/setup-plugin-api.js +2 -0
  22. package/dist/string-normalize-Ci6NM5DE.js +98 -0
  23. package/dist/target-parser-Y0prnrXD.js +245 -0
  24. package/package.json +15 -6
  25. package/api.ts +0 -56
  26. package/channel-plugin-api.ts +0 -1
  27. package/index.ts +0 -29
  28. package/runtime-api.ts +0 -9
  29. package/setup-entry.ts +0 -9
  30. package/setup-plugin-api.ts +0 -3
  31. package/src/bridge/approval/capability.ts +0 -237
  32. package/src/bridge/approval/handler-runtime.ts +0 -204
  33. package/src/bridge/bootstrap.ts +0 -135
  34. package/src/bridge/channel-entry.ts +0 -18
  35. package/src/bridge/commands/framework-context-adapter.ts +0 -60
  36. package/src/bridge/commands/framework-registration.ts +0 -47
  37. package/src/bridge/commands/from-parser.test.ts +0 -86
  38. package/src/bridge/commands/from-parser.ts +0 -60
  39. package/src/bridge/commands/result-dispatcher.ts +0 -76
  40. package/src/bridge/config-shared.ts +0 -132
  41. package/src/bridge/config.ts +0 -111
  42. package/src/bridge/gateway.ts +0 -174
  43. package/src/bridge/logger.ts +0 -31
  44. package/src/bridge/narrowing.ts +0 -31
  45. package/src/bridge/plugin-version.test.ts +0 -146
  46. package/src/bridge/plugin-version.ts +0 -102
  47. package/src/bridge/runtime.ts +0 -25
  48. package/src/bridge/sdk-adapter.ts +0 -131
  49. package/src/bridge/setup/finalize.ts +0 -144
  50. package/src/bridge/setup/surface.ts +0 -34
  51. package/src/bridge/tools/channel.ts +0 -58
  52. package/src/bridge/tools/index.ts +0 -15
  53. package/src/bridge/tools/remind.test.ts +0 -124
  54. package/src/bridge/tools/remind.ts +0 -91
  55. package/src/channel.setup.ts +0 -33
  56. package/src/channel.ts +0 -288
  57. package/src/command-auth.test.ts +0 -62
  58. package/src/config-schema.ts +0 -84
  59. package/src/config.test.ts +0 -364
  60. package/src/engine/access/access-control.test.ts +0 -198
  61. package/src/engine/access/access-control.ts +0 -226
  62. package/src/engine/access/index.ts +0 -16
  63. package/src/engine/access/resolve-policy.test.ts +0 -59
  64. package/src/engine/access/resolve-policy.ts +0 -57
  65. package/src/engine/access/sender-match.test.ts +0 -60
  66. package/src/engine/access/sender-match.ts +0 -55
  67. package/src/engine/access/types.ts +0 -53
  68. package/src/engine/adapter/audio.port.ts +0 -27
  69. package/src/engine/adapter/commands.port.ts +0 -22
  70. package/src/engine/adapter/history.port.ts +0 -52
  71. package/src/engine/adapter/index.ts +0 -139
  72. package/src/engine/adapter/mention-gate.port.ts +0 -50
  73. package/src/engine/adapter/types.ts +0 -38
  74. package/src/engine/api/api-client.ts +0 -212
  75. package/src/engine/api/media-chunked.test.ts +0 -336
  76. package/src/engine/api/media-chunked.ts +0 -622
  77. package/src/engine/api/media.ts +0 -218
  78. package/src/engine/api/messages.ts +0 -293
  79. package/src/engine/api/retry.ts +0 -217
  80. package/src/engine/api/routes.ts +0 -95
  81. package/src/engine/api/token.ts +0 -271
  82. package/src/engine/approval/index.test.ts +0 -22
  83. package/src/engine/approval/index.ts +0 -224
  84. package/src/engine/commands/builtin/log-helpers.ts +0 -319
  85. package/src/engine/commands/builtin/register-all.ts +0 -17
  86. package/src/engine/commands/builtin/register-approve.ts +0 -201
  87. package/src/engine/commands/builtin/register-basic.ts +0 -95
  88. package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
  89. package/src/engine/commands/builtin/register-logs.ts +0 -20
  90. package/src/engine/commands/builtin/register-streaming.ts +0 -137
  91. package/src/engine/commands/builtin/state.ts +0 -31
  92. package/src/engine/commands/slash-command-auth.ts +0 -48
  93. package/src/engine/commands/slash-command-handler.ts +0 -146
  94. package/src/engine/commands/slash-commands-impl.test.ts +0 -8
  95. package/src/engine/commands/slash-commands-impl.ts +0 -61
  96. package/src/engine/commands/slash-commands.ts +0 -199
  97. package/src/engine/config/credential-backup.test.ts +0 -88
  98. package/src/engine/config/credential-backup.ts +0 -107
  99. package/src/engine/config/credentials.ts +0 -76
  100. package/src/engine/config/group.test.ts +0 -234
  101. package/src/engine/config/group.ts +0 -299
  102. package/src/engine/config/resolve.test.ts +0 -152
  103. package/src/engine/config/resolve.ts +0 -283
  104. package/src/engine/config/setup-logic.ts +0 -84
  105. package/src/engine/engine-import-boundary.test.ts +0 -73
  106. package/src/engine/gateway/codec.ts +0 -47
  107. package/src/engine/gateway/constants.ts +0 -117
  108. package/src/engine/gateway/event-dispatcher.ts +0 -177
  109. package/src/engine/gateway/gateway-connection.ts +0 -371
  110. package/src/engine/gateway/gateway.ts +0 -291
  111. package/src/engine/gateway/inbound-attachments.test.ts +0 -126
  112. package/src/engine/gateway/inbound-attachments.ts +0 -360
  113. package/src/engine/gateway/inbound-context.ts +0 -195
  114. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
  115. package/src/engine/gateway/inbound-pipeline.ts +0 -235
  116. package/src/engine/gateway/interaction-handler.ts +0 -220
  117. package/src/engine/gateway/message-queue.test.ts +0 -282
  118. package/src/engine/gateway/message-queue.ts +0 -499
  119. package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
  120. package/src/engine/gateway/outbound-dispatch.ts +0 -575
  121. package/src/engine/gateway/reconnect.ts +0 -199
  122. package/src/engine/gateway/stages/access-stage.ts +0 -132
  123. package/src/engine/gateway/stages/assembly-stage.ts +0 -156
  124. package/src/engine/gateway/stages/content-stage.test.ts +0 -77
  125. package/src/engine/gateway/stages/content-stage.ts +0 -77
  126. package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
  127. package/src/engine/gateway/stages/envelope-stage.ts +0 -144
  128. package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
  129. package/src/engine/gateway/stages/index.ts +0 -18
  130. package/src/engine/gateway/stages/quote-stage.ts +0 -113
  131. package/src/engine/gateway/stages/refidx-stage.ts +0 -62
  132. package/src/engine/gateway/stages/stub-contexts.ts +0 -116
  133. package/src/engine/gateway/types.ts +0 -264
  134. package/src/engine/gateway/typing-keepalive.ts +0 -79
  135. package/src/engine/group/activation.test.ts +0 -114
  136. package/src/engine/group/activation.ts +0 -147
  137. package/src/engine/group/history.test.ts +0 -314
  138. package/src/engine/group/history.ts +0 -321
  139. package/src/engine/group/mention.test.ts +0 -141
  140. package/src/engine/group/mention.ts +0 -197
  141. package/src/engine/group/message-gating.test.ts +0 -188
  142. package/src/engine/group/message-gating.ts +0 -216
  143. package/src/engine/messaging/decode-media-path.ts +0 -82
  144. package/src/engine/messaging/media-source.ts +0 -215
  145. package/src/engine/messaging/media-type-detect.ts +0 -37
  146. package/src/engine/messaging/outbound-audio-port.ts +0 -38
  147. package/src/engine/messaging/outbound-deliver.ts +0 -810
  148. package/src/engine/messaging/outbound-media-send.ts +0 -702
  149. package/src/engine/messaging/outbound-reply.ts +0 -27
  150. package/src/engine/messaging/outbound-result-helpers.ts +0 -54
  151. package/src/engine/messaging/outbound-types.ts +0 -45
  152. package/src/engine/messaging/outbound.ts +0 -485
  153. package/src/engine/messaging/reply-dispatcher.ts +0 -597
  154. package/src/engine/messaging/reply-limiter.ts +0 -164
  155. package/src/engine/messaging/sender.ts +0 -729
  156. package/src/engine/messaging/streaming-c2c.ts +0 -1192
  157. package/src/engine/messaging/streaming-media-send.ts +0 -544
  158. package/src/engine/messaging/target-parser.ts +0 -104
  159. package/src/engine/ref/format-message-ref.ts +0 -142
  160. package/src/engine/ref/format-ref-entry.test.ts +0 -60
  161. package/src/engine/ref/format-ref-entry.ts +0 -27
  162. package/src/engine/ref/store.ts +0 -208
  163. package/src/engine/ref/types.ts +0 -27
  164. package/src/engine/session/known-users.ts +0 -137
  165. package/src/engine/session/session-store.ts +0 -204
  166. package/src/engine/tools/channel-api.ts +0 -244
  167. package/src/engine/tools/remind-logic.test.ts +0 -280
  168. package/src/engine/tools/remind-logic.ts +0 -377
  169. package/src/engine/types.ts +0 -313
  170. package/src/engine/utils/attachment-tags.test.ts +0 -186
  171. package/src/engine/utils/attachment-tags.ts +0 -174
  172. package/src/engine/utils/audio.test.ts +0 -250
  173. package/src/engine/utils/audio.ts +0 -585
  174. package/src/engine/utils/data-paths.ts +0 -38
  175. package/src/engine/utils/diagnostics.ts +0 -109
  176. package/src/engine/utils/file-utils.test.ts +0 -72
  177. package/src/engine/utils/file-utils.ts +0 -225
  178. package/src/engine/utils/format.test.ts +0 -68
  179. package/src/engine/utils/format.ts +0 -70
  180. package/src/engine/utils/image-size.test.ts +0 -158
  181. package/src/engine/utils/image-size.ts +0 -249
  182. package/src/engine/utils/log.test.ts +0 -28
  183. package/src/engine/utils/log.ts +0 -61
  184. package/src/engine/utils/media-tags.test.ts +0 -32
  185. package/src/engine/utils/media-tags.ts +0 -177
  186. package/src/engine/utils/payload.test.ts +0 -68
  187. package/src/engine/utils/payload.ts +0 -145
  188. package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
  189. package/src/engine/utils/platform.test.ts +0 -148
  190. package/src/engine/utils/platform.ts +0 -343
  191. package/src/engine/utils/request-context.ts +0 -60
  192. package/src/engine/utils/string-normalize.ts +0 -91
  193. package/src/engine/utils/stt.test.ts +0 -104
  194. package/src/engine/utils/stt.ts +0 -100
  195. package/src/engine/utils/text-parsing.test.ts +0 -29
  196. package/src/engine/utils/text-parsing.ts +0 -155
  197. package/src/engine/utils/upload-cache.ts +0 -96
  198. package/src/engine/utils/voice-text.ts +0 -15
  199. package/src/exec-approvals.ts +0 -218
  200. package/src/manifest-schema.test.ts +0 -56
  201. package/src/qqbot-test-support.ts +0 -29
  202. package/src/types.ts +0 -210
  203. package/tsconfig.json +0 -16
@@ -1,622 +0,0 @@
1
- /**
2
- * Chunked media upload for the QQ Open Platform.
3
- *
4
- * ## Flow (mirrors the upload sequence diagram)
5
- *
6
- * 1. `upload_prepare` — submit file metadata + (md5 / sha1 / md5_10m) hashes,
7
- * receive `{ upload_id, block_size, parts[], concurrency?, retry_timeout? }`.
8
- * 2. For every part (parallelized under a bounded concurrency):
9
- * a. Read the part bytes (stream from disk or slice in-memory buffer).
10
- * b. PUT the bytes to the pre-signed COS URL.
11
- * c. POST `upload_part_finish { upload_id, part_index, block_size, md5 }`,
12
- * retrying under {@link PART_FINISH_RETRY_POLICY} + the persistent
13
- * retry loop for {@link PART_FINISH_RETRYABLE_CODES}.
14
- * 3. POST `complete_upload { upload_id }` — returns `{ file_uuid, file_info,
15
- * ttl }` identical to the one-shot path.
16
- * 4. If `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE}
17
- * (`40093002` — daily upload quota exceeded), throw
18
- * {@link UploadDailyLimitExceededError} so the upper layer can surface a
19
- * user-facing message. The dispatcher is responsible for the fallback
20
- * (there is no server path that will accept the file at this point).
21
- *
22
- * ## Why a class
23
- *
24
- * Mirrors {@link MediaApi}: injects {@link ApiClient}, {@link TokenManager},
25
- * the upload cache adapter, an optional filename sanitizer, and a logger.
26
- * Keeping the client singleton plumbing consistent means only one place
27
- * manages UA / baseUrl / file-upload timeouts.
28
- *
29
- * ## Upload cache integration
30
- *
31
- * Chunked uploads participate in the same `file_info` cache as
32
- * {@link MediaApi.uploadMedia}. The cache key is derived from the full-file
33
- * md5 (already computed for `upload_prepare`) so repeat sends of the same
34
- * large file hit the cache before we even talk to `upload_prepare`.
35
- */
36
-
37
- import * as crypto from "node:crypto";
38
- import * as fs from "node:fs";
39
- import type { MediaSource } from "../messaging/media-source.js";
40
- import {
41
- ApiError,
42
- MediaFileType,
43
- type ChatScope,
44
- type EngineLogger,
45
- type UploadMediaResponse,
46
- type UploadPart,
47
- type UploadPrepareHashes,
48
- type UploadPrepareResponse,
49
- } from "../types.js";
50
- import { formatFileSize } from "../utils/file-utils.js";
51
- import type { ApiClient } from "./api-client.js";
52
- import type { SanitizeFileNameFn, UploadCacheAdapter } from "./media.js";
53
- import {
54
- buildPartFinishPersistentPolicy,
55
- COMPLETE_UPLOAD_RETRY_POLICY,
56
- PART_FINISH_RETRY_POLICY,
57
- UPLOAD_PREPARE_FALLBACK_CODE,
58
- withRetry,
59
- } from "./retry.js";
60
- import { uploadCompletePath, uploadPartFinishPath, uploadPreparePath } from "./routes.js";
61
- import type { TokenManager } from "./token.js";
62
-
63
- // ============ Public types ============
64
-
65
- /**
66
- * Raised when `upload_prepare` returns {@link UPLOAD_PREPARE_FALLBACK_CODE}
67
- * (40093002). Carries enough context for the outbound layer to render a
68
- * user-facing fallback message (file name, size, and the originating
69
- * local path when available).
70
- */
71
- export class UploadDailyLimitExceededError extends Error {
72
- override readonly name = "UploadDailyLimitExceededError";
73
-
74
- constructor(
75
- /** Original local file path, or `"<buffer>"` when uploading an in-memory buffer. */
76
- public readonly filePath: string,
77
- /** File size in bytes. */
78
- public readonly fileSize: number,
79
- /** Original error message from the server. */
80
- originalMessage: string,
81
- ) {
82
- super(originalMessage);
83
- }
84
- }
85
-
86
- /** Chunked-upload progress callback payload. */
87
- interface ChunkedUploadProgress {
88
- completedParts: number;
89
- totalParts: number;
90
- uploadedBytes: number;
91
- totalBytes: number;
92
- }
93
-
94
- /** Per-call options for {@link ChunkedMediaApi.uploadChunked}. */
95
- interface UploadChunkedOptions {
96
- scope: ChatScope;
97
- targetId: string;
98
- fileType: MediaFileType;
99
- source: MediaSource;
100
- creds: { appId: string; clientSecret: string };
101
- /**
102
- * Optional filename override. When omitted, derived from `source.path`
103
- * (localPath) / `source.fileName` (buffer) / `"file"` (fallback).
104
- */
105
- fileName?: string;
106
- /** Progress callback invoked after every successful part. */
107
- onProgress?: (progress: ChunkedUploadProgress) => void;
108
- /** Log prefix — defaults to `"[qqbot:chunked-upload]"`. */
109
- logPrefix?: string;
110
- }
111
-
112
- /** Configuration for the {@link ChunkedMediaApi} constructor. */
113
- interface ChunkedMediaApiConfig {
114
- logger?: EngineLogger;
115
- /** Upload cache adapter (optional; omit to disable caching). */
116
- uploadCache?: UploadCacheAdapter;
117
- /** File name sanitizer — defaults to identity. */
118
- sanitizeFileName?: SanitizeFileNameFn;
119
- }
120
-
121
- // ============ Tuning constants ============
122
-
123
- /** Default concurrency when the server does not specify one. */
124
- const DEFAULT_CONCURRENT_PARTS = 1;
125
-
126
- /** Hard cap on per-upload concurrency regardless of what the server returns. */
127
- const MAX_CONCURRENT_PARTS = 10;
128
-
129
- /**
130
- * Upper bound on the persistent-retry window for `upload_part_finish`.
131
- *
132
- * The server may suggest `retry_timeout` via `upload_prepare` — we honor
133
- * it but clamp to 10 minutes so a runaway server can't hold the caller
134
- * hostage.
135
- */
136
- const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000;
137
-
138
- /** Per-part PUT timeout (5 minutes). Matches the low-bandwidth tolerance. */
139
- const PART_UPLOAD_TIMEOUT_MS = 300_000;
140
-
141
- /**
142
- * Boundary used by `md5_10m` — first 10,002,432 bytes.
143
- *
144
- * Files smaller than this return the whole-file md5 for `md5_10m` (per the
145
- * server contract).
146
- */
147
- const MD5_10M_SIZE = 10_002_432;
148
-
149
- // ============ Class ============
150
-
151
- /**
152
- * Chunked upload module. Stateless across calls — see
153
- * {@link ChunkedMediaApi.uploadChunked} for the main entry.
154
- */
155
- export class ChunkedMediaApi {
156
- private readonly client: ApiClient;
157
- private readonly tokenManager: TokenManager;
158
- private readonly logger?: EngineLogger;
159
- private readonly cache?: UploadCacheAdapter;
160
- private readonly sanitize: SanitizeFileNameFn;
161
-
162
- constructor(client: ApiClient, tokenManager: TokenManager, config: ChunkedMediaApiConfig = {}) {
163
- this.client = client;
164
- this.tokenManager = tokenManager;
165
- this.logger = config.logger;
166
- this.cache = config.uploadCache;
167
- this.sanitize = config.sanitizeFileName ?? ((n) => n);
168
- }
169
-
170
- /**
171
- * Upload a {@link MediaSource} via the chunked endpoint. Only `localPath`
172
- * and `buffer` sources are accepted — `url` / `base64` must fall through
173
- * to {@link MediaApi.uploadMedia}.
174
- *
175
- * @throws {UploadDailyLimitExceededError} when `upload_prepare` returns
176
- * {@link UPLOAD_PREPARE_FALLBACK_CODE}.
177
- */
178
- async uploadChunked(opts: UploadChunkedOptions): Promise<UploadMediaResponse> {
179
- const prefix = opts.logPrefix ?? "[qqbot:chunked-upload]";
180
-
181
- // 1. Resolve input: size + local path (or temp buffer handle).
182
- const input = resolveSource(opts.source, opts.fileName);
183
-
184
- const displayName = input.fileName;
185
- const fileSize = input.size;
186
- const pathLabel = input.kind === "localPath" ? input.path : "<buffer>";
187
-
188
- this.logger?.info?.(
189
- `${prefix} Start: file=${displayName} size=${formatFileSize(fileSize)} type=${opts.fileType}`,
190
- );
191
-
192
- // 2. Compute md5 / sha1 / md5_10m. Identical for buffer and localPath,
193
- // but the localPath path streams so it never has to materialize the
194
- // whole file twice.
195
- const hashes = await computeHashes(input);
196
- this.logger?.debug?.(
197
- `${prefix} hashes: md5=${hashes.md5} sha1=${hashes.sha1} md5_10m=${hashes.md5_10m}`,
198
- );
199
-
200
- // 3. Upload-cache fast path: the md5 hash is already a strong content
201
- // identifier, so we can short-circuit before even calling upload_prepare.
202
- if (this.cache) {
203
- const cached = this.cache.get(hashes.md5, opts.scope, opts.targetId, opts.fileType);
204
- if (cached) {
205
- this.logger?.info?.(
206
- `${prefix} cache HIT (md5=${hashes.md5.slice(0, 8)}) — skipping chunked upload`,
207
- );
208
- return { file_uuid: "", file_info: cached, ttl: 0 };
209
- }
210
- }
211
-
212
- // 4. upload_prepare.
213
- const fileNameForPrepare =
214
- opts.fileType === MediaFileType.FILE ? this.sanitize(displayName) : displayName;
215
- const prepareResp = await this.callUploadPrepare(
216
- opts,
217
- fileNameForPrepare,
218
- fileSize,
219
- hashes,
220
- pathLabel,
221
- );
222
-
223
- const { upload_id, parts } = prepareResp;
224
- const block_size = prepareResp.block_size;
225
- const maxConcurrent = Math.min(
226
- prepareResp.concurrency ? prepareResp.concurrency : DEFAULT_CONCURRENT_PARTS,
227
- MAX_CONCURRENT_PARTS,
228
- );
229
- const retryTimeoutMs = prepareResp.retry_timeout
230
- ? Math.min(prepareResp.retry_timeout * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
231
- : undefined;
232
-
233
- this.logger?.info?.(
234
- `${prefix} prepared: upload_id=${upload_id} block=${formatFileSize(block_size)} parts=${parts.length} concurrency=${maxConcurrent}`,
235
- );
236
-
237
- // 5. Upload every part. Concurrency is per-upload, not global.
238
- let completedParts = 0;
239
- let uploadedBytes = 0;
240
-
241
- const uploadPart = async (part: UploadPart): Promise<void> => {
242
- const partIndex = part.index; // 1-based.
243
- const offset = (partIndex - 1) * block_size;
244
- const length = Math.min(block_size, fileSize - offset);
245
-
246
- const partBuffer = await readPart(input, offset, length);
247
- const md5Hex = crypto.createHash("md5").update(partBuffer).digest("hex");
248
-
249
- this.logger?.debug?.(
250
- `${prefix} part ${partIndex}/${parts.length}: ${formatFileSize(length)} offset=${offset} md5=${md5Hex}`,
251
- );
252
-
253
- // 5a. PUT to pre-signed COS URL.
254
- await putToPresignedUrl(
255
- part.presigned_url,
256
- partBuffer,
257
- partIndex,
258
- parts.length,
259
- this.logger,
260
- prefix,
261
- );
262
-
263
- // 5b. upload_part_finish — fetch a fresh token each time to defend
264
- // against long uploads exceeding the token TTL.
265
- await this.callUploadPartFinish(opts, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
266
-
267
- completedParts++;
268
- uploadedBytes += length;
269
- this.logger?.info?.(
270
- `${prefix} part ${partIndex}/${parts.length} done (${completedParts}/${parts.length})`,
271
- );
272
-
273
- opts.onProgress?.({
274
- completedParts,
275
- totalParts: parts.length,
276
- uploadedBytes,
277
- totalBytes: fileSize,
278
- });
279
- };
280
-
281
- try {
282
- await runWithConcurrency(
283
- parts.map((part) => () => uploadPart(part)),
284
- maxConcurrent,
285
- );
286
- } finally {
287
- // If the input opened a buffered read stream we don't keep state,
288
- // but localPath readers open / close the file per-part so there
289
- // is nothing to unwind here. Kept as a seam for future streaming
290
- // optimizations.
291
- }
292
-
293
- this.logger?.info?.(`${prefix} all parts uploaded, completing...`);
294
-
295
- // 6. complete_upload.
296
- const result = await this.callCompleteUpload(opts, upload_id);
297
- this.logger?.info?.(`${prefix} completed: file_uuid=${result.file_uuid} ttl=${result.ttl}s`);
298
-
299
- // 7. Populate the shared upload cache so subsequent sends skip re-uploading.
300
- if (this.cache && result.file_info && result.ttl > 0) {
301
- this.cache.set(
302
- hashes.md5,
303
- opts.scope,
304
- opts.targetId,
305
- opts.fileType,
306
- result.file_info,
307
- result.file_uuid,
308
- result.ttl,
309
- );
310
- }
311
-
312
- return result;
313
- }
314
-
315
- // -------- Internal call wrappers --------
316
-
317
- private async callUploadPrepare(
318
- opts: UploadChunkedOptions,
319
- fileName: string,
320
- fileSize: number,
321
- hashes: UploadPrepareHashes,
322
- pathLabel: string,
323
- ): Promise<UploadPrepareResponse> {
324
- const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
325
- const path = uploadPreparePath(opts.scope, opts.targetId);
326
- try {
327
- return await this.client.request<UploadPrepareResponse>(
328
- token,
329
- "POST",
330
- path,
331
- {
332
- file_type: opts.fileType,
333
- file_name: fileName,
334
- file_size: fileSize,
335
- md5: hashes.md5,
336
- sha1: hashes.sha1,
337
- md5_10m: hashes.md5_10m,
338
- },
339
- { uploadRequest: true },
340
- );
341
- } catch (err) {
342
- if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
343
- throw new UploadDailyLimitExceededError(pathLabel, fileSize, err.message);
344
- }
345
- throw err;
346
- }
347
- }
348
-
349
- private async callUploadPartFinish(
350
- opts: UploadChunkedOptions,
351
- uploadId: string,
352
- partIndex: number,
353
- blockSize: number,
354
- md5: string,
355
- retryTimeoutMs?: number,
356
- ): Promise<void> {
357
- const persistentPolicy = buildPartFinishPersistentPolicy(retryTimeoutMs);
358
- const path = uploadPartFinishPath(opts.scope, opts.targetId);
359
- await withRetry(
360
- async () => {
361
- // Refresh the token on every attempt — the token may be expired by
362
- // the time we reach the tail of a long upload.
363
- const token = await this.tokenManager.getAccessToken(
364
- opts.creds.appId,
365
- opts.creds.clientSecret,
366
- );
367
- return this.client.request(
368
- token,
369
- "POST",
370
- path,
371
- {
372
- upload_id: uploadId,
373
- part_index: partIndex,
374
- block_size: blockSize,
375
- md5,
376
- },
377
- { uploadRequest: true },
378
- );
379
- },
380
- PART_FINISH_RETRY_POLICY,
381
- persistentPolicy,
382
- this.logger,
383
- );
384
- }
385
-
386
- private async callCompleteUpload(
387
- opts: UploadChunkedOptions,
388
- uploadId: string,
389
- ): Promise<UploadMediaResponse> {
390
- const path = uploadCompletePath(opts.scope, opts.targetId);
391
- return withRetry(
392
- async () => {
393
- const token = await this.tokenManager.getAccessToken(
394
- opts.creds.appId,
395
- opts.creds.clientSecret,
396
- );
397
- return this.client.request<UploadMediaResponse>(
398
- token,
399
- "POST",
400
- path,
401
- { upload_id: uploadId },
402
- { uploadRequest: true },
403
- );
404
- },
405
- COMPLETE_UPLOAD_RETRY_POLICY,
406
- undefined,
407
- this.logger,
408
- );
409
- }
410
- }
411
-
412
- // ============ Legacy functional facade ============
413
-
414
- /**
415
- * @deprecated The chunked uploader is always implemented.
416
- *
417
- * Legacy feature flag. The chunked uploader is fully implemented, so this
418
- * returns `true`. Retained so that older call sites can be converted
419
- * progressively.
420
- */
421
- export function isChunkedUploadImplemented(): boolean {
422
- return true;
423
- }
424
-
425
- // ============ Source resolution ============
426
-
427
- /**
428
- * Normalized chunked-upload input: everything the uploader needs to read
429
- * the bytes plus the metadata required by `upload_prepare`.
430
- */
431
- type ChunkedInput =
432
- | { kind: "localPath"; path: string; size: number; fileName: string }
433
- | { kind: "buffer"; buffer: Buffer; size: number; fileName: string };
434
-
435
- function resolveSource(source: MediaSource, fileNameOverride?: string): ChunkedInput {
436
- if (source.kind === "localPath") {
437
- const inferredName = source.path.split(/[/\\]/).pop() || "file";
438
- return {
439
- kind: "localPath",
440
- path: source.path,
441
- size: source.size,
442
- fileName: fileNameOverride ?? inferredName,
443
- };
444
- }
445
- if (source.kind === "buffer") {
446
- return {
447
- kind: "buffer",
448
- buffer: source.buffer,
449
- size: source.buffer.length,
450
- fileName: fileNameOverride ?? source.fileName ?? "file",
451
- };
452
- }
453
- throw new Error(
454
- `ChunkedMediaApi: unsupported source kind '${source.kind}'. ` +
455
- "Chunked upload only supports 'localPath' and 'buffer'; route 'url'/'base64' through the one-shot uploader.",
456
- );
457
- }
458
-
459
- async function readPart(input: ChunkedInput, offset: number, length: number): Promise<Buffer> {
460
- if (input.kind === "buffer") {
461
- return input.buffer.subarray(offset, offset + length);
462
- }
463
- const handle = await fs.promises.open(input.path, "r");
464
- try {
465
- const buf = Buffer.alloc(length);
466
- const { bytesRead } = await handle.read(buf, 0, length, offset);
467
- return bytesRead < length ? buf.subarray(0, bytesRead) : buf;
468
- } finally {
469
- await handle.close();
470
- }
471
- }
472
-
473
- // ============ Hash computation ============
474
-
475
- /**
476
- * Stream the source once to compute md5 + sha1 + md5_10m.
477
- *
478
- * For buffer inputs the three hashes are computed in a single pass over
479
- * the existing memory. For localPath inputs a ReadStream drives the
480
- * hashers so memory use stays constant.
481
- */
482
- async function computeHashes(input: ChunkedInput): Promise<UploadPrepareHashes> {
483
- if (input.kind === "buffer") {
484
- const md5 = crypto.createHash("md5").update(input.buffer).digest("hex");
485
- const sha1 = crypto.createHash("sha1").update(input.buffer).digest("hex");
486
- const md5_10m =
487
- input.size > MD5_10M_SIZE
488
- ? crypto.createHash("md5").update(input.buffer.subarray(0, MD5_10M_SIZE)).digest("hex")
489
- : md5;
490
- return { md5, sha1, md5_10m };
491
- }
492
-
493
- return new Promise((resolve, reject) => {
494
- const md5 = crypto.createHash("md5");
495
- const sha1 = crypto.createHash("sha1");
496
- const md5_10m = crypto.createHash("md5");
497
- let consumed = 0;
498
- const needsMd5_10m = input.size > MD5_10M_SIZE;
499
-
500
- const stream = fs.createReadStream(input.path);
501
- stream.on("data", (chunk: Buffer | string) => {
502
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
503
- md5.update(buf);
504
- sha1.update(buf);
505
- if (needsMd5_10m) {
506
- const remaining = MD5_10M_SIZE - consumed;
507
- if (remaining > 0) {
508
- md5_10m.update(remaining >= buf.length ? buf : buf.subarray(0, remaining));
509
- }
510
- }
511
- consumed += buf.length;
512
- });
513
- stream.on("end", () => {
514
- const md5Hex = md5.digest("hex");
515
- const sha1Hex = sha1.digest("hex");
516
- resolve({
517
- md5: md5Hex,
518
- sha1: sha1Hex,
519
- md5_10m: needsMd5_10m ? md5_10m.digest("hex") : md5Hex,
520
- });
521
- });
522
- stream.on("error", reject);
523
- });
524
- }
525
-
526
- // ============ COS PUT ============
527
-
528
- /** Per-part retry budget for the COS PUT call (exponential backoff). */
529
- const PART_UPLOAD_MAX_RETRIES = 2;
530
-
531
- async function putToPresignedUrl(
532
- presignedUrl: string,
533
- data: Buffer,
534
- partIndex: number,
535
- totalParts: number,
536
- logger: EngineLogger | undefined,
537
- prefix: string,
538
- ): Promise<void> {
539
- let lastError: Error | null = null;
540
-
541
- for (let attempt = 0; attempt <= PART_UPLOAD_MAX_RETRIES; attempt++) {
542
- const controller = new AbortController();
543
- const timeoutId = setTimeout(() => controller.abort(), PART_UPLOAD_TIMEOUT_MS);
544
-
545
- try {
546
- // Convert to a standard ArrayBuffer before wrapping in Blob so type
547
- // definitions (incl. bun-types) accept the argument.
548
- const ab = data.buffer.slice(
549
- data.byteOffset,
550
- data.byteOffset + data.byteLength,
551
- ) as ArrayBuffer;
552
-
553
- const startTime = Date.now();
554
- const response = await fetch(presignedUrl, {
555
- method: "PUT",
556
- body: new Blob([ab]),
557
- headers: { "Content-Length": String(data.length) },
558
- signal: controller.signal,
559
- });
560
- const elapsed = Date.now() - startTime;
561
- const requestId = response.headers.get("x-cos-request-id") ?? "-";
562
- const etag = response.headers.get("ETag") ?? "-";
563
-
564
- if (!response.ok) {
565
- const body = await response.text().catch(() => "");
566
- logger?.error?.(
567
- `${prefix} PUT part ${partIndex}/${totalParts}: HTTP ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body.slice(0, 160)}`,
568
- );
569
- throw new Error(
570
- `COS PUT failed: ${response.status} ${response.statusText} - ${body.slice(0, 120)}`,
571
- );
572
- }
573
-
574
- logger?.debug?.(
575
- `${prefix} PUT part ${partIndex}/${totalParts} OK (${elapsed}ms ETag=${etag} requestId=${requestId})`,
576
- );
577
- return;
578
- } catch (err) {
579
- lastError = err instanceof Error ? err : new Error(String(err));
580
- if (lastError.name === "AbortError") {
581
- lastError = new Error(
582
- `Part ${partIndex}/${totalParts} upload timeout after ${PART_UPLOAD_TIMEOUT_MS}ms`,
583
- );
584
- }
585
- if (attempt < PART_UPLOAD_MAX_RETRIES) {
586
- const delay = 1000 * 2 ** attempt;
587
- (logger?.warn ?? logger?.error)?.(
588
- `${prefix} PUT part ${partIndex}/${totalParts} attempt ${attempt + 1} failed (${lastError.message.slice(0, 120)}), retrying in ${delay}ms`,
589
- );
590
- await sleep(delay);
591
- }
592
- } finally {
593
- clearTimeout(timeoutId);
594
- }
595
- }
596
-
597
- throw lastError ?? new Error(`Part ${partIndex}/${totalParts} upload failed`);
598
- }
599
-
600
- // ============ Concurrency ============
601
-
602
- /**
603
- * Batch-mode concurrency limiter. Deliberately simple: dispatch N tasks at
604
- * a time and wait for the whole batch to settle before the next batch.
605
- *
606
- * A pool / queue implementation would recover some throughput when tasks
607
- * have heavy variance, but part uploads are size-uniform (last part can be
608
- * short) so the extra complexity is not worth it.
609
- */
610
- async function runWithConcurrency(
611
- tasks: Array<() => Promise<void>>,
612
- maxConcurrent: number,
613
- ): Promise<void> {
614
- for (let i = 0; i < tasks.length; i += maxConcurrent) {
615
- const batch = tasks.slice(i, i + maxConcurrent);
616
- await Promise.all(batch.map((task) => task()));
617
- }
618
- }
619
-
620
- function sleep(ms: number): Promise<void> {
621
- return new Promise((resolve) => setTimeout(resolve, ms));
622
- }