Compare commits
2 Commits
e9f1a90592
...
e2c0e703ea
| Author | SHA1 | Date | |
|---|---|---|---|
| e2c0e703ea | |||
| 1a8334f705 |
@ -1,11 +1,11 @@
|
|||||||
FROM node:22-bookworm-slim AS base
|
FROM node:22-bookworm-slim AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --chown=node:node package.json server.mjs ./
|
COPY --chown=node:node package.json server.mjs studio.mjs ./
|
||||||
COPY --chown=node:node public ./public
|
COPY --chown=node:node public ./public
|
||||||
RUN mkdir /app/data && chown node:node /app/data
|
RUN mkdir /app/data && chown node:node /app/data
|
||||||
|
|
||||||
FROM base AS test
|
FROM base AS test
|
||||||
COPY --chown=node:node auth.test.mjs ./
|
COPY --chown=node:node auth.test.mjs studio.test.mjs ./
|
||||||
USER node
|
USER node
|
||||||
RUN npm test
|
RUN npm test
|
||||||
|
|
||||||
|
|||||||
51
README.md
51
README.md
@ -1,28 +1,51 @@
|
|||||||
# TS6 Botflow
|
# TS6 Botflow
|
||||||
|
|
||||||
Primeira experiência local: login Discord, conta do dono, sessão persistente e painel XanaX. Logótipos copiados sem alterações do projeto existente. As pastas de origem permanecem intactas.
|
Painel local XanaX para gerir bots TeamSpeak, com login Discord e editor visual de flows. Mantém os logótipos originais e o tema flat roxo. As pastas dos projetos de origem permanecem intactas.
|
||||||
|
|
||||||
O editor de flows e o motor de bots ainda não estão implementados nesta versão. Nenhuma ligação ao TeamSpeak é feita.
|
## Funcionalidades
|
||||||
|
|
||||||
|
- Dashboard com bots, flows e execuções guardados na base de dados.
|
||||||
|
- Editor com blocos arrastáveis, ligações, condições, propriedades, zoom e gravação.
|
||||||
|
- Blocos: temporizador, início manual, informações do servidor, clientes, ordenação, limite, preparação de texto, edição da descrição de canal, mensagem, espera e registo.
|
||||||
|
- Simulação identificada com dados de exemplo, sem enviar pedidos ao TeamSpeak.
|
||||||
|
- Execução manual ou por temporizador, com histórico e resultado de cada passo. Um bot não executa duas vezes em simultâneo.
|
||||||
|
- Ligações WebQuery, teste de ligação e API keys cifradas em repouso.
|
||||||
|
- Variáveis por bot: `{{variables.nome}}`. Outros valores: `{{server.virtualserver_clientsonline}}`, `{{count}}`, `{{top}}`, `{{text}}` e `{{time}}`.
|
||||||
|
|
||||||
|
O primeiro arranque cria um rascunho **Estatísticas**, desativado, para atualizar a descrição do canal **21** a cada 30 segundos. O top inicial ordena as pessoas atualmente online pela data de entrada; não é um ranking histórico de tempo acumulado.
|
||||||
|
|
||||||
|
Esta versão não inclui reprodução de música, eventos SSH, webhooks, edição de permissões nem gestão de perfis pelo painel. Cada bot tem um flow. O editor disponibiliza os blocos implementados acima.
|
||||||
|
|
||||||
## Docker / Portainer
|
## Docker / Portainer
|
||||||
|
|
||||||
Para **Web editor com imagem construida no Gitea**, seguir [deploy/README.md](deploy/README.md). O workflow `.gitea/workflows/container.yaml` testa e publica a imagem a cada push em main. As stacks prontas estao em `deploy/runner-stack.yaml` e `deploy/bot-stack.yaml`.
|
Para Web editor com imagem construída no Gitea, consultar [deploy/README.md](deploy/README.md) e usar [deploy/bot-stack.yaml](deploy/bot-stack.yaml). Cada push em `main` executa os testes e publica a imagem. Depois do workflow verde, atualizar a stack no Portainer com download da imagem e recriação do container. Restart sozinho não descarrega a nova imagem.
|
||||||
|
|
||||||
Para construcao local por Docker Compose, usar `compose.yaml` a partir deste repositório. O serviço é construído pelo Dockerfile, porta 3080, volume persistente `botflow-data`.
|
Para construir localmente com Docker Compose, usar `compose.yaml`. A aplicação escuta na porta 3080 e preserva os dados num volume montado em `/app/data`.
|
||||||
|
|
||||||
Configurar as quatro variáveis nas Environment variables da stack (ou copiar `.env.example` para `.env` quando executado por Docker Compose):
|
Variáveis de ambiente:
|
||||||
|
|
||||||
- `APP_URL`: endereço usado no navegador, por exemplo `http://192.168.88.99:3080`.
|
- `APP_URL`: endereço do navegador, por exemplo `http://192.168.88.99:3080`.
|
||||||
- `DISCORD_CLIENT_ID`: ID da aplicação Discord.
|
- `DISCORD_CLIENT_ID`: ID da aplicação Discord.
|
||||||
- `DISCORD_CLIENT_SECRET`: segredo OAuth da aplicação, nunca o token de um bot.
|
- `DISCORD_CLIENT_SECRET`: segredo OAuth da aplicação.
|
||||||
- `OWNER_DISCORD_ID`: ID **numérico** da tua conta Discord, não o nome `bxio`.
|
- `OWNER_DISCORD_ID`: ID numérico da conta do dono.
|
||||||
|
- `ALLOWED_DISCORD_IDS`: IDs de leitores separados por vírgulas. Por defeito, `723613831196311603`; valor vazio remove todos os leitores.
|
||||||
|
|
||||||
No Discord Developer Portal, OAuth2 → Redirects, adicionar exatamente `http://192.168.88.99:3080/auth/discord/callback` (ajustar se APP_URL mudar). É possível aproveitar a aplicação Discord existente adicionando esse redirect; manter os redirects antigos.
|
No Discord Developer Portal, adicionar o redirect exato `http://192.168.88.99:3080/auth/discord/callback`, ajustando ao `APP_URL`. O navegador e o container precisam de Internet para o OAuth; o site pode continuar apenas na rede local.
|
||||||
|
|
||||||
Para copiar o ID: Discord → Definições → Avançadas → Modo de programador; clicar com o botão direito na tua conta → Copiar ID do utilizador.
|
O **Dono** pode configurar, guardar, simular e executar. O **Leitor** pode consultar. Outras contas são recusadas; as permissões são também verificadas no servidor.
|
||||||
|
|
||||||
O navegador e o container precisam de Internet para o OAuth Discord. O painel não precisa de estar publicado na Internet. A rede local precisa de conseguir aceder à porta 3080. A conta OWNER_DISCORD_ID entra como Dono. O ID 723613831196311603 também está autorizado como Leitor por defeito. ALLOWED_DISCORD_IDS permite substituir a lista de leitores (IDs separados por vírgulas; valor vazio remove todos). Outras contas são recusadas. A gestão de utilizadores pelo painel ainda não está implementada.
|
## Ligar ao TeamSpeak
|
||||||
|
|
||||||
Local: Node 22.15+, definir as mesmas variáveis e executar `npm start`. Testes: `npm test`.
|
Entrar com a conta do dono. Em **Ligações**, configurar o endereço WebQuery HTTP/HTTPS, o ID do servidor virtual e a API key TeamSpeak. Por exemplo, `http://teamspeak6:10080` funciona quando ambos os containers partilham uma rede Docker com esse nome resolvível. Testar a ligação, escolher esse servidor na configuração do bot, simular e só depois ativar.
|
||||||
|
|
||||||
Sessões HttpOnly/SameSite, estado OAuth de uso único, credenciais no ambiente e armazenamento SQLite no volume. Em HTTP local os cookies não têm Secure; usar HTTPS quando houver publicação. Não adicionar `.env` nem dados ao Git.
|
O temporizador aceita intervalos a partir de um segundo. A execução seguinte aguarda a conclusão da anterior; pedidos de rede e blocos de espera podem prolongar o intervalo efetivo. A configuração de permissões e proteção contra flood do TeamSpeak deve permitir os pedidos do bot.
|
||||||
|
|
||||||
|
Sessões e flows usam SQLite no volume. A chave de cifra das API keys fica em `/app/data/credentials.key`; preservar o volume completo nas cópias de segurança. O histórico retém as últimas 500 execuções. Não adicionar `.env` nem dados ao Git.
|
||||||
|
|
||||||
|
## Desenvolvimento e validação
|
||||||
|
|
||||||
|
Node 22.15+, mesmas variáveis de ambiente e `npm start`. Executar `npm test` para verificar autenticação, restrições de acesso, persistência, simulação e execução WebQuery com transporte de teste. O build Docker executa os testes antes da publicação.
|
||||||
|
|
||||||
|
O editor foi verificado em navegador desktop e móvel. A execução no TeamSpeak real depende da ligação e API key configuradas na instalação; os testes locais não alteram o servidor real.
|
||||||
|
|
||||||
|
A fonte Public Sans é distribuída em `public/fonts` com a licença OFL. Os logótipos foram copiados sem alterações do projeto existente.
|
||||||
|
|||||||
@ -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"}}
|
||||||
|
|||||||
@ -1,33 +1,50 @@
|
|||||||
const app=document.querySelector('#app');
|
const app=document.querySelector('#app');
|
||||||
const errors = { denied: 'Esta conta não está autorizada a entrar no painel.', authentication: 'O login expirou ou foi cancelado. Tenta novamente.', configuration: 'Falta configurar o Discord e o ID numérico do dono no Docker.' };
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
async function boot() {
|
const paths={dashboard:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',bot:'M9 3h6 M12 3v3 M5 8h14v12H5z M8 12v3 M16 12v3 M2 11v6 M22 11v6',flow:'M4 3h6v6H4z M14 15h6v6h-6z M7 9v9h7 M10 6h7v9',play:'M8 5l12 7-12 7z',clock:'M12 8v5l3 2 M12 3a9 9 0 1 0 0 18 9 9 0 1 0 0-18',users:'M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3 M9 3a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M18 4a4 4 0 0 1 0 7 M22 21v-3a4 4 0 0 0-3-4',link:'M10 14l4-4 M8 16l-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0 M16 8l1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0',settings:'M12 8a4 4 0 1 0 0 8 4 4 0 1 0 0-8 M12 2v3 M12 19v3 M2 12h3 M19 12h3 M5 5l2 2 M17 17l2 2 M5 19l2-2 M17 7l2-2',variable:'M8 3H5v18h3 M16 3h3v18h-3 M10 9l4 6 M14 9l-4 6',plus:'M12 5v14 M5 12h14',save:'M4 3h14l3 3v15H3V3z M7 3v6h10V3 M7 21v-8h10v8',refresh:'M20 7a9 9 0 1 0 1 6 M20 2v5h-5',check:'M5 12l4 4L19 6',close:'M6 6l12 12 M18 6L6 18',shield:'M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6',music:'M9 18V5l11-2v13 M9 18a3 3 0 1 1-6 0 3 3 0 1 1 6 0 M20 16a3 3 0 1 1-6 0 3 3 0 1 1 6 0',trash:'M3 6h18 M8 6V3h8v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7',data:'M3 5c0-4 18-4 18 0s-18 4-18 0z M3 5v14c0 4 18 4 18 0V5 M3 12c0 4 18 4 18 0',condition:'M12 2l9 10-9 10-9-10z',text:'M4 5h16 M12 5v15 M8 20h8',sort:'M4 5h16 M4 12h12 M4 19h8',channel:'M8 3l-2 18 M18 3l-2 18 M3 8h18 M2 16h18',expand:'M8 3H3v5 M16 3h5v5 M3 16v5h5 M21 16v5h-5'};
|
||||||
const response = await fetch('/api/me');
|
const icon=n=>`<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[n]||paths.bot}"/></svg>`;
|
||||||
if (!response.ok) throw new Error();
|
const nodeIcon=t=>({timer:'clock',manual:'play',serverinfo:'data',clients:'users',condition:'condition',sort:'sort',limit:'dashboard',template:'text',channelEdit:'channel',message:'text',delay:'clock',log:'text'}[t]);
|
||||||
const { user, configured } = await response.json();
|
let user,data,page='dashboard',botId,draft,selectedNode,zoom=.85,pending,dirty=false;
|
||||||
if (!user) {
|
const owner=()=>user?.role==='Dono', dis=()=>owner()?'':'disabled title="O perfil Leitor permite consultar"';
|
||||||
app.innerHTML = `<section class="login"><div class="login-card"><img class="logo" src="/logo.png" alt="XanaX"><h1>O teu centro de bots.</h1><p>Configura, acompanha e organiza as automações do teu TeamSpeak num só lugar.</p><div id="notice"></div><a class="button" href="/auth/discord">Entrar com Discord →</a><p class="muted">Acesso reservado · TS6 Botflow</p></div></section>`;
|
const selectedBot=()=>data.bots.find(b=>b.id===botId);
|
||||||
const message = errors[new URLSearchParams(location.search).get('error')] || (!configured && errors.configuration);
|
async function api(path,options={}){const r=await fetch(path,{...options,headers:{'Content-Type':'application/json'}});const body=await r.json();if(!r.ok)throw new Error(body.error||'O pedido falhou.');return body;}
|
||||||
if (message) { const notice = document.querySelector('#notice'); notice.className = 'notice'; notice.textContent = message; }
|
function toast(text,error=false){document.querySelector('#toast')?.remove();const el=document.createElement('div');el.id='toast';el.className=`toast ${error?'error':''}`;el.setAttribute('role','status');el.textContent=text;document.body.append(el);setTimeout(()=>el.remove(),6000);}
|
||||||
return;
|
async function refresh(){data=await api('/api/dashboard');if(!data.bots.some(b=>b.id===botId))botId=data.bots[0]?.id;}
|
||||||
}
|
const nav=[['dashboard','dashboard','Dashboard'],['bots','bot','Bots'],['editor','flow','Editor de flows'],['executions','clock','Execuções'],['variables','variable','Variáveis'],['connections','link','Ligações'],['settings','settings','Definições']];
|
||||||
const data = await fetch('/api/dashboard'); if (!data.ok) throw new Error();
|
const empty=(i,title,text)=>`<div class="empty"><span class="icon-tile purple">${icon(i)}</span><h3>${title}</h3><p>${text}</p></div>`;
|
||||||
const dashboard = await data.json();
|
const button=(id,text,i='plus',style='primary')=>`<button id="${id}" class="button ${style}" ${dis()}>${icon(i)}${text}</button>`;
|
||||||
app.innerHTML = `<div class="shell"><aside class="sidebar"><img class="logo" src="/logo.png" alt="XanaX"><div class="eyebrow">Botflow</div><nav><button class="nav active" data-page="dashboard">◈ Dashboard</button><button class="nav" data-page="bots">◇ Bots e flows</button><button class="nav" data-page="access">♙ Acesso</button></nav></aside><section class="workspace"><header class="topbar"><span>TS6 Botflow <span class="badge">Local</span></span><span><span id="username"></span> <button class="quiet" id="logout">Sair</button></span></header><div id="content"></div><footer class="footer">XanaX · O teu centro de automações</footer></section></div>`;
|
const heading=(title,subtitle,actions='')=>`<div class="page-heading"><div><h1>${title}</h1><p>${subtitle}</p></div><div class="heading-actions">${actions}</div></div>`;
|
||||||
document.querySelector('#username').textContent = user.name;
|
const ago=d=>!d?'Sem execuções':Math.floor((Date.now()-Date.parse(d))/60000)<1?'Agora':`Há ${Math.floor((Date.now()-Date.parse(d))/60000)} min`;
|
||||||
const content = document.querySelector('#content');
|
async function boot(){const me=await api('/api/me');user=me.user;if(!user){app.innerHTML=`<section class="login"><div class="login-card"><img class="logo" src="/logo.png" alt="XanaX"><span class="overline">TS6 BOTFLOW</span><h1>O teu centro de bots.</h1><p>Cria flows, gere bots e acompanha as tuas automações num só lugar.</p><div id="notice"></div><a class="button primary" href="/auth/discord">Entrar com Discord ${icon('play')}</a><p class="muted">Acesso reservado às contas autorizadas.</p></div></section>`;const messages={denied:'Esta conta não está autorizada.',authentication:'O login expirou ou foi cancelado.',configuration:'Configura a aplicação Discord no Docker.'};const msg=messages[new URLSearchParams(location.search).get('error')]||(!me.configured&&messages.configuration);if(msg){const el=document.querySelector('#notice');el.className='notice';el.textContent=msg;}return;}
|
||||||
function page(name) {
|
await refresh();app.innerHTML=`<div class="shell"><aside class="sidebar"><a href="#dashboard" class="brand"><img class="logo" src="/logo.png" alt="XanaX"></a><div class="overline">AUTOMAÇÃO</div><nav>${nav.map(([id,i,label])=>`<button class="nav" data-page="${id}">${icon(i)}<span>${label}</span></button>`).join('')}</nav><div class="sidebar-bottom"><span class="status-dot"></span><span>TS6 Botflow<small>Ambiente local</small></span><small>0.2</small></div></aside><div class="workspace"><header class="topbar"><div class="breadcrumb">Workspace <span>/</span><strong id="page-label"></strong></div><div class="topbar-right"><select id="server-select" aria-label="Servidor">${data.connections.length?data.connections.map(c=>`<option>${esc(c.name)}</option>`).join(''):'<option>Sem servidor ligado</option>'}</select><span class="divider"></span><div class="avatar">${esc(user.name.slice(0,1).toUpperCase())}</div><div class="user-info"><strong>${esc(user.name)}</strong><small>${esc(user.role)}</small></div><button class="icon-button" id="logout" title="Terminar sessão">${icon('close')}</button></div></header><main id="content"></main><footer><span>XanaX · TS6 Botflow</span><span>O teu centro de automações</span></footer></div></div><dialog id="dialog"></dialog>`;
|
||||||
document.querySelectorAll('.nav').forEach(b => b.classList.toggle('active', b.dataset.page === name));
|
document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));document.querySelector('.brand').onclick=e=>{e.preventDefault();navigate('dashboard');};document.querySelector('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location.assign('/');};document.querySelector('#server-select').onchange=()=>navigate('connections');navigate(location.hash.slice(1));}
|
||||||
if (name === 'access') {
|
function navigate(name){if(dirty&&!confirm('Sair sem guardar as alterações do flow?'))return;dirty=false;pending=null;page=nav.some(([id])=>id===name)?name:'dashboard';location.hash=page;document.querySelectorAll('.nav').forEach(b=>b.classList.toggle('active',b.dataset.page===page));document.querySelector('#page-label').textContent=nav.find(([id])=>id===page)[2];document.querySelector('.workspace').classList.toggle('editor-workspace',page==='editor');({dashboard:dashboardPage,bots:botsPage,editor:editorPage,executions:executionsPage,variables:variablesPage,connections:connectionsPage,settings:settingsPage}[page])();wireExecutions();}
|
||||||
content.innerHTML = '<h1 class="title">Acesso ao painel</h1><p>A tua conta está autorizada a entrar no painel.</p><div class="card details"><div><span>Perfil</span><strong id="profile-role"></strong></div><div><span>ID Discord</span><strong id="discord-id"></strong></div><div><span>Novos utilizadores</span><strong>Sem acesso</strong></div></div>';
|
function botCard(bot){const runs=data.executions.filter(e=>e.botId===bot.id);return `<article class="card bot-card"><div class="bot-card-top"><span class="icon-tile ${esc(bot.color)}">${icon(bot.color==='red'?'shield':bot.color==='orange'?'music':bot.color==='cyan'?'users':'bot')}</span><div class="bot-card-title"><h2>${esc(bot.name)}</h2><span class="badge ${bot.enabled?'green':'gray'}">${bot.enabled?'Ativo':'Rascunho'}</span></div><button class="switch ${bot.enabled?'on':''}" data-toggle="${bot.id}" aria-label="Ativar ou desativar ${esc(bot.name)}" aria-pressed="${bot.enabled}" ${dis()}></button></div><p class="bot-description">${esc(bot.description||'Personaliza o flow deste bot.')}</p><div class="bot-meta"><span>${icon('flow')}1 flow</span><span>${icon('play')}${runs.length} execuções</span><span>${icon('clock')}${ago(runs[0]?.startedAt)}</span></div><div class="bot-actions"><button class="button secondary" data-open="${bot.id}">${icon('flow')}Abrir flow</button><button class="icon-button bordered" data-config="${bot.id}" aria-label="Configurar bot">${icon('settings')}</button></div></article>`;}
|
||||||
document.querySelector('#discord-id').textContent = user.id;
|
function wireBots(){document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>{botId=b.dataset.open;navigate('editor');});document.querySelectorAll('[data-config]').forEach(b=>b.onclick=()=>configureBot(b.dataset.config));const create=document.querySelector('#create-bot');if(create)create.onclick=()=>newBot();document.querySelectorAll('[data-toggle]').forEach(b=>b.onclick=async()=>{try{const bot=data.bots.find(bot=>bot.id===b.dataset.toggle);await api(`/api/bots/${bot.id}`,{method:'PUT',body:JSON.stringify({enabled:!bot.enabled})});await refresh();navigate(page);toast('Estado atualizado.');}catch(e){toast(e.message,true);}});}
|
||||||
document.querySelector('#profile-role').textContent = user.role; return;
|
function dashboardPage(){const today=new Date().toLocaleDateString('pt-PT',{timeZone:'Europe/Lisbon'});document.querySelector('#content').innerHTML=heading('Dashboard','Uma visão geral dos teus bots e automações.',button('create-bot','Criar bot'))+`<div class="stats">${[['Bots ativos',data.bots.filter(b=>b.enabled).length,'bot','green'],['Execuções hoje',data.executions.filter(e=>new Date(e.startedAt).toLocaleDateString('pt-PT',{timeZone:'Europe/Lisbon'})===today).length,'play','cyan'],['Flows guardados',data.bots.length,'flow','purple'],['Servidores configurados',data.connections.length,'link','orange']].map(([label,n,i,c])=>`<div class="card stat"><span class="icon-tile ${c}">${icon(i)}</span><div><span>${label}</span><strong>${n}</strong></div></div>`).join('')}</div><div class="section-heading"><h2>Os teus bots</h2><button class="text-button" id="all-bots">Ver todos →</button></div><div class="bot-grid">${data.bots.map(botCard).join('')}</div><div class="dashboard-bottom"><section class="card"><div class="section-heading"><h2>Atividade recente</h2><button class="text-button" id="all-runs">Ver execuções →</button></div>${data.executions.slice(0,4).map(runRow).join('')||empty('clock','Ainda sem execuções','Simula um flow para acompanhar aqui cada passo.')}</section><section class="card quick-start"><span class="icon-tile purple">${icon('flow')}</span><h2>O teu primeiro flow</h2><p>O rascunho Estatísticas prepara a descrição do canal 21. Escolhe a ligação e simula antes de ativar.</p><button class="button secondary" id="first-flow">${icon('flow')}Abrir editor</button></section></div>`;wireBots();document.querySelector('#all-bots').onclick=()=>navigate('bots');document.querySelector('#all-runs').onclick=()=>navigate('executions');document.querySelector('#first-flow').onclick=()=>navigate('editor');}
|
||||||
}
|
function botsPage(){document.querySelector('#content').innerHTML=heading('Bots','Gere os teus bots do TeamSpeak num só lugar.',button('create-bot','Criar bot'))+`<div class="filterbar"><input id="search-bots" placeholder="Pesquisar bots…" aria-label="Pesquisar bots"><span class="muted">${data.bots.length} bots · ${data.bots.filter(b=>b.enabled).length} ativos</span></div><div class="bot-grid" id="bot-list">${data.bots.map(botCard).join('')}</div><div class="section-heading templates-heading"><h2>Começar um novo bot</h2><span class="muted">Rascunhos para personalizar no editor</span></div><div class="template-grid">${[['Estatísticas','green','bot'],['Personalizado','purple','flow']].map(([name,c,i])=>`<button class="template-card" data-template="${name}" ${dis()}><span class="icon-tile ${c}">${icon(i)}</span><strong>${name}</strong><p>Cria um rascunho e configura os seus blocos.</p><span>Criar bot →</span></button>`).join('')}</div>`;wireBots();document.querySelector('#search-bots').oninput=e=>{document.querySelector('#bot-list').innerHTML=data.bots.filter(b=>b.name.toLowerCase().includes(e.target.value.toLowerCase())).map(botCard).join('');wireBots();};document.querySelectorAll('[data-template]').forEach(b=>b.onclick=()=>newBot(b.dataset.template));}
|
||||||
if (name === 'bots') { content.innerHTML = '<h1 class="title">Bots e flows</h1><p>A primeira experiência valida o painel e o acesso.</p><div class="card empty"><span class="empty-symbol">◇</span>Ainda não existem bots.<p>O editor de flows e a ligação ao TeamSpeak serão a próxima etapa.</p></div>'; return; }
|
function modal(title,body,submit){const dialog=document.querySelector('#dialog');dialog.innerHTML=`<form id="modal-form"><div class="modal-heading"><h2>${title}</h2><button class="icon-button" type="button" id="close-dialog" aria-label="Fechar">${icon('close')}</button></div>${body}<div class="modal-actions"><button type="button" class="button secondary" id="cancel">Cancelar</button><button class="button primary" ${dis()}>${icon('save')}Guardar</button></div></form>`;dialog.showModal();document.querySelector('#close-dialog').onclick=document.querySelector('#cancel').onclick=()=>dialog.close();document.querySelector('#modal-form').onsubmit=async e=>{e.preventDefault();try{await submit(new FormData(e.target));dialog.close();await refresh();dirty=false;navigate(page);toast('Guardado.');}catch(error){toast(error.message,true);}};}
|
||||||
content.innerHTML = `<h1 class="title">Dashboard</h1><p>Bem-vindo ao teu centro de automações.</p><div class="stats">${[['Bots',dashboard.bots.length],['Flows',dashboard.flows.length],['Execuções',dashboard.executions.length],['Servidores',dashboard.connections.length]].map(([label,value])=>`<div class="card"><span>${label}</span><strong class="stat-value">${value}</strong></div>`).join('')}</div><div class="split"><div class="card"><h2>Atividade recente</h2><div class="empty"><span class="empty-symbol">◷</span>Ainda sem execuções.<p>A atividade dos bots aparecerá aqui.</p></div></div><div class="card"><h2>O teu espaço</h2><div class="details"><div><span>Acesso</span><span class="badge" id="dashboard-role"></span></div><div><span>Autenticação</span><strong>Discord</strong></div><div><span>Ligação TeamSpeak</span><strong>Por configurar</strong></div></div><p>Começamos por testar o login. Depois ligamos os bots e construímos os flows.</p></div></div>`;
|
function newBot(name='Novo bot'){modal('Criar bot',`<label>Nome<input name="name" value="${esc(name)}" required maxlength="80"></label><label>Descrição<textarea name="description"></textarea></label><label>Cor<select name="color">${['purple','green','red','cyan','orange'].map(c=>`<option>${c}</option>`).join('')}</select></label><p class="form-help">O bot começa desativado. Usa o flow de estatísticas como base ou substitui os blocos.</p>`,async form=>{const bot=await api('/api/bots',{method:'POST',body:JSON.stringify(Object.fromEntries(form))});botId=bot.id;page='editor';});}
|
||||||
document.querySelector('#dashboard-role').textContent = user.role;
|
function configureBot(id){botId=id;const bot=selectedBot();modal('Configuração do bot',`<div class="config-tabs"><span class="active">Geral</span><button type="button" id="config-vars">Variáveis</button></div><label>Nome<input name="name" value="${esc(bot.name)}" required></label><label>Descrição<textarea name="description">${esc(bot.description)}</textarea></label><label>Servidor<select name="connectionId"><option value="">Escolher ligação</option>${data.connections.map(c=>`<option value="${c.id}" ${bot.connectionId===c.id?'selected':''}>${esc(c.name)}</option>`).join('')}</select></label><p class="form-help">Escolhe uma ligação para executar o flow no TeamSpeak.</p>`,f=>api(`/api/bots/${bot.id}`,{method:'PUT',body:JSON.stringify(Object.fromEntries(f))}));document.querySelector('#config-vars').onclick=()=>{document.querySelector('#dialog').close();navigate('variables');};}
|
||||||
}
|
function editorPage(){const bot=selectedBot();if(!bot){document.querySelector('#content').innerHTML=heading('Editor de flows','Cria um bot para começar.',button('create-bot','Criar bot'))+empty('flow','Ainda sem flows','Cria o teu primeiro bot.');wireBots();return;}draft=structuredClone(bot);selectedNode=draft.graph.nodes[0]?.id;zoom=.85;const groups=[...new Set(Object.values(data.nodeTypes).map(t=>t.group))];document.querySelector('#content').innerHTML=heading('Editor de flows','Cria e edita a lógica dos teus bots com um editor visual.',`<select id="bot-select" aria-label="Bot">${data.bots.map(b=>`<option value="${b.id}" ${b.id===botId?'selected':''}>${esc(b.name)}</option>`).join('')}</select><span class="badge gray">${owner()?'Rascunho':'Só leitura'}</span>`)+`<div class="editor" id="editor"><aside class="palette"><div class="panel-heading">Blocos ${icon('plus')}</div><input id="search-block" placeholder="Pesquisar blocos…" aria-label="Pesquisar blocos">${groups.map(g=>`<div class="palette-group"><span class="overline">${g}</span>${Object.entries(data.nodeTypes).filter(([,t])=>t.group===g).map(([id,t])=>`<button class="palette-item ${t.color}" data-add="${id}" ${dis()}>${icon(nodeIcon(id))}<span>${esc(t.title)}</span>${icon('plus')}</button>`).join('')}</div>`).join('')}</aside><div class="canvas-column"><div class="canvas-toolbar"><div><strong>${esc(bot.name)}</strong><span id="save-state">Guardado</span></div><div><button class="icon-button" id="zoom-out" title="Reduzir zoom">−</button><span id="zoom-label">85%</span><button class="icon-button" id="zoom-in" title="Aumentar zoom">+</button><button class="icon-button" id="fullscreen" title="Ecrã inteiro">${icon('expand')}</button></div></div><div class="canvas" id="canvas"><div class="world" id="world"><svg id="edges"></svg><div id="nodes"></div></div></div><div class="canvas-footer"><span id="graph-count"></span><span id="canvas-hint">${owner()?'Liga as portas dos blocos.':'Flow em modo de consulta.'}</span></div></div><aside class="inspector"><div id="node-properties"></div><div class="inspector-actions"><button class="button secondary" id="simulate" ${dis()}>${icon('play')}Simular</button><button class="button primary" id="save-flow" ${dis()}>${icon('save')}Guardar</button><button class="text-button" id="run" ${dis()}>Executar no TeamSpeak →</button></div></aside></div>`;
|
||||||
document.querySelectorAll('.nav').forEach(b => b.onclick = () => page(b.dataset.page));
|
document.querySelector('#bot-select').onchange=e=>{if(dirty&&!confirm('Sair sem guardar?')){e.target.value=botId;return;}botId=e.target.value;dirty=false;editorPage();};document.querySelectorAll('[data-add]').forEach(b=>b.onclick=()=>addNode(b.dataset.add));document.querySelector('#search-block').oninput=e=>document.querySelectorAll('.palette-item').forEach(b=>b.hidden=!b.textContent.toLowerCase().includes(e.target.value.toLowerCase()));document.querySelector('#zoom-in').onclick=()=>{zoom=Math.min(1.5,zoom+.1);setZoom();};document.querySelector('#zoom-out').onclick=()=>{zoom=Math.max(.4,zoom-.1);setZoom();};document.querySelector('#fullscreen').onclick=()=>document.fullscreenElement?document.exitFullscreen():document.querySelector('#editor').requestFullscreen().catch(()=>toast('Ecrã inteiro indisponível.',true));document.querySelector('#save-flow').onclick=saveFlow;document.querySelector('#simulate').onclick=()=>execute(true);document.querySelector('#run').onclick=()=>execute(false);renderGraph();}
|
||||||
document.querySelector('#logout').onclick = async () => { const r = await fetch('/auth/logout', { method: 'POST' }); if (r.ok) location.assign('/'); };
|
function markDirty(){dirty=true;const el=document.querySelector('#save-state');el.textContent='Por guardar';el.className='unsaved';}
|
||||||
page('dashboard');
|
function setZoom(){document.querySelector('#world').style.transform=`scale(${zoom})`;document.querySelector('#zoom-label').textContent=`${Math.round(zoom*100)}%`;}
|
||||||
}
|
function addNode(type){const configs={timer:{seconds:30},condition:{field:'count',operator:'greater',value:'0'},sort:{field:'client_lastconnected',direction:'asc'},limit:{count:5},template:{text:'{{top}}'},channelEdit:{channelId:21,description:'{{text}}'},message:{target:21,mode:'channel',text:'Olá!'},delay:{seconds:1},log:{text:'Executado às {{time}}'}};const node={id:`n${Date.now()}${Math.floor(Math.random()*10000)}`,type,label:data.nodeTypes[type].title,x:200,y:60+draft.graph.nodes.length*100,config:configs[type]||{}};draft.graph.nodes.push(node);selectedNode=node.id;markDirty();renderGraph();}
|
||||||
boot().catch(() => { app.textContent = 'Não foi possível carregar o painel. Atualiza a página para tentar novamente.'; });
|
function renderGraph(){const nodes=draft.graph.nodes;document.querySelector('#nodes').innerHTML=nodes.map(n=>`<div class="flow-node ${data.nodeTypes[n.type].color} ${n.id===selectedNode?'selected':''}" data-node="${esc(n.id)}" style="left:${n.x}px;top:${n.y}px"><button class="port input" data-input="${esc(n.id)}" aria-label="Entrada de ${esc(n.label)}" ${dis()}></button><div class="node-head">${icon(nodeIcon(n.type))}<strong>${esc(n.label)}</strong></div><div class="node-subtitle">${esc(n.type==='timer'?`A cada ${n.config.seconds}s`:data.nodeTypes[n.type].title)}</div>${(n.type==='condition'?['true','false']:['out']).map((p,i)=>`<button class="port output ${p}" data-output="${esc(n.id)}" data-port="${p}" style="top:${n.type==='condition'?i?54:28:38}px" aria-label="Saída ${p}" ${dis()}></button>`).join('')}</div>`).join('');document.querySelector('#world').style.width=`${Math.max(1000,...nodes.map(n=>n.x+350))}px`;document.querySelector('#world').style.height=`${Math.max(950,...nodes.map(n=>n.y+160))}px`;renderEdges();setZoom();inspector();document.querySelector('#graph-count').textContent=`${nodes.length} blocos · ${draft.graph.edges.length} ligações`;
|
||||||
|
document.querySelectorAll('[data-node]').forEach(el=>el.onpointerdown=e=>{if(e.target.closest('button'))return;const n=nodes.find(n=>n.id===el.dataset.node);selectedNode=n.id;document.querySelectorAll('.flow-node').forEach(b=>b.classList.toggle('selected',b===el));inspector();if(!owner())return;const sx=e.clientX,sy=e.clientY,x=n.x,y=n.y;el.setPointerCapture(e.pointerId);let moved=false;el.onpointermove=ev=>{const dx=(ev.clientX-sx)/zoom,dy=(ev.clientY-sy)/zoom;if(Math.abs(dx)+Math.abs(dy)>3)moved=true;n.x=Math.max(20,Math.round(x+dx));n.y=Math.max(20,Math.round(y+dy));el.style.left=`${n.x}px`;el.style.top=`${n.y}px`;renderEdges();};el.onpointerup=()=>{el.onpointermove=null;el.onpointerup=null;if(moved){markDirty();renderGraph();}};});
|
||||||
|
document.querySelectorAll('[data-output]').forEach(b=>b.onclick=()=>{pending={from:b.dataset.output,port:b.dataset.port};document.querySelector('#canvas-hint').textContent='Clica na entrada do bloco de destino.';});document.querySelectorAll('[data-input]').forEach(b=>b.onclick=()=>{if(!pending||pending.from===b.dataset.input)return;const edge={...pending,to:b.dataset.input};if(!draft.graph.edges.some(e=>e.from===edge.from&&e.to===edge.to&&e.port===edge.port)){draft.graph.edges.push(edge);markDirty();}pending=null;renderGraph();document.querySelector('#canvas-hint').textContent='Ligação criada.';});}
|
||||||
|
function renderEdges(){document.querySelector('#edges').innerHTML=`<defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M0 0L10 5L0 10" fill="#9290b8"/></marker></defs>`+draft.graph.edges.map(e=>{const a=draft.graph.nodes.find(n=>n.id===e.from),b=draft.graph.nodes.find(n=>n.id===e.to);if(!a||!b)return '';const x=a.x+230,y=a.y+(e.port==='true'?28:e.port==='false'?54:38),x2=b.x,y2=b.y+38;return `<path class="edge ${e.port}" d="M${x} ${y} C${x+80} ${y},${x2-80} ${y2},${x2} ${y2}" marker-end="url(#arrow)"/>`;}).join('');}
|
||||||
|
function inspector(){const n=draft.graph.nodes.find(n=>n.id===selectedNode),el=document.querySelector('#node-properties');if(!n){el.innerHTML=empty('flow','Seleciona um bloco','Clica num bloco para editar.');return;}const type=data.nodeTypes[n.type],edges=draft.graph.edges.map((e,i)=>({e,i})).filter(({e})=>e.from===n.id||e.to===n.id);el.innerHTML=`<div class="panel-heading"><span>${icon(nodeIcon(n.type))}${esc(type.title)}</span><button class="icon-button danger" id="delete-node" aria-label="Eliminar bloco" ${dis()}>${icon('trash')}</button></div><div class="inspector-body"><label>Nome do bloco<input id="node-label" value="${esc(n.label)}" ${owner()?'':'readonly'}></label><span class="badge ${type.color}">${esc(type.group)}</span><span class="overline">CONFIGURAÇÃO</span>${type.fields.map(([key,label,input,values])=>`<label>${esc(label)}${input==='textarea'?`<textarea data-field="${key}" ${owner()?'':'readonly'}>${esc(n.config[key])}</textarea>`:input==='select'?`<select data-field="${key}" ${dis()}>${values.map(v=>`<option value="${v}" ${n.config[key]===v?'selected':''}>${esc(({asc:'Crescente',desc:'Decrescente',greater:'Maior que',equals:'Igual a',contains:'Contém',client:'Cliente',channel:'Canal',server:'Servidor'})[v]||v)}</option>`).join('')}</select>`:`<input data-field="${key}" type="${input}" value="${esc(n.config[key])}" ${owner()?'':'readonly'}>`}</label>`).join('')||'<p class="form-help">Sem parâmetros adicionais.</p>'}<p class="form-help">Variáveis: <code>{{time}}</code>, <code>{{top}}</code>, <code>{{text}}</code> e <code>{{variables.nome}}</code>.</p><span class="overline">LIGAÇÕES</span>${edges.map(({e,i})=>`<div class="connection-row"><span>${esc(draft.graph.nodes.find(node=>node.id===(e.from===n.id?e.to:e.from))?.label)}<small>${e.from===n.id?'Saída':'Entrada'} · ${e.port}</small></span><button class="icon-button danger" data-delete-edge="${i}" aria-label="Eliminar ligação" ${dis()}>${icon('trash')}</button></div>`).join('')||'<p class="form-help">Ainda sem ligações.</p>'}</div>`;document.querySelector('#node-label').oninput=e=>{if(!owner())return;n.label=e.target.value;markDirty();document.querySelector(`[data-node="${n.id}"] strong`).textContent=n.label;};document.querySelectorAll('[data-field]').forEach(input=>input.onchange=()=>{if(!owner())return;n.config[input.dataset.field]=input.type==='number'?Number(input.value):input.value;markDirty();renderGraph();});document.querySelector('#delete-node').onclick=()=>{draft.graph.nodes=draft.graph.nodes.filter(node=>node.id!==n.id);draft.graph.edges=draft.graph.edges.filter(e=>e.from!==n.id&&e.to!==n.id);selectedNode=draft.graph.nodes[0]?.id;markDirty();renderGraph();};document.querySelectorAll('[data-delete-edge]').forEach(b=>b.onclick=()=>{draft.graph.edges.splice(Number(b.dataset.deleteEdge),1);markDirty();renderGraph();});}
|
||||||
|
async function saveFlow(){try{await api(`/api/bots/${draft.id}`,{method:'PUT',body:JSON.stringify({graph:draft.graph})});await refresh();dirty=false;document.querySelector('#save-state').textContent='Guardado';document.querySelector('#save-state').className='';toast('Flow guardado.');return true;}catch(e){toast(e.message,true);return false;}}
|
||||||
|
async function execute(simulation){if(!simulation&&!confirm('Executar este flow no servidor TeamSpeak configurado?'))return;if(dirty&&!(await saveFlow()))return;const b=document.querySelector(simulation?'#simulate':'#run');b.disabled=true;try{const result=await api(`/api/bots/${draft.id}/${simulation?'simulate':'run'}`,{method:'POST'});await refresh();showRun(result);}catch(e){toast(e.message,true);}finally{b.disabled=!owner();}}
|
||||||
|
function runRow(e){return `<button class="execution-row" data-execution="${e.id}"><span class="execution-status ${e.status==='success'?'green':'red'}">${icon(e.status==='success'?'check':'close')}</span><span><strong>${esc(e.botName)}</strong><small>${e.simulation?'Simulação':'Execução real'} · ${e.status==='success'?'Concluída':'Falhou'}</small></span><time>${ago(e.startedAt)}</time></button>`;}
|
||||||
|
function runDetail(e){return `<div class="section-heading"><h2>${esc(e.botName)}</h2><span class="badge ${e.status==='success'?'green':'red'}">${e.status==='success'?'Sucesso':'Falha'}</span></div><span class="muted">${new Date(e.startedAt).toLocaleString('pt-PT')} · ${e.simulation?'Simulação com dados de exemplo':'Servidor TeamSpeak'}</span><div class="execution-summary"><div><span>Duração</span><strong>${((e.durationMs||0)/1000).toFixed(2)}s</strong></div><div><span>Iniciado por</span><strong>${esc(e.actor)}</strong></div><div><span>Passos</span><strong>${e.steps.length}</strong></div></div><h3>Eventos e etapas</h3><div class="timeline">${e.steps.map(s=>`<div class="timeline-step"><span class="execution-status ${s.status==='success'?'green':'red'}">${icon(s.status==='success'?'check':'close')}</span><div><strong>${esc(s.label)}</strong><p>${esc(s.message)}</p></div><time>${s.durationMs||0} ms</time></div>`).join('')}</div>${e.error?`<div class="notice error">${esc(e.error)}</div>`:''}${e.output?`<h3>Texto produzido</h3><pre>${esc(e.output)}</pre>`:''}`;}
|
||||||
|
function showRun(e){const dialog=document.querySelector('#dialog');dialog.innerHTML=`<div class="modal-heading"><h2>${e.simulation?'Resultado da simulação':'Resultado da execução'}</h2><button class="icon-button" id="close-run">${icon('close')}</button></div>${runDetail(e)}`;dialog.showModal();document.querySelector('#close-run').onclick=()=>dialog.close();}
|
||||||
|
function wireExecutions(){document.querySelectorAll('[data-execution]').forEach(b=>b.onclick=()=>{const e=data.executions.find(e=>e.id===b.dataset.execution);const el=document.querySelector('#execution-detail');if(el){el.innerHTML=runDetail(e);document.querySelectorAll('.execution-row').forEach(row=>row.classList.toggle('selected',row===b));}else showRun(e);});}
|
||||||
|
function executionsPage(){document.querySelector('#content').innerHTML=heading('Execuções','Acompanha e analisa cada passo dos teus flows.',`<button class="button secondary" id="refresh-runs">${icon('refresh')}Atualizar</button>`)+`<div class="executions-layout"><section class="card execution-list"><div class="section-heading"><h2>Execuções recentes</h2><span class="badge gray">${data.executions.length}</span></div>${data.executions.map(runRow).join('')||empty('clock','Ainda sem execuções','Abre um flow e carrega em Simular.')}</section><section class="card" id="execution-detail">${data.executions.length?runDetail(data.executions[0]):empty('flow','Cada passo, num só lugar','Os resultados ficam guardados aqui.')}</section></div>`;document.querySelector('#refresh-runs').onclick=async()=>{await refresh();executionsPage();wireExecutions();};}
|
||||||
|
const variableRow=(k,v)=>`<div class="variable-row"><input data-key value="${esc(k)}" placeholder="nome" ${owner()?'':'readonly'}><input data-value value="${esc(v)}" placeholder="valor" ${owner()?'':'readonly'}><button type="button" class="icon-button danger" data-remove-var ${dis()}>${icon('trash')}</button></div>`;
|
||||||
|
function variablesPage(){const bot=selectedBot();document.querySelector('#content').innerHTML=heading('Variáveis','Valores persistentes para reutilizar nos teus flows.',`<select id="variables-bot" aria-label="Bot">${data.bots.map(b=>`<option value="${b.id}" ${b.id===botId?'selected':''}>${esc(b.name)}</option>`).join('')}</select>`)+`<section class="card"><div class="section-heading"><h2>Variáveis do bot</h2>${button('add-variable','Adicionar variável','plus','secondary')}</div><p class="form-help">Usa <code>{{variables.nome}}</code> no texto de um bloco.</p><form id="variables-form"><div class="table-heading"><span>Nome</span><span>Valor</span><span></span></div><div id="variable-rows">${Object.entries(bot?.variables||{}).map(([k,v])=>variableRow(k,v)).join('')}</div><div class="form-actions"><button class="button primary" ${dis()}>${icon('save')}Guardar variáveis</button></div></form></section>`;const wire=()=>document.querySelectorAll('[data-remove-var]').forEach(b=>b.onclick=()=>b.closest('.variable-row').remove());wire();document.querySelector('#variables-bot').onchange=e=>{botId=e.target.value;variablesPage();};document.querySelector('#add-variable').onclick=()=>{document.querySelector('#variable-rows').insertAdjacentHTML('beforeend',variableRow('',''));wire();};document.querySelector('#variables-form').onsubmit=async e=>{e.preventDefault();if(!bot)return;try{const variables=Object.create(null);document.querySelectorAll('.variable-row').forEach(row=>{const key=row.querySelector('[data-key]').value.trim();if(!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)||Object.hasOwn(variables,key))throw new Error('Usa nomes únicos com letras, números e underscore.');variables[key]=row.querySelector('[data-value]').value;});await api(`/api/bots/${bot.id}`,{method:'PUT',body:JSON.stringify({variables})});await refresh();toast('Variáveis guardadas.');}catch(error){toast(error.message,true);}};}
|
||||||
|
function connectionsPage(){document.querySelector('#content').innerHTML=heading('Ligações','Liga os teus bots ao servidor TeamSpeak.',button('new-connection','Adicionar servidor','link'))+`<div class="connection-grid">${data.connections.map(c=>`<article class="card"><div class="bot-card-top"><span class="icon-tile cyan">${icon('link')}</span><div><h2>${esc(c.name)}</h2><span class="muted">WebQuery · Servidor #${c.serverId}</span></div></div><p class="connection-url">${esc(c.url)}</p><div class="connection-actions"><button class="button secondary" data-edit-connection="${c.id}">${icon('settings')}Configurar</button><button class="button secondary" data-test-connection="${c.id}" ${dis()}>${icon('refresh')}Testar</button></div></article>`).join('')||`<section class="card">${empty('link','Liga o teu TeamSpeak','Adiciona o endereço WebQuery e a API key.')}</section>`}</div><div class="notice connection-tip">Podes usar <code>http://192.168.88.99:10080</code> se a porta estiver publicada. O nome <code>teamspeak6</code> só funciona quando os containers partilham a rede.</div>`;document.querySelector('#new-connection').onclick=()=>connectionModal();document.querySelectorAll('[data-edit-connection]').forEach(b=>b.onclick=()=>connectionModal(data.connections.find(c=>c.id===b.dataset.editConnection)));document.querySelectorAll('[data-test-connection]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{const result=await api(`/api/connections/${b.dataset.testConnection}/test`,{method:'POST'});toast(`Ligação confirmada: ${result.server.virtualserver_name||'TeamSpeak'}`);}catch(e){toast(e.message,true);}finally{b.disabled=!owner();}});}
|
||||||
|
function connectionModal(c={}){modal(c.id?'Configurar servidor':'Adicionar servidor',`<label>Nome<input name="name" value="${esc(c.name||'Servidor principal')}" required></label><label>Endereço WebQuery<input name="url" type="url" value="${esc(c.url||'http://192.168.88.99:10080')}" required></label><label>ID do servidor virtual<input name="serverId" type="number" min="1" value="${c.serverId||1}" required></label><label>API key<input name="apiKey" type="password" autocomplete="new-password" placeholder="${c.hasApiKey?'Guardada · deixa vazio para manter':'Coloca a API key'}" ${c.hasApiKey?'':'required'}></label><p class="form-help">A chave é guardada cifrada e não aparece no painel.</p>`,f=>api('/api/connections',{method:'POST',body:JSON.stringify({...Object.fromEntries(f),id:c.id})}));}
|
||||||
|
function settingsPage(){document.querySelector('#content').innerHTML=heading('Definições','A tua conta e o ambiente do painel.')+`<div class="settings-grid"><section class="card"><h2>O teu acesso</h2><div class="profile-card"><div class="avatar large">${esc(user.name.slice(0,1).toUpperCase())}</div><div><h3>${esc(user.name)}</h3><span class="badge purple">${esc(user.role)}</span></div></div><div class="details"><div><span>ID Discord</span><strong>${esc(user.id)}</strong></div><div><span>Autenticação</span><strong>Discord</strong></div><div><span>Permissões</span><strong>${owner()?'Configurar, guardar e executar':'Consultar bots, flows e execuções'}</strong></div></div></section><section class="card"><h2>Ambiente</h2><div class="details"><div><span>Painel</span><strong>TS6 Botflow 0.2</strong></div><div><span>Dados</span><strong>Volume Docker persistente</strong></div><div><span>Novas contas</span><strong>Apenas IDs autorizados</strong></div></div><p class="form-help">A tua conta Discord e as ligações TeamSpeak têm permissões separadas.</p></section></div>`;}
|
||||||
|
boot().catch(e=>{app.innerHTML=`<div class="login"><div class="login-card"><h1>Não foi possível carregar o painel.</h1><p>${esc(e.message)}</p><a class="button primary" href="/">Tentar novamente</a></div></div>`;});window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
|
||||||
|
|||||||
93
public/fonts/OFL.txt
Normal file
93
public/fonts/OFL.txt
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2015 The Public Sans Project Authors (https://github.com/uswds/public-sans)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
public/fonts/public-sans.ttf
Normal file
BIN
public/fonts/public-sans.ttf
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
10
server.mjs
10
server.mjs
@ -4,6 +4,7 @@ import { mkdirSync, readFileSync } from 'node:fs';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createStudio } from './studio.mjs';
|
||||||
|
|
||||||
export function createApp({ env = process.env, discordFetch = fetch } = {}) {
|
export function createApp({ env = process.env, discordFetch = fetch } = {}) {
|
||||||
const appUrl = new URL(env.APP_URL || 'http://localhost:3080');
|
const appUrl = new URL(env.APP_URL || 'http://localhost:3080');
|
||||||
@ -11,6 +12,7 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
|
|||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
const db = new DatabaseSync(resolve(dataDir, 'botflow.sqlite'));
|
const db = new DatabaseSync(resolve(dataDir, 'botflow.sqlite'));
|
||||||
db.exec('CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user TEXT NOT NULL, expires INTEGER NOT NULL)');
|
db.exec('CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user TEXT NOT NULL, expires INTEGER NOT NULL)');
|
||||||
|
const studio = createStudio(db, dataDir);
|
||||||
const pending = new Map();
|
const pending = new Map();
|
||||||
const root = fileURLToPath(new URL('./public/', import.meta.url));
|
const root = fileURLToPath(new URL('./public/', import.meta.url));
|
||||||
const configured = Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET && /^\d{17,20}$/.test(env.OWNER_DISCORD_ID || ''));
|
const configured = Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET && /^\d{17,20}$/.test(env.OWNER_DISCORD_ID || ''));
|
||||||
@ -26,7 +28,7 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
|
|||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||||
res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
|
res.setHeader('Content-Security-Policy', "default-src 'self'; img-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
|
||||||
const url = new URL(req.url, appUrl);
|
const url = new URL(req.url, appUrl);
|
||||||
const cookies = Object.fromEntries((req.headers.cookie || '').split(';').map(v => v.trim().split('=')));
|
const cookies = Object.fromEntries((req.headers.cookie || '').split(';').map(v => v.trim().split('=')));
|
||||||
const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
|
const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
|
||||||
@ -72,15 +74,15 @@ export function createApp({ env = process.env, discordFetch = fetch } = {}) {
|
|||||||
}
|
}
|
||||||
if (url.pathname.startsWith('/api/')) {
|
if (url.pathname.startsWith('/api/')) {
|
||||||
if (!user) return json(401, { error: 'Autenticação necessária.' });
|
if (!user) return json(401, { error: 'Autenticação necessária.' });
|
||||||
if (req.method === 'GET' && url.pathname === '/api/dashboard') return json(200, { bots: [], flows: [], executions: [], connections: [] });
|
if (await studio.route(req,url,user,json)) return;
|
||||||
return json(404, { error: 'Não encontrado.' });
|
return json(404, { error: 'Não encontrado.' });
|
||||||
}
|
}
|
||||||
const files = { '/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/style.css': ['style.css', 'text/css'], '/logo.png': ['logo.png', 'image/png'], '/favicon.ico': ['favicon.ico', 'image/x-icon'] };
|
const files = { '/': ['index.html', 'text/html; charset=utf-8'], '/app.js': ['app.js', 'text/javascript; charset=utf-8'], '/style.css': ['style.css', 'text/css'], '/logo.png': ['logo.png', 'image/png'], '/favicon.ico': ['favicon.ico', 'image/x-icon'], '/fonts/public-sans.ttf': ['fonts/public-sans.ttf', 'font/ttf'] };
|
||||||
if (req.method !== 'GET' || !files[url.pathname]) return json(404, { error: 'Não encontrado.' });
|
if (req.method !== 'GET' || !files[url.pathname]) return json(404, { error: 'Não encontrado.' });
|
||||||
const [file, type] = files[url.pathname]; res.writeHead(200, { 'Content-Type': type }); res.end(readFileSync(resolve(root, file)));
|
const [file, type] = files[url.pathname]; res.writeHead(200, { 'Content-Type': type }); res.end(readFileSync(resolve(root, file)));
|
||||||
} catch { json(502, { error: 'Não foi possível concluir o login Discord. Tenta novamente.' }); }
|
} catch { json(502, { error: 'Não foi possível concluir o login Discord. Tenta novamente.' }); }
|
||||||
});
|
});
|
||||||
server.on('close', () => db.close());
|
server.on('close', () => { studio.close(); db.close(); });
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
|||||||
174
studio.mjs
Normal file
174
studio.mjs
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
|
||||||
|
export const nodeTypes = {
|
||||||
|
timer: { title: 'Temporizador', group: 'Eventos', color: 'purple', fields: [['seconds','Intervalo (segundos)','number']] },
|
||||||
|
manual: { title: 'Início manual', group: 'Eventos', color: 'purple', fields: [] },
|
||||||
|
serverinfo: { title: 'Informações do servidor', group: 'Dados', color: 'cyan', fields: [] },
|
||||||
|
clients: { title: 'Listar clientes', group: 'Dados', color: 'cyan', fields: [] },
|
||||||
|
condition: { title: 'Condição', group: 'Lógica', color: 'orange', fields: [['field','Variável','text'],['operator','Comparação','select',['greater','equals','contains']],['value','Valor','text']] },
|
||||||
|
sort: { title: 'Ordenar', group: 'Lógica', color: 'purple', fields: [['field','Campo dos clientes','text'],['direction','Ordem','select',['desc','asc']]] },
|
||||||
|
limit: { title: 'Top / limite', group: 'Lógica', color: 'purple', fields: [['count','Número de pessoas','number']] },
|
||||||
|
template: { title: 'Preparar texto', group: 'Lógica', color: 'purple', fields: [['text','Texto e variáveis {{server.nome}}','textarea']] },
|
||||||
|
channelEdit: { title: 'Editar canal', group: 'TeamSpeak', color: 'cyan', fields: [['channelId','ID do canal','number'],['description','Descrição','textarea']] },
|
||||||
|
message: { title: 'Enviar mensagem', group: 'TeamSpeak', color: 'cyan', fields: [['target','ID de destino','number'],['mode','Destino','select',['channel','client','server']],['text','Mensagem','textarea']] },
|
||||||
|
delay: { title: 'Esperar', group: 'Tempo', color: 'gray', fields: [['seconds','Segundos','number']] },
|
||||||
|
log: { title: 'Registar mensagem', group: 'Tempo', color: 'gray', fields: [['text','Mensagem','textarea']] }
|
||||||
|
};
|
||||||
|
export function validateGraph(graph) {
|
||||||
|
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges) || graph.nodes.length > 80 || graph.edges.length > 160) throw new Error('O flow precisa de uma lista válida de blocos e ligações.');
|
||||||
|
const ids = new Set();
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
if (!nodeTypes[node.type] || typeof node.id !== 'string' || ids.has(node.id) || !Number.isFinite(node.x) || !Number.isFinite(node.y) || !node.config || typeof node.config !== 'object') throw new Error('Bloco inválido no flow.');
|
||||||
|
ids.add(node.id);
|
||||||
|
if (node.type === 'timer' && (!Number.isFinite(Number(node.config.seconds)) || Number(node.config.seconds) < 1)) throw new Error('O intervalo mínimo é 1 segundo.');
|
||||||
|
if (['channelEdit','message'].includes(node.type) && (!Number.isInteger(Number(node.config[node.type === 'channelEdit' ? 'channelId' : 'target'])) || Number(node.config[node.type === 'channelEdit' ? 'channelId' : 'target']) < 1)) throw new Error('Indica um ID válido no bloco TeamSpeak.');
|
||||||
|
if (node.type === 'limit' && (!Number.isInteger(Number(node.config.count)) || Number(node.config.count) < 1 || Number(node.config.count) > 1000)) throw new Error('O limite precisa de ser entre 1 e 1000.');
|
||||||
|
if (node.type === 'delay' && (!Number.isFinite(Number(node.config.seconds)) || Number(node.config.seconds) < 0 || Number(node.config.seconds) > 30)) throw new Error('O tempo de espera precisa de ser entre 0 e 30 segundos.');
|
||||||
|
}
|
||||||
|
if (graph.nodes.filter(n => ['timer','manual'].includes(n.type)).length !== 1) throw new Error('Usa exatamente um bloco de início (manual ou temporizador).');
|
||||||
|
const visiting = new Set(), visited = new Set();
|
||||||
|
const visit = id => {
|
||||||
|
if (visiting.has(id)) throw new Error('O flow contém um ciclo. Usa um temporizador para repetir.');
|
||||||
|
if (visited.has(id)) return;
|
||||||
|
visiting.add(id);
|
||||||
|
for (const edge of graph.edges.filter(e => e.from === id)) visit(edge.to);
|
||||||
|
visiting.delete(id); visited.add(id);
|
||||||
|
};
|
||||||
|
for (const edge of graph.edges) if (!ids.has(edge.from) || !ids.has(edge.to) || edge.from === edge.to || !['out','true','false'].includes(edge.port)) throw new Error('Ligação inválida no flow.');
|
||||||
|
for (const id of ids) visit(id);
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
const valueAt = (context, path) => String(path).split('.').reduce((value, key) => value == null ? undefined : Object.hasOwn(value, key) ? value[key] : undefined, context);
|
||||||
|
const interpolate = (text, context) => String(text || '').replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path) => String(valueAt(context, path) ?? ''));
|
||||||
|
const initialGraph = () => {
|
||||||
|
const nodes = [
|
||||||
|
['timer', 'Atualizar a cada 30 segundos', { seconds: 30 }],
|
||||||
|
['serverinfo', 'Estado do servidor', {}],
|
||||||
|
['clients', 'Pessoas online', {}],
|
||||||
|
['sort', 'Ordenar por tempo ligado', { field: 'client_lastconnected', direction: 'asc' }],
|
||||||
|
['limit', 'Top 5 pessoas', { count: 5 }],
|
||||||
|
['template', 'Informações e pessoas', { text: '[b]Informações do servidor[/b]\nOnline: {{server.virtualserver_clientsonline}} / {{server.virtualserver_maxclients}}\nCanais: {{server.virtualserver_channelsonline}}\n\n[b]Pessoas online[/b]\n{{top}}\n\nAtualizado: {{time}}' }],
|
||||||
|
['channelEdit', 'Atualizar descrição do canal', { channelId: 21, description: '{{text}}' }]
|
||||||
|
].map(([type, label, config], index) => ({ id: `n${index}`, type, label, config, x: index % 2 ? 420 : 140, y: 55 + index * 108 }));
|
||||||
|
return { nodes, edges: nodes.slice(1).map((node,index) => ({ from: nodes[index].id, to: node.id, port: 'out' })) };
|
||||||
|
};
|
||||||
|
export function createStudio(db, dataDir, { transport = query } = {}) {
|
||||||
|
db.exec('CREATE TABLE IF NOT EXISTS studio_bots (id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS studio_connections (id TEXT PRIMARY KEY, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS studio_executions (id TEXT PRIMARY KEY, data TEXT NOT NULL);');
|
||||||
|
const keyPath = join(dataDir, 'credentials.key');
|
||||||
|
if (!existsSync(keyPath)) writeFileSync(keyPath, randomBytes(32), { mode: 0o600 });
|
||||||
|
const key = readFileSync(keyPath);
|
||||||
|
const encrypt = text => { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', key, iv); const bytes = Buffer.concat([cipher.update(text,'utf8'),cipher.final()]); return Buffer.concat([iv,cipher.getAuthTag(),bytes]).toString('base64'); };
|
||||||
|
const decrypt = text => { const bytes = Buffer.from(text,'base64'); const decipher = createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12)); decipher.setAuthTag(bytes.subarray(12,28)); return Buffer.concat([decipher.update(bytes.subarray(28)),decipher.final()]).toString('utf8'); };
|
||||||
|
const list = table => db.prepare(`SELECT data FROM studio_${table} ORDER BY rowid DESC`).all().map(row=>JSON.parse(row.data));
|
||||||
|
const get = (table,id) => { const row=db.prepare(`SELECT data FROM studio_${table} WHERE id = ?`).get(id); return row ? JSON.parse(row.data) : null; };
|
||||||
|
const save = (table,item) => db.prepare(`INSERT INTO studio_${table} VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`).run(item.id,JSON.stringify(item));
|
||||||
|
if (!list('bots').length) save('bots',{ id:randomUUID(), name:'Estatísticas', description:'Informações do servidor e pessoas online na descrição do canal 21.', color:'green', enabled:false, connectionId:'', variables:{}, graph:initialGraph(), updatedAt:new Date().toISOString() });
|
||||||
|
const publicConnection = connection => { const { apiKey, ...fields }=connection; return { ...fields, hasApiKey:Boolean(apiKey) }; };
|
||||||
|
const running = new Set(), due = new Map();
|
||||||
|
async function run(bot, simulation, actor) {
|
||||||
|
if (running.has(bot.id)) throw new Error('Este bot já está em execução.');
|
||||||
|
validateGraph(bot.graph);
|
||||||
|
const connection = bot.connectionId ? get('connections',bot.connectionId) : null;
|
||||||
|
if (!simulation && !connection) throw new Error('Configura uma ligação TeamSpeak antes de executar.');
|
||||||
|
running.add(bot.id);
|
||||||
|
const execution={ id:randomUUID(), botId:bot.id, botName:bot.name, simulation, actor, status:'running', startedAt:new Date().toISOString(), steps:[] };
|
||||||
|
save('executions',execution);
|
||||||
|
const context={ variables:bot.variables || {}, time:new Date().toLocaleString('pt-PT',{timeZone:'Europe/Lisbon'}) };
|
||||||
|
const call = async (command,params={}) => transport({ ...connection, apiKey:decrypt(connection.apiKey) }, command, params);
|
||||||
|
let visits=0;
|
||||||
|
async function processNode(id) {
|
||||||
|
if (++visits > 160) throw new Error('Limite de passos excedido.');
|
||||||
|
const node=bot.graph.nodes.find(n=>n.id===id), config=node.config;
|
||||||
|
const step={ nodeId:id, label:node.label || nodeTypes[node.type].title, type:node.type, startedAt:new Date().toISOString(), status:'running' };
|
||||||
|
execution.steps.push(step); let port='out';
|
||||||
|
try {
|
||||||
|
switch(node.type) {
|
||||||
|
case 'serverinfo': context.server=simulation ? { virtualserver_clientsonline:3, virtualserver_maxclients:32, virtualserver_channelsonline:17, virtualserver_name:'Servidor de exemplo' } : (await call('serverinfo'))[0]; step.message=simulation?'Dados de exemplo para simulação':'Informações obtidas'; break;
|
||||||
|
case 'clients': context.clients=simulation ? [{client_nickname:'Pessoa A',client_lastconnected:1},{client_nickname:'Pessoa B',client_lastconnected:2},{client_nickname:'Pessoa C',client_lastconnected:3}] : (await call('clientlist',{'-times':true})).filter(client=>Number(client.client_type)===0); context.count=context.clients.length; step.message=`${context.count} pessoas${simulation?' (exemplo)':''}`; break;
|
||||||
|
case 'sort': context.clients=[...(context.clients || [])].sort((a,b)=>(Number(a[config.field] || 0)-Number(b[config.field] || 0))*(config.direction==='desc'?-1:1)); step.message=`Ordenado por ${config.field}`; break;
|
||||||
|
case 'limit': context.clients=(context.clients || []).slice(0,Number(config.count)); context.top=context.clients.map((client,index)=>`${index+1}. ${client.client_nickname}`).join('\n'); step.message=`Selecionadas ${context.clients.length} pessoas`; break;
|
||||||
|
case 'condition': { const value=valueAt(context,config.field); const yes=config.operator==='greater'?Number(value)>Number(config.value):config.operator==='contains'?String(value).includes(config.value):String(value)===String(config.value); port=yes?'true':'false'; step.message=yes?'Sim':'Não'; break; }
|
||||||
|
case 'template': context.text=interpolate(config.text,context); step.message=context.text; break;
|
||||||
|
case 'channelEdit': { const description=interpolate(config.description,context); if(!simulation) await call('channeledit',{cid:config.channelId,channel_description:description}); step.message=`${simulation?'Simulação: ':' '}Descrição do canal ${config.channelId}\n${description}`; break; }
|
||||||
|
case 'message': if(!simulation) await call('sendtextmessage',{targetmode:{client:1,channel:2,server:3}[config.mode] || 2,target:config.target,msg:interpolate(config.text,context)}); step.message=`${simulation?'Simulação: ':''}${interpolate(config.text,context)}`; break;
|
||||||
|
case 'delay': if(!simulation) await new Promise(resolve=>setTimeout(resolve,Number(config.seconds)*1000)); step.message=`${config.seconds} segundos`; break;
|
||||||
|
case 'log': step.message=interpolate(config.text,context); break;
|
||||||
|
default: step.message=simulation?'Teste manual do flow':'Flow iniciado';
|
||||||
|
}
|
||||||
|
step.status='success';
|
||||||
|
} catch(error) { step.status='error'; step.message=error.message; throw error; }
|
||||||
|
finally { step.durationMs=Date.now()-Date.parse(step.startedAt); save('executions',execution); }
|
||||||
|
for(const edge of bot.graph.edges.filter(edge=>edge.from===id && edge.port===port)) await processNode(edge.to);
|
||||||
|
}
|
||||||
|
try { await processNode(bot.graph.nodes.find(node=>['timer','manual'].includes(node.type)).id); execution.status='success'; }
|
||||||
|
catch(error) { execution.status='error'; execution.error=error.message; }
|
||||||
|
finally { execution.durationMs=Date.now()-Date.parse(execution.startedAt); execution.output=context.text || ''; save('executions',execution); running.delete(bot.id); db.exec('DELETE FROM studio_executions WHERE rowid NOT IN (SELECT rowid FROM studio_executions ORDER BY rowid DESC LIMIT 500)'); }
|
||||||
|
return execution;
|
||||||
|
}
|
||||||
|
const interval=setInterval(()=>{
|
||||||
|
for(const bot of list('bots').filter(b=>b.enabled)) {
|
||||||
|
const timer=bot.graph.nodes.find(n=>n.type==='timer');
|
||||||
|
if(!timer || running.has(bot.id) || (due.get(bot.id) || 0)>Date.now()) continue;
|
||||||
|
due.set(bot.id,Date.now()+Number(timer.config.seconds)*1000);
|
||||||
|
run(bot,false,'Temporizador').catch(()=>{});
|
||||||
|
}
|
||||||
|
},1000); interval.unref();
|
||||||
|
async function route(req,url,user,json) {
|
||||||
|
if(!url.pathname.startsWith('/api/')) return false;
|
||||||
|
if(req.method==='GET') {
|
||||||
|
if(url.pathname==='/api/dashboard') { const bots=list('bots'), executions=list('executions'); json(200,{ bots, flows:bots, executions, connections:list('connections').map(publicConnection), nodeTypes }); return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if(!['POST','PUT','DELETE'].includes(req.method)) return false;
|
||||||
|
if(user.role!=='Dono') { json(403,{error:'O teu perfil permite apenas consultar. Precisas do perfil Dono para guardar ou executar.'}); return true; }
|
||||||
|
if(req.headers.origin!==url.origin) { json(403,{error:'Origem inválida.'}); return true; }
|
||||||
|
let bytes=0, chunks=[];
|
||||||
|
for await(const chunk of req) { bytes+=chunk.length; if(bytes>250000) { json(413,{error:'Pedido demasiado grande.'}); return true; } chunks.push(chunk); }
|
||||||
|
try {
|
||||||
|
const body=chunks.length?JSON.parse(Buffer.concat(chunks).toString()):{};
|
||||||
|
if(url.pathname==='/api/bots' && req.method==='POST') {
|
||||||
|
const bot={id:randomUUID(),name:String(body.name || 'Novo bot').slice(0,80),description:String(body.description || '').slice(0,400),color:['green','red','cyan','orange','purple'].includes(body.color)?body.color:'purple',enabled:false,connectionId:'',variables:{},graph:initialGraph(),updatedAt:new Date().toISOString()}; save('bots',bot); json(201,bot); return true;
|
||||||
|
}
|
||||||
|
const botMatch=url.pathname.match(/^\/api\/bots\/([\w-]+)(?:\/(run|simulate))?$/);
|
||||||
|
if(botMatch) {
|
||||||
|
const bot=get('bots',botMatch[1]); if(!bot) { json(404,{error:'Bot não encontrado.'}); return true; }
|
||||||
|
if(req.method==='DELETE') { if(running.has(bot.id)) throw new Error('Espera que a execução termine.'); db.prepare('DELETE FROM studio_bots WHERE id = ?').run(bot.id); due.delete(bot.id); json(200,{ok:true}); return true; }
|
||||||
|
if(req.method==='POST' && botMatch[2]) { json(200,await run(bot,botMatch[2]==='simulate',user.name)); return true; }
|
||||||
|
if(req.method==='PUT') {
|
||||||
|
const graph=validateGraph(body.graph || bot.graph), connectionId=String(body.connectionId ?? bot.connectionId);
|
||||||
|
if(connectionId && !get('connections',connectionId)) throw new Error('Ligação não encontrada.');
|
||||||
|
if((body.enabled ?? bot.enabled) && !connectionId) throw new Error('Escolhe uma ligação antes de ativar o bot.');
|
||||||
|
if((body.enabled ?? bot.enabled) && !graph.nodes.some(n=>n.type==='timer')) throw new Error('Adiciona um temporizador para ativar a execução automática.');
|
||||||
|
const updated={...bot,name:String(body.name ?? bot.name).trim().slice(0,80),description:String(body.description ?? bot.description).slice(0,400),graph,connectionId,enabled:Boolean(body.enabled ?? bot.enabled),variables:body.variables ?? bot.variables,updatedAt:new Date().toISOString()};
|
||||||
|
if(!updated.name || !updated.variables || typeof updated.variables!=='object' || Array.isArray(updated.variables)) throw new Error('Nome ou variáveis inválidos.');
|
||||||
|
save('bots',updated); due.delete(bot.id); json(200,updated); return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(url.pathname==='/api/connections' && req.method==='POST') {
|
||||||
|
const old=body.id?get('connections',body.id):null;
|
||||||
|
const base=new URL(body.url); if(!['http:','https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash) throw new Error('Usa um endereço HTTP ou HTTPS sem credenciais.');
|
||||||
|
const serverId=Number(body.serverId || 1); if(!Number.isInteger(serverId) || serverId<1) throw new Error('ID de servidor inválido.');
|
||||||
|
if(!body.apiKey && !old?.apiKey) throw new Error('Indica a API key TeamSpeak.');
|
||||||
|
const connection={id:old?.id || randomUUID(),name:String(body.name || 'Servidor principal').slice(0,80),url:base.origin,serverId,apiKey:body.apiKey?encrypt(String(body.apiKey)):old.apiKey}; save('connections',connection); json(200,publicConnection(connection)); return true;
|
||||||
|
}
|
||||||
|
const testMatch=url.pathname.match(/^\/api\/connections\/([\w-]+)\/test$/);
|
||||||
|
if(testMatch) { const connection=get('connections',testMatch[1]); if(!connection) throw new Error('Ligação não encontrada.'); const server=(await transport({...connection,apiKey:decrypt(connection.apiKey)},'serverinfo',{}))[0]; json(200,{server}); return true; }
|
||||||
|
return false;
|
||||||
|
} catch(error) { json(400,{error:error instanceof SyntaxError?'Pedido inválido.':error.message}); return true; }
|
||||||
|
}
|
||||||
|
return {route,close:()=>clearInterval(interval)};
|
||||||
|
}
|
||||||
|
function query(connection,command,params) {
|
||||||
|
const url=new URL(`/${connection.serverId}/${command}`,connection.url);
|
||||||
|
for(const [key,value] of Object.entries(params)) url.searchParams.set(key,String(value));
|
||||||
|
return new Promise((resolve,reject)=>{
|
||||||
|
const req=(url.protocol==='https:'?https:http).get(url,{headers:{'x-api-key':connection.apiKey},timeout:10000},res=>{
|
||||||
|
let data='',size=0; res.on('data',chunk=>{size+=chunk.length; if(size>2000000) req.destroy(new Error('Resposta TeamSpeak demasiado grande.')); else data+=chunk;});
|
||||||
|
res.on('error',reject); res.on('end',()=>{ try { const result=JSON.parse(data); if(res.statusCode!==200 || Number(result.status?.code)!==0) throw new Error(result.status?.message || `TeamSpeak HTTP ${res.statusCode}`); resolve(Array.isArray(result.body)?result.body:[result.body]); } catch { reject(new Error('O TeamSpeak recusou o pedido ou devolveu uma resposta inválida. Verifica a ligação e a API key.')); } });
|
||||||
|
}); req.on('timeout',()=>req.destroy(new Error('A ligação TeamSpeak excedeu 10 segundos.'))); req.on('error',error=>reject(new Error(`Ligação TeamSpeak: ${error.code || error.message}`)));
|
||||||
|
});
|
||||||
|
}
|
||||||
41
studio.test.mjs
Normal file
41
studio.test.mjs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { createStudio, validateGraph } from './studio.mjs';
|
||||||
|
|
||||||
|
test('flows persist, simulation does not call TeamSpeak, credentials are hidden and readers cannot modify', async () => {
|
||||||
|
const directory=mkdtempSync(join(tmpdir(),'botflow-studio-'));
|
||||||
|
const db=new DatabaseSync(join(directory,'test.sqlite'));
|
||||||
|
const calls=[];
|
||||||
|
const studio=createStudio(db,directory,{transport:async (connection,command,params)=>{
|
||||||
|
assert.equal(connection.apiKey,'secret-api-key'); calls.push({command,params});
|
||||||
|
return command==='serverinfo'?[{virtualserver_clientsonline:1,virtualserver_maxclients:32,virtualserver_channelsonline:8}]:command==='clientlist'?[{client_type:0,client_nickname:'Client',client_lastconnected:1}]:[];
|
||||||
|
}});
|
||||||
|
const invoke=async (path,method='GET',body={},role='Dono',origin='http://localhost:3080')=>{
|
||||||
|
const req=Readable.from(method==='GET'?[]:[Buffer.from(JSON.stringify(body))]);req.method=method;req.headers={origin};let result;
|
||||||
|
await studio.route(req,new URL(path,'http://localhost:3080'),{role,name:'Tester'},(status,data)=>result={status,data});return result;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const dashboard=(await invoke('/api/dashboard')).data,bot=dashboard.bots[0];
|
||||||
|
assert.equal(bot.enabled,false); assert.equal(dashboard.executions.length,0);
|
||||||
|
assert.equal((await invoke('/api/bots','POST',{name:'Test'},'Leitor')).status,403);
|
||||||
|
assert.equal((await invoke('/api/bots','POST',{name:'Test'},'Dono','http://evil.example')).status,403);
|
||||||
|
const simulation=await invoke(`/api/bots/${bot.id}/simulate`,'POST');
|
||||||
|
assert.equal(simulation.data.status,'success');assert.match(simulation.data.output,/Pessoa A/);assert.equal(calls.length,0);
|
||||||
|
assert.equal((await invoke(`/api/bots/${bot.id}/run`,'POST')).status,400);
|
||||||
|
const connection=(await invoke('/api/connections','POST',{name:'Test',url:'http://teamspeak6:10080',serverId:1,apiKey:'secret-api-key'})).data;
|
||||||
|
assert.equal(connection.hasApiKey,true);assert.equal(connection.apiKey,undefined);
|
||||||
|
assert.ok(!db.prepare('SELECT data FROM studio_connections').get().data.includes('secret-api-key'));
|
||||||
|
await invoke(`/api/bots/${bot.id}`,'PUT',{connectionId:connection.id,variables:{greeting:'Olá'}});
|
||||||
|
const execution=await invoke(`/api/bots/${bot.id}/run`,'POST');
|
||||||
|
assert.equal(execution.data.status,'success');assert.deepEqual(calls.map(c=>c.command),['serverinfo','clientlist','channeledit']);assert.equal(calls.at(-1).params.cid,21);assert.match(calls.at(-1).params.channel_description,/Client/);
|
||||||
|
const modified=structuredClone(bot.graph);modified.edges.push({from:'n6',to:'n0',port:'out'});
|
||||||
|
assert.throws(()=>validateGraph(modified),/ciclo/);
|
||||||
|
assert.equal((await invoke(`/api/bots/${bot.id}`,'PUT',{graph:modified})).status,400);
|
||||||
|
const final=(await invoke('/api/dashboard')).data;assert.equal(final.executions.length,2);assert.equal(final.bots[0].variables.greeting,'Olá');assert.equal(final.connections[0].apiKey,undefined);
|
||||||
|
} finally {studio.close();db.close();rmSync(directory,{recursive:true,force:true});}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user