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 { 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(); 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().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( { 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", });