Build XanaX flow studio with visual editor and persistent bot execution

This commit is contained in:
Bxio 2026-10-10 19:26:42 +01:00
parent e9f1a90592
commit 1a8334f705
10 changed files with 410 additions and 55 deletions

View File

@ -1,11 +1,11 @@
FROM node:22-bookworm-slim AS base FROM node:22-bookworm-slim AS base
WORKDIR /app WORKDIR /app
COPY --chown=node:node package.json server.mjs ./ COPY --chown=node:node package.json server.mjs studio.mjs ./
COPY --chown=node:node public ./public COPY --chown=node:node public ./public
RUN mkdir /app/data && chown node:node /app/data RUN mkdir /app/data && chown node:node /app/data
FROM base AS test FROM base AS test
COPY --chown=node:node auth.test.mjs ./ COPY --chown=node:node auth.test.mjs studio.test.mjs ./
USER node USER node
RUN npm test RUN npm test

View File

@ -1,28 +1,51 @@
# TS6 Botflow # TS6 Botflow
Primeira experiência local: login Discord, conta do dono, sessão persistente e painel XanaX. Logótipos copiados sem alterações do projeto existente. As pastas de origem permanecem intactas. Painel local XanaX para gerir bots TeamSpeak, com login Discord e editor visual de flows. Mant?m os log?tipos originais e o tema flat roxo. As pastas dos projetos de origem permanecem intactas.
O editor de flows e o motor de bots ainda não estão implementados nesta versão. Nenhuma ligação ao TeamSpeak é feita. ## Funcionalidades
- Dashboard com bots, flows e execu??es guardados na base de dados.
- Editor com blocos arrast?veis, liga??es, condi??es, propriedades, zoom e grava??o.
- Blocos: temporizador, in?cio manual, informa??es do servidor, clientes, ordena??o, limite, prepara??o de texto, edi??o da descri??o de canal, mensagem, espera e registo.
- Simula??o identificada com dados de exemplo, sem enviar pedidos ao TeamSpeak.
- Execu??o manual ou por temporizador, com hist?rico e resultado de cada passo. Um bot n?o executa duas vezes em simult?neo.
- Liga??es WebQuery, teste de liga??o e API keys cifradas em repouso.
- Vari?veis por bot, acess?veis como `{{variables.nome}}`. Outros valores: `{{server.virtualserver_clientsonline}}`, `{{count}}`, `{{top}}`, `{{text}}` e `{{time}}`.
O primeiro arranque cria um rascunho **Estat?sticas**, desativado, para atualizar a descri??o do canal **21** a cada 30 segundos. O top inicial ordena as pessoas atualmente online pela data de entrada; n?o ? um ranking hist?rico de tempo acumulado.
Esta vers?o n?o inclui reprodu??o de m?sica, eventos SSH, webhooks, edi??o de permiss?es nem gest?o de perfis pelo painel. Cada bot tem um flow. O editor disponibiliza os blocos implementados acima.
## Docker / Portainer ## Docker / Portainer
Para **Web editor com imagem construida no Gitea**, seguir [deploy/README.md](deploy/README.md). O workflow `.gitea/workflows/container.yaml` testa e publica a imagem a cada push em main. As stacks prontas estao em `deploy/runner-stack.yaml` e `deploy/bot-stack.yaml`. Para Web editor com imagem constru?da no Gitea, consultar [deploy/README.md](deploy/README.md) e usar [deploy/bot-stack.yaml](deploy/bot-stack.yaml). Cada push em `main` executa os testes e publica a imagem. Depois do workflow verde, atualizar a stack no Portainer com download da imagem e recria??o do container. Restart sozinho n?o descarrega a nova imagem.
Para construcao local por Docker Compose, usar `compose.yaml` a partir deste repositório. O serviço é construído pelo Dockerfile, porta 3080, volume persistente `botflow-data`. Para construir localmente com Docker Compose, usar `compose.yaml`. A aplica??o escuta na porta 3080 e preserva os dados num volume montado em `/app/data`.
Configurar as quatro variáveis nas Environment variables da stack (ou copiar `.env.example` para `.env` quando executado por Docker Compose): Vari?veis de ambiente:
- `APP_URL`: endereço usado no navegador, por exemplo `http://192.168.88.99:3080`. - `APP_URL`: endere?o do navegador, por exemplo `http://192.168.88.99:3080`.
- `DISCORD_CLIENT_ID`: ID da aplicação Discord. - `DISCORD_CLIENT_ID`: ID da aplica??o Discord.
- `DISCORD_CLIENT_SECRET`: segredo OAuth da aplicação, nunca o token de um bot. - `DISCORD_CLIENT_SECRET`: segredo OAuth da aplica??o.
- `OWNER_DISCORD_ID`: ID **numérico** da tua conta Discord, não o nome `bxio`. - `OWNER_DISCORD_ID`: ID num?rico da conta do dono.
- `ALLOWED_DISCORD_IDS`: IDs de leitores separados por v?rgulas. Por defeito, `723613831196311603`; valor vazio remove todos os leitores.
No Discord Developer Portal, OAuth2 → Redirects, adicionar exatamente `http://192.168.88.99:3080/auth/discord/callback` (ajustar se APP_URL mudar). É possível aproveitar a aplicação Discord existente adicionando esse redirect; manter os redirects antigos. No Discord Developer Portal, adicionar o redirect exato `http://192.168.88.99:3080/auth/discord/callback`, ajustando ao `APP_URL`. O navegador e o container precisam de Internet para o OAuth; o site pode continuar apenas na rede local.
Para copiar o ID: Discord → Definições → Avançadas → Modo de programador; clicar com o botão direito na tua conta → Copiar ID do utilizador. O **Dono** pode configurar, guardar, simular e executar. O **Leitor** pode consultar. Outras contas s?o recusadas; as permiss?es s?o tamb?m verificadas no servidor.
O navegador e o container precisam de Internet para o OAuth Discord. O painel não precisa de estar publicado na Internet. A rede local precisa de conseguir aceder à porta 3080. A conta OWNER_DISCORD_ID entra como Dono. O ID 723613831196311603 também está autorizado como Leitor por defeito. ALLOWED_DISCORD_IDS permite substituir a lista de leitores (IDs separados por vírgulas; valor vazio remove todos). Outras contas são recusadas. A gestão de utilizadores pelo painel ainda não está implementada. ## Ligar ao TeamSpeak
Local: Node 22.15+, definir as mesmas variáveis e executar `npm start`. Testes: `npm test`. Entrar com a conta do dono. Em **Liga??es**, configurar o endere?o WebQuery HTTP/HTTPS, o ID do servidor virtual e a API key TeamSpeak. Por exemplo, `http://teamspeak6:10080` funciona quando ambos os containers partilham uma rede Docker com esse nome resolv?vel. Testar a liga??o, escolher esse servidor na configura??o do bot, simular e s? depois ativar.
Sessões HttpOnly/SameSite, estado OAuth de uso único, credenciais no ambiente e armazenamento SQLite no volume. Em HTTP local os cookies não têm Secure; usar HTTPS quando houver publicação. Não adicionar `.env` nem dados ao Git. O temporizador aceita intervalos a partir de um segundo. A execu??o seguinte aguarda a conclus?o da anterior; pedidos de rede e blocos de espera podem prolongar o intervalo efetivo. A configura??o de permiss?es e prote??o contra flood do TeamSpeak deve permitir os pedidos do bot.
Sess?es e flows usam SQLite no volume. A chave de cifra das API keys fica em `/app/data/credentials.key`; preservar o volume completo nas c?pias de seguran?a. O hist?rico ret?m as ?ltimas 500 execu??es. N?o adicionar `.env` nem dados ao Git.
## Desenvolvimento e valida??o
Node 22.15+, mesmas vari?veis de ambiente e `npm start`. Executar `npm test` para verificar autentica??o, restri??es de acesso, persist?ncia, simula??o e execu??o WebQuery com transporte de teste. O build Docker executa os testes antes da publica??o.
O editor foi verificado em navegador desktop e m?vel. A execu??o no TeamSpeak real depende da liga??o e API key configuradas na instala??o; os testes locais n?o alteram o servidor real.
A fonte Public Sans ? distribu?da em `public/fonts` com a licen?a OFL. Os log?tipos foram copiados sem altera??es do projeto existente.

View File

@ -1 +1 @@
{"name":"ts6-botflow","version":"0.1.0","private":true,"type":"module","scripts":{"start":"node --experimental-sqlite server.mjs","test":"node --experimental-sqlite --test"},"engines":{"node":">=22.15"}} {"name":"ts6-botflow","version":"0.2.0","private":true,"type":"module","scripts":{"start":"node --experimental-sqlite server.mjs","test":"node --experimental-sqlite --test"},"engines":{"node":">=22.15"}}

View File

@ -1,33 +1,50 @@
const app=document.querySelector('#app'); const app=document.querySelector('#app');
const errors = { denied: 'Esta conta não está autorizada a entrar no painel.', authentication: 'O login expirou ou foi cancelado. Tenta novamente.', configuration: 'Falta configurar o Discord e o ID numérico do dono no Docker.' }; const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function boot() { 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 response = await fetch('/api/me'); const icon=n=>`<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[n]||paths.bot}"/></svg>`;
if (!response.ok) throw new Error(); 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]);
const { user, configured } = await response.json(); let user,data,page='dashboard',botId,draft,selectedNode,zoom=.85,pending,dirty=false;
if (!user) { const owner=()=>user?.role==='Dono', dis=()=>owner()?'':'disabled title="O perfil Leitor permite consultar"';
app.innerHTML = `<section class="login"><div class="login-card"><img class="logo" src="/logo.png" alt="XanaX"><h1>O teu centro de bots.</h1><p>Configura, acompanha e organiza as automações do teu TeamSpeak num só lugar.</p><div id="notice"></div><a class="button" href="/auth/discord">Entrar com Discord →</a><p class="muted">Acesso reservado · TS6 Botflow</p></div></section>`; const selectedBot=()=>data.bots.find(b=>b.id===botId);
const message = errors[new URLSearchParams(location.search).get('error')] || (!configured && errors.configuration); 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;}
if (message) { const notice = document.querySelector('#notice'); notice.className = 'notice'; notice.textContent = message; } 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);}
return; 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 data = await fetch('/api/dashboard'); if (!data.ok) throw new Error(); const empty=(i,title,text)=>`<div class="empty"><span class="icon-tile purple">${icon(i)}</span><h3>${title}</h3><p>${text}</p></div>`;
const dashboard = await data.json(); const button=(id,text,i='plus',style='primary')=>`<button id="${id}" class="button ${style}" ${dis()}>${icon(i)}${text}</button>`;
app.innerHTML = `<div class="shell"><aside class="sidebar"><img class="logo" src="/logo.png" alt="XanaX"><div class="eyebrow">Botflow</div><nav><button class="nav active" data-page="dashboard">◈ Dashboard</button><button class="nav" data-page="bots">◇ Bots e flows</button><button class="nav" data-page="access">♙ Acesso</button></nav></aside><section class="workspace"><header class="topbar"><span>TS6 Botflow <span class="badge">Local</span></span><span><span id="username"></span> <button class="quiet" id="logout">Sair</button></span></header><div id="content"></div><footer class="footer">XanaX · O teu centro de automações</footer></section></div>`; const heading=(title,subtitle,actions='')=>`<div class="page-heading"><div><h1>${title}</h1><p>${subtitle}</p></div><div class="heading-actions">${actions}</div></div>`;
document.querySelector('#username').textContent = user.name; 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`;
const content = document.querySelector('#content'); async function boot(){const me=await api('/api/me');user=me.user;if(!user){app.innerHTML=`<section class="login"><div class="login-card"><img class="logo" src="/logo.png" alt="XanaX"><span class="overline">TS6 BOTFLOW</span><h1>O teu centro de bots.</h1><p>Cria flows, gere bots e acompanha as tuas automações num só lugar.</p><div id="notice"></div><a class="button primary" href="/auth/discord">Entrar com Discord ${icon('play')}</a><p class="muted">Acesso reservado às contas autorizadas.</p></div></section>`;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;}
function page(name) { await refresh();app.innerHTML=`<div class="shell"><aside class="sidebar"><a href="#dashboard" class="brand"><img class="logo" src="/logo.png" alt="XanaX"></a><div class="overline">AUTOMAÇÃO</div><nav>${nav.map(([id,i,label])=>`<button class="nav" data-page="${id}">${icon(i)}<span>${label}</span></button>`).join('')}</nav><div class="sidebar-bottom"><span class="status-dot"></span><span>TS6 Botflow<small>Ambiente local</small></span><small>0.2</small></div></aside><div class="workspace"><header class="topbar"><div class="breadcrumb">Workspace <span>/</span><strong id="page-label"></strong></div><div class="topbar-right"><select id="server-select" aria-label="Servidor">${data.connections.length?data.connections.map(c=>`<option>${esc(c.name)}</option>`).join(''):'<option>Sem servidor ligado</option>'}</select><span class="divider"></span><div class="avatar">${esc(user.name.slice(0,1).toUpperCase())}</div><div class="user-info"><strong>${esc(user.name)}</strong><small>${esc(user.role)}</small></div><button class="icon-button" id="logout" title="Terminar sessão">${icon('close')}</button></div></header><main id="content"></main><footer><span>XanaX · TS6 Botflow</span><span>O teu centro de automações</span></footer></div></div><dialog id="dialog"></dialog>`;
document.querySelectorAll('.nav').forEach(b => b.classList.toggle('active', b.dataset.page === name)); 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));}
if (name === 'access') { 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();}
content.innerHTML = '<h1 class="title">Acesso ao painel</h1><p>A tua conta está autorizada a entrar no painel.</p><div class="card details"><div><span>Perfil</span><strong id="profile-role"></strong></div><div><span>ID Discord</span><strong id="discord-id"></strong></div><div><span>Novos utilizadores</span><strong>Sem acesso</strong></div></div>'; function botCard(bot){const runs=data.executions.filter(e=>e.botId===bot.id);return `<article class="card bot-card"><div class="bot-card-top"><span class="icon-tile ${esc(bot.color)}">${icon(bot.color==='red'?'shield':bot.color==='orange'?'music':bot.color==='cyan'?'users':'bot')}</span><div class="bot-card-title"><h2>${esc(bot.name)}</h2><span class="badge ${bot.enabled?'green':'gray'}">${bot.enabled?'Ativo':'Rascunho'}</span></div><button class="switch ${bot.enabled?'on':''}" data-toggle="${bot.id}" aria-label="Ativar ou desativar ${esc(bot.name)}" aria-pressed="${bot.enabled}" ${dis()}></button></div><p class="bot-description">${esc(bot.description||'Personaliza o flow deste bot.')}</p><div class="bot-meta"><span>${icon('flow')}1 flow</span><span>${icon('play')}${runs.length} execuções</span><span>${icon('clock')}${ago(runs[0]?.startedAt)}</span></div><div class="bot-actions"><button class="button secondary" data-open="${bot.id}">${icon('flow')}Abrir flow</button><button class="icon-button bordered" data-config="${bot.id}" aria-label="Configurar bot">${icon('settings')}</button></div></article>`;}
document.querySelector('#discord-id').textContent = user.id; 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);}});}
document.querySelector('#profile-role').textContent = user.role; return; 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'))+`<div class="stats">${[['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])=>`<div class="card stat"><span class="icon-tile ${c}">${icon(i)}</span><div><span>${label}</span><strong>${n}</strong></div></div>`).join('')}</div><div class="section-heading"><h2>Os teus bots</h2><button class="text-button" id="all-bots">Ver todos →</button></div><div class="bot-grid">${data.bots.map(botCard).join('')}</div><div class="dashboard-bottom"><section class="card"><div class="section-heading"><h2>Atividade recente</h2><button class="text-button" id="all-runs">Ver execuções →</button></div>${data.executions.slice(0,4).map(runRow).join('')||empty('clock','Ainda sem execuções','Simula um flow para acompanhar aqui cada passo.')}</section><section class="card quick-start"><span class="icon-tile purple">${icon('flow')}</span><h2>O teu primeiro flow</h2><p>O rascunho Estatísticas prepara a descrição do canal 21. Escolhe a ligação e simula antes de ativar.</p><button class="button secondary" id="first-flow">${icon('flow')}Abrir editor</button></section></div>`;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('create-bot','Criar bot'))+`<div class="filterbar"><input id="search-bots" placeholder="Pesquisar bots…" aria-label="Pesquisar bots"><span class="muted">${data.bots.length} bots · ${data.bots.filter(b=>b.enabled).length} ativos</span></div><div class="bot-grid" id="bot-list">${data.bots.map(botCard).join('')}</div><div class="section-heading templates-heading"><h2>Começar um novo bot</h2><span class="muted">Rascunhos para personalizar no editor</span></div><div class="template-grid">${[['Estatísticas','green','bot'],['Moderação','red','shield'],['Boas-vindas','cyan','users'],['Música','orange','music']].map(([name,c,i])=>`<button class="template-card" data-template="${name}" ${dis()}><span class="icon-tile ${c}">${icon(i)}</span><strong>${name}</strong><p>Cria um rascunho e configura os seus blocos.</p><span>Criar bot →</span></button>`).join('')}</div>`;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));}
if (name === 'bots') { content.innerHTML = '<h1 class="title">Bots e flows</h1><p>A primeira experiência valida o painel e o acesso.</p><div class="card empty"><span class="empty-symbol">◇</span>Ainda não existem bots.<p>O editor de flows e a ligação ao TeamSpeak serão a próxima etapa.</p></div>'; return; } function modal(title,body,submit){const dialog=document.querySelector('#dialog');dialog.innerHTML=`<form id="modal-form"><div class="modal-heading"><h2>${title}</h2><button class="icon-button" type="button" id="close-dialog" aria-label="Fechar">${icon('close')}</button></div>${body}<div class="modal-actions"><button type="button" class="button secondary" id="cancel">Cancelar</button><button class="button primary" ${dis()}>${icon('save')}Guardar</button></div></form>`;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);}};}
content.innerHTML = `<h1 class="title">Dashboard</h1><p>Bem-vindo ao teu centro de automações.</p><div class="stats">${[['Bots',dashboard.bots.length],['Flows',dashboard.flows.length],['Execuções',dashboard.executions.length],['Servidores',dashboard.connections.length]].map(([label,value])=>`<div class="card"><span>${label}</span><strong class="stat-value">${value}</strong></div>`).join('')}</div><div class="split"><div class="card"><h2>Atividade recente</h2><div class="empty"><span class="empty-symbol">◷</span>Ainda sem execuções.<p>A atividade dos bots aparecerá aqui.</p></div></div><div class="card"><h2>O teu espaço</h2><div class="details"><div><span>Acesso</span><span class="badge" id="dashboard-role"></span></div><div><span>Autenticação</span><strong>Discord</strong></div><div><span>Ligação TeamSpeak</span><strong>Por configurar</strong></div></div><p>Começamos por testar o login. Depois ligamos os bots e construímos os flows.</p></div></div>`; function newBot(name='Novo bot'){modal('Criar bot',`<label>Nome<input name="name" value="${esc(name)}" required maxlength="80"></label><label>Descrição<textarea name="description"></textarea></label><label>Cor<select name="color">${['purple','green','red','cyan','orange'].map(c=>`<option>${c}</option>`).join('')}</select></label><p class="form-help">O bot começa desativado. Usa o flow de estatísticas como base ou substitui os blocos.</p>`,async form=>{const bot=await api('/api/bots',{method:'POST',body:JSON.stringify(Object.fromEntries(form))});botId=bot.id;page='editor';});}
document.querySelector('#dashboard-role').textContent = user.role; function configureBot(id){botId=id;const bot=selectedBot();modal('Configuração do bot',`<div class="config-tabs"><span class="active">Geral</span><button type="button" id="config-vars">Variáveis</button></div><label>Nome<input name="name" value="${esc(bot.name)}" required></label><label>Descrição<textarea name="description">${esc(bot.description)}</textarea></label><label>Servidor<select name="connectionId"><option value="">Escolher ligação</option>${data.connections.map(c=>`<option value="${c.id}" ${bot.connectionId===c.id?'selected':''}>${esc(c.name)}</option>`).join('')}</select></label><p class="form-help">Escolhe uma ligação para executar o flow no TeamSpeak.</p>`,f=>api(`/api/bots/${bot.id}`,{method:'PUT',body:JSON.stringify(Object.fromEntries(f))}));document.querySelector('#config-vars').onclick=()=>{document.querySelector('#dialog').close();navigate('variables');};}
} 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;}draft=structuredClone(bot);selectedNode=draft.graph.nodes[0]?.id;zoom=.85;const groups=[...new Set(Object.values(data.nodeTypes).map(t=>t.group))];document.querySelector('#content').innerHTML=heading('Editor de flows','Cria e edita a lógica dos teus bots com um editor visual.',`<select id="bot-select" aria-label="Bot">${data.bots.map(b=>`<option value="${b.id}" ${b.id===botId?'selected':''}>${esc(b.name)}</option>`).join('')}</select><span class="badge gray">${owner()?'Rascunho':'Só leitura'}</span>`)+`<div class="editor" id="editor"><aside class="palette"><div class="panel-heading">Blocos ${icon('plus')}</div><input id="search-block" placeholder="Pesquisar blocos…" aria-label="Pesquisar blocos">${groups.map(g=>`<div class="palette-group"><span class="overline">${g}</span>${Object.entries(data.nodeTypes).filter(([,t])=>t.group===g).map(([id,t])=>`<button class="palette-item ${t.color}" data-add="${id}" ${dis()}>${icon(nodeIcon(id))}<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><button class="icon-button" id="zoom-out" title="Reduzir zoom">−</button><span id="zoom-label">85%</span><button class="icon-button" id="zoom-in" title="Aumentar zoom">+</button><button class="icon-button" id="fullscreen" title="Ecrã inteiro">${icon('expand')}</button></div></div><div class="canvas" id="canvas"><div class="world" id="world"><svg id="edges"></svg><div id="nodes"></div></div></div><div class="canvas-footer"><span id="graph-count"></span><span id="canvas-hint">${owner()?'Liga as portas dos blocos.':'Flow em modo de consulta.'}</span></div></div><aside class="inspector"><div id="node-properties"></div><div class="inspector-actions"><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></div></aside></div>`;
document.querySelectorAll('.nav').forEach(b => b.onclick = () => page(b.dataset.page)); document.querySelector('#bot-select').onchange=e=>{if(dirty&&!confirm('Sair sem guardar?')){e.target.value=botId;return;}botId=e.target.value;dirty=false;editorPage();};document.querySelectorAll('[data-add]').forEach(b=>b.onclick=()=>addNode(b.dataset.add));document.querySelector('#search-block').oninput=e=>document.querySelectorAll('.palette-item').forEach(b=>b.hidden=!b.textContent.toLowerCase().includes(e.target.value.toLowerCase()));document.querySelector('#zoom-in').onclick=()=>{zoom=Math.min(1.5,zoom+.1);setZoom();};document.querySelector('#zoom-out').onclick=()=>{zoom=Math.max(.4,zoom-.1);setZoom();};document.querySelector('#fullscreen').onclick=()=>document.fullscreenElement?document.exitFullscreen():document.querySelector('#editor').requestFullscreen().catch(()=>toast('Ecrã inteiro indisponível.',true));document.querySelector('#save-flow').onclick=saveFlow;document.querySelector('#simulate').onclick=()=>execute(true);document.querySelector('#run').onclick=()=>execute(false);renderGraph();}
document.querySelector('#logout').onclick = async () => { const r = await fetch('/auth/logout', { method: 'POST' }); if (r.ok) location.assign('/'); }; function markDirty(){dirty=true;const el=document.querySelector('#save-state');el.textContent='Por guardar';el.className='unsaved';}
page('dashboard'); function setZoom(){document.querySelector('#world').style.transform=`scale(${zoom})`;document.querySelector('#zoom-label').textContent=`${Math.round(zoom*100)}%`;}
} function addNode(type){const configs={timer:{seconds:30},condition:{field:'count',operator:'greater',value:'0'},sort:{field:'client_lastconnected',direction:'asc'},limit:{count:5},template:{text:'{{top}}'},channelEdit:{channelId:21,description:'{{text}}'},message:{target:21,mode:'channel',text:'Olá!'},delay:{seconds:1},log:{text:'Executado às {{time}}'}};const node={id:`n${Date.now()}${Math.floor(Math.random()*10000)}`,type,label:data.nodeTypes[type].title,x:200,y:60+draft.graph.nodes.length*100,config:configs[type]||{}};draft.graph.nodes.push(node);selectedNode=node.id;markDirty();renderGraph();}
boot().catch(() => { app.textContent = 'Não foi possível carregar o painel. Atualiza a página para tentar novamente.'; }); function renderGraph(){const nodes=draft.graph.nodes;document.querySelector('#nodes').innerHTML=nodes.map(n=>`<div class="flow-node ${data.nodeTypes[n.type].color} ${n.id===selectedNode?'selected':''}" data-node="${esc(n.id)}" style="left:${n.x}px;top:${n.y}px"><button class="port input" data-input="${esc(n.id)}" aria-label="Entrada de ${esc(n.label)}" ${dis()}></button><div class="node-head">${icon(nodeIcon(n.type))}<strong>${esc(n.label)}</strong></div><div class="node-subtitle">${esc(n.type==='timer'?`A cada ${n.config.seconds}s`:data.nodeTypes[n.type].title)}</div>${(n.type==='condition'?['true','false']:['out']).map((p,i)=>`<button class="port output ${p}" data-output="${esc(n.id)}" data-port="${p}" style="top:${n.type==='condition'?i?54:28:38}px" aria-label="Saída ${p}" ${dis()}></button>`).join('')}</div>`).join('');document.querySelector('#world').style.width=`${Math.max(1000,...nodes.map(n=>n.x+350))}px`;document.querySelector('#world').style.height=`${Math.max(950,...nodes.map(n=>n.y+160))}px`;renderEdges();setZoom();inspector();document.querySelector('#graph-count').textContent=`${nodes.length} blocos · ${draft.graph.edges.length} ligações`;
document.querySelectorAll('[data-node]').forEach(el=>el.onpointerdown=e=>{if(e.target.closest('button'))return;const n=nodes.find(n=>n.id===el.dataset.node);selectedNode=n.id;document.querySelectorAll('.flow-node').forEach(b=>b.classList.toggle('selected',b===el));inspector();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`;renderEdges();};el.onpointerup=()=>{el.onpointermove=null;el.onpointerup=null;if(moved){markDirty();renderGraph();}};});
document.querySelectorAll('[data-output]').forEach(b=>b.onclick=()=>{pending={from:b.dataset.output,port:b.dataset.port};document.querySelector('#canvas-hint').textContent='Clica na entrada do bloco de destino.';});document.querySelectorAll('[data-input]').forEach(b=>b.onclick=()=>{if(!pending||pending.from===b.dataset.input)return;const edge={...pending,to:b.dataset.input};if(!draft.graph.edges.some(e=>e.from===edge.from&&e.to===edge.to&&e.port===edge.port)){draft.graph.edges.push(edge);markDirty();}pending=null;renderGraph();document.querySelector('#canvas-hint').textContent='Ligação criada.';});}
function renderEdges(){document.querySelector('#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+(e.port==='true'?28:e.port==='false'?54:38),x2=b.x,y2=b.y+38;return `<path class="edge ${e.port}" d="M${x} ${y} C${x+80} ${y},${x2-80} ${y2},${x2} ${y2}" marker-end="url(#arrow)"/>`;}).join('');}
function inspector(){const n=draft.graph.nodes.find(n=>n.id===selectedNode),el=document.querySelector('#node-properties');if(!n){el.innerHTML=empty('flow','Seleciona um bloco','Clica num bloco para editar.');return;}const type=data.nodeTypes[n.type],edges=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(nodeIcon(n.type))}${esc(type.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 ${type.color}">${esc(type.group)}</span><span class="overline">CONFIGURAÇÃO</span>${type.fields.map(([key,label,input,values])=>`<label>${esc(label)}${input==='textarea'?`<textarea data-field="${key}" ${owner()?'':'readonly'}>${esc(n.config[key])}</textarea>`:input==='select'?`<select data-field="${key}" ${dis()}>${values.map(v=>`<option value="${v}" ${n.config[key]===v?'selected':''}>${esc(({asc:'Crescente',desc:'Decrescente',greater:'Maior que',equals:'Igual a',contains:'Contém',client:'Cliente',channel:'Canal',server:'Servidor'})[v]||v)}</option>`).join('')}</select>`:`<input data-field="${key}" type="${input}" value="${esc(n.config[key])}" ${owner()?'':'readonly'}>`}</label>`).join('')||'<p class="form-help">Sem parâmetros adicionais.</p>'}<p class="form-help">Variáveis: <code>{{time}}</code>, <code>{{top}}</code>, <code>{{text}}</code> e <code>{{variables.nome}}</code>.</p><span class="overline">LIGAÇÕES</span>${edges.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'} · ${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>`;document.querySelector('#node-label').oninput=e=>{if(!owner())return;n.label=e.target.value;markDirty();document.querySelector(`[data-node="${n.id}"] strong`).textContent=n.label;};document.querySelectorAll('[data-field]').forEach(input=>input.onchange=()=>{if(!owner())return;n.config[input.dataset.field]=input.type==='number'?Number(input.value):input.value;markDirty();renderGraph();});document.querySelector('#delete-node').onclick=()=>{draft.graph.nodes=draft.graph.nodes.filter(node=>node.id!==n.id);draft.graph.edges=draft.graph.edges.filter(e=>e.from!==n.id&&e.to!==n.id);selectedNode=draft.graph.nodes[0]?.id;markDirty();renderGraph();};document.querySelectorAll('[data-delete-edge]').forEach(b=>b.onclick=()=>{draft.graph.edges.splice(Number(b.dataset.deleteEdge),1);markDirty();renderGraph();});}
async function saveFlow(){try{await api(`/api/bots/${draft.id}`,{method:'PUT',body:JSON.stringify({graph:draft.graph})});await refresh();dirty=false;document.querySelector('#save-state').textContent='Guardado';document.querySelector('#save-state').className='';toast('Flow guardado.');return true;}catch(e){toast(e.message,true);return false;}}
async function execute(simulation){if(!simulation&&!confirm('Executar este flow no servidor TeamSpeak configurado?'))return;if(dirty&&!(await saveFlow()))return;const b=document.querySelector(simulation?'#simulate':'#run');b.disabled=true;try{const result=await api(`/api/bots/${draft.id}/${simulation?'simulate':'run'}`,{method:'POST'});await refresh();showRun(result);}catch(e){toast(e.message,true);}finally{b.disabled=!owner();}}
function runRow(e){return `<button class="execution-row" data-execution="${e.id}"><span class="execution-status ${e.status==='success'?'green':'red'}">${icon(e.status==='success'?'check':'close')}</span><span><strong>${esc(e.botName)}</strong><small>${e.simulation?'Simulação':'Execução real'} · ${e.status==='success'?'Concluída':'Falhou'}</small></span><time>${ago(e.startedAt)}</time></button>`;}
function runDetail(e){return `<div class="section-heading"><h2>${esc(e.botName)}</h2><span class="badge ${e.status==='success'?'green':'red'}">${e.status==='success'?'Sucesso':'Falha'}</span></div><span class="muted">${new Date(e.startedAt).toLocaleString('pt-PT')} · ${e.simulation?'Simulação com dados de exemplo':'Servidor TeamSpeak'}</span><div class="execution-summary"><div><span>Duração</span><strong>${((e.durationMs||0)/1000).toFixed(2)}s</strong></div><div><span>Iniciado por</span><strong>${esc(e.actor)}</strong></div><div><span>Passos</span><strong>${e.steps.length}</strong></div></div><h3>Eventos e etapas</h3><div class="timeline">${e.steps.map(s=>`<div class="timeline-step"><span class="execution-status ${s.status==='success'?'green':'red'}">${icon(s.status==='success'?'check':'close')}</span><div><strong>${esc(s.label)}</strong><p>${esc(s.message)}</p></div><time>${s.durationMs||0} ms</time></div>`).join('')}</div>${e.error?`<div class="notice error">${esc(e.error)}</div>`:''}${e.output?`<h3>Texto produzido</h3><pre>${esc(e.output)}</pre>`:''}`;}
function showRun(e){const dialog=document.querySelector('#dialog');dialog.innerHTML=`<div class="modal-heading"><h2>${e.simulation?'Resultado da simulação':'Resultado da execução'}</h2><button class="icon-button" id="close-run">${icon('close')}</button></div>${runDetail(e)}`;dialog.showModal();document.querySelector('#close-run').onclick=()=>dialog.close();}
function wireExecutions(){document.querySelectorAll('[data-execution]').forEach(b=>b.onclick=()=>{const e=data.executions.find(e=>e.id===b.dataset.execution);const el=document.querySelector('#execution-detail');if(el){el.innerHTML=runDetail(e);document.querySelectorAll('.execution-row').forEach(row=>row.classList.toggle('selected',row===b));}else showRun(e);});}
function executionsPage(){document.querySelector('#content').innerHTML=heading('Execuções','Acompanha e analisa cada passo dos teus flows.',`<button class="button secondary" id="refresh-runs">${icon('refresh')}Atualizar</button>`)+`<div class="executions-layout"><section class="card execution-list"><div class="section-heading"><h2>Execuções recentes</h2><span class="badge gray">${data.executions.length}</span></div>${data.executions.map(runRow).join('')||empty('clock','Ainda sem execuções','Abre um flow e carrega em Simular.')}</section><section class="card" id="execution-detail">${data.executions.length?runDetail(data.executions[0]):empty('flow','Cada passo, num só lugar','Os resultados ficam guardados aqui.')}</section></div>`;document.querySelector('#refresh-runs').onclick=async()=>{await refresh();executionsPage();wireExecutions();};}
const variableRow=(k,v)=>`<div class="variable-row"><input data-key value="${esc(k)}" placeholder="nome" ${owner()?'':'readonly'}><input data-value value="${esc(v)}" placeholder="valor" ${owner()?'':'readonly'}><button type="button" class="icon-button danger" data-remove-var ${dis()}>${icon('trash')}</button></div>`;
function variablesPage(){const bot=selectedBot();document.querySelector('#content').innerHTML=heading('Variáveis','Valores persistentes para reutilizar nos teus flows.',`<select id="variables-bot" aria-label="Bot">${data.bots.map(b=>`<option value="${b.id}" ${b.id===botId?'selected':''}>${esc(b.name)}</option>`).join('')}</select>`)+`<section class="card"><div class="section-heading"><h2>Variáveis do bot</h2>${button('add-variable','Adicionar variável','plus','secondary')}</div><p class="form-help">Usa <code>{{variables.nome}}</code> no texto de um bloco.</p><form id="variables-form"><div class="table-heading"><span>Nome</span><span>Valor</span><span></span></div><div id="variable-rows">${Object.entries(bot?.variables||{}).map(([k,v])=>variableRow(k,v)).join('')}</div><div class="form-actions"><button class="button primary" ${dis()}>${icon('save')}Guardar variáveis</button></div></form></section>`;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.');variables[key]=row.querySelector('[data-value]').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','Liga os teus bots ao servidor TeamSpeak.',button('new-connection','Adicionar servidor','link'))+`<div class="connection-grid">${data.connections.map(c=>`<article class="card"><div class="bot-card-top"><span class="icon-tile cyan">${icon('link')}</span><div><h2>${esc(c.name)}</h2><span class="muted">WebQuery · Servidor #${c.serverId}</span></div></div><p class="connection-url">${esc(c.url)}</p><div class="connection-actions"><button class="button secondary" data-edit-connection="${c.id}">${icon('settings')}Configurar</button><button class="button secondary" data-test-connection="${c.id}" ${dis()}>${icon('refresh')}Testar</button></div></article>`).join('')||`<section class="card">${empty('link','Liga o teu TeamSpeak','Adiciona o endereço WebQuery e a API key.')}</section>`}</div><div class="notice connection-tip">Podes usar <code>http://192.168.88.99:10080</code> se a porta estiver publicada. O nome <code>teamspeak6</code> só funciona quando os containers partilham a rede.</div>`;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',`<label>Nome<input name="name" value="${esc(c.name||'Servidor principal')}" required></label><label>Endereço WebQuery<input name="url" type="url" value="${esc(c.url||'http://192.168.88.99:10080')}" required></label><label>ID do servidor virtual<input name="serverId" type="number" min="1" value="${c.serverId||1}" required></label><label>API key<input name="apiKey" type="password" autocomplete="new-password" placeholder="${c.hasApiKey?'Guardada · deixa vazio para manter':'Coloca a API key'}" ${c.hasApiKey?'':'required'}></label><p class="form-help">A chave é guardada cifrada e não aparece no painel.</p>`,f=>api('/api/connections',{method:'POST',body:JSON.stringify({...Object.fromEntries(f),id:c.id})}));}
function settingsPage(){document.querySelector('#content').innerHTML=heading('Definições','A tua conta e o ambiente do painel.')+`<div class="settings-grid"><section class="card"><h2>O teu acesso</h2><div class="profile-card"><div class="avatar large">${esc(user.name.slice(0,1).toUpperCase())}</div><div><h3>${esc(user.name)}</h3><span class="badge purple">${esc(user.role)}</span></div></div><div class="details"><div><span>ID Discord</span><strong>${esc(user.id)}</strong></div><div><span>Autenticação</span><strong>Discord</strong></div><div><span>Permissões</span><strong>${owner()?'Configurar, guardar e executar':'Consultar bots, flows e execuções'}</strong></div></div></section><section class="card"><h2>Ambiente</h2><div class="details"><div><span>Painel</span><strong>TS6 Botflow 0.2</strong></div><div><span>Dados</span><strong>Volume Docker persistente</strong></div><div><span>Novas contas</span><strong>Apenas IDs autorizados</strong></div></div><p class="form-help">A tua conta Discord e as ligações TeamSpeak têm permissões separadas.</p></section></div>`;}
boot().catch(e=>{app.innerHTML=`<div class="login"><div class="login-card"><h1>Não foi possível carregar o painel.</h1><p>${esc(e.message)}</p><a class="button primary" href="/">Tentar novamente</a></div></div>`;});window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});

93
public/fonts/OFL.txt Normal file
View File

@ -0,0 +1,93 @@
Copyright 2015 The Public Sans Project Authors (https://github.com/uswds/public-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,7 @@ import { mkdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { createStudio } from './studio.mjs';
export function createApp({ env = process.env, discordFetch = fetch } = {}) { export function createApp({ env = process.env, discordFetch = fetch } = {}) {
const appUrl = new URL(env.APP_URL || 'http://localhost:3080'); const appUrl = new URL(env.APP_URL || 'http://localhost:3080');
@ -11,6 +12,7 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
mkdirSync(dataDir, { recursive: true }); mkdirSync(dataDir, { recursive: true });
const db = new DatabaseSync(resolve(dataDir, 'botflow.sqlite')); const db = new DatabaseSync(resolve(dataDir, 'botflow.sqlite'));
db.exec('CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user TEXT NOT NULL, expires INTEGER NOT NULL)'); db.exec('CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user TEXT NOT NULL, expires INTEGER NOT NULL)');
const studio = createStudio(db, dataDir);
const pending = new Map(); const pending = new Map();
const root = fileURLToPath(new URL('./public/', import.meta.url)); const root = fileURLToPath(new URL('./public/', import.meta.url));
const configured = Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET && /^\d{17,20}$/.test(env.OWNER_DISCORD_ID || '')); const configured = Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET && /^\d{17,20}$/.test(env.OWNER_DISCORD_ID || ''));
@ -26,7 +28,7 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
const url = new URL(req.url, appUrl); const url = new URL(req.url, appUrl);
const cookies = Object.fromEntries((req.headers.cookie || '').split(';').map(v => v.trim().split('='))); const cookies = Object.fromEntries((req.headers.cookie || '').split(';').map(v => v.trim().split('=')));
const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); }; const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
@ -72,15 +74,15 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
} }
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
if (!user) return json(401, { error: 'Autenticação necessária.' }); if (!user) return json(401, { error: 'Autenticação necessária.' });
if (req.method === 'GET' && url.pathname === '/api/dashboard') return json(200, { bots: [], flows: [], executions: [], connections: [] }); if (await studio.route(req,url,user,json)) return;
return json(404, { error: 'Não encontrado.' }); return json(404, { error: 'Não encontrado.' });
} }
const files = { '/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/style.css': ['style.css', 'text/css'], '/logo.png': ['logo.png', 'image/png'], '/favicon.ico': ['favicon.ico', 'image/x-icon'] }; const files = { '/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/style.css': ['style.css', 'text/css'], '/logo.png': ['logo.png', 'image/png'], '/favicon.ico': ['favicon.ico', 'image/x-icon'], '/fonts/public-sans.ttf': ['fonts/public-sans.ttf', 'font/ttf'] };
if (req.method !== 'GET' || !files[url.pathname]) return json(404, { error: 'Não encontrado.' }); if (req.method !== 'GET' || !files[url.pathname]) return json(404, { error: 'Não encontrado.' });
const [file, type] = files[url.pathname]; res.writeHead(200, { 'Content-Type': type }); res.end(readFileSync(resolve(root, file))); const [file, type] = files[url.pathname]; res.writeHead(200, { 'Content-Type': type }); res.end(readFileSync(resolve(root, file)));
} catch { json(502, { error: 'Não foi possível concluir o login Discord. Tenta novamente.' }); } } catch { json(502, { error: 'Não foi possível concluir o login Discord. Tenta novamente.' }); }
}); });
server.on('close', () => db.close()); server.on('close', () => { studio.close(); db.close(); });
return server; return server;
} }
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {

174
studio.mjs Normal file
View File

@ -0,0 +1,174 @@
import { randomUUID, randomBytes, createCipheriv, createDecipheriv } 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';
export const nodeTypes = {
timer: { title: 'Temporizador', group: 'Eventos', color: 'purple', fields: [['seconds','Intervalo (segundos)','number']] },
manual: { title: 'Início manual', group: 'Eventos', color: 'purple', fields: [] },
serverinfo: { title: 'Informações do servidor', group: 'Dados', color: 'cyan', fields: [] },
clients: { title: 'Listar clientes', group: 'Dados', color: 'cyan', fields: [] },
condition: { title: 'Condição', group: 'Lógica', color: 'orange', fields: [['field','Variável','text'],['operator','Comparação','select',['greater','equals','contains']],['value','Valor','text']] },
sort: { title: 'Ordenar', group: 'Lógica', color: 'purple', fields: [['field','Campo dos clientes','text'],['direction','Ordem','select',['desc','asc']]] },
limit: { title: 'Top / limite', group: 'Lógica', color: 'purple', fields: [['count','Número de pessoas','number']] },
template: { title: 'Preparar texto', group: 'Lógica', color: 'purple', fields: [['text','Texto e variáveis {{server.nome}}','textarea']] },
channelEdit: { title: 'Editar canal', group: 'TeamSpeak', color: 'cyan', fields: [['channelId','ID do canal','number'],['description','Descrição','textarea']] },
message: { title: 'Enviar mensagem', group: 'TeamSpeak', color: 'cyan', fields: [['target','ID de destino','number'],['mode','Destino','select',['channel','client','server']],['text','Mensagem','textarea']] },
delay: { title: 'Esperar', group: 'Tempo', color: 'gray', fields: [['seconds','Segundos','number']] },
log: { title: 'Registar mensagem', group: 'Tempo', color: 'gray', fields: [['text','Mensagem','textarea']] }
};
export function validateGraph(graph) {
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges) || graph.nodes.length > 80 || graph.edges.length > 160) throw new Error('O flow precisa de uma lista válida de blocos e ligações.');
const ids = new Set();
for (const node of graph.nodes) {
if (!nodeTypes[node.type] || typeof node.id !== 'string' || ids.has(node.id) || !Number.isFinite(node.x) || !Number.isFinite(node.y) || !node.config || typeof node.config !== 'object') throw new Error('Bloco inválido no flow.');
ids.add(node.id);
if (node.type === 'timer' && (!Number.isFinite(Number(node.config.seconds)) || Number(node.config.seconds) < 1)) throw new Error('O intervalo mínimo é 1 segundo.');
if (['channelEdit','message'].includes(node.type) && (!Number.isInteger(Number(node.config[node.type === 'channelEdit' ? 'channelId' : 'target'])) || Number(node.config[node.type === 'channelEdit' ? 'channelId' : 'target']) < 1)) throw new Error('Indica um ID válido no bloco TeamSpeak.');
if (node.type === 'limit' && (!Number.isInteger(Number(node.config.count)) || Number(node.config.count) < 1 || Number(node.config.count) > 1000)) throw new Error('O limite precisa de ser entre 1 e 1000.');
if (node.type === 'delay' && (!Number.isFinite(Number(node.config.seconds)) || Number(node.config.seconds) < 0 || Number(node.config.seconds) > 30)) throw new Error('O tempo de espera precisa de ser entre 0 e 30 segundos.');
}
if (graph.nodes.filter(n => ['timer','manual'].includes(n.type)).length !== 1) throw new Error('Usa exatamente um bloco de início (manual ou temporizador).');
const visiting = new Set(), visited = new Set();
const visit = id => {
if (visiting.has(id)) throw new Error('O flow contém um ciclo. Usa um temporizador para repetir.');
if (visited.has(id)) return;
visiting.add(id);
for (const edge of graph.edges.filter(e => e.from === id)) visit(edge.to);
visiting.delete(id); visited.add(id);
};
for (const edge of graph.edges) if (!ids.has(edge.from) || !ids.has(edge.to) || edge.from === edge.to || !['out','true','false'].includes(edge.port)) throw new Error('Ligação inválida no flow.');
for (const id of ids) visit(id);
return graph;
}
const valueAt = (context, path) => String(path).split('.').reduce((value, key) => value == null ? undefined : Object.hasOwn(value, key) ? value[key] : undefined, context);
const interpolate = (text, context) => String(text || '').replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path) => String(valueAt(context, path) ?? ''));
const initialGraph = () => {
const nodes = [
['timer', 'Atualizar a cada 30 segundos', { seconds: 30 }],
['serverinfo', 'Estado do servidor', {}],
['clients', 'Pessoas online', {}],
['sort', 'Ordenar por tempo ligado', { field: 'client_lastconnected', direction: 'asc' }],
['limit', 'Top 5 pessoas', { count: 5 }],
['template', 'Informações e pessoas', { text: '[b]Informações do servidor[/b]\nOnline: {{server.virtualserver_clientsonline}} / {{server.virtualserver_maxclients}}\nCanais: {{server.virtualserver_channelsonline}}\n\n[b]Pessoas online[/b]\n{{top}}\n\nAtualizado: {{time}}' }],
['channelEdit', 'Atualizar descrição do canal', { channelId: 21, description: '{{text}}' }]
].map(([type, label, config], index) => ({ id: `n${index}`, type, label, config, x: index % 2 ? 420 : 140, y: 55 + index * 108 }));
return { nodes, edges: nodes.slice(1).map((node,index) => ({ from: nodes[index].id, to: node.id, port: 'out' })) };
};
export function createStudio(db, dataDir, { transport = query } = {}) {
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); const cipher = createCipheriv('aes-256-gcm', key, iv); const bytes = Buffer.concat([cipher.update(text,'utf8'),cipher.final()]); return Buffer.concat([iv,cipher.getAuthTag(),bytes]).toString('base64'); };
const decrypt = text => { const bytes = Buffer.from(text,'base64'); const decipher = createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12)); decipher.setAuthTag(bytes.subarray(12,28)); return Buffer.concat([decipher.update(bytes.subarray(28)),decipher.final()]).toString('utf8'); };
const list = table => db.prepare(`SELECT data FROM studio_${table} ORDER BY rowid DESC`).all().map(row=>JSON.parse(row.data));
const get = (table,id) => { const row=db.prepare(`SELECT data FROM studio_${table} WHERE id = ?`).get(id); return row ? 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(item));
if (!list('bots').length) save('bots',{ id:randomUUID(), name:'Estatísticas', description:'Informações do servidor e pessoas online na descrição do canal 21.', color:'green', enabled:false, connectionId:'', variables:{}, graph:initialGraph(), updatedAt:new Date().toISOString() });
const publicConnection = connection => { const { apiKey, ...fields }=connection; return { ...fields, hasApiKey:Boolean(apiKey) }; };
const running = new Set(), due = new Map();
async function run(bot, simulation, actor) {
if (running.has(bot.id)) throw new Error('Este bot já está em execução.');
validateGraph(bot.graph);
const connection = bot.connectionId ? get('connections',bot.connectionId) : null;
if (!simulation && !connection) throw new Error('Configura uma ligação TeamSpeak antes de executar.');
running.add(bot.id);
const execution={ id:randomUUID(), botId:bot.id, botName:bot.name, simulation, actor, status:'running', startedAt:new Date().toISOString(), steps:[] };
save('executions',execution);
const context={ variables:bot.variables || {}, time:new Date().toLocaleString('pt-PT',{timeZone:'Europe/Lisbon'}) };
const call = async (command,params={}) => transport({ ...connection, apiKey:decrypt(connection.apiKey) }, command, params);
let visits=0;
async function processNode(id) {
if (++visits > 160) throw new Error('Limite de passos excedido.');
const node=bot.graph.nodes.find(n=>n.id===id), config=node.config;
const step={ nodeId:id, label:node.label || nodeTypes[node.type].title, type:node.type, startedAt:new Date().toISOString(), status:'running' };
execution.steps.push(step); let port='out';
try {
switch(node.type) {
case 'serverinfo': context.server=simulation ? { virtualserver_clientsonline:3, virtualserver_maxclients:32, virtualserver_channelsonline:17, virtualserver_name:'Servidor de exemplo' } : (await call('serverinfo'))[0]; step.message=simulation?'Dados de exemplo para simulação':'Informações obtidas'; break;
case 'clients': context.clients=simulation ? [{client_nickname:'Pessoa A',client_lastconnected:1},{client_nickname:'Pessoa B',client_lastconnected:2},{client_nickname:'Pessoa C',client_lastconnected:3}] : (await call('clientlist',{'-times':true})).filter(client=>Number(client.client_type)===0); context.count=context.clients.length; step.message=`${context.count} pessoas${simulation?' (exemplo)':''}`; break;
case 'sort': context.clients=[...(context.clients || [])].sort((a,b)=>(Number(a[config.field] || 0)-Number(b[config.field] || 0))*(config.direction==='desc'?-1:1)); step.message=`Ordenado por ${config.field}`; break;
case 'limit': context.clients=(context.clients || []).slice(0,Number(config.count)); context.top=context.clients.map((client,index)=>`${index+1}. ${client.client_nickname}`).join('\n'); step.message=`Selecionadas ${context.clients.length} pessoas`; break;
case 'condition': { const value=valueAt(context,config.field); const yes=config.operator==='greater'?Number(value)>Number(config.value):config.operator==='contains'?String(value).includes(config.value):String(value)===String(config.value); port=yes?'true':'false'; step.message=yes?'Sim':'Não'; break; }
case 'template': context.text=interpolate(config.text,context); step.message=context.text; break;
case 'channelEdit': { const description=interpolate(config.description,context); if(!simulation) await call('channeledit',{cid:config.channelId,channel_description:description}); step.message=`${simulation?'Simulação: ':' '}Descrição do canal ${config.channelId}\n${description}`; break; }
case 'message': if(!simulation) await call('sendtextmessage',{targetmode:{client:1,channel:2,server:3}[config.mode] || 2,target:config.target,msg:interpolate(config.text,context)}); step.message=`${simulation?'Simulação: ':''}${interpolate(config.text,context)}`; break;
case 'delay': if(!simulation) await new Promise(resolve=>setTimeout(resolve,Number(config.seconds)*1000)); step.message=`${config.seconds} segundos`; break;
case 'log': step.message=interpolate(config.text,context); break;
default: step.message=simulation?'Teste manual do flow':'Flow iniciado';
}
step.status='success';
} catch(error) { step.status='error'; step.message=error.message; throw error; }
finally { step.durationMs=Date.now()-Date.parse(step.startedAt); save('executions',execution); }
for(const edge of bot.graph.edges.filter(edge=>edge.from===id && edge.port===port)) await processNode(edge.to);
}
try { await processNode(bot.graph.nodes.find(node=>['timer','manual'].includes(node.type)).id); execution.status='success'; }
catch(error) { execution.status='error'; execution.error=error.message; }
finally { execution.durationMs=Date.now()-Date.parse(execution.startedAt); execution.output=context.text || ''; save('executions',execution); running.delete(bot.id); db.exec('DELETE FROM studio_executions WHERE rowid NOT IN (SELECT rowid FROM studio_executions ORDER BY rowid DESC LIMIT 500)'); }
return execution;
}
const interval=setInterval(()=>{
for(const bot of list('bots').filter(b=>b.enabled)) {
const timer=bot.graph.nodes.find(n=>n.type==='timer');
if(!timer || running.has(bot.id) || (due.get(bot.id) || 0)>Date.now()) continue;
due.set(bot.id,Date.now()+Number(timer.config.seconds)*1000);
run(bot,false,'Temporizador').catch(()=>{});
}
},1000); interval.unref();
async function route(req,url,user,json) {
if(!url.pathname.startsWith('/api/')) return false;
if(req.method==='GET') {
if(url.pathname==='/api/dashboard') { const bots=list('bots'), executions=list('executions'); json(200,{ bots, flows:bots, executions, connections:list('connections').map(publicConnection), nodeTypes }); return true; }
return false;
}
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; }
let bytes=0, chunks=[];
for await(const chunk of req) { bytes+=chunk.length; if(bytes>250000) { json(413,{error:'Pedido demasiado grande.'}); return true; } chunks.push(chunk); }
try {
const body=chunks.length?JSON.parse(Buffer.concat(chunks).toString()):{};
if(url.pathname==='/api/bots' && req.method==='POST') {
const bot={id:randomUUID(),name:String(body.name || 'Novo bot').slice(0,80),description:String(body.description || '').slice(0,400),color:['green','red','cyan','orange','purple'].includes(body.color)?body.color:'purple',enabled:false,connectionId:'',variables:{},graph:initialGraph(),updatedAt:new Date().toISOString()}; save('bots',bot); json(201,bot); return true;
}
const botMatch=url.pathname.match(/^\/api\/bots\/([\w-]+)(?:\/(run|simulate))?$/);
if(botMatch) {
const bot=get('bots',botMatch[1]); if(!bot) { json(404,{error:'Bot não encontrado.'}); return true; }
if(req.method==='DELETE') { if(running.has(bot.id)) throw new Error('Espera que a execução termine.'); db.prepare('DELETE FROM studio_bots WHERE id = ?').run(bot.id); due.delete(bot.id); json(200,{ok:true}); return true; }
if(req.method==='POST' && botMatch[2]) { json(200,await run(bot,botMatch[2]==='simulate',user.name)); return true; }
if(req.method==='PUT') {
const graph=validateGraph(body.graph || bot.graph), connectionId=String(body.connectionId ?? bot.connectionId);
if(connectionId && !get('connections',connectionId)) throw new Error('Ligação não encontrada.');
if((body.enabled ?? bot.enabled) && !connectionId) throw new Error('Escolhe uma ligação antes de ativar o bot.');
if((body.enabled ?? bot.enabled) && !graph.nodes.some(n=>n.type==='timer')) throw new Error('Adiciona um temporizador para ativar a execução automática.');
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:Boolean(body.enabled ?? bot.enabled),variables:body.variables ?? bot.variables,updatedAt:new Date().toISOString()};
if(!updated.name || !updated.variables || typeof updated.variables!=='object' || Array.isArray(updated.variables)) throw new Error('Nome ou variáveis inválidos.');
save('bots',updated); due.delete(bot.id); json(200,updated); return true;
}
}
if(url.pathname==='/api/connections' && req.method==='POST') {
const old=body.id?get('connections',body.id):null;
const base=new URL(body.url); if(!['http:','https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) throw new Error('Usa um endereço HTTP ou HTTPS sem credenciais.');
const serverId=Number(body.serverId || 1); if(!Number.isInteger(serverId) || serverId<1) throw new Error('ID de servidor inválido.');
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(String(body.apiKey)):old.apiKey}; save('connections',connection); json(200,publicConnection(connection)); return true;
}
const testMatch=url.pathname.match(/^\/api\/connections\/([\w-]+)\/test$/);
if(testMatch) { const connection=get('connections',testMatch[1]); if(!connection) throw new Error('Ligação não encontrada.'); const server=(await transport({...connection,apiKey:decrypt(connection.apiKey)},'serverinfo',{}))[0]; json(200,{server}); return true; }
return false;
} catch(error) { json(400,{error:error instanceof SyntaxError?'Pedido inválido.':error.message}); return true; }
}
return {route,close:()=>clearInterval(interval)};
}
function query(connection,command,params) {
const url=new URL(`/${connection.serverId}/${command}`,connection.url);
for(const [key,value] of Object.entries(params)) url.searchParams.set(key,String(value));
return new Promise((resolve,reject)=>{
const req=(url.protocol==='https:'?https:http).get(url,{headers:{'x-api-key':connection.apiKey},timeout:10000},res=>{
let data='',size=0; res.on('data',chunk=>{size+=chunk.length; if(size>2000000) req.destroy(new Error('Resposta TeamSpeak demasiado grande.')); else data+=chunk;});
res.on('error',reject); res.on('end',()=>{ try { const result=JSON.parse(data); if(res.statusCode!==200 || Number(result.status?.code)!==0) throw new Error(result.status?.message || `TeamSpeak HTTP ${res.statusCode}`); resolve(Array.isArray(result.body)?result.body:[result.body]); } catch { reject(new Error('O TeamSpeak recusou o pedido ou devolveu uma resposta inválida. Verifica a ligação e a API key.')); } });
}); req.on('timeout',()=>req.destroy(new Error('A ligação TeamSpeak excedeu 10 segundos.'))); req.on('error',error=>reject(new Error(`Ligação TeamSpeak: ${error.code || error.message}`)));
});
}

41
studio.test.mjs Normal file
View File

@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { createStudio, validateGraph } from './studio.mjs';
test('flows persist, simulation does not call TeamSpeak, credentials are hidden and readers cannot modify', async () => {
const directory=mkdtempSync(join(tmpdir(),'botflow-studio-'));
const db=new DatabaseSync(join(directory,'test.sqlite'));
const calls=[];
const studio=createStudio(db,directory,{transport:async (connection,command,params)=>{
assert.equal(connection.apiKey,'secret-api-key'); calls.push({command,params});
return command==='serverinfo'?[{virtualserver_clientsonline:1,virtualserver_maxclients:32,virtualserver_channelsonline:8}]:command==='clientlist'?[{client_type:0,client_nickname:'Client',client_lastconnected:1}]:[];
}});
const invoke=async (path,method='GET',body={},role='Dono',origin='http://localhost:3080')=>{
const req=Readable.from(method==='GET'?[]:[Buffer.from(JSON.stringify(body))]);req.method=method;req.headers={origin};let result;
await studio.route(req,new URL(path,'http://localhost:3080'),{role,name:'Tester'},(status,data)=>result={status,data});return result;
};
try {
const dashboard=(await invoke('/api/dashboard')).data,bot=dashboard.bots[0];
assert.equal(bot.enabled,false); assert.equal(dashboard.executions.length,0);
assert.equal((await invoke('/api/bots','POST',{name:'Test'},'Leitor')).status,403);
assert.equal((await invoke('/api/bots','POST',{name:'Test'},'Dono','http://evil.example')).status,403);
const simulation=await invoke(`/api/bots/${bot.id}/simulate`,'POST');
assert.equal(simulation.data.status,'success');assert.match(simulation.data.output,/Pessoa A/);assert.equal(calls.length,0);
assert.equal((await invoke(`/api/bots/${bot.id}/run`,'POST')).status,400);
const connection=(await invoke('/api/connections','POST',{name:'Test',url:'http://teamspeak6:10080',serverId:1,apiKey:'secret-api-key'})).data;
assert.equal(connection.hasApiKey,true);assert.equal(connection.apiKey,undefined);
assert.ok(!db.prepare('SELECT data FROM studio_connections').get().data.includes('secret-api-key'));
await invoke(`/api/bots/${bot.id}`,'PUT',{connectionId:connection.id,variables:{greeting:'Olá'}});
const execution=await invoke(`/api/bots/${bot.id}/run`,'POST');
assert.equal(execution.data.status,'success');assert.deepEqual(calls.map(c=>c.command),['serverinfo','clientlist','channeledit']);assert.equal(calls.at(-1).params.cid,21);assert.match(calls.at(-1).params.channel_description,/Client/);
const modified=structuredClone(bot.graph);modified.edges.push({from:'n6',to:'n0',port:'out'});
assert.throws(()=>validateGraph(modified),/ciclo/);
assert.equal((await invoke(`/api/bots/${bot.id}`,'PUT',{graph:modified})).status,400);
const final=(await invoke('/api/dashboard')).data;assert.equal(final.executions.length,2);assert.equal(final.bots[0].variables.greeting,'Olá');assert.equal(final.connections[0].apiKey,undefined);
} finally {studio.close();db.close();rmSync(directory,{recursive:true,force:true});}
});