telstore 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/telark.js +121 -0
- package/package.json +42 -0
- package/src/caption.js +63 -0
- package/src/chat.js +42 -0
- package/src/chunking.js +92 -0
- package/src/cli.js +161 -0
- package/src/client.js +146 -0
- package/src/commands/config.js +130 -0
- package/src/commands/delete.js +250 -0
- package/src/commands/list.js +119 -0
- package/src/commands/login.js +109 -0
- package/src/commands/logout.js +13 -0
- package/src/commands/restore.js +215 -0
- package/src/commands/status.js +102 -0
- package/src/commands/upload.js +327 -0
- package/src/config.js +86 -0
- package/src/confirm.js +11 -0
- package/src/downloader.js +190 -0
- package/src/manifest.js +163 -0
- package/src/progress.js +100 -0
- package/src/retry.js +57 -0
- package/src/settings.js +190 -0
- package/src/stall.js +63 -0
- package/src/state.js +151 -0
- package/src/uploader.js +155 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Api } from 'telegram'
|
|
5
|
+
import { CustomFile } from 'telegram/client/uploads.js'
|
|
6
|
+
|
|
7
|
+
import { PART_SIZE, planChunks } from '../chunking.js'
|
|
8
|
+
import { chunkCaption, manifestCaption } from '../caption.js'
|
|
9
|
+
import { describeChat } from '../chat.js'
|
|
10
|
+
import { closeQuietly, connect as realConnect } from '../client.js'
|
|
11
|
+
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
12
|
+
import { requireChat, resolveSettings } from '../settings.js'
|
|
13
|
+
import {
|
|
14
|
+
buildManifest,
|
|
15
|
+
chunkFileName,
|
|
16
|
+
manifestFileName,
|
|
17
|
+
newBackupId,
|
|
18
|
+
serializeManifest,
|
|
19
|
+
} from '../manifest.js'
|
|
20
|
+
import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
21
|
+
import {
|
|
22
|
+
MAX_STATES,
|
|
23
|
+
clearState,
|
|
24
|
+
loadState,
|
|
25
|
+
markChunkDone,
|
|
26
|
+
pruneStates,
|
|
27
|
+
saveState,
|
|
28
|
+
stateFile,
|
|
29
|
+
stateKey,
|
|
30
|
+
} from '../state.js'
|
|
31
|
+
import { uploadRange } from '../uploader.js'
|
|
32
|
+
|
|
33
|
+
// Above this threshold the wait must be spelled out, per spec §8.
|
|
34
|
+
const LONG_WAIT_MS = 60_000
|
|
35
|
+
|
|
36
|
+
// A transient error that resolves itself on the next try is not news, and one line per
|
|
37
|
+
// occurrence buries the progress bar in a wall of text. Stay quiet until the third retry:
|
|
38
|
+
// by then the trouble has outlived two backoffs and is worth saying out loud.
|
|
39
|
+
const ANNOUNCE_AFTER_ATTEMPT = 3
|
|
40
|
+
|
|
41
|
+
// Chunks and manifests differ only in where the bytes come from. Everything Telegram is
|
|
42
|
+
// told about them — document, not preview; this exact file name — is decided once.
|
|
43
|
+
async function sendDocument(client, peer, { file, fileName, caption }) {
|
|
44
|
+
return await client.sendFile(peer, {
|
|
45
|
+
file,
|
|
46
|
+
caption,
|
|
47
|
+
forceDocument: true,
|
|
48
|
+
attributes: [new Api.DocumentAttributeFilename({ fileName })],
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function realSendChunk(client, peer, { inputFile, fileName, caption }) {
|
|
53
|
+
return await sendDocument(client, peer, { file: inputFile, fileName, caption })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function realSendManifest(client, peer, { bytes, fileName, caption }) {
|
|
57
|
+
return await sendDocument(client, peer, {
|
|
58
|
+
file: new CustomFile(fileName, bytes.length, '', bytes),
|
|
59
|
+
fileName,
|
|
60
|
+
caption,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runUpload(filePath, options = {}, deps = {}) {
|
|
65
|
+
const {
|
|
66
|
+
connect = realConnect,
|
|
67
|
+
sendChunk = realSendChunk,
|
|
68
|
+
sendManifest = realSendManifest,
|
|
69
|
+
disconnect = (client) => client.destroy(),
|
|
70
|
+
configDir = defaultConfigDir(),
|
|
71
|
+
partSize = PART_SIZE,
|
|
72
|
+
retryOptions = {},
|
|
73
|
+
writeErr = (line) => process.stderr.write(line),
|
|
74
|
+
log: writeLog = (line) => console.log(line),
|
|
75
|
+
silent = false,
|
|
76
|
+
onBackupId = () => {},
|
|
77
|
+
} = deps
|
|
78
|
+
|
|
79
|
+
const absPath = path.resolve(filePath)
|
|
80
|
+
|
|
81
|
+
let stat
|
|
82
|
+
try {
|
|
83
|
+
stat = await fs.stat(absPath)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code === 'ENOENT') throw new Error(`File does not exist: ${absPath}`)
|
|
86
|
+
throw err
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!stat.isFile()) {
|
|
90
|
+
throw new Error(`${absPath} is not a file.`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const config = await loadConfig(configDir)
|
|
94
|
+
const { values: settings, source } = resolveSettings(options, config, {
|
|
95
|
+
file: configFile(configDir),
|
|
96
|
+
})
|
|
97
|
+
const chat = requireChat(settings)
|
|
98
|
+
const concurrency = settings.concurrency
|
|
99
|
+
|
|
100
|
+
const key = stateKey(absPath, stat.size, stat.mtimeMs)
|
|
101
|
+
|
|
102
|
+
let state = await loadState(key, configDir)
|
|
103
|
+
|
|
104
|
+
// The chunks already in the chat were cut at the size this backup started with, and
|
|
105
|
+
// nothing can re-cut them. Carrying on at a different size would abandon every one of
|
|
106
|
+
// them in the chat, where telark can no longer find them — so an unfinished backup
|
|
107
|
+
// keeps its own chunk size, and a flag that disagrees is refused rather than obeyed.
|
|
108
|
+
//
|
|
109
|
+
// Only a flag is a disagreement. A configured chunkSize says what to use when nobody asks
|
|
110
|
+
// for anything, and this run asked for nothing — so the backup quietly keeps its own size
|
|
111
|
+
// rather than being refused over a preference set weeks ago for other files.
|
|
112
|
+
if (state && source('chunkSize') === 'flag' && settings.chunkSize !== state.chunkSize) {
|
|
113
|
+
const file = stateFile(key, configDir)
|
|
114
|
+
throw new Error(
|
|
115
|
+
`This unfinished backup is cut into ${formatBytes(state.chunkSize)} chunks, but ` +
|
|
116
|
+
`--chunk-size asks for ${formatBytes(settings.chunkSize)} — the chunks already in ` +
|
|
117
|
+
`${state.chat} cannot be re-cut. Run again without --chunk-size to carry on, or delete ` +
|
|
118
|
+
`${file} and run again to start a new backup, which leaves the chunks already sent ` +
|
|
119
|
+
'sitting in the chat with nothing to point at them.',
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const chunkSize = state ? state.chunkSize : settings.chunkSize
|
|
124
|
+
|
|
125
|
+
// A resumed upload takes its chunk size off disk, and nothing validated that file on the
|
|
126
|
+
// way in. planChunks will refuse an unusable one, but its message is about chunk sizes and
|
|
127
|
+
// would send the reader looking for a --chunk-size flag they never passed.
|
|
128
|
+
if (state && (!Number.isSafeInteger(chunkSize) || chunkSize < 1)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`The record of this unfinished backup gives a chunk size of ${JSON.stringify(chunkSize)}, ` +
|
|
131
|
+
`which cannot be used. ${stateFile(key, configDir)} is damaged — delete it and run ` +
|
|
132
|
+
'again to start a new backup, which leaves the chunks already sent sitting in the ' +
|
|
133
|
+
'chat with nothing to point at them.',
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const chunks = planChunks(stat.size, chunkSize)
|
|
138
|
+
const resuming = Boolean(state)
|
|
139
|
+
|
|
140
|
+
// Naming the way back rather than a flag to drop: the destination may have come from the
|
|
141
|
+
// command line or from the stored setting, and "run again without --to" is no help to
|
|
142
|
+
// someone who never typed one. Pointing at the chat itself is right either way.
|
|
143
|
+
if (resuming && state.chat !== String(chat)) {
|
|
144
|
+
const file = stateFile(key, configDir)
|
|
145
|
+
throw new Error(
|
|
146
|
+
`This unfinished backup is going to ${state.chat}, but the current command targets ${chat} — ` +
|
|
147
|
+
`a single backup cannot be split across two destinations. Run again with ` +
|
|
148
|
+
`--to ${state.chat} to carry on sending there, or delete ${file} and run again to ` +
|
|
149
|
+
`start a new backup in ${chat}.`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!resuming) {
|
|
154
|
+
state = {
|
|
155
|
+
id: newBackupId(),
|
|
156
|
+
chat: String(chat),
|
|
157
|
+
path: absPath,
|
|
158
|
+
size: stat.size,
|
|
159
|
+
mtimeMs: stat.mtimeMs,
|
|
160
|
+
chunkSize,
|
|
161
|
+
done: {},
|
|
162
|
+
}
|
|
163
|
+
await saveState(key, state, configDir)
|
|
164
|
+
|
|
165
|
+
// Only a new backup adds to the directory, so this is the one place it can grow.
|
|
166
|
+
// The report goes out even when the caller asked for silence: this is not narration
|
|
167
|
+
// about a transfer, it is telark dropping the only record of someone else's chunks.
|
|
168
|
+
for (const gone of await pruneStates(configDir)) {
|
|
169
|
+
writeErr(
|
|
170
|
+
`\nDropped the record of unfinished backup ${gone.id}: telark keeps the ` +
|
|
171
|
+
`${MAX_STATES} most recent. The chunks it sent are still in ${gone.chat}, ` +
|
|
172
|
+
'searchable by that id, but that backup can no longer be resumed.\n',
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
onBackupId(state.id)
|
|
178
|
+
|
|
179
|
+
const log = silent ? () => {} : writeLog
|
|
180
|
+
const warn = silent ? () => {} : writeErr
|
|
181
|
+
|
|
182
|
+
// Retries and FLOOD_WAIT must be announced: a silent FLOOD_WAIT_3600 leaves the user
|
|
183
|
+
// staring at a frozen progress bar for an hour, assuming the process has hung.
|
|
184
|
+
function onRetry(err, attempt, delayMs, elapsedMs = 0) {
|
|
185
|
+
if (delayMs > LONG_WAIT_MS) {
|
|
186
|
+
warn(
|
|
187
|
+
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next send ` +
|
|
188
|
+
`(${err.message}). telark is waiting and will carry on by itself, leave it running.\n`,
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The exception to staying quiet: an attempt that took a minute to fail spent that
|
|
194
|
+
// minute with the bar frozen, which is exactly what a hang looks like. Those are worth
|
|
195
|
+
// a line the first time, whatever the attempt number.
|
|
196
|
+
if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
|
|
197
|
+
|
|
198
|
+
warn(
|
|
199
|
+
`\nTemporary error (${err.message}), retry ${attempt} in ` +
|
|
200
|
+
`${formatDuration(delayMs / 1000)}.\n`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
log(`Backup ${state.id}`)
|
|
205
|
+
log(`File ${absPath} (${formatBytes(stat.size)}, ${chunks.length} chunks)`)
|
|
206
|
+
log(`To ${describeChat(chat)}\n`)
|
|
207
|
+
|
|
208
|
+
const client = await connect(config, { verbose: settings.verbose })
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
// Everything already in the chat is reported before the bar exists. These lines go to
|
|
212
|
+
// stdout while the bar is rewritten on stderr with \r, so printed from inside the loop
|
|
213
|
+
// they would land straight on the line the bar keeps returning to.
|
|
214
|
+
const pending = []
|
|
215
|
+
|
|
216
|
+
for (const chunk of chunks) {
|
|
217
|
+
if (state.done[String(chunk.i)]) {
|
|
218
|
+
log(`Chunk ${chunk.i + 1}/${chunks.length} already uploaded, skipping.`)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
pending.push(chunk)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A run that only failed to send the manifest has every chunk done and nothing left to
|
|
226
|
+
// transfer: no bar at all, rather than one that springs into existence at 100%.
|
|
227
|
+
if (pending.length > 0) {
|
|
228
|
+
const remaining = pending.reduce((sum, chunk) => sum + chunk.length, 0)
|
|
229
|
+
|
|
230
|
+
// One bar for the whole upload: the label names the chunk in flight, everything else
|
|
231
|
+
// describes the file. Chunks a previous run sent count towards the bar but not towards
|
|
232
|
+
// the speed, so an hour-old chunk cannot inflate the ETA of the ones still to go.
|
|
233
|
+
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
234
|
+
const progress = createProgress({
|
|
235
|
+
total: stat.size,
|
|
236
|
+
done: stat.size - remaining,
|
|
237
|
+
label: `Chunk ${pending[0].i + 1}/${chunks.length}`,
|
|
238
|
+
write: warn,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
for (const chunk of pending) {
|
|
243
|
+
progress.setLabel(`Chunk ${chunk.i + 1}/${chunks.length}`)
|
|
244
|
+
|
|
245
|
+
const fileName = chunkFileName(state.id, chunk.i)
|
|
246
|
+
const handle = await fs.open(absPath, 'r')
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const { inputFile, sha256 } = await uploadRange(client, handle.fd, {
|
|
250
|
+
offset: chunk.offset,
|
|
251
|
+
length: chunk.length,
|
|
252
|
+
fileName,
|
|
253
|
+
concurrency,
|
|
254
|
+
partSize,
|
|
255
|
+
onProgress: (bytes) => progress.advance(bytes),
|
|
256
|
+
retryOptions: { ...retryOptions, onRetry },
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const message = await sendChunk(client, chat, {
|
|
260
|
+
inputFile,
|
|
261
|
+
fileName,
|
|
262
|
+
caption: chunkCaption({ id: state.id, number: chunk.i + 1, total: chunks.length }),
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
state = await markChunkDone(
|
|
266
|
+
key,
|
|
267
|
+
state,
|
|
268
|
+
chunk.i,
|
|
269
|
+
{ msgId: message.id, size: chunk.length, sha256 },
|
|
270
|
+
configDir,
|
|
271
|
+
)
|
|
272
|
+
} finally {
|
|
273
|
+
await handle.close()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} finally {
|
|
277
|
+
// Same reason as restore: a send that fails must not leave "Error: ..." printed over
|
|
278
|
+
// the bar's own line.
|
|
279
|
+
progress.finish()
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// The file can be overwritten mid-upload — with a 50GB file an hour passes between
|
|
284
|
+
// the first and last chunk. The manifest would then describe a hybrid file that never
|
|
285
|
+
// existed: restore still matches every sha256, but the data is garbage.
|
|
286
|
+
const after = await fs.stat(absPath)
|
|
287
|
+
|
|
288
|
+
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`${absPath} changed during the upload ` +
|
|
291
|
+
`(size ${stat.size} → ${after.size}, mtime ${stat.mtimeMs} → ${after.mtimeMs}). ` +
|
|
292
|
+
'This backup mixes old and new data and cannot be trusted — telark is not sending the manifest. ' +
|
|
293
|
+
'Wait until the file settles, then run again to create a new backup.',
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const manifest = buildManifest({
|
|
298
|
+
id: state.id,
|
|
299
|
+
name: path.basename(absPath),
|
|
300
|
+
size: stat.size,
|
|
301
|
+
chunkSize,
|
|
302
|
+
chunks: chunks.map((chunk) => ({ i: chunk.i, ...state.done[String(chunk.i)] })),
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
await sendManifest(client, chat, {
|
|
306
|
+
bytes: serializeManifest(manifest),
|
|
307
|
+
fileName: manifestFileName(state.id),
|
|
308
|
+
caption: manifestCaption({
|
|
309
|
+
id: manifest.id,
|
|
310
|
+
name: manifest.name,
|
|
311
|
+
size: manifest.size,
|
|
312
|
+
chunks: manifest.chunks.length,
|
|
313
|
+
createdAt: manifest.createdAt,
|
|
314
|
+
}),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
await clearState(key, configDir)
|
|
318
|
+
|
|
319
|
+
log(`\nDone. Restore with:\n npx telark restore ${state.id}`)
|
|
320
|
+
|
|
321
|
+
return { id: state.id, chunks: chunks.length }
|
|
322
|
+
} finally {
|
|
323
|
+
await closeQuietly(client, disconnect, (err) =>
|
|
324
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const FILE_NAME = 'config.json'
|
|
6
|
+
|
|
7
|
+
export function defaultConfigDir() {
|
|
8
|
+
return path.join(os.homedir(), '.telark')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function configFile(dir = defaultConfigDir()) {
|
|
12
|
+
return path.join(dir, FILE_NAME)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isPlainObject(value) {
|
|
16
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// `config.json` is hand-editable now that the `config` command invites people into it,
|
|
20
|
+
// which puts it in the same category as a manifest or a state file: believed only after it
|
|
21
|
+
// has been checked. A `settings` that is not an object would make every lookup below it
|
|
22
|
+
// return undefined, and telark would then run happily on built-in defaults while the
|
|
23
|
+
// user's own choices sat there ignored — so it is named and refused instead.
|
|
24
|
+
export function checkConfigShape(raw, file) {
|
|
25
|
+
if (!isPlainObject(raw)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Corrupt config file: ${file} holds ${Array.isArray(raw) ? 'a list' : typeof raw}, ` +
|
|
28
|
+
'not a group of settings.',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (raw.settings !== undefined && !isPlainObject(raw.settings)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`"settings" in ${file} holds ${Array.isArray(raw.settings) ? 'a list' : typeof raw.settings}, ` +
|
|
35
|
+
'not a group of settings. telark will not guess what was meant — fix that entry, ' +
|
|
36
|
+
'or remove it to fall back to the defaults.',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return raw
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadConfig(dir = defaultConfigDir()) {
|
|
44
|
+
const file = configFile(dir)
|
|
45
|
+
|
|
46
|
+
let raw
|
|
47
|
+
try {
|
|
48
|
+
raw = await fs.readFile(file, 'utf8')
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.code === 'ENOENT') return {}
|
|
51
|
+
throw err
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let parsed
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(raw)
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Corrupt config file: ${file} is not valid JSON (${err.message}). ` +
|
|
60
|
+
'Fix the syntax to keep your session, or delete the file and run "telark login" again.',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return checkConfigShape(parsed, file)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Config and state are both small JSON files holding something that must survive a crash
|
|
68
|
+
// mid-write: write beside the target, then rename, which is atomic on the same filesystem.
|
|
69
|
+
export async function writeJsonAtomic(file, value) {
|
|
70
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
71
|
+
|
|
72
|
+
const tmp = `${file}.tmp`
|
|
73
|
+
|
|
74
|
+
await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
|
|
75
|
+
await fs.rename(tmp, file)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function saveConfig(config, dir = defaultConfigDir()) {
|
|
79
|
+
await writeJsonAtomic(configFile(dir), config)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function clearSession(dir = defaultConfigDir()) {
|
|
83
|
+
const config = await loadConfig(dir)
|
|
84
|
+
delete config.session
|
|
85
|
+
await saveConfig(config, dir)
|
|
86
|
+
}
|
package/src/confirm.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import readline from 'node:readline/promises'
|
|
2
|
+
import { stdin, stdout } from 'node:process'
|
|
3
|
+
|
|
4
|
+
// Anything but a yes is a no. Both commands that ask are about to do something that cannot
|
|
5
|
+
// be taken back, so a stray keystroke or an empty line has to mean stop.
|
|
6
|
+
export async function askConfirm(question) {
|
|
7
|
+
const rl = readline.createInterface({ input: stdin, output: stdout })
|
|
8
|
+
const answer = await rl.question(question)
|
|
9
|
+
rl.close()
|
|
10
|
+
return /^y/i.test(answer.trim())
|
|
11
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { read as readCallback, write as writeCallback } from 'node:fs'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
|
|
5
|
+
import { returnBigInt } from 'telegram/Helpers.js'
|
|
6
|
+
|
|
7
|
+
import { DEFAULT_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
|
|
8
|
+
import { withRetry } from './retry.js'
|
|
9
|
+
import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
|
|
10
|
+
|
|
11
|
+
const write = promisify(writeCallback)
|
|
12
|
+
const read = promisify(readCallback)
|
|
13
|
+
|
|
14
|
+
// Read back in far bigger bites than the 512KB the network hands us: this loop is pure disk.
|
|
15
|
+
const HASH_READ_SIZE = 4 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
async function writeExactly(fd, buffer, position) {
|
|
18
|
+
let written = 0
|
|
19
|
+
|
|
20
|
+
while (written < buffer.length) {
|
|
21
|
+
const { bytesWritten } = await write(fd, buffer, written, buffer.length - written, position + written)
|
|
22
|
+
written += bytesWritten
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readExactly(fd, length, position) {
|
|
27
|
+
const buffer = Buffer.allocUnsafe(length)
|
|
28
|
+
let filled = 0
|
|
29
|
+
|
|
30
|
+
while (filled < length) {
|
|
31
|
+
const { bytesRead } = await read(fd, buffer, filled, length - filled, position + filled)
|
|
32
|
+
if (bytesRead === 0) {
|
|
33
|
+
throw new Error(`Short read: needed ${length} bytes at offset ${position} but the file ended.`)
|
|
34
|
+
}
|
|
35
|
+
filled += bytesRead
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return buffer
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The digest is taken from the assembled range on disk rather than from the buffers as they
|
|
42
|
+
// arrive. Once slices land out of order that is the only order left to hash in, and it is
|
|
43
|
+
// the better check anyway: a slice written at the wrong offset, two slices overlapping, or
|
|
44
|
+
// one silently skipped all show up here. It does not prove the bytes reached the platter —
|
|
45
|
+
// this read may well be served from the page cache — it proves the assembly.
|
|
46
|
+
async function hashRange(fd, offset, length) {
|
|
47
|
+
const hash = createHash('sha256')
|
|
48
|
+
|
|
49
|
+
for (let at = 0; at < length; at += HASH_READ_SIZE) {
|
|
50
|
+
hash.update(await readExactly(fd, Math.min(HASH_READ_SIZE, length - at), offset + at))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return hash.digest('hex')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function downloadToFile(
|
|
57
|
+
client,
|
|
58
|
+
message,
|
|
59
|
+
fd,
|
|
60
|
+
{ offset, onProgress, retryOptions, concurrency = DEFAULT_CONCURRENCY, stallMs = DEFAULT_STALL_MS } = {},
|
|
61
|
+
) {
|
|
62
|
+
const document = message?.media?.document
|
|
63
|
+
|
|
64
|
+
if (!document) {
|
|
65
|
+
throw new Error(`Message ${message?.id} has no file attached.`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!Number.isFinite(offset)) {
|
|
69
|
+
throw new Error(`offset must be a finite number, got: ${offset}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A pool of fewer than one worker does no work. In the download path that means
|
|
73
|
+
// Promise.all resolves at once and a chunk nobody fetched is reported as complete; in the
|
|
74
|
+
// upload path the batch loop never advances and the process spins in microtasks, which not
|
|
75
|
+
// even a test timeout can interrupt. Neither is something a caller should be able to ask
|
|
76
|
+
// for by accident.
|
|
77
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Worker count must be a whole number of at least one, got: ${JSON.stringify(concurrency)}.`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Telegram's own record of how long the document is. Taking the length from the caller
|
|
84
|
+
// instead would make runRestore's size check compare the manifest against itself.
|
|
85
|
+
const size = returnBigInt(document.size ?? 0).toJSNumber()
|
|
86
|
+
|
|
87
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
88
|
+
throw new Error(`Message ${message?.id} has a document with no usable size.`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sliceCount = Math.ceil(size / SLICE_SIZE)
|
|
92
|
+
|
|
93
|
+
// One slice is one short stream. `done` lives outside withRetry, so a retried slice picks
|
|
94
|
+
// up at its own watermark instead of fetching again what it already has.
|
|
95
|
+
//
|
|
96
|
+
// The two offsets are not the same number: `start + done` is a position inside the
|
|
97
|
+
// document, which is where the stream resumes, while `offset + start + done` is a position
|
|
98
|
+
// inside the file being assembled, which is where the bytes land.
|
|
99
|
+
async function downloadSlice(index) {
|
|
100
|
+
const start = index * SLICE_SIZE
|
|
101
|
+
const length = Math.min(SLICE_SIZE, size - start)
|
|
102
|
+
let done = 0
|
|
103
|
+
|
|
104
|
+
await withRetry(async () => {
|
|
105
|
+
// Iterated by hand rather than with `for await` so each part can be given a deadline:
|
|
106
|
+
// a stalled stream yields nothing and raises nothing, and only a race against a timer
|
|
107
|
+
// turns that silence into an error withRetry can act on. Nothing is lost by stepping
|
|
108
|
+
// outside `for await` — GramJS's download iterator exposes `next` alone, so breaking
|
|
109
|
+
// out of the loop never closed anything either.
|
|
110
|
+
const stream = client.iterDownload({
|
|
111
|
+
file: message.media,
|
|
112
|
+
offset: returnBigInt(start + done),
|
|
113
|
+
requestSize: PART_SIZE,
|
|
114
|
+
})
|
|
115
|
+
const parts = stream[Symbol.asyncIterator]()
|
|
116
|
+
|
|
117
|
+
for (;;) {
|
|
118
|
+
const { value: buffer, done: ended } = await withStallTimeout(
|
|
119
|
+
parts.next(),
|
|
120
|
+
stallMs,
|
|
121
|
+
() =>
|
|
122
|
+
`Telegram stopped sending slice ${index + 1}/${sliceCount} of message ` +
|
|
123
|
+
`${message?.id}: nothing arrived for ${Math.round(stallMs / 1000)}s after ` +
|
|
124
|
+
`${done} of ${length} bytes.`,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if (ended) break
|
|
128
|
+
|
|
129
|
+
// The stream runs to the end of the document; this slice stops at its own boundary.
|
|
130
|
+
const take = Math.min(buffer.length, length - done)
|
|
131
|
+
|
|
132
|
+
await writeExactly(fd, buffer.subarray(0, take), offset + start + done)
|
|
133
|
+
done += take
|
|
134
|
+
onProgress?.(take)
|
|
135
|
+
|
|
136
|
+
if (done >= length) break
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A stream that ends before the slice's own boundary is a short delivery, not a
|
|
140
|
+
// completed slice — without this, a hole here would surface only as a digest
|
|
141
|
+
// mismatch, telling the user their data is corrupt when it was simply cut short.
|
|
142
|
+
// Checked inside the retried callback so a transient short stream is retried, and a
|
|
143
|
+
// stream that yields nothing at all still counts as a failure worth retrying.
|
|
144
|
+
if (done < length) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Slice ${index + 1}/${sliceCount} of message ${message?.id} ended after ${done} of ${length} bytes.`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
}, retryOptions)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let next = 0
|
|
153
|
+
let failed = false
|
|
154
|
+
let failure = null
|
|
155
|
+
|
|
156
|
+
const worker = async () => {
|
|
157
|
+
for (;;) {
|
|
158
|
+
// Stop handing out work the moment anything has failed: the chunk is lost either way,
|
|
159
|
+
// and every further request is bandwidth spent on a file about to be thrown away.
|
|
160
|
+
if (failed) return
|
|
161
|
+
|
|
162
|
+
const index = next
|
|
163
|
+
next += 1
|
|
164
|
+
if (index >= sliceCount) return
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await downloadSlice(index)
|
|
168
|
+
} catch (err) {
|
|
169
|
+
// Not `failure ??= err`: a falsy rejection reason would leave `failure` falsy and the
|
|
170
|
+
// throw below would read as success, turning a broken chunk into a silent one. The
|
|
171
|
+
// first error is the one kept — later ones are usually consequences of the shutdown
|
|
172
|
+
// rather than the cause.
|
|
173
|
+
if (!failed) {
|
|
174
|
+
failed = true
|
|
175
|
+
failure = err
|
|
176
|
+
}
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Every worker absorbs its own error above, so none of these promises rejects and none can
|
|
183
|
+
// become an unhandledRejection that hides the real one. This await is also what guarantees
|
|
184
|
+
// no write is still in flight when the function returns.
|
|
185
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, sliceCount) }, worker))
|
|
186
|
+
|
|
187
|
+
if (failed) throw failure
|
|
188
|
+
|
|
189
|
+
return { sha256: await hashRange(fd, offset, size), size }
|
|
190
|
+
}
|