@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/LICENSE +21 -0
- package/README.md +174 -0
- package/package.json +57 -0
- package/src/cli.js +1010 -0
- package/src/crypto/encryption.js +116 -0
- package/src/db/index.js +356 -0
- package/src/fuse/mount.js +516 -0
- package/src/index.js +219 -0
- package/src/sync/sync.js +297 -0
- package/src/telegram/client.js +131 -0
- package/src/utils/branding.js +94 -0
- package/src/utils/chunker.js +155 -0
- package/src/utils/compression.js +84 -0
- package/systemd/README.md +32 -0
- package/systemd/tas-sync.service +21 -0
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
|
+
}
|
package/src/sync/sync.js
ADDED
|
@@ -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
|
+
}
|