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 = `
TS6 Botflow Local
`; - 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 utilizadoresSem 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çãoDiscord
Ligação TeamSpeakPor 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')=>``; +const heading=(title,subtitle,actions='')=>`

${title}

${subtitle}

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

O teu centro de bots.

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

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

Acesso reservado às contas autorizadas.

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

${esc(bot.name)}

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

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

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

Os teus bots

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

Atividade recente

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

O teu primeiro flow

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

`;wireBots();document.querySelector('#all-bots').onclick=()=>navigate('bots');document.querySelector('#all-runs').onclick=()=>navigate('executions');document.querySelector('#first-flow').onclick=()=>navigate('editor');} +function botsPage(){document.querySelector('#content').innerHTML=heading('Bots','Gere os teus bots do TeamSpeak num só lugar.',button('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])=>``).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',`

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

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.',`${owner()?'Rascunho':'Só leitura'}`)+`
${esc(bot.name)}Guardado
85%
`; + 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)}
${esc(type.group)}CONFIGURAÇÃO${type.fields.map(([key,label,input,values])=>``).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}
`).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 ``;} +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)}

`).join('')}
${e.error?`
${esc(e.error)}
`:''}${e.output?`

Texto produzido

${esc(e.output)}
`:''}`;} +function showRun(e){const dialog=document.querySelector('#dialog');dialog.innerHTML=`${runDetail(e)}`;dialog.showModal();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.',``)+`

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)=>`
`; +function variablesPage(){const bot=selectedBot();document.querySelector('#content').innerHTML=heading('Variáveis','Valores persistentes para reutilizar nos teus flows.',``)+`

Variáveis do bot

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

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

NomeValor
${Object.entries(bot?.variables||{}).map(([k,v])=>variableRow(k,v)).join('')}
`;const wire=()=>document.querySelectorAll('[data-remove-var]').forEach(b=>b.onclick=()=>b.closest('.variable-row').remove());wire();document.querySelector('#variables-bot').onchange=e=>{botId=e.target.value;variablesPage();};document.querySelector('#add-variable').onclick=()=>{document.querySelector('#variable-rows').insertAdjacentHTML('beforeend',variableRow('',''));wire();};document.querySelector('#variables-form').onsubmit=async e=>{e.preventDefault();if(!bot)return;try{const variables=Object.create(null);document.querySelectorAll('.variable-row').forEach(row=>{const key=row.querySelector('[data-key]').value.trim();if(!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)||Object.hasOwn(variables,key))throw new Error('Usa nomes únicos com letras, números e underscore.');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)}

`).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',`

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çãoDiscord
Permissões${owner()?'Configurar, guardar e executar':'Consultar bots, flows e execuções'}

Ambiente

PainelTS6 Botflow 0.2
DadosVolume Docker persistente
Novas contasApenas IDs autorizados

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

`;} +boot().catch(e=>{app.innerHTML=`
`;});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});} +});