telstore 0.1.4 → 0.1.5

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/bin/telstore.js CHANGED
@@ -1,14 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { route, HELP, interruptMessage } from '../src/cli.js'
3
- 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 { runToken } from '../src/commands/token.js'
11
- import { runUpload } from '../src/commands/upload.js'
3
+
4
+ // Each command is imported where it runs, not here. Importing all nine up front pulled
5
+ // teleproto into every invocation — about 0.3s and 45MB — including `--help`, `config` and
6
+ // `logout`, which never open a socket. `src/cli.js` stays a static import because parsing the
7
+ // arguments is the one thing every run does, and nothing it touches reaches the network.
12
8
 
13
9
  const SIGINT_EXIT_CODE = 130
14
10
 
@@ -45,66 +41,97 @@ async function main() {
45
41
  process.stdout.write(HELP)
46
42
  return
47
43
 
48
- case 'login':
44
+ case 'login': {
45
+ const { runLogin } = await import('../src/commands/login.js')
46
+
49
47
  await runLogin({
50
48
  args: parsed.args,
51
49
  token: Boolean(parsed.options.token),
52
50
  verbose: Boolean(parsed.options.verbose),
53
51
  })
54
52
  return
53
+ }
54
+
55
+ case 'logout': {
56
+ const { runLogout } = await import('../src/commands/logout.js')
55
57
 
56
- case 'logout':
57
58
  await runLogout()
58
59
  return
60
+ }
61
+
62
+ case 'list': {
63
+ const { runList } = await import('../src/commands/list.js')
59
64
 
60
- case 'list':
61
65
  await runList(parsed.options)
62
66
  return
67
+ }
68
+
69
+ case 'status': {
70
+ const { runStatus } = await import('../src/commands/status.js')
63
71
 
64
- case 'status':
65
72
  await runStatus(parsed.options)
66
73
  return
74
+ }
75
+
76
+ case 'config': {
77
+ const { runConfig } = await import('../src/commands/config.js')
67
78
 
68
- case 'config':
69
79
  await runConfig(parsed.args, parsed.options)
70
80
  return
81
+ }
82
+
83
+ case 'token': {
84
+ const { runToken } = await import('../src/commands/token.js')
71
85
 
72
- case 'token':
73
86
  await runToken(parsed.args, parsed.options)
74
87
  return
88
+ }
89
+
90
+ case 'upload': {
91
+ const { runUpload } = await import('../src/commands/upload.js')
75
92
 
76
- case 'upload':
77
93
  await runUpload(parsed.args[0], parsed.options, {
78
94
  onBackupId: (id) => {
79
95
  currentBackupId = id
80
96
  },
81
97
  })
82
98
  return
99
+ }
83
100
 
84
- case 'restore':
101
+ case 'restore': {
85
102
  if (!parsed.args[0]) {
86
103
  throw new Error('Missing backup id. Example: npx telstore restore telstore-20260905-7f3a91')
87
104
  }
105
+
106
+ const { runRestore } = await import('../src/commands/restore.js')
107
+
88
108
  await runRestore(parsed.args[0], parsed.options)
89
109
  return
110
+ }
90
111
 
91
- case 'delete':
112
+ case 'delete': {
92
113
  if (!parsed.args[0]) {
93
114
  throw new Error('Missing backup id. Example: npx telstore delete telstore-20260905-7f3a91')
94
115
  }
116
+
117
+ const { runDelete } = await import('../src/commands/delete.js')
118
+
95
119
  await runDelete(parsed.args[0], parsed.options)
96
120
  return
121
+ }
97
122
 
98
123
  default:
99
124
  throw new Error(`Unknown command: ${parsed.command}`)
100
125
  }
101
126
  }
102
127
 
103
- // GramJS keeps "exported senders" around together with a 30-second timer to release
104
- // them, and neither client.disconnect() nor destroy() cleans them up: both of those
105
- // maps are Maps, but the code walks them with Object.values and so misses everything.
106
- // The result is a command that prints "Done" and then hangs for another ~30 seconds,
107
- // during which Ctrl-C falsely reports that nothing was saved. Finish the work, exit.
128
+ // Written for GramJS, which kept "exported senders" alive behind a 30-second release timer
129
+ // that neither disconnect() nor destroy() could clear — both walked a Map with Object.values
130
+ // and missed every entry, so a command printed "Done" and then hung for half a minute, during
131
+ // which Ctrl-C falsely reported that nothing had been saved. teleproto replaced that pool
132
+ // wholesale and closes it on destroy(). This stays anyway: a CLI that has written its last
133
+ // line has nothing left to wait for, and one stray timer in any dependency is all it takes
134
+ // for the wait to come back.
108
135
  function exitWhenFlushed(code) {
109
136
  // The empty writes exist only to borrow their callbacks: they fire after everything
110
137
  // queued earlier has flushed, so nothing is lost when stdout/stderr is not a TTY.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telstore",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Split large files into chunks and store them on Telegram",
5
5
  "keywords": [
6
6
  "telegram",
@@ -37,6 +37,6 @@
37
37
  "test": "node --test 'test/**/*.test.js'"
38
38
  },
39
39
  "dependencies": {
40
- "telegram": "^2.26.22"
40
+ "teleproto": "^1.229.0"
41
41
  }
42
42
  }
package/src/client.js CHANGED
@@ -1,14 +1,14 @@
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'
1
+ import { Api, TelegramClient } from 'teleproto'
2
+ import { Logger } from 'teleproto/extensions/index.js'
3
+ import { LogLevel } from 'teleproto/extensions/Logger.js'
4
+ import { StringSession } from 'teleproto/sessions/index.js'
5
5
 
6
6
  import { manifestFileName } from './manifest.js'
7
7
  import { withRetry } from './retry.js'
8
- import { unlockConfig } from './session.js'
8
+ import { assertLoggedIn, unlockConfig } from './session.js'
9
9
  import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
10
10
 
11
- // GramJS narrates its version, every connection and every disconnect at info level, and
11
+ // teleproto narrates its version, every connection and every disconnect at info level, and
12
12
  // those timestamped lines land in the middle of the progress bar. The client reads this
13
13
  // logger before it prints anything, so LogLevel.NONE silences all of it; --verbose asks
14
14
  // for the running commentary back when a connection needs diagnosing.
@@ -58,7 +58,7 @@ export async function readMessageBytes(client, message) {
58
58
  }
59
59
 
60
60
  // The one place telstore removes messages from a chat, and the mirror of searchDocuments
61
- // above. GramJS has its own deleteMessages, and it is the right thing to call — it resolves
61
+ // above. teleproto has its own deleteMessages, and it is the right thing to call — it resolves
62
62
  // the peer and picks between channels.DeleteMessages and messages.DeleteMessages, which is
63
63
  // exactly the choice a fake client would never catch us getting wrong.
64
64
  //
@@ -87,7 +87,7 @@ export async function deleteMessages(client, peer, ids, options = {}) {
87
87
 
88
88
  await withRetry(
89
89
  () =>
90
- // The options object is not optional: GramJS destructures `{ revoke }` with no
90
+ // The options object is not optional: teleproto destructures `{ revoke }` with no
91
91
  // default of its own, so a two-argument call throws a TypeError before it ever
92
92
  // reaches the network. revoke is passed explicitly anyway — a backup has to go for
93
93
  // everyone who can see the chat, and that intent belongs in our code rather than in
@@ -122,18 +122,6 @@ export async function closeQuietly(client, disconnect, onWarn) {
122
122
  }
123
123
  }
124
124
 
125
- // Two shapes count as logged in: the ordinary one login writes, and the sealed blob that
126
- // "login --token" leaves, which holds the same three fields behind a passphrase. Checked
127
- // before anything asks for that passphrase, so somebody who never logged in is told so rather
128
- // than asked to type a secret for an account that is not there.
129
- export function assertLoggedIn(config) {
130
- if (config.sealed) return
131
-
132
- if (!config.session || !config.apiId || !config.apiHash) {
133
- throw new Error('Not logged in — run "npx telstore login" first.')
134
- }
135
- }
136
-
137
125
  // Every command that needs Telegram comes through here, which makes this the one place a
138
126
  // sealed session has to be opened. Doing it anywhere else would mean eight places to keep in
139
127
  // step, and a ninth command would simply forget.
@@ -1,7 +1,6 @@
1
1
  import { chatName, describeChat } from '../chat.js'
2
2
  import {
3
3
  DELETE_BATCH_SIZE,
4
- assertLoggedIn,
5
4
  closeQuietly,
6
5
  connect as realConnect,
7
6
  deleteMessages as realDeleteMessages,
@@ -12,6 +11,7 @@ import { askConfirm } from '../confirm.js'
12
11
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
13
12
  import { manifestFileName, manifestMessageIds, parseManifestJson } from '../manifest.js'
14
13
  import { formatBytes, formatDuration } from '../progress.js'
14
+ import { assertLoggedIn } from '../session.js'
15
15
  import { requireChat, resolveSettings } from '../settings.js'
16
16
  import { clearState, findStates } from '../state.js'
17
17
 
@@ -1,12 +1,12 @@
1
1
  import { MANIFEST_TAG, parseManifestCaption } from '../caption.js'
2
2
  import { chatName, describeChat } from '../chat.js'
3
3
  import {
4
- assertLoggedIn,
5
4
  closeQuietly,
6
5
  connect as realConnect,
7
6
  searchDocuments,
8
7
  } from '../client.js'
9
8
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
9
+ import { assertLoggedIn } from '../session.js'
10
10
  import { requireChat, resolveSettings } from '../settings.js'
11
11
 
12
12
  const UNKNOWN = '—'
@@ -1,5 +1,5 @@
1
- import { TelegramClient } from 'telegram'
2
- import { StringSession } from 'telegram/sessions/index.js'
1
+ import { TelegramClient } from 'teleproto'
2
+ import { StringSession } from 'teleproto/sessions/index.js'
3
3
 
4
4
  import { loadConfig, saveConfig, defaultConfigDir } from '../config.js'
5
5
  import { normalizeChatTarget } from '../chat.js'
@@ -26,11 +26,12 @@ export function describeLoginError(err) {
26
26
  return `${description} (${message})`
27
27
  }
28
28
 
29
- // GramJS starts an update loop the moment a client connects, and that loop only stops when
30
- // destroy() marks the client destroyed. disconnect() alone leaves it pinging a socket that is
31
- // already closed: every ping fails with "Error: TIMEOUT" and asks the sender to reconnect,
32
- // printed straight over the destination question login asks after signing in. Every other
33
- // command shuts down the same way, and login is the seam tests need to reach it.
29
+ // An update loop starts the moment a client connects, and destroy() is what puts the whole
30
+ // client down — the sender pools included. Under GramJS, disconnect() alone left that loop
31
+ // pinging a closed socket, every ping failing with "Error: TIMEOUT" and asking for a
32
+ // reconnect, printed straight over the destination question login asks after signing in.
33
+ // Every command shuts down through destroy() for that reason, and login is the seam tests
34
+ // need to reach it.
34
35
  const createTelegramClient = (apiId, apiHash, options) =>
35
36
  new TelegramClient(new StringSession(''), apiId, apiHash, options)
36
37
 
@@ -1,8 +1,9 @@
1
1
  import { countChunks } from '../chunking.js'
2
2
  import { describeChat } from '../chat.js'
3
- import { assertLoggedIn, closeQuietly, connect as realConnect } from '../client.js'
3
+ import { closeQuietly, connect as realConnect } from '../client.js'
4
4
  import { configFile, defaultConfigDir, loadConfig } from '../config.js'
5
5
  import { formatBytes } from '../progress.js'
6
+ import { assertLoggedIn } from '../session.js'
6
7
  import { resolveSettings } from '../settings.js'
7
8
  import { canResume, listStates } from '../state.js'
8
9
 
@@ -1,7 +1,6 @@
1
- import { assertLoggedIn } from '../client.js'
2
1
  import { defaultConfigDir, loadConfig } from '../config.js'
3
2
  import { createPrompts } from '../prompt.js'
4
- import { unlockConfig } from '../session.js'
3
+ import { assertLoggedIn, unlockConfig } from '../session.js'
5
4
  import { knownSettings } from '../settings.js'
6
5
  import { encodeToken } from '../token.js'
7
6
 
@@ -1,8 +1,8 @@
1
1
  import { promises as fs } from 'node:fs'
2
2
  import path from 'node:path'
3
3
 
4
- import { Api } from 'telegram'
5
- import { CustomFile } from 'telegram/client/uploads.js'
4
+ import { Api } from 'teleproto'
5
+ import { CustomFile } from 'teleproto/client/uploads.js'
6
6
 
7
7
  import { PART_SIZE, planChunks } from '../chunking.js'
8
8
  import { chunkCaption, manifestCaption } from '../caption.js'
package/src/downloader.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'
2
2
  import { read as readCallback, write as writeCallback } from 'node:fs'
3
3
  import { promisify } from 'node:util'
4
4
 
5
- import { returnBigInt } from 'telegram/Helpers.js'
5
+ import { returnBigInt } from 'teleproto/Helpers.js'
6
6
 
7
7
  import { DEFAULT_DOWNLOAD_CONCURRENCY, PART_SIZE, SLICE_SIZE } from './chunking.js'
8
8
  import { withRetry } from './retry.js'
@@ -105,10 +105,14 @@ export async function downloadToFile(
105
105
  // Iterated by hand rather than with `for await` so each part can be given a deadline:
106
106
  // a stalled stream yields nothing and raises nothing, and only a race against a timer
107
107
  // turns that silence into an error withRetry can act on. Nothing is lost by stepping
108
- // outside `for await` — GramJS's download iterator exposes `next` alone, so breaking
108
+ // outside `for await` — teleproto's download iterator exposes `next` alone, so breaking
109
109
  // out of the loop never closed anything either.
110
- const stream = client.iterDownload({
111
- file: message.media,
110
+ //
111
+ // The media goes in its own argument, not inside the options: teleproto takes
112
+ // `iterDownload(file, params)` where GramJS took a single `{ file, ... }` object, and
113
+ // the options object arriving where the file belongs is not something a fake client
114
+ // would ever refuse — it fails only against the real one, at getFileInfo.
115
+ const stream = client.iterDownload(message.media, {
112
116
  offset: returnBigInt(start + done),
113
117
  requestSize: PART_SIZE,
114
118
  })
package/src/retry.js CHANGED
@@ -10,8 +10,14 @@ function defaultSleep(ms) {
10
10
  return new Promise((resolve) => setTimeout(resolve, ms))
11
11
  }
12
12
 
13
+ // Every "wait this long" answer Telegram gives arrives as code 420 with the seconds on the
14
+ // error itself — FLOOD_WAIT_n, SLOW_MODE_WAIT_n and the rest. Those two fields are what this
15
+ // reads, not how the message is spelled: under GramJS every flood error carried the literal
16
+ // errorMessage "FLOOD", so a check for "FLOOD_WAIT" in it matched nothing and each one was
17
+ // retried on the ordinary backoff — asking again inside a ban that was still running.
18
+ // A 420 with no seconds (a frozen account) has nothing to wait for and takes the backoff.
13
19
  function floodWaitSeconds(err) {
14
- if (typeof err?.seconds === 'number' && String(err?.errorMessage ?? '').includes('FLOOD_WAIT')) {
20
+ if (err?.code === 420 && typeof err.seconds === 'number') {
15
21
  return err.seconds
16
22
  }
17
23
  return null
package/src/session.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readSecret as realReadSecret } from './prompt.js'
2
2
  import { decodeToken } from './token.js'
3
3
 
4
- // How a stored config becomes the three things GramJS needs. There are two shapes on disk —
4
+ // How a stored config becomes the three things teleproto needs. There are two shapes on disk —
5
5
  // the ordinary login, and the sealed blob `login --token` writes — and this is the one place
6
6
  // that knows the difference, so `connect` stays about Telegram and the commands stay about
7
7
  // their own narrative.
@@ -22,3 +22,16 @@ export async function unlockConfig(config, { readSecret = realReadSecret } = {})
22
22
 
23
23
  return { apiId, apiHash, session }
24
24
  }
25
+
26
+ // Two shapes count as logged in: the ordinary one login writes, and the sealed blob that
27
+ // "login --token" leaves, which holds the same three fields behind a passphrase. It lives
28
+ // here rather than beside connect because knowing both shapes is this file's whole job, and
29
+ // because `token` is an offline command: reaching for it through src/client.js pulled the
30
+ // whole of teleproto into a run that never opens a socket.
31
+ export function assertLoggedIn(config) {
32
+ if (config.sealed) return
33
+
34
+ if (!config.session || !config.apiId || !config.apiHash) {
35
+ throw new Error('Not logged in — run "npx telstore login" first.')
36
+ }
37
+ }
package/src/stall.js CHANGED
@@ -10,16 +10,18 @@ const MAX_TIMER_MS = 2 ** 31 - 1
10
10
  // A request that never comes back is not the same as one that fails, and only one of the
11
11
  // two is something withRetry can do anything about.
12
12
  //
13
- // GramJS can leave a request queued on a sender it has quietly given up on. Its own abort
14
- // path is unreachable: MTProtoSender rejects pending states only when
15
- // `_currentRetries > _reconnectRetries`, and `reconnectRetries` has no default to compare
16
- // against, so the test is never true (network/MTProtoSender.js:376). Nor does the reconnect
17
- // itself report failure — `connect()` exhausts its attempts and returns false rather than
18
- // throwing, so `_reconnect()` finishes as if it had worked and puts the request back on a
19
- // queue with no send loop left to drain it (network/MTProtoSender.js:148,795).
13
+ // This was written for a GramJS bug that abandoned requests outright: its abort path tested
14
+ // `_currentRetries > _reconnectRetries` against a `reconnectRetries` of Infinity and so never
15
+ // fired, and `_reconnect()` read a `connect()` that merely returned false as success. A real
16
+ // network cut mid-restore froze a transfer for eleven minutes on Linux and ended it without a
17
+ // printed line on Windows. teleproto closes both halves — `connect()` throws once its attempts
18
+ // are spent, and `_reconnect()` catches that and rejects every pending request.
20
19
  //
21
- // The promise then simply sits there. Nothing throws, nothing prints, and once no handle is
22
- // left the process ends mid-transfer without a word — the one outcome this project forbids.
20
+ // The deadline stays because the library was never the only way to arrive here. A server that
21
+ // accepts a request and answers nothing, a socket that stays open with nothing coming down it:
22
+ // there is no failure for withRetry to see, only silence. The timer is what turns that silence
23
+ // into an error — and, deliberately not unref'd, it is also the handle that stops the event
24
+ // loop running dry and ending a transfer without a word.
23
25
  export async function withStallTimeout(promise, ms, describe) {
24
26
  if (!(ms > 0)) return await promise
25
27
 
package/src/token.js CHANGED
@@ -46,7 +46,7 @@ function isPlainObject(value) {
46
46
  // A token is untrusted input in the same category as a manifest or a state file. Opening one
47
47
  // proves whoever made it knew the passphrase, not that they made it correctly — and the
48
48
  // unprotected format proves nothing at all. An apiId of undefined does not throw here; it
49
- // reaches GramJS and fails much later as something that reads like a network problem.
49
+ // reaches teleproto and fails much later as something that reads like a network problem.
50
50
  export function checkTokenBundle(bundle) {
51
51
  if (!isPlainObject(bundle)) {
52
52
  throw new Error(
package/src/uploader.js CHANGED
@@ -2,8 +2,8 @@ import { createHash, randomBytes } from 'node:crypto'
2
2
  import { read as readCallback } from 'node:fs'
3
3
  import { promisify } from 'node:util'
4
4
 
5
- import { Api } from 'telegram'
6
- import { readBigIntFromBuffer } from 'telegram/Helpers.js'
5
+ import { Api } from 'teleproto'
6
+ import { readBigIntFromBuffer } from 'teleproto/Helpers.js'
7
7
 
8
8
  import { DEFAULT_UPLOAD_CONCURRENCY, PART_SIZE, MAX_PARTS } from './chunking.js'
9
9
  import { withRetry } from './retry.js'
@@ -12,8 +12,8 @@ import { DEFAULT_STALL_MS, withStallTimeout } from './stall.js'
12
12
  const read = promisify(readCallback)
13
13
 
14
14
  // Telegram splits its upload API by file size: only files above 10MB may use the
15
- // "big" family. GramJS picks the same threshold (LARGE_FILE_THRESHOLD in
16
- // node_modules/telegram/client/uploads.js), and telstore follows it.
15
+ // "big" family. teleproto picks the same threshold (LARGE_FILE_THRESHOLD in
16
+ // node_modules/teleproto/client/uploads.js), and telstore follows it.
17
17
  export const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
18
18
 
19
19
  async function readExactly(fd, length, position) {
@@ -105,9 +105,9 @@ export async function uploadRange(client, fd, options) {
105
105
  sending.push(
106
106
  withRetry(
107
107
  () =>
108
- // Same exposure as the download path: a request left on a sender GramJS has
109
- // stopped draining never settles, so without a deadline this await would hold
110
- // the batch open forever and the upload would end without a word.
108
+ // Same exposure as the download path: a request the server accepts and never
109
+ // answers settles neither way, so without a deadline this await would hold the
110
+ // batch open forever and the upload would end without a word.
111
111
  withStallTimeout(
112
112
  client.invoke(partRequest(part, bytes)),
113
113
  stallMs,