telstore 0.1.4 → 0.1.6
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 +74 -175
- package/bin/telstore.js +50 -23
- package/package.json +2 -2
- package/src/caption.js +2 -2
- package/src/client.js +8 -20
- package/src/commands/delete.js +1 -1
- package/src/commands/list.js +1 -1
- package/src/commands/login.js +8 -7
- package/src/commands/status.js +2 -1
- package/src/commands/token.js +1 -2
- package/src/commands/upload.js +2 -2
- package/src/downloader.js +8 -4
- package/src/retry.js +7 -1
- package/src/session.js +14 -1
- package/src/stall.js +11 -9
- package/src/token.js +1 -1
- package/src/uploader.js +7 -7
package/README.md
CHANGED
|
@@ -1,78 +1,68 @@
|
|
|
1
1
|
# telstore
|
|
2
2
|
|
|
3
|
-
Split large files into
|
|
3
|
+
Split large files into chunks, store them on Telegram, and restore them byte-for-byte.
|
|
4
|
+
|
|
5
|
+
telstore signs in with your own Telegram account over MTProto, not a bot: the Bot API caps
|
|
6
|
+
uploads at 50MB per file, a user account gets 2GB.
|
|
7
|
+
|
|
8
|
+
Needs Node.js 18+ and an `api_id`/`api_hash` from <https://my.telegram.org> → API
|
|
9
|
+
development tools; `login` asks for those, your phone number and the verification code.
|
|
4
10
|
|
|
5
11
|
## Quick start
|
|
6
12
|
|
|
7
13
|
```bash
|
|
8
|
-
npx telstore login
|
|
9
|
-
npx telstore config chat @my_backups
|
|
10
|
-
npx telstore data.tar
|
|
11
|
-
npx telstore
|
|
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
|
|
14
|
+
npx telstore login # once only
|
|
15
|
+
npx telstore config chat @my_backups # where backups go, from now on
|
|
16
|
+
npx telstore data.tar # split it and send it there
|
|
17
|
+
npx telstore list # what is already in the destination
|
|
15
18
|
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
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
##
|
|
21
|
-
|
|
22
|
-
- Node.js 18 or newer.
|
|
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.
|
|
21
|
+
## Commands
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
| Command | What it does |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `telstore login` | Log in to Telegram. Add `--token` to log in with a session token instead. |
|
|
26
|
+
| `telstore <file>` | Split the file and upload it. Prints the `backupId` you restore with. |
|
|
27
|
+
| `telstore list` | The backups stored in the destination, newest first. |
|
|
28
|
+
| `telstore restore <backup-id>` | Download every chunk and reassemble the file. |
|
|
29
|
+
| `telstore delete <backup-id>` | Remove a backup's chunks and manifest from the chat, for good. |
|
|
30
|
+
| `telstore status` | Account, destination, and unfinished backups. |
|
|
31
|
+
| `telstore config` | Show or change settings. |
|
|
32
|
+
| `telstore token` | Print a session token for a machine you do not trust. |
|
|
33
|
+
| `telstore logout` | Remove the locally stored session. |
|
|
26
34
|
|
|
27
|
-
## Settings
|
|
35
|
+
## Settings
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
`config` writes; flags do not. A flag applies to the run you typed it on and changes nothing
|
|
38
|
+
on disk, so `--to @elsewhere` sends one backup elsewhere without moving the destination for
|
|
39
|
+
the next one.
|
|
32
40
|
|
|
33
41
|
```bash
|
|
34
|
-
npx telstore config
|
|
35
|
-
npx telstore config
|
|
36
|
-
npx telstore config chunkSize
|
|
37
|
-
npx telstore config chunkSize --unset # back to the default
|
|
42
|
+
npx telstore config # every setting, and whether it is yours or a default
|
|
43
|
+
npx telstore config chunkSize 500MB # change it for good
|
|
44
|
+
npx telstore config chunkSize --unset # back to the default
|
|
38
45
|
```
|
|
39
46
|
|
|
40
47
|
| Setting | Flag | Default | Meaning |
|
|
41
48
|
|---|---|---|---|
|
|
42
|
-
| `chat` | `--to` | none | `@username`, `-100123…`, or `me
|
|
43
|
-
| `chunkSize` | `--chunk-size` | `1800MB` | e.g. `1.8GB`, `500MB`.
|
|
44
|
-
| `uploadConcurrency` | `--upload-concurrency` | `32` | 512KB parts
|
|
45
|
-
| `downloadConcurrency` | `--download-concurrency` | `8` | 8MB slices
|
|
46
|
-
| `limit` | `--limit` | `20` | How many backups `list` shows
|
|
47
|
-
| `verbose` | `--verbose` | off | Show the Telegram client's own connection logs
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
defaults to the basename in the manifest. Relative paths resolve against the current directory.
|
|
54
|
-
|
|
55
|
-
`config` reads and writes only settings — the `api_id`, `api_hash` and session that `login`
|
|
56
|
-
stores in the same file are not reachable from it. A value is checked before it is written,
|
|
57
|
-
so `config chunkSize 9GB` is refused there and then rather than at the start of a long upload.
|
|
49
|
+
| `chat` | `--to` | none | `@username`, `-100123…`, or `me` |
|
|
50
|
+
| `chunkSize` | `--chunk-size` | `1800MB` | e.g. `1.8GB`, `500MB`. Ceiling 1950MB. |
|
|
51
|
+
| `uploadConcurrency` | `--upload-concurrency` | `32` | 512KB parts in parallel while uploading, 1–64 |
|
|
52
|
+
| `downloadConcurrency` | `--download-concurrency` | `8` | 8MB slices in parallel while restoring, 1–64 |
|
|
53
|
+
| `limit` | `--limit` | `20` | How many backups `list` shows |
|
|
54
|
+
| `verbose` | `--verbose` | off | Show the Telegram client's own connection logs |
|
|
55
|
+
|
|
56
|
+
Three flags have no setting behind them: `--out <path>` names where one restore writes,
|
|
57
|
+
`--yes` skips the confirmation `delete` asks, and `--token` takes no value — a token written
|
|
58
|
+
on the command line would sit in `ps` and in that machine's shell history, so it is pasted at
|
|
59
|
+
a prompt that does not echo.
|
|
58
60
|
|
|
59
61
|
## What the chat looks like
|
|
60
62
|
|
|
61
|
-
Every chunk goes up as a document captioned `📦 <backupId> · 3/12`,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
```
|
|
65
|
-
🗄 data.tar
|
|
66
|
-
💾 21.4 GB · 12 chunks
|
|
67
|
-
🆔 telstore-20260905-7f3a91
|
|
68
|
-
📅 2026-09-05 16:40 UTC
|
|
69
|
-
|
|
70
|
-
↩ npx telstore restore telstore-20260905-7f3a91
|
|
71
|
-
#telstore
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
`npx telstore list` reads those cards straight out of the chat — one search, no downloads —
|
|
75
|
-
and lays them out as a table:
|
|
63
|
+
Every chunk goes up as a document captioned `📦 <backupId> · 3/12`, followed by a manifest
|
|
64
|
+
carrying a summary card — file name, size, id, date and the restore command. `list` reads
|
|
65
|
+
those cards straight out of the chat, one search and no downloads:
|
|
76
66
|
|
|
77
67
|
```
|
|
78
68
|
Destination https://web.telegram.org/k/#@my_backups
|
|
@@ -84,141 +74,50 @@ telstore-20260901-9de447 photos.zip 940.3 MB 1 2026-09-01
|
|
|
84
74
|
2 backups. Restore with: npx telstore restore <backup-id>
|
|
85
75
|
```
|
|
86
76
|
|
|
87
|
-
|
|
88
|
-
says nothing — `list` reports what the chat holds and never fills gaps with guesses.
|
|
89
|
-
|
|
90
|
-
## How it works
|
|
91
|
-
|
|
92
|
-
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.
|
|
93
|
-
|
|
94
|
-
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:
|
|
95
|
-
|
|
96
|
-
- 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`.
|
|
97
|
-
- 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.
|
|
98
|
-
- 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.
|
|
99
|
-
|
|
100
|
-
`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.
|
|
101
|
-
|
|
102
|
-
**Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
|
|
103
|
-
|
|
104
|
-
## Deleting a backup
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
npx telstore delete telstore-20260905-7f3a91
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
It prints what it is about to destroy, asks once, and then removes every chunk message and
|
|
111
|
-
the manifest from the chat and drops the local record if there is one. `--yes` skips the
|
|
112
|
-
question. **There is no undo** — Telegram is the only copy.
|
|
113
|
-
|
|
114
|
-
It also works on a backup that never finished: those have chunks in the chat but no manifest,
|
|
115
|
-
so `list` cannot see them and only `status` knows they exist. `delete` reads the local record
|
|
116
|
-
instead and clears both.
|
|
77
|
+
## Resuming an upload
|
|
117
78
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
runs the backup still shows up in `list`, and a `restore` of it fails loudly rather than
|
|
123
|
-
handing over a partial file.
|
|
79
|
+
Progress lives in `~/.telstore/state/` (the 20 most recent), so running the same command
|
|
80
|
+
again skips the finished chunks and keeps the same `backupId`. A resumed backup keeps the
|
|
81
|
+
chunk size it started with; passing a `--chunk-size` or a destination that differs from its
|
|
82
|
+
own makes telstore refuse to run rather than re-cut or redirect it silently.
|
|
124
83
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
manifest or local record giving a message id that is not a whole positive number. Neither
|
|
128
|
-
deletes anything at all. A manifest too damaged for `restore` to use *can* still be deleted —
|
|
129
|
-
that is usually the one you want gone.
|
|
84
|
+
**Restore keeps no state** — `Ctrl-C` mid-restore saves nothing, running again starts over.
|
|
85
|
+
And `delete` has **no undo**: Telegram is the only copy.
|
|
130
86
|
|
|
131
87
|
## Running on a machine you do not trust
|
|
132
88
|
|
|
133
|
-
`login` leaves your
|
|
134
|
-
|
|
89
|
+
`login` leaves your session on disk in plain text. When the machine is not yours, print a
|
|
90
|
+
**session token** on one that is and log in with that instead:
|
|
135
91
|
|
|
136
92
|
```bash
|
|
137
|
-
# on
|
|
138
|
-
npx telstore token
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# on the other machine
|
|
142
|
-
npx telstore login --token # paste the token, then type the passphrase
|
|
143
|
-
npx telstore restore telstore-20260905-7f3a91
|
|
144
|
-
npx telstore logout # when you are done
|
|
93
|
+
npx telstore token # on your own machine: asks for a passphrase, prints one line
|
|
94
|
+
npx telstore login --token # on the other one: paste the token, then the passphrase
|
|
95
|
+
npx telstore logout # when you are done
|
|
145
96
|
```
|
|
146
97
|
|
|
147
98
|
The token holds your `api_id`, `api_hash`, session and settings, encrypted with the
|
|
148
|
-
passphrase (AES-256-GCM,
|
|
149
|
-
|
|
150
|
-
passphrase
|
|
151
|
-
|
|
152
|
-
`login --token` stores the token exactly as it arrived, so **the session never exists on that
|
|
153
|
-
machine in a form anyone can read**. Every command that talks to Telegram asks for the
|
|
154
|
-
passphrase and opens it in memory:
|
|
155
|
-
|
|
156
|
-
```json
|
|
157
|
-
{ "sealed": "tls1.…", "settings": { "chat": "@my_backups" } }
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
`npx telstore status` shows which of the two you are running:
|
|
161
|
-
|
|
162
|
-
```
|
|
163
|
-
Session /home/you/.telstore/config.json (sealed — opened with a passphrase)
|
|
164
|
-
Account Sho (@shovity)
|
|
165
|
-
Destination https://web.telegram.org/k/#@my_backups
|
|
166
|
-
Unfinished none
|
|
167
|
-
```
|
|
99
|
+
passphrase (AES-256-GCM, scrypt), as one line of base64url that survives a chat message or a
|
|
100
|
+
QR code. `login --token` stores it exactly as it arrived and every command asks for the
|
|
101
|
+
passphrase, so the session never exists on that machine in readable form.
|
|
168
102
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
which lives inside it.
|
|
173
|
-
- **State files, yes.** An upload writes `~/.telstore/state/<key>.json` so it can be resumed
|
|
174
|
-
after an interruption. It holds the file path, the chunk sizes and the message ids — no
|
|
175
|
-
secret — and losing it would strand chunks in the chat with nothing able to name them.
|
|
176
|
-
|
|
177
|
-
### Things worth knowing before you use one
|
|
178
|
-
|
|
179
|
-
- **`--token` takes no value, on purpose.** A token written on the command line would sit in
|
|
180
|
-
`ps` for the whole life of the command and stay in that machine's shell history afterwards.
|
|
181
|
-
It is pasted at a prompt that does not echo it.
|
|
182
|
-
- **The passphrase may be left empty**, and then the token says so on its face: it starts
|
|
183
|
-
`tls0.` instead of `tls1.`, `token` warns, and `login` stores the session in plain text
|
|
184
|
-
exactly as an ordinary login would. telstore will not write a file that looks encrypted and
|
|
185
|
-
is not.
|
|
186
|
-
- **telstore cannot take a token back.** There is no expiry and no revocation. Like `logout`,
|
|
187
|
-
it says nothing to Telegram — to end the session for good, open Telegram → Settings →
|
|
188
|
-
Devices (Active sessions) and terminate it. Every copy of the token dies with it.
|
|
189
|
-
- **A wrong passphrase and a damaged token are the same event** to AES-GCM: one failed
|
|
190
|
-
authentication check. telstore names both possibilities rather than guessing which it was.
|
|
103
|
+
There is no expiry and no revocation: to end a session for good, terminate it under Telegram
|
|
104
|
+
→ Settings → Devices. An empty passphrase is allowed, and then the token says so on its face
|
|
105
|
+
— it starts `tls0.` and `login` stores the session in plain text.
|
|
191
106
|
|
|
192
107
|
## Limits worth knowing
|
|
193
108
|
|
|
194
|
-
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
`~/.telstore/config.json` (mode 600) holds `apiId`, `apiHash` and the session at the top level, with everything `config` manages under `settings`:
|
|
202
|
-
|
|
203
|
-
```json
|
|
204
|
-
{
|
|
205
|
-
"apiId": 123456,
|
|
206
|
-
"apiHash": "…",
|
|
207
|
-
"session": "…",
|
|
208
|
-
"settings": { "chat": "@my_backups", "chunkSize": 524288000 }
|
|
209
|
-
}
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
A machine logged in with `npx telstore login --token` holds `sealed` instead of those three
|
|
213
|
-
fields, and nothing else about the account:
|
|
214
|
-
|
|
215
|
-
```json
|
|
216
|
-
{ "sealed": "tls1.…", "settings": { "chat": "@my_backups" } }
|
|
217
|
-
```
|
|
109
|
+
- **Your data is not encrypted.** Don't upload anything you would mind sitting on someone
|
|
110
|
+
else's infrastructure — the one thing telstore encrypts is a session token, and that
|
|
111
|
+
protects your login rather than your files.
|
|
112
|
+
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file.
|
|
113
|
+
- Deleting a chunk message in the Telegram app destroys the backup, and keeping the
|
|
114
|
+
`backupId` is what saves you hunting for its manifest in the chat by hand.
|
|
218
115
|
|
|
219
|
-
|
|
220
|
-
|
|
116
|
+
Settings and credentials live in `~/.telstore/config.json`, mode 600 — `apiId`, `apiHash` and
|
|
117
|
+
the session at the top level (or a single `sealed` blob after `login --token`), everything
|
|
118
|
+
`config` manages under `settings`. Editing it by hand is fine: a value that cannot be used is
|
|
119
|
+
named on the next run, with the file and the key.
|
|
221
120
|
|
|
222
|
-
|
|
121
|
+
## License
|
|
223
122
|
|
|
224
|
-
|
|
123
|
+
MIT
|
package/bin/telstore.js
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { route, HELP, interruptMessage } from '../src/cli.js'
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import { runRestore } from '../src/commands/restore.js'
|
|
9
|
-
import { runStatus } from '../src/commands/status.js'
|
|
10
|
-
import { runToken } from '../src/commands/token.js'
|
|
11
|
-
import { runUpload } from '../src/commands/upload.js'
|
|
3
|
+
|
|
4
|
+
// Each command is imported where it runs, not here. Importing all nine up front pulled
|
|
5
|
+
// teleproto into every invocation — about 0.3s and 45MB — including `--help`, `config` and
|
|
6
|
+
// `logout`, which never open a socket. `src/cli.js` stays a static import because parsing the
|
|
7
|
+
// arguments is the one thing every run does, and nothing it touches reaches the network.
|
|
12
8
|
|
|
13
9
|
const SIGINT_EXIT_CODE = 130
|
|
14
10
|
|
|
@@ -45,66 +41,97 @@ async function main() {
|
|
|
45
41
|
process.stdout.write(HELP)
|
|
46
42
|
return
|
|
47
43
|
|
|
48
|
-
case 'login':
|
|
44
|
+
case 'login': {
|
|
45
|
+
const { runLogin } = await import('../src/commands/login.js')
|
|
46
|
+
|
|
49
47
|
await runLogin({
|
|
50
48
|
args: parsed.args,
|
|
51
49
|
token: Boolean(parsed.options.token),
|
|
52
50
|
verbose: Boolean(parsed.options.verbose),
|
|
53
51
|
})
|
|
54
52
|
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
case 'logout': {
|
|
56
|
+
const { runLogout } = await import('../src/commands/logout.js')
|
|
55
57
|
|
|
56
|
-
case 'logout':
|
|
57
58
|
await runLogout()
|
|
58
59
|
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
case 'list': {
|
|
63
|
+
const { runList } = await import('../src/commands/list.js')
|
|
59
64
|
|
|
60
|
-
case 'list':
|
|
61
65
|
await runList(parsed.options)
|
|
62
66
|
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case 'status': {
|
|
70
|
+
const { runStatus } = await import('../src/commands/status.js')
|
|
63
71
|
|
|
64
|
-
case 'status':
|
|
65
72
|
await runStatus(parsed.options)
|
|
66
73
|
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case 'config': {
|
|
77
|
+
const { runConfig } = await import('../src/commands/config.js')
|
|
67
78
|
|
|
68
|
-
case 'config':
|
|
69
79
|
await runConfig(parsed.args, parsed.options)
|
|
70
80
|
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
case 'token': {
|
|
84
|
+
const { runToken } = await import('../src/commands/token.js')
|
|
71
85
|
|
|
72
|
-
case 'token':
|
|
73
86
|
await runToken(parsed.args, parsed.options)
|
|
74
87
|
return
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'upload': {
|
|
91
|
+
const { runUpload } = await import('../src/commands/upload.js')
|
|
75
92
|
|
|
76
|
-
case 'upload':
|
|
77
93
|
await runUpload(parsed.args[0], parsed.options, {
|
|
78
94
|
onBackupId: (id) => {
|
|
79
95
|
currentBackupId = id
|
|
80
96
|
},
|
|
81
97
|
})
|
|
82
98
|
return
|
|
99
|
+
}
|
|
83
100
|
|
|
84
|
-
case 'restore':
|
|
101
|
+
case 'restore': {
|
|
85
102
|
if (!parsed.args[0]) {
|
|
86
103
|
throw new Error('Missing backup id. Example: npx telstore restore telstore-20260905-7f3a91')
|
|
87
104
|
}
|
|
105
|
+
|
|
106
|
+
const { runRestore } = await import('../src/commands/restore.js')
|
|
107
|
+
|
|
88
108
|
await runRestore(parsed.args[0], parsed.options)
|
|
89
109
|
return
|
|
110
|
+
}
|
|
90
111
|
|
|
91
|
-
case 'delete':
|
|
112
|
+
case 'delete': {
|
|
92
113
|
if (!parsed.args[0]) {
|
|
93
114
|
throw new Error('Missing backup id. Example: npx telstore delete telstore-20260905-7f3a91')
|
|
94
115
|
}
|
|
116
|
+
|
|
117
|
+
const { runDelete } = await import('../src/commands/delete.js')
|
|
118
|
+
|
|
95
119
|
await runDelete(parsed.args[0], parsed.options)
|
|
96
120
|
return
|
|
121
|
+
}
|
|
97
122
|
|
|
98
123
|
default:
|
|
99
124
|
throw new Error(`Unknown command: ${parsed.command}`)
|
|
100
125
|
}
|
|
101
126
|
}
|
|
102
127
|
|
|
103
|
-
// GramJS
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
128
|
+
// Written for GramJS, which kept "exported senders" alive behind a 30-second release timer
|
|
129
|
+
// that neither disconnect() nor destroy() could clear — both walked a Map with Object.values
|
|
130
|
+
// and missed every entry, so a command printed "Done" and then hung for half a minute, during
|
|
131
|
+
// which Ctrl-C falsely reported that nothing had been saved. teleproto replaced that pool
|
|
132
|
+
// wholesale and closes it on destroy(). This stays anyway: a CLI that has written its last
|
|
133
|
+
// line has nothing left to wait for, and one stray timer in any dependency is all it takes
|
|
134
|
+
// for the wait to come back.
|
|
108
135
|
function exitWhenFlushed(code) {
|
|
109
136
|
// The empty writes exist only to borrow their callbacks: they fire after everything
|
|
110
137
|
// queued earlier has flushed, so nothing is lost when stdout/stderr is not a TTY.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "telstore",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Split large files into chunks and store them on Telegram",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telegram",
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"test": "node --test 'test/**/*.test.js'"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"
|
|
40
|
+
"teleproto": "^1.229.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/caption.js
CHANGED
|
@@ -24,7 +24,7 @@ export function chunkCaption({ id, number, total }) {
|
|
|
24
24
|
|
|
25
25
|
export function manifestCaption({ id, name, size, chunks, createdAt }) {
|
|
26
26
|
return [
|
|
27
|
-
|
|
27
|
+
`📄 ${oneLine(name)}`,
|
|
28
28
|
`💾 ${formatBytes(size)} · ${chunks} chunk${chunks === 1 ? '' : 's'}`,
|
|
29
29
|
`🆔 ${id}`,
|
|
30
30
|
`📅 ${utcMinutes(createdAt)}`,
|
|
@@ -45,7 +45,7 @@ function marker(lines, emoji) {
|
|
|
45
45
|
export function parseManifestCaption(text) {
|
|
46
46
|
const lines = String(text ?? '').split('\n')
|
|
47
47
|
|
|
48
|
-
const name = marker(lines, '
|
|
48
|
+
const name = marker(lines, '📄')
|
|
49
49
|
const totals = marker(lines, '💾')
|
|
50
50
|
const id = marker(lines, '🆔')
|
|
51
51
|
const createdAt = marker(lines, '📅')
|
package/src/client.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { Api, TelegramClient } from '
|
|
2
|
-
import { Logger } from '
|
|
3
|
-
import { LogLevel } from '
|
|
4
|
-
import { StringSession } from '
|
|
1
|
+
import { Api, TelegramClient } from 'teleproto'
|
|
2
|
+
import { Logger } from 'teleproto/extensions/index.js'
|
|
3
|
+
import { LogLevel } from 'teleproto/extensions/Logger.js'
|
|
4
|
+
import { StringSession } from 'teleproto/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
|
+
import { assertLoggedIn, unlockConfig } from './session.js'
|
|
9
9
|
import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
|
|
10
10
|
|
|
11
|
-
//
|
|
11
|
+
// teleproto narrates its version, every connection and every disconnect at info level, and
|
|
12
12
|
// those timestamped lines land in the middle of the progress bar. The client reads this
|
|
13
13
|
// logger before it prints anything, so LogLevel.NONE silences all of it; --verbose asks
|
|
14
14
|
// for the running commentary back when a connection needs diagnosing.
|
|
@@ -58,7 +58,7 @@ export async function readMessageBytes(client, message) {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// The one place telstore removes messages from a chat, and the mirror of searchDocuments
|
|
61
|
-
// above.
|
|
61
|
+
// above. teleproto has its own deleteMessages, and it is the right thing to call — it resolves
|
|
62
62
|
// the peer and picks between channels.DeleteMessages and messages.DeleteMessages, which is
|
|
63
63
|
// exactly the choice a fake client would never catch us getting wrong.
|
|
64
64
|
//
|
|
@@ -87,7 +87,7 @@ export async function deleteMessages(client, peer, ids, options = {}) {
|
|
|
87
87
|
|
|
88
88
|
await withRetry(
|
|
89
89
|
() =>
|
|
90
|
-
// The options object is not optional:
|
|
90
|
+
// The options object is not optional: teleproto destructures `{ revoke }` with no
|
|
91
91
|
// default of its own, so a two-argument call throws a TypeError before it ever
|
|
92
92
|
// reaches the network. revoke is passed explicitly anyway — a backup has to go for
|
|
93
93
|
// everyone who can see the chat, and that intent belongs in our code rather than in
|
|
@@ -122,18 +122,6 @@ export async function closeQuietly(client, disconnect, onWarn) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
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.
|
|
129
|
-
export function assertLoggedIn(config) {
|
|
130
|
-
if (config.sealed) return
|
|
131
|
-
|
|
132
|
-
if (!config.session || !config.apiId || !config.apiHash) {
|
|
133
|
-
throw new Error('Not logged in — run "npx telstore login" first.')
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
125
|
// Every command that needs Telegram comes through here, which makes this the one place a
|
|
138
126
|
// sealed session has to be opened. Doing it anywhere else would mean eight places to keep in
|
|
139
127
|
// step, and a ninth command would simply forget.
|
package/src/commands/delete.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { chatName, describeChat } from '../chat.js'
|
|
2
2
|
import {
|
|
3
3
|
DELETE_BATCH_SIZE,
|
|
4
|
-
assertLoggedIn,
|
|
5
4
|
closeQuietly,
|
|
6
5
|
connect as realConnect,
|
|
7
6
|
deleteMessages as realDeleteMessages,
|
|
@@ -12,6 +11,7 @@ import { askConfirm } from '../confirm.js'
|
|
|
12
11
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
13
12
|
import { manifestFileName, manifestMessageIds, parseManifestJson } from '../manifest.js'
|
|
14
13
|
import { formatBytes, formatDuration } from '../progress.js'
|
|
14
|
+
import { assertLoggedIn } from '../session.js'
|
|
15
15
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
16
16
|
import { clearState, findStates } from '../state.js'
|
|
17
17
|
|
package/src/commands/list.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { MANIFEST_TAG, parseManifestCaption } from '../caption.js'
|
|
2
2
|
import { chatName, describeChat } from '../chat.js'
|
|
3
3
|
import {
|
|
4
|
-
assertLoggedIn,
|
|
5
4
|
closeQuietly,
|
|
6
5
|
connect as realConnect,
|
|
7
6
|
searchDocuments,
|
|
8
7
|
} from '../client.js'
|
|
9
8
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
9
|
+
import { assertLoggedIn } from '../session.js'
|
|
10
10
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
11
11
|
|
|
12
12
|
const UNKNOWN = '—'
|
package/src/commands/login.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { TelegramClient } from '
|
|
2
|
-
import { StringSession } from '
|
|
1
|
+
import { TelegramClient } from 'teleproto'
|
|
2
|
+
import { StringSession } from 'teleproto/sessions/index.js'
|
|
3
3
|
|
|
4
4
|
import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
|
|
5
5
|
import { normalizeChatTarget } from '../chat.js'
|
|
@@ -26,11 +26,12 @@ export function describeLoginError(err) {
|
|
|
26
26
|
return `${description} (${message})`
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// printed straight over the destination question login asks after signing in.
|
|
33
|
-
// command shuts down
|
|
29
|
+
// An update loop starts the moment a client connects, and destroy() is what puts the whole
|
|
30
|
+
// client down — the sender pools included. Under GramJS, disconnect() alone left that loop
|
|
31
|
+
// pinging a closed socket, every ping failing with "Error: TIMEOUT" and asking for a
|
|
32
|
+
// reconnect, printed straight over the destination question login asks after signing in.
|
|
33
|
+
// Every command shuts down through destroy() for that reason, and login is the seam tests
|
|
34
|
+
// need to reach it.
|
|
34
35
|
const createTelegramClient = (apiId, apiHash, options) =>
|
|
35
36
|
new TelegramClient(new StringSession(''), apiId, apiHash, options)
|
|
36
37
|
|
package/src/commands/status.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { countChunks } from '../chunking.js'
|
|
2
2
|
import { describeChat } from '../chat.js'
|
|
3
|
-
import {
|
|
3
|
+
import { closeQuietly, connect as realConnect } from '../client.js'
|
|
4
4
|
import { configFile, defaultConfigDir, loadConfig } from '../config.js'
|
|
5
5
|
import { formatBytes } from '../progress.js'
|
|
6
|
+
import { assertLoggedIn } from '../session.js'
|
|
6
7
|
import { resolveSettings } from '../settings.js'
|
|
7
8
|
import { canResume, listStates } from '../state.js'
|
|
8
9
|
|
package/src/commands/token.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { assertLoggedIn } from '../client.js'
|
|
2
1
|
import { defaultConfigDir, loadConfig } from '../config.js'
|
|
3
2
|
import { createPrompts } from '../prompt.js'
|
|
4
|
-
import { unlockConfig } from '../session.js'
|
|
3
|
+
import { assertLoggedIn, unlockConfig } from '../session.js'
|
|
5
4
|
import { knownSettings } from '../settings.js'
|
|
6
5
|
import { encodeToken } from '../token.js'
|
|
7
6
|
|
package/src/commands/upload.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { Api } from '
|
|
5
|
-
import { CustomFile } from '
|
|
4
|
+
import { Api } from 'teleproto'
|
|
5
|
+
import { CustomFile } from 'teleproto/client/uploads.js'
|
|
6
6
|
|
|
7
7
|
import { PART_SIZE, planChunks } from '../chunking.js'
|
|
8
8
|
import { chunkCaption, manifestCaption } from '../caption.js'
|
package/src/downloader.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { read as readCallback, write as writeCallback } from 'node:fs'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
|
|
5
|
-
import { returnBigInt } from '
|
|
5
|
+
import { returnBigInt } from 'teleproto/Helpers.js'
|
|
6
6
|
|
|
7
7
|
import { DEFAULT_DOWNLOAD_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
|
|
8
8
|
import { withRetry } from './retry.js'
|
|
@@ -105,10 +105,14 @@ export async function downloadToFile(
|
|
|
105
105
|
// Iterated by hand rather than with `for await` so each part can be given a deadline:
|
|
106
106
|
// a stalled stream yields nothing and raises nothing, and only a race against a timer
|
|
107
107
|
// turns that silence into an error withRetry can act on. Nothing is lost by stepping
|
|
108
|
-
// outside `for await` —
|
|
108
|
+
// outside `for await` — teleproto's download iterator exposes `next` alone, so breaking
|
|
109
109
|
// out of the loop never closed anything either.
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
//
|
|
111
|
+
// The media goes in its own argument, not inside the options: teleproto takes
|
|
112
|
+
// `iterDownload(file, params)` where GramJS took a single `{ file, ... }` object, and
|
|
113
|
+
// the options object arriving where the file belongs is not something a fake client
|
|
114
|
+
// would ever refuse — it fails only against the real one, at getFileInfo.
|
|
115
|
+
const stream = client.iterDownload(message.media, {
|
|
112
116
|
offset: returnBigInt(start + done),
|
|
113
117
|
requestSize: PART_SIZE,
|
|
114
118
|
})
|
package/src/retry.js
CHANGED
|
@@ -10,8 +10,14 @@ function defaultSleep(ms) {
|
|
|
10
10
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// Every "wait this long" answer Telegram gives arrives as code 420 with the seconds on the
|
|
14
|
+
// error itself — FLOOD_WAIT_n, SLOW_MODE_WAIT_n and the rest. Those two fields are what this
|
|
15
|
+
// reads, not how the message is spelled: under GramJS every flood error carried the literal
|
|
16
|
+
// errorMessage "FLOOD", so a check for "FLOOD_WAIT" in it matched nothing and each one was
|
|
17
|
+
// retried on the ordinary backoff — asking again inside a ban that was still running.
|
|
18
|
+
// A 420 with no seconds (a frozen account) has nothing to wait for and takes the backoff.
|
|
13
19
|
function floodWaitSeconds(err) {
|
|
14
|
-
if (
|
|
20
|
+
if (err?.code === 420 && typeof err.seconds === 'number') {
|
|
15
21
|
return err.seconds
|
|
16
22
|
}
|
|
17
23
|
return null
|
package/src/session.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readSecret as realReadSecret } from './prompt.js'
|
|
2
2
|
import { decodeToken } from './token.js'
|
|
3
3
|
|
|
4
|
-
// How a stored config becomes the three things
|
|
4
|
+
// How a stored config becomes the three things teleproto needs. There are two shapes on disk —
|
|
5
5
|
// the ordinary login, and the sealed blob `login --token` writes — and this is the one place
|
|
6
6
|
// that knows the difference, so `connect` stays about Telegram and the commands stay about
|
|
7
7
|
// their own narrative.
|
|
@@ -22,3 +22,16 @@ export async function unlockConfig(config, { readSecret = realReadSecret } = {})
|
|
|
22
22
|
|
|
23
23
|
return { apiId, apiHash, session }
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
// Two shapes count as logged in: the ordinary one login writes, and the sealed blob that
|
|
27
|
+
// "login --token" leaves, which holds the same three fields behind a passphrase. It lives
|
|
28
|
+
// here rather than beside connect because knowing both shapes is this file's whole job, and
|
|
29
|
+
// because `token` is an offline command: reaching for it through src/client.js pulled the
|
|
30
|
+
// whole of teleproto into a run that never opens a socket.
|
|
31
|
+
export function assertLoggedIn(config) {
|
|
32
|
+
if (config.sealed) return
|
|
33
|
+
|
|
34
|
+
if (!config.session || !config.apiId || !config.apiHash) {
|
|
35
|
+
throw new Error('Not logged in — run "npx telstore login" first.')
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/stall.js
CHANGED
|
@@ -10,16 +10,18 @@ const MAX_TIMER_MS = 2 ** 31 - 1
|
|
|
10
10
|
// A request that never comes back is not the same as one that fails, and only one of the
|
|
11
11
|
// two is something withRetry can do anything about.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// `
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// queue with no send loop left to drain it (network/MTProtoSender.js:148,795).
|
|
13
|
+
// This was written for a GramJS bug that abandoned requests outright: its abort path tested
|
|
14
|
+
// `_currentRetries > _reconnectRetries` against a `reconnectRetries` of Infinity and so never
|
|
15
|
+
// fired, and `_reconnect()` read a `connect()` that merely returned false as success. A real
|
|
16
|
+
// network cut mid-restore froze a transfer for eleven minutes on Linux and ended it without a
|
|
17
|
+
// printed line on Windows. teleproto closes both halves — `connect()` throws once its attempts
|
|
18
|
+
// are spent, and `_reconnect()` catches that and rejects every pending request.
|
|
20
19
|
//
|
|
21
|
-
// The
|
|
22
|
-
//
|
|
20
|
+
// The deadline stays because the library was never the only way to arrive here. A server that
|
|
21
|
+
// accepts a request and answers nothing, a socket that stays open with nothing coming down it:
|
|
22
|
+
// there is no failure for withRetry to see, only silence. The timer is what turns that silence
|
|
23
|
+
// into an error — and, deliberately not unref'd, it is also the handle that stops the event
|
|
24
|
+
// loop running dry and ending a transfer without a word.
|
|
23
25
|
export async function withStallTimeout(promise, ms, describe) {
|
|
24
26
|
if (!(ms > 0)) return await promise
|
|
25
27
|
|
package/src/token.js
CHANGED
|
@@ -46,7 +46,7 @@ function isPlainObject(value) {
|
|
|
46
46
|
// A token is untrusted input in the same category as a manifest or a state file. Opening one
|
|
47
47
|
// proves whoever made it knew the passphrase, not that they made it correctly — and the
|
|
48
48
|
// unprotected format proves nothing at all. An apiId of undefined does not throw here; it
|
|
49
|
-
// reaches
|
|
49
|
+
// reaches teleproto and fails much later as something that reads like a network problem.
|
|
50
50
|
export function checkTokenBundle(bundle) {
|
|
51
51
|
if (!isPlainObject(bundle)) {
|
|
52
52
|
throw new Error(
|
package/src/uploader.js
CHANGED
|
@@ -2,8 +2,8 @@ import { createHash, randomBytes } from 'node:crypto'
|
|
|
2
2
|
import { read as readCallback } from 'node:fs'
|
|
3
3
|
import { promisify } from 'node:util'
|
|
4
4
|
|
|
5
|
-
import { Api } from '
|
|
6
|
-
import { readBigIntFromBuffer } from '
|
|
5
|
+
import { Api } from 'teleproto'
|
|
6
|
+
import { readBigIntFromBuffer } from 'teleproto/Helpers.js'
|
|
7
7
|
|
|
8
8
|
import { DEFAULT_UPLOAD_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
|
|
9
9
|
import { withRetry } from './retry.js'
|
|
@@ -12,8 +12,8 @@ import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
|
|
|
12
12
|
const read = promisify(readCallback)
|
|
13
13
|
|
|
14
14
|
// Telegram splits its upload API by file size: only files above 10MB may use the
|
|
15
|
-
// "big" family.
|
|
16
|
-
// node_modules/
|
|
15
|
+
// "big" family. teleproto picks the same threshold (LARGE_FILE_THRESHOLD in
|
|
16
|
+
// node_modules/teleproto/client/uploads.js), and telstore follows it.
|
|
17
17
|
export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
|
|
18
18
|
|
|
19
19
|
async function readExactly(fd, length, position) {
|
|
@@ -105,9 +105,9 @@ export async function uploadRange(client, fd, options) {
|
|
|
105
105
|
sending.push(
|
|
106
106
|
withRetry(
|
|
107
107
|
() =>
|
|
108
|
-
// Same exposure as the download path: a request
|
|
109
|
-
//
|
|
110
|
-
//
|
|
108
|
+
// Same exposure as the download path: a request the server accepts and never
|
|
109
|
+
// answers settles neither way, so without a deadline this await would hold the
|
|
110
|
+
// batch open forever and the upload would end without a word.
|
|
111
111
|
withStallTimeout(
|
|
112
112
|
client.invoke(partRequest(part, bytes)),
|
|
113
113
|
stallMs,
|