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}`))); }); }