@nightowne/tas-cli 2.0.0 → 2.3.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/sync/sync.js CHANGED
@@ -13,6 +13,10 @@ import { processFile } from '../index.js';
13
13
  // Debounce time in ms to batch rapid file changes
14
14
  const DEBOUNCE_MS = 1000;
15
15
 
16
+ // Exponential backoff limits for upload errors
17
+ const INITIAL_BACKOFF_MS = 2000;
18
+ const MAX_BACKOFF_MS = 60000;
19
+
16
20
  // Ignore patterns
17
21
  const IGNORE_PATTERNS = [
18
22
  /^\./, // Hidden files
@@ -29,7 +33,9 @@ export class SyncEngine extends EventEmitter {
29
33
  this.dataDir = options.dataDir;
30
34
  this.password = options.password;
31
35
  this.config = options.config;
36
+ this.limitRate = options.limitRate || null;
32
37
  this.watchers = new Map(); // path -> FSWatcher
38
+ this.rootWatchers = new Map(); // rootPath -> Set of watched subpaths (for Linux recursive watch fallback)
33
39
  this.pendingChanges = new Map(); // path -> timeout
34
40
  this.db = null;
35
41
  this.running = false;
@@ -98,53 +104,76 @@ export class SyncEngine extends EventEmitter {
98
104
  let uploaded = 0;
99
105
  let skipped = 0;
100
106
 
101
- for (const file of files) {
102
- const existing = stateMap.get(file.relativePath);
107
+ // Process files with concurrency limit
108
+ const CONCURRENCY = 4;
109
+ const queue = [...files];
110
+ const promises = [];
103
111
 
104
- // Check if file has changed (by mtime)
105
- if (existing && existing.mtime >= file.mtime) {
106
- skipped++;
107
- continue;
108
- }
112
+ const worker = async () => {
113
+ let consecutiveErrors = 0;
109
114
 
110
- // Calculate hash to detect actual changes
111
- const hash = await hashFile(file.path);
115
+ while (queue.length > 0) {
116
+ const file = queue.shift();
117
+ const existing = stateMap.get(file.relativePath);
112
118
 
113
- if (existing && existing.file_hash === hash) {
114
- // File unchanged, just update mtime
115
- this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
116
- skipped++;
117
- continue;
118
- }
119
+ // Check if file has changed (by mtime)
120
+ if (existing && existing.mtime >= file.mtime) {
121
+ skipped++;
122
+ continue;
123
+ }
119
124
 
120
- // File is new or changed - upload it
121
- try {
122
- this.emit('file-upload-start', { file: file.relativePath });
125
+ // Calculate hash to detect actual changes
126
+ const hash = await hashFile(file.path);
123
127
 
124
- await processFile(file.path, {
125
- password: this.password,
126
- dataDir: this.dataDir,
127
- customName: file.relativePath, // Use relative path as name
128
- config: this.config,
129
- onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
130
- });
128
+ if (existing && existing.file_hash === hash) {
129
+ // File unchanged, just update mtime
130
+ this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
131
+ skipped++;
132
+ continue;
133
+ }
131
134
 
132
- // Update sync state
133
- this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
134
- uploaded++;
135
+ // File is new or changed - upload it
136
+ try {
137
+ this.emit('file-upload-start', { file: file.relativePath });
135
138
 
136
- this.emit('file-upload-complete', { file: file.relativePath });
137
- } catch (err) {
138
- // File might already exist, skip
139
- if (err.message.includes('duplicate')) {
139
+ await processFile(file.path, {
140
+ password: this.password,
141
+ dataDir: this.dataDir,
142
+ customName: file.relativePath, // Use relative path as name
143
+ config: this.config,
144
+ limitRate: this.limitRate ? Math.floor(this.limitRate / CONCURRENCY) : null,
145
+ onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
146
+ });
147
+
148
+ // Update sync state
140
149
  this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
141
- skipped++;
142
- } else {
143
- this.emit('file-upload-error', { file: file.relativePath, error: err.message });
150
+ uploaded++;
151
+ consecutiveErrors = 0; // Reset on success
152
+
153
+ this.emit('file-upload-complete', { file: file.relativePath });
154
+ } catch (err) {
155
+ // File might already exist, skip
156
+ if (err.message.includes('duplicate')) {
157
+ this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
158
+ skipped++;
159
+ consecutiveErrors = 0;
160
+ } else {
161
+ consecutiveErrors++;
162
+ // Exponential backoff: 2s → 4s → 8s → 16s → ... → 60s max
163
+ const delay = Math.min(INITIAL_BACKOFF_MS * Math.pow(2, consecutiveErrors - 1), MAX_BACKOFF_MS);
164
+ await new Promise(r => setTimeout(r, delay));
165
+ this.emit('file-upload-error', { file: file.relativePath, error: err.message });
166
+ }
144
167
  }
145
168
  }
169
+ };
170
+
171
+ for (let i = 0; i < CONCURRENCY; i++) {
172
+ promises.push(worker());
146
173
  }
147
174
 
175
+ await Promise.all(promises);
176
+
148
177
  this.emit('sync-complete', { folder: folderPath, uploaded, skipped });
149
178
 
150
179
  return { uploaded, skipped };
@@ -206,6 +235,7 @@ export class SyncEngine extends EventEmitter {
206
235
  dataDir: this.dataDir,
207
236
  customName: filename,
208
237
  config: this.config,
238
+ limitRate: this.limitRate,
209
239
  onProgress: (msg) => this.emit('progress', { file: filename, message: msg })
210
240
  });
211
241
 
@@ -219,37 +249,148 @@ export class SyncEngine extends EventEmitter {
219
249
  }
220
250
  }
221
251
 
252
+ /**
253
+ * Recursively list all subdirectories of a directory
254
+ */
255
+ getSubdirectories(dirPath) {
256
+ const subdirs = [];
257
+ try {
258
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
259
+ for (const entry of entries) {
260
+ if (entry.isDirectory()) {
261
+ if (this.shouldIgnore(entry.name)) continue;
262
+ const fullPath = path.join(dirPath, entry.name);
263
+ subdirs.push(fullPath);
264
+ subdirs.push(...this.getSubdirectories(fullPath));
265
+ }
266
+ }
267
+ } catch (err) {
268
+ // Ignore folder reading errors
269
+ }
270
+ return subdirs;
271
+ }
272
+
273
+ /**
274
+ * Watch a single directory (non-recursively) and dynamically watch new folders
275
+ */
276
+ watchSingleDir(rootPath, dir) {
277
+ if (this.watchers.has(dir)) return;
278
+
279
+ try {
280
+ const watcher = fs.watch(dir, { recursive: false }, (event, filename) => {
281
+ if (!filename) return;
282
+
283
+ const fullPath = path.join(dir, filename);
284
+ const relativePath = path.relative(rootPath, fullPath);
285
+
286
+ // If a new directory is created, watch it recursively
287
+ try {
288
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
289
+ if (!this.shouldIgnore(filename)) {
290
+ this.watchDirRecursively(rootPath, fullPath);
291
+ }
292
+ }
293
+ } catch (e) {
294
+ // Ignore stats errors
295
+ }
296
+
297
+ this.handleFileChange(rootPath, relativePath);
298
+ });
299
+
300
+ watcher.on('error', (err) => {
301
+ this.emit('watch-error', { folder: rootPath, error: err.message });
302
+ });
303
+
304
+ this.watchers.set(dir, watcher);
305
+
306
+ if (!this.rootWatchers.has(rootPath)) {
307
+ this.rootWatchers.set(rootPath, new Set());
308
+ }
309
+ this.rootWatchers.get(rootPath).add(dir);
310
+ } catch (err) {
311
+ this.emit('watch-error', { folder: rootPath, error: err.message });
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Recursively watch a newly created directory and its subdirectories
317
+ */
318
+ watchDirRecursively(rootPath, dirPath) {
319
+ this.watchSingleDir(rootPath, dirPath);
320
+ const subdirs = this.getSubdirectories(dirPath);
321
+ for (const subdir of subdirs) {
322
+ this.watchSingleDir(rootPath, subdir);
323
+ }
324
+ }
325
+
222
326
  /**
223
327
  * Start watching a folder
224
328
  */
225
329
  watchFolder(folderPath) {
226
- if (this.watchers.has(folderPath)) {
227
- return; // Already watching
228
- }
330
+ const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
331
+
332
+ if (isRecursiveSupported) {
333
+ if (this.watchers.has(folderPath)) {
334
+ return; // Already watching
335
+ }
336
+
337
+ const watcher = fs.watch(folderPath, { recursive: true }, (event, filename) => {
338
+ if (filename) {
339
+ this.handleFileChange(folderPath, filename);
340
+ }
341
+ });
229
342
 
230
- const watcher = fs.watch(folderPath, { recursive: true }, (event, filename) => {
231
- if (filename) {
232
- this.handleFileChange(folderPath, filename);
343
+ watcher.on('error', (err) => {
344
+ this.emit('watch-error', { folder: folderPath, error: err.message });
345
+ });
346
+
347
+ this.watchers.set(folderPath, watcher);
348
+ this.emit('watch-start', { folder: folderPath });
349
+ } else {
350
+ // Manual recursive watch for Linux/other platforms
351
+ if (this.rootWatchers.has(folderPath)) {
352
+ return; // Already watching
233
353
  }
234
- });
235
354
 
236
- watcher.on('error', (err) => {
237
- this.emit('watch-error', { folder: folderPath, error: err.message });
238
- });
355
+ // Watch root
356
+ this.watchSingleDir(folderPath, folderPath);
357
+
358
+ // Watch all existing subdirectories
359
+ const subdirs = this.getSubdirectories(folderPath);
360
+ for (const subdir of subdirs) {
361
+ this.watchSingleDir(folderPath, subdir);
362
+ }
239
363
 
240
- this.watchers.set(folderPath, watcher);
241
- this.emit('watch-start', { folder: folderPath });
364
+ this.emit('watch-start', { folder: folderPath });
365
+ }
242
366
  }
243
367
 
244
368
  /**
245
369
  * Stop watching a folder
246
370
  */
247
371
  unwatchFolder(folderPath) {
248
- const watcher = this.watchers.get(folderPath);
249
- if (watcher) {
250
- watcher.close();
251
- this.watchers.delete(folderPath);
252
- this.emit('watch-stop', { folder: folderPath });
372
+ const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
373
+
374
+ if (isRecursiveSupported) {
375
+ const watcher = this.watchers.get(folderPath);
376
+ if (watcher) {
377
+ watcher.close();
378
+ this.watchers.delete(folderPath);
379
+ this.emit('watch-stop', { folder: folderPath });
380
+ }
381
+ } else {
382
+ if (this.rootWatchers.has(folderPath)) {
383
+ const dirs = this.rootWatchers.get(folderPath);
384
+ for (const dir of dirs) {
385
+ const watcher = this.watchers.get(dir);
386
+ if (watcher) {
387
+ watcher.close();
388
+ this.watchers.delete(dir);
389
+ }
390
+ }
391
+ this.rootWatchers.delete(folderPath);
392
+ this.emit('watch-stop', { folder: folderPath });
393
+ }
253
394
  }
254
395
  }
255
396
 
@@ -283,11 +424,25 @@ export class SyncEngine extends EventEmitter {
283
424
  this.pendingChanges.clear();
284
425
 
285
426
  // Close all watchers
286
- for (const [folderPath, watcher] of this.watchers) {
287
- watcher.close();
288
- this.emit('watch-stop', { folder: folderPath });
427
+ const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
428
+ if (isRecursiveSupported) {
429
+ for (const [folderPath, watcher] of this.watchers) {
430
+ watcher.close();
431
+ this.emit('watch-stop', { folder: folderPath });
432
+ }
433
+ this.watchers.clear();
434
+ } else {
435
+ for (const rootPath of this.rootWatchers.keys()) {
436
+ this.unwatchFolder(rootPath);
437
+ }
438
+ this.rootWatchers.clear();
439
+
440
+ // Just in case, close any stray watchers
441
+ for (const watcher of this.watchers.values()) {
442
+ try { watcher.close(); } catch (e) {}
443
+ }
444
+ this.watchers.clear();
289
445
  }
290
- this.watchers.clear();
291
446
 
292
447
  if (this.db) {
293
448
  this.db.close();
@@ -1,25 +1,66 @@
1
1
  /**
2
2
  * Telegram Bot client wrapper
3
3
  * Uses official Telegram Bot API - 2GB file limit, FREE, no ban risk!
4
+ * Includes exponential backoff retry and rate limiting for production reliability.
4
5
  */
5
6
 
6
7
  import TelegramBot from 'node-telegram-bot-api';
7
8
  import fs from 'fs';
8
9
  import path from 'path';
10
+ import { pipeline } from 'stream/promises';
11
+
12
+ const MAX_RETRIES = 5;
13
+ const BASE_DELAY_MS = 1000;
14
+ const MAX_DELAY_MS = 60000;
15
+
16
+ /**
17
+ * Retry an async function with exponential backoff + jitter.
18
+ * Handles Telegram 429 (rate limit) errors by respecting retry_after.
19
+ */
20
+ async function withRetry(fn, label = 'operation', retries = MAX_RETRIES) {
21
+ for (let attempt = 0; attempt <= retries; attempt++) {
22
+ try {
23
+ return await fn();
24
+ } catch (err) {
25
+ const isRateLimit = err?.response?.statusCode === 429 || err?.message?.includes('429');
26
+ const isTransient = err?.code === 'ETIMEOUT' || err?.code === 'ECONNRESET' ||
27
+ err?.code === 'ENOTFOUND' || err?.message?.includes('ETIMEDOUT');
28
+
29
+ if (attempt >= retries) throw err;
30
+ if (!isRateLimit && !isTransient) throw err;
31
+
32
+ let delay;
33
+ if (isRateLimit && err?.response?.body?.parameters?.retry_after) {
34
+ delay = err.response.body.parameters.retry_after * 1000 + 500;
35
+ } else {
36
+ delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 1000, MAX_DELAY_MS);
37
+ }
38
+
39
+ const sec = (delay / 1000).toFixed(1);
40
+ console.log(`⏳ ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${sec}s...`);
41
+ await new Promise(r => setTimeout(r, delay));
42
+ }
43
+ }
44
+ }
9
45
 
10
46
  export class TelegramClient {
11
47
  constructor(dataDir) {
12
48
  this.dataDir = dataDir;
13
49
  this.bot = null;
14
50
  this.chatId = null;
51
+ this._lastSendTime = 0;
15
52
  }
16
53
 
17
54
  /**
18
55
  * Initialize with bot token
19
56
  * Get token from @BotFather on Telegram
20
57
  */
21
- async initialize(token) {
22
- this.bot = new TelegramBot(token, { polling: false });
58
+ async initialize(token, customApiUrl = null) {
59
+ const options = { polling: false };
60
+ if (customApiUrl) {
61
+ options.baseApiUrl = customApiUrl;
62
+ }
63
+ this.bot = new TelegramBot(token, options);
23
64
 
24
65
  // Verify the token works
25
66
  try {
@@ -71,49 +112,84 @@ export class TelegramClient {
71
112
  });
72
113
  }
73
114
 
115
+ /**
116
+ * Rate-limit: ensure at least 1s between sends to same chat (Telegram limit)
117
+ */
118
+ async _rateLimit() {
119
+ const now = Date.now();
120
+ const elapsed = now - this._lastSendTime;
121
+ if (elapsed < 1000) {
122
+ await new Promise(r => setTimeout(r, 1000 - elapsed));
123
+ }
124
+ this._lastSendTime = Date.now();
125
+ }
126
+
74
127
  /**
75
128
  * Send a file to the storage chat
76
129
  * Telegram supports up to 2GB for documents!
130
+ * Includes automatic retry with exponential backoff.
77
131
  */
78
- async sendFile(filePath, caption = '') {
132
+ async sendFile(filePath, caption = '', options = {}) {
79
133
  if (!this.chatId) {
80
134
  throw new Error('Chat ID not set. Run init first.');
81
135
  }
82
136
 
83
- const fileStream = fs.createReadStream(filePath);
137
+ if (!fs.existsSync(filePath)) {
138
+ throw new Error(`File not found: ${filePath}`);
139
+ }
140
+
84
141
  const filename = path.basename(filePath);
85
142
 
86
- const message = await this.bot.sendDocument(this.chatId, fileStream, {
87
- caption: caption
88
- }, {
89
- filename: filename,
90
- contentType: 'application/octet-stream'
91
- });
143
+ return withRetry(async () => {
144
+ await this._rateLimit();
145
+
146
+ let fileStream = fs.createReadStream(filePath);
147
+
148
+ if (options.limitRate) {
149
+ const { Throttle } = await import('../utils/throttle.js');
150
+ fileStream = fileStream.pipe(new Throttle(options.limitRate));
151
+ }
152
+
153
+ const message = await this.bot.sendDocument(this.chatId, fileStream, {
154
+ caption: caption
155
+ }, {
156
+ filename: filename,
157
+ contentType: 'application/octet-stream'
158
+ });
92
159
 
93
- return {
94
- messageId: message.message_id,
95
- fileId: message.document.file_id,
96
- timestamp: message.date
97
- };
160
+ return {
161
+ messageId: message.message_id,
162
+ fileId: message.document.file_id,
163
+ timestamp: message.date
164
+ };
165
+ }, `Upload ${filename}`);
98
166
  }
99
167
 
100
168
  /**
101
- * Download a file from Telegram
169
+ * Download a file from Telegram (In-Memory buffer)
170
+ * Includes automatic retry with exponential backoff.
102
171
  */
103
172
  async downloadFile(fileId) {
104
- // Get file path from Telegram servers
105
- const file = await this.bot.getFile(fileId);
173
+ return withRetry(async () => {
174
+ const fileStream = await this.bot.getFileStream(fileId);
106
175
 
107
- // Download the file
108
- const fileStream = await this.bot.getFileStream(fileId);
176
+ const chunks = [];
177
+ for await (const chunk of fileStream) {
178
+ chunks.push(chunk);
179
+ }
109
180
 
110
- // Collect chunks into buffer
111
- const chunks = [];
112
- for await (const chunk of fileStream) {
113
- chunks.push(chunk);
114
- }
181
+ return Buffer.concat(chunks);
182
+ }, `Download ${fileId.substring(0, 12)}...`);
183
+ }
115
184
 
116
- return Buffer.concat(chunks);
185
+ /**
186
+ * Efficiently download a file from Telegram straight to disk
187
+ */
188
+ async downloadFileToPath(fileId, destPath) {
189
+ const fileStream = await this.bot.getFileStream(fileId);
190
+ const writeStream = fs.createWriteStream(destPath);
191
+ await pipeline(fileStream, writeStream);
192
+ return destPath;
117
193
  }
118
194
 
119
195
  /**
@@ -3,6 +3,13 @@
3
3
  */
4
4
 
5
5
  import chalk from 'chalk';
6
+ import { readFileSync } from 'fs';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname, join } from 'path';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
6
13
 
7
14
  export const LOGO = `
8
15
  ████████╗ █████╗ ███████╗
@@ -14,7 +21,7 @@ export const LOGO = `
14
21
  `;
15
22
 
16
23
  export const TAGLINE = 'Telegram as Storage';
17
- export const VERSION = '1.0.0';
24
+ export const VERSION = pkg.version;
18
25
 
19
26
  /**
20
27
  * Print the TAS banner
@@ -57,10 +64,10 @@ export function warn(msg) {
57
64
  * Format file size
58
65
  */
59
66
  export function formatSize(bytes) {
60
- if (bytes === 0) return '0 B';
67
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
61
68
  const k = 1024;
62
69
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
63
- const i = Math.floor(Math.log(bytes) / Math.log(k));
70
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
64
71
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
65
72
  }
66
73
 
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * File chunking utilities for large files
3
- * WhatsApp document limit is ~2GB, we use 1.9GB chunks to be safe
3
+ * Telegram Bot API limit is 50 MB for bot uploads (sendDocument).
4
+ * We use 49 MB to leave room for the 64-byte WAS1 header.
5
+ * See: https://core.telegram.org/bots/api#senddocument
4
6
  */
5
7
 
6
- const MAX_CHUNK_SIZE = 1.9 * 1024 * 1024 * 1024; // 1.9 GB
8
+ const MAX_CHUNK_SIZE = 49 * 1024 * 1024; // 49 MB — Telegram Bot API safe limit
7
9
 
8
10
  export class Chunker {
9
11
  /**
@@ -91,7 +93,17 @@ export function createHeader(filename, originalSize, chunkIndex, totalChunks, fl
91
93
  offset += 2;
92
94
 
93
95
  // Filename length
94
- const filenameBytes = Buffer.from(filename, 'utf-8').subarray(0, 42);
96
+ let filenameBytes = Buffer.from(filename, 'utf-8');
97
+ if (filenameBytes.length > 42) {
98
+ // We carefully truncate by chars instead of bytes to avoid splitting a UTF-8 character in half!
99
+ let truncated = filename;
100
+ while (Buffer.from(truncated, 'utf-8').length > 42) {
101
+ // Remove one character at a time from the end
102
+ truncated = truncated.slice(0, -1);
103
+ }
104
+ filenameBytes = Buffer.from(truncated, 'utf-8');
105
+ }
106
+
95
107
  header.writeUInt16LE(filenameBytes.length, offset);
96
108
  offset += 2;
97
109
 
@@ -39,14 +39,13 @@ export async function getPassword(passwordOption, allowCache = true) {
39
39
  }
40
40
 
41
41
  /**
42
- * Verify password against config
42
+ * Verify password against config (supports both legacy and new hash formats)
43
43
  * @param {string} password - Password to verify
44
44
  * @param {Object} config - Config object with passwordHash
45
45
  * @returns {boolean}
46
46
  */
47
47
  export function verifyPassword(password, config) {
48
- const encryptor = new Encryptor(password);
49
- return encryptor.getPasswordHash() === config.passwordHash;
48
+ return Encryptor.verifyPasswordHash(password, config.passwordHash);
50
49
  }
51
50
 
52
51
  /**
@@ -62,11 +61,13 @@ export function validateConfig(config) {
62
61
  return { valid: false, errors };
63
62
  }
64
63
 
65
- if (!config.botToken || typeof config.botToken !== 'string') {
66
- errors.push('Missing or invalid botToken');
64
+ // v2: encrypted token, v1: plaintext token
65
+ const hasToken = config.encryptedBotToken || config.botToken;
66
+ if (!hasToken) {
67
+ errors.push('Missing bot token (botToken or encryptedBotToken)');
67
68
  }
68
69
 
69
- if (!config.botToken?.includes(':')) {
70
+ if (config.botToken && !config.botToken.includes(':')) {
70
71
  errors.push('Invalid bot token format (should contain :)');
71
72
  }
72
73
 
@@ -84,6 +85,29 @@ export function validateConfig(config) {
84
85
  };
85
86
  }
86
87
 
88
+ /**
89
+ * Decrypt bot token from config using password
90
+ * Supports both v1 (plaintext) and v2 (encrypted) configs
91
+ * @param {Object} config - Config object
92
+ * @param {string} password - User's password
93
+ * @returns {string} - Decrypted bot token
94
+ */
95
+ export function decryptBotToken(config, password) {
96
+ // v1: plaintext token (backward compatibility)
97
+ if (config.botToken) {
98
+ return config.botToken;
99
+ }
100
+
101
+ // v2: encrypted token
102
+ if (config.encryptedBotToken) {
103
+ const encryptor = new Encryptor(password);
104
+ const encryptedBuffer = Buffer.from(config.encryptedBotToken, 'base64');
105
+ return encryptor.decrypt(encryptedBuffer).toString('utf-8');
106
+ }
107
+
108
+ throw new Error('No bot token found in config');
109
+ }
110
+
87
111
  /**
88
112
  * Load and validate config
89
113
  * @param {string} dataDir - Data directory path
@@ -143,3 +167,17 @@ export async function getAndVerifyPassword(passwordOption, dataDir) {
143
167
 
144
168
  return password;
145
169
  }
170
+
171
+ /**
172
+ * Resolve config by decrypting bot token if needed.
173
+ * Returns a config object with a guaranteed plaintext `botToken` field.
174
+ * @param {Object} config - Raw config from disk
175
+ * @param {string} password - Verified password
176
+ * @returns {Object} - Config with decrypted botToken
177
+ */
178
+ export function resolveConfig(config, password) {
179
+ return {
180
+ ...config,
181
+ botToken: decryptBotToken(config, password)
182
+ };
183
+ }