data-ark 0.1.1 → 0.1.3
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 +45 -3
- package/bin/data-ark.js +25 -16
- package/package.json +1 -1
- package/src/caption.js +63 -0
- package/src/chunking.js +6 -0
- package/src/cli.js +65 -3
- package/src/client.js +78 -2
- package/src/commands/list.js +136 -0
- package/src/commands/login.js +25 -7
- package/src/commands/restore.js +41 -35
- package/src/commands/set-destination.js +15 -0
- package/src/commands/status.js +93 -0
- package/src/commands/upload.js +77 -29
- package/src/config.js +9 -4
- package/src/downloader.js +42 -6
- package/src/manifest.js +3 -1
- package/src/progress.js +7 -1
- package/src/state.js +76 -10
- package/src/uploader.js +4 -1
package/README.md
CHANGED
|
@@ -7,6 +7,9 @@ Split large files into 1.8GB chunks, store them on Telegram, and restore them in
|
|
|
7
7
|
```bash
|
|
8
8
|
npx data-ark login # once only
|
|
9
9
|
npx data-ark data.tar --to @my_backups # the destination is remembered
|
|
10
|
+
npx data-ark --to @my_backups # only change the destination, upload nothing
|
|
11
|
+
npx data-ark status # account, destination, unfinished backups
|
|
12
|
+
npx data-ark list # what is already stored in the destination
|
|
10
13
|
npx data-ark data.tar # from the second run on
|
|
11
14
|
npx data-ark restore ark-20260905-7f3a91
|
|
12
15
|
```
|
|
@@ -22,10 +25,46 @@ data-ark signs in with your own Telegram account (MTProto), not a bot. That is a
|
|
|
22
25
|
|
|
23
26
|
| Flag | Default | Meaning |
|
|
24
27
|
|---|---|---|
|
|
25
|
-
| `--to <chat>` | the remembered destination | `@username`, `-100123…`, or `me` |
|
|
26
|
-
| `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. |
|
|
28
|
+
| `--to <chat>` | the remembered destination | `@username`, `-100123…`, or `me`. `upload` and `status` remember it; `list` and `restore` only look there. A negative channel id works either way: `--to -100123…` or `--to=-100123…` |
|
|
29
|
+
| `--chunk-size <n>` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. An unfinished backup keeps the size it started with. |
|
|
27
30
|
| `--concurrency <n>` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. |
|
|
28
31
|
| `--out <path>` | the basename from the manifest | Where to write the restored file; relative paths resolve against the current directory |
|
|
32
|
+
| `--limit <n>` | `20` | How many backups `list` shows, newest first |
|
|
33
|
+
| `--verbose` | off | Show the Telegram client's own connection logs, hidden by default so they do not break up the progress bar |
|
|
34
|
+
|
|
35
|
+
## What the chat looks like
|
|
36
|
+
|
|
37
|
+
Every chunk goes up as a document captioned `📦 <backupId> · 3/12`, and the manifest that
|
|
38
|
+
follows carries a summary card:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
🗄 data.tar
|
|
42
|
+
━━━━━━━━━━━━━━━
|
|
43
|
+
💾 21.4 GB · 12 chunks
|
|
44
|
+
🆔 ark-20260905-7f3a91
|
|
45
|
+
📅 2026-09-05 16:40 UTC
|
|
46
|
+
|
|
47
|
+
↩ npx data-ark restore ark-20260905-7f3a91
|
|
48
|
+
#dataark
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`npx data-ark list` reads those cards straight out of the chat — one search, no downloads —
|
|
52
|
+
and lays them out as a table:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Destination https://web.telegram.org/k/#@my_backups
|
|
56
|
+
|
|
57
|
+
BACKUP ID FILE SIZE CHUNKS CREATED
|
|
58
|
+
ark-20260905-7f3a91 data.tar 21.4 GB 12 2026-09-05
|
|
59
|
+
ark-20260901-9de447 photos.zip 940.3 MB 1 2026-09-01
|
|
60
|
+
|
|
61
|
+
2 backups. Restore with: npx data-ark restore <backup-id>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A backup uploaded before the card existed still gets a row, with dashes where the caption
|
|
65
|
+
says nothing — `list` reports what the chat holds and never fills gaps with guesses.
|
|
66
|
+
`--to` here only chooses which chat to look at; it does not move the destination the way
|
|
67
|
+
`status --to` does.
|
|
29
68
|
|
|
30
69
|
## How it works
|
|
31
70
|
|
|
@@ -34,7 +73,10 @@ Every run mints a `backupId`. The file is read directly by offset — no tempora
|
|
|
34
73
|
If the connection drops during an **upload**, just run the same command again — progress lives in `~/.data-ark/state/` and finished chunks are skipped, keeping the same `backupId`. Two things to know about rerunning:
|
|
35
74
|
|
|
36
75
|
- Running again with a `--to` that differs from the destination stored in the unfinished progress makes data-ark **refuse to run** rather than silently redirect — one backup cannot be split across two destinations. The error points the way out: drop `--to` to keep sending to the original destination, or delete the state file to start a new backup.
|
|
37
|
-
- Running again
|
|
76
|
+
- Running again **without** `--chunk-size` resumes at the size the backup started with, whatever the default is today.
|
|
77
|
+
- Running again **with** a `--chunk-size` that differs from that size makes data-ark **refuse to run**, for the same reason as `--to`: the chunks already in the chat were cut that way and cannot be re-cut. Drop the flag to carry on, or delete the state file to start a new backup — which leaves the chunks already sent in the chat with nothing pointing at them.
|
|
78
|
+
|
|
79
|
+
`Ctrl-C` during an upload names the backup it was working on, so `data-ark status` and a later `restore` have something to go on. data-ark keeps the **20 most recent** unfinished backups in `~/.data-ark/state/`; starting a new one past that drops the oldest record and says which id it dropped. Only the local record goes — the chunks that backup sent stay in the chat, searchable by that id, but it can no longer be resumed.
|
|
38
80
|
|
|
39
81
|
**Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
|
|
40
82
|
|
package/bin/data-ark.js
CHANGED
|
@@ -1,30 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { route, HELP } from '../src/cli.js'
|
|
2
|
+
import { route, HELP, interruptMessage } from '../src/cli.js'
|
|
3
3
|
import { runLogin } from '../src/commands/login.js'
|
|
4
|
+
import { runList } from '../src/commands/list.js'
|
|
4
5
|
import { runLogout } from '../src/commands/logout.js'
|
|
5
6
|
import { runRestore } from '../src/commands/restore.js'
|
|
7
|
+
import { runSetDestination } from '../src/commands/set-destination.js'
|
|
8
|
+
import { runStatus } from '../src/commands/status.js'
|
|
6
9
|
import { runUpload } from '../src/commands/upload.js'
|
|
7
10
|
|
|
8
11
|
const SIGINT_EXIT_CODE = 130
|
|
9
12
|
|
|
10
13
|
// Which command is running when Ctrl-C arrives — each one tells a different truth
|
|
11
14
|
// about whether progress was saved, so we need to know which to pick the right line.
|
|
15
|
+
// The backup id arrives a moment later, once upload knows which backup this run is.
|
|
12
16
|
let currentCommand = null
|
|
13
|
-
|
|
14
|
-
function sigintMessage(command) {
|
|
15
|
-
if (command === 'upload') {
|
|
16
|
-
return '\nStopped. Progress is saved, run the same command again to continue.\n'
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (command === 'restore') {
|
|
20
|
-
return '\nStopped. Download progress is not saved, running again starts over.\n'
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return '\nStopped.\n'
|
|
24
|
-
}
|
|
17
|
+
let currentBackupId = null
|
|
25
18
|
|
|
26
19
|
process.on('SIGINT', () => {
|
|
27
|
-
process.stderr.write(
|
|
20
|
+
process.stderr.write(interruptMessage(currentCommand, { backupId: currentBackupId }))
|
|
28
21
|
process.exit(SIGINT_EXIT_CODE)
|
|
29
22
|
})
|
|
30
23
|
|
|
@@ -47,15 +40,31 @@ async function main() {
|
|
|
47
40
|
return
|
|
48
41
|
|
|
49
42
|
case 'login':
|
|
50
|
-
await runLogin()
|
|
43
|
+
await runLogin({ verbose: parsed.options.verbose })
|
|
51
44
|
return
|
|
52
45
|
|
|
53
46
|
case 'logout':
|
|
54
47
|
await runLogout()
|
|
55
48
|
return
|
|
56
49
|
|
|
50
|
+
case 'list':
|
|
51
|
+
await runList(parsed.options)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
case 'status':
|
|
55
|
+
await runStatus(parsed.options)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
case 'set-destination':
|
|
59
|
+
await runSetDestination(parsed.options)
|
|
60
|
+
return
|
|
61
|
+
|
|
57
62
|
case 'upload':
|
|
58
|
-
await runUpload(parsed.args[0], parsed.options
|
|
63
|
+
await runUpload(parsed.args[0], parsed.options, {
|
|
64
|
+
onBackupId: (id) => {
|
|
65
|
+
currentBackupId = id
|
|
66
|
+
},
|
|
67
|
+
})
|
|
59
68
|
return
|
|
60
69
|
|
|
61
70
|
case 'restore':
|
package/package.json
CHANGED
package/src/caption.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { formatBytes } from './progress.js'
|
|
2
|
+
|
|
3
|
+
// Captions are plain text on purpose. Telegram would render bold through a parse mode,
|
|
4
|
+
// but that turns every file name into something that has to be escaped correctly, and
|
|
5
|
+
// the fake client the tests talk to would never notice a mistake there.
|
|
6
|
+
|
|
7
|
+
const DIVIDER = '━'.repeat(15)
|
|
8
|
+
|
|
9
|
+
// The hashtag is what `list` searches for, and it lives on the manifest alone: chunk
|
|
10
|
+
// captions stay out of that search so a twelve-chunk backup is one hit, not thirteen.
|
|
11
|
+
export const MANIFEST_TAG = '#dataark'
|
|
12
|
+
|
|
13
|
+
// A file name may legally contain a newline or a tab, and either one would push the
|
|
14
|
+
// rest of the card down a row and take its shape apart.
|
|
15
|
+
function oneLine(name) {
|
|
16
|
+
return String(name).replace(/\s+/g, ' ').trim()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function utcMinutes(createdAt) {
|
|
20
|
+
return `${new Date(createdAt).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function chunkCaption({ id, number, total }) {
|
|
24
|
+
return `📦 ${id} · ${number}/${total}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function manifestCaption({ id, name, size, chunks, createdAt }) {
|
|
28
|
+
return [
|
|
29
|
+
`🗄 ${oneLine(name)}`,
|
|
30
|
+
DIVIDER,
|
|
31
|
+
`💾 ${formatBytes(size)} · ${chunks} chunk${chunks === 1 ? '' : 's'}`,
|
|
32
|
+
`🆔 ${id}`,
|
|
33
|
+
`📅 ${utcMinutes(createdAt)}`,
|
|
34
|
+
'',
|
|
35
|
+
`↩ npx data-ark restore ${id}`,
|
|
36
|
+
MANIFEST_TAG,
|
|
37
|
+
].join('\n')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function marker(lines, emoji) {
|
|
41
|
+
const found = lines.find((line) => line.startsWith(`${emoji} `))
|
|
42
|
+
return found ? found.slice(emoji.length + 1).trim() : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// list reads what the chat shows, and a caption is text a person can edit. Anything that
|
|
46
|
+
// does not carry the whole card is reported as unknown rather than half-guessed: a backup
|
|
47
|
+
// listed with invented numbers is worse than one listed with dashes.
|
|
48
|
+
export function parseManifestCaption(text) {
|
|
49
|
+
const lines = String(text ?? '').split('\n')
|
|
50
|
+
|
|
51
|
+
const name = marker(lines, '🗄')
|
|
52
|
+
const totals = marker(lines, '💾')
|
|
53
|
+
const id = marker(lines, '🆔')
|
|
54
|
+
const createdAt = marker(lines, '📅')
|
|
55
|
+
|
|
56
|
+
if (!name || !totals || !id || !createdAt) return null
|
|
57
|
+
|
|
58
|
+
const match = /^(.+) · (\d+) chunks?$/.exec(totals)
|
|
59
|
+
|
|
60
|
+
if (!match) return null
|
|
61
|
+
|
|
62
|
+
return { id, name, size: match[1], chunks: Number(match[2]), createdAt }
|
|
63
|
+
}
|
package/src/chunking.js
CHANGED
|
@@ -36,6 +36,12 @@ export function parseSize(input) {
|
|
|
36
36
|
return bytes
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// How many chunks a file of this size splits into. planChunks builds them and parseManifest
|
|
40
|
+
// checks them against this same rule, so the layout has one definition, not three.
|
|
41
|
+
export function countChunks(fileSize, chunkSize) {
|
|
42
|
+
return Math.ceil(fileSize / chunkSize)
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
export function planChunks(fileSize, chunkSize) {
|
|
40
46
|
if (fileSize <= 0) {
|
|
41
47
|
throw new Error('File is empty, nothing to upload.')
|
package/src/cli.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util'
|
|
2
2
|
|
|
3
|
-
const SUBCOMMANDS = new Set(['login', 'logout', 'restore', 'help'])
|
|
3
|
+
const SUBCOMMANDS = new Set(['login', 'logout', 'list', 'restore', 'status', 'help'])
|
|
4
4
|
|
|
5
5
|
const OPTIONS = {
|
|
6
6
|
to: { type: 'string' },
|
|
7
7
|
'chunk-size': { type: 'string' },
|
|
8
8
|
concurrency: { type: 'string' },
|
|
9
9
|
out: { type: 'string' },
|
|
10
|
+
limit: { type: 'string' },
|
|
11
|
+
verbose: { type: 'boolean' },
|
|
10
12
|
help: { type: 'boolean', short: 'h' },
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -15,26 +17,86 @@ export const HELP = `data-ark — split large files into chunks and store them o
|
|
|
15
17
|
Usage:
|
|
16
18
|
npx data-ark login Log in to Telegram, only needed once
|
|
17
19
|
npx data-ark <file> Split a file and upload it to Telegram
|
|
20
|
+
npx data-ark list List the backups stored in the destination
|
|
18
21
|
npx data-ark restore <backup-id> Download the chunks and reassemble the file
|
|
22
|
+
npx data-ark status Show the account, the destination and unfinished backups
|
|
23
|
+
npx data-ark --to <chat> Set the destination without uploading anything
|
|
19
24
|
npx data-ark logout Remove the saved session
|
|
20
25
|
|
|
21
26
|
Options:
|
|
22
|
-
--to <chat> Destination: @username, -100123..., or me.
|
|
27
|
+
--to <chat> Destination: @username, -100123..., or me. upload and status
|
|
28
|
+
remember it; list and restore only look there.
|
|
23
29
|
--chunk-size <n> Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
|
|
30
|
+
An unfinished backup keeps the size it started with.
|
|
24
31
|
--concurrency <n> 512KB parts sent in parallel, default 8, max 64.
|
|
25
32
|
--out <path> Where to write the restored file. Defaults to the basename in the manifest.
|
|
33
|
+
--limit <n> How many backups list shows, default 20.
|
|
34
|
+
--verbose Show Telegram connection logs, hidden by default.
|
|
26
35
|
-h, --help Show this help.
|
|
27
36
|
`
|
|
28
37
|
|
|
38
|
+
// What Ctrl-C means depends on the command that was running: upload has written every
|
|
39
|
+
// finished chunk to a state file, restore has not. Naming the backup matters because the
|
|
40
|
+
// id is what `status` lists and what a later `restore` needs — the chunks are already in
|
|
41
|
+
// the chat under that id, whether or not this run ever finishes.
|
|
42
|
+
export function interruptMessage(command, { backupId } = {}) {
|
|
43
|
+
if (command === 'upload') {
|
|
44
|
+
const backup = backupId ? `Backup ${backupId} is saved` : 'Progress is saved'
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
`\n${backup} — run the same command again to continue, ` +
|
|
48
|
+
'or "npx data-ark status" to see what is left.\n'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (command === 'restore') {
|
|
53
|
+
return '\nStopped. Download progress is not saved, running again starts over.\n'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return '\nStopped.\n'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A channel id is negative, and typing it separated by a space is the natural reflex —
|
|
60
|
+
// but parseArgs rejects any value starting with a dash as ambiguous. Join the pair itself
|
|
61
|
+
// so `--to -100123` works like `--to=-100123`. Only a bare negative integer qualifies, so
|
|
62
|
+
// `--to --verbose` still reports the missing value instead of eating the next flag, and
|
|
63
|
+
// everything after `--` is left alone because it is no longer an option there.
|
|
64
|
+
function joinNegativeChatId(argv) {
|
|
65
|
+
const joined = []
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
68
|
+
if (argv[i] === '--') {
|
|
69
|
+
joined.push(...argv.slice(i))
|
|
70
|
+
return joined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (argv[i] === '--to' && /^-\d+$/.test(argv[i + 1] ?? '')) {
|
|
74
|
+
joined.push(`--to=${argv[i + 1]}`)
|
|
75
|
+
i += 1
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
joined.push(argv[i])
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return joined
|
|
83
|
+
}
|
|
84
|
+
|
|
29
85
|
export function route(argv) {
|
|
30
86
|
const { values, positionals } = parseArgs({
|
|
31
|
-
args: argv,
|
|
87
|
+
args: joinNegativeChatId(argv),
|
|
32
88
|
options: OPTIONS,
|
|
33
89
|
allowPositionals: true,
|
|
34
90
|
})
|
|
35
91
|
|
|
36
92
|
const [first, ...rest] = positionals
|
|
37
93
|
|
|
94
|
+
// `data-ark --to @chan` with no file is not a malformed upload, it is someone changing
|
|
95
|
+
// where the next upload goes. Help would be an unhelpful answer to a clear request.
|
|
96
|
+
if (first === undefined && values.to && !values.help) {
|
|
97
|
+
return { command: 'set-destination', args: [], options: values }
|
|
98
|
+
}
|
|
99
|
+
|
|
38
100
|
if (values.help || first === undefined || first === 'help') {
|
|
39
101
|
return { command: 'help', args: [], options: values }
|
|
40
102
|
}
|
package/src/client.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import { TelegramClient } from 'telegram'
|
|
1
|
+
import { Api, TelegramClient } from 'telegram'
|
|
2
|
+
import { Logger } from 'telegram/extensions/index.js'
|
|
3
|
+
import { LogLevel } from 'telegram/extensions/Logger.js'
|
|
2
4
|
import { StringSession } from 'telegram/sessions/index.js'
|
|
3
5
|
|
|
6
|
+
// GramJS narrates its version, every connection and every disconnect at info level, and
|
|
7
|
+
// those timestamped lines land in the middle of the progress bar. The client reads this
|
|
8
|
+
// logger before it prints anything, so LogLevel.NONE silences all of it; --verbose asks
|
|
9
|
+
// for the running commentary back when a connection needs diagnosing.
|
|
10
|
+
export function createLogger(verbose) {
|
|
11
|
+
return new Logger(verbose ? LogLevel.INFO : LogLevel.NONE)
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
export function normalizeChatTarget(input) {
|
|
5
15
|
const text = String(input).trim()
|
|
6
16
|
|
|
@@ -15,6 +25,71 @@ export function normalizeChatTarget(input) {
|
|
|
15
25
|
return text
|
|
16
26
|
}
|
|
17
27
|
|
|
28
|
+
// Telegram's web client addresses a chat by putting the raw target in the fragment, which
|
|
29
|
+
// covers both a negative channel id and an @username. Saved Messages is the exception: it
|
|
30
|
+
// is reached by the account's own id, which data-ark does not know, so it gets no link
|
|
31
|
+
// rather than a guessed one that lands somewhere else.
|
|
32
|
+
export function chatUrl(chat) {
|
|
33
|
+
const text = String(chat)
|
|
34
|
+
|
|
35
|
+
if (text === 'me') return null
|
|
36
|
+
|
|
37
|
+
return `https://web.telegram.org/k/#${text}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// How a destination is spoken about. "me" is a target, not a name someone would recognise
|
|
41
|
+
// in a sentence, so every command that mentions a chat in prose goes through here.
|
|
42
|
+
export function chatName(chat) {
|
|
43
|
+
return String(chat) === 'me' ? 'Saved Messages' : String(chat)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A destination is worth more as something clickable than as a raw id, but Saved Messages
|
|
47
|
+
// has no link to give, so it is named instead of being dressed up as one.
|
|
48
|
+
export function describeChat(chat) {
|
|
49
|
+
const url = chatUrl(chat)
|
|
50
|
+
|
|
51
|
+
return url ?? `${chat} (${chatName(chat)})`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The name Telegram shows under a document lives in an attribute, not on the message.
|
|
55
|
+
export function documentFileName(message) {
|
|
56
|
+
const attributes = message?.media?.document?.attributes ?? []
|
|
57
|
+
const named = attributes.find((a) => a instanceof Api.DocumentAttributeFilename)
|
|
58
|
+
return named?.fileName ?? null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The one place data-ark searches a chat. Both callers want documents and nothing else,
|
|
62
|
+
// and getMessages is preferred over a raw Api.messages.Search because it handles offsets,
|
|
63
|
+
// hashes and pagination itself, so we don't hand-build easily mistyped fields. The raw
|
|
64
|
+
// message is kept alongside the flat fields because downloading needs it whole.
|
|
65
|
+
export async function searchDocuments(client, peer, { search, limit }) {
|
|
66
|
+
const messages = await client.getMessages(peer, {
|
|
67
|
+
search,
|
|
68
|
+
filter: new Api.InputMessagesFilterDocument(),
|
|
69
|
+
limit,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return messages.map((message) => ({
|
|
73
|
+
id: message.id,
|
|
74
|
+
fileName: documentFileName(message),
|
|
75
|
+
caption: message.message ?? '',
|
|
76
|
+
date: message.date,
|
|
77
|
+
message,
|
|
78
|
+
}))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Every command ends by putting the connection down, and a failure there must never
|
|
82
|
+
// swallow the real error already on its way up. Commands that print progress hand in an
|
|
83
|
+
// onWarn to say so; the quieter ones let it pass, because a connection that will not
|
|
84
|
+
// close cleanly says nothing about the work that already succeeded.
|
|
85
|
+
export async function closeQuietly(client, disconnect, onWarn) {
|
|
86
|
+
try {
|
|
87
|
+
await disconnect(client)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (onWarn) onWarn(err)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
18
93
|
export function requireChat(options, config) {
|
|
19
94
|
const raw = options.to ?? config.defaultChat
|
|
20
95
|
|
|
@@ -34,12 +109,13 @@ export function assertLoggedIn(config) {
|
|
|
34
109
|
}
|
|
35
110
|
}
|
|
36
111
|
|
|
37
|
-
export async function connect(config) {
|
|
112
|
+
export async function connect(config, { verbose = false } = {}) {
|
|
38
113
|
assertLoggedIn(config)
|
|
39
114
|
|
|
40
115
|
const client = new TelegramClient(new StringSession(config.session), config.apiId, config.apiHash, {
|
|
41
116
|
connectionRetries: 5,
|
|
42
117
|
floodSleepThreshold: 60,
|
|
118
|
+
baseLogger: createLogger(verbose),
|
|
43
119
|
})
|
|
44
120
|
|
|
45
121
|
await client.connect()
|
|
@@ -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 })
|
|
@@ -33,10 +33,25 @@ 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
|
|
|
@@ -53,7 +68,10 @@ export async function runLogin({ configDir = defaultConfigDir(), prompts = creat
|
|
|
53
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
77
|
phoneNumber: () => prompts.ask('Phone number (e.g. +1...): '),
|
|
@@ -64,7 +82,7 @@ export async function runLogin({ configDir = defaultConfigDir(), prompts = creat
|
|
|
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(
|
|
@@ -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
|
}
|