Support subtracting Query clients in flow text templates
All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 26s

This commit is contained in:
Bxio 2026-10-10 19:40:53 +01:00
parent 8c430b9486
commit 90dcbf895c
3 changed files with 30 additions and 5 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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);