import { option } from "@purrkit/router"; import { Result } from "@sapphire/result"; import config from "@stealth-developers/config"; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ContainerBuilder, SectionBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextDisplayBuilder, ThumbnailBuilder, } from "discord.js"; import { errorMessage, logger } from "@/lib"; import { useGetData } from "@/middleware"; import { Projects } from "../bugs/shared"; import { RobloxUserClient, type UserRestrictionLog } from "./client"; type BannedBy = { displayName: string; username: string; id: string }; type Project = (typeof Projects)[keyof typeof Projects]; type EnrichedUserRestrictionLog = UserRestrictionLog & { bannedBy: BannedBy; }; type EnrichedBans = Project & { bans: EnrichedUserRestrictionLog[] }; const DEFAULT_AVATAR_URL = "https://images-ext-1.discordapp.net/external/j7H9Ep50cCN5igmEKGwFnv7frj2590Xg2Z4hvHGFPPo/%3Fsize%3D4096/https/cdn.discordapp.com/icons/895998762630127616/36c6e14d0933eb338826599b632df818.png?format=webp&quality=lossless&width=852&height=852"; const client = new RobloxUserClient({ cookie: config.roblox.cookie, apiKey: config.roblox.apiKey }); function calculateBanDuration(from: Date, duration: string): Date { const match = duration.trim().match(/^(\d+)\s*([a-zA-Z]?)$/); if (!match) return new Date(); const value = parseInt(match[1]!, 10); const unit = (match[2] ?? "s").toLowerCase(); const multipliers: Record = { s: 1000, m: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, y: 365 * 24 * 60 * 60 * 1000, }; const multiplier = multipliers[unit]; if (multiplier === undefined) return new Date(); return new Date(from.getTime() + value * multiplier); } function getModeratorId(log: UserRestrictionLog): string | null { if ("robloxUser" in log.moderator) { return log.moderator.robloxUser.split("/")[1] ?? null; } const privateReason = log.privateReason; const match = privateReason ? privateReason.match(/^Banned by (.+)$/) : null; return match ? (match[1] ?? null) : null; } function resolveBannedBy( log: UserRestrictionLog, userProfiles: Map, ): BannedBy { const modId = getModeratorId(log); if (modId) { const profile = userProfiles.get(modId); return profile ? { displayName: profile.displayName, username: profile.name, id: modId } : { displayName: `User ID: ${modId}`, username: `User ID: ${modId}`, id: modId }; } if ("gameScript" in log.moderator) { return { displayName: "Game Script", username: "Game Script", id: "none" }; } return { displayName: "unknown", username: "unknown", id: "unknown" }; } async function resolveUserId(username?: string, id?: string): Promise { if (id) return id; if (!username) return null; const response = await Result.fromAsync(client.getUserId(username)); if (response.isErr()) { logger.error(response.unwrapErr(), `error fetching user ID for username ${username}`); return null; } return String(response.unwrap()); } export async function getLastBans(userId: string | number): Promise { const filter = `user == 'users/${userId}'`; const uniqueUserIds = new Set(); const rawProjectBans = await Promise.all( Object.values(Projects).map(async (project) => { const projectBans = await client.listUserRestrictionLogs(project.universe, { filter }); const logs = projectBans?.logs ?? []; for (const log of logs) { const modId = getModeratorId(log); if (modId) uniqueUserIds.add(modId); } return { project, logs }; }), ); const userProfiles = new Map(); await Promise.all( Array.from(uniqueUserIds).map(async (id) => { try { const profile = await client.getUserProfile(id); if (profile) userProfiles.set(id, profile); } catch (error) { logger.error(error, `failed to fetch profile for user ${id}`); } }), ); return rawProjectBans.map(({ project, logs }) => { const enrichedLogs = logs.map((log) => ({ ...log, bannedBy: resolveBannedBy(log, userProfiles), })); return { ...project, bans: enrichedLogs }; }); } function buildProfileContainer(user: any, thumbnailUrl: string, id: string): ContainerBuilder { const date = Math.round(new Date(user.createTime).getTime() / 1000); const userSection = new SectionBuilder() .addTextDisplayComponents( new TextDisplayBuilder().setContent(`# ${user.displayName || user.name}`), new TextDisplayBuilder().setContent( `@${user.name} - ${user.id} - created ()`, ), new TextDisplayBuilder().setContent(user.about ? `>>> ${user.about}` : "No bio provided."), ) .setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl)); const linkRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setStyle(ButtonStyle.Link) .setLabel("Open Profile") .setURL(`https://roblox.com/users/${id}/profile`), ); return new ContainerBuilder().addSectionComponents(userSection).addActionRowComponents(linkRow); } function buildBanContainer(projectsWithBans: EnrichedBans[]): ContainerBuilder { const banContainer = new ContainerBuilder(); const allBans = projectsWithBans .flatMap((project) => project.bans.map((ban) => ({ ...ban, projectName: project.displayName }))) .sort((a, b) => a.createTime.localeCompare(b.createTime)) .slice(-5); if (allBans.length === 0) { return banContainer.addTextDisplayComponents( new TextDisplayBuilder().setContent("No bans found for this user."), ); } for (const log of allBans) { const banTime = Math.round(new Date(log.createTime).getTime() / 1000); const duration = "duration" in log && log.duration ? calculateBanDuration(new Date(log.createTime), log.duration) : "permanent"; const timestamp = duration === "permanent" ? "Permanent" : ``; const reason = log.displayReason || "No reason specified"; const moderator = log.bannedBy; banContainer.addTextDisplayComponents( new TextDisplayBuilder().setContent( [ `**Banned in ${log.projectName}** - ()`, `> **Expires:** ${timestamp}`, `> **Reason:** ${reason}`, `> **Moderator:** [${moderator.displayName || moderator.username}](https://www.roblox.com/users/${moderator.id})`, ].join("\n"), ), ); } const ticketNumbers = allBans.flatMap((log) => { const match = log.privateReason?.trim().match(/^ticket\s+(\d+)$/i); return match ? [Number(match[1])] : []; }); if (ticketNumbers.length > 0) { const ticketSelectMenu = new StringSelectMenuBuilder() .setCustomId("ticket-select:view") .setPlaceholder("View relevant ticket") .addOptions( ticketNumbers.map((number) => new StringSelectMenuOptionBuilder() .setLabel(`Ticket ${number}`) .setValue(number.toString()), ), ); const ticketRow = new ActionRowBuilder().addComponents( ticketSelectMenu, ); banContainer.addActionRowComponents(ticketRow); } return banContainer; } export const robloxUserCommand = useGetData.command("user", { description: "Get a Roblox user's information", options: { username: option.string("The username of the user", { required: false }), id: option.string("The user ID of the user", { required: false }), }, run: async (interaction, args) => { const { username, id: userIdInput } = args; if (!username && !userIdInput) { return errorMessage(interaction, "You must provide either a username or user ID."); } const id = await resolveUserId(username, userIdInput); if (!id) { return errorMessage(interaction, "There was an error fetching the user's ID."); } const [profileResponse, thumbnailResponse, bansResponse] = await Promise.all([ Result.fromAsync(client.getUserProfile(id)), Result.fromAsync(client.getAvatarUrl(id, { shape: "SQUARE", size: "420" })), Result.fromAsync(getLastBans(id)), ]); if (profileResponse.isErr()) { logger.error(profileResponse.unwrapErr(), `error fetching profile for user ${id}`); return errorMessage(interaction, "There was an error fetching the user's profile."); } const user = profileResponse.unwrap(); const thumbnailUrl = thumbnailResponse.isOk() && thumbnailResponse.unwrap() ? thumbnailResponse.unwrap()! : DEFAULT_AVATAR_URL; const profileContainer = buildProfileContainer(user, thumbnailUrl, id); const banContainer = bansResponse.isOk() ? buildBanContainer(bansResponse.unwrap()) : new ContainerBuilder().addTextDisplayComponents( new TextDisplayBuilder().setContent("An error occurred while fetching ban history."), ); await interaction.reply({ components: [profileContainer, banContainer], flags: ["IsComponentsV2"], }); }, });