Last active
August 11, 2026 05:25
-
-
Save weskerty/e3bf15a0705cd24ace05317f5f1c7057 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const fs = require('fs').promises; | |
| const { openSync } = require('fs'); | |
| const path = require('path'); | |
| const os = require('os'); | |
| const net = require('net'); | |
| const crypto = require('crypto'); | |
| const { spawn, execFile } = require('child_process'); | |
| const { promisify } = require('util'); | |
| const { bot } = require('../lib'); | |
| const execFileAsync = promisify(execFile); | |
| const FILE_TYPES = { | |
| video: { extensions: new Set(['mp4', 'mkv', 'avi', 'webm', 'mov', 'flv', 'm4v']), mimetype: 'video/mp4' }, | |
| image: { extensions: new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']), mimetype: 'image/jpeg' }, | |
| document: { | |
| extensions: new Set(['pdf', 'epub', 'docx', 'txt', 'zip', 'rar', 'iso', 'cbr', 'cbz']), | |
| mimetypes: new Map([ | |
| ['pdf', 'application/pdf'], | |
| ['epub', 'application/epub+zip'], | |
| ['docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], | |
| ['txt', 'text/plain'], | |
| ['zip', 'application/zip'], | |
| ['rar', 'application/x-rar-compressed'], | |
| ['iso', 'application/x-iso9660-image'], | |
| ['cbr', 'application/x-cbr'], | |
| ['cbz', 'application/x-cbz'], | |
| ]), | |
| defaultMimetype: 'application/octet-stream', | |
| }, | |
| audio: { extensions: new Set(['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac']), mimetype: 'audio/mpeg' }, | |
| }; | |
| function getFileDetails(filePath) { | |
| const ext = path.extname(filePath).slice(1).toLowerCase(); | |
| for (const [category, info] of Object.entries(FILE_TYPES)) { | |
| if (info.extensions.has(ext)) { | |
| return { category, mimetype: category === 'document' ? (info.mimetypes.get(ext) || info.defaultMimetype) : info.mimetype }; | |
| } | |
| } | |
| return { category: 'document', mimetype: FILE_TYPES.document.defaultMimetype }; | |
| } | |
| function formatSize(bytes) { | |
| if (bytes === null || bytes === undefined || isNaN(bytes)) return 'Desconocido'; | |
| if (bytes < 1024) return `${bytes} B`; | |
| if (bytes < 1048576) return `${(bytes / 1024).toFixed(2)} KB`; | |
| if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(2)} MB`; | |
| return `${(bytes / 1073741824).toFixed(2)} GB`; | |
| } | |
| function waitForPort(host, port, timeoutMs) { | |
| return new Promise((resolve, reject) => { | |
| const deadline = Date.now() + timeoutMs; | |
| const attempt = () => { | |
| const sock = net.createConnection({ host, port }, () => { | |
| sock.destroy(); | |
| resolve(); | |
| }); | |
| sock.on('error', () => { | |
| sock.destroy(); | |
| if (Date.now() > deadline) reject(new Error(`Timeout esperando puerto ${port}`)); | |
| else setTimeout(attempt, 1000); | |
| }); | |
| }; | |
| attempt(); | |
| }); | |
| } | |
| const CATEGORY_NAME = 'levanter-bot'; | |
| const AMULE_REPO_URL = 'https://github.com/weskerty/aMuleD.bin.git'; | |
| const AMULE_DEFAULT_PASS = '1234567890-p'; | |
| const AMULE_BIN_BY_ARCH = { | |
| x64: { daemon: 'aMuleDx86_64.bin', api: 'aMuleAPIx86_64.bin' }, | |
| ia32: { daemon: 'aMuleDx86.bin', api: 'aMuleAPIx86.bin' }, | |
| arm64: { daemon: 'aMuleDARM64.bin', api: 'aMuleAPIARM64.bin' }, | |
| arm: { daemon: 'aMuleDARMv7.bin', api: 'aMuleAPIARMv7.bin' }, | |
| }; | |
| function getPaths() { | |
| const binDir = path.join(process.cwd(), 'media', 'bin', 'aMule'); | |
| const repoDir = path.join(binDir, 'repo'); | |
| const names = AMULE_BIN_BY_ARCH[os.arch()]; | |
| return { | |
| binDir, | |
| repoDir, | |
| names, | |
| daemonPath: names ? path.join(repoDir, 'bin', 'aMule', names.daemon) : null, | |
| apiPath: names ? path.join(repoDir, 'bin', 'aMule', names.api) : null, | |
| configDir: path.join(repoDir, 'conf', 'aMule'), | |
| }; | |
| } | |
| function md5Hex(text) { | |
| return crypto.createHash('md5').update(text).digest('hex').toUpperCase(); | |
| } | |
| let repoReady = false; | |
| let repoError = null; | |
| async function ensureAmuleRepo() { | |
| const { binDir, repoDir } = getPaths(); | |
| await fs.mkdir(binDir, { recursive: true }); | |
| let exists = false; | |
| try { await fs.access(path.join(repoDir, '.git')); exists = true; } catch {} | |
| if (!exists) { | |
| await fs.rm(repoDir, { recursive: true, force: true }).catch(() => {}); | |
| await execFileAsync('git', ['clone', '--depth', '1', AMULE_REPO_URL, repoDir], { timeout: 300000 }); | |
| } else { | |
| await execFileAsync('git', ['-C', repoDir, 'fetch', '--depth', '1', 'origin', 'master'], { timeout: 300000 }); | |
| await execFileAsync('git', ['-C', repoDir, 'reset', '--hard', 'origin/master'], { timeout: 60000 }); | |
| } | |
| } | |
| (async () => { | |
| try { | |
| await execFileAsync('git', ['--version']); | |
| await ensureAmuleRepo(); | |
| repoReady = true; | |
| console.log('[amule] Repositorio de binarios listo'); | |
| } catch (err) { | |
| repoError = err.message; | |
| console.error('[amule] No se pudo preparar el repositorio de binarios:', err.message); | |
| } | |
| })(); | |
| class AmuleApi { | |
| constructor() { | |
| this.token = null; | |
| this.tokenExp = 0; | |
| this.category = null; | |
| this.results = new Map(); | |
| this.ensurePromise = null; | |
| } | |
| setContext(ctx) { | |
| this.config = { | |
| host: ctx.AMULEAPI_HOST || '127.0.0.1', | |
| port: parseInt(ctx.AMULEAPI_PORT, 10) || 4713, | |
| pass: ctx.AMULEAPI_PASS || AMULE_DEFAULT_PASS, | |
| ecHost: ctx.AMULE_EC_HOST || '127.0.0.1', | |
| ecPort: parseInt(ctx.AMULE_EC_PORT, 10) || 4712, | |
| ecPass: ctx.AMULE_EC_PASS || ctx.AMULEAPI_PASS || AMULE_DEFAULT_PASS, | |
| ecManaged: !ctx.AMULE_EC_PORT, | |
| incomingDir: ctx.AMULE_INCOMING_DIR || '/tmp/amule', | |
| minSources: parseInt(ctx.AMULE_MIN_SOURCES, 10) || 20, | |
| searchWaitMs: parseInt(ctx.AMULE_SEARCH_WAIT, 10) || 8000, | |
| maxFileSize: (parseInt(ctx.MAX_UPLOAD, 10) || 200) * 1048576, | |
| dlTimeoutMs: (parseInt(ctx.AMULE_DL_TIMEOUT_MIN, 10) || 120) * 60000, | |
| deleteTemp: ctx.DELETE_TEMP_FILE !== 'false', | |
| }; | |
| this.base = `http://${this.config.host}:${this.config.port}/api/v0`; | |
| } | |
| async getToken() { | |
| if (this.token && Date.now() < this.tokenExp) return this.token; | |
| const res = await fetch(`${this.base}/auth/login?type=bearer`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ password: this.config.pass }), | |
| }); | |
| const d = await res.json(); | |
| if (!d.token) throw new Error(`Login amuleapi: ${d.error?.message || res.status}`); | |
| this.token = d.token; | |
| this.tokenExp = (d.expires_at_unix ? d.expires_at_unix * 1000 : Date.now() + 3600000) - 60000; | |
| return this.token; | |
| } | |
| async api(method, endpoint, body) { | |
| const token = await this.getToken(); | |
| const res = await fetch(`${this.base}${endpoint}`, { | |
| method, | |
| headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, | |
| body: body ? JSON.stringify(body) : undefined, | |
| }); | |
| const d = await res.json().catch(() => ({})); | |
| if (!res.ok) throw new Error(`${method} ${endpoint}: ${d.error?.message || res.status}`); | |
| return d; | |
| } | |
| async isReady() { | |
| try { | |
| this.token = null; | |
| await this.getToken(); | |
| await this.api('GET', '/categories'); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| async ensureBinaries() { | |
| if (!repoReady) throw new Error(repoError || 'Repositorio de aMule aun no esta listo, intenta en un momento'); | |
| const { daemonPath, apiPath, names, configDir } = getPaths(); | |
| if (!names) throw new Error(`Arquitectura no soportada por amuleapi: ${os.arch()}`); | |
| if (os.platform() !== 'win32') { | |
| await fs.chmod(daemonPath, '755').catch(() => {}); | |
| await fs.chmod(apiPath, '755').catch(() => {}); | |
| } | |
| if (this.config.ecManaged) await this.provisionAmuleConf(configDir); | |
| return { daemonPath, apiPath, configDir }; | |
| } | |
| async provisionAmuleConf(configDir) { | |
| const confFile = path.join(configDir, 'amule.conf'); | |
| const incoming = path.join(configDir, 'Incoming'); | |
| const temp = path.join(configDir, 'Temp'); | |
| await fs.mkdir(incoming, { recursive: true }); | |
| await fs.mkdir(temp, { recursive: true }); | |
| let text = await fs.readFile(confFile, 'utf8'); | |
| text = text.replace(/^IncomingDir=.*$/m, `IncomingDir=${incoming}`); | |
| text = text.replace(/^TempDir=.*$/m, `TempDir=${temp}`); | |
| text = text.replace(/^ECPassword=.*$/m, `ECPassword=${md5Hex(this.config.ecPass)}`); | |
| await fs.writeFile(confFile, text); | |
| } | |
| async writeAmuleApiConf(configDir) { | |
| const conf = [ | |
| '[Server]', | |
| `BindAddress=${this.config.host}`, | |
| `Port=${this.config.port}`, | |
| 'AllowCORS=0', | |
| 'StaticRoot=', | |
| '', | |
| '[EC]', | |
| `Host=${this.config.ecHost}`, | |
| `Port=${this.config.ecPort}`, | |
| `Password=${this.config.ecPass}`, | |
| 'Encryption=1', | |
| '', | |
| '[Auth]', | |
| 'LoginFailureWindowSeconds=60', | |
| 'LoginFailureThreshold=5', | |
| 'LoginLockoutSeconds=300', | |
| '', | |
| '[Streaming]', | |
| 'EventBusRingCapacity=16384', | |
| '', | |
| ].join('\n'); | |
| const apiConfPath = path.join(configDir, 'amuleapi.conf'); | |
| await fs.writeFile(apiConfPath, conf); | |
| await fs.chmod(apiConfPath, 0o600); | |
| } | |
| launchProcess(binPath, args, logPath) { | |
| const outFd = openSync(logPath, 'a'); | |
| const child = spawn(binPath, args, { detached: true, stdio: ['ignore', outFd, outFd] }); | |
| child.unref(); | |
| return child; | |
| } | |
| async bootstrap(message) { | |
| if (this.config.host !== '127.0.0.1' && this.config.host !== 'localhost') { | |
| throw new Error(`amuleapi no responde en ${this.config.host}:${this.config.port} y el host no es local, no se puede auto-iniciar`); | |
| } | |
| if (message) { | |
| const what = this.config.ecManaged ? 'aMule no esta corriendo, iniciando por primera vez (puede tardar ~1 minuto)...' : 'amuleapi no esta corriendo, conectando a amuled existente (puede tardar ~1 minuto)...'; | |
| await message.send(what, { quoted: message.data }); | |
| } | |
| const { daemonPath, apiPath, configDir } = await this.ensureBinaries(); | |
| const { binDir } = getPaths(); | |
| if (this.config.ecManaged) { | |
| this.launchProcess(daemonPath, ['--config-dir', configDir], path.join(binDir, 'amuled.log')); | |
| await waitForPort(this.config.ecHost, this.config.ecPort, 30000); | |
| } | |
| await this.writeAmuleApiConf(configDir); | |
| await execFileAsync(apiPath, ['--config-dir', configDir, `--set-admin-pass=${this.config.pass}`]); | |
| this.launchProcess(apiPath, ['--config-dir', configDir], path.join(binDir, 'amuleapi.log')); | |
| await waitForPort(this.config.host, this.config.port, 30000); | |
| this.token = null; | |
| this.category = null; | |
| let ready = false; | |
| for (let i = 0; i < 15 && !ready; i++) { | |
| try { await this.getToken(); ready = true; } catch { await new Promise(r => setTimeout(r, 2000)); } | |
| } | |
| if (!ready) throw new Error('amuleapi no acepto login tras iniciar'); | |
| await new Promise(r => setTimeout(r, 60000)); | |
| } | |
| async ensureRunning(message) { | |
| if (await this.isReady()) return; | |
| if (this.ensurePromise) return this.ensurePromise; | |
| this.ensurePromise = this.bootstrap(message).finally(() => { this.ensurePromise = null; }); | |
| return this.ensurePromise; | |
| } | |
| async ensureCategory() { | |
| if (this.category) return this.category; | |
| const list = await this.api('GET', '/categories'); | |
| const items = list.categories || list.items || (Array.isArray(list) ? list : []); | |
| const found = items.find(c => c.name === CATEGORY_NAME); | |
| if (found) { | |
| this.category = { index: found.index ?? found.id, path: found.path || this.config.incomingDir }; | |
| } else { | |
| const created = await this.api('POST', '/categories', { name: CATEGORY_NAME, path: this.config.incomingDir }); | |
| this.category = { index: created.index ?? created.id, path: created.path || this.config.incomingDir }; | |
| } | |
| await fs.mkdir(this.category.path, { recursive: true }).catch(() => {}); | |
| return this.category; | |
| } | |
| async search(query) { | |
| await this.ensureCategory(); | |
| const searchIds = []; | |
| for (const type of ['kad', 'global']) { | |
| try { | |
| const started = await this.api('POST', '/search', { query, type, min_avail: this.config.minSources }); | |
| const id = started.search_id ?? started.id; | |
| if (id) searchIds.push(id); | |
| } catch (err) { | |
| console.error(`[amule] search (${type}) error:`, err.message); | |
| } | |
| } | |
| if (searchIds.length === 0) throw new Error('No se pudo iniciar ninguna busqueda (kad/global)'); | |
| const merged = new Map(); | |
| const waitUntil = Date.now() + this.config.searchWaitMs; | |
| while (Date.now() < waitUntil) { | |
| await new Promise(r => setTimeout(r, 1500)); | |
| for (const id of searchIds) { | |
| try { | |
| const r = await this.api('GET', `/search/results?search_id=${id}`); | |
| const raw = r.results || r.items || (Array.isArray(r) ? r : []); | |
| for (const item of raw) { | |
| const hash = item.hash || item.file_hash; | |
| if (!hash) continue; | |
| const prev = merged.get(hash); | |
| if (!prev || (item.sources?.total ?? 0) > (prev.sources?.total ?? 0)) merged.set(hash, item); | |
| } | |
| } catch { /* keep last successful snapshot for this search_id */ } | |
| } | |
| } | |
| await Promise.all(searchIds.map(id => this.api('POST', '/search/stop', { search_id: id, close: true }).catch(() => {}))); | |
| const norm = Array.from(merged.values()) | |
| .map(r => ({ | |
| hash: r.hash || r.file_hash, | |
| name: r.name || r.filename || r.file_name || 'sin_nombre', | |
| size: r.size ?? r.file_size ?? 0, | |
| sources: r.sources?.total ?? 0, | |
| })) | |
| .filter(r => r.hash && r.sources >= this.config.minSources); | |
| norm.sort((a, b) => b.sources - a.sources); | |
| this.results.clear(); | |
| norm.forEach((r, i) => this.results.set(i + 1, r)); | |
| return norm; | |
| } | |
| formatList(list) { | |
| if (list.length === 0) return `Sin resultados con minimo ${this.config.minSources} fuentes.`; | |
| let msg = `Resultados (min. ${this.config.minSources} fuentes):\n\n`; | |
| list.forEach((r, i) => { | |
| const ext = path.extname(r.name).replace('.', '') || '?'; | |
| msg += `\`${i + 1}\` ${r.name}\n> Fuentes: ${r.sources} | Tamano: ${formatSize(r.size)} | Tipo: ${ext}\n\n`; | |
| }); | |
| msg += 'Elegir: `amule dd <numero>`'; | |
| return msg; | |
| } | |
| async waitForCompletion(hash) { | |
| const token = await this.getToken(); | |
| const res = await fetch(`${this.base}/events?channels=downloads`, { | |
| headers: { Authorization: `Bearer ${token}` }, | |
| }); | |
| if (!res.ok || !res.body) throw new Error(`Eventos amuleapi: ${res.status}`); | |
| return await Promise.race([ | |
| (async () => { | |
| let buf = ''; | |
| for await (const chunk of res.body) { | |
| buf += Buffer.from(chunk).toString('utf8'); | |
| let idx; | |
| while ((idx = buf.indexOf('\n\n')) !== -1) { | |
| const raw = buf.slice(0, idx); | |
| buf = buf.slice(idx + 2); | |
| const dataLine = raw.split('\n').find(l => l.startsWith('data:')); | |
| if (!dataLine) continue; | |
| let payload; | |
| try { payload = JSON.parse(dataLine.slice(5).trim()); } catch { continue; } | |
| if (payload.hash === hash && payload.status === 'completed') return payload; | |
| } | |
| } | |
| throw new Error('Stream de eventos cerrado sin completar'); | |
| })(), | |
| new Promise((_, rej) => setTimeout(() => rej(new Error('Timeout esperando descarga')), this.config.dlTimeoutMs)), | |
| ]); | |
| } | |
| async download(message, index) { | |
| const item = this.results.get(index); | |
| if (!item) throw new Error('Indice invalido, busca de nuevo con `amule <palabra>`'); | |
| if (item.size > this.config.maxFileSize) { | |
| throw new Error(`Supera el limite configurado (${formatSize(this.config.maxFileSize)}): ${formatSize(item.size)}`); | |
| } | |
| const cat = await this.ensureCategory(); | |
| await this.api('POST', `/search/results/${item.hash}/download`, { category: cat.index }); | |
| await message.send(`Descarga iniciada: ${item.name}\n> Fuentes: ${item.sources} | Tamano: ${formatSize(item.size)}`, { quoted: message.data }); | |
| const completed = await this.waitForCompletion(item.hash); | |
| const fileName = completed.name || completed.file_name || item.name; | |
| const srcPath = path.join(cat.path, fileName); | |
| const jobDir = path.join(cat.path, 'jobs', `${Date.now()}`); | |
| await fs.mkdir(jobDir, { recursive: true }); | |
| const destPath = path.join(jobDir, fileName); | |
| try { | |
| await fs.rename(srcPath, destPath); | |
| } catch { | |
| const buf = await fs.readFile(srcPath); | |
| await fs.writeFile(destPath, buf); | |
| await fs.unlink(srcPath).catch(() => {}); | |
| } | |
| const { category, mimetype } = getFileDetails(destPath); | |
| const buffer = await fs.readFile(destPath); | |
| await message.send(buffer, { fileName, mimetype, caption: `Descarga completa: ${fileName}`, quoted: message.data }, category); | |
| if (this.config.deleteTemp) await fs.rm(jobDir, { recursive: true, force: true }).catch(() => {}); | |
| await this.api('POST', '/downloads/clear_completed', { hashes: [item.hash] }).catch(() => {}); | |
| } | |
| } | |
| const amuleApi = new AmuleApi(); | |
| bot( | |
| { | |
| pattern: 'amule ?(.*)', | |
| fromMe: true, | |
| desc: 'Buscar y descargar de la red ed2k/Kad via amuleapi', | |
| type: 'download', | |
| }, | |
| async (message, match, ctx) => { | |
| amuleApi.setContext(ctx); | |
| const input = (match || '').trim(); | |
| if (!input) { | |
| await message.send('> Buscar: `amule` <palabra>\n> Descargar: `amule dd` <numero>', { quoted: message.data }); | |
| return; | |
| } | |
| try { | |
| if (input.toLowerCase().startsWith('dd')) { | |
| const idx = parseInt(input.slice(2).trim(), 10); | |
| if (isNaN(idx)) { | |
| await message.send('Indica un numero: `amule dd 1`', { quoted: message.data }); | |
| return; | |
| } | |
| await amuleApi.ensureRunning(message); | |
| await amuleApi.download(message, idx); | |
| return; | |
| } | |
| await amuleApi.ensureRunning(message); | |
| await message.send('Buscando (kad + global)...', { quoted: message.data }); | |
| const list = await amuleApi.search(input); | |
| await message.send(amuleApi.formatList(list), { quoted: message.data }); | |
| } catch (err) { | |
| await message.send(`Error: ${err.message}`, { quoted: message.data }); | |
| } | |
| } | |
| ); | |
| module.exports = {}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment