import { randomUUID, randomBytes, createCipheriv, createDecipheriv, timingSafeEqual, } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import http from "node:http"; import https from "node:https"; import { nodeTypes, queryCommands, validateGraph } from "./flow-catalog.mjs"; import { interpolate, valueAt, timeValues, resolveObject, } from "./flow-values.mjs"; import { executeStep, simulatedQuery, integer, httpRequest, } from "./flow-steps.mjs"; import { templates, templateGraph } from "./flow-templates.mjs"; import { compileCron } from "./flow-schedule.mjs"; import { createEventListener } from "./query-events.mjs"; export { nodeTypes, validateGraph, interpolate }; const ownName = (name) => /^[A-Za-z_]\w*$/.test(name) && !["__proto__", "prototype", "constructor"].includes(name); async function bodyOf(req) { let bytes = 0, chunks = []; for await (const chunk of req) { bytes += chunk.length; if (bytes > 500000) throw new Error("Pedido demasiado grande."); chunks.push(chunk); } return chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}; } export function createStudio( db, dataDir, { transport = query, eventFactory = createEventListener, httpTransport = httpRequest, scheduler = true, } = {}, ) { db.exec( "CREATE TABLE IF NOT EXISTS studio_bots (id TEXT PRIMARY KEY,data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS studio_connections (id TEXT PRIMARY KEY,data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS studio_executions (id TEXT PRIMARY KEY,data TEXT NOT NULL);", ); const keyPath = join(dataDir, "credentials.key"); if (!existsSync(keyPath)) writeFileSync(keyPath, randomBytes(32), { mode: 0o600 }); const key = readFileSync(keyPath); const encrypt = (text) => { const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", key, iv), bytes = Buffer.concat([ cipher.update(String(text), "utf8"), cipher.final(), ]); return Buffer.concat([iv, cipher.getAuthTag(), bytes]).toString("base64"); }; const decrypt = (text) => { const bytes = Buffer.from(text, "base64"), cipher = createDecipheriv("aes-256-gcm", key, bytes.subarray(0, 12)); cipher.setAuthTag(bytes.subarray(12, 28)); return Buffer.concat([ cipher.update(bytes.subarray(28)), cipher.final(), ]).toString("utf8"); }; const secretsFor = (node) => [ ...(nodeTypes[node.type]?.fields .filter((f) => f[2] === "password") .map((f) => f[0]) || []), ...(node.type === "http" ? ["headers"] : []), ]; const encode = (table, item) => { const copy = structuredClone(item); if (table === "bots") for (const node of copy.graph.nodes) { node.secureConfig = {}; for (const field of secretsFor(node)) { if (node.config[field] != null && node.config[field] !== "") { node.secureConfig[field] = encrypt( JSON.stringify(node.config[field]), ); delete node.config[field]; } } } return copy; }; const decode = (table, item) => { if (table === "bots") for (const node of item.graph.nodes) { for (const [field, secret] of Object.entries(node.secureConfig || {})) node.config[field] = JSON.parse(decrypt(secret)); delete node.secureConfig; } return item; }; const list = (table) => db .prepare(`SELECT data FROM studio_${table} ORDER BY rowid DESC`) .all() .map((row) => decode(table, JSON.parse(row.data))); const get = (table, id) => { const row = db .prepare(`SELECT data FROM studio_${table} WHERE id=?`) .get(id); return row ? decode(table, JSON.parse(row.data)) : null; }; const save = (table, item) => db .prepare( `INSERT INTO studio_${table} VALUES (?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data`, ) .run(item.id, JSON.stringify(encode(table, item))); if (!list("bots").length) save("bots", { id: randomUUID(), name: "Estatísticas", description: templates[0].description, color: "green", enabled: false, connectionId: "", variables: {}, graph: templateGraph(), updatedAt: new Date().toISOString(), }); db.exec( "UPDATE studio_executions SET data=json_set(data,'$.status','error','$.error','A aplicação reiniciou durante a execução.') WHERE json_extract(data,'$.status')='running'", ); const finished = new Map(), running = new Map(), due = new Map(), listeners = new Map(), eventStates = new Map(), queues = new Map(), locks = new Map(), hookLimits = new Map(); let closed = false; const publicConnection = (c) => { const { apiKey, sshPassword, ...fields } = c; return { ...fields, hasApiKey: Boolean(apiKey), hasSshPassword: Boolean(sshPassword), eventState: [...eventStates.entries()] .filter(([id]) => id.startsWith(c.id + ":")) .map(([, s]) => s), }; }; const publicBot = (bot, user) => { if (user.role === "Dono") return bot; const copy = structuredClone(bot); for (const node of copy.graph.nodes) for (const field of secretsFor(node)) delete node.config[field]; return copy; }; const connectionFor = (bot) => { const c = get("connections", bot.connectionId); if (!c) throw new Error("Configura uma ligação TeamSpeak antes de executar."); return { ...c, apiKey: decrypt(c.apiKey), sshPassword: c.sshPassword ? decrypt(c.sshPassword) : "", }; }; async function lock(id, fn) { const previous = locks.get(id) || Promise.resolve(); let release; const current = new Promise((r) => (release = r)); locks.set(id, current); await previous; try { return await fn(); } finally { release(); if (locks.get(id) === current) locks.delete(id); } } const callFor = (connection, signal) => (command, params = {}) => lock(connection.id, () => { if (signal?.aborted) throw new Error("Execução cancelada."); return transport(connection, command, params, { signal }); }); function warnings(graph) { const reachable = new Set(); function visit(id) { if (reachable.has(id)) return; reachable.add(id); for (const e of graph.edges.filter((e) => e.from === id)) visit(e.to); } for (const n of graph.nodes.filter((n) => nodeTypes[n.type]?.trigger)) visit(n.id); return graph.nodes .filter((n) => !reachable.has(n.id)) .map((n) => `O bloco ${n.label} não está ligado a um início.`); } function check(graph) { validateGraph(graph); for (const n of graph.nodes) if (n.type === "cron") compileCron(n.config.expression, n.config.timezone); return warnings(graph); } async function run(bot, simulation, actor, event = {}, triggerId) { if (closed) throw new Error("O motor está a terminar."); if (running.has(bot.id)) throw new Error("Este bot já está em execução."); check(bot.graph); const connection = simulation ? null : connectionFor(bot), controller = new AbortController(); running.set(bot.id, controller); const start = triggerId ? bot.graph.nodes.find((n) => n.id === triggerId) : bot.graph.nodes.find((n) => nodeTypes[n.type].trigger); if (!start || !nodeTypes[start.type].trigger) { running.delete(bot.id); throw new Error("Seleciona um trigger válido."); } let finish; finished.set(bot.id, new Promise((resolve) => (finish = resolve))); const execution = { id: randomUUID(), botId: bot.id, botName: bot.name, simulation, actor, triggerId: start.id, status: "running", startedAt: new Date().toISOString(), steps: [], }; save("executions", execution); const clock = timeValues(new Date(), bot.timezone || "Europe/Lisbon"), variables = structuredClone(bot.variables || {}), temp = {}; const context = { variables, var: variables, temp, event: simulation && Object.keys(event).length === 0 ? { clid: "1", cid: "21", client_database_id: "10", client_nickname: "Pessoa A", client_type: "0", msg: "!info", command_args: "", } : event, time: `${clock.date}, ${clock.time}:${clock.seconds}`, clock, exec: { flowId: bot.id, executionId: execution.id, sid: connection?.serverId || 1, triggerType: start.type, }, }; const timeout = setTimeout( () => controller.abort(), Math.min(600, Math.max(10, Number(bot.timeoutSeconds || 120))) * 1000, ); timeout.unref?.(); const call = connection ? callFor(connection, controller.signal) : async (command) => simulatedQuery(command); const channelMessage = (target, text) => lock(connection.id, async () => { const send = (cmd, params) => transport(connection, cmd, params, { signal: controller.signal }), who = (await send("whoami", {}))[0]; const clid = integer(who?.client_id || who?.clid, "ID Query"), original = who?.client_channel_id || who?.cid; await send("clientmove", { clid, cid: target }); try { await send("sendtextmessage", { targetmode: 2, msg: text }); } finally { if (original && String(original) !== String(target)) await transport( connection, "clientmove", { clid, cid: original }, {}, ); } }); let visits = 0, handledError = false; async function branch(id) { if (controller.signal.aborted) throw new Error("Execução cancelada ou limite de duração atingido."); if (++visits > 2000) throw new Error("Limite de 2000 passos excedido."); const node = bot.graph.nodes.find((n) => n.id === id), step = { nodeId: id, label: node.label || nodeTypes[node.type].title, type: node.type, startedAt: new Date().toISOString(), status: "running", attempts: 0, }; execution.steps.push(step); let port = "out", items; try { const retries = Number(node.config.retries || 0); for (let attempt = 0; attempt <= retries; attempt++) { step.attempts++; try { if (node.type === "foreach") { const config = resolveObject(node.config, context), list = valueAt(context, config.list); if (!Array.isArray(list)) throw new Error("Seleciona uma lista para repetir."); items = list.slice( 0, integer(config.limit, "Máximo de itens", 1, 1000), ); step.message = `${items.length} itens`; port = "done"; } else { const result = await executeStep(node, context, { simulation, call, signal: controller.signal, httpTransport, channelMessage, persist: (vars) => { const latest = get("bots", bot.id); if (latest) { latest.variables = vars; save("bots", latest); } }, }); step.message = result.message; port = result.port; } break; } catch (error) { if (controller.signal.aborted || attempt === retries) throw error; await new Promise((resolve) => setTimeout(resolve, simulation ? 0 : 1000), ); } } step.status = "success"; } catch (error) { step.status = "error"; step.message = error.message; if (controller.signal.aborted) throw error; const errorEdges = bot.graph.edges.filter( (e) => e.from === id && e.port === "error", ); if (!errorEdges.length) throw error; context.error = { message: error.message, nodeId: id }; context.temp.error = context.error; port = "error"; handledError = true; } finally { step.durationMs = Date.now() - Date.parse(step.startedAt); save("executions", execution); } if (items) { const before = context.item, index = context.index; for (let i = 0; i < items.length; i++) { context.item = items[i]; context.index = i; for (const e of bot.graph.edges.filter( (e) => e.from === id && e.port === "each", )) await branch(e.to); } context.item = before; context.index = index; } if (port) for (const e of bot.graph.edges.filter( (e) => e.from === id && e.port === port, )) await branch(e.to); } try { await branch(start.id); execution.status = handledError ? "warning" : "success"; } catch (error) { execution.status = controller.signal.aborted ? "cancelled" : "error"; execution.error = error.message; } finally { clearTimeout(timeout); execution.durationMs = Date.now() - Date.parse(execution.startedAt); execution.output = context.text || ""; save("executions", execution); running.delete(bot.id); finished.delete(bot.id); finish(); db.exec( "DELETE FROM studio_executions WHERE rowid NOT IN (SELECT rowid FROM studio_executions ORDER BY rowid DESC LIMIT 500)", ); } return execution; } function enqueue(bot, node, event, actor) { const queue = queues.get(bot.id) || []; if (queue.length >= 100) { eventStates.set(bot.connectionId + ":queue", { status: "error", error: "Fila de eventos cheia; evento descartado.", }); return; } queue.push({ node, event, actor }); queues.set(bot.id, queue); drain(bot.id); } async function drain(id) { if (running.has(id) || closed) return; const queue = queues.get(id), next = queue?.shift(); if (!next) return; const bot = get("bots", id); if (bot?.enabled) try { await run(bot, false, next.actor, next.event, next.node); } catch (error) { eventStates.set(bot.connectionId + ":queue", { status: "error", error: error.message, }); } if (queue.length) drain(id); } async function receive(connectionId, eventName, data) { if (closed) return; const event = { ...data, clid: data.clid || data.invokerid || data.client_id, cid: data.cid || data.ctid || data.target, }; for (const bot of list("bots").filter( (b) => b.enabled && b.connectionId === connectionId, )) { for (const node of bot.graph.nodes) { if (node.type === "event" && node.config.eventName === eventName) { if (data.__listenerChannel && data.__listenerChannel !== "0") continue; const filters = node.config.filters || {}; if ( Object.entries(filters).every( ([path, value]) => String(valueAt(event, path)) === String(value), ) ) enqueue(bot, node.id, event, "Evento TeamSpeak"); } if (node.type === "command" && eventName === "notifytextmessage") { const channel = String(node.config.channelId || "0"); if (channel !== String(data.__listenerChannel || "0")) continue; const msg = data.msg || "", command = node.config.command; if (msg !== command && !msg.startsWith(command + " ")) continue; try { const c = connectionFor(bot), call = callFor(c), info = event.clid ? (await call("clientinfo", { clid: event.clid }))[0] : {}; const enriched = { ...event, client_database_id: info?.client_database_id || event.client_database_id, client_nickname: info?.client_nickname || event.invokername, command_args: msg.slice(command.length).trim(), command_name: command, }; if (node.config.allowedGroup) { const groups = await call("servergroupsbyclientid", { cldbid: enriched.client_database_id, }); if ( !groups.some( (g) => String(g.sgid) === String(node.config.allowedGroup), ) ) continue; } enqueue(bot, node.id, enriched, "Comando de chat"); } catch (error) { eventStates.set(connectionId + ":command", { status: "error", error: error.message, }); } } } } } function reconcile() { const wanted = new Map(); for (const bot of list("bots").filter((b) => b.enabled)) { const c = get("connections", bot.connectionId); if (!c?.eventsEnabled) continue; for (const node of bot.graph.nodes.filter((n) => ["event", "command"].includes(n.type), )) { const channel = node.type === "command" ? Number(node.config.channelId || 0) : 0; wanted.set(`${c.id}:${channel}`, { c, channel }); } } for (const [id, entry] of listeners) if ( !wanted.has(id) || entry.signature !== JSON.stringify(wanted.get(id).c) ) { entry.listener.close(); listeners.delete(id); eventStates.delete(id); } for (const [id, { c, channel }] of wanted) if (!listeners.has(id)) { try { const listener = eventFactory( { ...c, apiKey: decrypt(c.apiKey), sshPassword: decrypt(c.sshPassword), }, (name, data) => receive(c.id, name, { ...data, __listenerChannel: String(channel), }).catch((error) => eventStates.set(id, { status: "error", error: error.message }), ), (state) => { if (closed) return; eventStates.set(id, state); if (state.fingerprint && !c.sshFingerprint) { c.sshFingerprint = state.fingerprint; const latest = get("connections", c.id); if (latest && !latest.sshFingerprint) { latest.sshFingerprint = state.fingerprint; save("connections", latest); } const entry = listeners.get(id); if (entry) entry.signature = JSON.stringify(c); } }, { channelId: channel }, ); listeners.set(id, { listener, signature: JSON.stringify(c) }); } catch (error) { eventStates.set(id, { status: "error", error: error.message }); } } } const interval = scheduler ? setInterval(() => { if (closed) return; reconcile(); const now = new Date(), second = Math.floor(now.getTime() / 1000); for (const bot of list("bots").filter((b) => b.enabled)) { drain(bot.id); for (const node of bot.graph.nodes) { const id = bot.id + ":" + node.id; if (node.type === "timer") { if ((due.get(id) || 0) > Date.now() || running.has(bot.id)) continue; due.set(id, Date.now() + Number(node.config.seconds) * 1000); run(bot, false, "Temporizador", {}, node.id).catch(() => {}); } if (node.type === "cron") { try { if ( due.get(id) === second || !compileCron( node.config.expression, node.config.timezone, )(now) ) continue; due.set(id, second); enqueue(bot, node.id, {}, "Cron"); } catch (error) { eventStates.set(bot.connectionId + ":cron", { status: "error", error: error.message, }); } } } } }, 1000) : null; interval?.unref(); async function handleWebhook(req, url, json) { if (!url.pathname.startsWith("/api/hooks/")) return false; try { if (req.method !== "POST") { json(405, { error: "Usa POST." }); return true; } const id = url.pathname.slice("/api/hooks/".length), bot = get("bots", id); if (!bot?.enabled) { json(404, { error: "Webhook indisponível." }); return true; } const supplied = String( req.headers["x-webhook-secret"] || req.headers.authorization?.replace(/^Bearer /, "") || "", ); const nodes = bot.graph.nodes.filter( (n) => n.type === "webhook" && n.config.secret && Buffer.byteLength(n.config.secret) === Buffer.byteLength(supplied) && timingSafeEqual(Buffer.from(n.config.secret), Buffer.from(supplied)), ); if (!nodes.length) { json(401, { error: "Segredo inválido." }); return true; } const limit = hookLimits.get(id) || { start: Date.now(), count: 0 }; if (Date.now() - limit.start > 60000) { limit.start = Date.now(); limit.count = 0; } hookLimits.set(id, limit); if (++limit.count > 30) { json(429, { error: "Limite de 30 webhooks por minuto." }); return true; } const body = await bodyOf(req), event = { webhook_body: body }; for (const node of nodes) if ( Object.entries(node.config.filters || {}).every( ([path, value]) => String(valueAt(body, path)) === String(value), ) ) enqueue(bot, node.id, event, "Webhook"); json(202, { accepted: true }); } catch (error) { json(400, { error: error.message }); } return true; } async function route(req, url, user, json) { if (!url.pathname.startsWith("/api/")) return false; if (req.method === "GET" && url.pathname === "/api/dashboard") { const bots = list("bots").map((b) => publicBot(b, user)); json(200, { bots, flows: bots, executions: list("executions"), connections: list("connections").map(publicConnection), nodeTypes, queryCommands, templates, engine: { running: [...running.keys()] }, }); return true; } if (!["POST", "PUT", "DELETE"].includes(req.method)) return false; if (user.role !== "Dono") { json(403, { error: "O teu perfil permite apenas consultar. Precisas do perfil Dono para guardar ou executar.", }); return true; } if (req.headers.origin !== url.origin) { json(403, { error: "Origem inválida." }); return true; } try { const body = await bodyOf(req); if (url.pathname === "/api/validate") { json(200, { warnings: check(body.graph) }); return true; } if (url.pathname === "/api/bots" && req.method === "POST") { const imported = body.import || null, graph = imported?.graph || templateGraph(body.templateId || "stats"); if (imported) for (const n of graph.nodes) if (n.type === "webhook" && !n.config.secret) n.config.secret = randomBytes(24).toString("hex"); check(graph); const bot = { id: randomUUID(), name: String(imported?.name || body.name || "Novo bot").slice(0, 80), description: String( imported?.description || body.description || "", ).slice(0, 400), color: ["green", "red", "cyan", "orange", "purple"].includes( body.color || imported?.color, ) ? body.color || imported.color : "purple", enabled: false, connectionId: "", variables: imported?.variables || {}, graph, timeoutSeconds: 120, updatedAt: new Date().toISOString(), }; if ( !bot.variables || typeof bot.variables !== "object" || Array.isArray(bot.variables) || Object.keys(bot.variables).some((k) => !ownName(k)) ) throw new Error("Variáveis inválidas."); save("bots", bot); json(201, bot); return true; } const match = url.pathname.match( /^\/api\/bots\/([\w-]+)(?:\/(run|simulate|cancel|duplicate|export))?$/, ); if (match) { const bot = get("bots", match[1]); if (!bot) { json(404, { error: "Bot não encontrado." }); return true; } if (match[2] === "cancel") { running.get(bot.id)?.abort(); queues.delete(bot.id); json(200, { ok: true }); return true; } if (match[2] === "duplicate") { const copy = { ...bot, id: randomUUID(), name: bot.name + " (cópia)", enabled: false, updatedAt: new Date().toISOString(), }; save("bots", copy); json(201, copy); return true; } if (match[2] === "export") { const copy = structuredClone(bot); for (const node of copy.graph.nodes) for (const field of secretsFor(node)) delete node.config[field]; delete copy.connectionId; delete copy.id; copy.enabled = false; json(200, { format: "ts6-botflow/v1", bot: copy }); return true; } if (req.method === "DELETE") { if (running.has(bot.id)) throw new Error("Cancela a execução antes de apagar."); db.prepare("DELETE FROM studio_bots WHERE id=?").run(bot.id); queues.delete(bot.id); reconcile(); json(200, { ok: true }); return true; } if (req.method === "POST" && ["run", "simulate"].includes(match[2])) { if ( body.event != null && (!body.event || typeof body.event !== "object" || Array.isArray(body.event)) ) throw new Error("O evento de teste precisa de ser um objeto JSON."); json( 200, await run( bot, match[2] === "simulate", user.name, body.event || {}, body.triggerId, ), ); return true; } if (req.method === "PUT") { const graph = body.graph || bot.graph; check(graph); const connectionId = String(body.connectionId ?? bot.connectionId), enabled = Boolean(body.enabled ?? bot.enabled); if (connectionId && !get("connections", connectionId)) throw new Error("Ligação não encontrada."); if (enabled && !connectionId) throw new Error("Escolhe uma ligação antes de ativar o bot."); if ( enabled && !graph.nodes.some( (n) => nodeTypes[n.type].trigger && n.type !== "manual", ) ) throw new Error( "Adiciona um trigger automático para ativar o bot.", ); const c = get("connections", connectionId); if ( enabled && graph.nodes.some((n) => ["event", "command"].includes(n.type)) && !c?.eventsEnabled ) throw new Error( "Ativa e configura os eventos SSH na ligação do servidor.", ); const updated = { ...bot, name: String(body.name ?? bot.name) .trim() .slice(0, 80), description: String(body.description ?? bot.description).slice( 0, 400, ), graph, connectionId, enabled, variables: body.variables ?? bot.variables, timeoutSeconds: integer( body.timeoutSeconds ?? bot.timeoutSeconds ?? 120, "Limite de duração", 10, 600, ), updatedAt: new Date().toISOString(), }; if ( !updated.name || !updated.variables || typeof updated.variables !== "object" || Array.isArray(updated.variables) || Object.keys(updated.variables).some((k) => !ownName(k)) ) throw new Error("Nome ou variáveis inválidos."); save("bots", updated); for (const id of due.keys()) if (id.startsWith(bot.id + ":")) due.delete(id); if (!enabled) queues.delete(bot.id); reconcile(); json(200, publicBot(updated, user)); return true; } } if (url.pathname === "/api/connections" && req.method === "POST") { const old = body.id ? get("connections", body.id) : null, base = new URL(body.url); if ( !["http:", "https:"].includes(base.protocol) || base.username || base.password || base.search || base.hash ) throw new Error("Usa HTTP/HTTPS sem credenciais no endereço."); const serverId = integer(body.serverId || 1, "Servidor"); if (!body.apiKey && !old?.apiKey) throw new Error("Indica a API key TeamSpeak."); const connection = { id: old?.id || randomUUID(), name: String(body.name || "Servidor principal").slice(0, 80), url: base.origin, serverId, apiKey: body.apiKey ? encrypt(body.apiKey) : old.apiKey, eventsEnabled: body.eventsEnabled === true || body.eventsEnabled === "true", sshHost: String(body.sshHost || base.hostname), sshPort: integer(body.sshPort || 10022, "Porta SSH", 1, 65535), sshUsername: String(body.sshUsername || ""), sshPassword: body.sshPassword ? encrypt(body.sshPassword) : old?.sshPassword || "", sshFingerprint: String( body.sshFingerprint ?? old?.sshFingerprint ?? "", ) .trim() .toLowerCase(), }; if ( connection.sshFingerprint && !/^[a-f0-9]{64}$/.test(connection.sshFingerprint) ) throw new Error( "A impressão digital SSH deve ter 64 caracteres hexadecimais (SHA-256).", ); if ( connection.eventsEnabled && (!connection.sshUsername || !connection.sshPassword) ) throw new Error( "Indica utilizador e password SSH Query para ativar eventos.", ); save("connections", connection); reconcile(); json(200, publicConnection(connection)); return true; } const cMatch = url.pathname.match( /^\/api\/connections\/([\w-]+)(?:\/(test|resources))?$/, ); if (cMatch) { const c = get("connections", cMatch[1]); if (!c) throw new Error("Ligação não encontrada."); if (req.method === "DELETE") { if (list("bots").some((b) => b.connectionId === c.id)) throw new Error("Remove primeiro a ligação dos bots que a usam."); db.prepare("DELETE FROM studio_connections WHERE id=?").run(c.id); reconcile(); json(200, { ok: true }); return true; } const call = callFor({ ...c, apiKey: decrypt(c.apiKey) }); if (cMatch[2] === "test") { json(200, { server: (await call("serverinfo"))[0] }); return true; } if (cMatch[2] === "resources") { const result = {}, errors = {}; for (const [name, command] of [ ["clients", "clientlist"], ["channels", "channellist"], ["groups", "servergrouplist"], ["channelGroups", "channelgrouplist"], ["permissions", "permissionlist"], ]) try { result[name] = await call(command, {}); } catch (error) { result[name] = []; errors[name] = error.message; } json(200, { ...result, errors }); return true; } } return false; } catch (error) { json(400, { error: error instanceof SyntaxError ? "Pedido JSON inválido." : error.message, }); return true; } } return { route, handleWebhook, receive, async close() { closed = true; clearInterval(interval); for (const c of running.values()) c.abort(); for (const { listener } of listeners.values()) listener.close(); await Promise.all([...finished.values()]); }, get running() { return running; }, }; } export function query(connection, command, params = {}, { signal } = {}) { const address = new URL(`/${connection.serverId}/${command}`, connection.url), form = new URLSearchParams(); for (const [key, value] of Object.entries(params)) form.set(key, String(value)); const readOnly = /^(?:.*list|.*info|.*find|.*get.*|servergroupsbyclientid|whoami|version|hostinfo)$/.test( command, ); if (readOnly) address.search = form.toString(); const payload = readOnly ? null : Buffer.from(JSON.stringify(params), "utf8"); return new Promise((resolve, reject) => { const req = (address.protocol === "https:" ? https : http).request( address, { method: readOnly ? "GET" : "POST", signal, headers: { "x-api-key": connection.apiKey, ...(!readOnly ? { "Content-Type": "application/json", "Content-Length": payload.length, } : {}), }, timeout: 10000, }, (res) => { const chunks = []; let size = 0; res.on("data", (chunk) => { size += chunk.length; if (size > 2000000) req.destroy(new Error("Resposta TeamSpeak demasiado grande.")); else chunks.push(chunk); }); res.on("error", reject); res.on("end", () => { let parsed; try { parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { reject( new Error(`TeamSpeak HTTP ${res.statusCode}: resposta inválida.`), ); return; } if (res.statusCode !== 200 || Number(parsed.status?.code) !== 0) { reject( new Error( `TeamSpeak ${parsed.status?.code ?? res.statusCode}: ${parsed.status?.message || "pedido recusado"}`, ), ); return; } resolve( Array.isArray(parsed.body) ? parsed.body : parsed.body ? [parsed.body] : [], ); }); }, ); req.on("timeout", () => req.destroy(new Error("A ligação TeamSpeak excedeu 10 segundos.")), ); req.on("error", (error) => reject( new Error( signal?.aborted ? "Execução cancelada." : `Ligação TeamSpeak: ${error.code || error.message}`, ), ), ); if (payload) req.write(payload); req.end(); }); }