@stelfield/cli 0.0.0-stage → 0.12.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 +327 -2
- package/bin/stelfield.js +11 -0
- package/package.json +36 -4
- package/src/api.js +1088 -0
- package/src/atomic-write.js +44 -0
- package/src/auto-accept.js +74 -0
- package/src/brand.js +20 -0
- package/src/callback-pages.js +175 -0
- package/src/cli-parse.js +645 -0
- package/src/client-version.js +84 -0
- package/src/collab-input.js +116 -0
- package/src/config.js +17 -0
- package/src/connect.js +416 -0
- package/src/cont-commands.js +264 -0
- package/src/credentials.js +70 -0
- package/src/error-map.js +126 -0
- package/src/exit-reasons.js +65 -0
- package/src/extension-commands.js +236 -0
- package/src/file-deliver.js +243 -0
- package/src/format.js +1454 -0
- package/src/index.js +1899 -0
- package/src/input-limits.js +84 -0
- package/src/libra-commands.js +681 -0
- package/src/local-media-refs.js +228 -0
- package/src/media-commands.js +121 -0
- package/src/media-safety.js +160 -0
- package/src/media-upload.js +478 -0
- package/src/oauth.js +327 -0
- package/src/pkce.js +47 -0
- package/src/poll-state.js +88 -0
- package/src/probe.js +155 -0
- package/src/profile-intro-input.js +108 -0
- package/src/profile.js +62 -0
- package/src/quote-pass.js +133 -0
- package/src/refund-commands.js +339 -0
- package/src/retired-commands.js +25 -0
- package/src/web-deliver.js +332 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// Local media upload for agents (v0.19.0 M3 / CLI 0.10.0).
|
|
2
|
+
//
|
|
3
|
+
// What this does for the user: an agent can put a local picture or video on the merchant's
|
|
4
|
+
// homepage. Each file goes through three steps the platform already offers to the merchant's
|
|
5
|
+
// web page: ask for an upload address -> PUT the bytes straight to storage -> confirm.
|
|
6
|
+
//
|
|
7
|
+
// This file holds everything that is NOT command wiring:
|
|
8
|
+
// - the extension whitelist and size limits (mirrors the backend MIME whitelist),
|
|
9
|
+
// - a local pre-check (exists / regular file / extension / size),
|
|
10
|
+
// - a pure-JS video duration reader (mp4/mov moov/mvhd, webm EBML Info) - no ffprobe needed,
|
|
11
|
+
// - the upload loop with sha256 de-duplication and a user-facing Chinese error mapping.
|
|
12
|
+
//
|
|
13
|
+
// Nothing here reads a path the caller did not hand over: choosing WHICH paths are allowed
|
|
14
|
+
// (the Markdown sub-tree rule) is the job of local-media-refs.js.
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
18
|
+
import { basename, extname } from 'node:path';
|
|
19
|
+
import { confirmProfileMedia, requestProfileMediaUpload } from './api.js';
|
|
20
|
+
import { GPS_REJECT_TEXT, jpegHasGps, sniffMagic } from './media-safety.js';
|
|
21
|
+
|
|
22
|
+
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
23
|
+
export const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
|
|
24
|
+
/** How much of a file's start is read locally for the magic-number and EXIF checks. */
|
|
25
|
+
const HEAD_READ_BYTES = 256 * 1024;
|
|
26
|
+
/** Max files per command (same number for `media upload` and for one Markdown file). */
|
|
27
|
+
export const MAX_FILES_PER_COMMAND = 20;
|
|
28
|
+
|
|
29
|
+
/** extension -> { mediaType, mime }. Mirrors backend IMAGE_MIME_TYPES / VIDEO_MIME_TYPES. */
|
|
30
|
+
export const MEDIA_EXTENSIONS = {
|
|
31
|
+
'.jpg': { mediaType: 'image', mime: 'image/jpeg' },
|
|
32
|
+
'.jpeg': { mediaType: 'image', mime: 'image/jpeg' },
|
|
33
|
+
'.png': { mediaType: 'image', mime: 'image/png' },
|
|
34
|
+
'.gif': { mediaType: 'image', mime: 'image/gif' },
|
|
35
|
+
'.webp': { mediaType: 'image', mime: 'image/webp' },
|
|
36
|
+
'.mp4': { mediaType: 'video', mime: 'video/mp4' },
|
|
37
|
+
'.mov': { mediaType: 'video', mime: 'video/quicktime' },
|
|
38
|
+
'.webm': { mediaType: 'video', mime: 'video/webm' },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const ALLOWED_EXT_TEXT = 'jpg/jpeg/png/gif/webp(图片)、mp4/mov/webm(视频)';
|
|
42
|
+
export const HOURLY_LIMIT_HINT = '每个 agent 每小时最多上传 30 个,请稍后再试。';
|
|
43
|
+
export const VIDEO_DURATION_HINT = '请改用 mp4 / mov 格式再试,或到网页上传。';
|
|
44
|
+
|
|
45
|
+
/** Error carrying a ready-to-print Chinese message. `uploaded` lists what already succeeded. */
|
|
46
|
+
export class MediaUploadError extends Error {
|
|
47
|
+
constructor(message, extra = {}) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'MediaUploadError';
|
|
50
|
+
this.status = extra.status ?? null;
|
|
51
|
+
this.file = extra.file ?? null;
|
|
52
|
+
this.uploaded = extra.uploaded ?? [];
|
|
53
|
+
/** true when the failure must stop the whole run (rate limit, quota, auth). */
|
|
54
|
+
this.fatal = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function humanSize(n) {
|
|
59
|
+
const v = Number(n);
|
|
60
|
+
if (!Number.isFinite(v) || v < 0) return '?';
|
|
61
|
+
if (v < 1024) return `${v}B`;
|
|
62
|
+
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)}KB`;
|
|
63
|
+
return `${(v / 1024 / 1024).toFixed(1)}MB`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================================
|
|
67
|
+
// pure-JS video duration
|
|
68
|
+
// ============================================================
|
|
69
|
+
|
|
70
|
+
function readAt(fd, pos, len) {
|
|
71
|
+
const buf = Buffer.alloc(len);
|
|
72
|
+
const n = readSync(fd, buf, 0, len, pos);
|
|
73
|
+
return buf.subarray(0, n);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Turn a (duration, timescale) pair into whole seconds; null when it does not make sense. */
|
|
77
|
+
function toSeconds(duration, timescale) {
|
|
78
|
+
if (!Number.isFinite(duration) || !Number.isFinite(timescale) || timescale <= 0 || duration <= 0) return null;
|
|
79
|
+
const sec = duration / timescale;
|
|
80
|
+
if (!Number.isFinite(sec) || sec <= 0) return null;
|
|
81
|
+
return Math.max(1, Math.round(sec));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Find the `mvhd` box inside the body of a `moov` box and return the duration in seconds. */
|
|
85
|
+
function durationFromMoovBody(body) {
|
|
86
|
+
let pos = 0;
|
|
87
|
+
while (pos + 8 <= body.length) {
|
|
88
|
+
let size = body.readUInt32BE(pos);
|
|
89
|
+
const type = body.toString('latin1', pos + 4, pos + 8);
|
|
90
|
+
let hdr = 8;
|
|
91
|
+
if (size === 1) {
|
|
92
|
+
if (pos + 16 > body.length) return null;
|
|
93
|
+
size = Number(body.readBigUInt64BE(pos + 8));
|
|
94
|
+
hdr = 16;
|
|
95
|
+
} else if (size === 0) {
|
|
96
|
+
size = body.length - pos;
|
|
97
|
+
}
|
|
98
|
+
if (size < hdr) return null;
|
|
99
|
+
if (type === 'mvhd') {
|
|
100
|
+
const p = pos + hdr;
|
|
101
|
+
const version = body[p];
|
|
102
|
+
if (version === 1) {
|
|
103
|
+
if (p + 32 > body.length) return null;
|
|
104
|
+
const timescale = body.readUInt32BE(p + 20);
|
|
105
|
+
const duration = Number(body.readBigUInt64BE(p + 24));
|
|
106
|
+
return toSeconds(duration, timescale);
|
|
107
|
+
}
|
|
108
|
+
if (p + 20 > body.length) return null;
|
|
109
|
+
const timescale = body.readUInt32BE(p + 12);
|
|
110
|
+
const duration = body.readUInt32BE(p + 16);
|
|
111
|
+
if (duration === 0xffffffff) return null; // "unknown" marker
|
|
112
|
+
return toSeconds(duration, timescale);
|
|
113
|
+
}
|
|
114
|
+
pos += size;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** mp4 / mov: walk the top-level boxes to `moov`, then read `mvhd`. */
|
|
120
|
+
function mp4Duration(fd, fileSize) {
|
|
121
|
+
let pos = 0;
|
|
122
|
+
let first = true;
|
|
123
|
+
for (let i = 0; i < 500 && pos + 8 <= fileSize; i++) {
|
|
124
|
+
const h = readAt(fd, pos, 16);
|
|
125
|
+
if (h.length < 8) return null;
|
|
126
|
+
let size = h.readUInt32BE(0);
|
|
127
|
+
const type = h.toString('latin1', 4, 8);
|
|
128
|
+
// A real mp4/mov starts with "ftyp"; anything else is a renamed file.
|
|
129
|
+
if (first && type !== 'ftyp') return null;
|
|
130
|
+
first = false;
|
|
131
|
+
let hdr = 8;
|
|
132
|
+
if (size === 1) {
|
|
133
|
+
if (h.length < 16) return null;
|
|
134
|
+
size = Number(h.readBigUInt64BE(8));
|
|
135
|
+
hdr = 16;
|
|
136
|
+
} else if (size === 0) {
|
|
137
|
+
size = fileSize - pos;
|
|
138
|
+
}
|
|
139
|
+
if (size < hdr) return null;
|
|
140
|
+
if (type === 'moov') {
|
|
141
|
+
// mvhd is the first child in practice; 1MB of the box is far more than enough.
|
|
142
|
+
const body = readAt(fd, pos + hdr, Math.min(size - hdr, 1024 * 1024));
|
|
143
|
+
return durationFromMoovBody(body);
|
|
144
|
+
}
|
|
145
|
+
pos += size;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const EBML_ID_HEADER = 0x1a45dfa3;
|
|
151
|
+
const EBML_ID_SEGMENT = 0x18538067;
|
|
152
|
+
const EBML_ID_INFO = 0x1549a966;
|
|
153
|
+
const EBML_ID_TIMECODE_SCALE = 0x2ad7b1;
|
|
154
|
+
const EBML_ID_DURATION = 0x4489;
|
|
155
|
+
|
|
156
|
+
/** Read one EBML element header at buf[pos]; null when truncated/invalid. size -1 = unknown. */
|
|
157
|
+
function ebmlHeader(buf, pos) {
|
|
158
|
+
if (pos >= buf.length) return null;
|
|
159
|
+
const b0 = buf[pos];
|
|
160
|
+
let idLen = 1;
|
|
161
|
+
while (idLen <= 4 && !(b0 & (0x80 >> (idLen - 1)))) idLen++;
|
|
162
|
+
if (idLen > 4 || pos + idLen >= buf.length) return null;
|
|
163
|
+
let id = 0;
|
|
164
|
+
for (let i = 0; i < idLen; i++) id = id * 256 + buf[pos + i];
|
|
165
|
+
const s0 = buf[pos + idLen];
|
|
166
|
+
let sizeLen = 1;
|
|
167
|
+
while (sizeLen <= 8 && !(s0 & (0x80 >> (sizeLen - 1)))) sizeLen++;
|
|
168
|
+
if (sizeLen > 8 || pos + idLen + sizeLen > buf.length) return null;
|
|
169
|
+
let size = s0 & (0xff >> sizeLen);
|
|
170
|
+
let allOnes = size === (0xff >> sizeLen);
|
|
171
|
+
for (let i = 1; i < sizeLen; i++) {
|
|
172
|
+
const b = buf[pos + idLen + i];
|
|
173
|
+
if (b !== 0xff) allOnes = false;
|
|
174
|
+
size = size * 256 + b;
|
|
175
|
+
}
|
|
176
|
+
return { id, size: allOnes ? -1 : size, dataStart: pos + idLen + sizeLen };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** webm: find Segment/Info and compute Duration * TimecodeScale (nanoseconds). */
|
|
180
|
+
function webmDuration(fd, fileSize) {
|
|
181
|
+
// The header region is enough in practice: Segment > SeekHead/Info come before the clusters.
|
|
182
|
+
const buf = readAt(fd, 0, Math.min(fileSize, 1024 * 1024));
|
|
183
|
+
let pos = 0;
|
|
184
|
+
let segmentEnd = buf.length;
|
|
185
|
+
for (let i = 0; i < 2000; i++) {
|
|
186
|
+
const el = ebmlHeader(buf, pos);
|
|
187
|
+
if (!el) return null;
|
|
188
|
+
if (i === 0 && el.id !== EBML_ID_HEADER) return null; // not a webm/mkv
|
|
189
|
+
if (el.id === EBML_ID_SEGMENT) {
|
|
190
|
+
// Descend into the segment: its children follow right after the header.
|
|
191
|
+
pos = el.dataStart;
|
|
192
|
+
segmentEnd = el.size < 0 ? buf.length : Math.min(buf.length, el.dataStart + el.size);
|
|
193
|
+
for (let j = 0; j < 2000 && pos < segmentEnd; j++) {
|
|
194
|
+
const ch = ebmlHeader(buf, pos);
|
|
195
|
+
if (!ch) return null;
|
|
196
|
+
if (ch.id === EBML_ID_INFO) {
|
|
197
|
+
const end = Math.min(buf.length, ch.dataStart + Math.max(ch.size, 0));
|
|
198
|
+
return webmInfoDuration(buf, ch.dataStart, end);
|
|
199
|
+
}
|
|
200
|
+
if (ch.size < 0) return null; // unknown-size child (cluster) before Info: give up
|
|
201
|
+
pos = ch.dataStart + ch.size;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (el.size < 0) return null;
|
|
206
|
+
pos = el.dataStart + el.size;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function webmInfoDuration(buf, start, end) {
|
|
212
|
+
let timecodeScale = 1000000; // spec default: 1ms
|
|
213
|
+
let duration = null;
|
|
214
|
+
let pos = start;
|
|
215
|
+
for (let i = 0; i < 200 && pos < end; i++) {
|
|
216
|
+
const el = ebmlHeader(buf, pos);
|
|
217
|
+
if (!el || el.size < 0) return null;
|
|
218
|
+
const dEnd = el.dataStart + el.size;
|
|
219
|
+
if (dEnd > end) return null;
|
|
220
|
+
if (el.id === EBML_ID_TIMECODE_SCALE) {
|
|
221
|
+
let v = 0;
|
|
222
|
+
for (let k = el.dataStart; k < dEnd; k++) v = v * 256 + buf[k];
|
|
223
|
+
if (v > 0) timecodeScale = v;
|
|
224
|
+
} else if (el.id === EBML_ID_DURATION) {
|
|
225
|
+
if (el.size === 4) duration = buf.readFloatBE(el.dataStart);
|
|
226
|
+
else if (el.size === 8) duration = buf.readDoubleBE(el.dataStart);
|
|
227
|
+
}
|
|
228
|
+
pos = dEnd;
|
|
229
|
+
}
|
|
230
|
+
if (duration == null) return null;
|
|
231
|
+
return toSeconds(duration * timecodeScale, 1e9);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Video duration in whole seconds, read from the file itself.
|
|
236
|
+
* @param {string} filePath
|
|
237
|
+
* @param {string} ext lower-case extension with dot
|
|
238
|
+
* @returns {number|null} null when the file cannot be parsed
|
|
239
|
+
*/
|
|
240
|
+
export function parseVideoDuration(filePath, ext) {
|
|
241
|
+
let fd;
|
|
242
|
+
try {
|
|
243
|
+
fd = openSync(filePath, 'r');
|
|
244
|
+
const size = statSync(filePath).size;
|
|
245
|
+
if (ext === '.webm') return webmDuration(fd, size);
|
|
246
|
+
return mp4Duration(fd, size);
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
} finally {
|
|
250
|
+
if (fd != null) { try { closeSync(fd); } catch { /* ignore */ } }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ============================================================
|
|
255
|
+
// local pre-check
|
|
256
|
+
// ============================================================
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Check one local file before anything is sent: exists, regular file (no directory / device /
|
|
260
|
+
* FIFO / socket), extension whitelist (of the typed name AND of the real file behind a symlink),
|
|
261
|
+
* size cap, the file's first bytes (must really be an allowed image / video), JPEG GPS data, and
|
|
262
|
+
* (video) a readable duration. The file content is only read locally here, never sent.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} absPath absolute path to read
|
|
265
|
+
* @param {string} label what to call the file in messages (the path the user typed)
|
|
266
|
+
* @param {{keepLocation?:boolean}} [opts] keepLocation: allow a JPEG that carries GPS data
|
|
267
|
+
* @returns {{ok:true, file:object}|{ok:false, error:string}}
|
|
268
|
+
*/
|
|
269
|
+
export function prepareFile(absPath, label = absPath, opts = {}) {
|
|
270
|
+
let real;
|
|
271
|
+
let st;
|
|
272
|
+
try {
|
|
273
|
+
real = realpathSync(absPath);
|
|
274
|
+
st = statSync(real);
|
|
275
|
+
} catch {
|
|
276
|
+
return { ok: false, error: `找不到文件:${label}。请检查路径是否写对。` };
|
|
277
|
+
}
|
|
278
|
+
if (!st.isFile()) {
|
|
279
|
+
return { ok: false, error: `${label} 不是普通文件(目录、设备等不能上传)。请指定具体的图片或视频文件。` };
|
|
280
|
+
}
|
|
281
|
+
const ext = extname(absPath).toLowerCase();
|
|
282
|
+
const kind = MEDIA_EXTENSIONS[ext];
|
|
283
|
+
if (!kind) {
|
|
284
|
+
return { ok: false, error: `${label} 的格式不支持(${ext || '没有扩展名'})。只支持:${ALLOWED_EXT_TEXT}。` };
|
|
285
|
+
}
|
|
286
|
+
// A symlink named a.png may point at a file with another name: that real name must be allowed too.
|
|
287
|
+
const realExt = extname(real).toLowerCase();
|
|
288
|
+
const realKind = MEDIA_EXTENSIONS[realExt];
|
|
289
|
+
if (!realKind || realKind.mediaType !== kind.mediaType) {
|
|
290
|
+
return { ok: false, error: `${label} 是符号链接,它实际指向的文件格式不支持(${realExt || '没有扩展名'})。只支持:${ALLOWED_EXT_TEXT}。没有读取或发送文件内容。` };
|
|
291
|
+
}
|
|
292
|
+
const cap = kind.mediaType === 'video' ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
|
|
293
|
+
if (st.size > cap) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
error: `${label} 太大(${humanSize(st.size)})。${kind.mediaType === 'video' ? '视频' : '图片'}不能超过 ${cap / 1024 / 1024}MB,请压缩后再试。`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
if (st.size === 0) {
|
|
300
|
+
return { ok: false, error: `${label} 是空文件,无法上传。` };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Look at the real first bytes (same magic table as the server). Local read only.
|
|
304
|
+
let head;
|
|
305
|
+
let fd;
|
|
306
|
+
try {
|
|
307
|
+
fd = openSync(real, 'r');
|
|
308
|
+
head = readAt(fd, 0, Math.min(st.size, HEAD_READ_BYTES));
|
|
309
|
+
} catch {
|
|
310
|
+
return { ok: false, error: `读不了文件:${label}` };
|
|
311
|
+
} finally {
|
|
312
|
+
if (fd != null) { try { closeSync(fd); } catch { /* ignore */ } }
|
|
313
|
+
}
|
|
314
|
+
const sniffed = sniffMagic(kind.mediaType, head);
|
|
315
|
+
if (!sniffed) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: `${label} 的内容不是有效的${kind.mediaType === 'video' ? '视频' : '图片'}(文件头不符,改扩展名没用)。没有读取或发送文件内容。`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (sniffed === 'image/jpeg' && !opts.keepLocation && jpegHasGps(head)) {
|
|
322
|
+
return { ok: false, error: `${label}:${GPS_REJECT_TEXT}。` };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The declared type follows the real content (the server insists they agree).
|
|
326
|
+
let mime = sniffed;
|
|
327
|
+
let durationExt = ext;
|
|
328
|
+
if (kind.mediaType === 'video') {
|
|
329
|
+
if (sniffed === 'video/mp4') { mime = ext === '.mov' ? 'video/quicktime' : 'video/mp4'; durationExt = '.mp4'; }
|
|
330
|
+
else durationExt = '.webm';
|
|
331
|
+
}
|
|
332
|
+
let durationSeconds = null;
|
|
333
|
+
if (kind.mediaType === 'video') {
|
|
334
|
+
durationSeconds = parseVideoDuration(real, durationExt);
|
|
335
|
+
if (durationSeconds == null) {
|
|
336
|
+
return { ok: false, error: `读不出视频时长:${label}。${VIDEO_DURATION_HINT}` };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
ok: true,
|
|
341
|
+
file: {
|
|
342
|
+
absPath: real,
|
|
343
|
+
label,
|
|
344
|
+
name: basename(absPath),
|
|
345
|
+
mediaType: kind.mediaType,
|
|
346
|
+
mime,
|
|
347
|
+
size: st.size,
|
|
348
|
+
durationSeconds,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ============================================================
|
|
354
|
+
// error mapping
|
|
355
|
+
// ============================================================
|
|
356
|
+
|
|
357
|
+
function bodyErrorCode(e) {
|
|
358
|
+
const b = e && e.body;
|
|
359
|
+
if (!b || typeof b !== 'object') return null;
|
|
360
|
+
return b.error || (b.detail && b.detail.error) || null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Translate a platform error from the upload endpoints into a Chinese message with a next step.
|
|
365
|
+
* Returns null for errors it does not know (the caller falls back to the raw message).
|
|
366
|
+
*/
|
|
367
|
+
export function mapMediaUploadError(e) {
|
|
368
|
+
if (!e || typeof e.status !== 'number') return null;
|
|
369
|
+
const code = bodyErrorCode(e);
|
|
370
|
+
if (e.status === 429) return HOURLY_LIMIT_HINT;
|
|
371
|
+
if (e.status === 413) {
|
|
372
|
+
if (code === 'media_storage_exceeded') return '媒体库已满(50 件 / 500MB),暂时无法再上传。请告知商户,目前需联系平台处理。';
|
|
373
|
+
return '文件太大:图片不能超过 10MB,视频不能超过 50MB。请压缩后再试。';
|
|
374
|
+
}
|
|
375
|
+
if (e.status === 415) return `文件类型不被接受。只支持:${ALLOWED_EXT_TEXT};文件内容也必须真的是对应格式(改扩展名没用)。`;
|
|
376
|
+
if (e.status === 422 && code === 'agent_media_cap_exceeded') return 'agent 上传的媒体已达 30 个上限,暂时无法再上传,请告知商户联系平台处理。';
|
|
377
|
+
if (e.status === 422 && code === 'media_count_exceeded') return '媒体库已满(50 件 / 500MB),暂时无法再上传。请告知商户,目前需联系平台处理。';
|
|
378
|
+
if (e.status === 422 && code === 'missing_duration') return `视频缺少时长信息。${VIDEO_DURATION_HINT}`;
|
|
379
|
+
if (e.status === 403 && code === 'agent_scope_forbidden') return '这个操作 agent 不能做(上传接口当前版本可能还没开放给 agent,请联系平台)。';
|
|
380
|
+
if (e.status === 403) return '没有权限上传:请确认登录的账号是商户本人名下的 agent,必要时重新运行 stelfield auth login。';
|
|
381
|
+
if (e.status === 401) return '登录状态已失效,请重新运行:stelfield auth login';
|
|
382
|
+
if (e.status === 404 && code === 'object_not_found') return '文件没有上传成功(存储里找不到),请重试。';
|
|
383
|
+
if (e.status === 409) return '这个上传地址已经用过,请重试。';
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ============================================================
|
|
388
|
+
// upload loop
|
|
389
|
+
// ============================================================
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Upload prepared files one by one: upload-request -> PUT -> confirm.
|
|
393
|
+
* Files with identical content (sha256) are uploaded once and the result is reused.
|
|
394
|
+
* The first failure aborts the whole run (a 429 especially: more attempts only waste quota).
|
|
395
|
+
*
|
|
396
|
+
* @param {object[]} files output of prepareFile().file
|
|
397
|
+
* @param {object} ctx { baseUrl, credOpts, fetchImpl?, onProgress?(msg) }
|
|
398
|
+
* @returns {Promise<object[]>} one result per input, same order:
|
|
399
|
+
* { file, ref, mediaId, mediaType, size, uploadedBy, duplicateOf? }
|
|
400
|
+
* @throws {MediaUploadError} with `.uploaded` = results done before the failure
|
|
401
|
+
*/
|
|
402
|
+
export async function uploadFiles(files, ctx) {
|
|
403
|
+
const { baseUrl, credOpts, fetchImpl, onProgress } = ctx;
|
|
404
|
+
const putFetch = fetchImpl || globalThis.fetch;
|
|
405
|
+
const bySha = new Map();
|
|
406
|
+
const results = [];
|
|
407
|
+
|
|
408
|
+
for (const f of files) {
|
|
409
|
+
let buf;
|
|
410
|
+
try {
|
|
411
|
+
buf = readFileSync(f.absPath);
|
|
412
|
+
} catch {
|
|
413
|
+
throw new MediaUploadError(`读不了文件:${f.label}`, { file: f.label, uploaded: results });
|
|
414
|
+
}
|
|
415
|
+
if (buf.length !== f.size) {
|
|
416
|
+
throw new MediaUploadError(`${f.label} 在检查后被改动了,请重试。`, { file: f.label, uploaded: results });
|
|
417
|
+
}
|
|
418
|
+
// Second line of defence: the bytes about to be sent must still look like the checked type
|
|
419
|
+
// (covers a file swapped between the check and now). Nothing has gone to the network yet.
|
|
420
|
+
if (sniffMagic(f.mediaType, buf.subarray(0, 32)) == null) {
|
|
421
|
+
throw new MediaUploadError(`${f.label} 的内容不是有效的${f.mediaType === 'video' ? '视频' : '图片'}(文件头不符),没有上传。`, { file: f.label, uploaded: results });
|
|
422
|
+
}
|
|
423
|
+
const sha = createHash('sha256').update(buf).digest('hex');
|
|
424
|
+
const seen = bySha.get(sha);
|
|
425
|
+
if (seen) {
|
|
426
|
+
results.push({ ...seen.result, file: f.label, duplicateOf: seen.label });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let item;
|
|
431
|
+
try {
|
|
432
|
+
if (onProgress) onProgress(`上传 ${f.label}(${humanSize(f.size)})…`);
|
|
433
|
+
const req = await requestProfileMediaUpload(
|
|
434
|
+
baseUrl, credOpts,
|
|
435
|
+
{ media_type: f.mediaType, mime_type: f.mime, file_size_bytes: f.size },
|
|
436
|
+
{ fetchImpl }
|
|
437
|
+
);
|
|
438
|
+
if (!req || !req.signed_put_url || !req.object_key) {
|
|
439
|
+
throw new MediaUploadError('平台没有返回上传地址,请稍后重试。', { file: f.label, uploaded: results });
|
|
440
|
+
}
|
|
441
|
+
let putResp;
|
|
442
|
+
try {
|
|
443
|
+
putResp = await putFetch(req.signed_put_url, {
|
|
444
|
+
method: 'PUT',
|
|
445
|
+
headers: { 'Content-Type': f.mime },
|
|
446
|
+
body: buf,
|
|
447
|
+
});
|
|
448
|
+
} catch (netErr) {
|
|
449
|
+
throw new MediaUploadError(`上传到存储失败(网络错误):${netErr && netErr.message ? netErr.message : netErr}`, { file: f.label, uploaded: results });
|
|
450
|
+
}
|
|
451
|
+
if (!putResp.ok) {
|
|
452
|
+
throw new MediaUploadError(`上传文件到存储失败(状态 ${putResp.status}),请重试。`, { file: f.label, uploaded: results, status: putResp.status });
|
|
453
|
+
}
|
|
454
|
+
const confirmBody = { object_key: req.object_key, media_type: f.mediaType };
|
|
455
|
+
if (f.mediaType === 'video') confirmBody.duration_seconds = f.durationSeconds;
|
|
456
|
+
item = await confirmProfileMedia(baseUrl, credOpts, confirmBody, { fetchImpl });
|
|
457
|
+
} catch (e) {
|
|
458
|
+
if (e instanceof MediaUploadError) throw e;
|
|
459
|
+
const mapped = mapMediaUploadError(e);
|
|
460
|
+
throw new MediaUploadError(mapped || (e && e.message ? e.message : String(e)), {
|
|
461
|
+
file: f.label, uploaded: results, status: e && typeof e.status === 'number' ? e.status : null,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const mediaId = item.media_id;
|
|
466
|
+
const result = {
|
|
467
|
+
file: f.label,
|
|
468
|
+
ref: item.ref || `${f.mediaType === 'video' ? 'linger://video' : 'linger://media'}/${mediaId}`,
|
|
469
|
+
mediaId,
|
|
470
|
+
mediaType: item.media_type || f.mediaType,
|
|
471
|
+
size: item.file_size_bytes != null ? item.file_size_bytes : f.size,
|
|
472
|
+
uploadedBy: item.uploaded_by ?? null,
|
|
473
|
+
};
|
|
474
|
+
bySha.set(sha, { result, label: f.label });
|
|
475
|
+
results.push(result);
|
|
476
|
+
}
|
|
477
|
+
return results;
|
|
478
|
+
}
|