zelin-bot / src /sftp.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================================
* sftp.js — Extractor SFTP inteligente de TomateSMP v2.0
* ============================================================
* Sistema de prioridades: CRÍTICO (2MB), ALTA (512KB), NORMAL (256KB).
* Elimina ruido: UUIDs, logs viejos, chunks.
* Genera .txt.gz con árbol + JARs + contenido por prioridad.
*/
import { readConfig } from './utils.js';
const config = readConfig();
// ── Prioridades ───────────────────────────────────────────────────────────────
const PRIORITY = { CRITICAL: 1, HIGH: 2, NORMAL: 3 };
const SIZE_LIMIT = {
[PRIORITY.CRITICAL]: 2 * 1024 * 1024,
[PRIORITY.HIGH] : 512 * 1024,
[PRIORITY.NORMAL] : 256 * 1024,
};
const MAX_TOTAL_CHARS = 20_000_000;
const PARALLEL = 16;
const MAX_DEPTH = 12;
const TEXT_EXT = new Set([
'.yml','.yaml','.json','.toml','.conf','.cfg','.config',
'.properties','.txt','.log','.md','.xml','.ini','.sh',
'.js','.java','.py','.sql','.csv','.ts','.hocon',
]);
const BINARY_EXT = new Set([
'.jar','.class','.png','.jpg','.jpeg','.gif','.webp','.ico',
'.ogg','.wav','.mp3','.flac',
'.zip','.gz','.tar','.7z','.rar','.bz2',
'.mca','.mcapm','.nbt','.dat_old',
'.ldb','.sst','.bson','.db','.sqlite','.h2',
'.lock','.lck','.trc','.dmp','.so','.dll',
]);
const SKIP_DIRS = new Set([
'region','entities','poi','DIM-1','DIM1','DIM2','datapacks',
'cache','libraries','versions','bundler','META-INF',
'.git','node_modules','crash-reports','debug',
'web','maps','assets','temp','tmp','generated',
]);
const CRITICAL_PATHS = new Set([
'server.properties','bukkit.yml','spigot.yml','paper.yml',
'paper-global.yml','paper-world-defaults.yml','commands.yml',
'permissions.yml','ops.json','whitelist.json',
'plugins/NotRanks/ranks.yml',
'plugins/LuckPerms/config.yml',
'plugins/Essentials/config.yml',
'plugins/Essentials/worth.yml',
'plugins/Essentials/kits.yml',
'plugins/EconomyShopGUI/config.yml',
'plugins/AuraSkills/config.yml',
'plugins/AuraSkills/skills.yml',
'plugins/ExcellentEnchants/config.yml',
'plugins/ExcellentCrates/config.yml',
'plugins/BattlePass/config.yml',
'plugins/Quests/config.yml',
'plugins/UltimateMobCoins/config.yml',
'plugins/MythicMobs/config.yml',
'plugins/LevelledMobs/rules.yml',
'plugins/LevelledMobs/customdrops.yml',
'plugins/Multiverse-Core/worlds.yml',
'plugins/WorldGuard/config.yml',
'plugins/ProtectionStones/config.yml',
'plugins/BetterRTP/config.yml',
'plugins/Duel/config.yml',
'plugins/Duel/arenas.yml',
'plugins/SH-Koth/config.yml',
'plugins/Vulcan/config.yml',
'plugins/AxMinions/config.yml',
'plugins/AlonsoChat/config.yml',
'plugins/AlonsoTags/tags.yml',
'plugins/TAB/config.yml',
'plugins/TAB/groups.yml',
'plugins/PlasmoVoice/config.yml',
'plugins/FancyNpcs/npcs.yml',
'plugins/FancyHolograms/holograms.yml',
'plugins/CommandPanels/panels.yml',
'plugins/AxRewards/config.yml',
'plugins/AxEnvoy/config.yml',
'plugins/zVoteParty/config.yml',
'plugins/Jobs/config.yml',
'plugins/Jobs/jobConfig.yml',
'plugins/AdvancedBan/config.yml',
'plugins/NexAuth/config.yml',
]);
const HIGH_DIRS = [
'plugins/NotRanks',
'plugins/ExcellentEnchants/enchants',
'plugins/ExcellentCrates/crates',
'plugins/EconomyShopGUI/shops',
'plugins/MythicMobs/Mobs',
'plugins/MythicMobs/Skills',
'plugins/MythicMobs/Items',
'plugins/Quests/quests',
'plugins/BattlePass',
'plugins/AuraSkills',
'plugins/LevelledMobs',
'plugins/AlonsoTags',
'plugins/Jobs/jobs',
'plugins/TAB',
'plugins/ConditionalEvents',
'plugins/CommandPanels',
'plugins/AxMinions/minions',
'plugins/SH-Koth',
];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
const SKIP_PATTERNS = [
UUID_RE,
/^\d+\.dat$/,
/^session\.lock$/,
/^level\.dat_old$/,
/^uid\.dat$/,
/^usercache\.json$/,
/^banned-ips\.json$/,
];
function shouldSkipFile(filename, dirPath) {
if (SKIP_PATTERNS.some(p => p.test(filename))) return true;
if (dirPath.includes('/logs') && filename !== 'latest.log') return true;
const playerDirs = ['/playerdata', '/stats', '/advancements'];
if (playerDirs.some(d => dirPath.includes(d)) && UUID_RE.test(filename)) return true;
return false;
}
function getPriority(absPath, root) {
const rel = absPath.startsWith(root + '/') ? absPath.slice(root.length + 1) : absPath.replace(/^\//, '');
if (CRITICAL_PATHS.has(rel)) return PRIORITY.CRITICAL;
const base = rel.split('/').pop();
if (base === 'plugin.yml') return PRIORITY.HIGH;
if (/^messages.*\.yml$/.test(base)) return PRIORITY.HIGH;
for (const d of HIGH_DIRS) {
if (rel.startsWith(d + '/')) return PRIORITY.HIGH;
}
return PRIORITY.NORMAL;
}
// ── SFTP helpers ──────────────────────────────────────────────────────────────
async function makeSFTP(cfg) {
const { Client } = await import('ssh2');
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
conn.sftp((err, sftp) => {
if (err) { conn.end(); return reject(err); }
resolve({ sftp, conn });
});
});
conn.on('error', reject);
conn.connect({
host: cfg.host, port: cfg.port ?? 22,
username: cfg.username, password: cfg.password,
readyTimeout: 20000,
algorithms: { serverHostKey: ['ssh-rsa','ecdsa-sha2-nistp256','ssh-ed25519'] },
});
});
}
function ls(sftp, dir) {
return new Promise(resolve => {
sftp.readdir(dir, (err, list) => resolve(err ? [] : (list || [])));
});
}
function readFile(sftp, path, maxBytes) {
return new Promise(resolve => {
const chunks = []; let total = 0, truncated = false;
const stream = sftp.createReadStream(path, { encoding: 'utf8' });
stream.on('data', chunk => {
if (total + chunk.length <= maxBytes) { chunks.push(chunk); total += chunk.length; }
else {
const remaining = maxBytes - total;
if (remaining > 0) chunks.push(chunk.slice(0, remaining));
total = maxBytes; truncated = true; stream.destroy();
}
});
stream.on('close', () => resolve({ text: chunks.join(''), truncated, bytes: total }));
stream.on('end', () => resolve({ text: chunks.join(''), truncated, bytes: total }));
stream.on('error', () => resolve(null));
});
}
async function detectRoot(sftp, preferred) {
const candidates = [preferred, '/', '/home/container', '/data', '/server'];
for (const c of [...new Set(candidates)]) {
const entries = await ls(sftp, c);
const names = entries.map(e => e.filename);
const isMC = names.some(n => ['plugins','world','server.properties','paper.yml','spigot.yml','bukkit.yml'].includes(n));
if (isMC) return { root: c, entries };
if (entries.length > 3) return { root: c, entries };
}
return { root: preferred, entries: [] };
}
async function buildFileTree(sftp, root, rootEntries, onProgress) {
const fileQueue = [], jarList = [], treeLines = [];
let fileCount = 0;
async function crawl(dir, depth) {
if (depth > MAX_DEPTH) return;
const entries = dir === root ? rootEntries : await ls(sftp, dir);
const relDir = dir.startsWith(root) ? dir.slice(root.length) || '/' : dir;
const indent = ' '.repeat(depth);
const subdirs = entries.filter(e => (e.attrs.mode & 0o040000) && !SKIP_DIRS.has(e.filename) && !e.filename.startsWith('.'));
const files = entries.filter(e => !(e.attrs.mode & 0o040000));
treeLines.push(`${indent}📁 ${relDir}/`);
for (const f of files) {
const ext = f.filename.includes('.') ? '.' + f.filename.split('.').pop().toLowerCase() : '';
const absPath = `${dir}/${f.filename}`;
const isBin = BINARY_EXT.has(ext);
const isTxt = TEXT_EXT.has(ext);
if (ext === '.jar' && dir.endsWith('/plugins')) jarList.push({ name: f.filename, sizeKB: Math.round(f.attrs.size / 1024) });
const skip = isBin || !isTxt || shouldSkipFile(f.filename, dir);
treeLines.push(`${indent} ${skip ? '○' : '●'} ${f.filename} (${(f.attrs.size/1024).toFixed(1)}KB)`);
if (!skip) {
fileQueue.push({ path: absPath, size: f.attrs.size, priority: getPriority(absPath, root) });
fileCount++;
if (fileCount % 200 === 0) onProgress?.(`🌲 Indexando árbol: ${fileCount} archivos...`);
}
}
for (let i = 0; i < subdirs.length; i += 4) {
await Promise.all(subdirs.slice(i, i+4).map(d => crawl(`${dir}/${d.filename}`, depth+1)));
}
}
await crawl(root, 0);
fileQueue.sort((a, b) => a.priority - b.priority);
return { fileQueue, jarList, treeLines };
}
async function readAllFiles(sftp, fileQueue, onProgress) {
const results = new Map();
const queue = [...fileQueue];
let done = 0, totalChars = 0, lastUpdate = Date.now();
const total = queue.length;
const skipped = { overflow: 0, error: 0 };
async function worker() {
while (queue.length > 0) {
if (totalChars >= MAX_TOTAL_CHARS) { skipped.overflow += queue.length; queue.length = 0; break; }
const item = queue.shift(); if (!item) break;
const maxBytes = SIZE_LIMIT[item.priority] ?? SIZE_LIMIT[PRIORITY.NORMAL];
const res = await readFile(sftp, item.path, maxBytes);
if (res) { results.set(item.path, { ...res, priority: item.priority }); totalChars += res.text.length; }
else skipped.error++;
done++;
if (Date.now() - lastUpdate > 1500) {
onProgress?.(`📖 ${done}/${total} archivos | ${(totalChars/1024/1024).toFixed(1)}MB leídos...`);
lastUpdate = Date.now();
}
}
}
await Promise.all(Array.from({ length: PARALLEL }, worker));
return { results, skipped, totalChars };
}
function buildDocument(root, treeLines, jarList, results, stats) {
const sep = '═'.repeat(70);
const lines = [
`TOMATESMP — EXTRACCIÓN COMPLETA v2.0`,
`Fecha: ${new Date().toISOString()}`,
`Raíz: ${root} | Host: ${config.sftp.host}:${config.sftp.port ?? 22}`,
`Archivos leídos: ${results.size} | Tamaño texto: ${(stats.totalChars/1024/1024).toFixed(2)}MB`,
sep, '',
'🗂️ ÁRBOL DE ARCHIVOS', '─'.repeat(70),
treeLines.join('\n'), '',
sep, `📦 PLUGINS INSTALADOS (${jarList.length} JARs detectados)`, '─'.repeat(70),
jarList.map(j => ` ${j.name} (${j.sizeKB}KB)`).join('\n'), '',
sep, '🔴 ARCHIVOS CRÍTICOS', sep,
];
for (const [path, { text, truncated, priority }] of results.entries()) {
if (priority !== PRIORITY.CRITICAL) continue;
lines.push(`\n━━━ ${path}${truncated ? ' [TRUNCADO]' : ''}\n${text}`);
}
lines.push('', sep, '🟡 ARCHIVOS DE ALTA PRIORIDAD', sep);
for (const [path, { text, truncated, priority }] of results.entries()) {
if (priority !== PRIORITY.HIGH) continue;
lines.push(`\n━━━ ${path}${truncated ? ' [TRUNCADO]' : ''}\n${text}`);
}
lines.push('', sep, '⚪ RESTO DE ARCHIVOS', sep);
for (const [path, { text, truncated, priority }] of results.entries()) {
if (priority !== PRIORITY.NORMAL) continue;
lines.push(`\n━━━ ${path}${truncated ? ' [TRUNCADO]' : ''}\n${text}`);
}
lines.push('', sep, `FIN DE EXTRACCIÓN — ${results.size} archivos procesados`);
return lines.join('\n');
}
export async function extractServerInfo(onProgress, overridePassword = null) {
const cfg = config.sftp;
if (!cfg?.host || !cfg?.username || !cfg?.password) throw new Error('Credenciales SFTP no configuradas');
const effectiveCfg = overridePassword ? { ...cfg, password: overridePassword } : cfg;
onProgress?.('🔌 Conectando...');
const { sftp, conn } = await makeSFTP(effectiveCfg);
try {
onProgress?.('🔍 Detectando raíz del servidor...');
const { root, entries: rootEntries } = await detectRoot(sftp, cfg.rootPath ?? '/home/container');
onProgress?.(`📂 Raíz: \`${root}\` — construyendo árbol de archivos...`);
const { fileQueue, jarList, treeLines } = await buildFileTree(sftp, root, rootEntries, onProgress);
const critical = fileQueue.filter(f => f.priority === PRIORITY.CRITICAL).length;
const high = fileQueue.filter(f => f.priority === PRIORITY.HIGH).length;
onProgress?.(`📋 ${fileQueue.length} archivos indexados — ${critical} críticos, ${high} alta prioridad, ${jarList.length} JARs`);
const { results, skipped, totalChars } = await readAllFiles(sftp, fileQueue, onProgress);
onProgress?.(`📦 Listo — ${results.size} archivos leídos (${(totalChars/1024/1024).toFixed(1)}MB) — comprimiendo...`);
const document = buildDocument(root, treeLines, jarList, results, { totalChars, skipped });
const { gzipSync } = await import('zlib');
const compressed = gzipSync(Buffer.from(document, 'utf8'), { level: 9 });
return {
buffer : compressed,
filename: `tomatesmp_${new Date().toISOString().slice(0,10)}.txt.gz`,
stats : {
files : results.size,
critical: [...results.values()].filter(r => r.priority === PRIORITY.CRITICAL).length,
high : [...results.values()].filter(r => r.priority === PRIORITY.HIGH).length,
normal : [...results.values()].filter(r => r.priority === PRIORITY.NORMAL).length,
plugins : jarList.length,
skipped : skipped.overflow + skipped.error,
errors : skipped.error,
sizeMB : (totalChars / 1024 / 1024).toFixed(2),
},
sizeKB : Math.round(compressed.length / 1024),
};
} finally {
conn.end();
}
}