Skip to content

Instantly share code, notes, and snippets.

@weskerty
Last active August 11, 2026 05:25
Show Gist options
  • Select an option

  • Save weskerty/e3bf15a0705cd24ace05317f5f1c7057 to your computer and use it in GitHub Desktop.

Select an option

Save weskerty/e3bf15a0705cd24ace05317f5f1c7057 to your computer and use it in GitHub Desktop.
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