ts6-botflow/studio.mjs
Bxio 52f44b368d
All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 32s
Send WebQuery action parameters as JSON instead of form data
2026-10-10 21:06:18 +01:00

1069 lines
35 KiB
JavaScript

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();
});
}