import { mountEditor } from "./flow-editor.js"; const app = document.querySelector("#app"); const esc = (v) => String(v ?? "").replace( /[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ c ], ); const paths = { dashboard: "M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z", bot: "M9 3h6 M12 3v3 M5 8h14v12H5z M8 12v3 M16 12v3 M2 11v6 M22 11v6", flow: "M4 3h6v6H4z M14 15h6v6h-6z M7 9v9h7 M10 6h7v9", play: "M8 5l12 7-12 7z", clock: "M12 8v5l3 2 M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18", users: "M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3 M9 3a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M18 4a4 4 0 0 1 0 7 M22 21v-3a4 4 0 0 0-3-4", link: "M10 14l4-4 M8 16l-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0 M16 8l1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0", settings: "M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2", variable: "M8 3H5v18h3 M16 3h3v18h-3 M10 9l4 6 M14 9l-4 6", plus: "M12 5v14 M5 12h14", save: "M4 3h14l3 3v15H3V3z M7 3v6h10V3 M7 21v-8h10v8", refresh: "M20 7a9 9 0 1 0 1 6 M20 2v5h-5", check: "M5 12l4 4L19 6", close: "M6 6l12 12 M18 6L6 18", shield: "M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6", music: "M9 18V5l11-2v13 M9 18a3 3 0 1 1-6 0 3 3 0 1 1 6 0 M20 16a3 3 0 1 1-6 0 3 3 0 1 1 6 0", trash: "M3 6h18 M8 6V3h8v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7", data: "M3 5c0-4 18-4 18 0s-18 4-18 0z M3 5v14c0 4 18 4 18 0V5 M3 12c0 4 18 4 18 0", condition: "M12 2l9 10-9 10-9-10z", text: "M4 5h16 M12 5v15 M8 20h8", sort: "M4 5h16 M4 12h12 M4 19h8", channel: "M8 3l-2 18 M18 3l-2 18 M3 8h18 M2 16h18", expand: "M8 3H3v5 M16 3h5v5 M3 16v5h5 M21 16v5h-5", }; const icon = (n) => ``; const nodeIcon = (t) => ({ timer: "clock", manual: "play", serverinfo: "data", clients: "users", condition: "condition", sort: "sort", limit: "dashboard", template: "text", channelEdit: "channel", message: "text", delay: "clock", log: "text", })[t]; let user, data, page = "dashboard", botId, pending, dirty = false, editorView, executionFilter = { bot: "", status: "", mode: "" }, executionId, polling = false; const owner = () => user?.role === "Dono", dis = () => owner() ? "" : 'disabled title="O perfil Leitor permite consultar"'; const selectedBot = () => data.bots.find((b) => b.id === botId); async function api(path, options = {}) { const r = await fetch(path, { ...options, headers: { "Content-Type": "application/json" }, }); const body = await r.json(); if (!r.ok) throw new Error(body.error || "O pedido falhou."); return body; } function toast(text, error = false) { document.querySelector("#toast")?.remove(); const el = document.createElement("div"); el.id = "toast"; el.className = `toast ${error ? "error" : ""}`; el.setAttribute("role", "status"); el.textContent = text; document.body.append(el); setTimeout(() => el.remove(), 6000); } async function refresh() { data = await api("/api/dashboard"); if (!data.bots.some((b) => b.id === botId)) botId = data.bots[0]?.id; } const nav = [ ["dashboard", "dashboard", "Dashboard"], ["bots", "bot", "Bots"], ["editor", "flow", "Editor de flows"], ["executions", "clock", "Execuções"], ["variables", "variable", "Variáveis"], ["connections", "link", "Ligações"], ["settings", "settings", "Definições"], ]; const empty = (i, title, text) => `
${icon(i)}

${title}

${text}

`; const button = (id, text, i = "plus", style = "primary") => ``; const heading = (title, subtitle, actions = "") => `

${title}

${subtitle}

${actions}
`; const ago = (d) => !d ? "Sem execuções" : Math.floor((Date.now() - Date.parse(d)) / 60000) < 1 ? "Agora" : `Há ${Math.floor((Date.now() - Date.parse(d)) / 60000)} min`; async function boot() { const me = await api("/api/me"); user = me.user; if (!user) { app.innerHTML = `
TS6 BOTFLOW

O teu centro de bots.

Cria flows, gere bots e acompanha as tuas automações num só lugar.

Entrar com Discord ${icon("play")}

Acesso reservado às contas autorizadas.

`; const messages = { denied: "Esta conta não está autorizada.", authentication: "O login expirou ou foi cancelado.", configuration: "Configura a aplicação Discord no Docker.", }; const msg = messages[new URLSearchParams(location.search).get("error")] || (!me.configured && messages.configuration); if (msg) { const el = document.querySelector("#notice"); el.className = "notice"; el.textContent = msg; } return; } await refresh(); app.innerHTML = `
${esc(user.name.slice(0, 1).toUpperCase())}
`; document .querySelectorAll("[data-page]") .forEach((b) => (b.onclick = () => navigate(b.dataset.page))); document.querySelector(".brand").onclick = (e) => { e.preventDefault(); navigate("dashboard"); }; document.querySelector("#logout").onclick = async () => { await api("/auth/logout", { method: "POST" }); location.assign("/"); }; document.querySelector("#server-select").onchange = () => navigate("connections"); navigate(location.hash.slice(1)); } function navigate(name) { if (dirty && !confirm("Sair sem guardar as alterações do flow?")) return; dirty = false; pending = null; page = nav.some(([id]) => id === name) ? name : "dashboard"; location.hash = page; document .querySelectorAll(".nav") .forEach((b) => b.classList.toggle("active", b.dataset.page === page)); document.querySelector("#page-label").textContent = nav.find( ([id]) => id === page, )[2]; document .querySelector(".workspace") .classList.toggle("editor-workspace", page === "editor"); ({ dashboard: dashboardPage, bots: botsPage, editor: editorPage, executions: executionsPage, variables: variablesPage, connections: connectionsPage, settings: settingsPage, })[page](); wireExecutions(); updateServerLabel(); } function botCard(bot) { const runs = data.executions.filter((e) => e.botId === bot.id); return `
${icon(bot.color === "red" ? "shield" : bot.color === "orange" ? "music" : bot.color === "cyan" ? "users" : "bot")}

${esc(bot.name)}

${bot.enabled ? "Ativo" : "Rascunho"}

${esc(bot.description || "Personaliza o flow deste bot.")}

${icon("flow")}1 flow${icon("play")}${runs.length} execuções${icon("clock")}${ago(runs[0]?.startedAt)}
`; } function wireBots() { document.querySelectorAll("[data-open]").forEach( (b) => (b.onclick = () => { botId = b.dataset.open; navigate("editor"); }), ); document .querySelectorAll("[data-config]") .forEach((b) => (b.onclick = () => configureBot(b.dataset.config))); const create = document.querySelector("#create-bot"); if (create) create.onclick = () => newBot(); document.querySelectorAll("[data-toggle]").forEach( (b) => (b.onclick = async () => { try { const bot = data.bots.find((bot) => bot.id === b.dataset.toggle); await api(`/api/bots/${bot.id}`, { method: "PUT", body: JSON.stringify({ enabled: !bot.enabled }), }); await refresh(); navigate(page); toast("Estado atualizado."); } catch (e) { toast(e.message, true); } }), ); } function dashboardPage() { const today = new Date().toLocaleDateString("pt-PT", { timeZone: "Europe/Lisbon", }); document.querySelector("#content").innerHTML = heading( "Dashboard", "Uma visão geral dos teus bots e automações.", button("create-bot", "Criar bot"), ) + `
${[ [ "Bots ativos", data.bots.filter((b) => b.enabled).length, "bot", "green", ], [ "Execuções hoje", data.executions.filter( (e) => new Date(e.startedAt).toLocaleDateString("pt-PT", { timeZone: "Europe/Lisbon", }) === today, ).length, "play", "cyan", ], ["Flows guardados", data.bots.length, "flow", "purple"], ["Servidores configurados", data.connections.length, "link", "orange"], ] .map( ([label, n, i, c]) => `
${icon(i)}
${label}${n}
`, ) .join( "", )}

Os teus bots

${data.bots.map(botCard).join("")}

Atividade recente

${data.executions.slice(0, 4).map(runRow).join("") || empty("clock", "Ainda sem execuções", "Simula um flow para acompanhar aqui cada passo.")}
${icon("flow")}

O teu primeiro flow

O rascunho Estatísticas prepara a descrição do canal 21. Escolhe a ligação e simula antes de ativar.

`; wireBots(); document.querySelector("#all-bots").onclick = () => navigate("bots"); document.querySelector("#all-runs").onclick = () => navigate("executions"); document.querySelector("#first-flow").onclick = () => navigate("editor"); } function botsPage() { document.querySelector("#content").innerHTML = heading( "Bots", "Gere os teus bots do TeamSpeak num só lugar.", button("import-bot", "Importar", "flow", "secondary") + button("create-bot", "Criar bot"), ) + `
${data.bots.length} bots · ${data.bots.filter((b) => b.enabled).length} ativos
${data.bots.map(botCard).join("")}

Templates

Flows prontos para configurar
${data.templates.map((t) => ``).join("")}
`; wireBots(); document.querySelector("#search-bots").oninput = (e) => { document.querySelector("#bot-list").innerHTML = data.bots .filter((b) => b.name.toLowerCase().includes(e.target.value.toLowerCase()), ) .map(botCard) .join(""); wireBots(); }; document .querySelectorAll("[data-template]") .forEach((b) => (b.onclick = () => newBot(b.dataset.template))); document.querySelector("#import-bot").onclick = () => document.querySelector("#bot-file").click(); document.querySelector("#bot-file").onchange = async (e) => { const file = e.target.files[0]; if (!file) return; try { if (file.size > 500000) throw new Error("O ficheiro deve ter menos de 500 KB."); const exported = JSON.parse(await file.text()); if (exported.format !== "ts6-botflow/v1" || !exported.bot) throw new Error("Usa um ficheiro exportado pelo Botflow."); const bot = await api("/api/bots", { method: "POST", body: JSON.stringify({ import: exported.bot }), }); botId = bot.id; await refresh(); navigate("editor"); toast( "Bot importado como rascunho. Configura a ligação e os campos secretos.", ); } catch (error) { toast(error.message, true); } finally { e.target.value = ""; } }; } function modal(title, body, submit) { const dialog = document.querySelector("#dialog"); dialog.innerHTML = ``; dialog.showModal(); document.querySelector("#close-dialog").onclick = document.querySelector( "#cancel", ).onclick = () => dialog.close(); document.querySelector("#modal-form").onsubmit = async (e) => { e.preventDefault(); try { await submit(new FormData(e.target)); dialog.close(); await refresh(); dirty = false; navigate(page); toast("Guardado."); } catch (error) { toast(error.message, true); } }; } function newBot(templateId = "custom") { const template = data.templates.find((t) => t.id === templateId) || data.templates.find((t) => t.id === "custom"); modal( "Criar bot", `

O bot começa desativado. Configura os blocos e escolhe o servidor no editor.

`, async (form) => { const bot = await api("/api/bots", { method: "POST", body: JSON.stringify(Object.fromEntries(form)), }); botId = bot.id; page = "editor"; }, ); } function configureBot(id) { botId = id; const bot = selectedBot(); modal( "Configuração do bot", `

A exportação omite a ligação e os campos secretos dos blocos.

`, (f) => api(`/api/bots/${bot.id}`, { method: "PUT", body: JSON.stringify(Object.fromEntries(f)), }), ); document.querySelector("#duplicate-bot").onclick = async () => { try { const copy = await api(`/api/bots/${bot.id}/duplicate`, { method: "POST", }); botId = copy.id; document.querySelector("#dialog").close(); await refresh(); navigate("bots"); toast("Cópia criada, desativada."); } catch (error) { toast(error.message, true); } }; document.querySelector("#export-bot").onclick = async () => { try { const body = await api(`/api/bots/${bot.id}/export`, { method: "POST" }), url = URL.createObjectURL( new Blob([JSON.stringify(body, null, 2)], { type: "application/json", }), ), a = document.createElement("a"); a.href = url; a.download = `${bot.name.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); toast("Bot exportado."); } catch (error) { toast(error.message, true); } }; document.querySelector("#delete-bot").onclick = async () => { if (!confirm(`Apagar o bot «${bot.name}»?`)) return; try { await api(`/api/bots/${bot.id}`, { method: "DELETE" }); document.querySelector("#dialog").close(); await refresh(); navigate("bots"); toast("Bot apagado."); } catch (error) { toast(error.message, true); } }; } function editorPage() { const bot = selectedBot(); if (!bot) { document.querySelector("#content").innerHTML = heading( "Editor de flows", "Cria um bot para começar.", button("create-bot", "Criar bot"), ) + empty("flow", "Ainda sem flows", "Cria o teu primeiro bot."); wireBots(); return; } editorView = mountEditor(bot, { api, data, owner, dis, icon, esc, heading, toast, refresh, showRun, onDirty: (value) => { dirty = value; updateServerLabel(); }, onBot: (id) => { if (dirty && !confirm("Sair sem guardar o flow?")) { document.querySelector("#bot-select").value = botId; return; } botId = id; dirty = false; editorPage(); updateServerLabel(); }, }); } const statuses = { success: ["green", "check", "Concluída"], error: ["red", "close", "Falhou"], warning: ["orange", "shield", "Erro tratado"], running: ["cyan", "clock", "Em execução"], cancelled: ["gray", "close", "Cancelada"], }; function runRow(e) { const [color, i, label] = statuses[e.status] || statuses.error; return ``; } function runDetail(e) { const [color, , label] = statuses[e.status] || statuses.error; return `

${esc(e.botName)}

${label}
${new Date(e.startedAt).toLocaleString("pt-PT")} · ${e.simulation ? "Simulação com dados de exemplo" : "Servidor TeamSpeak"}
Duração${((e.durationMs || 0) / 1000).toFixed(2)}s
Iniciado por${esc(e.actor)}
Passos${e.steps.length}
${e.status === "running" ? `` : ""}

Eventos e etapas

${e.steps .map((s) => { const [c, i] = statuses[s.status] || statuses.error; return `
${icon(i)}
${esc(s.label)}${esc(s.type)}${s.attempts > 1 ? ` · ${s.attempts} tentativas` : ""}

${esc(s.message || "A executar…")}

`; }) .join( "", )}
${e.error ? `
${esc(e.error)}
` : ""}${e.output ? `

Texto produzido

${esc(e.output)}
` : ""}`; } function showRun(e) { const dialog = document.querySelector("#dialog"); dialog.innerHTML = `${runDetail(e)}`; dialog.showModal(); wireCancel(); document.querySelector("#close-run").onclick = () => dialog.close(); } function wireExecutions() { document.querySelectorAll("[data-execution]").forEach( (b) => (b.onclick = () => { executionId = b.dataset.execution; const e = data.executions.find((e) => e.id === executionId), el = document.querySelector("#execution-detail"); if (el) { el.innerHTML = runDetail(e); document .querySelectorAll(".execution-row") .forEach((row) => row.classList.toggle("selected", row === b)); wireCancel(); } else showRun(e); }), ); wireCancel(); } function wireCancel() { document.querySelectorAll("[data-cancel-execution]").forEach( (b) => (b.onclick = async () => { try { await api(`/api/bots/${b.dataset.cancelExecution}/cancel`, { method: "POST", }); toast("Cancelamento solicitado."); } catch (error) { toast(error.message, true); } }), ); } function executionsPage() { const filtered = data.executions.filter( (e) => (!executionFilter.bot || e.botId === executionFilter.bot) && (!executionFilter.status || e.status === executionFilter.status) && (!executionFilter.mode || String(e.simulation) === executionFilter.mode), ); if (!filtered.some((e) => e.id === executionId)) executionId = filtered[0]?.id; document.querySelector("#content").innerHTML = heading( "Execuções", "Histórico por passo, com atualização automática a cada 3 segundos.", ``, ) + `

Execuções recentes

${filtered.length}
${filtered.map(runRow).join("") || empty("clock", "Sem resultados", "Escolhe outro filtro ou simula um flow.")}
${filtered.length ? runDetail(filtered.find((e) => e.id === executionId)) : empty("flow", "Cada passo, num só lugar", "Os resultados ficam guardados aqui.")}
`; for (const [name, key] of [ ["bot", "bot"], ["status", "status"], ["mode", "mode"], ]) document.querySelector(`#execution-${name}`).onchange = (e) => { executionFilter[key] = e.target.value; executionsPage(); wireExecutions(); }; document.querySelector("#refresh-runs").onclick = async () => { try { await refresh(); executionsPage(); wireExecutions(); } catch (error) { toast(error.message, true); } }; } const variableRow = (k, v) => { const kind = (v !== null && typeof v === "object") || v === null ? "json" : typeof v === "number" ? "number" : typeof v === "boolean" ? "boolean" : "text"; return `
`; }; function variablesPage() { const bot = selectedBot(); document.querySelector("#content").innerHTML = heading( "Variáveis", "Valores persistentes para reutilizar nos teus flows.", ``, ) + `

Variáveis do bot

${button("add-variable", "Adicionar variável", "plus", "secondary")}

Usa {{variables.nome}} no texto de um bloco.

NomeTipoValor
${Object.entries( bot?.variables || {}, ) .map(([k, v]) => variableRow(k, v)) .join( "", )}
`; const wire = () => document .querySelectorAll("[data-remove-var]") .forEach((b) => (b.onclick = () => b.closest(".variable-row").remove())); wire(); document.querySelector("#variables-bot").onchange = (e) => { botId = e.target.value; variablesPage(); }; document.querySelector("#add-variable").onclick = () => { document .querySelector("#variable-rows") .insertAdjacentHTML("beforeend", variableRow("", "")); wire(); }; document.querySelector("#variables-form").onsubmit = async (e) => { e.preventDefault(); if (!bot) return; try { const variables = Object.create(null); document.querySelectorAll(".variable-row").forEach((row) => { const key = row.querySelector("[data-key]").value.trim(); if ( !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key) || Object.hasOwn(variables, key) ) throw new Error("Usa nomes únicos com letras, números e underscore."); const value = row.querySelector("[data-value]").value, type = row.querySelector("[data-value-type]").value; if (type === "number") { if (value.trim() === "" || !Number.isFinite(Number(value))) throw new Error("Indica um número válido."); variables[key] = Number(value); } else if (type === "json") variables[key] = JSON.parse(value); else if (type === "boolean") { if (!["true", "false"].includes(value)) throw new Error("Usa true ou false para booleanos."); variables[key] = value === "true"; } else variables[key] = value; }); await api(`/api/bots/${bot.id}`, { method: "PUT", body: JSON.stringify({ variables }), }); await refresh(); toast("Variáveis guardadas."); } catch (error) { toast(error.message, true); } }; } function connectionsPage() { document.querySelector("#content").innerHTML = heading( "Ligações", "WebQuery para ações e SSH Query para eventos e comandos.", button("new-connection", "Adicionar servidor", "link"), ) + `
${ data.connections .map((c) => { const states = c.eventState || [], connected = states.filter((s) => s.status === "connected").length; return `
${icon("link")}

${esc(c.name)}

WebQuery · Servidor #${c.serverId}

${esc(c.url)}

Eventos SSH${c.eventsEnabled ? (states.length ? `${connected} / ${states.length} escutas ligadas` : "À espera de um bot ativo") : "Desativados"}
${c.eventsEnabled ? `
Destino SSH${esc(c.sshHost)}:${c.sshPort}
` : ""}
${states .filter((s) => s.error) .map((s) => `

${esc(s.error)}

`) .join( "", )}
`; }) .join("") || `
${empty("link", "Liga o teu TeamSpeak", "Adiciona o endereço WebQuery e a API key.")}
` }
Os eventos usam a porta SSH Query do TeamSpeak (habitualmente 10022). O nome teamspeak6 funciona quando os containers partilham a rede Docker.
`; document.querySelector("#new-connection").onclick = () => connectionModal(); document .querySelectorAll("[data-edit-connection]") .forEach( (b) => (b.onclick = () => connectionModal( data.connections.find((c) => c.id === b.dataset.editConnection), )), ); document.querySelectorAll("[data-test-connection]").forEach( (b) => (b.onclick = async () => { b.disabled = true; try { const result = await api( `/api/connections/${b.dataset.testConnection}/test`, { method: "POST" }, ); toast( `Ligação confirmada: ${result.server.virtualserver_name || "TeamSpeak"}`, ); } catch (e) { toast(e.message, true); } finally { b.disabled = !owner(); } }), ); } function connectionModal(c = {}) { modal( c.id ? "Configurar servidor" : "Adicionar servidor", `EVENTOS E COMANDOS
Identidade do servidor SSH

A primeira ligação guarda a impressão digital. Se o servidor mudar de chave, confirma a nova chave e atualiza este campo.

As credenciais são guardadas cifradas. A escuta inicia quando ativares um bot com eventos ou comandos.

${c.id ? `` : ""}`, (f) => api("/api/connections", { method: "POST", body: JSON.stringify({ ...Object.fromEntries(f), id: c.id }), }), ); if (c.id) document.querySelector("#delete-connection").onclick = async () => { if (!confirm("Apagar esta ligação?")) return; try { await api(`/api/connections/${c.id}`, { method: "DELETE" }); document.querySelector("#dialog").close(); await refresh(); navigate("connections"); toast("Ligação apagada."); } catch (error) { toast(error.message, true); } }; } function settingsPage() { document.querySelector("#content").innerHTML = heading("Definições", "A tua conta e o ambiente do painel.") + `

O teu acesso

${esc(user.name.slice(0, 1).toUpperCase())}

${esc(user.name)}

${esc(user.role)}
ID Discord${esc(user.id)}
AutenticaçãoDiscord
Permissões${owner() ? "Configurar, guardar e executar" : "Consultar bots, flows e execuções"}

Ambiente

Blocos disponíveis${Object.keys(data.nodeTypes).length}
MotorWebQuery + eventos SSH
PainelTS6 Botflow 0.3
DadosVolume Docker persistente
Novas contasApenas IDs autorizados

A tua conta Discord e as ligações TeamSpeak têm permissões separadas.

`; } boot().catch((e) => { app.innerHTML = `

Não foi possível carregar o painel.

${esc(e.message)}

Tentar novamente
`; }); window.addEventListener("beforeunload", (e) => { if (dirty) { e.preventDefault(); e.returnValue = ""; } }); function updateServerLabel() { const select = document.querySelector("#server-select"); if (!select) return; const connectionId = page === "editor" ? editorView?.draft.connectionId : selectedBot()?.connectionId; select.innerHTML = '' + data.connections .map( (c) => ``, ) .join(""); } window.addEventListener("keydown", (e) => { if (page === "editor" && !document.querySelector("dialog[open]")) editorView?.keyboard(e); }); setInterval(async () => { if ( !user || polling || document.hidden || document.querySelector("dialog[open]") || !["executions", "connections"].includes(page) ) return; polling = true; try { await refresh(); if (page === "executions") { executionsPage(); wireExecutions(); } if (page === "connections") connectionsPage(); updateServerLabel(); } catch { } finally { polling = false; } }, 3000);