diff --git a/README.md b/README.md index 56aa49a..14a413b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Painel local XanaX para gerir bots TeamSpeak, com login Discord e editor visual - 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}}`. +- Subtração de duas variáveis numéricas no texto: `{{server.virtualserver_clientsonline - server.virtualserver_queryclientsonline}}` mostra o total online sem os clientes Query. O bloco Informações do servidor deve executar antes. Valores em falta ou não numéricos são assinalados no histórico como erro. 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. diff --git a/studio.mjs b/studio.mjs index d540376..1785624 100644 --- a/studio.mjs +++ b/studio.mjs @@ -43,7 +43,15 @@ export function validateGraph(graph) { 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) ?? '')); +export const interpolate = (text, context) => String(text || '').replace(/\{\{\s*([\w.]+)(?:\s*-\s*([\w.]+))?\s*\}\}/g, (_, path, subtract) => { + if (!subtract) return String(valueAt(context, path) ?? ''); + const numeric = operand => { + const value = valueAt(context, operand); + if (!['number','string'].includes(typeof value) || String(value).trim() === '' || !Number.isFinite(Number(value))) throw new Error(`A variável ${operand} precisa de um valor numérico para subtrair.`); + return Number(value); + }; + return String(numeric(path) - numeric(subtract)); +}); const initialGraph = () => { const nodes = [ ['timer', 'Atualizar a cada 30 segundos', { seconds: 30 }], @@ -51,7 +59,7 @@ const initialGraph = () => { ['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}}' }], + ['template', 'Informações e pessoas', { text: '[b]Informações do servidor[/b]\nOnline: {{server.virtualserver_clientsonline - server.virtualserver_queryclientsonline}} / {{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' })) }; @@ -87,7 +95,7 @@ export function createStudio(db, dataDir, { transport = query } = {}) { 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 'serverinfo': context.server=simulation ? { virtualserver_clientsonline:4, virtualserver_queryclientsonline:1, 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; diff --git a/studio.test.mjs b/studio.test.mjs index 429abc8..a452bca 100644 --- a/studio.test.mjs +++ b/studio.test.mjs @@ -5,7 +5,21 @@ 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'; +import { createStudio, validateGraph, interpolate } from './studio.mjs'; + +test('templates subtract numeric Query counts without executing expressions', () => { + const context = { server: { virtualserver_clientsonline:'6', virtualserver_queryclientsonline:'2' }, text:'Título' }; + const formula = '{{server.virtualserver_clientsonline - server.virtualserver_queryclientsonline}}'; + assert.equal(interpolate(`Online: ${formula} · {{text}}`, context), 'Online: 4 · Título'); + context.server.virtualserver_queryclientsonline = 0; + assert.equal(interpolate(formula, context), '6'); + context.server.virtualserver_queryclientsonline = 'invalid'; + assert.throws(() => interpolate(formula, context), /valor numérico/); + delete context.server.virtualserver_queryclientsonline; + assert.throws(() => interpolate(formula, context), /valor numérico/); + assert.equal(interpolate('{{process.exit()}}', context), '{{process.exit()}}'); + assert.equal(interpolate('{{server.constructor}}', context), ''); +}); test('flows persist, simulation does not call TeamSpeak, credentials are hidden and readers cannot modify', async () => { const directory=mkdtempSync(join(tmpdir(),'botflow-studio-')); @@ -13,7 +27,7 @@ test('flows persist, simulation does not call TeamSpeak, credentials are hidden 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}]:[]; + return command==='serverinfo'?[{virtualserver_clientsonline:'3',virtualserver_queryclientsonline:'2',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; @@ -26,6 +40,7 @@ test('flows persist, simulation does not call TeamSpeak, credentials are hidden 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.match(simulation.data.output, /Online: 3 \/ 32/); 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); @@ -33,6 +48,7 @@ test('flows persist, simulation does not call TeamSpeak, credentials are hidden 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/); + assert.match(calls.at(-1).params.channel_description, /Online: 1 \/ 32/); 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);