diff --git a/Dockerfile b/Dockerfile
index 89d9a9a..4c426e0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,11 @@
FROM node:22-bookworm-slim AS base
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
RUN mkdir /app/data && chown node:node /app/data
FROM base AS test
-COPY --chown=node:node auth.test.mjs ./
+COPY --chown=node:node auth.test.mjs studio.test.mjs ./
USER node
RUN npm test
diff --git a/README.md b/README.md
index c3c3f32..25359ac 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,51 @@
# 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
-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`.
-- `DISCORD_CLIENT_ID`: ID da aplicação Discord.
-- `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`.
+- `APP_URL`: endere?o do 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.
-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.
diff --git a/package.json b/package.json
index 8e186f2..4f3a060 100644
--- a/package.json
+++ b/package.json
@@ -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"}}
diff --git a/public/app.js b/public/app.js
index 2603f00..2aa3d8b 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,33 +1,50 @@
-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.' };
-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 = `O teu centro de bots. Configura, acompanha e organiza as automações do teu TeamSpeak num só lugar.
Entrar com Discord → Acesso reservado · TS6 Botflow
`;
- 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 = `
`;
- 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 = 'Acesso ao painel A tua conta está autorizada a entrar no painel.
Perfil
ID Discord
Novos utilizadores Sem acesso
';
- document.querySelector('#discord-id').textContent = user.id;
- document.querySelector('#profile-role').textContent = user.role; return;
- }
- if (name === 'bots') { content.innerHTML = 'Bots e flows A primeira experiência valida o painel e o acesso.
◇ Ainda não existem bots.
O editor de flows e a ligação ao TeamSpeak serão a próxima etapa.
'; return; }
- content.innerHTML = `Dashboard Bem-vindo ao teu centro de automações.
${[['Bots',dashboard.bots.length],['Flows',dashboard.flows.length],['Execuções',dashboard.executions.length],['Servidores',dashboard.connections.length]].map(([label,value])=>`
${label} ${value}
`).join('')}
Atividade recente ◷ Ainda sem execuções.
A atividade dos bots aparecerá aqui.
O teu espaço Acesso
Autenticação Discord
Ligação TeamSpeak Por configurar
Começamos por testar o login. Depois ligamos os bots e construímos os flows.
`;
- 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.'; });
+const app=document.querySelector('#app');
+const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+const paths={dashboard:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',bot:'M9 3h6 M12 3v3 M5 8h14v12H5z M8 12v3 M16 12v3 M2 11v6 M22 11v6',flow:'M4 3h6v6H4z M14 15h6v6h-6z M7 9v9h7 M10 6h7v9',play:'M8 5l12 7-12 7z',clock:'M12 8v5l3 2 M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18',users:'M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3 M9 3a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M18 4a4 4 0 0 1 0 7 M22 21v-3a4 4 0 0 0-3-4',link:'M10 14l4-4 M8 16l-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0 M16 8l1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2',variable:'M8 3H5v18h3 M16 3h3v18h-3 M10 9l4 6 M14 9l-4 6',plus:'M12 5v14 M5 12h14',save:'M4 3h14l3 3v15H3V3z M7 3v6h10V3 M7 21v-8h10v8',refresh:'M20 7a9 9 0 1 0 1 6 M20 2v5h-5',check:'M5 12l4 4L19 6',close:'M6 6l12 12 M18 6L6 18',shield:'M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6',music:'M9 18V5l11-2v13 M9 18a3 3 0 1 1-6 0 3 3 0 1 1 6 0 M20 16a3 3 0 1 1-6 0 3 3 0 1 1 6 0',trash:'M3 6h18 M8 6V3h8v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',data:'M3 5c0-4 18-4 18 0s-18 4-18 0z M3 5v14c0 4 18 4 18 0V5 M3 12c0 4 18 4 18 0',condition:'M12 2l9 10-9 10-9-10z',text:'M4 5h16 M12 5v15 M8 20h8',sort:'M4 5h16 M4 12h12 M4 19h8',channel:'M8 3l-2 18 M18 3l-2 18 M3 8h18 M2 16h18',expand:'M8 3H3v5 M16 3h5v5 M3 16v5h5 M21 16v5h-5'};
+const icon=n=>` `;
+const nodeIcon=t=>({timer:'clock',manual:'play',serverinfo:'data',clients:'users',condition:'condition',sort:'sort',limit:'dashboard',template:'text',channelEdit:'channel',message:'text',delay:'clock',log:'text'}[t]);
+let user,data,page='dashboard',botId,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)=>`${icon(i)} ${title} ${text}
`;
+const button=(id,text,i='plus',style='primary')=>`${icon(i)}${text} `;
+const heading=(title,subtitle,actions='')=>``;
+const ago=d=>!d?'Sem execuções':Math.floor((Date.now()-Date.parse(d))/60000)<1?'Agora':`Há ${Math.floor((Date.now()-Date.parse(d))/60000)} min`;
+async function boot(){const me=await api('/api/me');user=me.user;if(!user){app.innerHTML=`TS6 BOTFLOW O teu centro de bots. Cria flows, gere bots e acompanha as tuas automações num só lugar.
Entrar com Discord ${icon('play')} Acesso reservado às contas autorizadas.
`;const messages={denied:'Esta conta não está autorizada.',authentication:'O login expirou ou foi cancelado.',configuration:'Configura a aplicação Discord no Docker.'};const msg=messages[new URLSearchParams(location.search).get('error')]||(!me.configured&&messages.configuration);if(msg){const el=document.querySelector('#notice');el.className='notice';el.textContent=msg;}return;}
+ await refresh();app.innerHTML=`XanaX · TS6 Botflow O teu centro de automações `;
+ 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 `${icon(bot.color==='red'?'shield':bot.color==='orange'?'music':bot.color==='cyan'?'users':'bot')}
${esc(bot.name)} ${bot.enabled?'Ativo':'Rascunho'} ${esc(bot.description||'Personaliza o flow deste bot.')}
${icon('flow')}1 flow ${icon('play')}${runs.length} execuções ${icon('clock')}${ago(runs[0]?.startedAt)}
${icon('flow')}Abrir flow ${icon('settings')}
`;}
+function wireBots(){document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>{botId=b.dataset.open;navigate('editor');});document.querySelectorAll('[data-config]').forEach(b=>b.onclick=()=>configureBot(b.dataset.config));const create=document.querySelector('#create-bot');if(create)create.onclick=()=>newBot();document.querySelectorAll('[data-toggle]').forEach(b=>b.onclick=async()=>{try{const bot=data.bots.find(bot=>bot.id===b.dataset.toggle);await api(`/api/bots/${bot.id}`,{method:'PUT',body:JSON.stringify({enabled:!bot.enabled})});await refresh();navigate(page);toast('Estado atualizado.');}catch(e){toast(e.message,true);}});}
+function dashboardPage(){const today=new Date().toLocaleDateString('pt-PT',{timeZone:'Europe/Lisbon'});document.querySelector('#content').innerHTML=heading('Dashboard','Uma visão geral dos teus bots e automações.',button('create-bot','Criar bot'))+`${[['Bots ativos',data.bots.filter(b=>b.enabled).length,'bot','green'],['Execuções hoje',data.executions.filter(e=>new Date(e.startedAt).toLocaleDateString('pt-PT',{timeZone:'Europe/Lisbon'})===today).length,'play','cyan'],['Flows guardados',data.bots.length,'flow','purple'],['Servidores configurados',data.connections.length,'link','orange']].map(([label,n,i,c])=>`
`).join('')}
Os teus bots Ver todos → ${data.bots.map(botCard).join('')}
Atividade recente Ver execuções → ${data.executions.slice(0,4).map(runRow).join('')||empty('clock','Ainda sem execuções','Simula um flow para acompanhar aqui cada passo.')}${icon('flow')} O teu primeiro flow O rascunho Estatísticas prepara a descrição do canal 21. Escolhe a ligação e simula antes de ativar.
${icon('flow')}Abrir editor `;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'))+`${data.bots.length} bots · ${data.bots.filter(b=>b.enabled).length} ativos
${data.bots.map(botCard).join('')}
Começar um novo bot Rascunhos para personalizar no editor ${[['Estatísticas','green','bot'],['Moderação','red','shield'],['Boas-vindas','cyan','users'],['Música','orange','music']].map(([name,c,i])=>`
${icon(i)} ${name} Cria um rascunho e configura os seus blocos.
Criar bot → `).join('')}
`;wireBots();document.querySelector('#search-bots').oninput=e=>{document.querySelector('#bot-list').innerHTML=data.bots.filter(b=>b.name.toLowerCase().includes(e.target.value.toLowerCase())).map(botCard).join('');wireBots();};document.querySelectorAll('[data-template]').forEach(b=>b.onclick=()=>newBot(b.dataset.template));}
+function modal(title,body,submit){const dialog=document.querySelector('#dialog');dialog.innerHTML=``;dialog.showModal();document.querySelector('#close-dialog').onclick=document.querySelector('#cancel').onclick=()=>dialog.close();document.querySelector('#modal-form').onsubmit=async e=>{e.preventDefault();try{await submit(new FormData(e.target));dialog.close();await refresh();dirty=false;navigate(page);toast('Guardado.');}catch(error){toast(error.message,true);}};}
+function newBot(name='Novo bot'){modal('Criar bot',`Nome Descrição Cor${['purple','green','red','cyan','orange'].map(c=>`${c} `).join('')} O bot começa desativado. Usa o flow de estatísticas como base ou substitui os blocos.
`,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',`Geral Variáveis
Nome Descrição ServidorEscolher ligação ${data.connections.map(c=>`${esc(c.name)} `).join('')} Escolhe uma ligação para executar o flow no TeamSpeak.
`,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.',`${data.bots.map(b=>`${esc(b.name)} `).join('')} ${owner()?'Rascunho':'Só leitura'} `)+``;
+ 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=>`${icon(nodeIcon(n.type))}${esc(n.label)}
${esc(n.type==='timer'?`A cada ${n.config.seconds}s`:data.nodeTypes[n.type].title)}
${(n.type==='condition'?['true','false']:['out']).map((p,i)=>`
`).join('')}
`).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=` `+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 ` `;}).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=`${icon(nodeIcon(n.type))}${esc(type.title)} ${icon('trash')}
Nome do bloco ${esc(type.group)} CONFIGURAÇÃO ${type.fields.map(([key,label,input,values])=>`
${esc(label)}${input==='textarea'?``:input==='select'?`${values.map(v=>`${esc(({asc:'Crescente',desc:'Decrescente',greater:'Maior que',equals:'Igual a',contains:'Contém',client:'Cliente',channel:'Canal',server:'Servidor'})[v]||v)} `).join('')} `:` `} `).join('')||'
Sem parâmetros adicionais.
'}
Variáveis: {{time}}, {{top}}, {{text}} e {{variables.nome}}.
LIGAÇÕES ${edges.map(({e,i})=>`
${esc(draft.graph.nodes.find(node=>node.id===(e.from===n.id?e.to:e.from))?.label)}${e.from===n.id?'Saída':'Entrada'} · ${e.port} ${icon('trash')}
`).join('')||'
Ainda sem ligações.
'}
`;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 `${icon(e.status==='success'?'check':'close')} ${esc(e.botName)} ${e.simulation?'Simulação':'Execução real'} · ${e.status==='success'?'Concluída':'Falhou'} ${ago(e.startedAt)} `;}
+function runDetail(e){return `
${esc(e.botName)} ${e.status==='success'?'Sucesso':'Falha'} ${new Date(e.startedAt).toLocaleString('pt-PT')} · ${e.simulation?'Simulação com dados de exemplo':'Servidor TeamSpeak'} Duração ${((e.durationMs||0)/1000).toFixed(2)}s
Iniciado por ${esc(e.actor)}
Passos ${e.steps.length}
Eventos e etapas ${e.steps.map(s=>`
${icon(s.status==='success'?'check':'close')} ${esc(s.label)} ${esc(s.message)}
${s.durationMs||0} ms `).join('')}
${e.error?`${esc(e.error)}
`:''}${e.output?`Texto produzido ${esc(e.output)} `:''}`;}
+function showRun(e){const dialog=document.querySelector('#dialog');dialog.innerHTML=`
${e.simulation?'Resultado da simulação':'Resultado da execução'} ${icon('close')} ${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.',`${icon('refresh')}Atualizar `)+`
Execuções recentes ${data.executions.length} ${data.executions.map(runRow).join('')||empty('clock','Ainda sem execuções','Abre um flow e carrega em Simular.')}${data.executions.length?runDetail(data.executions[0]):empty('flow','Cada passo, num só lugar','Os resultados ficam guardados aqui.')} `;document.querySelector('#refresh-runs').onclick=async()=>{await refresh();executionsPage();wireExecutions();};}
+const variableRow=(k,v)=>`${icon('trash')}
`;
+function variablesPage(){const bot=selectedBot();document.querySelector('#content').innerHTML=heading('Variáveis','Valores persistentes para reutilizar nos teus flows.',`${data.bots.map(b=>`${esc(b.name)} `).join('')} `)+`
Variáveis do bot ${button('add-variable','Adicionar variável','plus','secondary')}Usa {{variables.nome}} no texto de um bloco.
`;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'))+`${data.connections.map(c=>`
${icon('link')}
${esc(c.name)} WebQuery · Servidor #${c.serverId} ${esc(c.url)}
${icon('settings')}Configurar ${icon('refresh')}Testar
`).join('')||`
${empty('link','Liga o teu TeamSpeak','Adiciona o endereço WebQuery e a API key.')} `}
Podes usar http://192.168.88.99:10080 se a porta estiver publicada. O nome teamspeak6 só funciona quando os containers partilham a rede.
`;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',`Nome Endereço WebQuery ID do servidor virtual API key A chave é guardada cifrada e não aparece no painel.
`,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.')+`O teu acesso ${esc(user.name.slice(0,1).toUpperCase())}
${esc(user.name)} ${esc(user.role)} ID Discord ${esc(user.id)}
Autenticação Discord
Permissões ${owner()?'Configurar, guardar e executar':'Consultar bots, flows e execuções'}
Ambiente Painel TS6 Botflow 0.2
Dados Volume Docker persistente
Novas contas Apenas IDs autorizados
A tua conta Discord e as ligações TeamSpeak têm permissões separadas.
`;}
+boot().catch(e=>{app.innerHTML=``;});window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
diff --git a/public/fonts/OFL.txt b/public/fonts/OFL.txt
new file mode 100644
index 0000000..9eef1e9
--- /dev/null
+++ b/public/fonts/OFL.txt
@@ -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.
diff --git a/public/fonts/public-sans.ttf b/public/fonts/public-sans.ttf
new file mode 100644
index 0000000..183d105
Binary files /dev/null and b/public/fonts/public-sans.ttf differ
diff --git a/public/style.css b/public/style.css
index 55947f5..1a4fa7d 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1 +1,6 @@
-:root{color-scheme:dark;font-family:'Public Sans',system-ui,sans-serif;background:#25293c;color:#cfcde4;--surface:#2f3349;--purple:#7367f0}*{box-sizing:border-box}body{margin:0}button,a{font:inherit}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:10px;border:0;border-radius:6px;padding:12px 20px;background:var(--purple);color:white;text-decoration:none;cursor:pointer}button:hover,.button:hover{background:#8479f5}button:focus-visible,a:focus-visible{outline:2px solid white;outline-offset:4px}.login{min-height:100vh;display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:40px;background:var(--surface);border-radius:10px;box-shadow:0 8px 32px #13162440}.logo{width:190px;height:auto;object-fit:contain}.login h1{font-size:24px;margin-top:34px;color:#e6e5f2}p{line-height:1.7;color:#aaa8bf}.login .button{width:100%;margin-top:16px}.eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:1.4px;color:#9591ab}.notice{padding:14px;background:#ff9f4318;border:1px solid #ff9f4340;border-radius:6px;font-size:13px;color:#ffd5aa}.shell{display:grid;grid-template-columns:240px 1fr;min-height:100vh}.sidebar{background:var(--surface);padding:30px 18px}.sidebar .logo{width:180px;margin-bottom:40px}.nav{display:block;width:100%;text-align:left;padding:13px 16px;margin:8px 0;background:transparent;color:#cfcde4}.nav.active{background:var(--purple)}.workspace{padding:28px 36px;max-width:1500px;width:100%;margin:auto}.topbar{display:flex;align-items:center;justify-content:space-between;background:var(--surface);padding:18px 24px;border-radius:8px;margin-bottom:36px}.muted{font-size:13px;color:#aaa8bf}.quiet{background:#7367f019;color:#b5adff;padding:8px 12px}.title{font-size:27px;color:#e6e5f2;margin-bottom:6px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;margin:28px 0}.card{background:var(--surface);padding:24px;border-radius:8px}.stat-value{display:block;font-size:30px;color:#e6e5f2;margin-top:10px}.split{display:grid;grid-template-columns:1.6fr 1fr;gap:22px}h2{font-size:18px;font-weight:600;margin:0 0 18px}.empty{text-align:center;padding:42px 20px;color:#aaa8bf}.empty-symbol{display:block;font-size:36px;color:#7367f0;margin-bottom:15px}.badge{padding:5px 9px;border-radius:4px;background:#28c76f18;color:#50d895;font-size:12px}.footer{margin-top:32px;font-size:12px;color:#9591ab}.details{display:grid;gap:18px;font-size:14px}.details div{display:flex;justify-content:space-between;gap:16px}@media(max-width:900px){.shell{grid-template-columns:1fr}.sidebar{padding:18px}.sidebar .logo{margin-bottom:12px}.sidebar nav{display:flex;flex-wrap:wrap}.nav{width:auto}.workspace{padding:18px}.stats{grid-template-columns:repeat(2,1fr)}.split{grid-template-columns:1fr}}@media(max-width:480px){.login-card{padding:26px}.topbar{padding:14px;gap:12px}.stats{gap:12px}.card{padding:18px}}
+@font-face{font-family:'Public Sans';src:url('/fonts/public-sans.ttf') format('truetype');font-weight:400;font-display:swap}
+:root{color-scheme:dark;--bg:#25293c;--surface:#2f3349;--purple:#7367f0;--text:#cfcde4;--muted:#9c99b1;--line:#454961;--green:#28c76f;--cyan:#00cfe8;--orange:#ff9f43;--red:#ea5455;font-size:14px}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:'Public Sans',Arial,sans-serif}button,input,select,textarea{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:not-allowed;opacity:.45}button:focus-visible,a:focus-visible{outline:2px solid #b5acff;outline-offset:3px}a{color:inherit;text-decoration:none}p{line-height:1.65;color:var(--muted);margin:8px 0 14px}h1,h2,h3{color:#e4e2f0;margin:0;line-height:1.35}h1{font-size:25px;letter-spacing:-.55px;font-weight:600}h2{font-size:16px;font-weight:600}h3{font-size:14px;font-weight:600}.icon{width:20px;height:20px;flex-shrink:0}.button{display:inline-flex;align-items:center;justify-content:center;gap:9px;border:1px solid transparent;border-radius:6px;padding:10px 15px;min-height:39px;font-size:13px;white-space:nowrap}.primary{background:var(--purple);color:#fff}.primary:hover:enabled{background:#8479f5}.secondary{background:#7367f010;border-color:#50516e;color:#d0caff}.secondary:hover:enabled{background:#7367f02b}.icon-button{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border:0;background:transparent;border-radius:5px;color:var(--muted);padding:6px}.icon-button:hover:enabled{background:#ffffff0a}.bordered{border:1px solid #50516e}.danger{color:#ed7276}.text-button{border:0;background:transparent;color:#b3aaff;font-size:12px;padding:6px 0}.logo{width:177px;height:auto;display:block}.overline{font-size:10px;letter-spacing:1.1px;color:#9591ab;display:block;margin-bottom:12px}.shell{display:grid;grid-template-columns:224px minmax(0,1fr);min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;background:var(--surface);padding:27px 15px 18px;display:flex;flex-direction:column}.brand{padding:0 8px;display:block;margin-bottom:43px}.sidebar>.overline{padding:0 13px}.nav{width:100%;display:flex;align-items:center;gap:12px;text-align:left;background:transparent;border:0;padding:12px 14px;border-radius:6px;margin:3px 0;font-size:13px;color:#b8b5cd}.nav .icon{width:18px;height:18px}.nav:hover{background:#ffffff06}.nav.active{background:var(--purple);color:#fff;box-shadow:0 3px 8px #7367f025}.sidebar-bottom{display:flex;gap:10px;align-items:center;margin-top:auto;border-top:1px solid #ffffff08;padding:22px 10px 0;font-size:12px}.sidebar-bottom small{display:block;color:var(--muted);font-size:10px;margin-top:5px}.sidebar-bottom>small{margin-left:auto}.status-dot{width:7px;height:7px;border-radius:50%;background:var(--green)}.workspace{min-width:0;width:100%;max-width:1660px;margin:0 auto;padding:22px 30px;display:flex;flex-direction:column;align-self:start;min-height:100vh}.topbar{display:flex;align-items:center;justify-content:space-between;background:var(--surface);min-height:66px;padding:12px 20px;border-radius:8px;margin-bottom:27px;gap:20px}.breadcrumb{display:flex;gap:15px;align-items:center;font-size:12px;color:var(--muted)}.breadcrumb strong{color:var(--text);font-weight:500}.topbar-right{display:flex;gap:12px;align-items:center}.topbar-right select{min-width:180px;font-size:12px;background:#353950;padding:9px 11px}.divider{height:26px;width:1px;background:var(--line);margin:0 2px}.avatar{width:34px;height:34px;border-radius:50%;background:var(--purple);display:grid;place-items:center;color:white;font-size:14px}.avatar.large{width:52px;height:52px;font-size:20px}.user-info strong{display:block;font-size:12px;font-weight:500}.user-info small{display:block;color:var(--muted);font-size:10px;margin-top:3px}#content{flex:1;min-width:0}.page-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:25px}.page-heading p{font-size:13px;margin:6px 0 0}.heading-actions{display:flex;align-items:center;gap:10px}.card{background:var(--surface);border-radius:8px;padding:22px;border:1px solid #ffffff03}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px;margin-bottom:30px}.stat{display:flex;align-items:center;gap:16px;padding:21px}.stat>div>span{font-size:12px;color:#b5b1c8}.stat strong{display:block;font-size:26px;margin-top:8px;font-weight:600;color:#e9e7f4}.icon-tile{width:43px;height:43px;border-radius:7px;display:inline-flex;align-items:center;justify-content:center;background:#7367f01a;color:var(--purple);flex-shrink:0}.icon-tile .icon{width:24px;height:24px}.green{color:var(--green);--accent:var(--green)}.red{color:var(--red);--accent:var(--red)}.cyan{color:var(--cyan);--accent:var(--cyan)}.orange{color:var(--orange);--accent:var(--orange)}.purple{color:#a194ff;--accent:var(--purple)}.gray{color:#aaa5bc;--accent:#aaa5bc}.icon-tile.green,.badge.green,.execution-status.green{background:#28c76f18}.icon-tile.red,.badge.red,.execution-status.red{background:#ea545518}.icon-tile.cyan,.badge.cyan{background:#00cfe818}.icon-tile.orange{background:#ff9f4318}.badge.purple{background:#7367f018}.badge.gray{background:#ffffff08}.badge{display:inline-flex;align-items:center;padding:4px 8px;border-radius:4px;font-size:10px;line-height:1.2;width:max-content}.section-heading{display:flex;justify-content:space-between;align-items:center;gap:15px;margin-bottom:17px}.bot-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.bot-card-top{display:flex;align-items:center;gap:12px}.bot-card-title{display:flex;align-items:center;flex-wrap:wrap;gap:12px;flex:1}.bot-card h2{font-size:15px}.bot-description{font-size:12px;min-height:40px;margin:13px 0 18px 55px}.switch{width:38px;height:21px;border:0;border-radius:20px;background:#545365;margin-left:auto;position:relative;flex-shrink:0}.switch:after{content:'';position:absolute;width:15px;height:15px;top:3px;left:3px;background:#b9b6c9;border-radius:50%}.switch.on{background:var(--purple)}.switch.on:after{background:white;transform:translateX(17px)}.bot-meta{display:flex;gap:19px;font-size:11px;color:#aba6c2;margin:0 0 18px;flex-wrap:wrap}.bot-meta span{display:flex;align-items:center;gap:6px}.bot-meta .icon{width:14px;height:14px}.bot-actions{display:flex;gap:9px}.bot-actions .button{flex:1;min-height:35px;padding:7px;font-size:12px}.dashboard-bottom{display:grid;grid-template-columns:1.6fr 1fr;gap:20px;margin-top:26px}.quick-start{display:flex;flex-direction:column;align-items:flex-start;padding:25px}.quick-start h2{margin-top:17px}.quick-start p{font-size:12px;max-width:360px}.quick-start .button{margin-top:auto}.empty{padding:34px 20px;text-align:center;width:100%}.empty .icon-tile{margin-bottom:15px}.empty p{font-size:12px;max-width:380px;margin:8px auto 0}.muted{color:var(--muted);font-size:12px}footer{display:flex;justify-content:space-between;gap:15px;margin-top:27px;padding:0 2px;font-size:11px;color:#8e89a3}.filterbar{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:22px}.filterbar input{max-width:350px}.templates-heading{margin-top:36px}.template-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.template-card{text-align:left;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:20px}.template-card strong{display:block;margin-top:15px;font-size:14px}.template-card p{font-size:12px;min-height:40px}.template-card>span:last-child{font-size:11px;color:#b3aaff}.template-card:hover:enabled{border-color:var(--purple)}input,select,textarea{background:#292d41;color:var(--text);border:1px solid #484b62;border-radius:5px;padding:10px 12px;width:100%;outline:none;font-size:13px;min-width:0}input:focus,textarea:focus,select:focus{border-color:var(--purple)}input::placeholder,textarea::placeholder{color:#817d96}textarea{min-height:110px;resize:vertical;line-height:1.55}select{width:auto}label{display:flex;flex-direction:column;gap:7px;font-size:12px;margin-bottom:16px;color:#aaa5be}label select{width:100%}.form-help{font-size:11px;line-height:1.7}.notice{padding:14px 17px;background:#ff9f430d;border:1px solid #ff9f4325;border-radius:6px;font-size:12px;color:#ddbca0;line-height:1.6}.notice.error{background:#ea545518;color:#f79797}code{font-family:Consolas,monospace;font-size:11px;color:#b8aff9}dialog{background:var(--surface);color:var(--text);border:1px solid #4d4f68;border-radius:10px;padding:25px;width:min(560px,calc(100vw - 32px));max-height:85vh;box-shadow:0 15px 65px #090b2040}dialog::backdrop{background:#11132290;backdrop-filter:blur(3px)}.modal-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:25px}.modal-heading h2{font-size:18px}.modal-actions{display:flex;justify-content:flex-end;gap:12px;border-top:1px solid var(--line);padding-top:18px;margin-top:20px}.config-tabs{display:flex;gap:25px;border-bottom:1px solid var(--line);margin-bottom:20px;font-size:12px}.config-tabs>*{padding:0 0 12px;border:0;background:transparent}.config-tabs .active{color:#b3aaff;border-bottom:2px solid var(--purple)}.toast{position:fixed;bottom:26px;right:26px;z-index:1000;background:#393d58;border:1px solid #7367f070;color:#e4e0ff;padding:15px 20px;border-radius:8px;max-width:440px;box-shadow:0 8px 25px #11132250;font-size:13px}.toast.error{border-color:#ea545560;color:#ffc0c0}.editor{display:grid;grid-template-columns:185px minmax(0,1fr) 280px;gap:15px;min-height:640px;height:calc(100vh - 215px)}.editor:fullscreen{background:var(--bg);padding:15px;height:100vh}.editor-workspace{max-width:none;padding:18px 24px}.palette,.inspector{background:var(--surface);border-radius:7px;min-height:0;overflow:auto}.palette{padding:15px 12px}.panel-heading{display:flex;align-items:center;justify-content:space-between;color:#dedbeb;font-size:12px;font-weight:600;margin-bottom:18px}.panel-heading .icon{width:16px;height:16px}.palette>input{padding:8px;font-size:11px;margin-bottom:10px}.palette-group{margin:15px 0 21px}.palette-group .overline{font-size:9px;margin:0 0 10px 3px}.palette-item{display:flex;align-items:center;gap:8px;border:1px solid #4b4865;background:#7367f009;border-radius:5px;width:100%;margin:7px 0;padding:10px 8px;text-align:left;font-size:10px;line-height:1.35;color:var(--accent);min-height:39px}.palette-item .icon{width:16px;height:16px}.palette-item span{flex:1}.palette-item .icon:last-child{width:12px}.palette-item[hidden]{display:none}.canvas-column{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid #3c4057;border-radius:7px;overflow:hidden}.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;padding:11px 15px;background:var(--surface);min-height:52px;font-size:11px;gap:10px}.canvas-toolbar>div{display:flex;align-items:center;gap:10px}.canvas-toolbar strong{font-size:12px}.canvas-toolbar #save-state{color:#918da5;font-size:10px}.canvas-toolbar #save-state.unsaved{color:var(--orange)}.canvas-toolbar .icon-button{width:25px;height:25px;font-size:18px}.canvas{flex:1;overflow:auto;position:relative;background-color:#262a3c;background-image:radial-gradient(#66617c60 .8px,transparent .8px);background-size:20px 20px;min-height:400px}.world{position:relative;transform-origin:0 0;width:1000px;height:1000px}#edges{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.edge{fill:none;stroke:#9290b880;stroke-width:1.5}.edge.true{stroke:#28c76f80}.edge.false{stroke:#ea545580}.flow-node{position:absolute;width:230px;min-height:76px;background:#32364d;border:1px solid var(--accent);border-radius:6px;box-shadow:0 4px 10px #191c3330;color:var(--text);touch-action:none;cursor:grab;user-select:none}.flow-node.selected{outline:2px solid #9b8fff40;box-shadow:0 0 18px #7367f01c}.node-head{display:flex;align-items:center;gap:10px;padding:12px 14px 8px;color:var(--accent)}.node-head .icon{width:18px;height:18px}.node-head strong{font-size:12px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node-subtitle{font-size:10px;color:#9d98b0;padding:0 14px 12px 42px}.port{position:absolute;border-radius:50%;width:10px;height:10px;padding:0;border:2px solid #777287;background:#33364d;z-index:2}.port.input{left:-6px;top:33px}.port.output{right:-6px;transform:translateY(-50%);border-color:var(--accent)}.port.true{background:var(--green);border-color:var(--green)}.port.false{background:var(--red);border-color:var(--red)}.port:hover:enabled{outline:4px solid #7367f050}.canvas-footer{background:var(--surface);padding:9px 12px;display:flex;justify-content:space-between;gap:10px;font-size:9px;color:#9691ab;min-height:32px}.inspector{display:flex;flex-direction:column}.inspector .panel-heading{padding:15px;border-bottom:1px solid #46475e;margin:0}.inspector .panel-heading>span{display:flex;gap:10px;align-items:center}.inspector-body{padding:18px 15px}.inspector-body label{font-size:10px;gap:7px;margin-bottom:15px}.inspector-body input,.inspector-body select,.inspector-body textarea{font-size:11px;padding:9px}.inspector-body textarea{min-height:140px}.inspector-body .badge{margin-bottom:20px}.inspector-body .overline{font-size:9px;margin-top:17px}.connection-row{display:flex;align-items:center;justify-content:space-between;font-size:10px;margin-bottom:9px;gap:8px}.connection-row small{display:block;color:var(--muted);font-size:9px;margin-top:4px}.connection-row .icon{width:13px}.inspector-actions{display:flex;flex-wrap:wrap;gap:8px;padding:15px;border-top:1px solid #46475e;margin-top:auto}.inspector-actions .button{flex:1;font-size:11px;padding:9px 6px}.inspector-actions .button .icon{width:15px}.inspector-actions .text-button{width:100%;text-align:center;font-size:10px}.executions-layout{display:grid;grid-template-columns:350px minmax(0,1fr);gap:20px}.execution-list{padding:19px 15px}.execution-row{display:flex;align-items:center;gap:10px;background:transparent;border:0;border-bottom:1px solid #ffffff07;width:100%;text-align:left;padding:14px 5px}.execution-row:hover,.execution-row.selected{background:#7367f014;border-radius:5px}.execution-status{width:24px;height:24px;display:grid;place-items:center;border-radius:50%;flex-shrink:0}.execution-status .icon{width:14px;height:14px}.execution-row>span:nth-child(2){flex:1}.execution-row strong{font-size:11px;font-weight:500}.execution-row small{display:block;margin-top:5px;color:#aaa4be;font-size:10px}.execution-row time{color:#918ba4;font-size:9px}.execution-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:25px 0}.execution-summary div{background:#262a3d;padding:14px;border-radius:5px}.execution-summary span{display:block;color:var(--muted);font-size:10px;margin-bottom:7px}.execution-summary strong{font-size:11px;font-weight:500}.timeline{margin:20px 0}.timeline-step{display:flex;gap:12px;align-items:flex-start;position:relative;padding-bottom:24px}.timeline-step:before{content:'';position:absolute;left:11px;top:24px;width:1px;bottom:0;background:#565068}.timeline-step:last-child:before{display:none}.timeline-step>div{flex:1;min-width:0}.timeline-step strong{font-size:12px;font-weight:500}.timeline-step p{font-size:11px;margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.timeline-step time{font-size:9px;color:var(--muted)}pre{background:#242738;border:1px solid #41445e;border-radius:5px;padding:16px;white-space:pre-wrap;font-family:Consolas,monospace;font-size:11px;line-height:1.6;overflow-wrap:anywhere;max-height:300px;overflow:auto}.table-heading,.variable-row{display:grid;grid-template-columns:1fr 2fr 35px;gap:12px;align-items:center;margin-bottom:12px}.table-heading{font-size:11px;color:var(--muted);margin-top:22px}.form-actions{display:flex;justify-content:flex-end;border-top:1px solid var(--line);padding-top:20px;margin-top:25px}.connection-grid,.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.connection-url{font-size:12px;color:#c6bfdc;margin:24px 0}.connection-actions{display:flex;gap:10px}.connection-actions .button{font-size:11px}.connection-tip{margin-top:25px}.profile-card{display:flex;align-items:center;gap:15px;margin:25px 0}.profile-card h3{margin-bottom:8px}.details{display:grid;gap:17px;font-size:12px;margin:25px 0}.details div{display:flex;justify-content:space-between;gap:20px}.details span{color:var(--muted)}.details strong{font-size:11px;font-weight:500;text-align:right}.login{min-height:100vh;display:grid;place-items:center;padding:25px}.login-card{width:min(460px,100%);padding:42px;background:var(--surface);border-radius:10px;box-shadow:0 10px 40px #11132325}.login-card .logo{width:192px;margin-bottom:37px}.login-card .button{width:100%;margin-top:18px}.login-card p{font-size:13px}.login-card .muted{font-size:11px;margin-top:22px}
+@media(min-width:1600px){.workspace{padding:25px 40px}.editor{grid-template-columns:205px minmax(0,1fr) 300px}.editor-workspace{padding:20px 28px}}
+@media(max-width:1200px){.shell{grid-template-columns:195px minmax(0,1fr)}.sidebar .logo{width:160px}.workspace{padding:20px}.stats{gap:12px}.stat{padding:15px;gap:10px}.stat .icon-tile{width:34px;height:34px}.bot-meta{gap:12px}.editor{grid-template-columns:165px minmax(0,1fr) 240px;gap:10px}.executions-layout{grid-template-columns:280px minmax(0,1fr)}.template-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-width:950px){.shell{grid-template-columns:75px minmax(0,1fr)}.sidebar{padding:20px 10px}.brand{overflow:hidden;width:48px;padding:0;margin-bottom:30px}.sidebar .logo{width:170px;max-width:none}.sidebar>.overline,.nav span,.sidebar-bottom{display:none}.nav{justify-content:center;padding:13px}.workspace{padding:18px}.topbar{padding:12px 15px}.topbar-right select{min-width:135px;max-width:160px}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.bot-card{padding:17px}.bot-description{margin-left:0}.bot-meta{gap:10px}.bot-grid{gap:15px}.editor{grid-template-columns:135px minmax(0,1fr) 220px;gap:8px}.palette{padding:12px 8px}.palette-item{font-size:9px;gap:5px}.palette-item .icon:last-child{display:none}.canvas-toolbar #save-state{display:none}.executions-layout{grid-template-columns:1fr}.connection-grid{grid-template-columns:1fr}}
+@media(max-width:700px){.shell{display:block}.sidebar{position:relative;height:auto;padding:15px}.brand{width:auto;margin-bottom:17px}.sidebar .logo{width:150px}.sidebar nav{display:flex;overflow:auto;gap:5px}.nav{width:auto;padding:10px}.nav span{display:block;font-size:10px}.nav .icon{width:15px}.workspace{padding:15px}.breadcrumb{display:none}.topbar-right{width:100%;gap:9px}.topbar-right select{flex:1;max-width:none;min-width:0}.user-info,.divider{display:none}.page-heading{align-items:flex-start;flex-wrap:wrap;gap:15px}h1{font-size:23px}.bot-grid,.dashboard-bottom,.settings-grid{grid-template-columns:1fr}.editor{grid-template-columns:1fr;height:auto;min-height:0}.palette{max-height:170px;overflow:auto}.palette-group{display:inline-block;vertical-align:top;margin:0 10px;width:150px}.palette>input{display:none}.canvas-column{height:550px}.inspector{max-height:none}.inspector-body{display:grid;grid-template-columns:1fr 1fr;gap:10px}.inspector-body textarea{min-height:80px}.template-grid{grid-template-columns:1fr 1fr}.filterbar{flex-wrap:wrap}footer{font-size:9px}.execution-summary{gap:8px}.toast{right:15px;left:15px;bottom:15px;max-width:none}.connection-actions{flex-wrap:wrap}.variable-row,.table-heading{grid-template-columns:1fr 1fr 30px}.heading-actions{flex-wrap:wrap}.connection-row{grid-column:span 2}}
diff --git a/server.mjs b/server.mjs
index 32f07cd..3b64024 100644
--- a/server.mjs
+++ b/server.mjs
@@ -4,6 +4,7 @@ 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');
@@ -11,6 +12,7 @@ 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 || ''));
@@ -26,7 +28,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'; 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 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)); };
@@ -72,15 +74,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 (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.' });
}
- 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.' });
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', () => db.close());
+ server.on('close', () => { studio.close(); db.close(); });
return server;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
diff --git a/studio.mjs b/studio.mjs
new file mode 100644
index 0000000..d540376
--- /dev/null
+++ b/studio.mjs
@@ -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}`)));
+ });
+}
diff --git a/studio.test.mjs b/studio.test.mjs
new file mode 100644
index 0000000..429abc8
--- /dev/null
+++ b/studio.test.mjs
@@ -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});}
+});