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/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
|
+
}
|