import { Result } from "@sapphire/result"; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType } from "discord.js"; import { parseIncludedGames, parseDuration, createReactive } from "@/utils"; import config, { type Game } from "@/config"; import { ban, getUser } from "@/roblox"; import { kitten } from "@/discord"; import { INTEGRATION_CONTEXT_PRESETS, INTEGRATION_TYPE_PRESETS, option } from "@purrkit/core"; const { games: GAMES } = config; type State = { prefix: string; successfulBans: Game[]; failedBans: Game[]; }; function renderGameButtons(state: State, banningIn: Game[]): ActionRowBuilder { const { successfulBans, failedBans } = state; 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 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 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 const banCommand = kitten.command("ban", { description: "ban a user from the specified games", contexts: INTEGRATION_CONTEXT_PRESETS.EVERYWHERE, integrationTypes: INTEGRATION_TYPE_PRESETS.USER_ONLY, options: { player: option.string("ID of the player to ban", { required: true }), reason: option.string("reason for the ban", { required: true }), "private-reason": option.string("private reason for the ban", { required: false }), length: option.string("length of the ban; defaults to permanent"), "ban-alts": option.boolean("whether to ban alts of the user; defaults to true"), games: option.string("games to ban them from; defaults to all"), }, run: async (interaction, options) => { await interaction.deferReply({ withResponse: true }); let ticketNumber: number | undefined; if (interaction.channel && interaction.channel.type === ChannelType.GuildText) { const parts = interaction.channel.name.split("-"); if (parts[1]) ticketNumber = parseInt(parts[1]); } const gamesResult = Result.from(() => parseIncludedGames(options.games)); 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(options.player); 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["private-reason"]}`, ].join("\n"); const state = createReactive( { successfulBans: [], failedBans: [], prefix: "⏳ Banning" }, (_newState) => { const gameButtons = renderGameButtons(state, games); const userParts = [ `**@${user.name}**`, 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["ban-alts"], displayReason: options.reason, privateReason: options["private-reason"] || ticketNumber ? `ticket #${ticketNumber}` : "none provided", }; await Promise.all( games.map(async (game) => { const banResult = await Result.fromAsync(() => ban(user.id.toString(), game.universeId, banOptions), ); if (banResult.isOk()) state.successfulBans.push(game); else state.failedBans.push(game); }), ); state.prefix = "✅ Banned"; }, });