data-ark 0.1.0 → 0.1.2
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/README.md +67 -28
- package/bin/data-ark.js +33 -18
- package/package.json +20 -2
- package/src/caption.js +63 -0
- package/src/chunking.js +11 -5
- package/src/cli.js +54 -14
- package/src/client.js +83 -7
- package/src/commands/list.js +136 -0
- package/src/commands/login.js +38 -20
- package/src/commands/logout.js +4 -4
- package/src/commands/restore.js +38 -56
- package/src/commands/set-destination.js +15 -0
- package/src/commands/status.js +93 -0
- package/src/commands/upload.js +57 -49
- package/src/config.js +10 -5
- package/src/downloader.js +3 -3
- package/src/manifest.js +17 -14
- package/src/progress.js +9 -3
- package/src/state.js +27 -10
- package/src/uploader.js +20 -16
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { MANIFEST_TAG, parseManifestCaption } from '../caption.js'
|
|
2
|
+
import {
|
|
3
|
+
assertLoggedIn,
|
|
4
|
+
chatName,
|
|
5
|
+
closeQuietly,
|
|
6
|
+
connect as realConnect,
|
|
7
|
+
describeChat,
|
|
8
|
+
requireChat,
|
|
9
|
+
searchDocuments,
|
|
10
|
+
} from '../client.js'
|
|
11
|
+
import { defaultConfigDir, loadConfig } from '../config.js'
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_LIMIT = 20
|
|
14
|
+
|
|
15
|
+
const UNKNOWN = '—'
|
|
16
|
+
const GAP = ' '
|
|
17
|
+
|
|
18
|
+
const COLUMNS = [
|
|
19
|
+
{ header: 'BACKUP ID', key: 'id' },
|
|
20
|
+
{ header: 'FILE', key: 'name' },
|
|
21
|
+
{ header: 'SIZE', key: 'size', right: true },
|
|
22
|
+
{ header: 'CHUNKS', key: 'chunks', right: true },
|
|
23
|
+
{ header: 'CREATED', key: 'created' },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// Telegram indexes the tag the manifest caption carries, so one search returns one hit
|
|
27
|
+
// per backup instead of one per chunk. What comes back is still whatever the server
|
|
28
|
+
// decided to match, which is why the caller filters on the file name afterwards.
|
|
29
|
+
async function realSearchManifests(client, peer, limit) {
|
|
30
|
+
return await searchDocuments(client, peer, { search: MANIFEST_TAG, limit })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function backupIdFromFileName(fileName) {
|
|
34
|
+
return fileName.replace(/\.manifest\.json$/, '')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function utcDay(unixSeconds) {
|
|
38
|
+
return new Date(unixSeconds * 1000).toISOString().slice(0, 10)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The card is text in a chat, which means a person can edit or predate it. The id is the
|
|
42
|
+
// one field restore cannot be wrong about, so it always comes from the file name data-ark
|
|
43
|
+
// wrote; the caption only decorates. A card that cannot be read back leaves the rest
|
|
44
|
+
// unknown, because inventing it would describe a backup that does not exist.
|
|
45
|
+
function toRow(message) {
|
|
46
|
+
const id = backupIdFromFileName(message.fileName)
|
|
47
|
+
const card = parseManifestCaption(message.caption)
|
|
48
|
+
|
|
49
|
+
if (!card) {
|
|
50
|
+
return { id, name: UNKNOWN, size: UNKNOWN, chunks: UNKNOWN, created: utcDay(message.date) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
name: card.name,
|
|
56
|
+
size: card.size,
|
|
57
|
+
chunks: String(card.chunks),
|
|
58
|
+
created: card.createdAt.slice(0, 10),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderTable(rows) {
|
|
63
|
+
const widths = COLUMNS.map((column) =>
|
|
64
|
+
Math.max(column.header.length, ...rows.map((row) => row[column.key].length)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const line = (cells) =>
|
|
68
|
+
cells
|
|
69
|
+
.map((cell, i) => (COLUMNS[i].right ? cell.padStart(widths[i]) : cell.padEnd(widths[i])))
|
|
70
|
+
.join(GAP)
|
|
71
|
+
.trimEnd()
|
|
72
|
+
|
|
73
|
+
return [
|
|
74
|
+
line(COLUMNS.map((column) => column.header)),
|
|
75
|
+
...rows.map((row) => line(COLUMNS.map((column) => row[column.key]))),
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseLimit(raw) {
|
|
80
|
+
if (raw === undefined) return DEFAULT_LIMIT
|
|
81
|
+
|
|
82
|
+
const limit = Number(raw)
|
|
83
|
+
|
|
84
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
85
|
+
throw new Error(`Invalid --limit: "${raw}". Must be a whole number of backups, 1 or more.`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return limit
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runList(options = {}, deps = {}) {
|
|
92
|
+
const {
|
|
93
|
+
configDir = defaultConfigDir(),
|
|
94
|
+
connect = realConnect,
|
|
95
|
+
disconnect = (client) => client.destroy(),
|
|
96
|
+
searchManifests = realSearchManifests,
|
|
97
|
+
log = (line) => console.log(line),
|
|
98
|
+
} = deps
|
|
99
|
+
|
|
100
|
+
const limit = parseLimit(options.limit)
|
|
101
|
+
const config = await loadConfig(configDir)
|
|
102
|
+
// Ask about the login before the destination: telling someone who has never logged in
|
|
103
|
+
// to pick a chat sends them off after the wrong thing.
|
|
104
|
+
assertLoggedIn(config)
|
|
105
|
+
// Unlike status, --to here means "look over there", not "send there from now on":
|
|
106
|
+
// listing another chat should not quietly redirect the next upload.
|
|
107
|
+
const chat = requireChat(options, config)
|
|
108
|
+
|
|
109
|
+
const client = await connect(config, { verbose: options.verbose })
|
|
110
|
+
|
|
111
|
+
let found
|
|
112
|
+
try {
|
|
113
|
+
found = await searchManifests(client, chat, limit)
|
|
114
|
+
} finally {
|
|
115
|
+
await closeQuietly(client, disconnect)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
log(`Destination ${describeChat(chat)}`)
|
|
119
|
+
log('')
|
|
120
|
+
|
|
121
|
+
const rows = found
|
|
122
|
+
.filter((message) => message.fileName?.endsWith('.manifest.json'))
|
|
123
|
+
.map(toRow)
|
|
124
|
+
|
|
125
|
+
if (rows.length === 0) {
|
|
126
|
+
log(`No backups found in ${chatName(chat)}. Upload one with: npx data-ark <file>`)
|
|
127
|
+
return rows
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const line of renderTable(rows)) log(line)
|
|
131
|
+
|
|
132
|
+
log('')
|
|
133
|
+
log(`${rows.length} backup${rows.length === 1 ? '' : 's'}. Restore with: npx data-ark restore <backup-id>`)
|
|
134
|
+
|
|
135
|
+
return rows
|
|
136
|
+
}
|
package/src/commands/login.js
CHANGED
|
@@ -5,7 +5,7 @@ import { TelegramClient } from 'telegram'
|
|
|
5
5
|
import { StringSession } from 'telegram/sessions/index.js'
|
|
6
6
|
|
|
7
7
|
import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
|
|
8
|
-
import { normalizeChatTarget } from '../client.js'
|
|
8
|
+
import { createLogger, normalizeChatTarget } from '../client.js'
|
|
9
9
|
|
|
10
10
|
function createPrompts() {
|
|
11
11
|
const rl = readline.createInterface({ input: stdin, output: stdout })
|
|
@@ -16,11 +16,11 @@ function createPrompts() {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const LOGIN_ERROR_MESSAGES = {
|
|
19
|
-
PHONE_NUMBER_INVALID: '
|
|
20
|
-
PHONE_CODE_INVALID: '
|
|
21
|
-
PHONE_CODE_EXPIRED: '
|
|
22
|
-
PASSWORD_HASH_INVALID: '
|
|
23
|
-
FLOOD_WAIT: '
|
|
19
|
+
PHONE_NUMBER_INVALID: 'invalid phone number',
|
|
20
|
+
PHONE_CODE_INVALID: 'wrong verification code',
|
|
21
|
+
PHONE_CODE_EXPIRED: 'verification code expired',
|
|
22
|
+
PASSWORD_HASH_INVALID: 'wrong two-step password',
|
|
23
|
+
FLOOD_WAIT: 'rate limited by Telegram, need to wait',
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function describeLoginError(err) {
|
|
@@ -33,42 +33,60 @@ export function describeLoginError(err) {
|
|
|
33
33
|
return `${description} (${message})`
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// GramJS starts an update loop the moment a client connects, and that loop only stops when
|
|
37
|
+
// destroy() marks the client destroyed. disconnect() alone leaves it pinging a socket that is
|
|
38
|
+
// already closed: every ping fails with "Error: TIMEOUT" and asks the sender to reconnect,
|
|
39
|
+
// printed straight over the destination question login asks after signing in. Every other
|
|
40
|
+
// command shuts down the same way, and login is the seam tests need to reach it.
|
|
41
|
+
const createTelegramClient = (apiId, apiHash, options) =>
|
|
42
|
+
new TelegramClient(new StringSession(''), apiId, apiHash, options)
|
|
43
|
+
|
|
44
|
+
export async function runLogin({
|
|
45
|
+
configDir = defaultConfigDir(),
|
|
46
|
+
prompts = createPrompts(),
|
|
47
|
+
verbose = false,
|
|
48
|
+
createClient = createTelegramClient,
|
|
49
|
+
shutdown = (client) => client.destroy(),
|
|
50
|
+
log = (line) => console.log(line),
|
|
51
|
+
} = {}) {
|
|
37
52
|
const config = await loadConfig(configDir)
|
|
38
53
|
|
|
39
|
-
|
|
54
|
+
log('You need your own api_id and api_hash. Get them at https://my.telegram.org → API development tools.\n')
|
|
40
55
|
|
|
41
56
|
const apiIdAnswer = (await prompts.ask(`api_id${config.apiId ? ` [${config.apiId}]` : ''}: `)).trim()
|
|
42
57
|
|
|
43
58
|
if (apiIdAnswer !== '' && !/^\d+$/.test(apiIdAnswer)) {
|
|
44
59
|
prompts.close()
|
|
45
|
-
throw new Error('api_id
|
|
60
|
+
throw new Error('api_id must be an integer.')
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
const apiId = apiIdAnswer === '' ? config.apiId : Number(apiIdAnswer)
|
|
49
|
-
const apiHash = (await prompts.ask(`api_hash${config.apiHash ? ' [
|
|
64
|
+
const apiHash = (await prompts.ask(`api_hash${config.apiHash ? ' [keep current]' : ''}: `)) || config.apiHash
|
|
50
65
|
|
|
51
66
|
if (!apiId || !apiHash) {
|
|
52
67
|
prompts.close()
|
|
53
|
-
throw new Error('
|
|
68
|
+
throw new Error('Missing api_id or api_hash.')
|
|
54
69
|
}
|
|
55
70
|
|
|
56
|
-
const client =
|
|
71
|
+
const client = createClient(apiId, apiHash, {
|
|
72
|
+
connectionRetries: 5,
|
|
73
|
+
baseLogger: createLogger(verbose),
|
|
74
|
+
})
|
|
57
75
|
|
|
58
76
|
await client.start({
|
|
59
|
-
phoneNumber: () => prompts.ask('
|
|
60
|
-
phoneCode: () => prompts.ask('
|
|
61
|
-
password: () => prompts.ask('
|
|
62
|
-
onError: (err) => console.error(`
|
|
77
|
+
phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
|
|
78
|
+
phoneCode: () => prompts.ask('Verification code Telegram just sent: '),
|
|
79
|
+
password: () => prompts.ask('Two-step password (leave blank if not enabled): '),
|
|
80
|
+
onError: (err) => console.error(`Login failed: ${describeLoginError(err)}`),
|
|
63
81
|
})
|
|
64
82
|
|
|
65
83
|
const me = await client.getMe()
|
|
66
84
|
const session = client.session.save()
|
|
67
|
-
await client
|
|
85
|
+
await shutdown(client)
|
|
68
86
|
|
|
69
87
|
const chatAnswer = (
|
|
70
88
|
await prompts.ask(
|
|
71
|
-
|
|
89
|
+
`Which chat should backups go to? (@username, -100..., or me)${config.defaultChat ? ` [${config.defaultChat}]` : ''}, Enter to skip: `,
|
|
72
90
|
)
|
|
73
91
|
).trim()
|
|
74
92
|
|
|
@@ -82,6 +100,6 @@ export async function runLogin({ configDir = defaultConfigDir(), prompts = creat
|
|
|
82
100
|
|
|
83
101
|
await saveConfig(next, configDir)
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
log(`\nLogged in as ${me.username ? `@${me.username}` : me.firstName}.`)
|
|
104
|
+
log(`Config saved to ${configDir}/config.json`)
|
|
87
105
|
}
|
package/src/commands/logout.js
CHANGED
|
@@ -3,11 +3,11 @@ import { clearSession, defaultConfigDir } from '../config.js'
|
|
|
3
3
|
export async function runLogout({ configDir = defaultConfigDir() } = {}) {
|
|
4
4
|
await clearSession(configDir)
|
|
5
5
|
console.log(
|
|
6
|
-
'
|
|
6
|
+
'Removed the session stored on this machine. api_id, api_hash and the destination are kept.',
|
|
7
7
|
)
|
|
8
8
|
console.log(
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
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
12
|
)
|
|
13
13
|
}
|
package/src/commands/restore.js
CHANGED
|
@@ -3,32 +3,17 @@ import path from 'node:path'
|
|
|
3
3
|
import readline from 'node:readline/promises'
|
|
4
4
|
import { stdin, stdout } from 'node:process'
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import { connect as realConnect, requireChat } from '../client.js'
|
|
6
|
+
import { closeQuietly, connect as realConnect, requireChat, searchDocuments } from '../client.js'
|
|
9
7
|
import { defaultConfigDir, loadConfig } from '../config.js'
|
|
10
8
|
import { downloadToFile } from '../downloader.js'
|
|
11
9
|
import { manifestFileName, parseManifest } from '../manifest.js'
|
|
12
10
|
import { createProgress, formatBytes } from '../progress.js'
|
|
13
11
|
|
|
14
|
-
function documentFileName(message) {
|
|
15
|
-
const attributes = message?.media?.document?.attributes ?? []
|
|
16
|
-
const named = attributes.find((a) => a instanceof Api.DocumentAttributeFilename)
|
|
17
|
-
return named?.fileName ?? null
|
|
18
|
-
}
|
|
19
|
-
|
|
20
12
|
async function realSearchManifest(client, peer, backupId) {
|
|
21
13
|
const wanted = manifestFileName(backupId)
|
|
14
|
+
const found = await searchDocuments(client, peer, { search: backupId, limit: 100 })
|
|
22
15
|
|
|
23
|
-
|
|
24
|
-
// hash và phân trang, nên không phải tự dựng các trường dễ sai kiểu.
|
|
25
|
-
const messages = await client.getMessages(peer, {
|
|
26
|
-
search: backupId,
|
|
27
|
-
filter: new Api.InputMessagesFilterDocument(),
|
|
28
|
-
limit: 100,
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
return messages.find((m) => documentFileName(m) === wanted) ?? null
|
|
16
|
+
return found.find((doc) => doc.fileName === wanted)?.message ?? null
|
|
32
17
|
}
|
|
33
18
|
|
|
34
19
|
async function realReadMessageBytes(client, message) {
|
|
@@ -44,17 +29,17 @@ export async function realDownloadChunk(client, message, handle, offset, onProgr
|
|
|
44
29
|
return await downloadToFile(client, message, handle.fd, { offset, onProgress })
|
|
45
30
|
}
|
|
46
31
|
|
|
47
|
-
// manifest.name
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
32
|
+
// manifest.name comes from data downloaded off Telegram — don't trust it when picking
|
|
33
|
+
// a path ourselves. path.basename stops "../../x" but still returns "..", "." or "" for
|
|
34
|
+
// a few pathological names: path.resolve('..') is the parent directory, so a multi-GB
|
|
35
|
+
// .partial file would land outside the current directory and only blow up at rename.
|
|
51
36
|
function safeOutName(name) {
|
|
52
37
|
const base = path.basename(String(name ?? ''))
|
|
53
38
|
|
|
54
39
|
if (base === '' || base === '.' || base === '..') {
|
|
55
40
|
throw new Error(
|
|
56
|
-
`
|
|
57
|
-
'
|
|
41
|
+
`The name in the manifest ("${name}") cannot be used as a file name. ` +
|
|
42
|
+
'Run again with --out <path> to choose where to write.',
|
|
58
43
|
)
|
|
59
44
|
}
|
|
60
45
|
|
|
@@ -65,7 +50,7 @@ async function askConfirm(question) {
|
|
|
65
50
|
const rl = readline.createInterface({ input: stdin, output: stdout })
|
|
66
51
|
const answer = await rl.question(question)
|
|
67
52
|
rl.close()
|
|
68
|
-
return /^
|
|
53
|
+
return /^y/i.test(answer.trim())
|
|
69
54
|
}
|
|
70
55
|
|
|
71
56
|
export async function runRestore(backupId, options = {}, deps = {}) {
|
|
@@ -87,25 +72,25 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
87
72
|
const log = silent ? () => {} : (line) => console.log(line)
|
|
88
73
|
const warn = silent ? () => {} : writeErr
|
|
89
74
|
|
|
90
|
-
const client = await connect(config)
|
|
75
|
+
const client = await connect(config, { verbose: options.verbose })
|
|
91
76
|
|
|
92
77
|
try {
|
|
93
78
|
const manifestMessage = await searchManifest(client, chat, backupId)
|
|
94
79
|
|
|
95
80
|
if (!manifestMessage) {
|
|
96
81
|
throw new Error(
|
|
97
|
-
`
|
|
98
|
-
'
|
|
82
|
+
`No manifest found for ${backupId} in ${chat}. ` +
|
|
83
|
+
'Check the backup id, or use --to to point at the right chat.',
|
|
99
84
|
)
|
|
100
85
|
}
|
|
101
86
|
|
|
102
87
|
const manifest = parseManifest(await readMessageBytes(client, manifestMessage))
|
|
103
|
-
//
|
|
88
|
+
// When the user passes --out, respect that path verbatim.
|
|
104
89
|
const target = path.resolve(options.out ?? safeOutName(manifest.name))
|
|
105
90
|
const partial = `${target}.partial`
|
|
106
91
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
92
|
+
// Only ENOENT means "no file yet". Treating a permission or I/O error as absence
|
|
93
|
+
// would have data-ark overwrite the user's file without asking.
|
|
109
94
|
let exists = true
|
|
110
95
|
try {
|
|
111
96
|
await fs.stat(target)
|
|
@@ -114,12 +99,12 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
114
99
|
exists = false
|
|
115
100
|
}
|
|
116
101
|
|
|
117
|
-
if (exists && !(await confirm(`${target}
|
|
118
|
-
throw new Error('
|
|
102
|
+
if (exists && !(await confirm(`${target} already exists. Overwrite? [y/N] `))) {
|
|
103
|
+
throw new Error('Cancelled on request.')
|
|
119
104
|
}
|
|
120
105
|
|
|
121
106
|
log(`Backup ${manifest.id}`)
|
|
122
|
-
log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length}
|
|
107
|
+
log(`File ${target} (${formatBytes(manifest.size)}, ${manifest.chunks.length} chunks)\n`)
|
|
123
108
|
|
|
124
109
|
const handle = await fs.open(partial, 'w+')
|
|
125
110
|
|
|
@@ -131,18 +116,17 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
131
116
|
|
|
132
117
|
if (!message) {
|
|
133
118
|
throw new Error(
|
|
134
|
-
`
|
|
135
|
-
'
|
|
119
|
+
`Missing chunk ${chunk.i + 1}/${manifest.chunks.length}: message ${chunk.msgId} is no longer in ${chat}. ` +
|
|
120
|
+
'This backup cannot be restored.',
|
|
136
121
|
)
|
|
137
122
|
}
|
|
138
123
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
})
|
|
124
|
+
// warn is already the no-op when silent, and createProgress draws through nothing else.
|
|
125
|
+
const progress = createProgress({
|
|
126
|
+
total: chunk.size,
|
|
127
|
+
label: `Chunk ${chunk.i + 1}/${manifest.chunks.length}`,
|
|
128
|
+
write: warn,
|
|
129
|
+
})
|
|
146
130
|
|
|
147
131
|
const { sha256, size } = await downloadChunk(
|
|
148
132
|
client,
|
|
@@ -156,13 +140,13 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
156
140
|
|
|
157
141
|
if (size !== chunk.size) {
|
|
158
142
|
throw new Error(
|
|
159
|
-
`Chunk ${chunk.i + 1}
|
|
143
|
+
`Chunk ${chunk.i + 1} has ${size} bytes, the manifest records ${chunk.size} bytes — mismatch.`,
|
|
160
144
|
)
|
|
161
145
|
}
|
|
162
146
|
|
|
163
147
|
if (sha256 !== chunk.sha256) {
|
|
164
148
|
throw new Error(
|
|
165
|
-
`Chunk ${chunk.i + 1}
|
|
149
|
+
`Chunk ${chunk.i + 1} has a sha256 that does not match the manifest. The download is kept at ${partial} for inspection.`,
|
|
166
150
|
)
|
|
167
151
|
}
|
|
168
152
|
}
|
|
@@ -170,28 +154,26 @@ export async function runRestore(backupId, options = {}, deps = {}) {
|
|
|
170
154
|
await handle.close()
|
|
171
155
|
}
|
|
172
156
|
|
|
173
|
-
//
|
|
174
|
-
//
|
|
157
|
+
// Last line of defence: if every chunk matched its sha256 and the file is still the
|
|
158
|
+
// wrong length, the layout went wrong somewhere. Better to fail than to rename a
|
|
159
|
+
// wrong file into the real one.
|
|
175
160
|
const written = await fs.stat(partial)
|
|
176
161
|
|
|
177
162
|
if (written.size !== manifest.size) {
|
|
178
163
|
throw new Error(
|
|
179
|
-
`
|
|
180
|
-
`
|
|
164
|
+
`The assembled file has ${written.size} bytes, the manifest records ${manifest.size} bytes — mismatch. ` +
|
|
165
|
+
`The download is kept at ${partial} for inspection.`,
|
|
181
166
|
)
|
|
182
167
|
}
|
|
183
168
|
|
|
184
169
|
await fs.rename(partial, target)
|
|
185
170
|
|
|
186
|
-
log(`\
|
|
171
|
+
log(`\nDone. Wrote ${formatBytes(manifest.size)} to ${target}`)
|
|
187
172
|
|
|
188
173
|
return { path: target, size: manifest.size }
|
|
189
174
|
} finally {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
} catch (err) {
|
|
194
|
-
warn(`\nCảnh báo: không đóng được kết nối Telegram: ${err.message}\n`)
|
|
195
|
-
}
|
|
175
|
+
await closeQuietly(client, disconnect, (err) =>
|
|
176
|
+
warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
|
|
177
|
+
)
|
|
196
178
|
}
|
|
197
179
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { normalizeChatTarget } from '../client.js'
|
|
2
|
+
import { defaultConfigDir, loadConfig, saveConfig } from '../config.js'
|
|
3
|
+
|
|
4
|
+
export async function runSetDestination(options = {}, deps = {}) {
|
|
5
|
+
const { configDir = defaultConfigDir(), log = (line) => console.log(line) } = deps
|
|
6
|
+
|
|
7
|
+
// Normalize before touching the config: an unusable destination must not be written,
|
|
8
|
+
// or the next upload fails with an error about a value the user never really set.
|
|
9
|
+
const chat = normalizeChatTarget(options.to)
|
|
10
|
+
const config = await loadConfig(configDir)
|
|
11
|
+
|
|
12
|
+
await saveConfig({ ...config, defaultChat: String(chat) }, configDir)
|
|
13
|
+
|
|
14
|
+
log(`Destination set to ${chat}. It will be used for the next upload.`)
|
|
15
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { countChunks } from '../chunking.js'
|
|
4
|
+
import { assertLoggedIn, closeQuietly, connect as realConnect, describeChat } from '../client.js'
|
|
5
|
+
import { defaultConfigDir, loadConfig } from '../config.js'
|
|
6
|
+
import { runSetDestination } from './set-destination.js'
|
|
7
|
+
import { formatBytes } from '../progress.js'
|
|
8
|
+
import { listStates } from '../state.js'
|
|
9
|
+
|
|
10
|
+
const LABEL_WIDTH = 'Destination'.length + 2
|
|
11
|
+
|
|
12
|
+
function row(label, value) {
|
|
13
|
+
return `${label.padEnd(LABEL_WIDTH)}${value}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function describeAccount(me) {
|
|
17
|
+
const name = [me.firstName, me.lastName].filter(Boolean).join(' ')
|
|
18
|
+
const handle = me.username ? ` (@${me.username})` : ''
|
|
19
|
+
|
|
20
|
+
return `${name || me.username || me.phone || 'unknown'}${handle}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The account line is the only part that needs Telegram, and it is also the only part that
|
|
24
|
+
// can fail. Whatever it costs, it must not take the rest of the report down with it: the
|
|
25
|
+
// unfinished backups are exactly what someone runs status to see after a session expires.
|
|
26
|
+
async function accountLine(config, options, deps) {
|
|
27
|
+
const { connect, disconnect } = deps
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
assertLoggedIn(config)
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return err.message
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let client
|
|
36
|
+
try {
|
|
37
|
+
client = await connect(config, { verbose: options.verbose })
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return err.message
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return describeAccount(await client.getMe())
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return `could not be read: ${err.message}`
|
|
46
|
+
} finally {
|
|
47
|
+
await closeQuietly(client, disconnect)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runStatus(options = {}, deps = {}) {
|
|
52
|
+
const {
|
|
53
|
+
configDir = defaultConfigDir(),
|
|
54
|
+
connect = realConnect,
|
|
55
|
+
disconnect = (client) => client.destroy(),
|
|
56
|
+
log = (line) => console.log(line),
|
|
57
|
+
} = deps
|
|
58
|
+
|
|
59
|
+
// `status --to @chan` reads as one request: point somewhere new, then show me where I am.
|
|
60
|
+
// Setting it first is also what makes the Destination line below tell the current truth.
|
|
61
|
+
if (options.to) {
|
|
62
|
+
await runSetDestination(options, { configDir, log: () => {} })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const config = await loadConfig(configDir)
|
|
66
|
+
|
|
67
|
+
log(row('Account', await accountLine(config, options, { connect, disconnect })))
|
|
68
|
+
log(
|
|
69
|
+
row(
|
|
70
|
+
'Destination',
|
|
71
|
+
config.defaultChat
|
|
72
|
+
? describeChat(config.defaultChat)
|
|
73
|
+
: 'none set — pass --to @my_backups to set one',
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const states = await listStates(configDir)
|
|
78
|
+
|
|
79
|
+
if (states.length === 0) {
|
|
80
|
+
log(row('Unfinished', 'none'))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
log(row('Unfinished', `${states.length} backup${states.length === 1 ? '' : 's'}`))
|
|
85
|
+
|
|
86
|
+
for (const state of states) {
|
|
87
|
+
const total = countChunks(state.size, state.chunkSize)
|
|
88
|
+
const done = Object.keys(state.done ?? {}).length
|
|
89
|
+
|
|
90
|
+
log(` ${state.id} ${path.basename(state.path)} ${done}/${total} chunks ${formatBytes(state.size)}`)
|
|
91
|
+
log(` → ${describeChat(state.chat)}`)
|
|
92
|
+
}
|
|
93
|
+
}
|