Files
EpicNext-Cms/src/lib/services/rcon.ts
T
SimoandCursor 9854719cfd
CI / check (push) Successful in 24s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m2s
feat(admin): drizzle trade-lock + RCON sync and photo local purge
Co-authored-by: Cursor <[email protected]>
2026-07-31 21:14:03 +02:00

170 lines
5.1 KiB
TypeScript

import net from "node:net";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import { CurrencyType } from "@/lib/services/currency";
export interface RconPayload {
key: string;
data: Record<string, unknown> | null;
}
export type RconTransport = (payload: RconPayload) => Promise<boolean>;
/**
* TCP transport faithful to AtomCMS's RconService: writes the raw JSON
* `{"key":...,"data":...}` to rcon_ip:rcon_port with no terminator, fire-and-
* forget. The PHP original silently drops on a dead socket; we add a bounded
* connect timeout + exponential-backoff retry so delivery is best-effort.
*/
export function tcpTransport(opts: {
host: string;
port: number;
timeoutMs: number;
maxRetries: number;
}): RconTransport {
const sendOnce = (payload: RconPayload): Promise<boolean> =>
new Promise((resolve) => {
const socket = new net.Socket();
const json = JSON.stringify(payload);
let settled = false;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
resolve(ok);
};
const timer = setTimeout(() => finish(false), opts.timeoutMs);
socket.once("error", () => finish(false));
socket.connect(opts.port, opts.host, () => {
socket.write(json, () => finish(true));
});
});
return async (payload) => {
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
if (await sendOnce(payload)) return true;
if (attempt < opts.maxRetries - 1) {
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
}
}
logger.error("RCON delivery failed after retries", {
module: "rcon",
key: payload.key,
});
return false;
};
}
/**
* Typed RCON commands. Payload shapes mirror AtomCMS's RconService EXACTLY
* (e.g. sendgift uses `itemid`, disconnect carries `username`, no-data commands
* send `data: null`, givepoints `type` is the numeric CurrencyType value).
*/
export class RconClient {
constructor(private readonly transport: RconTransport) {}
send(
key: string,
data: Record<string, unknown> | null = null,
): Promise<boolean> {
return this.transport({ key, data });
}
giveCredits(userId: number, credits: number) {
return this.send("givecredits", { user_id: userId, credits });
}
givePoints(userId: number, amount: number, type: CurrencyType) {
return this.send("givepoints", { user_id: userId, points: amount, type });
}
giveDuckets(userId: number, amount: number) {
return this.givePoints(userId, amount, CurrencyType.Duckets);
}
giveDiamonds(userId: number, amount: number) {
return this.givePoints(userId, amount, CurrencyType.Diamonds);
}
givePointsGotw(userId: number, amount: number) {
return this.givePoints(userId, amount, CurrencyType.Points);
}
giveBadge(userId: number, badge: string) {
return this.send("givebadge", { user_id: userId, badge });
}
setMotto(userId: number, motto: string) {
return this.send("setmotto", { user_id: userId, motto });
}
setRank(userId: number, rank: number) {
return this.send("setrank", { user_id: userId, rank });
}
sendGift(userId: number, itemId: number, message = "Here is a gift.") {
return this.send("sendgift", { user_id: userId, itemid: itemId, message });
}
disconnectUser(userId: number, username = "") {
return this.send("disconnect", { user_id: userId, username });
}
alertUser(userId: number, message: string) {
return this.send("alertuser", { user_id: userId, message });
}
forwardUser(userId: number, roomId: number) {
return this.send("forwarduser", { user_id: userId, room_id: roomId });
}
kickAll(roomId: number) {
return this.send("kickall", { room_id: roomId });
}
hotelAlert(message: string) {
return this.send("hotelalert", { message });
}
staffAlert(message: string) {
return this.send("staffalert", { message });
}
executeCommand(userId: number, command: string) {
return this.send("executecommand", { user_id: userId, command });
}
updateWordFilter() {
return this.send("updatewordfilter");
}
updateCatalog() {
return this.send("updatecatalog");
}
updateItems() {
return this.send("updateitems");
}
updateConfig() {
return this.send("updateconfig");
}
removeBadge(userId: number, badge: string) {
return this.send("removebadge", { user_id: userId, badge });
}
muteUser(userId: number, duration: number) {
return this.send("muteuser", { user_id: userId, duration });
}
unmuteUser(userId: number) {
return this.send("unmuteuser", { user_id: userId });
}
/**
* Best-effort live trade lock sync. Polaris/Arcturus forks may expose
* `settradelock`; unknown keys are ignored by the emulator. Prefer updating
* `users_settings.can_trade` in DB and disconnecting online users.
*/
setTradeLock(userId: number, locked: boolean) {
return this.send("settradelock", {
user_id: userId,
enabled: locked ? 0 : 1,
});
}
}
const globalForRcon = globalThis as unknown as { rcon?: RconClient };
export const rcon =
globalForRcon.rcon ??
new RconClient(
tcpTransport({
host: env.RCON_HOST,
port: env.RCON_PORT,
timeoutMs: env.RCON_TIMEOUT_MS,
maxRetries: env.RCON_MAX_RETRIES,
}),
);
if (env.NODE_ENV !== "production") globalForRcon.rcon = rcon;