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
package/README.md
CHANGED
|
@@ -1,50 +1,89 @@
|
|
|
1
1
|
# data-ark
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Split large files into 1.8GB chunks, store them on Telegram, and restore them intact.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Quick start
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx data-ark login #
|
|
9
|
-
npx data-ark data.tar --to @
|
|
10
|
-
npx data-ark
|
|
8
|
+
npx data-ark login # once only
|
|
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
|
|
13
|
+
npx data-ark data.tar # from the second run on
|
|
11
14
|
npx data-ark restore ark-20260905-7f3a91
|
|
12
15
|
```
|
|
13
16
|
|
|
14
|
-
##
|
|
17
|
+
## What you need
|
|
15
18
|
|
|
16
|
-
- Node.js 18
|
|
17
|
-
- `api_id`
|
|
19
|
+
- Node.js 18 or newer.
|
|
20
|
+
- An `api_id` and `api_hash` from <https://my.telegram.org> → API development tools. The `login` command asks for both, plus your phone number and the verification code.
|
|
18
21
|
|
|
19
|
-
data-ark
|
|
22
|
+
data-ark signs in with your own Telegram account (MTProto), not a bot. That is a hard requirement: the Bot API caps uploads at 50MB per file, while a user account gets 2GB.
|
|
20
23
|
|
|
21
|
-
##
|
|
24
|
+
## Options
|
|
22
25
|
|
|
23
|
-
|
|
|
26
|
+
| Flag | Default | Meaning |
|
|
24
27
|
|---|---|---|
|
|
25
|
-
| `--to <chat>` |
|
|
26
|
-
| `--chunk-size <n>` | `1800MB` |
|
|
27
|
-
| `--concurrency <n>` | `8` |
|
|
28
|
-
| `--out
|
|
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. |
|
|
30
|
+
| `--concurrency <n>` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. |
|
|
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 |
|
|
29
34
|
|
|
30
|
-
##
|
|
35
|
+
## What the chat looks like
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
Every chunk goes up as a document captioned `📦 <backupId> · 3/12`, and the manifest that
|
|
38
|
+
follows carries a summary card:
|
|
33
39
|
|
|
34
|
-
|
|
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.
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
Every run mints a `backupId`. The file is read directly by offset — no temporary copies — and uploaded as documents named `<backupId>.partNNNN`. Once every chunk is up, data-ark sends a JSON manifest listing the message id and sha256 of each one. Restore needs only the `backupId`: it finds the manifest in the chat, downloads each chunk to its exact position in a `.partial` file, checks every chunk's sha256 and size, and renames it to the real file only after *all* chunks match.
|
|
72
|
+
|
|
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
|
-
-
|
|
37
|
-
-
|
|
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.
|
|
76
|
+
- Running again with a different `--chunk-size` is treated as an entirely new backup (new backup id), not a resume.
|
|
38
77
|
|
|
39
|
-
**Restore
|
|
78
|
+
**Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
|
|
40
79
|
|
|
41
|
-
##
|
|
80
|
+
## Limits worth knowing
|
|
42
81
|
|
|
43
|
-
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
82
|
+
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file, an arithmetic ceiling of about 1953MB, and data-ark stops at 1950MB to leave a safety margin.
|
|
83
|
+
- The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure.
|
|
84
|
+
- Deleting a chunk message on Telegram destroys the backup, with no way to recover it.
|
|
85
|
+
- Keep the `backupId`. Without it you have to hunt for the manifest in the chat by hand.
|
|
47
86
|
|
|
48
|
-
##
|
|
87
|
+
## Where the config lives
|
|
49
88
|
|
|
50
|
-
`~/.data-ark/config.json` (
|
|
89
|
+
`~/.data-ark/config.json` (mode 600) holds `apiId`, `apiHash`, the session and the default destination. `npx data-ark logout` **only deletes the locally stored session** and keeps the rest — the session is still alive on Telegram's side. To revoke access for good, open Telegram → Settings → Devices (Active sessions) and terminate that session.
|
package/bin/data-ark.js
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { route, HELP } 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
|
-
//
|
|
11
|
-
//
|
|
13
|
+
// Which command is running when Ctrl-C arrives — each one tells a different truth
|
|
14
|
+
// about whether progress was saved, so we need to know which to pick the right line.
|
|
12
15
|
let currentCommand = null
|
|
13
16
|
|
|
14
17
|
function sigintMessage(command) {
|
|
15
18
|
if (command === 'upload') {
|
|
16
|
-
return '\
|
|
19
|
+
return '\nStopped. Progress is saved, run the same command again to continue.\n'
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
if (command === 'restore') {
|
|
20
|
-
return '\
|
|
23
|
+
return '\nStopped. Download progress is not saved, running again starts over.\n'
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
return '\
|
|
26
|
+
return '\nStopped.\n'
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
process.on('SIGINT', () => {
|
|
@@ -34,7 +37,7 @@ async function main() {
|
|
|
34
37
|
try {
|
|
35
38
|
parsed = route(process.argv.slice(2))
|
|
36
39
|
} catch (err) {
|
|
37
|
-
process.stderr.write(`
|
|
40
|
+
process.stderr.write(`Error: ${err.message}\n\n${HELP}`)
|
|
38
41
|
process.exitCode = 2
|
|
39
42
|
return
|
|
40
43
|
}
|
|
@@ -47,37 +50,49 @@ async function main() {
|
|
|
47
50
|
return
|
|
48
51
|
|
|
49
52
|
case 'login':
|
|
50
|
-
await runLogin()
|
|
53
|
+
await runLogin({ verbose: parsed.options.verbose })
|
|
51
54
|
return
|
|
52
55
|
|
|
53
56
|
case 'logout':
|
|
54
57
|
await runLogout()
|
|
55
58
|
return
|
|
56
59
|
|
|
60
|
+
case 'list':
|
|
61
|
+
await runList(parsed.options)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
case 'status':
|
|
65
|
+
await runStatus(parsed.options)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
case 'set-destination':
|
|
69
|
+
await runSetDestination(parsed.options)
|
|
70
|
+
return
|
|
71
|
+
|
|
57
72
|
case 'upload':
|
|
58
73
|
await runUpload(parsed.args[0], parsed.options)
|
|
59
74
|
return
|
|
60
75
|
|
|
61
76
|
case 'restore':
|
|
62
77
|
if (!parsed.args[0]) {
|
|
63
|
-
throw new Error('
|
|
78
|
+
throw new Error('Missing backup id. Example: npx data-ark restore ark-20260905-7f3a91')
|
|
64
79
|
}
|
|
65
80
|
await runRestore(parsed.args[0], parsed.options)
|
|
66
81
|
return
|
|
67
82
|
|
|
68
83
|
default:
|
|
69
|
-
throw new Error(`
|
|
84
|
+
throw new Error(`Unknown command: ${parsed.command}`)
|
|
70
85
|
}
|
|
71
86
|
}
|
|
72
87
|
|
|
73
|
-
// GramJS
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
88
|
+
// GramJS keeps "exported senders" around together with a 30-second timer to release
|
|
89
|
+
// them, and neither client.disconnect() nor destroy() cleans them up: both of those
|
|
90
|
+
// maps are Maps, but the code walks them with Object.values and so misses everything.
|
|
91
|
+
// The result is a command that prints "Done" and then hangs for another ~30 seconds,
|
|
92
|
+
// during which Ctrl-C falsely reports that nothing was saved. Finish the work, exit.
|
|
78
93
|
function exitWhenFlushed(code) {
|
|
79
|
-
//
|
|
80
|
-
//
|
|
94
|
+
// The empty writes exist only to borrow their callbacks: they fire after everything
|
|
95
|
+
// queued earlier has flushed, so nothing is lost when stdout/stderr is not a TTY.
|
|
81
96
|
let pending = 2
|
|
82
97
|
|
|
83
98
|
const done = () => {
|
|
@@ -85,7 +100,7 @@ function exitWhenFlushed(code) {
|
|
|
85
100
|
if (pending === 0) process.exit(code)
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
//
|
|
103
|
+
// Safety net: exit anyway if a callback never arrives (the pipe is already closed).
|
|
89
104
|
setTimeout(() => process.exit(code), 2000).unref()
|
|
90
105
|
|
|
91
106
|
process.stdout.write('', done)
|
|
@@ -97,7 +112,7 @@ main().then(
|
|
|
97
112
|
exitWhenFlushed(process.exitCode ?? 0)
|
|
98
113
|
},
|
|
99
114
|
(err) => {
|
|
100
|
-
process.stderr.write(`
|
|
115
|
+
process.stderr.write(`Error: ${err.message}\n`)
|
|
101
116
|
process.exitCode = 1
|
|
102
117
|
exitWhenFlushed(1)
|
|
103
118
|
},
|
package/package.json
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "data-ark",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Split large files into chunks and store them on Telegram",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telegram",
|
|
7
|
+
"backup",
|
|
8
|
+
"chunk",
|
|
9
|
+
"split",
|
|
10
|
+
"restore",
|
|
11
|
+
"cli",
|
|
12
|
+
"mtproto"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/shovity/data-ark#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/shovity/data-ark/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/shovity/data-ark.git"
|
|
21
|
+
},
|
|
5
22
|
"type": "module",
|
|
6
23
|
"license": "MIT",
|
|
24
|
+
"author": "shovity",
|
|
7
25
|
"bin": {
|
|
8
26
|
"data-ark": "bin/data-ark.js"
|
|
9
27
|
},
|
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
|
@@ -17,28 +17,34 @@ export function parseSize(input) {
|
|
|
17
17
|
const match = text.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/)
|
|
18
18
|
|
|
19
19
|
if (!match) {
|
|
20
|
-
throw new Error(`
|
|
20
|
+
throw new Error(`Invalid size: "${input}". Valid examples: 1800MB, 1.8GB, 524288.`)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const bytes = Math.floor(Number(match[1]) * UNITS[match[2] ?? 'b'])
|
|
24
24
|
|
|
25
25
|
if (bytes <= 0) {
|
|
26
|
-
throw new Error('
|
|
26
|
+
throw new Error('Size must be greater than 0.')
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
if (bytes > MAX_CHUNK_SIZE) {
|
|
30
30
|
throw new Error(
|
|
31
|
-
'
|
|
32
|
-
'
|
|
31
|
+
'Maximum chunk size is 1950MB. Telegram accepts only 4000 parts of 512KB per file, ' +
|
|
32
|
+
'an arithmetic ceiling of about 1953MB, so a safety margin is needed.',
|
|
33
33
|
)
|
|
34
34
|
}
|
|
35
35
|
|
|
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
|
-
throw new Error('File
|
|
47
|
+
throw new Error('File is empty, nothing to upload.')
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
const chunks = []
|
package/src/cli.js
CHANGED
|
@@ -1,40 +1,80 @@
|
|
|
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
|
|
|
13
|
-
export const HELP = `data-ark —
|
|
15
|
+
export const HELP = `data-ark — split large files into chunks and store them on Telegram
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
npx data-ark login
|
|
17
|
-
npx data-ark <file>
|
|
18
|
-
npx data-ark
|
|
19
|
-
npx data-ark
|
|
17
|
+
Usage:
|
|
18
|
+
npx data-ark login Log in to Telegram, only needed once
|
|
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
|
|
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
|
|
24
|
+
npx data-ark logout Remove the saved session
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
--to <chat>
|
|
23
|
-
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
|
|
26
|
+
Options:
|
|
27
|
+
--to <chat> Destination: @username, -100123..., or me. upload and status
|
|
28
|
+
remember it; list and restore only look there.
|
|
29
|
+
--chunk-size <n> Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
|
|
30
|
+
--concurrency <n> 512KB parts sent in parallel, default 8, max 64.
|
|
31
|
+
--out <path> Where to write the restored file. Defaults to the basename in the manifest.
|
|
32
|
+
--limit <n> How many backups list shows, default 20.
|
|
33
|
+
--verbose Show Telegram connection logs, hidden by default.
|
|
34
|
+
-h, --help Show this help.
|
|
27
35
|
`
|
|
28
36
|
|
|
37
|
+
// A channel id is negative, and typing it separated by a space is the natural reflex —
|
|
38
|
+
// but parseArgs rejects any value starting with a dash as ambiguous. Join the pair itself
|
|
39
|
+
// so `--to -100123` works like `--to=-100123`. Only a bare negative integer qualifies, so
|
|
40
|
+
// `--to --verbose` still reports the missing value instead of eating the next flag, and
|
|
41
|
+
// everything after `--` is left alone because it is no longer an option there.
|
|
42
|
+
function joinNegativeChatId(argv) {
|
|
43
|
+
const joined = []
|
|
44
|
+
|
|
45
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
46
|
+
if (argv[i] === '--') {
|
|
47
|
+
joined.push(...argv.slice(i))
|
|
48
|
+
return joined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (argv[i] === '--to' && /^-\d+$/.test(argv[i + 1] ?? '')) {
|
|
52
|
+
joined.push(`--to=${argv[i + 1]}`)
|
|
53
|
+
i += 1
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
joined.push(argv[i])
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return joined
|
|
61
|
+
}
|
|
62
|
+
|
|
29
63
|
export function route(argv) {
|
|
30
64
|
const { values, positionals } = parseArgs({
|
|
31
|
-
args: argv,
|
|
65
|
+
args: joinNegativeChatId(argv),
|
|
32
66
|
options: OPTIONS,
|
|
33
67
|
allowPositionals: true,
|
|
34
68
|
})
|
|
35
69
|
|
|
36
70
|
const [first, ...rest] = positionals
|
|
37
71
|
|
|
72
|
+
// `data-ark --to @chan` with no file is not a malformed upload, it is someone changing
|
|
73
|
+
// where the next upload goes. Help would be an unhelpful answer to a clear request.
|
|
74
|
+
if (first === undefined && values.to && !values.help) {
|
|
75
|
+
return { command: 'set-destination', args: [], options: values }
|
|
76
|
+
}
|
|
77
|
+
|
|
38
78
|
if (values.help || first === undefined || first === 'help') {
|
|
39
79
|
return { command: 'help', args: [], options: values }
|
|
40
80
|
}
|
package/src/client.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
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
|
|
|
7
17
|
if (text === '') {
|
|
8
|
-
throw new Error('
|
|
18
|
+
throw new Error('Destination must not be empty.')
|
|
9
19
|
}
|
|
10
20
|
|
|
11
21
|
if (/^-?\d+$/.test(text)) {
|
|
@@ -15,13 +25,78 @@ 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
|
|
|
21
96
|
if (!raw) {
|
|
22
97
|
throw new Error(
|
|
23
|
-
'
|
|
24
|
-
'
|
|
98
|
+
'No destination set — run again with --to @my_backups (or --to me for Saved Messages). ' +
|
|
99
|
+
'data-ark will remember it next time.',
|
|
25
100
|
)
|
|
26
101
|
}
|
|
27
102
|
|
|
@@ -30,22 +105,23 @@ export function requireChat(options, config) {
|
|
|
30
105
|
|
|
31
106
|
export function assertLoggedIn(config) {
|
|
32
107
|
if (!config.session || !config.apiId || !config.apiHash) {
|
|
33
|
-
throw new Error('
|
|
108
|
+
throw new Error('Not logged in — run "npx data-ark login" first.')
|
|
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()
|
|
46
122
|
|
|
47
123
|
if (!(await client.isUserAuthorized())) {
|
|
48
|
-
throw new Error('
|
|
124
|
+
throw new Error('Session expired — run "npx data-ark login".')
|
|
49
125
|
}
|
|
50
126
|
|
|
51
127
|
return client
|