@nightowne/tas-cli 1.0.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/index.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Main processing module
3
+ * Orchestrates the upload/download pipeline
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { Encryptor, hashFile } from './crypto/encryption.js';
9
+ import { Compressor } from './utils/compression.js';
10
+ import { Chunker, createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
11
+ import { TelegramClient } from './telegram/client.js';
12
+ import { FileIndex } from './db/index.js';
13
+
14
+ // Telegram has 50MB limit for bots, 2GB for user uploads
15
+ // We'll use 49MB chunks to be safe
16
+ const TELEGRAM_CHUNK_SIZE = 49 * 1024 * 1024;
17
+
18
+ /**
19
+ * Process and upload a file to Telegram
20
+ */
21
+ export async function processFile(filePath, options) {
22
+ const { password, dataDir, customName, config, onProgress } = options;
23
+
24
+ onProgress?.('Reading file...');
25
+
26
+ // Read file
27
+ const filename = customName || path.basename(filePath);
28
+ const fileData = fs.readFileSync(filePath);
29
+ const originalSize = fileData.length;
30
+
31
+ // Calculate hash
32
+ onProgress?.('Calculating hash...');
33
+ const hash = await hashFile(filePath);
34
+
35
+ // Check if already uploaded
36
+ const db = new FileIndex(path.join(dataDir, 'index.db'));
37
+ db.init();
38
+
39
+ if (db.exists(hash)) {
40
+ db.close();
41
+ throw new Error('File already uploaded (duplicate hash)');
42
+ }
43
+
44
+ // Compress
45
+ onProgress?.('Compressing...');
46
+ const compressor = new Compressor();
47
+ const { data: compressedData, compressed } = await compressor.compress(fileData, filename);
48
+
49
+ // Encrypt
50
+ onProgress?.('Encrypting...');
51
+ const encryptor = new Encryptor(password);
52
+ const encryptedData = encryptor.encrypt(compressedData);
53
+
54
+ // Chunk if needed (Telegram bot limit ~50MB per file)
55
+ onProgress?.('Preparing chunks...');
56
+ const chunks = [];
57
+ const numChunks = Math.ceil(encryptedData.length / TELEGRAM_CHUNK_SIZE);
58
+
59
+ for (let i = 0; i < numChunks; i++) {
60
+ const start = i * TELEGRAM_CHUNK_SIZE;
61
+ const end = Math.min(start + TELEGRAM_CHUNK_SIZE, encryptedData.length);
62
+ chunks.push({
63
+ index: i,
64
+ total: numChunks,
65
+ data: encryptedData.subarray(start, end)
66
+ });
67
+ }
68
+
69
+ // Prepare files with headers
70
+ const tempDir = path.join(dataDir, 'tmp');
71
+ if (!fs.existsSync(tempDir)) {
72
+ fs.mkdirSync(tempDir, { recursive: true });
73
+ }
74
+
75
+ const chunkFiles = [];
76
+ const flags = compressed ? 1 : 0;
77
+
78
+ for (const chunk of chunks) {
79
+ const header = createHeader(filename, originalSize, chunk.index, chunk.total, flags);
80
+ const chunkData = Buffer.concat([header, chunk.data]);
81
+
82
+ const chunkFilename = chunks.length > 1
83
+ ? `${hash.substring(0, 12)}.part${chunk.index}.tas`
84
+ : `${hash.substring(0, 12)}.tas`;
85
+
86
+ const chunkPath = path.join(tempDir, chunkFilename);
87
+ fs.writeFileSync(chunkPath, chunkData);
88
+
89
+ chunkFiles.push({
90
+ index: chunk.index,
91
+ path: chunkPath,
92
+ size: chunkData.length
93
+ });
94
+ }
95
+
96
+ // Connect to Telegram
97
+ onProgress?.('Connecting to Telegram...');
98
+ const client = new TelegramClient(dataDir);
99
+ await client.initialize(config.botToken);
100
+ client.setChatId(config.chatId);
101
+
102
+ // Upload chunks
103
+ const fileId = db.addFile({
104
+ filename,
105
+ hash,
106
+ originalSize,
107
+ storedSize: encryptedData.length,
108
+ chunks: chunks.length,
109
+ compressed
110
+ });
111
+
112
+ for (const chunk of chunkFiles) {
113
+ onProgress?.(`Uploading chunk ${chunk.index + 1}/${chunkFiles.length}...`);
114
+
115
+ const caption = chunks.length > 1
116
+ ? `📦 ${filename} (${chunk.index + 1}/${chunks.length})`
117
+ : `📦 ${filename}`;
118
+
119
+ const result = await client.sendFile(chunk.path, caption);
120
+
121
+ // Store file_id instead of message_id for downloads
122
+ db.addChunk(fileId, chunk.index, result.messageId.toString(), chunk.size);
123
+
124
+ // Also store file_id for easier retrieval
125
+ db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
126
+ .run(result.fileId, fileId, chunk.index);
127
+
128
+ // Clean up temp file
129
+ fs.unlinkSync(chunk.path);
130
+ }
131
+
132
+ db.close();
133
+
134
+ // Clean up temp dir
135
+ try {
136
+ fs.rmdirSync(tempDir);
137
+ } catch (e) {
138
+ // Ignore if not empty
139
+ }
140
+
141
+ return {
142
+ filename,
143
+ hash,
144
+ originalSize,
145
+ storedSize: encryptedData.length,
146
+ chunks: chunks.length,
147
+ compressed
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Retrieve a file from Telegram
153
+ */
154
+ export async function retrieveFile(fileRecord, options) {
155
+ const { password, dataDir, outputPath, config, onProgress } = options;
156
+
157
+ onProgress?.('Connecting to Telegram...');
158
+
159
+ // Get chunk info
160
+ const db = new FileIndex(path.join(dataDir, 'index.db'));
161
+ db.init();
162
+
163
+ const chunks = db.getChunks(fileRecord.id);
164
+ db.close();
165
+
166
+ if (chunks.length === 0) {
167
+ throw new Error('No chunk metadata found for this file');
168
+ }
169
+
170
+ // Connect to Telegram
171
+ const client = new TelegramClient(dataDir);
172
+ await client.initialize(config.botToken);
173
+ client.setChatId(config.chatId);
174
+
175
+ // Download all chunks
176
+ const downloadedChunks = [];
177
+
178
+ for (const chunk of chunks) {
179
+ onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
180
+
181
+ const data = await client.downloadFile(chunk.file_telegram_id);
182
+
183
+ // Parse header
184
+ const header = parseHeader(data);
185
+ const payload = data.subarray(HEADER_SIZE);
186
+
187
+ downloadedChunks.push({
188
+ index: header.chunkIndex,
189
+ total: header.totalChunks,
190
+ data: payload,
191
+ compressed: header.compressed
192
+ });
193
+ }
194
+
195
+ // Reassemble
196
+ onProgress?.('Reassembling...');
197
+ downloadedChunks.sort((a, b) => a.index - b.index);
198
+ const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
199
+
200
+ // Decrypt
201
+ onProgress?.('Decrypting...');
202
+ const encryptor = new Encryptor(password);
203
+ const compressedData = encryptor.decrypt(encryptedData);
204
+
205
+ // Decompress
206
+ onProgress?.('Decompressing...');
207
+ const compressor = new Compressor();
208
+ const wasCompressed = downloadedChunks[0].compressed;
209
+ const originalData = await compressor.decompress(compressedData, wasCompressed);
210
+
211
+ // Write to output
212
+ onProgress?.('Writing file...');
213
+ fs.writeFileSync(outputPath, originalData);
214
+
215
+ return {
216
+ path: outputPath,
217
+ size: originalData.length
218
+ };
219
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * SyncEngine - Watches folders and syncs to Telegram
3
+ * Dropbox-like auto-sync functionality
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { EventEmitter } from 'events';
9
+ import { FileIndex } from '../db/index.js';
10
+ import { hashFile } from '../crypto/encryption.js';
11
+ import { processFile } from '../index.js';
12
+
13
+ // Debounce time in ms to batch rapid file changes
14
+ const DEBOUNCE_MS = 1000;
15
+
16
+ // Ignore patterns
17
+ const IGNORE_PATTERNS = [
18
+ /^\./, // Hidden files
19
+ /~$/, // Backup files
20
+ /\.swp$/, // Vim swap files
21
+ /\.tmp$/, // Temp files
22
+ /node_modules/,
23
+ /\.git/
24
+ ];
25
+
26
+ export class SyncEngine extends EventEmitter {
27
+ constructor(options) {
28
+ super();
29
+ this.dataDir = options.dataDir;
30
+ this.password = options.password;
31
+ this.config = options.config;
32
+ this.watchers = new Map(); // path -> FSWatcher
33
+ this.pendingChanges = new Map(); // path -> timeout
34
+ this.db = null;
35
+ this.running = false;
36
+ }
37
+
38
+ /**
39
+ * Initialize the sync engine
40
+ */
41
+ async initialize() {
42
+ this.db = new FileIndex(path.join(this.dataDir, 'index.db'));
43
+ this.db.init();
44
+ }
45
+
46
+ /**
47
+ * Check if a file should be ignored
48
+ */
49
+ shouldIgnore(filename) {
50
+ return IGNORE_PATTERNS.some(pattern => pattern.test(filename));
51
+ }
52
+
53
+ /**
54
+ * Get all files in a directory recursively
55
+ */
56
+ async scanDirectory(dirPath, relativeTo = dirPath) {
57
+ const files = [];
58
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
59
+
60
+ for (const entry of entries) {
61
+ if (this.shouldIgnore(entry.name)) continue;
62
+
63
+ const fullPath = path.join(dirPath, entry.name);
64
+ const relativePath = path.relative(relativeTo, fullPath);
65
+
66
+ if (entry.isDirectory()) {
67
+ const subFiles = await this.scanDirectory(fullPath, relativeTo);
68
+ files.push(...subFiles);
69
+ } else if (entry.isFile()) {
70
+ const stats = fs.statSync(fullPath);
71
+ files.push({
72
+ path: fullPath,
73
+ relativePath,
74
+ mtime: stats.mtimeMs,
75
+ size: stats.size
76
+ });
77
+ }
78
+ }
79
+
80
+ return files;
81
+ }
82
+
83
+ /**
84
+ * Sync a single folder - initial scan
85
+ */
86
+ async syncFolder(folderPath) {
87
+ const folder = this.db.getSyncFolderByPath(folderPath);
88
+ if (!folder) {
89
+ throw new Error(`Folder not registered: ${folderPath}`);
90
+ }
91
+
92
+ this.emit('sync-start', { folder: folderPath });
93
+
94
+ const files = await this.scanDirectory(folderPath);
95
+ const existingStates = this.db.getFolderSyncStates(folder.id);
96
+ const stateMap = new Map(existingStates.map(s => [s.relative_path, s]));
97
+
98
+ let uploaded = 0;
99
+ let skipped = 0;
100
+
101
+ for (const file of files) {
102
+ const existing = stateMap.get(file.relativePath);
103
+
104
+ // Check if file has changed (by mtime)
105
+ if (existing && existing.mtime >= file.mtime) {
106
+ skipped++;
107
+ continue;
108
+ }
109
+
110
+ // Calculate hash to detect actual changes
111
+ const hash = await hashFile(file.path);
112
+
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
+
120
+ // File is new or changed - upload it
121
+ try {
122
+ this.emit('file-upload-start', { file: file.relativePath });
123
+
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
+ });
131
+
132
+ // Update sync state
133
+ this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
134
+ uploaded++;
135
+
136
+ this.emit('file-upload-complete', { file: file.relativePath });
137
+ } catch (err) {
138
+ // File might already exist, skip
139
+ if (err.message.includes('duplicate')) {
140
+ 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 });
144
+ }
145
+ }
146
+ }
147
+
148
+ this.emit('sync-complete', { folder: folderPath, uploaded, skipped });
149
+
150
+ return { uploaded, skipped };
151
+ }
152
+
153
+ /**
154
+ * Handle a file change event (debounced)
155
+ */
156
+ handleFileChange(folderPath, filename) {
157
+ if (this.shouldIgnore(filename)) return;
158
+
159
+ const fullPath = path.join(folderPath, filename);
160
+ const key = fullPath;
161
+
162
+ // Clear existing timeout
163
+ if (this.pendingChanges.has(key)) {
164
+ clearTimeout(this.pendingChanges.get(key));
165
+ }
166
+
167
+ // Set new debounced handler
168
+ const timeout = setTimeout(async () => {
169
+ this.pendingChanges.delete(key);
170
+ await this.processFileChange(folderPath, filename);
171
+ }, DEBOUNCE_MS);
172
+
173
+ this.pendingChanges.set(key, timeout);
174
+ }
175
+
176
+ /**
177
+ * Process a file change after debounce
178
+ */
179
+ async processFileChange(folderPath, filename) {
180
+ const fullPath = path.join(folderPath, filename);
181
+
182
+ // Check if file still exists
183
+ if (!fs.existsSync(fullPath)) {
184
+ this.emit('file-deleted', { file: filename });
185
+ return;
186
+ }
187
+
188
+ const stats = fs.statSync(fullPath);
189
+ if (!stats.isFile()) return;
190
+
191
+ const folder = this.db.getSyncFolderByPath(folderPath);
192
+ if (!folder) return;
193
+
194
+ try {
195
+ const hash = await hashFile(fullPath);
196
+ const existing = this.db.getSyncState(folder.id, filename);
197
+
198
+ if (existing && existing.file_hash === hash) {
199
+ return; // No actual change
200
+ }
201
+
202
+ this.emit('file-upload-start', { file: filename });
203
+
204
+ await processFile(fullPath, {
205
+ password: this.password,
206
+ dataDir: this.dataDir,
207
+ customName: filename,
208
+ config: this.config,
209
+ onProgress: (msg) => this.emit('progress', { file: filename, message: msg })
210
+ });
211
+
212
+ this.db.updateSyncState(folder.id, filename, hash, stats.mtimeMs);
213
+
214
+ this.emit('file-upload-complete', { file: filename });
215
+ } catch (err) {
216
+ if (!err.message.includes('duplicate')) {
217
+ this.emit('file-upload-error', { file: filename, error: err.message });
218
+ }
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Start watching a folder
224
+ */
225
+ watchFolder(folderPath) {
226
+ if (this.watchers.has(folderPath)) {
227
+ return; // Already watching
228
+ }
229
+
230
+ const watcher = fs.watch(folderPath, { recursive: true }, (event, filename) => {
231
+ if (filename) {
232
+ this.handleFileChange(folderPath, filename);
233
+ }
234
+ });
235
+
236
+ watcher.on('error', (err) => {
237
+ this.emit('watch-error', { folder: folderPath, error: err.message });
238
+ });
239
+
240
+ this.watchers.set(folderPath, watcher);
241
+ this.emit('watch-start', { folder: folderPath });
242
+ }
243
+
244
+ /**
245
+ * Stop watching a folder
246
+ */
247
+ 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 });
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Start syncing all registered folders
258
+ */
259
+ async start() {
260
+ this.running = true;
261
+ const folders = this.db.getSyncFolders();
262
+
263
+ for (const folder of folders) {
264
+ if (folder.enabled) {
265
+ // Initial sync
266
+ await this.syncFolder(folder.local_path);
267
+ // Start watching
268
+ this.watchFolder(folder.local_path);
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Stop all watchers
275
+ */
276
+ stop() {
277
+ this.running = false;
278
+
279
+ // Clear pending changes
280
+ for (const timeout of this.pendingChanges.values()) {
281
+ clearTimeout(timeout);
282
+ }
283
+ this.pendingChanges.clear();
284
+
285
+ // Close all watchers
286
+ for (const [folderPath, watcher] of this.watchers) {
287
+ watcher.close();
288
+ this.emit('watch-stop', { folder: folderPath });
289
+ }
290
+ this.watchers.clear();
291
+
292
+ if (this.db) {
293
+ this.db.close();
294
+ this.db = null;
295
+ }
296
+ }
297
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Telegram Bot client wrapper
3
+ * Uses official Telegram Bot API - 2GB file limit, FREE, no ban risk!
4
+ */
5
+
6
+ import TelegramBot from 'node-telegram-bot-api';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+
10
+ export class TelegramClient {
11
+ constructor(dataDir) {
12
+ this.dataDir = dataDir;
13
+ this.bot = null;
14
+ this.chatId = null;
15
+ }
16
+
17
+ /**
18
+ * Initialize with bot token
19
+ * Get token from @BotFather on Telegram
20
+ */
21
+ async initialize(token) {
22
+ this.bot = new TelegramBot(token, { polling: false });
23
+
24
+ // Verify the token works
25
+ try {
26
+ const me = await this.bot.getMe();
27
+ console.log(`✓ Connected as @${me.username}`);
28
+ return me;
29
+ } catch (err) {
30
+ throw new Error(`Invalid bot token: ${err.message}`);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Set the chat ID for storage (your personal chat with the bot)
36
+ */
37
+ setChatId(chatId) {
38
+ this.chatId = chatId;
39
+ }
40
+
41
+ /**
42
+ * Wait for user to send a message to get their chat ID
43
+ * This is needed for first-time setup
44
+ */
45
+ async waitForChatId(timeout = 120000) {
46
+ return new Promise((resolve, reject) => {
47
+ const pollingBot = new TelegramBot(this.bot.token, { polling: true });
48
+
49
+ const timer = setTimeout(() => {
50
+ pollingBot.stopPolling();
51
+ reject(new Error('Timeout waiting for message. Please message your bot on Telegram.'));
52
+ }, timeout);
53
+
54
+ pollingBot.on('message', (msg) => {
55
+ clearTimeout(timer);
56
+ pollingBot.stopPolling();
57
+ this.chatId = msg.chat.id;
58
+ resolve({
59
+ chatId: msg.chat.id,
60
+ username: msg.from.username,
61
+ firstName: msg.from.first_name
62
+ });
63
+ });
64
+
65
+ pollingBot.on('polling_error', (err) => {
66
+ // Ignore polling errors during shutdown
67
+ if (!err.message.includes('ETELEGRAM')) {
68
+ console.error('Polling error:', err.message);
69
+ }
70
+ });
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Send a file to the storage chat
76
+ * Telegram supports up to 2GB for documents!
77
+ */
78
+ async sendFile(filePath, caption = '') {
79
+ if (!this.chatId) {
80
+ throw new Error('Chat ID not set. Run init first.');
81
+ }
82
+
83
+ const fileStream = fs.createReadStream(filePath);
84
+ const filename = path.basename(filePath);
85
+
86
+ const message = await this.bot.sendDocument(this.chatId, fileStream, {
87
+ caption: caption
88
+ }, {
89
+ filename: filename,
90
+ contentType: 'application/octet-stream'
91
+ });
92
+
93
+ return {
94
+ messageId: message.message_id,
95
+ fileId: message.document.file_id,
96
+ timestamp: message.date
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Download a file from Telegram
102
+ */
103
+ async downloadFile(fileId) {
104
+ // Get file path from Telegram servers
105
+ const file = await this.bot.getFile(fileId);
106
+
107
+ // Download the file
108
+ const fileStream = await this.bot.getFileStream(fileId);
109
+
110
+ // Collect chunks into buffer
111
+ const chunks = [];
112
+ for await (const chunk of fileStream) {
113
+ chunks.push(chunk);
114
+ }
115
+
116
+ return Buffer.concat(chunks);
117
+ }
118
+
119
+ /**
120
+ * Delete a message (optional cleanup)
121
+ */
122
+ async deleteMessage(messageId) {
123
+ try {
124
+ await this.bot.deleteMessage(this.chatId, messageId);
125
+ return true;
126
+ } catch (err) {
127
+ // Message might already be deleted
128
+ return false;
129
+ }
130
+ }
131
+ }