telstore 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 +109 -36
- package/bin/{telark.js → telstore.js} +16 -3
- package/package.json +6 -6
- package/src/caption.js +2 -2
- package/src/chat.js +2 -2
- package/src/chunking.js +14 -1
- package/src/cli.js +44 -31
- package/src/client.js +21 -9
- package/src/commands/config.js +8 -8
- package/src/commands/delete.js +5 -5
- package/src/commands/list.js +3 -3
- package/src/commands/login.js +88 -15
- package/src/commands/logout.js +15 -5
- package/src/commands/restore.js +8 -5
- package/src/commands/status.js +85 -7
- package/src/commands/token.js +80 -0
- package/src/commands/upload.js +7 -7
- package/src/config.js +28 -4
- package/src/downloader.js +2 -2
- package/src/manifest.js +4 -4
- package/src/prompt.js +81 -0
- package/src/session.js +24 -0
- package/src/settings.js +32 -6
- package/src/state.js +35 -4
- package/src/token.js +221 -0
- package/src/uploader.js +3 -3
package/README.md
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# telstore
|
|
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
|
|
9
|
-
npx
|
|
10
|
-
npx
|
|
11
|
-
npx
|
|
12
|
-
npx
|
|
13
|
-
npx
|
|
14
|
-
npx
|
|
15
|
-
npx
|
|
16
|
-
npx
|
|
8
|
+
npx telstore login # once only
|
|
9
|
+
npx telstore config chat @my_backups # where backups go, from now on
|
|
10
|
+
npx telstore data.tar # split it and send it there
|
|
11
|
+
npx telstore data.tar --to @somewhere # somewhere else, this run only
|
|
12
|
+
npx telstore config # every setting and where its value comes from
|
|
13
|
+
npx telstore status # account, destination, unfinished backups
|
|
14
|
+
npx telstore list # what is already stored in the destination
|
|
15
|
+
npx telstore restore telstore-20260905-7f3a91
|
|
16
|
+
npx telstore delete telstore-20260905-7f3a91 # take it back out of the chat, for good
|
|
17
|
+
npx telstore token # a session token for a machine you do not trust
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
## What you need
|
|
@@ -21,26 +22,27 @@ npx telark delete telark-20260905-7f3a91 # take it back out of the chat, for g
|
|
|
21
22
|
- Node.js 18 or newer.
|
|
22
23
|
- 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.
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
telstore 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.
|
|
25
26
|
|
|
26
27
|
## Settings and flags
|
|
27
28
|
|
|
28
|
-
There are two ways to say what
|
|
29
|
+
There are two ways to say what telstore should do, and they never overlap. **`config` writes;
|
|
29
30
|
flags do not.** A flag applies to the run you typed it on and changes nothing on disk, so
|
|
30
31
|
`--to @elsewhere` sends one backup elsewhere without moving the destination for the next one.
|
|
31
32
|
|
|
32
33
|
```bash
|
|
33
|
-
npx
|
|
34
|
-
npx
|
|
35
|
-
npx
|
|
36
|
-
npx
|
|
34
|
+
npx telstore config # everything, and whether it is yours or a default
|
|
35
|
+
npx telstore config chat # one value, bare, ready to pipe
|
|
36
|
+
npx telstore config chunkSize 500MB # change it for good
|
|
37
|
+
npx telstore config chunkSize --unset # back to the default
|
|
37
38
|
```
|
|
38
39
|
|
|
39
40
|
| Setting | Flag | Default | Meaning |
|
|
40
41
|
|---|---|---|---|
|
|
41
42
|
| `chat` | `--to` | none | `@username`, `-100123…`, or `me`. A negative channel id works with a space or an `=`, as a flag or as a config value. |
|
|
42
43
|
| `chunkSize` | `--chunk-size` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. An unfinished backup keeps the size it started with. |
|
|
43
|
-
| `
|
|
44
|
+
| `uploadConcurrency` | `--upload-concurrency` | `32` | 512KB parts sent in parallel while uploading. An integer from 1 to 64. |
|
|
45
|
+
| `downloadConcurrency` | `--download-concurrency` | `8` | 8MB slices fetched in parallel while restoring. An integer from 1 to 64. |
|
|
44
46
|
| `limit` | `--limit` | `20` | How many backups `list` shows, newest first |
|
|
45
47
|
| `verbose` | `--verbose` | off | Show the Telegram client's own connection logs, hidden by default so they do not break up the progress bar |
|
|
46
48
|
|
|
@@ -63,24 +65,24 @@ follows carries a summary card:
|
|
|
63
65
|
🗄 data.tar
|
|
64
66
|
━━━━━━━━━━━━━━━
|
|
65
67
|
💾 21.4 GB · 12 chunks
|
|
66
|
-
🆔
|
|
68
|
+
🆔 telstore-20260905-7f3a91
|
|
67
69
|
📅 2026-09-05 16:40 UTC
|
|
68
70
|
|
|
69
|
-
↩ npx
|
|
70
|
-
#
|
|
71
|
+
↩ npx telstore restore telstore-20260905-7f3a91
|
|
72
|
+
#telstore
|
|
71
73
|
```
|
|
72
74
|
|
|
73
|
-
`npx
|
|
75
|
+
`npx telstore list` reads those cards straight out of the chat — one search, no downloads —
|
|
74
76
|
and lays them out as a table:
|
|
75
77
|
|
|
76
78
|
```
|
|
77
79
|
Destination https://web.telegram.org/k/#@my_backups
|
|
78
80
|
|
|
79
|
-
BACKUP ID
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
BACKUP ID FILE SIZE CHUNKS CREATED
|
|
82
|
+
telstore-20260905-7f3a91 data.tar 21.4 GB 12 2026-09-05
|
|
83
|
+
telstore-20260901-9de447 photos.zip 940.3 MB 1 2026-09-01
|
|
82
84
|
|
|
83
|
-
2 backups. Restore with: npx
|
|
85
|
+
2 backups. Restore with: npx telstore restore <backup-id>
|
|
84
86
|
```
|
|
85
87
|
|
|
86
88
|
A backup uploaded before the card existed still gets a row, with dashes where the caption
|
|
@@ -88,22 +90,22 @@ says nothing — `list` reports what the chat holds and never fills gaps with gu
|
|
|
88
90
|
|
|
89
91
|
## How it works
|
|
90
92
|
|
|
91
|
-
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,
|
|
93
|
+
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, telstore 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.
|
|
92
94
|
|
|
93
|
-
If the connection drops during an **upload**, just run the same command again — progress lives in `~/.
|
|
95
|
+
If the connection drops during an **upload**, just run the same command again — progress lives in `~/.telstore/state/` and finished chunks are skipped, keeping the same `backupId`. Two things to know about rerunning:
|
|
94
96
|
|
|
95
|
-
- Running again against a destination that differs from the one in the unfinished progress makes
|
|
97
|
+
- Running again against a destination that differs from the one in the unfinished progress makes telstore **refuse to run** rather than silently redirect — one backup cannot be split across two destinations. The error names the chat to pass as `--to` to carry on, or the state file to delete to start a new backup. It reads the same whether the mismatch came from a flag or from your configured `chat`.
|
|
96
98
|
- Running again **without** `--chunk-size` resumes at the size the backup started with, whatever your configured `chunkSize` says today. A setting is what to use when nobody asks for anything; it is not somebody asking.
|
|
97
|
-
- Running again **with** a `--chunk-size` that differs from that size makes
|
|
99
|
+
- Running again **with** a `--chunk-size` that differs from that size makes telstore **refuse to run**: 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.
|
|
98
100
|
|
|
99
|
-
`Ctrl-C` during an upload names the backup it was working on, so `
|
|
101
|
+
`Ctrl-C` during an upload names the backup it was working on, so `telstore status` and a later `restore` have something to go on. telstore keeps the **20 most recent** unfinished backups in `~/.telstore/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.
|
|
100
102
|
|
|
101
103
|
**Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
|
|
102
104
|
|
|
103
105
|
## Deleting a backup
|
|
104
106
|
|
|
105
107
|
```bash
|
|
106
|
-
npx
|
|
108
|
+
npx telstore delete telstore-20260905-7f3a91
|
|
107
109
|
```
|
|
108
110
|
|
|
109
111
|
It prints what it is about to destroy, asks once, and then removes every chunk message and
|
|
@@ -127,16 +129,77 @@ manifest or local record giving a message id that is not a whole positive number
|
|
|
127
129
|
deletes anything at all. A manifest too damaged for `restore` to use *can* still be deleted —
|
|
128
130
|
that is usually the one you want gone.
|
|
129
131
|
|
|
132
|
+
## Running on a machine you do not trust
|
|
133
|
+
|
|
134
|
+
`login` leaves your Telegram session on the machine you run it on, in plain text. When that
|
|
135
|
+
machine is not yours, print a **session token** on one that is and log in with that instead:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# on the machine you are already logged in on
|
|
139
|
+
npx telstore token # asks for a passphrase, twice
|
|
140
|
+
# prints one line: tls1.…
|
|
141
|
+
|
|
142
|
+
# on the other machine
|
|
143
|
+
npx telstore login --token # paste the token, then type the passphrase
|
|
144
|
+
npx telstore restore telstore-20260905-7f3a91
|
|
145
|
+
npx telstore logout # when you are done
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The token holds your `api_id`, `api_hash`, session and settings, encrypted with the
|
|
149
|
+
passphrase (AES-256-GCM, key derived with scrypt). It is one line of base64url, so it
|
|
150
|
+
survives a chat message, an email or a QR code — and a copy that leaks without the
|
|
151
|
+
passphrase is not enough to use your account.
|
|
152
|
+
|
|
153
|
+
`login --token` stores the token exactly as it arrived, so **the session never exists on that
|
|
154
|
+
machine in a form anyone can read**. Every command that talks to Telegram asks for the
|
|
155
|
+
passphrase and opens it in memory:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{ "sealed": "tls1.…", "settings": { "chat": "@my_backups" } }
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`npx telstore status` shows which of the two you are running:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
Session /home/you/.telstore/config.json (sealed — opened with a passphrase)
|
|
165
|
+
Account Sho (@shovity)
|
|
166
|
+
Destination https://web.telegram.org/k/#@my_backups
|
|
167
|
+
Unfinished none
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### What is left behind
|
|
171
|
+
|
|
172
|
+
- **No readable session.** `logout` removes the sealed blob, and with it the `api_hash`,
|
|
173
|
+
which lives inside it.
|
|
174
|
+
- **State files, yes.** An upload writes `~/.telstore/state/<key>.json` so it can be resumed
|
|
175
|
+
after an interruption. It holds the file path, the chunk sizes and the message ids — no
|
|
176
|
+
secret — and losing it would strand chunks in the chat with nothing able to name them.
|
|
177
|
+
|
|
178
|
+
### Things worth knowing before you use one
|
|
179
|
+
|
|
180
|
+
- **`--token` takes no value, on purpose.** A token written on the command line would sit in
|
|
181
|
+
`ps` for the whole life of the command and stay in that machine's shell history afterwards.
|
|
182
|
+
It is pasted at a prompt that does not echo it.
|
|
183
|
+
- **The passphrase may be left empty**, and then the token says so on its face: it starts
|
|
184
|
+
`tls0.` instead of `tls1.`, `token` warns, and `login` stores the session in plain text
|
|
185
|
+
exactly as an ordinary login would. telstore will not write a file that looks encrypted and
|
|
186
|
+
is not.
|
|
187
|
+
- **telstore cannot take a token back.** There is no expiry and no revocation. Like `logout`,
|
|
188
|
+
it says nothing to Telegram — to end the session for good, open Telegram → Settings →
|
|
189
|
+
Devices (Active sessions) and terminate it. Every copy of the token dies with it.
|
|
190
|
+
- **A wrong passphrase and a damaged token are the same event** to AES-GCM: one failed
|
|
191
|
+
authentication check. telstore names both possibilities rather than guessing which it was.
|
|
192
|
+
|
|
130
193
|
## Limits worth knowing
|
|
131
194
|
|
|
132
|
-
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file, an arithmetic ceiling of about 1953MB, and
|
|
133
|
-
- The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure.
|
|
134
|
-
- Deleting a chunk message on Telegram destroys the backup, with no way to recover it. Use `npx
|
|
195
|
+
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file, an arithmetic ceiling of about 1953MB, and telstore stops at 1950MB to leave a safety margin.
|
|
196
|
+
- The data is **not** encrypted. Don't upload anything you would mind sitting on someone else's infrastructure. The one thing telstore does encrypt is a session token, and that protects your login rather than your files.
|
|
197
|
+
- Deleting a chunk message on Telegram destroys the backup, with no way to recover it. Use `npx telstore delete <backup-id>` when that is what you actually want.
|
|
135
198
|
- Keep the `backupId`. Without it you have to hunt for the manifest in the chat by hand.
|
|
136
199
|
|
|
137
200
|
## Where the config lives
|
|
138
201
|
|
|
139
|
-
`~/.
|
|
202
|
+
`~/.telstore/config.json` (mode 600) holds `apiId`, `apiHash` and the session at the top level, with everything `config` manages under `settings`:
|
|
140
203
|
|
|
141
204
|
```json
|
|
142
205
|
{
|
|
@@ -147,6 +210,16 @@ that is usually the one you want gone.
|
|
|
147
210
|
}
|
|
148
211
|
```
|
|
149
212
|
|
|
213
|
+
A machine logged in with `npx telstore login --token` holds `sealed` instead of those three
|
|
214
|
+
fields, and nothing else about the account:
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{ "sealed": "tls1.…", "settings": { "chat": "@my_backups" } }
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A file holding both shapes is refused rather than guessed at — there would be no way to know
|
|
221
|
+
which account was meant.
|
|
222
|
+
|
|
150
223
|
Editing it by hand is fine, and a value that cannot be used is named on the next run — with the file and the key, never with a flag you did not type.
|
|
151
224
|
|
|
152
|
-
`npx
|
|
225
|
+
`npx telstore 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.
|
|
@@ -7,6 +7,7 @@ import { runList } from '../src/commands/list.js'
|
|
|
7
7
|
import { runLogout } from '../src/commands/logout.js'
|
|
8
8
|
import { runRestore } from '../src/commands/restore.js'
|
|
9
9
|
import { runStatus } from '../src/commands/status.js'
|
|
10
|
+
import { runToken } from '../src/commands/token.js'
|
|
10
11
|
import { runUpload } from '../src/commands/upload.js'
|
|
11
12
|
|
|
12
13
|
const SIGINT_EXIT_CODE = 130
|
|
@@ -18,6 +19,10 @@ let currentCommand = null
|
|
|
18
19
|
let currentBackupId = null
|
|
19
20
|
|
|
20
21
|
process.on('SIGINT', () => {
|
|
22
|
+
// A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
|
|
23
|
+
// Without this, Ctrl-C hands back a shell that no longer echoes what is typed into it.
|
|
24
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
25
|
+
|
|
21
26
|
process.stderr.write(interruptMessage(currentCommand, { backupId: currentBackupId }))
|
|
22
27
|
process.exit(SIGINT_EXIT_CODE)
|
|
23
28
|
})
|
|
@@ -41,7 +46,11 @@ async function main() {
|
|
|
41
46
|
return
|
|
42
47
|
|
|
43
48
|
case 'login':
|
|
44
|
-
await runLogin({
|
|
49
|
+
await runLogin({
|
|
50
|
+
args: parsed.args,
|
|
51
|
+
token: Boolean(parsed.options.token),
|
|
52
|
+
verbose: Boolean(parsed.options.verbose),
|
|
53
|
+
})
|
|
45
54
|
return
|
|
46
55
|
|
|
47
56
|
case 'logout':
|
|
@@ -60,6 +69,10 @@ async function main() {
|
|
|
60
69
|
await runConfig(parsed.args, parsed.options)
|
|
61
70
|
return
|
|
62
71
|
|
|
72
|
+
case 'token':
|
|
73
|
+
await runToken(parsed.args, parsed.options)
|
|
74
|
+
return
|
|
75
|
+
|
|
63
76
|
case 'upload':
|
|
64
77
|
await runUpload(parsed.args[0], parsed.options, {
|
|
65
78
|
onBackupId: (id) => {
|
|
@@ -70,14 +83,14 @@ async function main() {
|
|
|
70
83
|
|
|
71
84
|
case 'restore':
|
|
72
85
|
if (!parsed.args[0]) {
|
|
73
|
-
throw new Error('Missing backup id. Example: npx
|
|
86
|
+
throw new Error('Missing backup id. Example: npx telstore restore telstore-20260905-7f3a91')
|
|
74
87
|
}
|
|
75
88
|
await runRestore(parsed.args[0], parsed.options)
|
|
76
89
|
return
|
|
77
90
|
|
|
78
91
|
case 'delete':
|
|
79
92
|
if (!parsed.args[0]) {
|
|
80
|
-
throw new Error('Missing backup id. Example: npx
|
|
93
|
+
throw new Error('Missing backup id. Example: npx telstore delete telstore-20260905-7f3a91')
|
|
81
94
|
}
|
|
82
95
|
await runDelete(parsed.args[0], parsed.options)
|
|
83
96
|
return
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "telstore",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Split large files into chunks and store them on Telegram",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telegram",
|
|
@@ -11,19 +11,19 @@
|
|
|
11
11
|
"cli",
|
|
12
12
|
"mtproto"
|
|
13
13
|
],
|
|
14
|
-
"homepage": "https://github.com/shovity/
|
|
14
|
+
"homepage": "https://github.com/shovity/telstore#readme",
|
|
15
15
|
"bugs": {
|
|
16
|
-
"url": "https://github.com/shovity/
|
|
16
|
+
"url": "https://github.com/shovity/telstore/issues"
|
|
17
17
|
},
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
|
-
"url": "git+https://github.com/shovity/
|
|
20
|
+
"url": "git+https://github.com/shovity/telstore.git"
|
|
21
21
|
},
|
|
22
22
|
"type": "module",
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"author": "shovity",
|
|
25
25
|
"bin": {
|
|
26
|
-
"
|
|
26
|
+
"telstore": "bin/telstore.js"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"bin",
|
|
@@ -39,4 +39,4 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"telegram": "^2.26.22"
|
|
41
41
|
}
|
|
42
|
-
}
|
|
42
|
+
}
|
package/src/caption.js
CHANGED
|
@@ -8,7 +8,7 @@ const DIVIDER = '━'.repeat(15)
|
|
|
8
8
|
|
|
9
9
|
// The hashtag is what `list` searches for, and it lives on the manifest alone: chunk
|
|
10
10
|
// captions stay out of that search so a twelve-chunk backup is one hit, not thirteen.
|
|
11
|
-
export const MANIFEST_TAG = '#
|
|
11
|
+
export const MANIFEST_TAG = '#telstore'
|
|
12
12
|
|
|
13
13
|
// A file name may legally contain a newline or a tab, and either one would push the
|
|
14
14
|
// rest of the card down a row and take its shape apart.
|
|
@@ -32,7 +32,7 @@ export function manifestCaption({ id, name, size, chunks, createdAt }) {
|
|
|
32
32
|
`🆔 ${id}`,
|
|
33
33
|
`📅 ${utcMinutes(createdAt)}`,
|
|
34
34
|
'',
|
|
35
|
-
`↩ npx
|
|
35
|
+
`↩ npx telstore restore ${id}`,
|
|
36
36
|
MANIFEST_TAG,
|
|
37
37
|
].join('\n')
|
|
38
38
|
}
|
package/src/chat.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// How
|
|
1
|
+
// How telstore talks about a destination. None of this touches Telegram — it is string
|
|
2
2
|
// handling around a target the user typed — so it lives apart from the client that does.
|
|
3
3
|
|
|
4
4
|
export function normalizeChatTarget(input) {
|
|
@@ -17,7 +17,7 @@ export function normalizeChatTarget(input) {
|
|
|
17
17
|
|
|
18
18
|
// Telegram's web client addresses a chat by putting the raw target in the fragment, which
|
|
19
19
|
// covers both a negative channel id and an @username. Saved Messages is the exception: it
|
|
20
|
-
// is reached by the account's own id, which
|
|
20
|
+
// is reached by the account's own id, which telstore does not know, so it gets no link
|
|
21
21
|
// rather than a guessed one that lands somewhere else.
|
|
22
22
|
export function chatUrl(chat) {
|
|
23
23
|
const text = String(chat)
|
package/src/chunking.js
CHANGED
|
@@ -7,7 +7,20 @@ export const MAX_PARTS = 4000
|
|
|
7
7
|
export const SLICE_SIZE = 8 * 1024 * 1024
|
|
8
8
|
export const MAX_CHUNK_SIZE = 1950 * 1024 * 1024
|
|
9
9
|
export const DEFAULT_CHUNK_SIZE = 1800 * 1024 * 1024
|
|
10
|
-
|
|
10
|
+
// Upload counts 512KB parts and download counts 8MB slices, so one number cannot serve both.
|
|
11
|
+
// Measured on a 1Gb/s line against a single 1800MB chunk, so every run transfers for three
|
|
12
|
+
// to five minutes rather than the seconds a burst benchmark samples — the distinction the
|
|
13
|
+
// parallel-download spec insists on, and it matters: measured in 20s bursts the download
|
|
14
|
+
// order came out reversed, with 4 apparently the fastest value.
|
|
15
|
+
// upload 16 -> 7.7 MB/s (234s), 32 -> 9.4 (193s), 64 -> 9.5 (191s)
|
|
16
|
+
// download 4 -> 5.8 MB/s (311s), 8 -> 6.0 (300s), 16 -> 6.0 (302s)
|
|
17
|
+
// Upload takes 32 because 64 measures the same speed (1% apart, inside the noise) at twice
|
|
18
|
+
// the cost: 64 puts 32MB in flight, and a batch's last part then needs 4.4 Mbps to land
|
|
19
|
+
// before the 60s stall deadline, against 2.1 Mbps at 32. A slow link would be told its
|
|
20
|
+
// transfer stalled while it was merely slow, which is the one thing that deadline must not say.
|
|
21
|
+
// Download takes 8 for the same reason from the other side: 16 buys nothing and 4 is slower.
|
|
22
|
+
export const DEFAULT_UPLOAD_CONCURRENCY = 32
|
|
23
|
+
export const DEFAULT_DOWNLOAD_CONCURRENCY = 8
|
|
11
24
|
export const MAX_CONCURRENCY = 64
|
|
12
25
|
|
|
13
26
|
// Every chunk is one message in the chat and one entry in the manifest, so a plan this long
|
package/src/cli.js
CHANGED
|
@@ -8,55 +8,68 @@ const SUBCOMMANDS = new Set([
|
|
|
8
8
|
'delete',
|
|
9
9
|
'status',
|
|
10
10
|
'config',
|
|
11
|
+
'token',
|
|
11
12
|
'help',
|
|
12
13
|
])
|
|
13
14
|
|
|
14
15
|
const OPTIONS = {
|
|
15
16
|
to: { type: 'string' },
|
|
16
17
|
'chunk-size': { type: 'string' },
|
|
17
|
-
concurrency: { type: 'string' },
|
|
18
|
+
'upload-concurrency': { type: 'string' },
|
|
19
|
+
'download-concurrency': { type: 'string' },
|
|
18
20
|
out: { type: 'string' },
|
|
19
21
|
limit: { type: 'string' },
|
|
20
22
|
verbose: { type: 'boolean' },
|
|
21
23
|
unset: { type: 'boolean' },
|
|
22
24
|
yes: { type: 'boolean' },
|
|
25
|
+
token: { type: 'boolean' },
|
|
23
26
|
help: { type: 'boolean', short: 'h' },
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
export const HELP = `
|
|
29
|
+
export const HELP = `telstore — split large files into chunks and store them on Telegram
|
|
27
30
|
|
|
28
31
|
Usage:
|
|
29
|
-
npx
|
|
30
|
-
npx
|
|
31
|
-
npx
|
|
32
|
-
npx
|
|
33
|
-
npx
|
|
34
|
-
npx
|
|
35
|
-
npx
|
|
36
|
-
npx
|
|
32
|
+
npx telstore login Log in to Telegram, only needed once
|
|
33
|
+
npx telstore <file> Split a file and upload it to Telegram
|
|
34
|
+
npx telstore list List the backups stored in the destination
|
|
35
|
+
npx telstore restore <backup-id> Download the chunks and reassemble the file
|
|
36
|
+
npx telstore delete <backup-id> Remove a backup's chunks and manifest from the chat
|
|
37
|
+
npx telstore status Show the account, the destination and unfinished backups
|
|
38
|
+
npx telstore config Show every setting and where its value comes from
|
|
39
|
+
npx telstore logout Remove the saved session
|
|
40
|
+
|
|
41
|
+
Running on a machine you do not trust:
|
|
42
|
+
npx telstore token Print a session token for another machine
|
|
43
|
+
npx telstore login --token Log in there by pasting one, session stays sealed
|
|
37
44
|
|
|
38
45
|
Settings:
|
|
39
|
-
npx
|
|
40
|
-
npx
|
|
41
|
-
npx
|
|
46
|
+
npx telstore config <name> Print one setting's value
|
|
47
|
+
npx telstore config <name> <value> Change it for good
|
|
48
|
+
npx telstore config <name> --unset Drop it and fall back to the default
|
|
42
49
|
|
|
43
|
-
chat
|
|
44
|
-
chunkSize
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
chat Where backups go: @username, -100123..., or me. No default.
|
|
51
|
+
chunkSize Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
|
|
52
|
+
uploadConcurrency 512KB parts sent in parallel, default 32, max 64.
|
|
53
|
+
downloadConcurrency 8MB slices fetched in parallel, default 8, max 64.
|
|
54
|
+
limit How many backups list shows, default 20.
|
|
55
|
+
verbose Show Telegram connection logs, default false.
|
|
48
56
|
|
|
49
57
|
Options apply to one run and are never saved. Use config to change a setting for good.
|
|
50
|
-
--to <chat>
|
|
51
|
-
--chunk-size <n>
|
|
52
|
-
|
|
53
|
-
--concurrency <n>
|
|
54
|
-
|
|
55
|
-
--out <path>
|
|
56
|
-
|
|
57
|
-
--
|
|
58
|
-
--
|
|
59
|
-
|
|
58
|
+
--to <chat> Destination for this run only.
|
|
59
|
+
--chunk-size <n> Chunk size for this run only. An unfinished backup keeps the
|
|
60
|
+
size it started with.
|
|
61
|
+
--upload-concurrency <n> 512KB parts in parallel while uploading, this run only.
|
|
62
|
+
--download-concurrency <n> 8MB slices in parallel while restoring, this run only.
|
|
63
|
+
--out <path> Where to write the restored file. Defaults to the basename in
|
|
64
|
+
the manifest.
|
|
65
|
+
--limit <n> How many backups list shows this run.
|
|
66
|
+
--token Log in by pasting a session token. It takes no value on
|
|
67
|
+
purpose: a token written on the command line would sit in
|
|
68
|
+
"ps" for the whole life of the command, and stay in that
|
|
69
|
+
machine's shell history afterwards.
|
|
70
|
+
--yes Delete without asking to confirm first.
|
|
71
|
+
--verbose Show Telegram connection logs for this run.
|
|
72
|
+
-h, --help Show this help.
|
|
60
73
|
`
|
|
61
74
|
|
|
62
75
|
// What Ctrl-C means depends on the command that was running: upload has written every
|
|
@@ -69,7 +82,7 @@ export function interruptMessage(command, { backupId } = {}) {
|
|
|
69
82
|
|
|
70
83
|
return (
|
|
71
84
|
`\n${backup} — run the same command again to continue, ` +
|
|
72
|
-
'or "npx
|
|
85
|
+
'or "npx telstore status" to see what is left.\n'
|
|
73
86
|
)
|
|
74
87
|
}
|
|
75
88
|
|
|
@@ -138,13 +151,13 @@ export function route(argv) {
|
|
|
138
151
|
|
|
139
152
|
const [first, ...rest] = positionals
|
|
140
153
|
|
|
141
|
-
// `
|
|
154
|
+
// `telstore --to @chan` with no file used to mean "remember this destination". Flags no
|
|
142
155
|
// longer write anything, so that line now asks for a run that has nothing to upload —
|
|
143
156
|
// say where the destination actually lives instead of printing help at someone who was
|
|
144
157
|
// perfectly clear about what they wanted.
|
|
145
158
|
if (first === undefined && values.to && !values.help) {
|
|
146
159
|
throw new Error(
|
|
147
|
-
`Nothing to upload. To change the destination for good, run "npx
|
|
160
|
+
`Nothing to upload. To change the destination for good, run "npx telstore config chat ${values.to}". ` +
|
|
148
161
|
'To use it for one run, pass --to alongside a file or a command.',
|
|
149
162
|
)
|
|
150
163
|
}
|
package/src/client.js
CHANGED
|
@@ -5,6 +5,7 @@ import { StringSession } from 'telegram/sessions/index.js'
|
|
|
5
5
|
|
|
6
6
|
import { manifestFileName } from './manifest.js'
|
|
7
7
|
import { withRetry } from './retry.js'
|
|
8
|
+
import { unlockConfig } from './session.js'
|
|
8
9
|
import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
|
|
9
10
|
|
|
10
11
|
// GramJS narrates its version, every connection and every disconnect at info level, and
|
|
@@ -22,7 +23,7 @@ export function documentFileName(message) {
|
|
|
22
23
|
return named?.fileName ?? null
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
// The one place
|
|
26
|
+
// The one place telstore searches a chat. Both callers want documents and nothing else,
|
|
26
27
|
// and getMessages is preferred over a raw Api.messages.Search because it handles offsets,
|
|
27
28
|
// hashes and pagination itself, so we don't hand-build easily mistyped fields. The raw
|
|
28
29
|
// message is kept alongside the flat fields because downloading needs it whole.
|
|
@@ -42,9 +43,9 @@ export async function searchDocuments(client, peer, { search, limit }) {
|
|
|
42
43
|
}))
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
// How
|
|
46
|
+
// How telstore finds a backup's manifest, in one place because restore and delete must not
|
|
46
47
|
// disagree about it. The search is by backup id, but the answer is decided by the file name
|
|
47
|
-
//
|
|
48
|
+
// telstore itself wrote — a caption is text a person can edit, a file name is not.
|
|
48
49
|
export async function findManifestMessage(client, peer, backupId) {
|
|
49
50
|
const wanted = manifestFileName(backupId)
|
|
50
51
|
const found = await searchDocuments(client, peer, { search: backupId, limit: 100 })
|
|
@@ -56,7 +57,7 @@ export async function readMessageBytes(client, message) {
|
|
|
56
57
|
return await client.downloadMedia(message)
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
// The one place
|
|
60
|
+
// The one place telstore removes messages from a chat, and the mirror of searchDocuments
|
|
60
61
|
// above. GramJS has its own deleteMessages, and it is the right thing to call — it resolves
|
|
61
62
|
// the peer and picks between channels.DeleteMessages and messages.DeleteMessages, which is
|
|
62
63
|
// exactly the choice a fake client would never catch us getting wrong.
|
|
@@ -64,7 +65,7 @@ export async function readMessageBytes(client, message) {
|
|
|
64
65
|
// What it does on top of that is the problem: it splits the ids into batches of a hundred
|
|
65
66
|
// and fires every batch at once through Promise.all. A ten-thousand-chunk backup would put
|
|
66
67
|
// a hundred requests in flight together, none of them under the retry policy or the stall
|
|
67
|
-
// deadline that every other network wait in
|
|
68
|
+
// deadline that every other network wait in telstore carries. Batching here instead keeps
|
|
68
69
|
// one request outstanding at a time, under both.
|
|
69
70
|
//
|
|
70
71
|
// Telegram does not complain about an id that is no longer there, so sending a batch twice
|
|
@@ -121,16 +122,27 @@ export async function closeQuietly(client, disconnect, onWarn) {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
// Two shapes count as logged in: the ordinary one login writes, and the sealed blob that
|
|
126
|
+
// "login --token" leaves, which holds the same three fields behind a passphrase. Checked
|
|
127
|
+
// before anything asks for that passphrase, so somebody who never logged in is told so rather
|
|
128
|
+
// than asked to type a secret for an account that is not there.
|
|
124
129
|
export function assertLoggedIn(config) {
|
|
130
|
+
if (config.sealed) return
|
|
131
|
+
|
|
125
132
|
if (!config.session || !config.apiId || !config.apiHash) {
|
|
126
|
-
throw new Error('Not logged in — run "npx
|
|
133
|
+
throw new Error('Not logged in — run "npx telstore login" first.')
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
130
|
-
|
|
137
|
+
// Every command that needs Telegram comes through here, which makes this the one place a
|
|
138
|
+
// sealed session has to be opened. Doing it anywhere else would mean eight places to keep in
|
|
139
|
+
// step, and a ninth command would simply forget.
|
|
140
|
+
export async function connect(config, { verbose = false, unlock = unlockConfig } = {}) {
|
|
131
141
|
assertLoggedIn(config)
|
|
132
142
|
|
|
133
|
-
const
|
|
143
|
+
const { apiId, apiHash, session } = await unlock(config)
|
|
144
|
+
|
|
145
|
+
const client = new TelegramClient(new StringSession(session), apiId, apiHash, {
|
|
134
146
|
connectionRetries: 5,
|
|
135
147
|
floodSleepThreshold: 60,
|
|
136
148
|
baseLogger: createLogger(verbose),
|
|
@@ -139,7 +151,7 @@ export async function connect(config, { verbose = false } = {}) {
|
|
|
139
151
|
await client.connect()
|
|
140
152
|
|
|
141
153
|
if (!(await client.isUserAuthorized())) {
|
|
142
|
-
throw new Error('Session expired — run "npx
|
|
154
|
+
throw new Error('Session expired — run "npx telstore login".')
|
|
143
155
|
}
|
|
144
156
|
|
|
145
157
|
return client
|