All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 32s
1069 lines
35 KiB
JavaScript
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();
|
|
});
|
|
}
|