const field = (key, label, type = "text", values) => [key, label, type, values]; const f = field, client = f("clientId", "Cliente / {{event.clid}}", "client"), dbclient = f("databaseId", "Cliente (ID da base de dados)", "dbclient"), channel = f("channelId", "Canal / {{event.cid}}", "channel"), group = f("groupId", "Grupo de servidor", "serverGroup"), permission = f("permission", "Permissão", "permission"); const type = (title, category, fields = [], defaults = {}, extra = {}) => ({ title, group: category, color: ["Eventos", "Canais"].includes(category) ? "green" : ["Clientes", "Grupos"].includes(category) ? "orange" : category === "Permissões" ? "red" : ["Integrações", "Dados"].includes(category) ? "cyan" : "purple", fields, defaults, outputs: ["out", "error"], ...extra, }); const action = (title, category, command, fields, params, defaults = {}) => type(title, category, fields, defaults, { command, params }); export const nodeTypes = { timer: type( "Temporizador", "Eventos", [f("seconds", "Intervalo (segundos)", "number")], { seconds: 30 }, { trigger: true, outputs: ["out"] }, ), cron: type( "Calendário / Cron", "Eventos", [f("expression", "Cron: 5 ou 6 campos"), f("timezone", "Fuso horário")], { expression: "*/30 * * * * *", timezone: "Europe/Lisbon" }, { trigger: true, outputs: ["out"] }, ), manual: type( "Início manual", "Eventos", [], {}, { trigger: true, outputs: ["out"] }, ), event: type( "Evento TeamSpeak", "Eventos", [ f("eventName", "Evento", "select", [ "notifycliententerview", "notifyclientleftview", "notifyclientmoved", "notifytextmessage", "notifychannelcreated", "notifychanneledited", "notifychanneldeleted", "notifyserveredited", ]), f("filters", "Filtros de campos (JSON)", "json"), ], { eventName: "notifycliententerview", filters: {} }, { trigger: true, outputs: ["out"] }, ), command: type( "Comando de chat", "Eventos", [ f("command", "Comando (com prefixo)"), f("channelId", "Canal de escuta (vazio: privado/servidor)", "channel"), f("allowedGroup", "Grupo autorizado (opcional)", "serverGroup"), ], { command: "!info", channelId: "", allowedGroup: "" }, { trigger: true, outputs: ["out"] }, ), webhook: type( "Webhook de entrada", "Eventos", [ f("secret", "Segredo do webhook", "password"), f("filters", "Filtros do corpo (JSON)", "json"), ], { secret: "", filters: {} }, { trigger: true, outputs: ["out"] }, ), serverinfo: type("Informações do servidor", "Dados"), clients: type( "Listar clientes", "Dados", [f("includeQuery", "Incluir clientes Query", "select", ["false", "true"])], { includeQuery: "false" }, ), channels: action( "Listar canais", "Dados", "channellist", [f("storeAs", "Guardar em temp…")], {}, { storeAs: "channels" }, ), groups: action( "Listar grupos", "Dados", "servergrouplist", [f("storeAs", "Guardar em temp…")], {}, { storeAs: "groups" }, ), channelGroups: action( "Listar grupos de canal", "Dados", "channelgrouplist", [f("storeAs", "Guardar em temp…")], {}, { storeAs: "channelGroups" }, ), permissions: action( "Listar permissões", "Dados", "permissionlist", [f("storeAs", "Guardar em temp…")], {}, { storeAs: "permissions" }, ), offlineClients: action( "Clientes da base de dados", "Dados", "clientdblist", [ f("start", "Início", "number"), f("duration", "Quantidade", "number"), f("storeAs", "Guardar em temp…"), ], { start: "start", duration: "duration" }, { start: 0, duration: 50, storeAs: "offlineClients" }, ), clientInfo: action( "Informações do cliente", "Dados", "clientinfo", [client, f("storeAs", "Guardar em temp…")], { clid: "clientId" }, { clientId: "{{event.clid}}", storeAs: "client" }, ), channelInfo: action( "Informações do canal", "Dados", "channelinfo", [channel, f("storeAs", "Guardar em temp…")], { cid: "channelId" }, { channelId: 21, storeAs: "channel" }, ), condition: type( "Condição", "Lógica", [ f("field", "Variável / caminho"), f("operator", "Comparação", "select", [ "equals", "notEquals", "greater", "less", "atLeast", "atMost", "contains", "startsWith", "endsWith", "exists", ]), f("value", "Valor"), ], { field: "count", operator: "greater", value: "0" }, { outputs: ["true", "false", "error"] }, ), expression: type( "Condição por expressão", "Lógica", [f("expression", "Expressão")], { expression: "count > 0" }, { outputs: ["true", "false", "error"] }, ), sort: type( "Ordenar", "Lógica", [ f("list", "Lista / caminho"), f("field", "Campo"), f("direction", "Ordem", "select", ["desc", "asc"]), ], { list: "clients", field: "client_lastconnected", direction: "asc" }, ), limit: type("Top / limite", "Lógica", [f("count", "Quantidade", "number")], { count: 5, }), filter: type( "Filtrar lista", "Lógica", [ f("list", "Lista / caminho"), f("field", "Campo"), f("operator", "Comparação", "select", [ "equals", "notEquals", "greater", "less", "contains", "exists", ]), f("value", "Valor"), f("storeAs", "Guardar em temp…"), ], { list: "clients", field: "client_idle_time", operator: "greater", value: "300000", storeAs: "filtered", }, ), foreach: type( "Para cada item", "Lógica", [f("list", "Lista / caminho"), f("limit", "Máximo de itens", "number")], { list: "clients", limit: 100 }, { outputs: ["each", "done", "error"] }, ), variable: type( "Definir variável", "Lógica", [ f("name", "Nome"), f("scope", "Guardar", "select", ["temp", "persistent"]), f("operation", "Operação", "select", [ "set", "increment", "append", "delete", ]), f("valueType", "Tipo do valor", "select", [ "text", "number", "boolean", "json", ]), f("value", "Valor", "textarea"), ], { name: "resultado", scope: "temp", operation: "set", valueType: "text", value: "", }, ), calculate: type( "Calcular", "Lógica", [f("expression", "Expressão"), f("storeAs", "Guardar em temp…")], { expression: "server.virtualserver_clientsonline - server.virtualserver_queryclientsonline", storeAs: "online", }, ), random: type( "Gerar código", "Lógica", [ f("length", "Comprimento", "number"), f("alphabet", "Tipo", "select", ["numbers", "letters", "mixed"]), f("storeAs", "Guardar em temp…"), ], { length: 6, alphabet: "numbers", storeAs: "code" }, ), template: type( "Preparar texto", "Lógica", [f("text", "Texto e variáveis", "textarea")], { text: "{{top}}" }, ), delay: type("Esperar", "Lógica", [f("seconds", "Segundos", "number")], { seconds: 1, }), stop: type("Terminar ramo", "Lógica", [], {}, { outputs: [] }), log: type( "Registar mensagem", "Lógica", [f("text", "Mensagem", "textarea")], { text: "Executado às {{time}}" }, ), message: type( "Enviar mensagem", "Clientes", [ f("mode", "Destino", "select", ["client", "channel", "server"]), f("target", "Destino (ID / variável)", "client"), f("text", "Mensagem", "textarea"), ], { mode: "client", target: "{{event.clid}}", text: "Olá {{event.client_nickname}}!", }, ), poke: action( "Poke cliente", "Clientes", "clientpoke", [client, f("text", "Mensagem", "textarea")], { clid: "clientId", msg: "text" }, { clientId: "{{event.clid}}", text: "Olá!" }, ), move: action( "Mover cliente", "Clientes", "clientmove", [ client, channel, f("password", "Password do canal (opcional)", "password"), ], { clid: "clientId", cid: "channelId", cpw: "password" }, { clientId: "{{event.clid}}", channelId: 21 }, ), kick: action( "Expulsar cliente", "Clientes", "clientkick", [ client, f("reasonId", "Destino", "select", ["5", "4"]), f("reason", "Motivo"), ], { clid: "clientId", reasonid: "reasonId", reasonmsg: "reason" }, { clientId: "{{event.clid}}", reasonId: "5", reason: "Regra automática" }, ), ban: action( "Banir cliente", "Clientes", "banclient", [ client, f("seconds", "Duração (0: permanente)", "number"), f("reason", "Motivo"), ], { clid: "clientId", time: "seconds", banreason: "reason" }, { clientId: "{{event.clid}}", seconds: 60, reason: "Regra automática" }, ), unban: action( "Remover ban", "Clientes", "bandel", [f("banId", "ID do ban")], { banid: "banId" }, { banId: "" }, ), channelCreate: type( "Criar canal", "Canais", [ f("name", "Nome"), f("parentId", "Canal pai (0: raiz)", "channel"), f("topic", "Tópico"), f("description", "Descrição", "textarea"), f("password", "Password", "password"), f("permanent", "Permanente", "select", ["false", "true"]), f("params", "Campos adicionais (JSON)", "json"), f("storeAs", "Guardar resultado em temp…"), ], { name: "Novo canal", parentId: 0, permanent: "false", params: {}, storeAs: "createdChannel", }, ), channelEdit: type( "Editar canal", "Canais", [ channel, f("name", "Nome (vazio: manter)"), f("topic", "Tópico (vazio: manter)"), f("description", "Descrição (vazio: manter)", "textarea"), f("password", "Password (vazio: manter)", "password"), f("params", "Campos adicionais (JSON)", "json"), ], { channelId: 21, description: "{{text}}", params: {} }, ), channelMove: action( "Mover canal", "Canais", "channelmove", [ channel, f("parentId", "Novo canal pai (0: raiz)", "channel"), f("order", "Depois do canal (0: primeiro)", "channel"), ], { cid: "channelId", cpid: "parentId", order: "order" }, { channelId: 21, parentId: 0, order: 0 }, ), channelDelete: action( "Apagar canal", "Canais", "channeldelete", [channel, f("force", "Forçar (inclui subcanais)", "select", ["0", "1"])], { cid: "channelId", force: "force" }, { channelId: 21, force: "0" }, ), groupAdd: action( "Adicionar ao grupo", "Grupos", "servergroupaddclient", [dbclient, group], { cldbid: "databaseId", sgid: "groupId" }, { databaseId: "{{event.client_database_id}}", groupId: 9 }, ), groupRemove: action( "Remover do grupo", "Grupos", "servergroupdelclient", [dbclient, group], { cldbid: "databaseId", sgid: "groupId" }, { databaseId: "{{event.client_database_id}}", groupId: 9 }, ), channelGroupSet: action( "Definir grupo de canal", "Grupos", "setclientchannelgroup", [dbclient, channel, f("groupId", "Grupo de canal", "channelGroup")], { cldbid: "databaseId", cid: "channelId", cgid: "groupId" }, { databaseId: "{{event.client_database_id}}", channelId: 21, groupId: "" }, ), groupClients: action( "Clientes de um grupo", "Grupos", "servergroupclientlist", [group, f("storeAs", "Guardar em temp…")], { sgid: "groupId" }, { groupId: 9, storeAs: "groupClients" }, ), clientGroups: action( "Grupos de um cliente", "Grupos", "servergroupsbyclientid", [dbclient, f("storeAs", "Guardar em temp…")], { cldbid: "databaseId" }, { databaseId: "{{event.client_database_id}}", storeAs: "clientGroups" }, ), serverEdit: type( "Editar servidor", "Servidor", [f("params", "Campos do servidor (JSON)", "json")], { params: {} }, ), http: type( "Pedido HTTP", "Integrações", [ f("url", "Endereço"), f("method", "Método", "select", [ "GET", "POST", "PUT", "PATCH", "DELETE", ]), f("headers", "Cabeçalhos (JSON)", "json"), f("body", "Corpo", "textarea"), f("storeAs", "Guardar resposta em temp…"), ], { url: "https://example.com", method: "GET", headers: {}, body: "", storeAs: "http", }, ), discord: type( "Webhook Discord", "Integrações", [ f("url", "URL do webhook", "password"), f("text", "Mensagem", "textarea"), f("username", "Nome (opcional)"), ], { url: "", text: "{{text}}", username: "XanaX Botflow" }, ), webquery: type( "WebQuery avançado", "Integrações", [ f("command", "Operação WebQuery", "queryCommand"), f("params", "Parâmetros (JSON)", "json"), f("storeAs", "Guardar em temp…"), ], { command: "serverinfo", params: {}, storeAs: "result" }, ), }; for (const [prefix, title, groupType, parameter] of [ ["servergroup", "servidor", "serverGroup", "sgid"], ["channelgroup", "canal", "channelGroup", "cgid"], ]) { nodeTypes[`${prefix}Create`] = action( `Criar grupo de ${title}`, "Grupos", `${prefix}add`, [ f("name", "Nome"), f("type", "Tipo", "select", ["1", "2"]), f("storeAs", "Guardar resultado em temp…"), ], { name: "name", type: "type" }, { name: "Novo grupo", type: "1", storeAs: "createdGroup" }, ); nodeTypes[`${prefix}Rename`] = action( `Renomear grupo de ${title}`, "Grupos", `${prefix}rename`, [f("groupId", `Grupo de ${title}`, groupType), f("name", "Novo nome")], { [parameter]: "groupId", name: "name" }, { groupId: 9, name: "Novo nome" }, ); nodeTypes[`${prefix}Delete`] = action( `Apagar grupo de ${title}`, "Grupos", `${prefix}del`, [ f("groupId", `Grupo de ${title}`, groupType), f("force", "Forçar", "select", ["0", "1"]), ], { [parameter]: "groupId", force: "force" }, { groupId: 9, force: "0" }, ); } for (const [scope, scopeTitle, idFields, idParams] of [ ["client", "Cliente", [dbclient], { cldbid: "databaseId" }], ["channel", "Canal", [channel], { cid: "channelId" }], ["servergroup", "Grupo de servidor", [group], { sgid: "groupId" }], [ "channelgroup", "Grupo de canal", [f("groupId", "Grupo de canal", "channelGroup")], { cgid: "groupId" }, ], [ "channelclient", "Cliente num canal", [dbclient, channel], { cldbid: "databaseId", cid: "channelId" }, ], ]) { nodeTypes[`${scope}PermissionSet`] = action( `Permissão: ${scopeTitle}`, "Permissões", `${scope}addperm`, [ ...idFields, permission, f("value", "Valor"), ...(["client", "servergroup"].includes(scope) ? [f("skip", "Ignorar grupo de canal (skip)", "select", ["0", "1"])] : []), ...(scope === "servergroup" ? [f("negated", "Negado (negated)", "select", ["0", "1"])] : []), ], { ...idParams, permsid: "permission", permvalue: "value", permskip: "skip", permnegated: "negated", }, { databaseId: "{{event.client_database_id}}", channelId: 21, groupId: 9, permission: "i_client_talk_power", value: "0", skip: "0", negated: "0", }, ); nodeTypes[`${scope}PermissionRemove`] = action( `Remover permissão: ${scopeTitle}`, "Permissões", `${scope}delperm`, [...idFields, permission], { ...idParams, permsid: "permission" }, { databaseId: "{{event.client_database_id}}", channelId: 21, groupId: 9, permission: "i_client_talk_power", }, ); nodeTypes[`${scope}PermissionList`] = action( `Consultar permissões: ${scopeTitle}`, "Permissões", `${scope}permlist`, [...idFields, f("storeAs", "Guardar em temp…")], idParams, { databaseId: "{{event.client_database_id}}", channelId: 21, groupId: 9, storeAs: "permissions", }, ); } export const queryCommands = [ ...new Set([ "serverinfo", "serverlist", "hostinfo", "whoami", "version", "clientgetdbidfromuid", "clientgetids", "clientgetnamefromdbid", "clientfind", "channelfind", "banlist", "banadd", "tokenlist", "tokenadd", "tokendelete", "messageadd", "messagelist", "messageget", "messagedel", "complainlist", "complainadd", "complaindel", "logview", "connectioninfo", ...Object.values(nodeTypes) .map((t) => t.command) .filter(Boolean), "channeledit", "channelcreate", "serveredit", "servergroupcopy", "channelgroupcopy", "clientedit", "sendtextmessage", "clientupdate", ]), ].sort(); export function validateGraph(graph) { if ( !graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges) || graph.nodes.length > 150 || graph.edges.length > 400 ) throw new Error("Usa até 150 blocos e 400 ligações."); const ids = new Map(); for (const n of graph.nodes) { if ( !nodeTypes[n.type] || typeof n.id !== "string" || !/^[\w-]{1,100}$/.test(n.id) || ids.has(n.id) || !Number.isFinite(n.x) || !Number.isFinite(n.y) || !n.config || typeof n.config !== "object" || Array.isArray(n.config) ) throw new Error("Bloco inválido no flow."); ids.set(n.id, n); n.config = { ...structuredClone(nodeTypes[n.type].defaults), ...n.config }; for (const [key, label, fieldType] of nodeTypes[n.type].fields) { if (fieldType !== "json" || n.config[key] == null) continue; let value = n.config[key]; if (typeof value === "string") { try { value = JSON.parse(value); } catch { throw new Error( `${n.label || nodeTypes[n.type].title}: JSON inválido em ${label}.`, ); } } if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: usa um objeto JSON.`); n.config[key] = value; } for (const [parameter, key] of Object.entries( nodeTypes[n.type].params || {}, )) { if ( !["clid", "cldbid", "cid", "sgid", "cgid", "banid"].includes(parameter) ) continue; const value = n.config[key]; if (typeof value === "string" && /\{\{[^{}]+\}\}/.test(value)) continue; if (!Number.isInteger(Number(value)) || Number(value) < 1) throw new Error( `${n.label || nodeTypes[n.type].title}: configura ${key} com um ID ou variável.`, ); } if ( n.type === "webquery" && !/\{\{/.test(n.config.command) && !queryCommands.includes(n.config.command) ) throw new Error("Operação WebQuery não suportada."); if ( n.type === "timer" && (!Number.isFinite(Number(n.config.seconds)) || Number(n.config.seconds) < 1) ) throw new Error("O intervalo mínimo é 1 segundo."); if ( n.type === "delay" && (!Number.isFinite(Number(n.config.seconds)) || Number(n.config.seconds) < 0 || Number(n.config.seconds) > 300) ) throw new Error("A espera máxima é 300 segundos."); if ( n.type === "limit" && (!Number.isInteger(Number(n.config.count)) || Number(n.config.count) < 1 || Number(n.config.count) > 1000) ) throw new Error("O limite precisa de ser entre 1 e 1000."); if ( n.type === "foreach" && (!Number.isInteger(Number(n.config.limit)) || n.config.limit < 1 || n.config.limit > 1000) ) throw new Error("Limite de repetição inválido."); if ( n.type === "random" && (!Number.isInteger(Number(n.config.length)) || n.config.length < 1 || n.config.length > 128) ) throw new Error("O código precisa de ter entre 1 e 128 caracteres."); if (n.type === "webhook" && String(n.config.secret || "").length < 16) throw new Error( "Usa um segredo de webhook com pelo menos 16 caracteres.", ); if (n.type === "command" && !/^\S{1,64}$/.test(n.config.command || "")) throw new Error("Indica um comando de chat sem espaços."); if ( n.config.retries != null && (!Number.isInteger(Number(n.config.retries)) || n.config.retries < 0 || n.config.retries > 3) ) throw new Error("São permitidas até 3 tentativas adicionais."); } if (!graph.nodes.some((n) => nodeTypes[n.type].trigger)) throw new Error("Adiciona pelo menos um bloco de início."); const seenEdges = new Set(); for (const e of graph.edges) { const source = ids.get(e.from), target = ids.get(e.to), key = `${e.from}:${e.port}:${e.to}`; if ( !source || !target || source === target || nodeTypes[target.type].trigger || !nodeTypes[source.type].outputs.includes(e.port) || seenEdges.has(key) ) throw new Error("Ligação inválida no flow."); seenEdges.add(key); } const visiting = new Set(), visited = new Set(); function visit(id) { if (visiting.has(id)) throw new Error( "O flow contém um ciclo. Usa Para cada item ou um temporizador para repetir.", ); if (visited.has(id)) return; visiting.add(id); for (const e of graph.edges.filter((e) => e.from === id)) visit(e.to); visiting.delete(id); visited.add(id); } for (const id of ids.keys()) visit(id); return graph; }