All checks were successful
Testar e publicar imagem Docker / publish (push) Successful in 44s
523 lines
16 KiB
JavaScript
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 };
|
|
}
|