ts6-botflow/flow-steps.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

523 lines
16 KiB
JavaScript

import { randomInt } from "node:crypto";
import http from "node:http";
import https from "node:https";
import { nodeTypes, queryCommands } from "./flow-catalog.mjs";
import {
valueAt,
interpolate,
evaluate,
compare,
resolveObject,
} from "./flow-values.mjs";
export const exampleClients = [
{
clid: "1",
cid: "21",
client_database_id: "10",
client_type: "0",
client_nickname: "Pessoa A",
client_lastconnected: "1",
client_idle_time: "0",
},
{
clid: "2",
cid: "21",
client_database_id: "11",
client_type: "0",
client_nickname: "Pessoa B",
client_lastconnected: "2",
client_idle_time: "600000",
},
{
clid: "3",
cid: "22",
client_database_id: "12",
client_type: "0",
client_nickname: "Pessoa C",
client_lastconnected: "3",
client_idle_time: "120000",
},
];
export function simulatedQuery(command) {
switch (command) {
case "serverinfo":
return [
{
virtualserver_clientsonline: 4,
virtualserver_queryclientsonline: 1,
virtualserver_maxclients: 32,
virtualserver_channelsonline: 17,
virtualserver_name: "Servidor de exemplo",
},
];
case "clientlist":
return structuredClone(exampleClients);
case "channellist":
return [
{
cid: "21",
channel_name: "Canal de exemplo",
channel_flag_permanent: "1",
total_clients: "3",
},
{
cid: "22",
channel_name: "AFK",
channel_flag_permanent: "1",
total_clients: "0",
},
];
case "servergrouplist":
return [{ sgid: "9", name: "Grupo de exemplo" }];
case "channelgrouplist":
return [{ cgid: "8", name: "Convidado" }];
case "permissionlist":
return [
{
permid: "1",
permname: "i_client_talk_power",
permdesc: "Poder de fala",
},
];
case "channelcreate":
return [{ cid: "99" }];
case "clientinfo":
return [structuredClone(exampleClients[0])];
case "servergroupsbyclientid":
return [{ sgid: "9" }];
default:
return [];
}
}
export function integer(value, label, min = 1, max = Number.MAX_SAFE_INTEGER) {
const number = Number(value);
if (
String(value ?? "").trim() === "" ||
!Number.isInteger(number) ||
number < min ||
number > max
)
throw new Error(`${label}: indica um número inteiro válido.`);
return number;
}
function object(value) {
if (typeof value === "string") {
try {
value = JSON.parse(value || "{}");
} catch {
throw new Error("JSON inválido nos parâmetros do bloco.");
}
}
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Usa um objeto JSON nos parâmetros.");
return value;
}
export function httpRequest(
url,
{ method = "GET", headers = {}, body = "", signal } = {},
) {
const address = new URL(url);
if (
!["http:", "https:"].includes(address.protocol) ||
address.username ||
address.password
)
throw new Error("Usa um endereço HTTP/HTTPS sem credenciais no URL.");
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method))
throw new Error("Método HTTP inválido.");
return new Promise((resolve, reject) => {
const req = (address.protocol === "https:" ? https : http).request(
address,
{ method, headers, signal, timeout: 10000 },
(res) => {
const chunks = [];
let size = 0;
res.on("data", (chunk) => {
size += chunk.length;
if (size > 2000000)
req.destroy(new Error("Resposta HTTP demasiado grande."));
else chunks.push(chunk);
});
res.on("error", reject);
res.on("end", () => {
if (res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode}: pedido recusado.`));
return;
}
const text = Buffer.concat(chunks).toString("utf8");
let data = text;
try {
data = JSON.parse(text);
} catch {}
resolve({ status: res.statusCode, body: data });
});
},
);
req.on("timeout", () =>
req.destroy(new Error("O pedido HTTP excedeu 10 segundos.")),
);
req.on("error", reject);
if (method !== "GET" && body) req.write(body);
req.end();
});
}
export async function executeStep(
node,
context,
{
simulation,
call,
persist,
signal,
httpTransport = httpRequest,
channelMessage,
},
) {
const type = nodeTypes[node.type],
config = { ...type.defaults, ...node.config },
c = resolveObject(config, context);
let message = "",
port = "out";
const store = (name, value) => {
if (!name) return;
if (
!/^[A-Za-z_]\w*$/.test(name) ||
["__proto__", "prototype", "constructor"].includes(name)
)
throw new Error("Nome de variável inválido.");
context.temp[name] = value;
};
const query = async (command, params) =>
simulation ? simulatedQuery(command) : call(command, params);
if (type.command) {
const params = Object.fromEntries(
Object.entries(type.params)
.map(([key, value]) => [key, c[value]])
.filter(([, value]) => value !== undefined && value !== ""),
);
for (const key of ["clid", "cldbid", "cid", "sgid", "cgid", "banid"])
if (params[key] != null) params[key] = integer(params[key], key);
for (const key of ["cpid", "order", "time", "start", "duration"])
if (params[key] != null) params[key] = integer(params[key], key, 0);
if (node.type.includes("PermissionSet")) {
if (!/^[bi]_[\w]+$/.test(c.permission))
throw new Error("Seleciona uma permissão válida.");
params.permvalue = integer(
c.value,
"Valor da permissão",
-2147483648,
2147483647,
);
if (!node.type.startsWith("servergroup")) delete params.permnegated;
if (
!node.type.startsWith("servergroup") &&
!node.type.startsWith("client")
)
delete params.permskip;
}
const result = await query(type.command, params);
store(
c.storeAs,
[
"clientInfo",
"channelInfo",
"servergroupCreate",
"channelgroupCreate",
].includes(node.type)
? result[0] || {}
: result,
);
context.temp.lastResult = result;
if (node.type === "channels") context.channels = result;
message = `${simulation ? "Simulação: " : ""}${type.command} · ${result.length} resultados`;
return { message, port };
}
switch (node.type) {
case "serverinfo":
context.server = (await query("serverinfo", {}))[0];
context.temp.server = context.server;
message = simulation
? "Dados de exemplo para simulação"
: "Informações obtidas";
break;
case "clients":
context.clients = (
await query("clientlist", {
"-times": true,
"-groups": true,
"-uid": true,
})
).filter(
(client) =>
c.includeQuery === "true" || Number(client.client_type) === 0,
);
context.temp.clients = context.clients;
context.count = context.clients.length;
message = `${context.count} clientes${simulation ? " (exemplo)" : ""}`;
break;
case "sort": {
const list = valueAt(context, c.list || "clients");
if (!Array.isArray(list))
throw new Error("A variável selecionada não é uma lista.");
const sorted = [...list].sort((a, b) => {
const av = valueAt(a, c.field),
bv = valueAt(b, c.field),
order =
Number.isFinite(Number(av)) && Number.isFinite(Number(bv))
? Number(av) - Number(bv)
: String(av ?? "").localeCompare(String(bv ?? ""));
return order * (c.direction === "desc" ? -1 : 1);
});
if ((c.list || "clients") === "clients") context.clients = sorted;
else store((c.list || "").replace(/^temp\./, ""), sorted);
message = `Ordenado por ${c.field}`;
break;
}
case "limit":
context.clients = (context.clients || []).slice(
0,
integer(c.count, "Limite", 1, 1000),
);
context.top = context.clients
.map((client, index) => `${index + 1}. ${client.client_nickname}`)
.join("\n");
message = `Selecionadas ${context.clients.length} pessoas`;
break;
case "filter": {
const list = valueAt(context, c.list);
if (!Array.isArray(list))
throw new Error("Seleciona uma lista para filtrar.");
const result = list.filter((item) =>
compare(valueAt(item, c.field), c.operator, c.value),
);
store(c.storeAs, result);
message = `${result.length} de ${list.length} itens`;
break;
}
case "condition":
port = compare(valueAt(context, c.field), c.operator, c.value)
? "true"
: "false";
message = port === "true" ? "Sim" : "Não";
break;
case "expression":
port = evaluate(c.expression, { ...context, time: context.clock })
? "true"
: "false";
message = port === "true" ? "Sim" : "Não";
break;
case "variable": {
if (
!/^[A-Za-z_]\w*$/.test(c.name) ||
["__proto__", "prototype", "constructor"].includes(c.name)
)
throw new Error("Nome de variável inválido.");
const target =
c.scope === "persistent" ? context.variables : context.temp;
switch (c.operation) {
case "delete":
delete target[c.name];
break;
case "increment": {
const value = Number(target[c.name] ?? 0) + Number(c.value);
if (!Number.isFinite(value))
throw new Error("O incremento precisa de um número.");
target[c.name] = value;
break;
}
case "append":
target[c.name] = String(target[c.name] ?? "") + String(c.value);
break;
case "set": {
let value = c.value;
if (c.valueType === "json") {
try {
value = JSON.parse(value);
} catch {
throw new Error("Valor JSON inválido.");
}
}
if (c.valueType === "number") {
value = Number(value);
if (!Number.isFinite(value) || String(c.value).trim() === "")
throw new Error("Valor numérico inválido.");
}
if (c.valueType === "boolean") {
if (!["true", "false"].includes(String(value)))
throw new Error("Usa true ou false para booleanos.");
value = String(value) === "true";
}
target[c.name] = value;
break;
}
default:
throw new Error("Operação inválida.");
}
if (c.scope === "persistent" && !simulation) persist(context.variables);
message = `${c.scope === "persistent" ? "Persistente" : "Temporária"}: ${c.name}`;
break;
}
case "calculate":
store(
c.storeAs,
evaluate(c.expression, { ...context, time: context.clock }),
);
message = `${c.storeAs} = ${context.temp[c.storeAs]}`;
break;
case "random": {
const alphabet =
c.alphabet === "numbers"
? "0123456789"
: c.alphabet === "letters"
? "ABCDEFGHJKLMNPQRSTUVWXYZ"
: "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let code = "";
for (let i = 0; i < integer(c.length, "Comprimento", 1, 128); i++)
code += alphabet[randomInt(alphabet.length)];
store(c.storeAs, code);
message = `Código guardado em temp.${c.storeAs}`;
break;
}
case "template":
context.text = c.text;
message = context.text;
break;
case "serverEdit": {
const params = object(c.params);
if (!Object.keys(params).length) {
message = "Nenhum campo de servidor indicado.";
break;
}
await query("serveredit", params);
message = `${simulation ? "Simulação: " : ""}Editar servidor · ${Object.keys(params).length} campos`;
break;
}
case "channelCreate":
case "channelEdit": {
const create = node.type === "channelCreate",
params = object(c.params || {});
if (create) {
if (!c.name) throw new Error("Indica um nome para o canal.");
Object.assign(params, {
channel_name: c.name,
cpid: integer(c.parentId ?? 0, "Canal pai", 0),
channel_flag_permanent: c.permanent === "true" ? 1 : 0,
});
} else params.cid = integer(c.channelId, "Canal");
for (const [key, field] of [
["channel_name", "name"],
["channel_topic", "topic"],
["channel_description", "description"],
["channel_password", "password"],
])
if (c[field] !== undefined && c[field] !== "") params[key] = c[field];
const result = await query(
create ? "channelcreate" : "channeledit",
params,
);
if (create) store(c.storeAs, result[0] || {});
message = `${simulation ? "Simulação: " : ""}${create ? "Criar canal" : "Editar canal " + c.channelId}${c.description ? "\n" + c.description : ""}`;
break;
}
case "message": {
const mode = { client: 1, channel: 2, server: 3 }[c.mode];
if (!mode) throw new Error("Destino da mensagem inválido.");
const target = mode === 3 ? 0 : integer(c.target, "Destino");
if (!simulation) {
if (mode === 2) await channelMessage(target, c.text);
else
await call("sendtextmessage", {
targetmode: mode,
target,
msg: c.text,
});
}
message = `${simulation ? "Simulação: " : ""}${c.text}`;
break;
}
case "http":
case "discord": {
const discord = node.type === "discord";
if (
discord &&
!/^https:\/\/(?:(?:canary|ptb)\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\//.test(
c.url,
)
)
throw new Error("Indica um URL de webhook Discord válido.");
const options = discord
? {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: c.text,
username: c.username || undefined,
allowed_mentions: { parse: [] },
}),
signal,
}
: {
method: c.method,
headers: object(c.headers || {}),
body: c.body,
signal,
};
const response = simulation
? { status: 200, body: { simulation: true } }
: await httpTransport(c.url, options);
store(c.storeAs, response.body);
context.temp.httpStatus = response.status;
message = simulation
? "Simulação: nenhum pedido HTTP enviado"
: `HTTP ${response.status}`;
break;
}
case "webquery": {
const command = String(c.command).toLowerCase();
if (!queryCommands.includes(command))
throw new Error("Operação WebQuery não suportada no editor.");
const result = await query(command, object(c.params || {}));
context.temp.lastResult = result;
store(c.storeAs, result);
message = `${simulation ? "Simulação: " : ""}${command} · ${result.length} resultados`;
break;
}
case "delay":
if (
!Number.isFinite(Number(c.seconds)) ||
Number(c.seconds) < 0 ||
Number(c.seconds) > 300
)
throw new Error("A espera deve ser entre 0 e 300 segundos.");
if (!simulation)
await new Promise((resolve, reject) => {
const finish = () => {
signal?.removeEventListener("abort", abort);
resolve();
},
timer = setTimeout(finish, Number(c.seconds) * 1000),
abort = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
reject(new Error("Execução cancelada."));
};
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
});
message = `${c.seconds} segundos`;
break;
case "log":
message = c.text;
break;
case "stop":
message = "Ramo terminado.";
port = null;
break;
default:
if (!type.trigger && node.type !== "foreach")
throw new Error("Bloco sem implementação.");
message = simulation ? "Teste manual do trigger" : "Flow iniciado";
}
return { message, port };
}