ts6-botflow/flow-catalog.mjs
Bxio 1be8a45dde
All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
Expand flow editor with TeamSpeak events, permissions and execution controls
2026-10-10 20:38:31 +01:00

799 lines
22 KiB
JavaScript

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