telstore 0.1.0
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/LICENSE +21 -0
- package/README.md +152 -0
- package/bin/telark.js +121 -0
- package/package.json +42 -0
- package/src/caption.js +63 -0
- package/src/chat.js +42 -0
- package/src/chunking.js +92 -0
- package/src/cli.js +161 -0
- package/src/client.js +146 -0
- package/src/commands/config.js +130 -0
- package/src/commands/delete.js +250 -0
- package/src/commands/list.js +119 -0
- package/src/commands/login.js +109 -0
- package/src/commands/logout.js +13 -0
- package/src/commands/restore.js +215 -0
- package/src/commands/status.js +102 -0
- package/src/commands/upload.js +327 -0
- package/src/config.js +86 -0
- package/src/confirm.js +11 -0
- package/src/downloader.js +190 -0
- package/src/manifest.js +163 -0
- package/src/progress.js +100 -0
- package/src/retry.js +57 -0
- package/src/settings.js +190 -0
- package/src/stall.js +63 -0
- package/src/state.js +151 -0
- package/src/uploader.js +155 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shovity
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# telark
|
|
2
|
+
|
|
3
|
+
Split large files into 1.8GB chunks, store them on Telegram, and restore them intact.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx telark login # once only
|
|
9
|
+
npx telark config chat @my_backups # where backups go, from now on
|
|
10
|
+
npx telark data.tar # split it and send it there
|
|
11
|
+
npx telark data.tar --to @somewhere # somewhere else, this run only
|
|
12
|
+
npx telark config # every setting and where its value comes from
|
|
13
|
+
npx telark status # account, destination, unfinished backups
|
|
14
|
+
npx telark list # what is already stored in the destination
|
|
15
|
+
npx telark restore telark-20260905-7f3a91
|
|
16
|
+
npx telark delete telark-20260905-7f3a91 # take it back out of the chat, for good
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What you need
|
|
20
|
+
|
|
21
|
+
- Node.js 18 or newer.
|
|
22
|
+
- 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
|
+
telark 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
|
+
## Settings and flags
|
|
27
|
+
|
|
28
|
+
There are two ways to say what telark should do, and they never overlap. **`config` writes;
|
|
29
|
+
flags do not.** A flag applies to the run you typed it on and changes nothing on disk, so
|
|
30
|
+
`--to @elsewhere` sends one backup elsewhere without moving the destination for the next one.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx telark config # everything, and whether it is yours or a default
|
|
34
|
+
npx telark config chat # one value, bare, ready to pipe
|
|
35
|
+
npx telark config chunkSize 500MB # change it for good
|
|
36
|
+
npx telark config chunkSize --unset # back to the default
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Setting | Flag | Default | Meaning |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| `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
|
+
| `chunkSize` | `--chunk-size` | `1800MB` | e.g. `1.8GB`, `500MB`. Hard ceiling 1950MB. An unfinished backup keeps the size it started with. |
|
|
43
|
+
| `concurrency` | `--concurrency` | `8` | 512KB parts sent in parallel. An integer from 1 to 64. Upload only — `restore` downloads through its own fixed pool of workers. |
|
|
44
|
+
| `limit` | `--limit` | `20` | How many backups `list` shows, newest first |
|
|
45
|
+
| `verbose` | `--verbose` | off | Show the Telegram client's own connection logs, hidden by default so they do not break up the progress bar |
|
|
46
|
+
|
|
47
|
+
`--yes` has no setting either — it answers the confirmation `delete` asks before destroying
|
|
48
|
+
a backup, and an answer stored in a file would be an answer to a question nobody heard.
|
|
49
|
+
|
|
50
|
+
`--out <path>` has no setting: it names where one particular restore should write, and
|
|
51
|
+
defaults to the basename in the manifest. Relative paths resolve against the current directory.
|
|
52
|
+
|
|
53
|
+
`config` reads and writes only settings — the `api_id`, `api_hash` and session that `login`
|
|
54
|
+
stores in the same file are not reachable from it. A value is checked before it is written,
|
|
55
|
+
so `config chunkSize 9GB` is refused there and then rather than at the start of a long upload.
|
|
56
|
+
|
|
57
|
+
## What the chat looks like
|
|
58
|
+
|
|
59
|
+
Every chunk goes up as a document captioned `📦 <backupId> · 3/12`, and the manifest that
|
|
60
|
+
follows carries a summary card:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
🗄 data.tar
|
|
64
|
+
━━━━━━━━━━━━━━━
|
|
65
|
+
💾 21.4 GB · 12 chunks
|
|
66
|
+
🆔 telark-20260905-7f3a91
|
|
67
|
+
📅 2026-09-05 16:40 UTC
|
|
68
|
+
|
|
69
|
+
↩ npx telark restore telark-20260905-7f3a91
|
|
70
|
+
#telark
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`npx telark list` reads those cards straight out of the chat — one search, no downloads —
|
|
74
|
+
and lays them out as a table:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Destination https://web.telegram.org/k/#@my_backups
|
|
78
|
+
|
|
79
|
+
BACKUP ID FILE SIZE CHUNKS CREATED
|
|
80
|
+
telark-20260905-7f3a91 data.tar 21.4 GB 12 2026-09-05
|
|
81
|
+
telark-20260901-9de447 photos.zip 940.3 MB 1 2026-09-01
|
|
82
|
+
|
|
83
|
+
2 backups. Restore with: npx telark restore <backup-id>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A backup uploaded before the card existed still gets a row, with dashes where the caption
|
|
87
|
+
says nothing — `list` reports what the chat holds and never fills gaps with guesses.
|
|
88
|
+
|
|
89
|
+
## How it works
|
|
90
|
+
|
|
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, telark 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
|
+
|
|
93
|
+
If the connection drops during an **upload**, just run the same command again — progress lives in `~/.telark/state/` and finished chunks are skipped, keeping the same `backupId`. Two things to know about rerunning:
|
|
94
|
+
|
|
95
|
+
- Running again against a destination that differs from the one in the unfinished progress makes telark **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
|
+
- 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 telark **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
|
+
|
|
99
|
+
`Ctrl-C` during an upload names the backup it was working on, so `telark status` and a later `restore` have something to go on. telark keeps the **20 most recent** unfinished backups in `~/.telark/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
|
+
|
|
101
|
+
**Restore keeps no state to resume from.** Pressing `Ctrl-C` mid-restore saves nothing — running again starts over.
|
|
102
|
+
|
|
103
|
+
## Deleting a backup
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npx telark delete telark-20260905-7f3a91
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
It prints what it is about to destroy, asks once, and then removes every chunk message and
|
|
110
|
+
the manifest from the chat and drops the local record if there is one. `--yes` skips the
|
|
111
|
+
question. **There is no undo** — Telegram is the only copy.
|
|
112
|
+
|
|
113
|
+
It also works on a backup that never finished: those have chunks in the chat but no manifest,
|
|
114
|
+
so `list` cannot see them and only `status` knows they exist. `delete` reads the local record
|
|
115
|
+
instead and clears both.
|
|
116
|
+
|
|
117
|
+
The chunks go first and the manifest goes last, deliberately. The manifest is the only list
|
|
118
|
+
of the message ids, so if a delete is interrupted — Ctrl-C, a dropped connection — running
|
|
119
|
+
the same command again finds that list still there and finishes the job. Telegram says
|
|
120
|
+
nothing about an id that is already gone, so a second run costs nothing. In between the two
|
|
121
|
+
runs the backup still shows up in `list`, and a `restore` of it fails loudly rather than
|
|
122
|
+
handing over a partial file.
|
|
123
|
+
|
|
124
|
+
Two things it refuses rather than guesses at: a manifest whose body names a *different*
|
|
125
|
+
backup (a file renamed in the chat — its message ids point at somebody else's chunks), and a
|
|
126
|
+
manifest or local record giving a message id that is not a whole positive number. Neither
|
|
127
|
+
deletes anything at all. A manifest too damaged for `restore` to use *can* still be deleted —
|
|
128
|
+
that is usually the one you want gone.
|
|
129
|
+
|
|
130
|
+
## Limits worth knowing
|
|
131
|
+
|
|
132
|
+
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file, an arithmetic ceiling of about 1953MB, and telark stops at 1950MB to leave a safety margin.
|
|
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 telark delete <backup-id>` when that is what you actually want.
|
|
135
|
+
- Keep the `backupId`. Without it you have to hunt for the manifest in the chat by hand.
|
|
136
|
+
|
|
137
|
+
## Where the config lives
|
|
138
|
+
|
|
139
|
+
`~/.telark/config.json` (mode 600) holds `apiId`, `apiHash` and the session at the top level, with everything `config` manages under `settings`:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"apiId": 123456,
|
|
144
|
+
"apiHash": "…",
|
|
145
|
+
"session": "…",
|
|
146
|
+
"settings": { "chat": "@my_backups", "chunkSize": 524288000 }
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
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
|
+
|
|
152
|
+
`npx telark 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/telark.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { route, HELP, interruptMessage } from '../src/cli.js'
|
|
3
|
+
import { runLogin } from '../src/commands/login.js'
|
|
4
|
+
import { runConfig } from '../src/commands/config.js'
|
|
5
|
+
import { runDelete } from '../src/commands/delete.js'
|
|
6
|
+
import { runList } from '../src/commands/list.js'
|
|
7
|
+
import { runLogout } from '../src/commands/logout.js'
|
|
8
|
+
import { runRestore } from '../src/commands/restore.js'
|
|
9
|
+
import { runStatus } from '../src/commands/status.js'
|
|
10
|
+
import { runUpload } from '../src/commands/upload.js'
|
|
11
|
+
|
|
12
|
+
const SIGINT_EXIT_CODE = 130
|
|
13
|
+
|
|
14
|
+
// Which command is running when Ctrl-C arrives — each one tells a different truth
|
|
15
|
+
// about whether progress was saved, so we need to know which to pick the right line.
|
|
16
|
+
// The backup id arrives a moment later, once upload knows which backup this run is.
|
|
17
|
+
let currentCommand = null
|
|
18
|
+
let currentBackupId = null
|
|
19
|
+
|
|
20
|
+
process.on('SIGINT', () => {
|
|
21
|
+
process.stderr.write(interruptMessage(currentCommand, { backupId: currentBackupId }))
|
|
22
|
+
process.exit(SIGINT_EXIT_CODE)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
let parsed
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
parsed = route(process.argv.slice(2))
|
|
30
|
+
} catch (err) {
|
|
31
|
+
process.stderr.write(`Error: ${err.message}\n\n${HELP}`)
|
|
32
|
+
process.exitCode = 2
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
currentCommand = parsed.command
|
|
37
|
+
|
|
38
|
+
switch (parsed.command) {
|
|
39
|
+
case 'help':
|
|
40
|
+
process.stdout.write(HELP)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
case 'login':
|
|
44
|
+
await runLogin({ verbose: Boolean(parsed.options.verbose) })
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
case 'logout':
|
|
48
|
+
await runLogout()
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
case 'list':
|
|
52
|
+
await runList(parsed.options)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
case 'status':
|
|
56
|
+
await runStatus(parsed.options)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
case 'config':
|
|
60
|
+
await runConfig(parsed.args, parsed.options)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
case 'upload':
|
|
64
|
+
await runUpload(parsed.args[0], parsed.options, {
|
|
65
|
+
onBackupId: (id) => {
|
|
66
|
+
currentBackupId = id
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
case 'restore':
|
|
72
|
+
if (!parsed.args[0]) {
|
|
73
|
+
throw new Error('Missing backup id. Example: npx telark restore telark-20260905-7f3a91')
|
|
74
|
+
}
|
|
75
|
+
await runRestore(parsed.args[0], parsed.options)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
case 'delete':
|
|
79
|
+
if (!parsed.args[0]) {
|
|
80
|
+
throw new Error('Missing backup id. Example: npx telark delete telark-20260905-7f3a91')
|
|
81
|
+
}
|
|
82
|
+
await runDelete(parsed.args[0], parsed.options)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
default:
|
|
86
|
+
throw new Error(`Unknown command: ${parsed.command}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// GramJS keeps "exported senders" around together with a 30-second timer to release
|
|
91
|
+
// them, and neither client.disconnect() nor destroy() cleans them up: both of those
|
|
92
|
+
// maps are Maps, but the code walks them with Object.values and so misses everything.
|
|
93
|
+
// The result is a command that prints "Done" and then hangs for another ~30 seconds,
|
|
94
|
+
// during which Ctrl-C falsely reports that nothing was saved. Finish the work, exit.
|
|
95
|
+
function exitWhenFlushed(code) {
|
|
96
|
+
// The empty writes exist only to borrow their callbacks: they fire after everything
|
|
97
|
+
// queued earlier has flushed, so nothing is lost when stdout/stderr is not a TTY.
|
|
98
|
+
let pending = 2
|
|
99
|
+
|
|
100
|
+
const done = () => {
|
|
101
|
+
pending -= 1
|
|
102
|
+
if (pending === 0) process.exit(code)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Safety net: exit anyway if a callback never arrives (the pipe is already closed).
|
|
106
|
+
setTimeout(() => process.exit(code), 2000).unref()
|
|
107
|
+
|
|
108
|
+
process.stdout.write('', done)
|
|
109
|
+
process.stderr.write('', done)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
main().then(
|
|
113
|
+
() => {
|
|
114
|
+
exitWhenFlushed(process.exitCode ?? 0)
|
|
115
|
+
},
|
|
116
|
+
(err) => {
|
|
117
|
+
process.stderr.write(`Error: ${err.message}\n`)
|
|
118
|
+
process.exitCode = 1
|
|
119
|
+
exitWhenFlushed(1)
|
|
120
|
+
},
|
|
121
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "telstore",
|
|
3
|
+
"version": "0.1.0",
|
|
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/telark#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/shovity/telark/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/shovity/telark.git"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "shovity",
|
|
25
|
+
"bin": {
|
|
26
|
+
"telark": "bin/telark.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin",
|
|
30
|
+
"src",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test 'test/**/*.test.js'"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"telegram": "^2.26.22"
|
|
41
|
+
}
|
|
42
|
+
}
|
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 = '#telark'
|
|
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 telark 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/chat.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// How telark talks about a destination. None of this touches Telegram — it is string
|
|
2
|
+
// handling around a target the user typed — so it lives apart from the client that does.
|
|
3
|
+
|
|
4
|
+
export function normalizeChatTarget(input) {
|
|
5
|
+
const text = String(input).trim()
|
|
6
|
+
|
|
7
|
+
if (text === '') {
|
|
8
|
+
throw new Error('Destination must not be empty.')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (/^-?\d+$/.test(text)) {
|
|
12
|
+
return Number(text)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return text
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Telegram's web client addresses a chat by putting the raw target in the fragment, which
|
|
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 telark does not know, so it gets no link
|
|
21
|
+
// rather than a guessed one that lands somewhere else.
|
|
22
|
+
export function chatUrl(chat) {
|
|
23
|
+
const text = String(chat)
|
|
24
|
+
|
|
25
|
+
if (text === 'me') return null
|
|
26
|
+
|
|
27
|
+
return `https://web.telegram.org/k/#${text}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// How a destination is spoken about. "me" is a target, not a name someone would recognise
|
|
31
|
+
// in a sentence, so every command that mentions a chat in prose goes through here.
|
|
32
|
+
export function chatName(chat) {
|
|
33
|
+
return String(chat) === 'me' ? 'Saved Messages' : String(chat)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A destination is worth more as something clickable than as a raw id, but Saved Messages
|
|
37
|
+
// has no link to give, so it is named instead of being dressed up as one.
|
|
38
|
+
export function describeChat(chat) {
|
|
39
|
+
const url = chatUrl(chat)
|
|
40
|
+
|
|
41
|
+
return url ?? `${chat} (${chatName(chat)})`
|
|
42
|
+
}
|
package/src/chunking.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const PART_SIZE = 512 * 1024
|
|
2
|
+
export const MAX_PARTS = 4000
|
|
3
|
+
|
|
4
|
+
// One slice is one short iterDownload stream. Small enough that a failed slice costs little
|
|
5
|
+
// and that there are far more slices than workers for the pool to balance across; big enough
|
|
6
|
+
// that the per-stream setup disappears against 16 parts of payload.
|
|
7
|
+
export const SLICE_SIZE = 8 * 1024 * 1024
|
|
8
|
+
export const MAX_CHUNK_SIZE = 1950 * 1024 * 1024
|
|
9
|
+
export const DEFAULT_CHUNK_SIZE = 1800 * 1024 * 1024
|
|
10
|
+
export const DEFAULT_CONCURRENCY = 8
|
|
11
|
+
export const MAX_CONCURRENCY = 64
|
|
12
|
+
|
|
13
|
+
// Every chunk is one message in the chat and one entry in the manifest, so a plan this long
|
|
14
|
+
// describes a backup nobody could use. At the 1950MB ceiling it allows a 19TB file; the only
|
|
15
|
+
// way to reach it is a chunk size picked by mistake, and reaching it by mistake means asking
|
|
16
|
+
// the loop below for billions of objects — an out-of-memory crash rather than an answer.
|
|
17
|
+
export const MAX_CHUNKS = 10_000
|
|
18
|
+
|
|
19
|
+
const UNITS = {
|
|
20
|
+
b: 1,
|
|
21
|
+
kb: 1024,
|
|
22
|
+
mb: 1024 * 1024,
|
|
23
|
+
gb: 1024 * 1024 * 1024,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseSize(input) {
|
|
27
|
+
const text = String(input).trim().toLowerCase()
|
|
28
|
+
const match = text.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/)
|
|
29
|
+
|
|
30
|
+
if (!match) {
|
|
31
|
+
throw new Error(`Invalid size: "${input}". Valid examples: 1800MB, 1.8GB, 524288.`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const bytes = Math.floor(Number(match[1]) * UNITS[match[2] ?? 'b'])
|
|
35
|
+
|
|
36
|
+
if (bytes <= 0) {
|
|
37
|
+
throw new Error('Size must be greater than 0.')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (bytes > MAX_CHUNK_SIZE) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'Maximum chunk size is 1950MB. Telegram accepts only 4000 parts of 512KB per file, ' +
|
|
43
|
+
'an arithmetic ceiling of about 1953MB, so a safety margin is needed.',
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return bytes
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// How many chunks a file of this size splits into. planChunks builds them and parseManifest
|
|
51
|
+
// checks them against this same rule, so the layout has one definition, not three.
|
|
52
|
+
export function countChunks(fileSize, chunkSize) {
|
|
53
|
+
return Math.ceil(fileSize / chunkSize)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function planChunks(fileSize, chunkSize) {
|
|
57
|
+
if (fileSize <= 0) {
|
|
58
|
+
throw new Error('File is empty, nothing to upload.')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `offset += chunkSize` has to advance or the loop below never reaches fileSize. Anything
|
|
62
|
+
// that is not a whole number above zero either stands still or walks backwards, and the
|
|
63
|
+
// caller gets a process that spins until it runs out of memory instead of an error. This
|
|
64
|
+
// is reachable: an upload resuming from a state file takes chunkSize straight off disk.
|
|
65
|
+
if (!Number.isSafeInteger(chunkSize) || chunkSize < 1) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Chunk size must be a whole number of bytes above zero, got: ${JSON.stringify(chunkSize)}.`,
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!Number.isSafeInteger(fileSize)) {
|
|
72
|
+
throw new Error(`File size must be a whole number of bytes, got: ${JSON.stringify(fileSize)}.`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Counted before anything is built, so an impossible plan costs no memory.
|
|
76
|
+
const count = countChunks(fileSize, chunkSize)
|
|
77
|
+
|
|
78
|
+
if (count > MAX_CHUNKS) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Splitting ${fileSize} bytes into ${chunkSize}-byte chunks needs ${count} chunks, ` +
|
|
81
|
+
`and a backup holds at most ${MAX_CHUNKS} chunks.`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const chunks = []
|
|
86
|
+
|
|
87
|
+
for (let offset = 0, i = 0; offset < fileSize; offset += chunkSize, i += 1) {
|
|
88
|
+
chunks.push({ i, offset, length: Math.min(chunkSize, fileSize - offset) })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return chunks
|
|
92
|
+
}
|