175 lines
18 KiB
JavaScript
175 lines
18 KiB
JavaScript
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}`)));
|
|
});
|
|
}
|