all repos — discord-utils @ b2c00f4dd362361aace8e4afc9baf28c31a0cb18

feat: core command logic; stub out api stuff
vi did:web:vt3e.cat
Sun, 24 May 2026 02:36:21 +0100
commit

b2c00f4dd362361aace8e4afc9baf28c31a0cb18

parent

c287392a83b2e1c582aa9c6a7d659bc143867536

A build.ts

@@ -0,0 +1,7 @@

+await Bun.build({ + entrypoints: ["./src/index.ts"], + compile: { + outfile: "./bin", + }, + target: "bun", +});
M bun.lockbun.lock

@@ -6,9 +6,11 @@ "": {

"name": "utils", "dependencies": { "@sapphire/framework": "^5.5.0", + "@sapphire/result": "^2.8.0", "discord.js": "^14.26.4", "pino": "^10.3.1", "pino-pretty": "^13.1.3", + "real-require": "^1.0.0", "zod": "^4.4.3", }, "devDependencies": {

@@ -200,7 +202,7 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],

"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], - "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],

@@ -242,6 +244,6 @@ "@sapphire/discord.js-utilities/@sapphire/discord-utilities": ["@sapphire/discord-utilities@3.5.0", "", { "dependencies": { "discord-api-types": "^0.38.1" } }, "sha512-H4SY5KTVDZrqA5QG7ob6etwqhdOb3TRSY2wv56f0tiobUdIr0irlrYvdmr8Kg/FRxWU+aiHDIISWGG5vBuxOGw=="],

"@sapphire/pieces/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], - "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "pino/real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], } }
M package.jsonpackage.json

@@ -10,9 +10,11 @@ "lint": "bunx oxlint lint src tests"

}, "dependencies": { "@sapphire/framework": "^5.5.0", + "@sapphire/result": "^2.8.0", "discord.js": "^14.26.4", "pino": "^10.3.1", "pino-pretty": "^13.1.3", + "real-require": "^1.0.0", "zod": "^4.4.3" }, "devDependencies": {
A src/commands/ban.ts

@@ -0,0 +1,195 @@

+import { Command, container, Result } from "@sapphire/framework"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; + +import { parseIncludedGames, parseDuration, createReactive } from "@/utils"; +import config, { type Game } from "@/config"; +import { ban, getUser } from "@/roblox"; + +const { games: GAMES } = config; +type State = { + prefix: string; + successfulBans: Game[]; + failedBans: Game[]; +}; + +function renderGameButtons(state: State, banningIn: Game[]): ActionRowBuilder<ButtonBuilder> { + const { successfulBans, failedBans } = state; + const allBans = [...successfulBans, ...failedBans]; + + const successfulIds = new Set(successfulBans.map((b) => b.id)); + const failedIds = new Set(failedBans.map((b) => b.id)); + const pendingIds = new Set(banningIn.map((b) => b.id)); + const allBanIds = new Set(allBans.map((b) => b.id)); + const excludedGameIds = new Set( + GAMES.filter((game) => !allBanIds.has(game.id)).map((game) => game.id), + ); + + const row = new ActionRowBuilder<ButtonBuilder>(); + + for (const game of GAMES) { + const isSuccessful = successfulIds.has(game.id); + const isFailed = failedIds.has(game.id); + const isPending = pendingIds.has(game.id); + const isExcluded = excludedGameIds.has(game.id); + + container.logger.debug( + `isExcluded: ${isExcluded}, isSuccessful: ${isSuccessful}, isFailed: ${isFailed}, isPending: ${isPending}`, + ); + + const button = new ButtonBuilder() + .setLabel(game.name) + .setDisabled(true) + .setCustomId(`ban_${game.id}`); + + if (isSuccessful) { + button.setStyle(ButtonStyle.Success); + button.setEmoji("✅"); + } else if (isFailed) { + button.setStyle(ButtonStyle.Danger); + button.setEmoji("✖️"); + } else if (isPending) { + button.setStyle(ButtonStyle.Primary); + button.setEmoji("⏳"); + } else { + button.setStyle(ButtonStyle.Secondary); + button.setEmoji("➖"); + } + + row.addComponents(button); + } + + return row; +} + +export class BanCommand extends Command { + public constructor(context: Command.LoaderContext, options: Command.Options) { + super(context, { ...options }); + } + + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand((builder) => + builder + .setName("ban") + .setDescription("ban a player") + .addStringOption((option) => + option.setName("player").setDescription("the player to ban").setRequired(true), + ) + .addStringOption((option) => + option.setName("reason").setDescription("the reason for the ban").setRequired(true), + ) + .addStringOption((option) => + option + .setName("private-reason") + .setDescription("the private reason for the ban") + .setRequired(true), + ) + .addStringOption((option) => + option + .setName("length") + .setDescription("length of the ban; defaults to permanent") + .setRequired(false), + ) + .addBooleanOption((option) => + option + .setName("ban-alts") + .setDescription("whether to ban alts of the player") + .setRequired(false), + ) + .addStringOption((option) => + option + .setName("games") + .setDescription("the games to ban them from; defaults to all") + .setRequired(false), + ), + ); + } + + public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + await interaction.deferReply({ withResponse: true }); + + const options = { + player: interaction.options.getString("player", true), + reason: interaction.options.getString("reason", true), + privateReason: interaction.options.getString("private-reason", true), + length: interaction.options.getString("length", false), + banAlts: interaction.options.getBoolean("ban-alts", false), + includedGames: interaction.options.getString("games", false), + }; + const id = options.player.startsWith("id:") ? options.player.slice(3) : options.player; + + const gamesResult = Result.from(() => parseIncludedGames(options.includedGames)); + if (gamesResult.isErr()) { + const err = gamesResult.unwrapErr(); + const message = err instanceof Error ? err.message : "failed to parse included games"; + + await interaction.editReply({ content: message }); + return; + } + + const games = gamesResult.unwrap(); + const user = await getUser(id); + const duration = options.length ? parseDuration(options.length).toString() : undefined; + + const extraButtons = new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setLabel(options.length ? `For ${options.length}` : "Permanently") + .setStyle(ButtonStyle.Primary) + .setDisabled(true) + .setCustomId("duration"), + new ButtonBuilder() + .setLabel("View Profile") + .setStyle(ButtonStyle.Link) + .setURL(`https://roblox.com/users/${user.id}/profile`), + new ButtonBuilder() + .setLabel("Unban") + .setStyle(ButtonStyle.Secondary) + .setCustomId(`unban:${user.id}`), + ); + + const details = [ + `> **Reason:** ${options.reason}`, + `> **Private Reason:** ${options.privateReason}`, + ].join("\n"); + + const state = createReactive<State>( + { successfulBans: [], failedBans: [], prefix: "⏳ Banning" }, + (updatedState) => { + this.container.logger.debug("state updated", updatedState); + const gameButtons = renderGameButtons(state, games); + + const userParts = [ + `**@${user.username}**`, + user.displayName ? `(${user.displayName})` : null, + `id:${user.id}`, + ]; + const userString = userParts.filter(Boolean).join(" "); + const message = `${state.prefix} ${userString}\n${details}`; + + interaction.editReply({ content: message, components: [gameButtons, extraButtons] }); + }, + ); + + const banOptions = { + duration, + excludeAltAccounts: !options.banAlts, + displayReason: options.reason, + privateReason: options.privateReason, + }; + + await Promise.all( + games.map(async (game) => { + const banResult = await Result.fromAsync(() => ban(id, game.universeId, banOptions)); + if (banResult.isOk()) state.successfulBans.push(game); + else state.failedBans.push(game); + }), + ); + + state.prefix = "✅ Banned"; + } +} + +container.stores.loadPiece({ + piece: BanCommand, + name: "ban", + store: "commands", +});
A src/commands/index.ts

@@ -0,0 +1,1 @@

+export * from "./ban";
M src/config.tssrc/config.ts

@@ -9,9 +9,29 @@ token: z.string(),

app_id: z.string(), }); +const gameSchema = z + .object({ + name: z.string(), + universeId: z.string(), + }) + .transform((game) => { + const initials = game.name + .split(" ") + .map((word) => word[0]) + .join(""); + + return { + ...game, + id: initials.toLowerCase(), + }; + }); + +const gamesSchema = z.array(gameSchema); + const configSchema = z .object({ discord: discordSchema, + games: gamesSchema, }) .transform((config) => { return {

@@ -21,8 +41,11 @@ isProduction: IS_PROD,

}; }); +// --- types ------------------------------------------------------------------ export type Config = z.infer<typeof configSchema>; +export type Game = z.infer<typeof gameSchema>; +// ---------------------------------------------------------------------------- async function loadConfig(): Promise<Config> { const file = Bun.file(CONFIG_PATH); if (!(await file.exists())) {

@@ -45,5 +68,6 @@ }

process.exit(1); } +// ---------------------------------------------------------------------------- const config = await loadConfig(); export default config;
M src/discord/client.tssrc/discord/client.ts

@@ -1,8 +1,11 @@

import { SapphireClient } from "@sapphire/framework"; + import { PinoSapphireLogger } from "@/logger"; +import "@/commands"; export const client = new SapphireClient({ intents: [], + baseUserDirectory: null, allowedMentions: { parse: [], repliedUser: true,
M src/logger.tssrc/logger.ts

@@ -11,8 +11,12 @@ this.minLevel = minLevel;

this.pino = pino({ transport: config.isProduction - ? { target: "pino-pretty", options: { colorize: true } } - : undefined, + ? undefined + : { target: "pino-pretty", options: { colorize: true } }, + base: { + pid: false, + }, + level: Bun.env.LOG_LEVEL ?? (config.isProduction ? "info" : "debug"), ...options, }); }
A src/roblox/index.ts

@@ -0,0 +1,32 @@

+export type RobloxUser = { + username: string; + displayName: string | null; + id: string; +}; + +export async function getUser(id: string): Promise<RobloxUser> { + const includeDisplayName = Math.random() < 0.5; + + return { + username: "stub", + displayName: includeDisplayName ? "stubDisplay" : null, + id: id, + }; +} + +export async function ban( + id: string, + universeId: string, + options: { + duration: string | undefined; + excludeAltAccounts: boolean; + displayReason: string; + privateReason: string; + }, +): Promise<void> { + const delay = Math.floor(Math.random() * 750) + 250; + await new Promise((resolve) => setTimeout(resolve, delay)); + + const chance = Math.random(); + if (chance < 0.3) throw new Error("Failed to ban user"); +}
A src/utils/games.ts

@@ -0,0 +1,25 @@

+import config, { type Game } from "@/config"; + +const { games: GAMES } = config; + +export function getGame(id: string) { + const game = GAMES.find((game) => game.universeId === id); + return game; +} + +export function getGames(ids: string[]) { + return GAMES.filter((game) => ids.includes(game.id)); +} + +export function parseIncludedGames(games: string | null | undefined): Game[] { + if (!games) return GAMES; + + const parsed = games.split(","); + const acceptedIds = GAMES.map((game) => game.id); + + const unmatched = parsed.filter((id) => !acceptedIds.includes(id)); + if (unmatched.length > 0) throw new Error(`Invalid game ID: ${unmatched.join(", ")}`); + + const matched = parsed.filter((id) => acceptedIds.includes(id.toLowerCase())); + return getGames(matched); +}
A src/utils/index.ts

@@ -0,0 +1,3 @@

+export * from "./reactivity"; +export * from "./time"; +export * from "./games";
A src/utils/reactivity.ts

@@ -0,0 +1,26 @@

+export function createReactive<T extends object>(initialState: T, onChange: (state: T) => void): T { + const handler: ProxyHandler<any> = { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof value === "object" && value !== null) return new Proxy(value, handler); + + return value; + }, + set(target, prop, value, receiver) { + const oldValue = Reflect.get(target, prop, receiver); + const success = Reflect.set(target, prop, value, receiver); + + if (success && oldValue !== value) onChange(initialState); + + return success; + }, + deleteProperty(target, prop) { + const success = Reflect.deleteProperty(target, prop); + if (success) onChange(initialState); + + return success; + }, + }; + + return new Proxy(initialState, handler); +}
A src/utils/time.ts

@@ -0,0 +1,26 @@

+export function parseDuration(input: string): number { + const matches = input.match(/(\d+)([smhd])/g) || []; + let totalSeconds = 0; + + for (const match of matches) { + const value = Number.parseInt(match.match(/\d+/)?.[0] || "0", 10); + const unit = match.match(/[smhd]/)?.[0]; + + switch (unit) { + case "s": + totalSeconds += value; + break; + case "m": + totalSeconds += value * 60; + break; + case "h": + totalSeconds += value * 3600; + break; + case "d": + totalSeconds += value * 86400; + break; + } + } + + return totalSeconds; +}