|
|
@@ -0,0 +1,233 @@
|
|
|
+import {
|
|
|
+ buscarDispositivo,
|
|
|
+ executarDiagnostico,
|
|
|
+ refPorPppoe,
|
|
|
+ refPorSerial,
|
|
|
+ statusConexao,
|
|
|
+ minutosDesde,
|
|
|
+ classificarSinalRx
|
|
|
+} from "../services/acsService.js";
|
|
|
+
|
|
|
+// comando explícito, à parte dos 3 modos do roteador por LLM — mesmo espírito do /cto e do
|
|
|
+// /<pppoe>: consulta estruturada direto no ACS (TR-069), sem LLM. Só leitura + diagnóstico:
|
|
|
+// /acs <pppoe> | /acs serial:<numero> resumo da CPE
|
|
|
+// /acs ping <pppoe> [host] ping a partir do roteador do cliente
|
|
|
+// /acs traceroute <pppoe> [host]
|
|
|
+// /acs wifi-scan <pppoe> redes Wi-Fi vizinhas (escolha de canal)
|
|
|
+const ACS_COMMAND_RE = /^\/acs(?:\s+(.+))?$/i;
|
|
|
+const DIAGNOSTICOS = ["ping", "traceroute", "wifi-scan"];
|
|
|
+const HOST_PADRAO = "8.8.8.8";
|
|
|
+const HOST_RE = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i;
|
|
|
+const CLIENTES_WIFI_LIMIT = 10;
|
|
|
+const REDES_SCAN_LIMIT = 10;
|
|
|
+
|
|
|
+const AJUDA =
|
|
|
+ "Uso do `/acs`:\n" +
|
|
|
+ "- `/acs <pppoe>` ou `/acs serial:<numero>` — status, sinal óptico e Wi-Fi da CPE\n" +
|
|
|
+ "- `/acs ping <pppoe> [host]` — ping a partir do roteador do cliente (padrão 8.8.8.8)\n" +
|
|
|
+ "- `/acs traceroute <pppoe> [host]`\n" +
|
|
|
+ "- `/acs wifi-scan <pppoe>` — redes vizinhas por canal";
|
|
|
+
|
|
|
+const STATUS_TEXTO = {
|
|
|
+ online: "🟢 Online",
|
|
|
+ atrasado: "🟡 Inform atrasado",
|
|
|
+ offline: "🔴 Offline",
|
|
|
+ nunca_informou: "🔴 Nunca informou ao ACS"
|
|
|
+};
|
|
|
+
|
|
|
+const SINAL_TEXTO = { ok: "ok", atencao: "atenção", critico: "crítico", saturado: "saturado (forte demais)" };
|
|
|
+
|
|
|
+export function parseAcsCommand(message) {
|
|
|
+ const match = ACS_COMMAND_RE.exec(String(message ?? "").trim());
|
|
|
+ if (!match) return null;
|
|
|
+ const partes = (match[1] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
|
+ if (!partes.length) return { acao: "ajuda" };
|
|
|
+
|
|
|
+ const primeira = partes[0].toLowerCase();
|
|
|
+ if (DIAGNOSTICOS.includes(primeira)) {
|
|
|
+ return { acao: primeira, alvo: partes[1] ?? null, host: partes[2] ?? null };
|
|
|
+ }
|
|
|
+ return { acao: "resumo", alvo: partes[0] };
|
|
|
+}
|
|
|
+
|
|
|
+function montarRef(alvo) {
|
|
|
+ const m = /^serial:(.+)$/i.exec(alvo);
|
|
|
+ return m ? refPorSerial(m[1]) : refPorPppoe(alvo.replace(/^pppoe:/i, ""));
|
|
|
+}
|
|
|
+
|
|
|
+function textoTempo(min) {
|
|
|
+ if (min === null || min === undefined) return "?";
|
|
|
+ if (min < 1) return "agora";
|
|
|
+ if (min < 60) return `${Math.round(min)} min`;
|
|
|
+ if (min < 1440) return `${Math.round(min / 60)} h`;
|
|
|
+ return `${Math.round(min / 1440)} d`;
|
|
|
+}
|
|
|
+
|
|
|
+function textoUptime(segundos) {
|
|
|
+ if (typeof segundos !== "number" || !Number.isFinite(segundos)) return null;
|
|
|
+ const d = Math.floor(segundos / 86400);
|
|
|
+ const h = Math.floor((segundos % 86400) / 3600);
|
|
|
+ const m = Math.floor((segundos % 3600) / 60);
|
|
|
+ return d ? `${d}d ${h}h` : h ? `${h}h ${m}min` : `${m} min`;
|
|
|
+}
|
|
|
+
|
|
|
+function numero(v, casas = 1) {
|
|
|
+ return typeof v === "number" && Number.isFinite(v) ? v.toFixed(casas) : "?";
|
|
|
+}
|
|
|
+
|
|
|
+function formatarOptico(optico) {
|
|
|
+ if (!optico) return null;
|
|
|
+ const rx = optico.sinalRxDbm;
|
|
|
+ const classe = classificarSinalRx(rx);
|
|
|
+ const partes = [`RX ${numero(rx, 2)} dBm${classe ? ` (${SINAL_TEXTO[classe]})` : ""}`];
|
|
|
+ if (optico.sinalTxDbm != null) partes.push(`TX ${numero(optico.sinalTxDbm, 2)} dBm`);
|
|
|
+ if (optico.temperaturaC != null) partes.push(`${numero(optico.temperaturaC)} °C`);
|
|
|
+ return `**Sinal óptico:** ${partes.join(" · ")}`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatarRadios(radios = []) {
|
|
|
+ if (!radios.length) return "Nenhum rádio Wi-Fi reportado.";
|
|
|
+ const linhas = radios.map(
|
|
|
+ (r) => `| ${r.banda ?? "?"} | ${r.ssid ?? "—"} | ${r.canal ?? "auto/?"} | ${r.habilitado === false ? "desligado" : "ligado"} |`
|
|
|
+ );
|
|
|
+ return ["| Banda | SSID | Canal | Estado |", "|---|---|---|---|", ...linhas].join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function formatarClientesWifi(clientes = []) {
|
|
|
+ if (!clientes.length) return "Nenhum aparelho conectado no Wi-Fi no momento.";
|
|
|
+ // os de sinal mais fraco primeiro: são os que explicam reclamação de Wi-Fi ruim
|
|
|
+ const ordenados = [...clientes].sort((a, b) => (a.sinal ?? 0) - (b.sinal ?? 0));
|
|
|
+ const linhas = ordenados
|
|
|
+ .slice(0, CLIENTES_WIFI_LIMIT)
|
|
|
+ .map((c) => `| ${c.mac ?? "?"} | ${c.banda ?? "?"} | ${c.sinal != null ? `${c.sinal} dBm` : "?"} | ${c.protocolo ?? "—"} |`);
|
|
|
+ const extra = clientes.length > CLIENTES_WIFI_LIMIT ? `\n_...e mais ${clientes.length - CLIENTES_WIFI_LIMIT}._` : "";
|
|
|
+ return ["| MAC | Banda | Sinal | Padrão |", "|---|---|---|---|", ...linhas].join("\n") + extra;
|
|
|
+}
|
|
|
+
|
|
|
+function formatarResumo(d, alvo) {
|
|
|
+ const info = d.device_id ?? {};
|
|
|
+ const status = statusConexao(d.ultimo_inform);
|
|
|
+ const linhas = [
|
|
|
+ `## CPE ${d.pppoe_usuario ?? alvo} — ${info._ProductClass ?? "modelo ?"}${info._Manufacturer ? ` (${info._Manufacturer})` : ""}`,
|
|
|
+ "",
|
|
|
+ `**Status:** ${STATUS_TEXTO[status]} · último inform há ${textoTempo(minutosDesde(d.ultimo_inform))}`,
|
|
|
+ `**Serial:** ${info._SerialNumber ?? "?"} · **Firmware:** ${d.versao_software ?? "?"}`
|
|
|
+ ];
|
|
|
+ const rede = [];
|
|
|
+ if (d.pppoe_usuario) rede.push(`**PPPoE:** ${d.pppoe_usuario}`);
|
|
|
+ if (d.ip) rede.push(`**IP WAN:** ${d.ip}`);
|
|
|
+ const uptime = textoUptime(d.uptime_segundos);
|
|
|
+ if (uptime) rede.push(`**Ligado há:** ${uptime}`);
|
|
|
+ if (rede.length) linhas.push(rede.join(" · "));
|
|
|
+ const optico = formatarOptico(d.optico);
|
|
|
+ if (optico) linhas.push(optico);
|
|
|
+ if (d.saude?.cpuPercent != null || d.saude?.memUsadaPercent != null) {
|
|
|
+ linhas.push(`**CPU:** ${numero(d.saude.cpuPercent, 0)}% · **Memória:** ${numero(d.saude.memUsadaPercent, 0)}%`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const clientesWifi = d.clientes_wifi ?? [];
|
|
|
+ linhas.push(
|
|
|
+ "",
|
|
|
+ "### Wi-Fi",
|
|
|
+ formatarRadios(d.radios),
|
|
|
+ "",
|
|
|
+ `### Aparelhos no Wi-Fi (${clientesWifi.length})${d.clientes_conectados ? ` · ${d.clientes_conectados.length} na rede local` : ""}`,
|
|
|
+ formatarClientesWifi(clientesWifi)
|
|
|
+ );
|
|
|
+ if (status !== "online") {
|
|
|
+ linhas.push("", "_A CPE não está informando ao ACS agora: os dados acima são do último contato._");
|
|
|
+ }
|
|
|
+ return linhas.join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function formatarPing(r, host) {
|
|
|
+ const total = (Number(r.sucesso) || 0) + (Number(r.falha) || 0);
|
|
|
+ return [
|
|
|
+ `## Ping ${host} a partir da CPE`,
|
|
|
+ "",
|
|
|
+ `**Respostas:** ${r.sucesso ?? "?"}/${total || "?"}${Number(r.falha) ? ` (${r.falha} perdidas)` : ""}`,
|
|
|
+ `**Tempo:** médio ${r.tempo_medio_ms ?? "?"} ms · mín ${r.tempo_min_ms ?? "?"} ms · máx ${r.tempo_max_ms ?? "?"} ms`
|
|
|
+ ].join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function formatarTraceroute(r, host) {
|
|
|
+ const saltos = Array.isArray(r.saltos) ? r.saltos : [];
|
|
|
+ if (!saltos.length) return `## Traceroute ${host}\n\nO roteador concluiu, mas não reportou nenhum salto.`;
|
|
|
+ const linhas = saltos.map((s) => `| ${s.indice ?? "?"} | ${s.host || s.endereco || "*"} | ${s.endereco ?? "—"} | ${s.tempos_ms ?? "—"} |`);
|
|
|
+ return [`## Traceroute ${host} a partir da CPE`, "", "| # | Host | Endereço | Tempos (ms) |", "|---|---|---|---|", ...linhas].join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function formatarScan(r, possivelmenteAntigo) {
|
|
|
+ const redes = Array.isArray(r.redes) ? r.redes : [];
|
|
|
+ if (!redes.length) return "## Redes Wi-Fi vizinhas\n\nNenhuma rede encontrada na varredura.";
|
|
|
+ const porCanal = new Map();
|
|
|
+ for (const rede of redes) {
|
|
|
+ const chave = `${rede.banda ?? "?"} canal ${rede.canal ?? "?"}`;
|
|
|
+ porCanal.set(chave, (porCanal.get(chave) ?? 0) + 1);
|
|
|
+ }
|
|
|
+ const ocupacao = [...porCanal.entries()]
|
|
|
+ .sort((a, b) => b[1] - a[1])
|
|
|
+ .map(([canal, n]) => `${canal}: ${n}`)
|
|
|
+ .join(" · ");
|
|
|
+ const fortes = [...redes]
|
|
|
+ .sort((a, b) => (b.sinal ?? -999) - (a.sinal ?? -999))
|
|
|
+ .slice(0, REDES_SCAN_LIMIT)
|
|
|
+ .map((n) => `| ${n.ssid || "(oculta)"} | ${n.banda ?? "?"} | ${n.canal ?? "?"} | ${n.sinal != null ? `${n.sinal} dBm` : "?"} |`);
|
|
|
+ return [
|
|
|
+ `## Redes Wi-Fi vizinhas (${redes.length})`,
|
|
|
+ possivelmenteAntigo ? "_Não deu pra confirmar que é desta varredura — pode ser de uma anterior._" : null,
|
|
|
+ "",
|
|
|
+ `**Ocupação por canal:** ${ocupacao}`,
|
|
|
+ "",
|
|
|
+ "**Mais fortes:**",
|
|
|
+ "| SSID | Banda | Canal | Sinal |",
|
|
|
+ "|---|---|---|---|",
|
|
|
+ ...fortes
|
|
|
+ ]
|
|
|
+ .filter((l) => l !== null)
|
|
|
+ .join("\n");
|
|
|
+}
|
|
|
+
|
|
|
+function mensagemErro(err, alvo) {
|
|
|
+ if (err.status === 404) return `Não encontrei nenhuma CPE com "${alvo}" no ACS. Confira o login PPPoE (ou use \`/acs serial:<numero>\`).`;
|
|
|
+ if (err.status === 409) return `Mais de uma CPE no ACS com "${alvo}" — use \`/acs serial:<numero>\` para escolher uma.`;
|
|
|
+ if (err.message === "integracao_acs_nao_configurada") return "A integração com o ACS ainda não foi configurada (ACS_URL/ACS_API_KEY + seed).";
|
|
|
+ if (err.statusCode === 400) return `Pedido inválido para o ACS: ${err.message}`;
|
|
|
+ console.warn("[acsCommand] falha ao consultar o ACS:", err.message);
|
|
|
+ return "Não consegui falar com o ACS agora (fora do ar ou demorou demais). Tente de novo em instantes.";
|
|
|
+}
|
|
|
+
|
|
|
+async function resolverDiagnostico(acao, alvo, hostInformado) {
|
|
|
+ if (!alvo) return `Informe o PPPoE da CPE. Ex.: \`/acs ${acao} joao.silva${acao === "wifi-scan" ? "" : " 8.8.8.8"}\`.`;
|
|
|
+ const host = acao === "wifi-scan" ? null : (hostInformado ?? HOST_PADRAO);
|
|
|
+ if (host && !HOST_RE.test(host)) return `Host inválido: "${host}". Use um IP ou domínio (ex.: 8.8.8.8).`;
|
|
|
+
|
|
|
+ const { status, resultado, reaproveitado, possivelmenteAntigo } = await executarDiagnostico(montarRef(alvo), acao, host);
|
|
|
+ if (status === "pendente") {
|
|
|
+ return (
|
|
|
+ `${reaproveitado ? "O diagnóstico já estava em andamento" : "Diagnóstico disparado"} na CPE de **${alvo}**, ` +
|
|
|
+ "mas o resultado ainda não chegou (o roteador responde no próximo contato com o ACS, até ~2 min). " +
|
|
|
+ `Repita \`/acs ${acao} ${alvo}${hostInformado ? ` ${hostInformado}` : ""}\` em 1 minuto — não vai disparar outro teste.`
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (status === "erro") return `O roteador de **${alvo}** recusou ou falhou no diagnóstico (estado: ${resultado.estado}).`;
|
|
|
+ if (acao === "ping") return formatarPing(resultado, host);
|
|
|
+ if (acao === "traceroute") return formatarTraceroute(resultado, host);
|
|
|
+ return formatarScan(resultado, possivelmenteAntigo);
|
|
|
+}
|
|
|
+
|
|
|
+export async function resolverComandoAcs(comando) {
|
|
|
+ const { acao, alvo, host } = comando;
|
|
|
+ if (acao === "ajuda") return { answer: AJUDA, sources: [] };
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (acao === "resumo") {
|
|
|
+ const dispositivo = await buscarDispositivo(montarRef(alvo));
|
|
|
+ if (!dispositivo) return { answer: mensagemErro({ status: 404 }, alvo), sources: [] };
|
|
|
+ return { answer: formatarResumo(dispositivo, alvo), sources: [] };
|
|
|
+ }
|
|
|
+ return { answer: await resolverDiagnostico(acao, alvo, host), sources: [] };
|
|
|
+ } catch (err) {
|
|
|
+ return { answer: mensagemErro(err, alvo), sources: [] };
|
|
|
+ }
|
|
|
+}
|