All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
703 lines
28 KiB
JavaScript
703 lines
28 KiB
JavaScript
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) => `<option value="${esc(n.id)}">${esc(n.label)}</option>`)
|
||
.join("");
|
||
const tool = (name, title, content, write = false) =>
|
||
`<button class="icon-button" id="${name}" title="${title}" aria-label="${title}" ${write ? dis() : ""}>${content}</button>`;
|
||
q("#content").innerHTML =
|
||
heading(
|
||
"Editor de flows",
|
||
"Liga eventos, dados e ações para construir as tuas automações.",
|
||
`<select id="bot-select" aria-label="Bot">${data.bots.map((b) => `<option value="${b.id}" ${b.id === bot.id ? "selected" : ""}>${esc(b.name)}</option>`).join("")}</select><span class="badge ${bot.enabled ? "green" : "gray"}">${owner() ? (bot.enabled ? "Ativo" : "Rascunho") : "Só leitura"}</span>`,
|
||
) +
|
||
`
|
||
<div class="editor" id="editor">
|
||
<aside class="palette"><div class="panel-heading">Blocos <span class="badge gray">${Object.keys(types).length}</span></div><input id="search-block" placeholder="Pesquisar blocos…" aria-label="Pesquisar blocos">
|
||
${[...new Set(Object.values(types).map((t) => t.group))]
|
||
.map(
|
||
(group) =>
|
||
`<div class="palette-group"><span class="overline">${esc(group)}</span>${Object.entries(
|
||
types,
|
||
)
|
||
.filter(([, t]) => t.group === group)
|
||
.map(
|
||
([type, t]) =>
|
||
`<button class="palette-item ${t.color}" data-add="${type}" ${dis()}>${icon(t.trigger ? "clock" : t.command ? "channel" : t.group === "Dados" ? "data" : t.group === "Lógica" ? "flow" : "bot")}<span>${esc(t.title)}</span>${icon("plus")}</button>`,
|
||
)
|
||
.join("")}</div>`,
|
||
)
|
||
.join("")}</aside>
|
||
<div class="canvas-column">
|
||
<div class="canvas-toolbar"><div><strong>${esc(bot.name)}</strong><span id="save-state">Guardado</span></div><div class="editor-tools">
|
||
${tool("undo", "Desfazer (Ctrl+Z)", "↶", true)}${tool("redo", "Refazer (Ctrl+Y)", "↷", true)}${tool("duplicate-node", "Duplicar bloco (Ctrl+D)", icon("plus"), true)}${tool("arrange", "Organizar blocos", icon("flow"), true)}${tool("validate", "Validar flow", icon("check"), true)}
|
||
${tool("zoom-out", "Reduzir zoom", "−")}<span id="zoom-label">85%</span>${tool("zoom-in", "Aumentar zoom", "+")}${tool("fit", "Ajustar ao ecrã", icon("expand"))}${tool("fullscreen", "Ecrã inteiro", icon("expand"))}</div></div>
|
||
<div class="editor-context"><label>Servidor<select id="flow-connection" ${dis()}><option value="">Escolher ligação</option>${data.connections.map((c) => `<option value="${c.id}" ${draft.connectionId === c.id ? "selected" : ""}>${esc(c.name)}</option>`).join("")}</select></label><button class="text-button" id="load-resources" ${dis()}>Carregar listas do servidor</button></div>
|
||
<div class="canvas" id="canvas" tabindex="0" aria-label="Área do flow"><div id="world-space"><div class="world" id="world"><svg id="edges"></svg><div id="nodes"></div></div></div></div>
|
||
<div class="canvas-footer"><span id="graph-count"></span><span id="canvas-hint">Liga uma saída à entrada de outro bloco.</span></div>
|
||
</div>
|
||
<aside class="inspector"><div id="node-properties"></div><div class="inspector-actions"><button class="text-button" id="test-data" ${dis()}>Dados da simulação</button><label class="trigger-picker">Início do teste<select id="test-trigger" aria-label="Início do teste">${triggerOptions()}</select></label><button class="button secondary" id="simulate" ${dis()}>${icon("play")}Simular</button><button class="button primary" id="save-flow" ${dis()}>${icon("save")}Guardar</button><button class="text-button" id="run" ${dis()}>Executar no TeamSpeak →</button><button class="text-button danger" id="cancel-flow" hidden>Cancelar execução</button></div></aside>
|
||
</div>`;
|
||
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 =
|
||
`<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0 0L10 5L0 10" fill="#9290b8"/></marker></defs>` +
|
||
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 `<path class="edge ${esc(e.port)}" d="M${x} ${y} C${x + 80} ${y},${x2 - 80} ${y2},${x2} ${y2}" marker-end="url(#arrow)"/>`;
|
||
})
|
||
.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 `<div class="flow-node ${t.color} ${selected === n.id ? "selected" : ""}" data-node="${esc(n.id)}" style="left:${n.x}px;top:${n.y}px;height:${height(n)}px">
|
||
${!t.trigger ? `<button class="port input" style="top:${height(n) / 2 - 5}px" data-input="${n.id}" aria-label="Entrada de ${esc(n.label)}" ${dis()}></button>` : ""}
|
||
<div class="node-head">${icon(t.trigger ? "clock" : t.group === "Lógica" ? "flow" : "channel")}<strong>${esc(n.label)}</strong></div><div class="node-subtitle">${esc(n.type === "timer" ? `A cada ${n.config.seconds}s` : n.type === "cron" ? n.config.expression : t.title)}</div>
|
||
${ports.map((p) => `<button class="port output ${p}" data-output="${n.id}" data-port="${p}" style="top:${portY(n, p)}px" aria-label="Saída ${labels[p]} de ${esc(n.label)}" ${dis()}></button>${ports.length > 1 ? `<span class="port-label ${p}" style="top:${portY(n, p) - 6}px">${labels[p]}</span>` : ""}`).join("")}</div>`;
|
||
})
|
||
.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 `<label>${esc(label)}<select data-field="${key}" ${dis()}>${(values || data.queryCommands).map((v) => `<option value="${esc(v)}" ${String(value) === String(v) ? "selected" : ""}>${esc(labels[v] || v)}</option>`).join("")}</select></label>`;
|
||
if (input === "textarea" || input === "json")
|
||
return `<label>${esc(label)}<textarea ${attrs} ${input === "json" ? 'data-json spellcheck="false"' : ""}>${esc(input === "json" ? (typeof value === "string" ? value : JSON.stringify(value, null, 2)) : value)}</textarea></label>`;
|
||
const definition = resourceFor[input],
|
||
list = definition
|
||
? resources.get(draft.connectionId)?.[definition[0]] || []
|
||
: [];
|
||
const suggestions = definition
|
||
? `<datalist id="list-${key}">${list
|
||
.filter((item) => item[definition[1]] != null)
|
||
.map(
|
||
(item) =>
|
||
`<option value="${esc(item[definition[1]])}">${esc(item[definition[2]] || item[definition[1]])}</option>`,
|
||
)
|
||
.join("")}</datalist>`
|
||
: "";
|
||
return `<label>${esc(label)}<input ${attrs} type="${["number", "password"].includes(input) ? input : "text"}" ${input === "number" ? 'step="any"' : ""} ${definition ? `list="list-${key}"` : ""} value="${esc(value)}" ${input === "password" ? 'autocomplete="new-password"' : ""}>${suggestions}</label>`;
|
||
}
|
||
function inspect() {
|
||
const n = active(),
|
||
el = q("#node-properties");
|
||
if (!n) {
|
||
el.innerHTML =
|
||
'<div class="empty"><h3>Seleciona um bloco</h3><p>Adiciona um bloco ou seleciona um existente.</p></div>';
|
||
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 = `<div class="panel-heading"><span>${icon("flow")}${esc(t.title)}</span><button class="icon-button danger" id="delete-node" aria-label="Eliminar bloco" ${dis()}>${icon("trash")}</button></div><div class="inspector-body"><label>Nome do bloco<input id="node-label" value="${esc(n.label)}" ${owner() ? "" : "readonly"}></label><span class="badge ${t.color}">${esc(t.group)}</span><span class="overline">CONFIGURAÇÃO</span>${t.fields.map((f) => field(n, f)).join("") || '<p class="form-help">Sem parâmetros adicionais.</p>'}
|
||
${n.type === "webhook" ? `<button class="text-button" id="new-secret" ${dis()}>Gerar novo segredo</button><p class="form-help">POST <code>${esc(location.origin)}/api/hooks/${esc(bot.id)}</code><br>Cabeçalho: <code>X-Webhook-Secret</code></p>` : ""}
|
||
${!t.trigger && t.outputs.includes("error") ? `<label>Tentativas adicionais (0–3)<input data-field="retries" type="number" min="0" max="3" value="${Number(n.config.retries || 0)}" ${owner() ? "" : "readonly"}></label><p class="form-help">Liga a saída Erro para tratar a falha. As tentativas repetem a ação; usa-as quando a repetição for adequada.</p>` : ""}
|
||
<details class="variable-help"><summary>Variáveis e expressões</summary><p class="form-help"><code>{{server.virtualserver_name}}</code><br><code>{{server.virtualserver_clientsonline - server.virtualserver_queryclientsonline}}</code><br><code>{{event.clid}}</code> · <code>{{event.client_database_id}}</code><br><code>{{item.clid}}</code> no ramo Cada<br><code>{{temp.nome}}</code> · <code>{{variables.nome}}</code><br><code>{{time.time}}:{{time.seconds}}</code><br><code>{{text}}</code> · <code>{{top}}</code><br><code>{{temp.error.message}}</code><br>Funções: round, min, max, length, contains. Filtros: <code>{{text | upper}}</code> e <code>{{temp.result | json}}</code>.</p></details>
|
||
<span class="overline">LIGAÇÕES</span>${connections.map(({ e, i }) => `<div class="connection-row"><span>${esc(draft.graph.nodes.find((node) => node.id === (e.from === n.id ? e.to : e.from))?.label)}<small>${e.from === n.id ? "Saída" : "Entrada"} · ${labels[e.port] || e.port}</small></span><button class="icon-button danger" data-delete-edge="${i}" aria-label="Eliminar ligação" ${dis()}>${icon("trash")}</button></div>`).join("") || '<p class="form-help">Ainda sem ligações.</p>'}</div>`;
|
||
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 = `<form id="test-data-form"><div class="modal-heading"><h2>Dados da simulação</h2><button type="button" class="icon-button" id="close-test-data">${icon("close")}</button></div><p class="form-help">Estes dados substituem o evento de exemplo apenas na simulação. Usa {} para os valores iniciais.</p><label>Evento (JSON)<textarea id="test-event" spellcheck="false">${esc(JSON.stringify(eventFixture, null, 2))}</textarea></label><p class="form-help">Exemplo: <code>{"clid":"1","client_database_id":"10","client_nickname":"Bxio","webhook_body":{"name":"Teste"}}</code></p><button class="button primary">Guardar dados</button></form>`;
|
||
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;
|
||
},
|
||
};
|
||
}
|