import { randomInt } from "node:crypto"; import http from "node:http"; import https from "node:https"; import { nodeTypes, queryCommands } from "./flow-catalog.mjs"; import { valueAt, interpolate, evaluate, compare, resolveObject, } from "./flow-values.mjs"; export const exampleClients = [ { clid: "1", cid: "21", client_database_id: "10", client_type: "0", client_nickname: "Pessoa A", client_lastconnected: "1", client_idle_time: "0", }, { clid: "2", cid: "21", client_database_id: "11", client_type: "0", client_nickname: "Pessoa B", client_lastconnected: "2", client_idle_time: "600000", }, { clid: "3", cid: "22", client_database_id: "12", client_type: "0", client_nickname: "Pessoa C", client_lastconnected: "3", client_idle_time: "120000", }, ]; export function simulatedQuery(command) { switch (command) { case "serverinfo": return [ { virtualserver_clientsonline: 4, virtualserver_queryclientsonline: 1, virtualserver_maxclients: 32, virtualserver_channelsonline: 17, virtualserver_name: "Servidor de exemplo", }, ]; case "clientlist": return structuredClone(exampleClients); case "channellist": return [ { cid: "21", channel_name: "Canal de exemplo", channel_flag_permanent: "1", total_clients: "3", }, { cid: "22", channel_name: "AFK", channel_flag_permanent: "1", total_clients: "0", }, ]; case "servergrouplist": return [{ sgid: "9", name: "Grupo de exemplo" }]; case "channelgrouplist": return [{ cgid: "8", name: "Convidado" }]; case "permissionlist": return [ { permid: "1", permname: "i_client_talk_power", permdesc: "Poder de fala", }, ]; case "channelcreate": return [{ cid: "99" }]; case "clientinfo": return [structuredClone(exampleClients[0])]; case "servergroupsbyclientid": return [{ sgid: "9" }]; default: return []; } } export function integer(value, label, min = 1, max = Number.MAX_SAFE_INTEGER) { const number = Number(value); if ( String(value ?? "").trim() === "" || !Number.isInteger(number) || number < min || number > max ) throw new Error(`${label}: indica um número inteiro válido.`); return number; } function object(value) { if (typeof value === "string") { try { value = JSON.parse(value || "{}"); } catch { throw new Error("JSON inválido nos parâmetros do bloco."); } } if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Usa um objeto JSON nos parâmetros."); return value; } export function httpRequest( url, { method = "GET", headers = {}, body = "", signal } = {}, ) { const address = new URL(url); if ( !["http:", "https:"].includes(address.protocol) || address.username || address.password ) throw new Error("Usa um endereço HTTP/HTTPS sem credenciais no URL."); if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) throw new Error("Método HTTP inválido."); return new Promise((resolve, reject) => { const req = (address.protocol === "https:" ? https : http).request( address, { method, headers, signal, timeout: 10000 }, (res) => { const chunks = []; let size = 0; res.on("data", (chunk) => { size += chunk.length; if (size > 2000000) req.destroy(new Error("Resposta HTTP demasiado grande.")); else chunks.push(chunk); }); res.on("error", reject); res.on("end", () => { if (res.statusCode >= 300) { reject(new Error(`HTTP ${res.statusCode}: pedido recusado.`)); return; } const text = Buffer.concat(chunks).toString("utf8"); let data = text; try { data = JSON.parse(text); } catch {} resolve({ status: res.statusCode, body: data }); }); }, ); req.on("timeout", () => req.destroy(new Error("O pedido HTTP excedeu 10 segundos.")), ); req.on("error", reject); if (method !== "GET" && body) req.write(body); req.end(); }); } export async function executeStep( node, context, { simulation, call, persist, signal, httpTransport = httpRequest, channelMessage, }, ) { const type = nodeTypes[node.type], config = { ...type.defaults, ...node.config }, c = resolveObject(config, context); let message = "", port = "out"; const store = (name, value) => { if (!name) return; if ( !/^[A-Za-z_]\w*$/.test(name) || ["__proto__", "prototype", "constructor"].includes(name) ) throw new Error("Nome de variável inválido."); context.temp[name] = value; }; const query = async (command, params) => simulation ? simulatedQuery(command) : call(command, params); if (type.command) { const params = Object.fromEntries( Object.entries(type.params) .map(([key, value]) => [key, c[value]]) .filter(([, value]) => value !== undefined && value !== ""), ); for (const key of ["clid", "cldbid", "cid", "sgid", "cgid", "banid"]) if (params[key] != null) params[key] = integer(params[key], key); for (const key of ["cpid", "order", "time", "start", "duration"]) if (params[key] != null) params[key] = integer(params[key], key, 0); if (node.type.includes("PermissionSet")) { if (!/^[bi]_[\w]+$/.test(c.permission)) throw new Error("Seleciona uma permissão válida."); params.permvalue = integer( c.value, "Valor da permissão", -2147483648, 2147483647, ); if (!node.type.startsWith("servergroup")) delete params.permnegated; if ( !node.type.startsWith("servergroup") && !node.type.startsWith("client") ) delete params.permskip; } const result = await query(type.command, params); store( c.storeAs, [ "clientInfo", "channelInfo", "servergroupCreate", "channelgroupCreate", ].includes(node.type) ? result[0] || {} : result, ); context.temp.lastResult = result; if (node.type === "channels") context.channels = result; message = `${simulation ? "Simulação: " : ""}${type.command} · ${result.length} resultados`; return { message, port }; } switch (node.type) { case "serverinfo": context.server = (await query("serverinfo", {}))[0]; context.temp.server = context.server; message = simulation ? "Dados de exemplo para simulação" : "Informações obtidas"; break; case "clients": context.clients = ( await query("clientlist", { "-times": true, "-groups": true, "-uid": true, }) ).filter( (client) => c.includeQuery === "true" || Number(client.client_type) === 0, ); context.temp.clients = context.clients; context.count = context.clients.length; message = `${context.count} clientes${simulation ? " (exemplo)" : ""}`; break; case "sort": { const list = valueAt(context, c.list || "clients"); if (!Array.isArray(list)) throw new Error("A variável selecionada não é uma lista."); const sorted = [...list].sort((a, b) => { const av = valueAt(a, c.field), bv = valueAt(b, c.field), order = Number.isFinite(Number(av)) && Number.isFinite(Number(bv)) ? Number(av) - Number(bv) : String(av ?? "").localeCompare(String(bv ?? "")); return order * (c.direction === "desc" ? -1 : 1); }); if ((c.list || "clients") === "clients") context.clients = sorted; else store((c.list || "").replace(/^temp\./, ""), sorted); message = `Ordenado por ${c.field}`; break; } case "limit": context.clients = (context.clients || []).slice( 0, integer(c.count, "Limite", 1, 1000), ); context.top = context.clients .map((client, index) => `${index + 1}. ${client.client_nickname}`) .join("\n"); message = `Selecionadas ${context.clients.length} pessoas`; break; case "filter": { const list = valueAt(context, c.list); if (!Array.isArray(list)) throw new Error("Seleciona uma lista para filtrar."); const result = list.filter((item) => compare(valueAt(item, c.field), c.operator, c.value), ); store(c.storeAs, result); message = `${result.length} de ${list.length} itens`; break; } case "condition": port = compare(valueAt(context, c.field), c.operator, c.value) ? "true" : "false"; message = port === "true" ? "Sim" : "Não"; break; case "expression": port = evaluate(c.expression, { ...context, time: context.clock }) ? "true" : "false"; message = port === "true" ? "Sim" : "Não"; break; case "variable": { if ( !/^[A-Za-z_]\w*$/.test(c.name) || ["__proto__", "prototype", "constructor"].includes(c.name) ) throw new Error("Nome de variável inválido."); const target = c.scope === "persistent" ? context.variables : context.temp; switch (c.operation) { case "delete": delete target[c.name]; break; case "increment": { const value = Number(target[c.name] ?? 0) + Number(c.value); if (!Number.isFinite(value)) throw new Error("O incremento precisa de um número."); target[c.name] = value; break; } case "append": target[c.name] = String(target[c.name] ?? "") + String(c.value); break; case "set": { let value = c.value; if (c.valueType === "json") { try { value = JSON.parse(value); } catch { throw new Error("Valor JSON inválido."); } } if (c.valueType === "number") { value = Number(value); if (!Number.isFinite(value) || String(c.value).trim() === "") throw new Error("Valor numérico inválido."); } if (c.valueType === "boolean") { if (!["true", "false"].includes(String(value))) throw new Error("Usa true ou false para booleanos."); value = String(value) === "true"; } target[c.name] = value; break; } default: throw new Error("Operação inválida."); } if (c.scope === "persistent" && !simulation) persist(context.variables); message = `${c.scope === "persistent" ? "Persistente" : "Temporária"}: ${c.name}`; break; } case "calculate": store( c.storeAs, evaluate(c.expression, { ...context, time: context.clock }), ); message = `${c.storeAs} = ${context.temp[c.storeAs]}`; break; case "random": { const alphabet = c.alphabet === "numbers" ? "0123456789" : c.alphabet === "letters" ? "ABCDEFGHJKLMNPQRSTUVWXYZ" : "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; let code = ""; for (let i = 0; i < integer(c.length, "Comprimento", 1, 128); i++) code += alphabet[randomInt(alphabet.length)]; store(c.storeAs, code); message = `Código guardado em temp.${c.storeAs}`; break; } case "template": context.text = c.text; message = context.text; break; case "serverEdit": { const params = object(c.params); if (!Object.keys(params).length) { message = "Nenhum campo de servidor indicado."; break; } await query("serveredit", params); message = `${simulation ? "Simulação: " : ""}Editar servidor · ${Object.keys(params).length} campos`; break; } case "channelCreate": case "channelEdit": { const create = node.type === "channelCreate", params = object(c.params || {}); if (create) { if (!c.name) throw new Error("Indica um nome para o canal."); Object.assign(params, { channel_name: c.name, cpid: integer(c.parentId ?? 0, "Canal pai", 0), channel_flag_permanent: c.permanent === "true" ? 1 : 0, }); } else params.cid = integer(c.channelId, "Canal"); for (const [key, field] of [ ["channel_name", "name"], ["channel_topic", "topic"], ["channel_description", "description"], ["channel_password", "password"], ]) if (c[field] !== undefined && c[field] !== "") params[key] = c[field]; const result = await query( create ? "channelcreate" : "channeledit", params, ); if (create) store(c.storeAs, result[0] || {}); message = `${simulation ? "Simulação: " : ""}${create ? "Criar canal" : "Editar canal " + c.channelId}${c.description ? "\n" + c.description : ""}`; break; } case "message": { const mode = { client: 1, channel: 2, server: 3 }[c.mode]; if (!mode) throw new Error("Destino da mensagem inválido."); const target = mode === 3 ? 0 : integer(c.target, "Destino"); if (!simulation) { if (mode === 2) await channelMessage(target, c.text); else await call("sendtextmessage", { targetmode: mode, target, msg: c.text, }); } message = `${simulation ? "Simulação: " : ""}${c.text}`; break; } case "http": case "discord": { const discord = node.type === "discord"; if ( discord && !/^https:\/\/(?:(?:canary|ptb)\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\//.test( c.url, ) ) throw new Error("Indica um URL de webhook Discord válido."); const options = discord ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: c.text, username: c.username || undefined, allowed_mentions: { parse: [] }, }), signal, } : { method: c.method, headers: object(c.headers || {}), body: c.body, signal, }; const response = simulation ? { status: 200, body: { simulation: true } } : await httpTransport(c.url, options); store(c.storeAs, response.body); context.temp.httpStatus = response.status; message = simulation ? "Simulação: nenhum pedido HTTP enviado" : `HTTP ${response.status}`; break; } case "webquery": { const command = String(c.command).toLowerCase(); if (!queryCommands.includes(command)) throw new Error("Operação WebQuery não suportada no editor."); const result = await query(command, object(c.params || {})); context.temp.lastResult = result; store(c.storeAs, result); message = `${simulation ? "Simulação: " : ""}${command} · ${result.length} resultados`; break; } case "delay": if ( !Number.isFinite(Number(c.seconds)) || Number(c.seconds) < 0 || Number(c.seconds) > 300 ) throw new Error("A espera deve ser entre 0 e 300 segundos."); if (!simulation) await new Promise((resolve, reject) => { const finish = () => { signal?.removeEventListener("abort", abort); resolve(); }, timer = setTimeout(finish, Number(c.seconds) * 1000), abort = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(new Error("Execução cancelada.")); }; signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) abort(); }); message = `${c.seconds} segundos`; break; case "log": message = c.text; break; case "stop": message = "Ramo terminado."; port = null; break; default: if (!type.trigger && node.type !== "foreach") throw new Error("Bloco sem implementação."); message = simulation ? "Teste manual do trigger" : "Flow iniciado"; } return { message, port }; }