Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
# zander-web
The web component of the Zander project that contains database, API and website.

Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander)
Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander)

## Tebex bridge integration

The `/api/tebex/webhook` endpoint accepts Tebex purchase webhooks and queues bridge tasks for any packages configured in `tebex.json`. Update the mapping file with your package IDs, the commands or rank assignments to trigger, and (optionally) override the target slug or priority per action.

Secure the webhook by setting the `TEBEX_WEBHOOK_SECRET` environment variable (or the legacy `tebexWebhookSecret`). Requests must include the matching token via an `Authorization`, `X-Tebex-Secret`, or `X-Webhook-Secret` header.
173 changes: 153 additions & 20 deletions api/routes/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,26 @@ const ROUTINE_TABLE = "executorRoutines";
const ROUTINE_STEPS_TABLE = "executorRoutineSteps";

const VALID_STATUSES = ["pending", "processing", "completed", "failed"];
const ROUTINE_CONFIG_KEY = "_routineConfig";
const DEFAULT_PLAYER_METADATA_KEY = "player";

function normalizeCommand(command) {
export function normalizeCommand(command) {
if (typeof command !== "string") {
return "";
}

return command.trim().replace(/^\/+/, "").trim();
}

function toMetadataObject(value) {
export function toMetadataObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}

return value;
}

function mergeMetadata(...sources) {
export function mergeMetadata(...sources) {
const merged = {};
let hasEntries = false;

Expand All @@ -46,7 +48,7 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function applyMetadataPlaceholders(command, ...metadataSources) {
export function applyMetadataPlaceholders(command, ...metadataSources) {
let resolved = typeof command === "string" ? command : "";

const mergedMetadata = mergeMetadata(...metadataSources);
Expand Down Expand Up @@ -87,6 +89,61 @@ function safeJsonParse(value) {
}
}

function readRoutineConfig(metadata) {
if (!metadata || typeof metadata !== "object") {
return {};
}

const config = metadata[ROUTINE_CONFIG_KEY];
if (!config || typeof config !== "object") {
return {};
}

return { ...config };
}

function setRoutineConfig(metadata, updates = {}, options = {}) {
if (!metadata || typeof metadata !== "object") {
return;
}

const { overwrite = false } = options;
const baseConfig = overwrite ? {} : readRoutineConfig(metadata);
metadata[ROUTINE_CONFIG_KEY] = { ...baseConfig, ...updates };
}

export function normalizeRankAction(action) {
if (typeof action !== "string") {
return "assign";
}

return action.toLowerCase() === "remove" ? "remove" : "assign";
}

export function normalizePlayerMetadataKey(value) {
if (typeof value !== "string") {
return DEFAULT_PLAYER_METADATA_KEY;
}

const trimmed = value.trim();
return trimmed || DEFAULT_PLAYER_METADATA_KEY;
}

export function buildRankCommand(rankSlug, rankAction, playerMetadataKey) {
if (!rankSlug) {
return "";
}

const action = normalizeRankAction(rankAction);
const playerKey = normalizePlayerMetadataKey(playerMetadataKey);

if (action === "remove") {
return `lp user {{${playerKey}}} parent remove ${rankSlug}`;
}

return `lp user {{${playerKey}}} parent add ${rankSlug}`;
}

export default function bridgeApiRoute(app, config, db, features, lang) {
function query(sql, params = []) {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -238,19 +295,56 @@ export default function bridgeApiRoute(app, config, db, features, lang) {
throw new Error(`Task payload at index ${index} must be an object`);
}

if (!task.command) {
throw new Error(`Task payload at index ${index} is missing 'command'`);
}

const taskSlug = task.slug || task.target || inlineSlug;
if (!taskSlug) {
throw new Error(`Task payload at index ${index} is missing 'slug'`);
}

const stepMetadata = toMetadataObject(task.metadata);
const baseMetadata = toMetadataObject(task.metadata);
const stepMetadata = baseMetadata ? { ...baseMetadata } : {};
const config = readRoutineConfig(stepMetadata);
const explicitType =
typeof task.type === "string" ? task.type.toLowerCase() : "";
const taskType = explicitType || (config.type ? String(config.type).toLowerCase() : "command");

let commandTemplate = typeof task.command === "string" ? task.command : "";

if (taskType === "rank") {
const rankSlug = (task.rankSlug || config.rankSlug || "").trim();
if (!rankSlug) {
throw new Error(
`Task payload at index ${index} is missing 'rankSlug' for rank assignment`
);
}

const rankAction = normalizeRankAction(task.rankAction || config.rankAction);
const playerKey = normalizePlayerMetadataKey(
task.playerMetadataKey || config.playerMetadataKey
);

commandTemplate = buildRankCommand(rankSlug, rankAction, playerKey);

setRoutineConfig(
stepMetadata,
{
type: "rank",
rankSlug,
rankAction,
playerMetadataKey: playerKey,
},
{ overwrite: true }
);
} else {
if (!commandTemplate) {
throw new Error(`Task payload at index ${index} is missing 'command'`);
}

setRoutineConfig(stepMetadata, { type: "command" });
}

const combinedMetadata = mergeMetadata(rootMetadata, stepMetadata);
const resolvedCommand = normalizeCommand(
applyMetadataPlaceholders(task.command, rootMetadata, stepMetadata)
applyMetadataPlaceholders(commandTemplate, rootMetadata, stepMetadata)
);

if (!resolvedCommand) {
Expand Down Expand Up @@ -552,30 +646,69 @@ export default function bridgeApiRoute(app, config, db, features, lang) {
throw new Error(`Routine step at index ${index} must be an object`);
}

if (!step.command) {
throw new Error(`Routine step at index ${index} is missing 'command'`);
}

const stepSlug = step.slug || step.target;
if (!stepSlug) {
throw new Error(`Routine step at index ${index} is missing 'slug'`);
}

const orderValue = Number(step.order ?? index);
const metadataObject = toMetadataObject(step.metadata);
const sanitizedCommand = normalizeCommand(step.command);
const baseMetadata = toMetadataObject(step.metadata);
const metadataObject = baseMetadata ? { ...baseMetadata } : {};
const config = readRoutineConfig(metadataObject);
const explicitType =
typeof step.type === "string" ? step.type.toLowerCase() : "";
const stepType = explicitType || (config.type ? String(config.type).toLowerCase() : "command");

let sanitizedCommand = "";

if (stepType === "rank") {
const rankSlug = (step.rankSlug || config.rankSlug || "").trim();
if (!rankSlug) {
throw new Error(
`Routine step at index ${index} is missing 'rankSlug' for rank assignment`
);
}

const rankAction = normalizeRankAction(step.rankAction || config.rankAction);
const playerKey = normalizePlayerMetadataKey(
step.playerMetadataKey || config.playerMetadataKey
);

sanitizedCommand = normalizeCommand(
buildRankCommand(rankSlug, rankAction, playerKey)
);

if (!sanitizedCommand) {
throw new Error(
`Routine step at index ${index} must include a command after removing leading slashes`
setRoutineConfig(
metadataObject,
{
type: "rank",
rankSlug,
rankAction,
playerMetadataKey: playerKey,
},
{ overwrite: true }
);
} else {
const commandSource = typeof step.command === "string" ? step.command : "";
sanitizedCommand = normalizeCommand(commandSource);

if (!sanitizedCommand) {
throw new Error(
`Routine step at index ${index} must include a command after removing leading slashes`
);
}

setRoutineConfig(metadataObject, { type: "command" });
}

const metadataPayload =
metadataObject && Object.keys(metadataObject).length ? metadataObject : null;

return {
slug: stepSlug,
command: sanitizedCommand,
order: Number.isFinite(orderValue) ? orderValue : index,
metadata: metadataObject,
metadata: metadataPayload,
};
});

Expand Down
14 changes: 8 additions & 6 deletions api/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import webApiRoute from "./web.js";
import filterApiRoute from "./filter.js";
import rankApiRoute from "./ranks.js";
import reportApiRoute from "./report.js";
import shopApiRoute from "./shopdirectory.js";
import vaultApiRoute from "./vault.js";
import bridgeApiRoute from "./bridge.js";
import shopApiRoute from "./shopdirectory.js";
import vaultApiRoute from "./vault.js";
import bridgeApiRoute from "./bridge.js";
import tebexApiRoute from "./tebex.js";

export default (app, client, moment, config, db, features, lang) => {
announcementApiRoute(app, config, db, features, lang);
Expand All @@ -23,9 +24,10 @@ export default (app, client, moment, config, db, features, lang) => {
webApiRoute(app, config, db, features, lang);
rankApiRoute(app, config, db, features, lang);
filterApiRoute(app, client, config, db, features, lang);
shopApiRoute(app, config, db, features, lang);
vaultApiRoute(app, config, db, features, lang);
bridgeApiRoute(app, config, db, features, lang);
shopApiRoute(app, config, db, features, lang);
vaultApiRoute(app, config, db, features, lang);
bridgeApiRoute(app, config, db, features, lang);
tebexApiRoute(app, config, db, features, lang);

app.get("/api/heartbeat", async function (req, res) {
return res.send({
Expand Down
Loading