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"}`, ) + `
${esc(bot.name)}Guardado
${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", "−")}85%${tool("zoom-in", "Aumentar zoom", "+")}${tool("fit", "Ajustar ao ecrã", icon("expand"))}${tool("fullscreen", "Ecrã inteiro", icon("expand"))}
`; 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 ? `${list .filter((item) => item[definition[1]] != null) .map( (item) => ``, ) .join("")}` : ""; 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 = `

Estes dados substituem o evento de exemplo apenas na simulação. Usa {} para os valores iniciais.

Exemplo: {"clid":"1","client_database_id":"10","client_nickname":"Bxio","webhook_body":{"name":"Teste"}}

`; 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; }, }; }