feat: ban history command
vi v@vt3e.cat
Sun, 01 Mar 2026 05:01:23 +0000
3 files changed,
359 insertions(+),
79 deletions(-)
A
src/interactions/commands/roblox/getBanHistory.ts
@@ -0,0 +1,191 @@
+import { + type ChatInputCommandInteraction, + type Client, + type GuildMember, + SlashCommandBuilder, +} from "discord.js"; + +import config from "@/config"; +import { isApiErrorV2, roblox } from "@/roblox/client"; +import type { RobloxUserId } from "@/roblox/types"; +import { hasManagerPermissions } from "@/utils/permissions"; +import { + constructBansContainer, + constructUserContainer, +} from "@/utils/profile"; +import { processRobloxUserInteraction } from "@/utils/robloxUser"; + +const commandData = new SlashCommandBuilder() + .setName("ban-history") + .setDescription( + `Get a user's ban history in the selected ${config.terminology}`, + ) + .addSubcommand((subcommand) => + subcommand + .setName("discord") + .setDescription("Use Bloxlink to get Roblox info from a Discord user") + + .addUserOption((option) => + option + .setName("user") + .setDescription("The Discord user to look up") + .setRequired(true), + ) + .addStringOption((option) => + option + .setName(config.terminology) + .setDescription(`The ${config.terminology} to filter by (optional)`) + .addChoices( + ...Object.values(config.projects).map((project) => ({ + name: project.displayName, + value: project.name, + })), + ) + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName("roblox") + .setDescription("Get Roblox info from a user ID or username") + .addStringOption((option) => + option + .setName("input") + .setDescription( + "The Roblox username to lookup - prefix with id: for IDs", + ) + .setRequired(true), + ) + .addStringOption((option) => + option + .setName(config.terminology) + .setDescription(`The ${config.terminology} to filter by (optional)`) + .addChoices( + ...Object.entries(config.projects).map(([projectKey, project]) => ({ + name: project.displayName, + value: projectKey, + })), + ) + .setRequired(true), + ), + ); + +async function execute( + _client: Client, + interaction: ChatInputCommandInteraction, +) { + const subcommand = interaction.options.getSubcommand(); + const projectKey = interaction.options.getString( + config.terminology, + true, + ) as keyof typeof config.projects; + console.log(projectKey); + + const project = config.projects[projectKey]; + console.log(project); + const universeId = project.universe; + + const isModerator = await hasManagerPermissions( + interaction.member as GuildMember, + ); + await interaction.deferReply(); + + try { + let id: RobloxUserId; + + if (subcommand === "discord") { + const target = interaction.options.getUser("user", true); + const [linkedRes, linkedErr] = await roblox.getLinkedAccount(target.id); + if (linkedErr) { + const message = linkedErr.error; + if (message === "User not found") { + await interaction.editReply( + "No linked Roblox account found for that user.", + ); + return; + } + await interaction.editReply( + `There was an error fetching the linked account: ${linkedErr.error}`, + ); + return; + } + id = linkedRes.robloxID; + } else { + const input = interaction.options.getString("input", true); + const isId = input.startsWith("id:"); + + if (isId) { + id = input.slice(3); + if (!id) { + await interaction.editReply("Invalid ID provided."); + return; + } + } else { + const [usersRes] = await roblox.getUsersByUsernames([input]); + if (!usersRes || !usersRes.data || usersRes.data.length === 0) { + await interaction.editReply("User not found"); + return; + } + id = usersRes.data[0].id; + } + } + + await interaction.editReply(`Fetching user with ID ${id}...`); + const [user, userErr] = await roblox.getUser(id); + if (userErr) { + if (isApiErrorV2(userErr)) { + await interaction.editReply( + `There was an error fetching the user: ${userErr.message}`, + ); + } else { + await interaction.editReply("There was an error fetching the user."); + } + return; + } + + await interaction.editReply( + `Found user with username ${user.name}, fetching thumbnail...`, + ); + const [thumbnailRes] = await roblox.getThumbnail(id, { shape: "SQUARE" }); + const thumbnailUri = thumbnailRes ? thumbnailRes.response.imageUri : null; + + const randomMessages = [ + "grok is this true", + "<:swagong:1176858666452922469>", + "awawawawawawawawawawwaawawawwa", + "<:wawa:1158423817698430977>", + "<:max:1476776753845370991>", + "<:snickerdoodle:1477173429575749716>", + ]; + const message = + randomMessages[Math.floor(Math.random() * randomMessages.length)]; + await interaction.editReply(message); + + const { mainContainer } = await constructUserContainer(user, { + thumbnailUri, + }); + + const [bansRes] = await roblox.getUserRestrictionLogs(universeId, id); + const bans = bansRes ?? null; + + const { container: bansContainer } = await constructBansContainer(bans, { + showPrivate: isModerator, + }); + + await interaction.editReply(user.id); + await interaction.followUp({ + flags: ["IsComponentsV2"], + components: [mainContainer, bansContainer], + }); + } catch (err) { + console.error(err); + await interaction.editReply( + "An unexpected error occurred while fetching the user.", + ); + } +} + +export default { + data: commandData, + execute, +};
M
src/utils/profile.ts
→
src/utils/profile.ts
@@ -1,3 +1,4 @@
+import config from "@/config"; import { roblox } from "@/roblox/client"; import type { GetUserResponse,@@ -128,7 +129,101 @@ }
// -- cards ---------------------------------------------------------------------------------------- -// ---- bans ------------------------------------------------------------------- +// helpers --------------------------------------------------------------------- +type EnrichedLog = UserRestrictionLog & { + startMs: number; + endMs: number; + nowActive: boolean; + universeId?: string; +}; + +type UserRestrictionLogWithUid = UserRestrictionLog & { + universeId?: string; +}; +function enrichLogs(logs: UserRestrictionLogWithUid[]): EnrichedLog[] { + const now = Date.now(); + return logs.map((log) => { + const startMs = new Date(log.startTime).getTime(); + const durationMs = durationToMs(log.duration ?? undefined); + const endMs = + durationMs == null ? Number.POSITIVE_INFINITY : startMs + durationMs; + const nowActive = now < endMs; + return { + ...(log as UserRestrictionLog), + startMs, + endMs, + nowActive, + ...(log.universeId ? { universeId: log.universeId } : {}), + }; + }); +} + +async function formatRestrictionLog( + log: EnrichedLog, + opts: { + showPrivate?: boolean; + includeProjectLabel?: string | null; + includeStatus?: boolean; + } = {}, +) { + const { + showPrivate = false, + includeProjectLabel = null, + includeStatus = false, + } = opts; + + const reason = log.displayReason || "no public reason"; + const moderator = await moderatorMention(log.moderator); + const startRel = tsRelative(log.createTime); + + const bannedFor = + formatDurationShort(log.duration) === "permanent" + ? "permanently" + : `for ${formatDurationShort(log.duration)}`; + + const { endAbsolute, endRelative } = getDurationString({ + duration: log.duration, + startDate: new Date(log.createTime), + }); + + const endString = + endAbsolute === "permanent" + ? undefined + : `> **Ends:** ${endAbsolute} (${endRelative})`; + + const lines = [ + includeProjectLabel ? `**${includeProjectLabel}**` : undefined, + `> Banned **${bannedFor}**, ${startRel}, by **${moderator}**`, + includeStatus + ? `> **Status:** ${log.active ? "Active" : "Expired"}` + : undefined, + `> **Reason:** ${reason}`, + showPrivate + ? `> **Private Reason:** ${log.privateReason || "no private reason"}` + : undefined, + endString, + ].filter(Boolean); + + return lines.join("\n"); +} + +function buildLogsContainer( + headerTitle: string, + lines: string[], + shown: number, + total: number, +) { + const header = `### ${headerTitle} - showing ${shown} of ${total}`; + const container = new ContainerBuilder() + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`${header}\n\n${lines.join("\n\n")}`), + ) + .addSeparatorComponents( + new SeparatorBuilder().setDivider(false).setSpacing(1), + ); + return container; +} + export async function constructBansContainer( bans: UserRestrictionLog[] | null, opts: { limit?: number; showPrivate?: boolean; userId?: string } = {},@@ -145,96 +240,86 @@ );
return { container: c, total: 0 }; } - type EnrichedLog = UserRestrictionLog & { - startMs: number; - endMs: number; - nowActive: boolean; - }; - const now = Date.now(); - const enriched: EnrichedLog[] = bans.map((log) => { - const startMs = new Date(log.startTime).getTime(); - const durationMs = durationToMs(log.duration ?? undefined); - const endMs = - durationMs == null ? Number.POSITIVE_INFINITY : startMs + durationMs; - const nowActive = now < endMs; - return { ...log, startMs, endMs, nowActive }; - }); + const enriched = enrichLogs(bans); + const sorted = enriched.sort((a, b) => b.startMs - a.startMs); + const shown = sorted.slice(0, limit); + + const lines = await Promise.all( + shown.map((l) => formatRestrictionLog(l, { showPrivate })), + ); - const sorted = enriched.sort((a, b) => b.startMs - a.startMs); + const container = buildLogsContainer( + "Bans", + lines, + shown.length, + bans.length, + ); + return { container, total: bans.length }; +} - const newerIntervals: { startMs: number; endMs: number }[] = []; - const statuses = new Map<string, "ACTIVE" | "SUPERSEDED" | "EXPIRED">(); +export async function constructBansSummaryContainer( + userId: string, + opts: { showPrivate?: boolean } = {}, +) { + const universeIds = Object.values(config.projects) + .map((p) => p.universe) + .filter((id): id is string => Boolean(id)); - for (const log of sorted) { - const isSuperseded = newerIntervals.some( - (n) => n.startMs <= log.endMs && n.endMs >= log.startMs, - ); + const perUniverseLogs = await Promise.all( + universeIds.map(async (universeId) => { + const [logs] = await roblox.getUserRestrictionLogs(universeId, userId); + const arr = logs ?? []; + return arr.map((log) => ({ ...log, universeId }) as EnrichedLog); + }), + ); - if (isSuperseded) statuses.set(log.createTime, "SUPERSEDED"); - else if (log.nowActive && log.active) - statuses.set(log.createTime, "ACTIVE"); - else statuses.set(log.createTime, "EXPIRED"); + const allLogs = perUniverseLogs.flat(); - newerIntervals.push({ startMs: log.startMs, endMs: log.endMs }); + if (!allLogs || allLogs.length === 0) { + const c = new ContainerBuilder().addTextDisplayComponents( + new TextDisplayBuilder().setContent("## Recent Bans\n-# No bans found"), + ); + return { container: c, total: 0 }; } - const shown = sorted.slice(0, limit); - - const lines = shown.map(async (log) => { - if (!log) return undefined; + const lastBans = allLogs + .sort( + (a, b) => + new Date(b.createTime).getTime() - new Date(a.createTime).getTime(), + ) + .slice(0, 3); - const reason = log.displayReason || "no public reason"; - const moderator = await moderatorMention(log.moderator); - const startRel = tsRelative(log.createTime); - const { endAbsolute, endRelative } = getDurationString({ - duration: log.duration, - startDate: new Date(log.createTime), - }); + const projectForUniverse = (universeId: string) => + Object.values(config.projects).find((p) => p.universe === universeId); - const bannedFor = - formatDurationShort(log.duration) === "permanent" - ? "permanently" - : `for ${formatDurationShort(log.duration)}`; - const endString = - endAbsolute === "permanent" - ? undefined - : `> **Ends:** ${endAbsolute} (${endRelative})`; + const enriched = enrichLogs(lastBans); - return [ - `Banned **${bannedFor}**, ${startRel}, by **${moderator}**`, - `> **Reason:** ${reason}`, - showPrivate - ? `> **Private Reason:** ${log.privateReason || "no private reason"}` - : undefined, - endString, - ] - .filter(Boolean) - .join("\n"); - }); - const awaitedLines = await Promise.all(lines).then((lines) => - lines.filter(Boolean), + const lines = await Promise.all( + enriched.map((log) => + formatRestrictionLog(log, { + showPrivate: opts.showPrivate, + includeProjectLabel: + projectForUniverse(log.universeId || "")?.displayName || null, + includeStatus: true, + }), + ), ); - const header = `### Bans - showing ${shown.length} of ${bans.length}`; - const container = new ContainerBuilder() - .addTextDisplayComponents( - new TextDisplayBuilder().setContent( - `${header}\n\n${awaitedLines.join("\n\n")}`, - ), - ) - .addSeparatorComponents( - new SeparatorBuilder().setDivider(false).setSpacing(1), - ); - - return { container, total: bans.length }; + const container = buildLogsContainer( + "Recent Bans", + lines, + lines.length, + allLogs.length, + ); + return { container, total: allLogs.length }; } // ---- user ------------------------------------------------------------------- export async function constructUserContainer( profile: GetUserResponse, extras?: { - bans: UserRestrictionLog[] | null; - thumbnailUri: string | null; + bans?: UserRestrictionLog[] | null; + thumbnailUri?: string | null; showPrivate?: boolean; }, ) {@@ -282,3 +367,5 @@ );
return { mainContainer, bans, buttons: [profileButton] }; } + +await constructBansSummaryContainer("7492141728");
M
src/utils/robloxUser.ts
→
src/utils/robloxUser.ts
@@ -1,6 +1,9 @@
import { isApiErrorV2, roblox } from "@/roblox/client"; import type { RobloxUserId } from "@/roblox/types"; -import { constructUserContainer } from "@/utils/profile"; +import { + constructBansSummaryContainer, + constructUserContainer, +} from "@/utils/profile"; import type { ChatInputCommandInteraction, MessageContextMenuCommandInteraction,@@ -50,19 +53,18 @@ const message =
randomMessages[Math.floor(Math.random() * randomMessages.length)]; await interaction.editReply(message); - const { - mainContainer, - bans: { container: bansContainer }, - } = await constructUserContainer(user, { + const { mainContainer } = await constructUserContainer(user, { bans, thumbnailUri, showPrivate: isModerator, }); + const banSummary = await constructBansSummaryContainer(id); + await interaction.editReply(user.id); await interaction.followUp({ flags: ["IsComponentsV2"], - components: [mainContainer, bansContainer], + components: [mainContainer, banSummary.container], }); } catch (err) { console.error(err);