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,119 @@
|
|
|
1
|
+
import { MANIFEST_TAG, parseManifestCaption } from '../caption.js'
|
|
2
|
+
import { chatName, describeChat } from '../chat.js'
|
|
3
|
+
import {
|
|
4
|
+
assertLoggedIn,
|
|
5
|
+
closeQuietly,
|
|
6
|
+
connect as realConnect,
|
|
7
|
+
searchDocuments,
|
|
8
|
+
} from '../client.js'
|
|
9
|
+
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
10
|
+
import { requireChat, resolveSettings } from '../settings.js'
|
|
11
|
+
|
|
12
|
+
const UNKNOWN = '—'
|
|
13
|
+
const GAP = ' '
|
|
14
|
+
|
|
15
|
+
const COLUMNS = [
|
|
16
|
+
{ header: 'BACKUP ID', key: 'id' },
|
|
17
|
+
{ header: 'FILE', key: 'name' },
|
|
18
|
+
{ header: 'SIZE', key: 'size', right: true },
|
|
19
|
+
{ header: 'CHUNKS', key: 'chunks', right: true },
|
|
20
|
+
{ header: 'CREATED', key: 'created' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
// Telegram indexes the tag the manifest caption carries, so one search returns one hit
|
|
24
|
+
// per backup instead of one per chunk. What comes back is still whatever the server
|
|
25
|
+
// decided to match, which is why the caller filters on the file name afterwards.
|
|
26
|
+
async function realSearchManifests(client, peer, limit) {
|
|
27
|
+
return await searchDocuments(client, peer, { search: MANIFEST_TAG, limit })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function backupIdFromFileName(fileName) {
|
|
31
|
+
return fileName.replace(/\.manifest\.json$/, '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function utcDay(unixSeconds) {
|
|
35
|
+
return new Date(unixSeconds * 1000).toISOString().slice(0, 10)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The card is text in a chat, which means a person can edit or predate it. The id is the
|
|
39
|
+
// one field restore cannot be wrong about, so it always comes from the file name telark
|
|
40
|
+
// wrote; the caption only decorates. A card that cannot be read back leaves the rest
|
|
41
|
+
// unknown, because inventing it would describe a backup that does not exist.
|
|
42
|
+
function toRow(message) {
|
|
43
|
+
const id = backupIdFromFileName(message.fileName)
|
|
44
|
+
const card = parseManifestCaption(message.caption)
|
|
45
|
+
|
|
46
|
+
if (!card) {
|
|
47
|
+
return { id, name: UNKNOWN, size: UNKNOWN, chunks: UNKNOWN, created: utcDay(message.date) }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
name: card.name,
|
|
53
|
+
size: card.size,
|
|
54
|
+
chunks: String(card.chunks),
|
|
55
|
+
created: card.createdAt.slice(0, 10),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderTable(rows) {
|
|
60
|
+
const widths = COLUMNS.map((column) =>
|
|
61
|
+
Math.max(column.header.length, ...rows.map((row) => row[column.key].length)),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const line = (cells) =>
|
|
65
|
+
cells
|
|
66
|
+
.map((cell, i) => (COLUMNS[i].right ? cell.padStart(widths[i]) : cell.padEnd(widths[i])))
|
|
67
|
+
.join(GAP)
|
|
68
|
+
.trimEnd()
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
line(COLUMNS.map((column) => column.header)),
|
|
72
|
+
...rows.map((row) => line(COLUMNS.map((column) => row[column.key]))),
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runList(options = {}, deps = {}) {
|
|
77
|
+
const {
|
|
78
|
+
configDir = defaultConfigDir(),
|
|
79
|
+
connect = realConnect,
|
|
80
|
+
disconnect = (client) => client.destroy(),
|
|
81
|
+
searchManifests = realSearchManifests,
|
|
82
|
+
log = (line) => console.log(line),
|
|
83
|
+
} = deps
|
|
84
|
+
|
|
85
|
+
const config = await loadConfig(configDir)
|
|
86
|
+
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
87
|
+
// Ask about the login before the destination: telling someone who has never logged in
|
|
88
|
+
// to pick a chat sends them off after the wrong thing.
|
|
89
|
+
assertLoggedIn(config)
|
|
90
|
+
const chat = requireChat(settings)
|
|
91
|
+
|
|
92
|
+
const client = await connect(config, { verbose: settings.verbose })
|
|
93
|
+
|
|
94
|
+
let found
|
|
95
|
+
try {
|
|
96
|
+
found = await searchManifests(client, chat, settings.limit)
|
|
97
|
+
} finally {
|
|
98
|
+
await closeQuietly(client, disconnect)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
log(`Destination ${describeChat(chat)}`)
|
|
102
|
+
log('')
|
|
103
|
+
|
|
104
|
+
const rows = found
|
|
105
|
+
.filter((message) => message.fileName?.endsWith('.manifest.json'))
|
|
106
|
+
.map(toRow)
|
|
107
|
+
|
|
108
|
+
if (rows.length === 0) {
|
|
109
|
+
log(`No backups found in ${chatName(chat)}. Upload one with: npx telark <file>`)
|
|
110
|
+
return rows
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const line of renderTable(rows)) log(line)
|
|
114
|
+
|
|
115
|
+
log('')
|
|
116
|
+
log(`${rows.length} backup${rows.length === 1 ? '' : 's'}. Restore with: npx telark restore <backup-id>`)
|
|
117
|
+
|
|
118
|
+
return rows
|
|
119
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import readline from 'node:readline/promises'
|
|
2
|
+
import { stdin, stdout } from 'node:process'
|
|
3
|
+
|
|
4
|
+
import { TelegramClient } from 'telegram'
|
|
5
|
+
import { StringSession } from 'telegram/sessions/index.js'
|
|
6
|
+
|
|
7
|
+
import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
|
|
8
|
+
import { normalizeChatTarget } from '../chat.js'
|
|
9
|
+
import { createLogger } from '../client.js'
|
|
10
|
+
import { resolveSettings } from '../settings.js'
|
|
11
|
+
|
|
12
|
+
function createPrompts() {
|
|
13
|
+
const rl = readline.createInterface({ input: stdin, output: stdout })
|
|
14
|
+
return {
|
|
15
|
+
ask: (question) => rl.question(question),
|
|
16
|
+
close: () => rl.close(),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const LOGIN_ERROR_MESSAGES = {
|
|
21
|
+
PHONE_NUMBER_INVALID: 'invalid phone number',
|
|
22
|
+
PHONE_CODE_INVALID: 'wrong verification code',
|
|
23
|
+
PHONE_CODE_EXPIRED: 'verification code expired',
|
|
24
|
+
PASSWORD_HASH_INVALID: 'wrong two-step password',
|
|
25
|
+
FLOOD_WAIT: 'rate limited by Telegram, need to wait',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function describeLoginError(err) {
|
|
29
|
+
const message = String(err?.message ?? err ?? '')
|
|
30
|
+
const known = Object.entries(LOGIN_ERROR_MESSAGES).find(([code]) => message.startsWith(code))
|
|
31
|
+
|
|
32
|
+
if (!known) return message
|
|
33
|
+
|
|
34
|
+
const [, description] = known
|
|
35
|
+
return `${description} (${message})`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// GramJS starts an update loop the moment a client connects, and that loop only stops when
|
|
39
|
+
// destroy() marks the client destroyed. disconnect() alone leaves it pinging a socket that is
|
|
40
|
+
// already closed: every ping fails with "Error: TIMEOUT" and asks the sender to reconnect,
|
|
41
|
+
// printed straight over the destination question login asks after signing in. Every other
|
|
42
|
+
// command shuts down the same way, and login is the seam tests need to reach it.
|
|
43
|
+
const createTelegramClient = (apiId, apiHash, options) =>
|
|
44
|
+
new TelegramClient(new StringSession(''), apiId, apiHash, options)
|
|
45
|
+
|
|
46
|
+
export async function runLogin({
|
|
47
|
+
configDir = defaultConfigDir(),
|
|
48
|
+
prompts = createPrompts(),
|
|
49
|
+
verbose = false,
|
|
50
|
+
createClient = createTelegramClient,
|
|
51
|
+
shutdown = (client) => client.destroy(),
|
|
52
|
+
log = (line) => console.log(line),
|
|
53
|
+
} = {}) {
|
|
54
|
+
const config = await loadConfig(configDir)
|
|
55
|
+
const storedChat = config.settings?.chat
|
|
56
|
+
const loud = verbose || resolveSettings({}, config).values.verbose
|
|
57
|
+
|
|
58
|
+
log('You need your own api_id and api_hash. Get them at https://my.telegram.org → API development tools.\n')
|
|
59
|
+
|
|
60
|
+
const apiIdAnswer = (await prompts.ask(`api_id${config.apiId ? ` [${config.apiId}]` : ''}: `)).trim()
|
|
61
|
+
|
|
62
|
+
if (apiIdAnswer !== '' && !/^\d+$/.test(apiIdAnswer)) {
|
|
63
|
+
prompts.close()
|
|
64
|
+
throw new Error('api_id must be an integer.')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const apiId = apiIdAnswer === '' ? config.apiId : Number(apiIdAnswer)
|
|
68
|
+
const apiHash = (await prompts.ask(`api_hash${config.apiHash ? ' [keep current]' : ''}: `)) || config.apiHash
|
|
69
|
+
|
|
70
|
+
if (!apiId || !apiHash) {
|
|
71
|
+
prompts.close()
|
|
72
|
+
throw new Error('Missing api_id or api_hash.')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const client = createClient(apiId, apiHash, {
|
|
76
|
+
connectionRetries: 5,
|
|
77
|
+
baseLogger: createLogger(loud),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
await client.start({
|
|
81
|
+
phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
|
|
82
|
+
phoneCode: () => prompts.ask('Verification code Telegram just sent: '),
|
|
83
|
+
password: () => prompts.ask('Two-step password (leave blank if not enabled): '),
|
|
84
|
+
onError: (err) => console.error(`Login failed: ${describeLoginError(err)}`),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const me = await client.getMe()
|
|
88
|
+
const session = client.session.save()
|
|
89
|
+
await shutdown(client)
|
|
90
|
+
|
|
91
|
+
const chatAnswer = (
|
|
92
|
+
await prompts.ask(
|
|
93
|
+
`Which chat should backups go to? (@username, -100..., or me)${storedChat ? ` [${storedChat}]` : ''}, Enter to skip: `,
|
|
94
|
+
)
|
|
95
|
+
).trim()
|
|
96
|
+
|
|
97
|
+
prompts.close()
|
|
98
|
+
|
|
99
|
+
const next = { ...config, apiId, apiHash, session }
|
|
100
|
+
|
|
101
|
+
if (chatAnswer !== '') {
|
|
102
|
+
next.settings = { ...config.settings, chat: String(normalizeChatTarget(chatAnswer)) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await saveConfig(next, configDir)
|
|
106
|
+
|
|
107
|
+
log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
|
|
108
|
+
log(`Config saved to ${configDir}/config.json`)
|
|
109
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { clearSession, defaultConfigDir } from '../config.js'
|
|
2
|
+
|
|
3
|
+
export async function runLogout({ configDir = defaultConfigDir() } = {}) {
|
|
4
|
+
await clearSession(configDir)
|
|
5
|
+
console.log(
|
|
6
|
+
'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
|
|
7
|
+
)
|
|
8
|
+
console.log(
|
|
9
|
+
'Note: this only deletes the local copy — the session is still alive on Telegram\'s side. ' +
|
|
10
|
+
'To revoke access for good, open Telegram → Settings → Devices (Active sessions) ' +
|
|
11
|
+
'and terminate that session.',
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
closeQuietly,
|
|
6
|
+
connect as realConnect,
|
|
7
|
+
findManifestMessage,
|
|
8
|
+
readMessageBytes as realReadMessageBytes,
|
|
9
|
+
} from '../client.js'
|
|
10
|
+
import { askConfirm } from '../confirm.js'
|
|
11
|
+
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
12
|
+
import { requireChat, resolveSettings } from '../settings.js'
|
|
13
|
+
import { downloadToFile } from '../downloader.js'
|
|
14
|
+
import { parseManifest } from '../manifest.js'
|
|
15
|
+
import { createProgress, formatBytes, formatDuration } from '../progress.js'
|
|
16
|
+
|
|
17
|
+
// Anything past a minute of waiting needs saying out loud; below that the pause is shorter
|
|
18
|
+
// than the time a user would spend wondering about it.
|
|
19
|
+
const LONG_WAIT_MS = 60_000
|
|
20
|
+
|
|
21
|
+
// A transient error that resolves itself on the next try is not news, and one line per
|
|
22
|
+
// occurrence buries the progress bar in a wall of text. Stay quiet until the third retry:
|
|
23
|
+
// by then the trouble has outlived two backoffs and is worth saying out loud.
|
|
24
|
+
const ANNOUNCE_AFTER_ATTEMPT = 3
|
|
25
|
+
|
|
26
|
+
async function realGetMessage(client, peer, msgId) {
|
|
27
|
+
const [message] = await client.getMessages(peer, { ids: [msgId] })
|
|
28
|
+
return message ?? null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function realDownloadChunk(client, message, handle, offset, onProgress, retryOptions) {
|
|
32
|
+
return await downloadToFile(client, message, handle.fd, { offset, onProgress, retryOptions })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// manifest.name comes from data downloaded off Telegram — don't trust it when picking
|
|
36
|
+
// a path ourselves. path.basename stops "../../x" but still returns "..", "." or "" for
|
|
37
|
+
// a few pathological names: path.resolve('..') is the parent directory, so a multi-GB
|
|
38
|
+
// .partial file would land outside the current directory and only blow up at rename.
|
|
39
|
+
function safeOutName(name) {
|
|
40
|
+
const base = path.basename(String(name ?? ''))
|
|
41
|
+
|
|
42
|
+
if (base === '' || base === '.' || base === '..') {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`The name in the manifest ("${name}") cannot be used as a file name. ` +
|
|
45
|
+
'Run again with --out <path> to choose where to write.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return base
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runRestore(backupId, options = {}, deps = {}) {
|
|
53
|
+
const {
|
|
54
|
+
connect = realConnect,
|
|
55
|
+
disconnect = (client) => client.destroy(),
|
|
56
|
+
configDir = defaultConfigDir(),
|
|
57
|
+
searchManifest = findManifestMessage,
|
|
58
|
+
readMessageBytes = realReadMessageBytes,
|
|
59
|
+
getMessage = realGetMessage,
|
|
60
|
+
downloadChunk = realDownloadChunk,
|
|
61
|
+
confirm = askConfirm,
|
|
62
|
+
retryOptions = {},
|
|
63
|
+
writeErr = (line) => process.stderr.write(line),
|
|
64
|
+
log: writeLog = (line) => console.log(line),
|
|
65
|
+
silent = false,
|
|
66
|
+
} = deps
|
|
67
|
+
|
|
68
|
+
const config = await loadConfig(configDir)
|
|
69
|
+
const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
|
|
70
|
+
const chat = requireChat(settings)
|
|
71
|
+
const log = silent ? () => {} : writeLog
|
|
72
|
+
const warn = silent ? () => {} : writeErr
|
|
73
|
+
|
|
74
|
+
// A restore keeps no progress file, so a part that comes back -503 is retried rather than
|
|
75
|
+
// thrown away — and a retry nobody is told about is indistinguishable from a hung transfer,
|
|
76
|
+
// because the progress bar simply stops moving while the wait runs.
|
|
77
|
+
function onRetry(err, attempt, delayMs, elapsedMs = 0) {
|
|
78
|
+
if (delayMs > LONG_WAIT_MS) {
|
|
79
|
+
warn(
|
|
80
|
+
`\nTelegram wants ${formatDuration(delayMs / 1000)} of waiting before the next part ` +
|
|
81
|
+
`(${err.message}). telark is waiting and will carry on by itself, leave it running.\n`,
|
|
82
|
+
)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The exception to staying quiet: an attempt that took a minute to fail spent that
|
|
87
|
+
// minute with the bar frozen, which is exactly what a hang looks like. Those are worth
|
|
88
|
+
// a line the first time, whatever the attempt number.
|
|
89
|
+
if (attempt < ANNOUNCE_AFTER_ATTEMPT && elapsedMs < LONG_WAIT_MS) return
|
|
90
|
+
|
|
91
|
+
warn(
|
|
92
|
+
`\nTemporary error (${err.message}), retry ${attempt} in ` +
|
|
93
|
+
`${formatDuration(delayMs / 1000)}.\n`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const client = await connect(config, { verbose: settings.verbose })
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const manifestMessage = await searchManifest(client, chat, backupId)
|
|
101
|
+
|
|
102
|
+
if (!manifestMessage) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`No manifest found for ${backupId} in ${chat}. ` +
|
|
105
|
+
'Check the backup id, or use --to to point at the right chat.',
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
|
|
110
|
+
// When the user passes --out, respect that path verbatim.
|
|
111
|
+
const target = path.resolve(options.out ?? safeOutName(manifest.name))
|
|
112
|
+
const partial = `${target}.partial`
|
|
113
|
+
|
|
114
|
+
// Only ENOENT means "no file yet". Treating a permission or I/O error as absence
|
|
115
|
+
// would have telark overwrite the user's file without asking.
|
|
116
|
+
let exists = true
|
|
117
|
+
try {
|
|
118
|
+
await fs.stat(target)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (err.code !== 'ENOENT') throw err
|
|
121
|
+
exists = false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (exists && !(await confirm(`${target} already exists. Overwrite? [y/N] `))) {
|
|
125
|
+
throw new Error('Cancelled on request.')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
log(`Backup ${manifest.id}`)
|
|
129
|
+
log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunks)\n`)
|
|
130
|
+
|
|
131
|
+
const handle = await fs.open(partial, 'w+')
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await handle.truncate(manifest.size)
|
|
135
|
+
|
|
136
|
+
// One bar for the whole restore. The label names the chunk in flight, but the bar, the
|
|
137
|
+
// byte counts, the speed and the ETA all describe the file, so the line runs 0% to 100%
|
|
138
|
+
// once instead of restarting at every chunk boundary — with 1800MB chunks, a per-chunk
|
|
139
|
+
// ETA answers a question nobody asked.
|
|
140
|
+
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
141
|
+
const progress = createProgress({
|
|
142
|
+
total: manifest.size,
|
|
143
|
+
label: `Chunk 1/${manifest.chunks.length}`,
|
|
144
|
+
write: warn,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
for (const chunk of manifest.chunks) {
|
|
149
|
+
// Before getMessage, not after: the bar is then on screen from the first moment,
|
|
150
|
+
// and finish() below always has a line to close.
|
|
151
|
+
progress.setLabel(`Chunk ${chunk.i + 1}/${manifest.chunks.length}`)
|
|
152
|
+
|
|
153
|
+
const message = await getMessage(client, chat, chunk.msgId)
|
|
154
|
+
|
|
155
|
+
if (!message) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Missing chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} is no longer in ${chat}. ` +
|
|
158
|
+
'This backup cannot be restored.',
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { sha256, size } = await downloadChunk(
|
|
163
|
+
client,
|
|
164
|
+
message,
|
|
165
|
+
handle,
|
|
166
|
+
chunk.i * manifest.chunkSize,
|
|
167
|
+
progress.advance,
|
|
168
|
+
{ ...retryOptions, onRetry },
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if (size !== chunk.size) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Chunk ${chunk.i + 1} has ${size} bytes, the manifest records ${chunk.size} bytes — mismatch.`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (sha256 !== chunk.sha256) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Chunk ${chunk.i + 1} has a sha256 that does not match the manifest. The download is kept at ${partial} for inspection.`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
// The bar owns a line that \r keeps returning to. Ending it here rather than after the
|
|
185
|
+
// loop means a chunk that fails mid-download still leaves the cursor on a fresh line,
|
|
186
|
+
// so "Error: ..." does not land on top of the bar.
|
|
187
|
+
progress.finish()
|
|
188
|
+
}
|
|
189
|
+
} finally {
|
|
190
|
+
await handle.close()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Last line of defence: if every chunk matched its sha256 and the file is still the
|
|
194
|
+
// wrong length, the layout went wrong somewhere. Better to fail than to rename a
|
|
195
|
+
// wrong file into the real one.
|
|
196
|
+
const written = await fs.stat(partial)
|
|
197
|
+
|
|
198
|
+
if (written.size !== manifest.size) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`The assembled file has ${written.size} bytes, the manifest records ${manifest.size} bytes — mismatch. ` +
|
|
201
|
+
`The download is kept at ${partial} for inspection.`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await fs.rename(partial, target)
|
|
206
|
+
|
|
207
|
+
log(`\nDone. Wrote ${formatBytes(manifest.size)} to ${target}`)
|
|
208
|
+
|
|
209
|
+
return { path: target, size: manifest.size }
|
|
210
|
+
} finally {
|
|
211
|
+
await closeQuietly(client, disconnect, (err) =>
|
|
212
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { countChunks } from '../chunking.js'
|
|
4
|
+
import { describeChat } from '../chat.js'
|
|
5
|
+
import { assertLoggedIn, closeQuietly, connect as realConnect } from '../client.js'
|
|
6
|
+
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
7
|
+
import { formatBytes } from '../progress.js'
|
|
8
|
+
import { resolveSettings } from '../settings.js'
|
|
9
|
+
import { listStates } from '../state.js'
|
|
10
|
+
|
|
11
|
+
const LABEL_WIDTH = 'Destination'.length + 2
|
|
12
|
+
|
|
13
|
+
function row(label, value) {
|
|
14
|
+
return `${label.padEnd(LABEL_WIDTH)}${value}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function describeAccount(me) {
|
|
18
|
+
const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
|
|
19
|
+
const handle = me.username ? ` (@${me.username})` : ''
|
|
20
|
+
|
|
21
|
+
return `${name || me.username || me.phone || 'unknown'}${handle}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The account line is the only part that needs Telegram, and it is also the only part that
|
|
25
|
+
// can fail. Whatever it costs, it must not take the rest of the report down with it: the
|
|
26
|
+
// unfinished backups are exactly what someone runs status to see after a session expires.
|
|
27
|
+
async function accountLine(config, verbose, deps) {
|
|
28
|
+
const { connect, disconnect } = deps
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
assertLoggedIn(config)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return err.message
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let client
|
|
37
|
+
try {
|
|
38
|
+
client = await connect(config, { verbose })
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return err.message
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
return describeAccount(await client.getMe())
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return `could not be read: ${err.message}`
|
|
47
|
+
} finally {
|
|
48
|
+
await closeQuietly(client, disconnect)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runStatus(options = {}, deps = {}) {
|
|
53
|
+
const {
|
|
54
|
+
configDir = defaultConfigDir(),
|
|
55
|
+
connect = realConnect,
|
|
56
|
+
disconnect = (client) => client.destroy(),
|
|
57
|
+
log = (line) => console.log(line),
|
|
58
|
+
} = deps
|
|
59
|
+
|
|
60
|
+
const config = await loadConfig(configDir)
|
|
61
|
+
|
|
62
|
+
// status is what someone runs *because* something is wrong, so nothing here may take the
|
|
63
|
+
// whole report down — the same reason accountLine catches its own failures. A stored
|
|
64
|
+
// setting that will not parse is loud everywhere else; here it is loud in the row it
|
|
65
|
+
// belongs to, with the account and the unfinished backups still printed around it.
|
|
66
|
+
let settings = null
|
|
67
|
+
let settingsError = null
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
settings = resolveSettings(options, config, { file: configFile(configDir) }).values
|
|
71
|
+
} catch (err) {
|
|
72
|
+
settingsError = err.message
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
log(row('Account', await accountLine(config, settings?.verbose ?? false, { connect, disconnect })))
|
|
76
|
+
log(
|
|
77
|
+
row(
|
|
78
|
+
'Destination',
|
|
79
|
+
settingsError ??
|
|
80
|
+
(settings.chat === null
|
|
81
|
+
? 'none set — run "npx telark config chat @my_backups" to set one'
|
|
82
|
+
: describeChat(settings.chat)),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
const states = await listStates(configDir)
|
|
87
|
+
|
|
88
|
+
if (states.length === 0) {
|
|
89
|
+
log(row('Unfinished', 'none'))
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
|
|
94
|
+
|
|
95
|
+
for (const state of states) {
|
|
96
|
+
const total = countChunks(state.size, state.chunkSize)
|
|
97
|
+
const done = Object.keys(state.done ?? {}).length
|
|
98
|
+
|
|
99
|
+
log(` ${state.id} ${path.basename(state.path)} ${done}/${total} chunks ${formatBytes(state.size)}`)
|
|
100
|
+
log(` → ${describeChat(state.chat)}`)
|
|
101
|
+
}
|
|
102
|
+
}
|