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/src/cli.js ADDED
@@ -0,0 +1,161 @@
1
+ import { parseArgs } from 'node:util'
2
+
3
+ const SUBCOMMANDS = new Set([
4
+ 'login',
5
+ 'logout',
6
+ 'list',
7
+ 'restore',
8
+ 'delete',
9
+ 'status',
10
+ 'config',
11
+ 'help',
12
+ ])
13
+
14
+ const OPTIONS = {
15
+ to: { type: 'string' },
16
+ 'chunk-size': { type: 'string' },
17
+ concurrency: { type: 'string' },
18
+ out: { type: 'string' },
19
+ limit: { type: 'string' },
20
+ verbose: { type: 'boolean' },
21
+ unset: { type: 'boolean' },
22
+ yes: { type: 'boolean' },
23
+ help: { type: 'boolean', short: 'h' },
24
+ }
25
+
26
+ export const HELP = `telark — split large files into chunks and store them on Telegram
27
+
28
+ Usage:
29
+ npx telark login Log in to Telegram, only needed once
30
+ npx telark <file> Split a file and upload it to Telegram
31
+ npx telark list List the backups stored in the destination
32
+ npx telark restore <backup-id> Download the chunks and reassemble the file
33
+ npx telark delete <backup-id> Remove a backup's chunks and manifest from the chat
34
+ npx telark status Show the account, the destination and unfinished backups
35
+ npx telark config Show every setting and where its value comes from
36
+ npx telark logout Remove the saved session
37
+
38
+ Settings:
39
+ npx telark config <name> Print one setting's value
40
+ npx telark config <name> <value> Change it for good
41
+ npx telark config <name> --unset Drop it and fall back to the default
42
+
43
+ chat Where backups go: @username, -100123..., or me. No default.
44
+ chunkSize Size of each chunk, default 1800MB. Examples: 1.8GB, 500MB.
45
+ concurrency 512KB parts sent in parallel, default 8, max 64. Upload only.
46
+ limit How many backups list shows, default 20.
47
+ verbose Show Telegram connection logs, default false.
48
+
49
+ Options apply to one run and are never saved. Use config to change a setting for good.
50
+ --to <chat> Destination for this run only.
51
+ --chunk-size <n> Chunk size for this run only. An unfinished backup keeps the size
52
+ it started with.
53
+ --concurrency <n> Parts in parallel for this run only. Upload only — restore always
54
+ downloads with its own fixed pool of workers.
55
+ --out <path> Where to write the restored file. Defaults to the basename in the manifest.
56
+ --limit <n> How many backups list shows this run.
57
+ --yes Delete without asking to confirm first.
58
+ --verbose Show Telegram connection logs for this run.
59
+ -h, --help Show this help.
60
+ `
61
+
62
+ // What Ctrl-C means depends on the command that was running: upload has written every
63
+ // finished chunk to a state file, restore has not. Naming the backup matters because the
64
+ // id is what `status` lists and what a later `restore` needs — the chunks are already in
65
+ // the chat under that id, whether or not this run ever finishes.
66
+ export function interruptMessage(command, { backupId } = {}) {
67
+ if (command === 'upload') {
68
+ const backup = backupId ? `Backup ${backupId} is saved` : 'Progress is saved'
69
+
70
+ return (
71
+ `\n${backup} — run the same command again to continue, ` +
72
+ 'or "npx telark status" to see what is left.\n'
73
+ )
74
+ }
75
+
76
+ if (command === 'restore') {
77
+ return '\nStopped. Download progress is not saved, running again starts over.\n'
78
+ }
79
+
80
+ // A delete has already destroyed messages for good by the time Ctrl-C lands, and the
81
+ // manifest is deliberately still there — it is what a second run reads to finish. Saying
82
+ // only "Stopped." would read as "nothing happened", which is the one thing it never means.
83
+ if (command === 'delete') {
84
+ return (
85
+ '\nStopped. Some chunk messages are already gone — run the same command again to ' +
86
+ 'finish removing the backup.\n'
87
+ )
88
+ }
89
+
90
+ return '\nStopped.\n'
91
+ }
92
+
93
+ // A channel id is negative, and typing it separated by a space is the natural reflex — but
94
+ // parseArgs rejects anything starting with a dash as an option, and reports it as one:
95
+ // `config chat -100123` fails with "Unknown option '-1'", naming a flag nobody typed.
96
+ //
97
+ // Two shapes need rescuing, and they are rescued differently. As a flag value, `--to -100123`
98
+ // is joined into `--to=-100123`; only a bare negative integer qualifies, so `--to --verbose`
99
+ // still reports the missing value instead of eating the next flag. As a positional —
100
+ // `config chat -100123` — there is nothing to join it to, so `--` goes in front and the rest
101
+ // of the line is handed over verbatim. That is greedy on purpose: a flag written after the
102
+ // id becomes a positional too, and runConfig refuses the extra argument by name, which beats
103
+ // an error about `-1`.
104
+ //
105
+ // Everything after an explicit `--` is left alone, because it is no longer an option there.
106
+ function protectNegativeChatIds(argv) {
107
+ const safe = []
108
+
109
+ for (let i = 0; i < argv.length; i += 1) {
110
+ if (argv[i] === '--') {
111
+ safe.push(...argv.slice(i))
112
+ return safe
113
+ }
114
+
115
+ if (argv[i] === '--to' && /^-\d+$/.test(argv[i + 1] ?? '')) {
116
+ safe.push(`--to=${argv[i + 1]}`)
117
+ i += 1
118
+ continue
119
+ }
120
+
121
+ if (/^-\d+$/.test(argv[i])) {
122
+ safe.push('--', ...argv.slice(i))
123
+ return safe
124
+ }
125
+
126
+ safe.push(argv[i])
127
+ }
128
+
129
+ return safe
130
+ }
131
+
132
+ export function route(argv) {
133
+ const { values, positionals } = parseArgs({
134
+ args: protectNegativeChatIds(argv),
135
+ options: OPTIONS,
136
+ allowPositionals: true,
137
+ })
138
+
139
+ const [first, ...rest] = positionals
140
+
141
+ // `telark --to @chan` with no file used to mean "remember this destination". Flags no
142
+ // longer write anything, so that line now asks for a run that has nothing to upload —
143
+ // say where the destination actually lives instead of printing help at someone who was
144
+ // perfectly clear about what they wanted.
145
+ if (first === undefined && values.to && !values.help) {
146
+ throw new Error(
147
+ `Nothing to upload. To change the destination for good, run "npx telark config chat ${values.to}". ` +
148
+ 'To use it for one run, pass --to alongside a file or a command.',
149
+ )
150
+ }
151
+
152
+ if (values.help || first === undefined || first === 'help') {
153
+ return { command: 'help', args: [], options: values }
154
+ }
155
+
156
+ if (SUBCOMMANDS.has(first)) {
157
+ return { command: first, args: rest, options: values }
158
+ }
159
+
160
+ return { command: 'upload', args: [first], options: values }
161
+ }
package/src/client.js ADDED
@@ -0,0 +1,146 @@
1
+ import { Api, TelegramClient } from 'telegram'
2
+ import { Logger } from 'telegram/extensions/index.js'
3
+ import { LogLevel } from 'telegram/extensions/Logger.js'
4
+ import { StringSession } from 'telegram/sessions/index.js'
5
+
6
+ import { manifestFileName } from './manifest.js'
7
+ import { withRetry } from './retry.js'
8
+ import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
9
+
10
+ // GramJS narrates its version, every connection and every disconnect at info level, and
11
+ // those timestamped lines land in the middle of the progress bar. The client reads this
12
+ // logger before it prints anything, so LogLevel.NONE silences all of it; --verbose asks
13
+ // for the running commentary back when a connection needs diagnosing.
14
+ export function createLogger(verbose) {
15
+ return new Logger(verbose ? LogLevel.INFO : LogLevel.NONE)
16
+ }
17
+
18
+ // The name Telegram shows under a document lives in an attribute, not on the message.
19
+ export function documentFileName(message) {
20
+ const attributes = message?.media?.document?.attributes ?? []
21
+ const named = attributes.find((a) => a instanceof Api.DocumentAttributeFilename)
22
+ return named?.fileName ?? null
23
+ }
24
+
25
+ // The one place telark searches a chat. Both callers want documents and nothing else,
26
+ // and getMessages is preferred over a raw Api.messages.Search because it handles offsets,
27
+ // hashes and pagination itself, so we don't hand-build easily mistyped fields. The raw
28
+ // message is kept alongside the flat fields because downloading needs it whole.
29
+ export async function searchDocuments(client, peer, { search, limit }) {
30
+ const messages = await client.getMessages(peer, {
31
+ search,
32
+ filter: new Api.InputMessagesFilterDocument(),
33
+ limit,
34
+ })
35
+
36
+ return messages.map((message) => ({
37
+ id: message.id,
38
+ fileName: documentFileName(message),
39
+ caption: message.message ?? '',
40
+ date: message.date,
41
+ message,
42
+ }))
43
+ }
44
+
45
+ // How telark finds a backup's manifest, in one place because restore and delete must not
46
+ // disagree about it. The search is by backup id, but the answer is decided by the file name
47
+ // telark itself wrote — a caption is text a person can edit, a file name is not.
48
+ export async function findManifestMessage(client, peer, backupId) {
49
+ const wanted = manifestFileName(backupId)
50
+ const found = await searchDocuments(client, peer, { search: backupId, limit: 100 })
51
+
52
+ return found.find((doc) => doc.fileName === wanted)?.message ?? null
53
+ }
54
+
55
+ export async function readMessageBytes(client, message) {
56
+ return await client.downloadMedia(message)
57
+ }
58
+
59
+ // The one place telark removes messages from a chat, and the mirror of searchDocuments
60
+ // above. GramJS has its own deleteMessages, and it is the right thing to call — it resolves
61
+ // the peer and picks between channels.DeleteMessages and messages.DeleteMessages, which is
62
+ // exactly the choice a fake client would never catch us getting wrong.
63
+ //
64
+ // What it does on top of that is the problem: it splits the ids into batches of a hundred
65
+ // and fires every batch at once through Promise.all. A ten-thousand-chunk backup would put
66
+ // a hundred requests in flight together, none of them under the retry policy or the stall
67
+ // deadline that every other network wait in telark carries. Batching here instead keeps
68
+ // one request outstanding at a time, under both.
69
+ //
70
+ // Telegram does not complain about an id that is no longer there, so sending a batch twice
71
+ // costs nothing: a delete interrupted halfway is finished by running it again.
72
+ export const DELETE_BATCH_SIZE = 100
73
+
74
+ export async function deleteMessages(client, peer, ids, options = {}) {
75
+ const {
76
+ batchSize = DELETE_BATCH_SIZE,
77
+ retryOptions = {},
78
+ stallMs = DEFAULT_STALL_MS,
79
+ onBatch,
80
+ } = options
81
+
82
+ let deleted = 0
83
+
84
+ for (let start = 0; start < ids.length; start += batchSize) {
85
+ const batch = ids.slice(start, start + batchSize)
86
+
87
+ await withRetry(
88
+ () =>
89
+ // The options object is not optional: GramJS destructures `{ revoke }` with no
90
+ // default of its own, so a two-argument call throws a TypeError before it ever
91
+ // reaches the network. revoke is passed explicitly anyway — a backup has to go for
92
+ // everyone who can see the chat, and that intent belongs in our code rather than in
93
+ // a dependency's default.
94
+ withStallTimeout(
95
+ client.deleteMessages(peer, batch, { revoke: true }),
96
+ stallMs,
97
+ () =>
98
+ `Telegram stopped answering while removing messages ${start + 1}-` +
99
+ `${start + batch.length} of ${ids.length}: nothing back for ` +
100
+ `${Math.round(stallMs / 1000)}s.`,
101
+ ),
102
+ retryOptions,
103
+ )
104
+
105
+ deleted += batch.length
106
+ onBatch?.(deleted, ids.length)
107
+ }
108
+
109
+ return deleted
110
+ }
111
+
112
+ // Every command ends by putting the connection down, and a failure there must never
113
+ // swallow the real error already on its way up. Commands that print progress hand in an
114
+ // onWarn to say so; the quieter ones let it pass, because a connection that will not
115
+ // close cleanly says nothing about the work that already succeeded.
116
+ export async function closeQuietly(client, disconnect, onWarn) {
117
+ try {
118
+ await disconnect(client)
119
+ } catch (err) {
120
+ if (onWarn) onWarn(err)
121
+ }
122
+ }
123
+
124
+ export function assertLoggedIn(config) {
125
+ if (!config.session || !config.apiId || !config.apiHash) {
126
+ throw new Error('Not logged in — run "npx telark login" first.')
127
+ }
128
+ }
129
+
130
+ export async function connect(config, { verbose = false } = {}) {
131
+ assertLoggedIn(config)
132
+
133
+ const client = new TelegramClient(new StringSession(config.session), config.apiId, config.apiHash, {
134
+ connectionRetries: 5,
135
+ floodSleepThreshold: 60,
136
+ baseLogger: createLogger(verbose),
137
+ })
138
+
139
+ await client.connect()
140
+
141
+ if (!(await client.isUserAuthorized())) {
142
+ throw new Error('Session expired — run "npx telark login".')
143
+ }
144
+
145
+ return client
146
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ SETTINGS,
3
+ SETTING_KEYS,
4
+ canonicalKey,
5
+ isManagedByLogin,
6
+ resolveSettings,
7
+ } from '../settings.js'
8
+ import { configFile, defaultConfigDir, loadConfig, saveConfig } from '../config.js'
9
+
10
+ const GAP = ' '
11
+
12
+ function unknownKey(name) {
13
+ if (isManagedByLogin(name)) {
14
+ return new Error(
15
+ `"${name}" is managed by "npx telark login", not by config. ` +
16
+ `Settings you can change: ${SETTING_KEYS.join(', ')}.`,
17
+ )
18
+ }
19
+
20
+ return new Error(`Unknown setting: "${name}". Settings are: ${SETTING_KEYS.join(', ')}.`)
21
+ }
22
+
23
+ // Every setting, resolved, with where the value came from. A stored value and a built-in
24
+ // default look identical once resolved, and the difference is the whole question someone
25
+ // runs this command to answer.
26
+ function listLines(values, source, stored) {
27
+ const rows = SETTING_KEYS.map((key) => {
28
+ const spec = SETTINGS[key]
29
+ const value = values[key]
30
+
31
+ // chat is the one setting with no default, so "(default)" beside it would name a
32
+ // fallback that does not exist. It is either set or it is not.
33
+ if (value === null) return [key, 'not set', '']
34
+
35
+ const note = spec.describe ? ` (${spec.describe(value)})` : ''
36
+
37
+ return [key, `${spec.format(value)}${note}`, source(key) === 'settings' ? '' : '(default)']
38
+ })
39
+
40
+ // Keys telark does not know are left in the file untouched, but a value that silently
41
+ // does nothing is worse than one that is refused: show them, so a typo is visible from
42
+ // the command rather than only from opening the file that also holds the session.
43
+ const strays = Object.keys(stored).filter((key) => !SETTING_KEYS.includes(key))
44
+
45
+ const widths = [0, 1].map((i) => Math.max(...rows.map((row) => row[i].length)))
46
+ const lines = rows.map((row) =>
47
+ `${row[0].padEnd(widths[0])}${GAP}${row[1].padEnd(widths[1])}${GAP}${row[2]}`.trimEnd(),
48
+ )
49
+
50
+ if (strays.length > 0) {
51
+ lines.push('')
52
+ lines.push(
53
+ `Ignored, telark does not know these: ${strays.join(', ')}. ` +
54
+ 'Remove one with: npx telark config <name> --unset',
55
+ )
56
+ }
57
+
58
+ return lines
59
+ }
60
+
61
+ export async function runConfig(args = [], options = {}, deps = {}) {
62
+ const { configDir = defaultConfigDir(), log = (line) => console.log(line) } = deps
63
+ const [name, value, ...extra] = args
64
+
65
+ if (extra.length > 0) {
66
+ throw new Error(
67
+ `Too many arguments: a setting takes one value, but got ${args.length}. ` +
68
+ `Did you mean: npx telark config ${name} "${[value, ...extra].join(' ')}"`,
69
+ )
70
+ }
71
+
72
+ // Both of these would otherwise be obeyed halfway and reported as a success: a listing
73
+ // that quietly dropped the --unset, or an unset that quietly dropped the value beside it.
74
+ if (options.unset && name === undefined) {
75
+ throw new Error('--unset needs the setting to drop. Try: npx telark config chat --unset')
76
+ }
77
+
78
+ if (options.unset && value !== undefined) {
79
+ throw new Error(
80
+ `--unset takes no value, but "${value}" was given. ` +
81
+ `Use "npx telark config ${name} --unset" to drop it, ` +
82
+ `or "npx telark config ${name} ${value}" to set it.`,
83
+ )
84
+ }
85
+
86
+ const file = configFile(configDir)
87
+ const config = await loadConfig(configDir)
88
+ const stored = config.settings ?? {}
89
+
90
+ if (name === undefined) {
91
+ const { values, source } = resolveSettings({}, config, { file })
92
+ for (const line of listLines(values, source, stored)) log(line)
93
+ return
94
+ }
95
+
96
+ const key = canonicalKey(name)
97
+
98
+ // --unset has to reach a key the registry does not know: the listing above points at
99
+ // strays, and this is the only way to remove one without opening the file by hand.
100
+ if (options.unset) {
101
+ if (!key && stored[name] === undefined) throw unknownKey(name)
102
+
103
+ const target = key ?? name
104
+ const next = { ...stored }
105
+ delete next[target]
106
+
107
+ await saveConfig({ ...config, settings: next }, configDir)
108
+ log(`${target} unset.`)
109
+ return
110
+ }
111
+
112
+ if (!key) throw unknownKey(name)
113
+
114
+ if (value === undefined) {
115
+ // The bare value and nothing else, so this can be read by a script. An unset key
116
+ // reports the default that will actually be used; chat has no default, so it says
117
+ // nothing rather than inventing one.
118
+ const { values } = resolveSettings({}, config, { file })
119
+ if (values[key] !== null) log(SETTINGS[key].format(values[key]))
120
+ return
121
+ }
122
+
123
+ // Parse before writing: an unusable value must never reach the file, or the next command
124
+ // fails over something the user was told had been saved.
125
+ const parsed = SETTINGS[key].parse(value, key)
126
+
127
+ await saveConfig({ ...config, settings: { ...stored, [key]: parsed } }, configDir)
128
+
129
+ log(`${key} = ${SETTINGS[key].format(parsed)}`)
130
+ }
@@ -0,0 +1,250 @@
1
+ import { chatName, describeChat } from '../chat.js'
2
+ import {
3
+ DELETE_BATCH_SIZE,
4
+ assertLoggedIn,
5
+ closeQuietly,
6
+ connect as realConnect,
7
+ deleteMessages as realDeleteMessages,
8
+ findManifestMessage,
9
+ readMessageBytes as realReadMessageBytes,
10
+ } from '../client.js'
11
+ import { askConfirm } from '../confirm.js'
12
+ import { configFile, defaultConfigDir, loadConfig } from '../config.js'
13
+ import { manifestFileName, manifestMessageIds, parseManifestJson } from '../manifest.js'
14
+ import { formatBytes, formatDuration } from '../progress.js'
15
+ import { requireChat, resolveSettings } from '../settings.js'
16
+ import { clearState, findStates } from '../state.js'
17
+
18
+ // What list prints when a card cannot be read back. A manifest is text off a chat, and a
19
+ // summary is not worth inventing: the numbers below only decorate a decision the backup id
20
+ // has already settled.
21
+ const UNKNOWN = '—'
22
+
23
+ function describeName(name) {
24
+ return typeof name === 'string' && name.trim() !== '' ? name : UNKNOWN
25
+ }
26
+
27
+ function describeSize(size) {
28
+ return Number.isSafeInteger(size) && size >= 0 ? formatBytes(size) : UNKNOWN
29
+ }
30
+
31
+ function plural(n, word) {
32
+ return `${n} ${word}${n === 1 ? '' : 's'}`
33
+ }
34
+
35
+ // The same rule the manifest gets, for the same reason: a message id is the name of
36
+ // something about to be destroyed for good, so a record that cannot say it exactly is
37
+ // refused whole rather than half-obeyed. Sorted by chunk index so the batches — and the
38
+ // error naming a chunk — are the same on every run.
39
+ function stateMessageIds(record) {
40
+ const done = record.state.done
41
+
42
+ if (typeof done !== 'object' || done === null) {
43
+ throw new Error(
44
+ `The record of unfinished backup ${record.state.id} does not list the chunks it sent. ` +
45
+ `${record.file} is damaged — delete that file by hand to drop the record, which ` +
46
+ 'leaves any chunks it did send sitting in the chat with nothing to point at them.',
47
+ )
48
+ }
49
+
50
+ return Object.entries(done)
51
+ .sort((a, b) => Number(a[0]) - Number(b[0]))
52
+ .map(([index, entry]) => {
53
+ const msgId = entry?.msgId
54
+
55
+ if (!Number.isSafeInteger(msgId) || msgId < 1) {
56
+ throw new Error(
57
+ `The record of unfinished backup ${record.state.id} gives ` +
58
+ `${JSON.stringify(msgId)} as the message id of chunk ${Number(index) + 1}, which ` +
59
+ `is not a message id. ${record.file} is damaged, so telark is not deleting ` +
60
+ 'anything.',
61
+ )
62
+ }
63
+
64
+ return msgId
65
+ })
66
+ }
67
+
68
+ export async function runDelete(backupId, options = {}, deps = {}) {
69
+ const {
70
+ connect = realConnect,
71
+ disconnect = (client) => client.destroy(),
72
+ configDir = defaultConfigDir(),
73
+ searchManifest = findManifestMessage,
74
+ readMessageBytes = realReadMessageBytes,
75
+ deleteMessages = realDeleteMessages,
76
+ confirm = askConfirm,
77
+ retryOptions = {},
78
+ writeErr = (line) => process.stderr.write(line),
79
+ log: writeLog = (line) => console.log(line),
80
+ silent = false,
81
+ } = deps
82
+
83
+ const config = await loadConfig(configDir)
84
+ const { values: settings } = resolveSettings(options, config, { file: configFile(configDir) })
85
+ // Before requireChat, as in list: telling somebody who has never logged in to go and pick
86
+ // a destination sends them after the wrong thing.
87
+ assertLoggedIn(config)
88
+ const chat = requireChat(settings)
89
+
90
+ const log = silent ? () => {} : writeLog
91
+ const warn = silent ? () => {} : writeErr
92
+
93
+ // Upload and restore stay quiet until the third retry so a handful of -503s do not bury
94
+ // the progress bar. There is no bar here to bury, and a wait in the middle of destroying
95
+ // somebody's backup is always worth saying out loud — so this one announces from the first.
96
+ function onRetry(err, attempt, delayMs) {
97
+ warn(
98
+ `\nTemporary error (${err.message}), retry ${attempt} in ` +
99
+ `${formatDuration(delayMs / 1000)}.\n`,
100
+ )
101
+ }
102
+
103
+ // A local lookup that refuses should not cost a connection first.
104
+ const records = await findStates(backupId, configDir)
105
+
106
+ if (records.length > 1) {
107
+ throw new Error(
108
+ `Two local records both claim to be backup ${backupId}: ` +
109
+ `${records.map((r) => r.file).join(' and ')}. telark will not guess which one to ` +
110
+ 'drop — remove the wrong one by hand and run again.',
111
+ )
112
+ }
113
+
114
+ const record = records[0] ?? null
115
+ const client = await connect(config, { verbose: settings.verbose })
116
+
117
+ try {
118
+ const manifestMessage = await searchManifest(client, chat, backupId)
119
+
120
+ if (!manifestMessage && !record) {
121
+ throw new Error(
122
+ `No backup ${backupId} found in ${chatName(chat)}, and no unfinished record of it on ` +
123
+ 'this machine. Check the id with "npx telark list", or use --to to point at the ' +
124
+ 'right chat.',
125
+ )
126
+ }
127
+
128
+ let manifest = null
129
+ const ids = new Set()
130
+
131
+ if (manifestMessage) {
132
+ manifest = parseManifestJson(await readMessageBytes(client, manifestMessage))
133
+
134
+ // The manifest was found by the file name telark itself wrote, and that name is the
135
+ // id this command was asked about. A body naming a different backup is a file that was
136
+ // renamed or replaced, and its message ids point at somebody else's chunks — the one
137
+ // mistake in this whole command that nothing can undo.
138
+ if (manifest?.id !== undefined && manifest.id !== backupId) {
139
+ throw new Error(
140
+ `The manifest named ${manifestFileName(backupId)} describes backup ` +
141
+ `${JSON.stringify(manifest.id)}, not ${backupId}. Its message ids point at ` +
142
+ 'another backup\'s chunks, so telark is not deleting anything.',
143
+ )
144
+ }
145
+
146
+ for (const id of manifestMessageIds(manifest)) ids.add(id)
147
+ }
148
+
149
+ // Both sources describe the same backup, so an id in either is a message this backup put
150
+ // in the chat. In practice the record holds nothing the manifest does not — but it is a
151
+ // file on disk that a truncated write or a hand edit can mangle, and an id left out here
152
+ // is a chunk that nothing can point at ever again.
153
+ if (record) {
154
+ for (const id of stateMessageIds(record)) ids.add(id)
155
+ }
156
+
157
+ const chunkIds = [...ids]
158
+
159
+ if (manifest) {
160
+ log(`Backup ${backupId}`)
161
+ log(
162
+ `File ${describeName(manifest.name)} ` +
163
+ `(${describeSize(manifest.size)}, ${plural(manifest.chunks.length, 'chunk')})`,
164
+ )
165
+ } else {
166
+ log(`Backup ${backupId} (unfinished — no manifest in the chat)`)
167
+ log(`File ${describeName(record.state.path)}`)
168
+ }
169
+
170
+ log(`From ${describeChat(chat)}`)
171
+ log('')
172
+
173
+ const prompt = manifest
174
+ ? `Delete this backup from ${chatName(chat)}? The chunks cannot be recovered. [y/N] `
175
+ : `Delete the ${plural(chunkIds.length, 'chunk message')} it sent, and its local ` +
176
+ `record? The chunks cannot be recovered. [y/N] `
177
+
178
+ if (!options.yes && !(await confirm(prompt))) {
179
+ throw new Error('Cancelled on request.')
180
+ }
181
+
182
+ const loud = chunkIds.length > DELETE_BATCH_SIZE
183
+ let removed = 0
184
+
185
+ try {
186
+ await deleteMessages(client, chat, chunkIds, {
187
+ retryOptions: { ...retryOptions, onRetry },
188
+ onBatch: (done, total) => {
189
+ removed = done
190
+ if (loud) warn(`\rRemoving chunk messages ${done}/${total}…`)
191
+ },
192
+ })
193
+ } catch (err) {
194
+ throw new Error(
195
+ `Removed ${removed} of ${plural(chunkIds.length, 'chunk message')} of ${backupId}, ` +
196
+ `then Telegram refused: ${err.message}. ` +
197
+ (manifestMessage
198
+ ? 'The manifest was left in place on purpose — it is the only list of the ' +
199
+ 'messages that are still there. '
200
+ : 'The local record was left in place on purpose — it is the only list of the ' +
201
+ 'messages that are still there. ') +
202
+ 'Run the same command again to finish.',
203
+ )
204
+ }
205
+
206
+ if (loud) warn('\n')
207
+
208
+ // Only now. The manifest is the only index of the ids above, and where there is no
209
+ // manifest the local record is. Anything that throws before this line leaves the way
210
+ // back intact, and running delete again picks up where this run stopped.
211
+ if (manifestMessage) {
212
+ try {
213
+ await deleteMessages(client, chat, [manifestMessage.id], {
214
+ retryOptions: { ...retryOptions, onRetry },
215
+ })
216
+ } catch (err) {
217
+ throw new Error(
218
+ `Removed every chunk message of ${backupId}, but Telegram refused to remove its ` +
219
+ `manifest: ${err.message}. Run the same command again to finish.`,
220
+ )
221
+ }
222
+ }
223
+
224
+ if (record) await clearState(record.key, configDir)
225
+
226
+ if (manifestMessage) {
227
+ log(
228
+ `\nDone. Removed ${backupId} from ${chatName(chat)}: ` +
229
+ `${plural(chunkIds.length, 'chunk message')} and its manifest.`,
230
+ )
231
+ if (record) log('The local record of this backup was removed too.')
232
+ } else {
233
+ log(
234
+ `\nDone. Removed ${plural(chunkIds.length, 'chunk message')} from ${chatName(chat)} ` +
235
+ `and dropped the local record of ${backupId}.`,
236
+ )
237
+ }
238
+
239
+ return {
240
+ id: backupId,
241
+ chunks: chunkIds.length,
242
+ manifestDeleted: Boolean(manifestMessage),
243
+ stateCleared: Boolean(record),
244
+ }
245
+ } finally {
246
+ await closeQuietly(client, disconnect, (err) =>
247
+ warn(`\nWarning: could not close the Telegram connection: ${err.message}\n`),
248
+ )
249
+ }
250
+ }