telstore 0.1.7 โ 0.1.9
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 +46 -6
- package/bin/telstore.js +28 -6
- package/package.json +1 -1
- package/src/cli.js +46 -4
- package/src/client.js +162 -9
- package/src/commands/delete.js +36 -3
- package/src/commands/list.js +200 -16
- package/src/commands/restore.js +179 -49
- package/src/commands/status.js +77 -10
- package/src/commands/verify.js +298 -0
- package/src/downloader.js +4 -1
- package/src/manifest.js +6 -1
- package/src/progress.js +1 -0
- package/src/state.js +161 -45
package/README.md
CHANGED
|
@@ -28,8 +28,9 @@ npx telstore restore telstore-20260905-7f3a91
|
|
|
28
28
|
| `telstore <file\|folder\|pattern>...` | Split each file and upload it. Prints the `backupId` you restore with. |
|
|
29
29
|
| `telstore list` | The backups stored in the destination, newest first. |
|
|
30
30
|
| `telstore restore <backup-id>...` | Download every chunk and reassemble the file. Several ids run one after another. |
|
|
31
|
+
| `telstore verify <backup-id>...` | Check that every chunk of a backup is still in the chat. Downloads nothing. |
|
|
31
32
|
| `telstore delete <backup-id>...` | Remove a backup's chunks and manifest from the chat, for good. Several ids are listed and confirmed once. |
|
|
32
|
-
| `telstore status` | Account, destination, and unfinished
|
|
33
|
+
| `telstore status` | Account, destination, and unfinished uploads and restores. |
|
|
33
34
|
| `telstore config` | Show or change settings. |
|
|
34
35
|
| `telstore token` | Print a session token for a machine you do not trust. |
|
|
35
36
|
| `telstore logout` | Remove the locally stored session. |
|
|
@@ -65,7 +66,7 @@ a prompt that does not echo.
|
|
|
65
66
|
|
|
66
67
|
Every chunk goes up as a document captioned `๐ฆ <backupId> ยท 3/12`, followed by a manifest
|
|
67
68
|
carrying a summary card โ file name, size, id, date and the restore command. `list` reads
|
|
68
|
-
those cards straight out of the chat,
|
|
69
|
+
those cards straight out of the chat, no downloads:
|
|
69
70
|
|
|
70
71
|
```
|
|
71
72
|
Destination https://web.telegram.org/k/#@my_backups
|
|
@@ -117,13 +118,44 @@ it, and the run ends with a line per file and a non-zero exit code:
|
|
|
117
118
|
c.tar telstore-20260905-9de447 (1 chunk)
|
|
118
119
|
```
|
|
119
120
|
|
|
121
|
+
## Checking a backup is still there
|
|
122
|
+
|
|
123
|
+
A backup is a set of messages in a chat, and messages can be deleted by hand. `list` reads
|
|
124
|
+
the manifest's card and would happily show a backup whose chunks are long gone; `verify` asks
|
|
125
|
+
the chat about every chunk the manifest names:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
$ npx telstore verify telstore-20260905-7f3a91
|
|
129
|
+
Backup telstore-20260905-7f3a91
|
|
130
|
+
File data.tar (21.4 GB, 12 chunks)
|
|
131
|
+
In https://web.telegram.org/k/#@my_backups
|
|
132
|
+
|
|
133
|
+
12 chunks present, at the sizes the manifest records.
|
|
134
|
+
This does not download them, so it cannot prove their contents.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
It costs one request per hundred chunks and no bandwidth, so it is cheap enough to run on a
|
|
138
|
+
schedule. What it proves is that a restore would find everything it needs โ every chunk still
|
|
139
|
+
there, under the file name telstore wrote, at the length the manifest records. It does not
|
|
140
|
+
read the chunks, so it cannot speak for what is inside them; only a restore does that, and a
|
|
141
|
+
restore checks every sha256 before it renames anything into place.
|
|
142
|
+
|
|
143
|
+
A backup missing chunks is named line by line, and the exit code is 1:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
Chunk 3/12 is gone: message 1042 is no longer in @my_backups.
|
|
147
|
+
|
|
148
|
+
12 chunks checked, 1 damaged. This backup cannot be restored.
|
|
149
|
+
```
|
|
150
|
+
|
|
120
151
|
## Several backups at once
|
|
121
152
|
|
|
122
|
-
`restore` and `delete` take a list of ids the same way, over one connection, with a
|
|
123
|
-
and a non-zero exit code if any of them failed:
|
|
153
|
+
`restore`, `verify` and `delete` take a list of ids the same way, over one connection, with a
|
|
154
|
+
summary and a non-zero exit code if any of them failed:
|
|
124
155
|
|
|
125
156
|
```bash
|
|
126
157
|
npx telstore restore telstore-20260905-7f3a91 telstore-20260901-9de447
|
|
158
|
+
npx telstore verify telstore-20260905-7f3a91 telstore-20260901-9de447
|
|
127
159
|
npx telstore delete telstore-20260905-7f3a91 telstore-20260901-9de447
|
|
128
160
|
```
|
|
129
161
|
|
|
@@ -143,7 +175,14 @@ After a batch, run telstore again with **only the files that are left**: the fin
|
|
|
143
175
|
have had their records cleared, so repeating the whole command would upload them a second
|
|
144
176
|
time as new backups. `npx telstore status` lists what is unfinished.
|
|
145
177
|
|
|
146
|
-
|
|
178
|
+
## Resuming a restore
|
|
179
|
+
|
|
180
|
+
`Ctrl-C` mid-restore keeps the `<target>.partial` file rather than throwing it away. Running
|
|
181
|
+
the same command again hashes each chunk-sized region of it against the manifest, in order,
|
|
182
|
+
and carries on from the first one that does not match โ nothing already on disk is trusted
|
|
183
|
+
just because it is there. `npx telstore status` lists unfinished restores alongside
|
|
184
|
+
unfinished uploads, with a resume command for each.
|
|
185
|
+
|
|
147
186
|
And `delete` has **no undo**: Telegram is the only copy.
|
|
148
187
|
|
|
149
188
|
## Running on a machine you do not trust
|
|
@@ -173,7 +212,8 @@ There is no expiry and no revocation: to end a session for good, terminate it un
|
|
|
173
212
|
protects your login rather than your files.
|
|
174
213
|
- A chunk cannot exceed 1950MB: Telegram accepts at most 4000 parts of 512KB per file.
|
|
175
214
|
- Deleting a chunk message in the Telegram app destroys the backup, and keeping the
|
|
176
|
-
`backupId` is what saves you hunting for its manifest in the chat by hand.
|
|
215
|
+
`backupId` is what saves you hunting for its manifest in the chat by hand. `verify` is how
|
|
216
|
+
you find that out before you need the file rather than after.
|
|
177
217
|
|
|
178
218
|
Settings and credentials live in `~/.telstore/config.json`, mode 600 โ `apiId`, `apiHash` and
|
|
179
219
|
the session at the top level (or a single `sealed` blob after `login --token`), everything
|
package/bin/telstore.js
CHANGED
|
@@ -14,9 +14,9 @@ const SIGINT_EXIT_CODE = 130
|
|
|
14
14
|
let currentCommand = null
|
|
15
15
|
let currentBackupId = null
|
|
16
16
|
|
|
17
|
-
// A batch clears each finished
|
|
18
|
-
// are
|
|
19
|
-
const
|
|
17
|
+
// A batch clears each finished item's record as it goes, so by the time Ctrl-C lands these
|
|
18
|
+
// are transfers no second run should touch. Ctrl-C needs their names to say so.
|
|
19
|
+
const finished = []
|
|
20
20
|
|
|
21
21
|
process.on('SIGINT', () => {
|
|
22
22
|
// A passphrase prompt has stdin in raw mode, and process.exit skips readline's own cleanup.
|
|
@@ -24,7 +24,7 @@ process.on('SIGINT', () => {
|
|
|
24
24
|
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
25
25
|
|
|
26
26
|
process.stderr.write(
|
|
27
|
-
interruptMessage(currentCommand, { backupId: currentBackupId, done:
|
|
27
|
+
interruptMessage(currentCommand, { backupId: currentBackupId, done: finished }),
|
|
28
28
|
)
|
|
29
29
|
process.exit(SIGINT_EXIT_CODE)
|
|
30
30
|
})
|
|
@@ -104,7 +104,7 @@ async function main() {
|
|
|
104
104
|
currentBackupId = id
|
|
105
105
|
},
|
|
106
106
|
onFileDone: (file) => {
|
|
107
|
-
if (file.id)
|
|
107
|
+
if (file.id) finished.push(file)
|
|
108
108
|
},
|
|
109
109
|
})
|
|
110
110
|
|
|
@@ -121,8 +121,30 @@ async function main() {
|
|
|
121
121
|
|
|
122
122
|
const { runRestores } = await import('../src/commands/restore.js')
|
|
123
123
|
|
|
124
|
-
const { failed } = await runRestores(parsed.args, parsed.options
|
|
124
|
+
const { failed } = await runRestores(parsed.args, parsed.options, {
|
|
125
|
+
onBackupId: (id) => {
|
|
126
|
+
currentBackupId = id
|
|
127
|
+
},
|
|
128
|
+
onRestoreDone: (item) => {
|
|
129
|
+
finished.push(item)
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
if (failed > 0) process.exitCode = 1
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'verify': {
|
|
138
|
+
if (!parsed.args[0]) {
|
|
139
|
+
throw new Error('Missing backup id. Example: npx telstore verify telstore-20260905-7f3a91')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { runVerifies } = await import('../src/commands/verify.js')
|
|
143
|
+
|
|
144
|
+
const { failed } = await runVerifies(parsed.args, parsed.options)
|
|
125
145
|
|
|
146
|
+
// A backup that is damaged, and one telstore could not look up at all, both mean the
|
|
147
|
+
// run did not find what it was asked to check. Whatever runs telstore learns that here.
|
|
126
148
|
if (failed > 0) process.exitCode = 1
|
|
127
149
|
return
|
|
128
150
|
}
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ const SUBCOMMANDS = new Set([
|
|
|
6
6
|
'logout',
|
|
7
7
|
'list',
|
|
8
8
|
'restore',
|
|
9
|
+
'verify',
|
|
9
10
|
'delete',
|
|
10
11
|
'status',
|
|
11
12
|
'config',
|
|
@@ -21,6 +22,7 @@ const OPTIONS = {
|
|
|
21
22
|
out: { type: 'string' },
|
|
22
23
|
note: { type: 'string' },
|
|
23
24
|
limit: { type: 'string' },
|
|
25
|
+
search: { type: 'string' },
|
|
24
26
|
verbose: { type: 'boolean' },
|
|
25
27
|
unset: { type: 'boolean' },
|
|
26
28
|
yes: { type: 'boolean' },
|
|
@@ -34,9 +36,11 @@ Usage:
|
|
|
34
36
|
npx telstore login Log in to Telegram, only needed once
|
|
35
37
|
npx telstore <file|folder|pattern>... Split files and upload them to Telegram
|
|
36
38
|
npx telstore list List the backups stored in the destination
|
|
39
|
+
npx telstore list --search <text> List only the backups that text appears in
|
|
37
40
|
npx telstore restore <backup-id>... Download the chunks and reassemble the files
|
|
41
|
+
npx telstore verify <backup-id>... Check that a backup's chunks are all still in the chat
|
|
38
42
|
npx telstore delete <backup-id>... Remove backups' chunks and manifests from the chat
|
|
39
|
-
npx telstore status Show the account, the destination and unfinished
|
|
43
|
+
npx telstore status Show the account, the destination and unfinished uploads and restores
|
|
40
44
|
npx telstore config Show every setting and where its value comes from
|
|
41
45
|
npx telstore logout Remove the saved session
|
|
42
46
|
|
|
@@ -50,8 +54,10 @@ backup. A folder means the files one level inside it, and a pattern means the na
|
|
|
50
54
|
one file is listed and confirmed before the first byte goes out. Run telstore again with only
|
|
51
55
|
the files that are left to carry on after an interruption.
|
|
52
56
|
|
|
53
|
-
restore and delete take several ids the same way: one connection, one line each, and
|
|
54
|
-
code that reports any that failed. delete shows everything it is about to destroy and
|
|
57
|
+
restore, verify and delete take several ids the same way: one connection, one line each, and
|
|
58
|
+
an exit code that reports any that failed. delete shows everything it is about to destroy and
|
|
59
|
+
asks once. verify downloads nothing: it asks the chat whether every chunk message is still
|
|
60
|
+
there at the length the manifest records, which is what restore would need.
|
|
55
61
|
|
|
56
62
|
Settings:
|
|
57
63
|
npx telstore config <name> Print one setting's value
|
|
@@ -80,6 +86,12 @@ Options apply to one run and are never saved. Use config to change a setting for
|
|
|
80
86
|
shell hands the words after the first to telstore as more files
|
|
81
87
|
to upload.
|
|
82
88
|
--limit <n> How many backups list shows this run.
|
|
89
|
+
--search <text> List only the backups whose file name, note, backup id or
|
|
90
|
+
creation day contains this text. Telegram's own index does
|
|
91
|
+
the looking, so the whole chat is reached without reading it
|
|
92
|
+
message by message โ but it matches whole words only:
|
|
93
|
+
"projex" finds projex.zip and "proj" finds nothing. A term
|
|
94
|
+
with spaces has to be quoted, as --note does.
|
|
83
95
|
--token Log in by pasting a session token. It takes no value on
|
|
84
96
|
purpose: a token written on the command line would sit in
|
|
85
97
|
"ps" for the whole life of the command, and stay in that
|
|
@@ -120,8 +132,38 @@ export function interruptMessage(command, { backupId, done = [] } = {}) {
|
|
|
120
132
|
)
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
// A restore keeps its .partial now, and the next run proves each chunk in it against the
|
|
136
|
+
// manifest before trusting a byte โ so "running again starts over", which this said while
|
|
137
|
+
// there was nothing to resume from, would now be false.
|
|
123
138
|
if (command === 'restore') {
|
|
124
|
-
|
|
139
|
+
// Finished ids have been renamed to their real names and their records removed, so
|
|
140
|
+
// repeating the whole command line would meet an overwrite prompt and then download
|
|
141
|
+
// them again from nothing. Name them and ask for the rest, exactly as a batch upload does.
|
|
142
|
+
if (done.length > 0) {
|
|
143
|
+
const width = Math.max(...done.map((item) => basename(item.path).length))
|
|
144
|
+
const finished = done
|
|
145
|
+
.map((item) => ` ${basename(item.path).padEnd(width)} ${item.id}`)
|
|
146
|
+
.join('\n')
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
`\nStopped. These are finished and need no second run:\n${finished}\n` +
|
|
150
|
+
'Run telstore again with only the ids that are left โ their .partial files are kept, ' +
|
|
151
|
+
'so those carry on where they stopped. "npx telstore status" shows what is unfinished.\n'
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// With onBackupId firing only once the .partial is open, an id here means there is a
|
|
156
|
+
// file to carry on from. Without one, this run stopped before it wrote anything, and
|
|
157
|
+
// saying a .partial was kept would be the same lie this message was rewritten to stop
|
|
158
|
+
// telling โ just from the other side.
|
|
159
|
+
if (!backupId) {
|
|
160
|
+
return '\nStopped before anything was written. Run the same command again to start.\n'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
`\nBackup ${backupId} kept its .partial file โ run the same command again from this ` +
|
|
165
|
+
'directory to carry on, or "npx telstore status" to see what is left.\n'
|
|
166
|
+
)
|
|
125
167
|
}
|
|
126
168
|
|
|
127
169
|
// A delete has already destroyed messages for good by the time Ctrl-C lands, and the
|
package/src/client.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Api, TelegramClient } from 'teleproto'
|
|
2
2
|
import { Logger } from 'teleproto/extensions/index.js'
|
|
3
3
|
import { LogLevel } from 'teleproto/extensions/Logger.js'
|
|
4
|
+
import { returnBigInt } from 'teleproto/Helpers.js'
|
|
4
5
|
import { StringSession } from 'teleproto/sessions/index.js'
|
|
5
6
|
|
|
7
|
+
import { MANIFEST_TAG } from './caption.js'
|
|
6
8
|
import { manifestFileName } from './manifest.js'
|
|
7
9
|
import { withRetry } from './retry.js'
|
|
8
10
|
import { assertLoggedIn, unlockConfig } from './session.js'
|
|
@@ -23,6 +25,26 @@ export function documentFileName(message) {
|
|
|
23
25
|
return named?.fileName ?? null
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
// Telegram records a document's length as a BigInteger, and comparing that to the plain
|
|
29
|
+
// number a manifest carries with === is false for every size there is.
|
|
30
|
+
export function documentSize(message) {
|
|
31
|
+
const size = message?.media?.document?.size
|
|
32
|
+
|
|
33
|
+
return size === undefined || size === null ? null : returnBigInt(size).toJSNumber()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The flat shape both readers of a chat hand back. The raw message is kept alongside it
|
|
37
|
+
// because downloading needs it whole.
|
|
38
|
+
function toDocument(message) {
|
|
39
|
+
return {
|
|
40
|
+
id: message.id,
|
|
41
|
+
fileName: documentFileName(message),
|
|
42
|
+
caption: message.message ?? '',
|
|
43
|
+
date: message.date,
|
|
44
|
+
message,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
// The one place telstore searches a chat. Both callers want documents and nothing else,
|
|
27
49
|
// and getMessages is preferred over a raw Api.messages.Search because it handles offsets,
|
|
28
50
|
// hashes and pagination itself, so we don't hand-build easily mistyped fields. The raw
|
|
@@ -34,13 +56,93 @@ export async function searchDocuments(client, peer, { search, limit }) {
|
|
|
34
56
|
limit,
|
|
35
57
|
})
|
|
36
58
|
|
|
37
|
-
return messages.map(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
59
|
+
return messages.map(toDocument)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The paging both readers share. It is ours rather than iterMessages', for the reasons
|
|
63
|
+
// deleteMessages does not use teleproto's: every page then carries the retry policy and the
|
|
64
|
+
// stall deadline, and a page that fails is retried by itself instead of restarting from the
|
|
65
|
+
// newest message. offsetId is the id of the last message of the page before, and Telegram
|
|
66
|
+
// answers with the messages older than it โ a reader that forgot to advance it would fetch
|
|
67
|
+
// the newest page over and over and never reach an older backup.
|
|
68
|
+
//
|
|
69
|
+
// A generator because the caller stops when it has what it wants: a chat of ten thousand
|
|
70
|
+
// chunks costs one request to list the backups at the top of it.
|
|
71
|
+
export const DOCUMENT_PAGE_SIZE = 100
|
|
72
|
+
|
|
73
|
+
async function* iterMessagePages(client, peer, { search, what, options }) {
|
|
74
|
+
const {
|
|
75
|
+
pageSize = DOCUMENT_PAGE_SIZE,
|
|
76
|
+
max = Infinity,
|
|
77
|
+
retryOptions = {},
|
|
78
|
+
stallMs = DEFAULT_STALL_MS,
|
|
79
|
+
} = options
|
|
80
|
+
|
|
81
|
+
let offsetId = 0
|
|
82
|
+
let read = 0
|
|
83
|
+
|
|
84
|
+
while (read < max) {
|
|
85
|
+
const limit = Math.min(pageSize, max - read)
|
|
86
|
+
|
|
87
|
+
const messages = await withRetry(
|
|
88
|
+
() =>
|
|
89
|
+
withStallTimeout(
|
|
90
|
+
client.getMessages(peer, {
|
|
91
|
+
...(search === undefined ? {} : { search }),
|
|
92
|
+
filter: new Api.InputMessagesFilterDocument(),
|
|
93
|
+
limit,
|
|
94
|
+
offsetId,
|
|
95
|
+
}),
|
|
96
|
+
stallMs,
|
|
97
|
+
() =>
|
|
98
|
+
`Telegram stopped answering while reading the ${what} older than message ` +
|
|
99
|
+
`${offsetId}: nothing back for ${Math.round(stallMs / 1000)}s.`,
|
|
100
|
+
),
|
|
101
|
+
retryOptions,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if (!messages || messages.length === 0) return
|
|
105
|
+
|
|
106
|
+
for (const message of messages) yield toDocument(message)
|
|
107
|
+
|
|
108
|
+
read += messages.length
|
|
109
|
+
offsetId = messages[messages.length - 1].id
|
|
110
|
+
|
|
111
|
+
// A short page is the end of the results. Asking again would cost a request to be told
|
|
112
|
+
// the same thing.
|
|
113
|
+
if (messages.length < limit) return
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What `list` reads the chat with. searchDocuments asks Telegram's text index a question;
|
|
118
|
+
// this asks for the documents themselves, newest first, which is the only answer that was
|
|
119
|
+
// right every time it was measured โ a chat's text index can come back empty while the chat
|
|
120
|
+
// is full of backups, and did for a whole day in a channel that had just been created
|
|
121
|
+
// (docs/design/captions.md carries the measurements).
|
|
122
|
+
export async function* iterDocuments(client, peer, options = {}) {
|
|
123
|
+
yield* iterMessagePages(client, peer, { what: 'documents', options })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// What `list --search` reads the chat with, and the one place that knows how to ask the index
|
|
127
|
+
// for backups. Two things about the query are load-bearing, both measured against a real chat
|
|
128
|
+
// on 2026-09-08 (docs/design/captions.md carries the numbers):
|
|
129
|
+
//
|
|
130
|
+
// The tag is ANDed in because a term alone brings the backup's chunk messages back too โ a
|
|
131
|
+
// chunk caption carries the id โ and on a backup of a few hundred chunks those would fill
|
|
132
|
+
// every page before a single manifest appeared. `#telstore` rides on the manifest alone, so
|
|
133
|
+
// the results come back manifests only: searching one backup id returned 1 manifest and 1
|
|
134
|
+
// chunk, and the same id with the tag returned the manifest by itself.
|
|
135
|
+
//
|
|
136
|
+
// The tag goes *after* the term, never before. A query that starts with the hash is read as
|
|
137
|
+
// a hashtag lookup and stops ANDing the rest: "#telstore projex" came back empty while
|
|
138
|
+
// "projex #telstore" returned the one manifest. Leading it would turn every search into
|
|
139
|
+
// "no backups found", which is the sentence this project must never say wrongly.
|
|
140
|
+
export async function* iterManifestSearch(client, peer, term, options = {}) {
|
|
141
|
+
yield* iterMessagePages(client, peer, {
|
|
142
|
+
search: `${term} ${MANIFEST_TAG}`,
|
|
143
|
+
what: 'search results',
|
|
144
|
+
options,
|
|
145
|
+
})
|
|
44
146
|
}
|
|
45
147
|
|
|
46
148
|
// How telstore finds a backup's manifest, in one place because restore and delete must not
|
|
@@ -70,11 +172,15 @@ export async function readMessageBytes(client, message) {
|
|
|
70
172
|
//
|
|
71
173
|
// Telegram does not complain about an id that is no longer there, so sending a batch twice
|
|
72
174
|
// costs nothing: a delete interrupted halfway is finished by running it again.
|
|
73
|
-
|
|
175
|
+
//
|
|
176
|
+
// The hundred is Telegram's own limit on how many message ids one request may name, and it
|
|
177
|
+
// is the same limit whether the request removes them or asks about them โ so getDocuments
|
|
178
|
+
// below counts in the same batches rather than keeping a second opinion about one number.
|
|
179
|
+
export const MESSAGE_BATCH_SIZE = 100
|
|
74
180
|
|
|
75
181
|
export async function deleteMessages(client, peer, ids, options = {}) {
|
|
76
182
|
const {
|
|
77
|
-
batchSize =
|
|
183
|
+
batchSize = MESSAGE_BATCH_SIZE,
|
|
78
184
|
retryOptions = {},
|
|
79
185
|
stallMs = DEFAULT_STALL_MS,
|
|
80
186
|
onBatch,
|
|
@@ -110,6 +216,53 @@ export async function deleteMessages(client, peer, ids, options = {}) {
|
|
|
110
216
|
return deleted
|
|
111
217
|
}
|
|
112
218
|
|
|
219
|
+
// What verify asks the chat, and the read-only mirror of deleteMessages above: our own
|
|
220
|
+
// batching, one request in flight at a time, under the same retry policy and the same stall
|
|
221
|
+
// deadline as every other network wait in telstore.
|
|
222
|
+
//
|
|
223
|
+
// The answer is a Map rather than a list because the question is "which of these are still
|
|
224
|
+
// there". Telegram reports a message that is gone as MessageEmpty โ an object carrying the
|
|
225
|
+
// id it was asked about โ so anything that is not a real message is left out here, where the
|
|
226
|
+
// shape is understood, rather than passed on to a caller that would read an empty as a chunk
|
|
227
|
+
// still sitting in the chat.
|
|
228
|
+
export async function getDocuments(client, peer, ids, options = {}) {
|
|
229
|
+
const {
|
|
230
|
+
batchSize = MESSAGE_BATCH_SIZE,
|
|
231
|
+
retryOptions = {},
|
|
232
|
+
stallMs = DEFAULT_STALL_MS,
|
|
233
|
+
onBatch,
|
|
234
|
+
} = options
|
|
235
|
+
|
|
236
|
+
const found = new Map()
|
|
237
|
+
|
|
238
|
+
for (let start = 0; start < ids.length; start += batchSize) {
|
|
239
|
+
const batch = ids.slice(start, start + batchSize)
|
|
240
|
+
|
|
241
|
+
const messages = await withRetry(
|
|
242
|
+
() =>
|
|
243
|
+
withStallTimeout(
|
|
244
|
+
client.getMessages(peer, { ids: batch }),
|
|
245
|
+
stallMs,
|
|
246
|
+
() =>
|
|
247
|
+
`Telegram stopped answering while looking up messages ${start + 1}-` +
|
|
248
|
+
`${start + batch.length} of ${ids.length}: nothing back for ` +
|
|
249
|
+
`${Math.round(stallMs / 1000)}s.`,
|
|
250
|
+
),
|
|
251
|
+
retryOptions,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
for (const message of messages ?? []) {
|
|
255
|
+
if (!message || message instanceof Api.MessageEmpty) continue
|
|
256
|
+
|
|
257
|
+
found.set(message.id, message)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
onBatch?.(Math.min(start + batch.length, ids.length), ids.length)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return found
|
|
264
|
+
}
|
|
265
|
+
|
|
113
266
|
// Every command ends by putting the connection down, and a failure there must never
|
|
114
267
|
// swallow the real error already on its way up. Commands that print progress hand in an
|
|
115
268
|
// onWarn to say so; the quieter ones let it pass, because a connection that will not
|
package/src/commands/delete.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
import { chatName, describeChat } from '../chat.js'
|
|
2
4
|
import {
|
|
3
|
-
|
|
5
|
+
MESSAGE_BATCH_SIZE,
|
|
4
6
|
closeQuietly,
|
|
5
7
|
connect as realConnect,
|
|
6
8
|
deleteMessages as realDeleteMessages,
|
|
@@ -13,7 +15,7 @@ import { manifestFileName, manifestMessageIds, parseManifestJson } from '../mani
|
|
|
13
15
|
import { formatBytes, formatDuration } from '../progress.js'
|
|
14
16
|
import { assertLoggedIn } from '../session.js'
|
|
15
17
|
import { requireChat, resolveSettings } from '../settings.js'
|
|
16
|
-
import { clearState, findStates } from '../state.js'
|
|
18
|
+
import { clearRestore, clearState, findRestores, findStates } from '../state.js'
|
|
17
19
|
|
|
18
20
|
// What list prints when a card cannot be read back. A manifest is text off a chat, and a
|
|
19
21
|
// summary is not worth inventing: the numbers below only decorate a decision the backup id
|
|
@@ -179,7 +181,7 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
179
181
|
throw new Error('Cancelled on request.')
|
|
180
182
|
}
|
|
181
183
|
|
|
182
|
-
const loud = chunkIds.length >
|
|
184
|
+
const loud = chunkIds.length > MESSAGE_BATCH_SIZE
|
|
183
185
|
let removed = 0
|
|
184
186
|
|
|
185
187
|
try {
|
|
@@ -223,6 +225,30 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
223
225
|
|
|
224
226
|
if (record) await clearState(record.key, configDir)
|
|
225
227
|
|
|
228
|
+
// The chunks are gone from the chat, so a restore record pointing at this backup now
|
|
229
|
+
// names messages nobody can fetch: `status` would keep offering a resume command that
|
|
230
|
+
// can only fail. Dropped here rather than earlier for the same reason the upload record
|
|
231
|
+
// is โ anything that throws above leaves the way back intact.
|
|
232
|
+
//
|
|
233
|
+
// The .partial itself stays. It is the user's data, sometimes gigabytes of it, and this
|
|
234
|
+
// command removes what was asked for and nothing else. But it can never be completed
|
|
235
|
+
// now, so it is named on the way out: that is the difference between a file they can
|
|
236
|
+
// reclaim and one they will never think to look for.
|
|
237
|
+
const stranded = []
|
|
238
|
+
|
|
239
|
+
for (const found of await findRestores(backupId, configDir)) {
|
|
240
|
+
await clearRestore(found.key, configDir)
|
|
241
|
+
|
|
242
|
+
const partial = `${found.record.target}.partial`
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
await fs.stat(partial)
|
|
246
|
+
stranded.push(partial)
|
|
247
|
+
} catch {
|
|
248
|
+
// Nothing there to tell them about.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
226
252
|
if (manifestMessage) {
|
|
227
253
|
log(
|
|
228
254
|
`\nDone. Removed ${backupId} from ${chatName(chat)}: ` +
|
|
@@ -236,6 +262,13 @@ export async function runDelete(backupId, options = {}, deps = {}) {
|
|
|
236
262
|
)
|
|
237
263
|
}
|
|
238
264
|
|
|
265
|
+
for (const partial of stranded) {
|
|
266
|
+
log(
|
|
267
|
+
`${partial} is a half-finished restore of this backup. Nothing can finish it now โ ` +
|
|
268
|
+
'delete it when you want the space back.',
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
239
272
|
return {
|
|
240
273
|
id: backupId,
|
|
241
274
|
chunks: chunkIds.length,
|