Compare commits

..

No commits in common. "e2c0e703eac51d45f0d63b2590148290505adbb4" and "e9f1a90592f3b006672f6209623b46e3a5f86ef0" have entirely different histories.

10 changed files with 55 additions and 410 deletions

View File

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

View File

@ -1,51 +1,28 @@
# TS6 Botflow
# TS6 Botflow
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.
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.
## 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: `{{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.
O editor de flows e o motor de bots ainda não estão implementados nesta versão. Nenhuma ligação ao TeamSpeak é feita.
## Docker / Portainer
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 **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 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`.
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`.
Variáveis de ambiente:
Configurar as quatro variáveis nas Environment variables da stack (ou copiar `.env.example` para `.env` quando executado por Docker Compose):
- `APP_URL`: endereço do navegador, por exemplo `http://192.168.88.99:3080`.
- `APP_URL`: endereço usado no navegador, por exemplo `http://192.168.88.99:3080`.
- `DISCORD_CLIENT_ID`: ID da aplicação Discord.
- `DISCORD_CLIENT_SECRET`: segredo OAuth da aplicação.
- `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.
- `DISCORD_CLIENT_SECRET`: segredo OAuth da aplicação, nunca o token de um bot.
- `OWNER_DISCORD_ID`: ID **numérico** da tua conta Discord, não o nome `bxio`.
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.
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.
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.
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.
## Ligar ao TeamSpeak
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.
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.
Local: Node 22.15+, definir as mesmas variáveis e executar `npm start`. Testes: `npm test`.
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.
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.

View File

@ -1 +1 @@
{"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"}}
{"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"}}

View File

@ -1,50 +1,33 @@
const app = document.querySelector('#app');
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=>`<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>`;
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,draft,selectedNode,zoom=.85,pending,dirty=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)=>`<div class="empty"><span class="icon-tile purple">${icon(i)}</span><h3>${title}</h3><p>${text}</p></div>`;
const button=(id,text,i='plus',style='primary')=>`<button id="${id}" class="button ${style}" ${dis()}>${icon(i)}${text}</button>`;
const heading=(title,subtitle,actions='')=>`<div class="page-heading"><div><h1>${title}</h1><p>${subtitle}</p></div><div class="heading-actions">${actions}</div></div>`;
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=`<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;}
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('[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();}
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>`;}
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'))+`<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'],['Personalizado','purple','flow']].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));}
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);}};}
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';});}
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.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();}
function markDirty(){dirty=true;const el=document.querySelector('#save-state');el.textContent='Por guardar';el.className='unsaved';}
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();}
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='';}});
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.' };
async function boot() {
const response = await fetch('/api/me');
if (!response.ok) throw new Error();
const { user, configured } = await response.json();
if (!user) {
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 message = errors[new URLSearchParams(location.search).get('error')] || (!configured && errors.configuration);
if (message) { const notice = document.querySelector('#notice'); notice.className = 'notice'; notice.textContent = message; }
return;
}
const data = await fetch('/api/dashboard'); if (!data.ok) throw new Error();
const dashboard = await data.json();
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>`;
document.querySelector('#username').textContent = user.name;
const content = document.querySelector('#content');
function page(name) {
document.querySelectorAll('.nav').forEach(b => b.classList.toggle('active', b.dataset.page === name));
if (name === 'access') {
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>';
document.querySelector('#discord-id').textContent = user.id;
document.querySelector('#profile-role').textContent = user.role; return;
}
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; }
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>`;
document.querySelector('#dashboard-role').textContent = user.role;
}
document.querySelectorAll('.nav').forEach(b => b.onclick = () => page(b.dataset.page));
document.querySelector('#logout').onclick = async () => { const r = await fetch('/auth/logout', { method: 'POST' }); if (r.ok) location.assign('/'); };
page('dashboard');
}
boot().catch(() => { app.textContent = 'Não foi possível carregar o painel. Atualiza a página para tentar novamente.'; });

View File

@ -1,93 +0,0 @@
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,7 +4,6 @@ import { mkdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'node:url';
import { createStudio } from './studio.mjs';
export function createApp({ env = process.env, discordFetch = fetch } = {}) {
const appUrl = new URL(env.APP_URL || 'http://localhost:3080');
@ -12,7 +11,6 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
mkdirSync(dataDir, { recursive: true });
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)');
const studio = createStudio(db, dataDir);
const pending = new Map();
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 || ''));
@ -28,7 +26,7 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'no-referrer');
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'");
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'");
const url = new URL(req.url, appUrl);
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)); };
@ -74,15 +72,15 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
}
if (url.pathname.startsWith('/api/')) {
if (!user) return json(401, { error: 'Autenticação necessária.' });
if (await studio.route(req,url,user,json)) return;
if (req.method === 'GET' && url.pathname === '/api/dashboard') return json(200, { bots: [], flows: [], executions: [], connections: [] });
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'], '/fonts/public-sans.ttf': ['fonts/public-sans.ttf', 'font/ttf'] };
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'] };
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)));
} catch { json(502, { error: 'Não foi possível concluir o login Discord. Tenta novamente.' }); }
});
server.on('close', () => { studio.close(); db.close(); });
server.on('close', () => db.close());
return server;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {

View File

@ -1,174 +0,0 @@
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}`)));
});
}

View File

@ -1,41 +0,0 @@
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});}
});