const labels = {
out: "Seguinte",
error: "Erro",
true: "Sim",
false: "Não",
each: "Cada",
done: "Fim",
asc: "Crescente",
desc: "Decrescente",
equals: "Igual",
notEquals: "Diferente",
greater: "Maior",
less: "Menor",
atLeast: "Maior ou igual",
atMost: "Menor ou igual",
contains: "Contém",
startsWith: "Começa com",
endsWith: "Termina com",
exists: "Existe",
client: "Cliente",
channel: "Canal",
server: "Servidor",
temp: "Temporária",
persistent: "Persistente",
set: "Definir",
increment: "Somar",
append: "Acrescentar",
delete: "Apagar",
text: "Texto",
number: "Número",
boolean: "Booleano",
json: "JSON",
mixed: "Letras e números",
numbers: "Números",
letters: "Letras",
};
const id = () => `n${Date.now()}${Math.floor(Math.random() * 100000)}`;
const token = () =>
Array.from(crypto.getRandomValues(new Uint8Array(24)), (b) =>
b.toString(16).padStart(2, "0"),
).join("");
const clone = structuredClone;
export function mountEditor(bot, helpers) {
const {
api,
data,
owner,
dis,
icon,
esc,
heading,
toast,
refresh,
showRun,
onDirty,
onBot,
} = helpers;
let draft = clone(bot),
selected = draft.graph.nodes[0]?.id,
zoom = 0.85,
pending = null,
busy = false,
history = [clone(draft.graph)],
cursor = 0,
eventFixture = {};
const resources = new Map(),
types = data.nodeTypes,
q = (s) => document.querySelector(s);
const active = () => draft.graph.nodes.find((n) => n.id === selected),
outputs = (n) => types[n.type].outputs;
const height = (n) => Math.max(76, outputs(n).length * 25 + 23),
portY = (n, p) =>
outputs(n).length === 1 ? 38 : 28 + outputs(n).indexOf(p) * 25;
const triggerOptions = () =>
draft.graph.nodes
.filter((n) => types[n.type].trigger)
.map((n) => ``)
.join("");
const tool = (name, title, content, write = false) =>
``;
q("#content").innerHTML =
heading(
"Editor de flows",
"Liga eventos, dados e ações para construir as tuas automações.",
`${owner() ? (bot.enabled ? "Ativo" : "Rascunho") : "Só leitura"}`,
) +
`
`;
const dirty = (record = true) => {
onDirty(true);
q("#save-state").textContent = "Por guardar";
q("#save-state").className = "unsaved";
if (record) {
const snapshot = JSON.stringify(draft.graph);
if (snapshot !== JSON.stringify(history[cursor])) {
history = history.slice(0, cursor + 1);
history.push(clone(draft.graph));
if (history.length > 80) history.shift();
cursor = history.length - 1;
}
}
updateTools();
};
function updateTools() {
q("#undo").disabled = !owner() || cursor === 0;
q("#redo").disabled = !owner() || cursor === history.length - 1;
q("#duplicate-node").disabled = !owner() || !active();
}
function undo(direction) {
if (
!owner() ||
cursor + direction < 0 ||
cursor + direction >= history.length
)
return;
cursor += direction;
draft.graph = clone(history[cursor]);
if (!active()) selected = draft.graph.nodes[0]?.id;
dirty(false);
render();
}
function sizing() {
const maxX = Math.max(800, ...draft.graph.nodes.map((n) => n.x + 290)),
maxY = Math.max(
600,
...draft.graph.nodes.map((n) => n.y + height(n) + 80),
);
q("#world").style.width = `${maxX}px`;
q("#world").style.height = `${maxY}px`;
q("#world-space").style.width = `${maxX * zoom}px`;
q("#world-space").style.height = `${maxY * zoom}px`;
q("#world").style.transform = `scale(${zoom})`;
q("#zoom-label").textContent = `${Math.round(zoom * 100)}%`;
}
function fit() {
const nodes = draft.graph.nodes;
if (!nodes.length) return;
const minX = Math.min(...nodes.map((n) => n.x)),
minY = Math.min(...nodes.map((n) => n.y)),
width = Math.max(...nodes.map((n) => n.x + 230)) - minX + 80,
totalHeight = Math.max(...nodes.map((n) => n.y + height(n))) - minY + 80;
zoom = Math.min(
1.2,
Math.max(
0.25,
Math.min(
q("#canvas").clientWidth / width,
q("#canvas").clientHeight / totalHeight,
),
),
);
sizing();
q("#canvas").scrollLeft = Math.max(0, (minX - 40) * zoom);
q("#canvas").scrollTop = Math.max(0, (minY - 40) * zoom);
}
function edges() {
q("#edges").innerHTML =
`` +
draft.graph.edges
.map((e) => {
const a = draft.graph.nodes.find((n) => n.id === e.from),
b = draft.graph.nodes.find((n) => n.id === e.to);
if (!a || !b) return "";
const x = a.x + 230,
y = a.y + portY(a, e.port),
x2 = b.x,
y2 = b.y + height(b) / 2;
return ``;
})
.join("");
}
function add(type) {
const t = types[type],
config = clone(t.defaults);
if (type === "webhook") config.secret = token();
const canvas = q("#canvas"),
n = {
id: id(),
type,
label: t.title,
x: Math.round(canvas.scrollLeft / zoom + 80),
y: Math.round(
canvas.scrollTop / zoom + 80 + (draft.graph.nodes.length % 4) * 120,
),
config,
};
draft.graph.nodes.push(n);
selected = n.id;
dirty();
render();
}
function remove() {
if (!owner() || !active()) return;
draft.graph.nodes = draft.graph.nodes.filter((n) => n.id !== selected);
draft.graph.edges = draft.graph.edges.filter(
(e) => e.from !== selected && e.to !== selected,
);
selected = draft.graph.nodes[0]?.id;
dirty();
render();
}
function duplicate() {
if (!owner() || !active()) return;
const n = clone(active());
n.id = id();
n.x += 45;
n.y += 100;
n.label += " (cópia)";
if (n.type === "webhook") n.config.secret = token();
draft.graph.nodes.push(n);
selected = n.id;
dirty();
render();
}
function arrange() {
const rank = new Map(draft.graph.nodes.map((n) => [n.id, 0]));
for (let i = 0; i < draft.graph.nodes.length; i++) {
let changed = false;
for (const edge of draft.graph.edges) {
const next = rank.get(edge.from) + 1;
if (next > rank.get(edge.to)) {
rank.set(edge.to, next);
changed = true;
}
}
if (!changed) break;
if (i === draft.graph.nodes.length - 1) {
toast("Corrige o ciclo de ligações antes de organizar.", true);
return;
}
}
const rows = new Map();
for (const n of draft.graph.nodes) {
const level = rank.get(n.id),
row = rows.get(level) || 0;
n.x = 60 + level * 310;
n.y = 60 + row * 145;
rows.set(level, row + 1);
}
dirty();
render();
fit();
}
function render() {
q("#nodes").innerHTML = draft.graph.nodes
.map((n) => {
const t = types[n.type],
ports = outputs(n);
return `
${!t.trigger ? `
` : ""}
${icon(t.trigger ? "clock" : t.group === "Lógica" ? "flow" : "channel")}${esc(n.label)}
${esc(n.type === "timer" ? `A cada ${n.config.seconds}s` : n.type === "cron" ? n.config.expression : t.title)}
${ports.map((p) => `
${ports.length > 1 ? `
${labels[p]}` : ""}`).join("")}
`;
})
.join("");
sizing();
edges();
inspect();
q("#graph-count").textContent =
`${draft.graph.nodes.length} blocos · ${draft.graph.edges.length} ligações`;
const oldTrigger = q("#test-trigger").value;
q("#test-trigger").innerHTML = triggerOptions();
if (
draft.graph.nodes.some(
(n) => n.id === oldTrigger && types[n.type].trigger,
)
)
q("#test-trigger").value = oldTrigger;
q("#nodes")
.querySelectorAll("[data-node]")
.forEach(
(el) =>
(el.onpointerdown = (e) => {
if (e.target.closest("button")) return;
const n = draft.graph.nodes.find((n) => n.id === el.dataset.node);
selected = n.id;
q("#nodes")
.querySelectorAll(".flow-node")
.forEach((b) => b.classList.toggle("selected", b === el));
inspect();
updateTools();
if (!owner()) return;
const sx = e.clientX,
sy = e.clientY,
x = n.x,
y = n.y;
el.setPointerCapture(e.pointerId);
let moved = false;
el.onpointermove = (ev) => {
const dx = (ev.clientX - sx) / zoom,
dy = (ev.clientY - sy) / zoom;
if (Math.abs(dx) + Math.abs(dy) > 3) moved = true;
n.x = Math.max(20, Math.round(x + dx));
n.y = Math.max(20, Math.round(y + dy));
el.style.left = `${n.x}px`;
el.style.top = `${n.y}px`;
edges();
};
const finish = () => {
el.onpointermove = null;
el.onpointerup = null;
el.onpointercancel = null;
if (moved) {
dirty();
render();
}
};
el.onpointerup = finish;
el.onpointercancel = finish;
}),
);
q("#nodes")
.querySelectorAll("[data-output]")
.forEach(
(b) =>
(b.onclick = () => {
pending = { from: b.dataset.output, port: b.dataset.port };
q("#canvas-hint").textContent =
`Saída ${labels[pending.port]}: escolhe a entrada de destino. Esc cancela.`;
}),
);
q("#nodes")
.querySelectorAll("[data-input]")
.forEach(
(b) =>
(b.onclick = () => {
if (!pending || pending.from === b.dataset.input) return;
const edge = { ...pending, to: b.dataset.input };
const reaches = (node, target, seen = new Set()) => {
if (node === target) return true;
if (seen.has(node)) return false;
seen.add(node);
return draft.graph.edges
.filter((e) => e.from === node)
.some((e) => reaches(e.to, target, seen));
};
if (reaches(edge.to, edge.from)) {
toast(
"Esta ligação criaria um ciclo. Usa o bloco Para cada item.",
true,
);
return;
}
if (
!draft.graph.edges.some(
(e) =>
e.from === edge.from &&
e.to === edge.to &&
e.port === edge.port,
)
) {
draft.graph.edges.push(edge);
dirty();
}
pending = null;
render();
q("#canvas-hint").textContent = "Ligação criada.";
}),
);
updateTools();
}
const resourceFor = {
client: ["clients", "clid", "client_nickname"],
dbclient: ["clients", "client_database_id", "client_nickname"],
channel: ["channels", "cid", "channel_name"],
serverGroup: ["groups", "sgid", "name"],
channelGroup: ["channelGroups", "cgid", "name"],
permission: ["permissions", "permname", "permdesc"],
};
function field(n, [key, label, input, values]) {
const value = n.config[key] ?? types[n.type].defaults[key] ?? "",
attrs = `data-field="${key}" ${owner() ? "" : "readonly"}`;
if (n.type === "message" && key === "target")
input = n.config.mode === "channel" ? "channel" : "client";
if (input === "select" || input === "queryCommand")
return ``;
if (input === "textarea" || input === "json")
return ``;
const definition = resourceFor[input],
list = definition
? resources.get(draft.connectionId)?.[definition[0]] || []
: [];
const suggestions = definition
? ``
: "";
return ``;
}
function inspect() {
const n = active(),
el = q("#node-properties");
if (!n) {
el.innerHTML =
'Seleciona um bloco
Adiciona um bloco ou seleciona um existente.
';
return;
}
const t = types[n.type],
connections = draft.graph.edges
.map((e, i) => ({ e, i }))
.filter(({ e }) => e.from === n.id || e.to === n.id);
el.innerHTML = `${icon("flow")}${esc(t.title)}
${esc(t.group)}CONFIGURAÇÃO${t.fields.map((f) => field(n, f)).join("") || '
Sem parâmetros adicionais.
'}
${n.type === "webhook" ? `
POST ${esc(location.origin)}/api/hooks/${esc(bot.id)}
Cabeçalho: X-Webhook-Secret
` : ""}
${!t.trigger && t.outputs.includes("error") ? `
Liga a saída Erro para tratar a falha. As tentativas repetem a ação; usa-as quando a repetição for adequada.
` : ""}
Variáveis e expressões
{{server.virtualserver_name}}
{{server.virtualserver_clientsonline - server.virtualserver_queryclientsonline}}
{{event.clid}} · {{event.client_database_id}}
{{item.clid}} no ramo Cada
{{temp.nome}} · {{variables.nome}}
{{time.time}}:{{time.seconds}}
{{text}} · {{top}}
{{temp.error.message}}
Funções: round, min, max, length, contains. Filtros: {{text | upper}} e {{temp.result | json}}.
LIGAÇÕES${connections.map(({ e, i }) => `
${esc(draft.graph.nodes.find((node) => node.id === (e.from === n.id ? e.to : e.from))?.label)}${e.from === n.id ? "Saída" : "Entrada"} · ${labels[e.port] || e.port}
`).join("") || '
Ainda sem ligações.
'}
`;
q("#node-label").oninput = (e) => {
if (!owner()) return;
n.label = e.target.value;
dirty();
q(`[data-node="${n.id}"] strong`).textContent = n.label;
};
el.querySelectorAll("[data-field]").forEach(
(input) =>
(input.onchange = () => {
if (!owner()) return;
try {
n.config[input.dataset.field] = input.hasAttribute("data-json")
? JSON.parse(input.value || "{}")
: input.type === "number"
? Number(input.value)
: input.value;
input.setCustomValidity("");
dirty();
render();
} catch {
input.setCustomValidity("JSON inválido.");
input.reportValidity();
toast("Corrige o JSON do bloco antes de guardar.", true);
}
}),
);
q("#delete-node").onclick = remove;
el.querySelectorAll("[data-delete-edge]").forEach(
(b) =>
(b.onclick = () => {
draft.graph.edges.splice(Number(b.dataset.deleteEdge), 1);
dirty();
render();
}),
);
if (q("#new-secret"))
q("#new-secret").onclick = () => {
n.config.secret = token();
dirty();
render();
};
}
async function validate() {
const invalid = q("#editor input:invalid, #editor textarea:invalid");
if (invalid) {
invalid.reportValidity();
throw new Error("Corrige o campo inválido antes de guardar.");
}
return api("/api/validate", {
method: "POST",
body: JSON.stringify({ graph: draft.graph }),
});
}
async function save() {
try {
const result = await validate();
await api(`/api/bots/${bot.id}`, {
method: "PUT",
body: JSON.stringify({
graph: draft.graph,
connectionId: draft.connectionId,
}),
});
await refresh();
onDirty(false);
q("#save-state").textContent = "Guardado";
q("#save-state").className = "";
toast(
result.warnings.length
? `Guardado. ${result.warnings[0]}`
: "Flow guardado.",
);
return true;
} catch (error) {
toast(error.message, true);
return false;
}
}
async function execute(simulation) {
if (busy) return;
if (
!simulation &&
!confirm("Executar este flow no servidor TeamSpeak configurado?")
)
return;
if (!(await save())) return;
busy = true;
q("#simulate").disabled = q("#run").disabled = true;
q("#cancel-flow").hidden = false;
try {
const result = await api(
`/api/bots/${bot.id}/${simulation ? "simulate" : "run"}`,
{
method: "POST",
body: JSON.stringify({
triggerId: q("#test-trigger").value,
...(simulation ? { event: eventFixture } : {}),
}),
},
);
await refresh();
showRun(result);
} catch (error) {
toast(error.message, true);
} finally {
busy = false;
if (q("#simulate")) {
q("#simulate").disabled = q("#run").disabled = !owner();
q("#cancel-flow").hidden = true;
}
}
}
q("#bot-select").onchange = (e) => onBot(e.target.value);
q("#search-block").oninput = (e) =>
q("#editor")
.querySelectorAll(".palette-item")
.forEach(
(b) =>
(b.hidden = !b.textContent
.toLowerCase()
.includes(e.target.value.toLowerCase())),
);
q("#editor")
.querySelectorAll("[data-add]")
.forEach((b) => (b.onclick = () => add(b.dataset.add)));
q("#undo").onclick = () => undo(-1);
q("#redo").onclick = () => undo(1);
q("#duplicate-node").onclick = duplicate;
q("#arrange").onclick = arrange;
q("#validate").onclick = async () => {
try {
const result = await validate();
toast(
result.warnings.join(" ") ||
"Flow válido: ligações e parâmetros verificados.",
);
} catch (error) {
toast(error.message, true);
}
};
q("#zoom-in").onclick = () => {
zoom = Math.min(1.5, zoom + 0.1);
sizing();
};
q("#zoom-out").onclick = () => {
zoom = Math.max(0.25, zoom - 0.1);
sizing();
};
q("#fit").onclick = fit;
q("#fullscreen").onclick = () =>
document.fullscreenElement
? document.exitFullscreen()
: q("#editor")
.requestFullscreen()
.catch(() => toast("Ecrã inteiro indisponível.", true));
q("#flow-connection").onchange = (e) => {
draft.connectionId = e.target.value;
dirty(false);
inspect();
};
q("#load-resources").onclick = async () => {
if (!draft.connectionId) {
toast("Escolhe uma ligação primeiro.", true);
return;
}
const button = q("#load-resources");
button.disabled = true;
try {
const result = await api(
`/api/connections/${draft.connectionId}/resources`,
{ method: "POST" },
);
resources.set(draft.connectionId, result);
inspect();
toast(
Object.keys(result.errors).length
? `Listas carregadas parcialmente: ${Object.values(result.errors).join("; ")}`
: "Clientes, canais, grupos e permissões carregados.",
Boolean(Object.keys(result.errors).length),
);
} catch (error) {
toast(error.message, true);
} finally {
button.disabled = !owner();
}
};
q("#test-data").onclick = () => {
const dialog = q("#dialog");
dialog.innerHTML = ``;
dialog.showModal();
q("#close-test-data").onclick = () => dialog.close();
q("#test-data-form").onsubmit = (e) => {
e.preventDefault();
try {
const value = JSON.parse(q("#test-event").value);
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Usa um objeto JSON.");
eventFixture = value;
dialog.close();
toast("Dados da simulação guardados.");
} catch (error) {
toast(error.message, true);
}
};
};
q("#save-flow").onclick = save;
q("#simulate").onclick = () => execute(true);
q("#run").onclick = () => execute(false);
q("#cancel-flow").onclick = async () => {
try {
await api(`/api/bots/${bot.id}/cancel`, { method: "POST" });
toast("Cancelamento solicitado.");
} catch (error) {
toast(error.message, true);
}
};
q("#canvas").onpointerdown = (e) => {
if (e.target.closest(".flow-node") || !(e.button === 1 || e.shiftKey))
return;
e.preventDefault();
const canvas = q("#canvas"),
x = e.clientX,
y = e.clientY,
sx = canvas.scrollLeft,
sy = canvas.scrollTop;
canvas.setPointerCapture(e.pointerId);
canvas.onpointermove = (ev) => {
canvas.scrollLeft = sx - (ev.clientX - x);
canvas.scrollTop = sy - (ev.clientY - y);
};
canvas.onpointerup = () => (canvas.onpointermove = null);
};
render();
return {
keyboard(e) {
const editing = e.target.closest("input,textarea,select");
if (e.key === "Escape") {
pending = null;
q("#canvas-hint").textContent = "Ligação cancelada.";
return;
}
if (editing) return;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (owner()) save();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
undo(e.shiftKey ? 1 : -1);
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
undo(1);
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "d") {
e.preventDefault();
duplicate();
}
if (e.key === "Delete") {
e.preventDefault();
remove();
}
},
get draft() {
return draft;
},
};
}