bot: fetch bans; better api client
vi did:web:vt3e.cat
Fri, 03 Jul 2026 18:11:54 +0100
6 files changed,
549 insertions(+),
415 deletions(-)
A
apps/bot/src/feats/roblox/client.ts
@@ -0,0 +1,320 @@
+export interface RobloxClientOptions { + cookie: string; + apiKey: string; +} + +export interface RobloxCloudUser { + path: string; + id: string; + createTime: string; + name: string; + displayName: string; + about: string; + locale: string; + premium: boolean; + idVerified: boolean; +} + +export interface UsernameLookupResponse { + data: Array<{ + requestedUsername: string; + hasVerifiedBadge: boolean; + id: number; + name: string; + displayName: string; + }>; +} + +export interface UserSearchResponse { + previousPageCursor: string | null; + nextPageCursor: string | null; + data: Array<{ + previousUsernames: string[]; + hasVerifiedBadge: boolean; + id: number; + name: string; + displayName: string; + }>; +} + +export interface ThumbnailOptions { + size?: "150" | "352" | "420" | "720"; + format?: "PNG" | "JPEG"; + shape?: "ROUND" | "SQUARE"; +} + +export interface ThumbnailResponse { + imageUri: string; +} + +export interface RobloxOperation<T> { + path: string; + done: boolean; + response?: T; + metadata?: unknown; +} + +export interface GameJoinRestriction { + active: boolean; + startTime?: string; + duration?: string; + privateReason?: string; + displayReason?: string; + excludeAltAccounts?: boolean; + inherited?: boolean; +} + +export interface UserRestriction { + path: string; + user: string; + gameJoinRestriction: GameJoinRestriction; +} + +export interface ListUserRestrictionsResponse { + userRestrictions: UserRestriction[]; + nextPageToken?: string; +} + +export interface GameJoinRestrictionLogBehavior { + action: "CREATE" | "UPDATE" | "DELETE" | string; + duration?: string; + privateReason?: string; + displayReason?: string; + excludeAltAccounts?: boolean; +} + +export interface UserRestrictionLog { + path: string; + user: string; + duration?: string; + moderator: + | { + robloxUser: `users/${string}`; + } + | { + gameScript: unknown; + }; + createTime: string; + active: boolean; + privateReason: string; + displayReason: string; + excludeAltAccounts?: boolean; +} + +export interface ListUserRestrictionLogsResponse { + logs: UserRestrictionLog[]; + nextPageToken?: string; +} + +export interface RequestOptions { + maxPageSize?: number; + pageToken?: string; + filter?: string; +} + +export class RobloxUserClient { + private cookie: string; + private apiKey: string; + private csrfToken: string | null = null; + + constructor(options: RobloxClientOptions) { + this.cookie = options.cookie.includes(".ROBLOSECURITY=") + ? options.cookie + : `.ROBLOSECURITY=${options.cookie}`; + this.apiKey = options.apiKey; + } + + private async request<T>( + url: string, + method: "GET" | "POST", + body?: unknown, + queryParams?: Record<string, string>, + isRetry = false, + ): Promise<T> { + const headers: Record<string, string> = { + Cookie: this.cookie, + Accept: "application/json", + ["x-api-key"]: this.apiKey, + }; + + if (body) headers["Content-Type"] = "application/json"; + if (this.csrfToken) headers["X-CSRF-TOKEN"] = this.csrfToken; + + const urlObj = new URL(url); + if (queryParams) { + Object.entries(queryParams).forEach(([key, val]) => { + if (val !== undefined && val !== null) { + urlObj.searchParams.append(key, val); + } + }); + } + + const response = await fetch(urlObj.toString(), { + method, + headers, + ...(method !== "GET" && body ? { body: JSON.stringify(body) } : {}), + }); + + if (response.status === 403) { + const csrfHeader = response.headers.get("x-csrf-token"); + if (csrfHeader && !isRetry) { + this.csrfToken = csrfHeader; + return this.request<T>(url, method, body, queryParams, true); + } + } + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Roblox API Error [${response.status}]: ${errorText}`); + } + + return response.json() as Promise<T>; + } + + // --- + async getUserProfile(userId: string | number): Promise<RobloxCloudUser> { + return this.request<RobloxCloudUser>(`https://apis.roblox.com/cloud/v2/users/${userId}`, "GET"); + } + + // --- + async getUsersByUsernames( + usernames: string[], + excludeBannedUsers = false, + ): Promise<UsernameLookupResponse> { + return this.request<UsernameLookupResponse>( + "https://users.roblox.com/v1/usernames/users", + "POST", + { usernames, excludeBannedUsers }, + ); + } + + async getUserId(username: string): Promise<number | null> { + const result = await this.getUsersByUsernames([username]); + const user = result.data[0]; + return user ? user.id : null; + } + + async getUserIds(usernames: string[]): Promise<number[]> { + const result = await this.getUsersByUsernames(usernames); + return result.data.map((user) => user.id); + } + + // --- + async searchUsers( + keyword: string, + options?: { limit?: number; cursor?: string }, + ): Promise<UserSearchResponse> { + const queryParams: Record<string, string> = { keyword }; + if (options?.limit) queryParams.limit = options.limit.toString(); + if (options?.cursor) queryParams.cursor = options.cursor; + + return this.request<UserSearchResponse>( + "https://users.roblox.com/v1/users/search", + "GET", + undefined, + queryParams, + ); + } + + // --- + async generateUserThumbnail( + userId: string | number, + options?: ThumbnailOptions, + ): Promise<RobloxOperation<ThumbnailResponse>> { + return this.request<RobloxOperation<ThumbnailResponse>>( + `https://apis.roblox.com/cloud/v2/users/${userId}:generateThumbnail`, + "GET", + undefined, + options as Record<string, string>, + ); + } + + async getAvatarUrl(userId: string | number, options?: ThumbnailOptions): Promise<string | null> { + const operation = await this.generateUserThumbnail(userId, options); + + if (operation.done && operation.response) { + return operation.response.imageUri ?? null; + } else { + const finalResult = await this.pollThumbnailOperation( + userId, + operation.path.split("/").pop()!, + ); + return finalResult.imageUri; + } + } + + async pollThumbnailOperation( + userId: string | number, + operationId: string, + intervalMs = 1000, + maxRetries = 15, + ): Promise<ThumbnailResponse> { + let attempts = 0; + while (attempts < maxRetries) { + const result = await this.request<RobloxOperation<ThumbnailResponse>>( + `https://apis.roblox.com/cloud/v2/users/${userId}/operations/${operationId}`, + "GET", + ); + if (result.done) { + if (result.response) return result.response; + throw new Error("Operation completed but response data was missing."); + } + + attempts++; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`Polling operation timed out after ${maxRetries} attempts.`); + } + + // --- + async listUserRestrictions( + universeId: string | number, + options?: RequestOptions, + ): Promise<ListUserRestrictionsResponse> { + const queryParams: Record<string, string> = {}; + if (options?.maxPageSize) queryParams.maxPageSize = options.maxPageSize.toString(); + if (options?.pageToken) queryParams.pageToken = options.pageToken; + if (options?.filter) queryParams.filter = options.filter; + + return this.request<ListUserRestrictionsResponse>( + `https://apis.roblox.com/user/cloud/v2/universes/${universeId}/user-restrictions`, + "GET", + undefined, + queryParams, + ); + } + + // --- + async getUserRestriction( + universeId: string | number, + userRestrictionId: string | number, + ): Promise<UserRestriction> { + const restrictionId = + typeof userRestrictionId === "number" || !userRestrictionId.includes("/") + ? `users/${userRestrictionId}` + : userRestrictionId; + + return this.request<UserRestriction>( + `https://apis.roblox.com/user/cloud/v2/universes/${universeId}/user-restrictions/${restrictionId}`, + "GET", + ); + } + + // --- + async listUserRestrictionLogs( + universeId: string | number, + options?: RequestOptions, + ): Promise<ListUserRestrictionLogsResponse> { + const queryParams: Record<string, string> = {}; + if (options?.maxPageSize) queryParams.maxPageSize = options.maxPageSize.toString(); + if (options?.pageToken) queryParams.pageToken = options.pageToken; + if (options?.filter) queryParams.filter = options.filter; + + return this.request<ListUserRestrictionLogsResponse>( + `https://apis.roblox.com/user/cloud/v2/universes/${universeId}/user-restrictions:listLogs`, + "GET", + undefined, + queryParams, + ); + } +}
D
apps/bot/src/feats/roblox/lib.ts
@@ -1,139 +0,0 @@
-import config from "@stealth-developers/config"; - -import type { ApiError, ApiErrorV2, ErrorCode, GatewayError, RobloxResult } from "./types"; -import type { - GenerateThumbnailOptions, - GenerateThumbnailResponse, - GetUserResponse, - GetUsersByUsernamesPayload, - GetUsersByUsernamesResponse, -} from "./types/users"; - -const API_KEY = config.roblox.apiKey; -const COOKIE = config.roblox.cookie; - -const ERROR_CODES: Set<string> = new Set<ErrorCode>([ - "INVALID_ARGUMENT", - "PERMISSION_DENIED", - "NOT_FOUND", - "ABORTED", - "RESOURCE_EXHAUSTED", - "CANCELLED", - "INTERNAL", - "NOT_IMPLEMENTED", - "UNAVAILABLE", -]); - -function isErrorCode(value: string): value is ErrorCode { - return ERROR_CODES.has(value); -} - -export function isApiErrorV2(x: unknown): x is ApiErrorV2 { - return ( - typeof x === "object" && - x !== null && - "code" in x && - "message" in x && - typeof (x as Record<string, unknown>).code === "string" && - typeof (x as Record<string, unknown>).message === "string" && - isErrorCode((x as Record<string, unknown>).code as string) - ); -} - -export function isGatewayError(x: unknown): x is GatewayError { - return ( - typeof x === "object" && - x !== null && - "errors" in x && - Array.isArray((x as Record<string, unknown>).errors) - ); -} - -function isApiError(x: unknown): x is ApiError { - return isApiErrorV2(x) || isGatewayError(x); -} - -function errorFromStatus(status: number, statusText: string): ApiErrorV2 { - const codeMap: Record<number, ErrorCode> = { - 400: "INVALID_ARGUMENT", - 403: "PERMISSION_DENIED", - 404: "NOT_FOUND", - 409: "ABORTED", - 429: "RESOURCE_EXHAUSTED", - 499: "CANCELLED", - 500: "INTERNAL", - 501: "NOT_IMPLEMENTED", - 503: "UNAVAILABLE", - }; - - return { - code: codeMap[status] ?? "INTERNAL", - message: statusText, - }; -} - -export async function makeRequest<T>(path: string, init?: RequestInit): Promise<RobloxResult<T>> { - let baseUrl = "https://apis.roblox.com"; - if (path.startsWith("https://")) baseUrl = ""; - - try { - const res = await fetch(`${baseUrl}${path}`, { - ...init, - headers: { - Cookie: COOKIE, - "x-api-key": API_KEY, - accept: "application/json", - "Content-Type": "application/json", - UserAgent: - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", - ...init?.headers, - }, - }); - - const body = await res.json(); - - if (!res.ok || isApiError(body)) { - const err: ApiError = isApiError(body) ? body : errorFromStatus(res.status, res.statusText); - - return [null, err]; - } - - return [body as T, null]; - } catch (e) { - return [null, { code: "INTERNAL", message: String(e) }]; - } -} - -export async function getUser(userId: string) { - const path = `/cloud/v2/users/${userId}`; - return makeRequest<GetUserResponse>(path); -} - -export async function getThumbnail(userId: string, options?: GenerateThumbnailOptions) { - const _path = `/cloud/v2/users/${userId}:generateThumbnail`; - - const query = new URLSearchParams(); - if (options) { - for (const [key, value] of Object.entries(options)) { - query.set(key, value.toString()); - } - } - - const path = `${_path}?${query.toString()}`; - - return makeRequest<GenerateThumbnailResponse>(path); -} - -export async function getUsersByUsernames(usernames: string[]) { - const payload: GetUsersByUsernamesPayload = { - usernames, - excludeBannedUsers: false, - }; - - const path = "https://users.roblox.com/v1/usernames/users"; - - return makeRequest<GetUsersByUsernamesResponse>(path, { - method: "POST", - body: JSON.stringify(payload), - }); -}
D
apps/bot/src/feats/roblox/types/index.ts
@@ -1,97 +0,0 @@
-// -- primitives ----------------------------------------------------------------------------------- -export type RobloxUserId = string; -export type UniverseId = string; -export type PlaceId = string; -export type UserRef = `users/${RobloxUserId}`; -export type UniverseRef = `universes/${UniverseId}`; -export type PlaceRef = `universes/${UniverseId}/places/${PlaceId}`; -export type ISO8601 = string; -export type Duration = `${number}s`; -export type Base64 = string; -export type FieldMask = string; -export type CurrencyCode = string; - -// -- commons -------------------------------------------------------------------------------------- -export interface Decimal { - significand: number; - exponent?: number; -} - -export interface Money { - currencyCode: CurrencyCode; - quantity: Decimal; -} - -export interface Moderator { - robloxUser: UserRef; -} - -export interface Paginated<T> { - logs: T[]; - nextPageToken?: string; -} - -// -- operations ----------------------------------------------------------------------------------- -export interface Operation<T = unknown, M = unknown> { - path: string; - done: boolean; - response?: T; - metadata?: M; -} - -// -- errors --------------------------------------------------------------------------------------- -export interface GatewayError { - errors: { - code: number; - message: string; - }[]; -} - -export type ErrorCode = - | "INVALID_ARGUMENT" - | "PERMISSION_DENIED" - | "NOT_FOUND" - | "ABORTED" - | "RESOURCE_EXHAUSTED" - | "CANCELLED" - | "INTERNAL" - | "NOT_IMPLEMENTED" - | "UNAVAILABLE"; - -export interface ApiErrorV2 { - code: ErrorCode; - message: string; - details?: Record<string, unknown>[]; -} - -export interface ApiErrorV1 { - error: ErrorCode; - message: string; - errorDetails?: { - errorDetailType: string; - datastoreErrorCode?: string; - }[]; -} - -export interface ApiErrorV1Alt { - code: ErrorCode; - message: string; -} - -export interface ApiValidationError { - errors: Record<string, string[]>; - type: string; - title: string; - status: number; - extensions?: { - traceId?: string; - }; -} - -export type ApiError = ApiErrorV2 | ApiErrorV1 | ApiErrorV1Alt | GatewayError | ApiValidationError; - -export type RobloxResult<T> = [T, null] | [null, ApiError]; - -// -- exports -------------------------------------------------------------------------------------- -export * from "./user-restriction"; -export * from "./users";
D
apps/bot/src/feats/roblox/types/user-restriction.ts
@@ -1,32 +0,0 @@
-import type { Duration, ISO8601, Moderator, Paginated, UserRef } from "./"; - -export interface UserRestriction { - path: string; - user: UserRef; - gameJoinRestriction: GameJoinRestriction; -} - -export interface GameJoinRestriction { - active: boolean; - startTime: ISO8601; - duration?: Duration; - privateReason: string; - displayReason: string; - excludeAltAccounts: boolean; - inherited: boolean; -} - -export interface UserRestrictionLog { - user: UserRef; - place?: string; - moderator: Moderator; - createTime: ISO8601; - startTime: ISO8601; - active: boolean; - duration?: Duration; - privateReason: string; - displayReason: string; - excludeAltAccounts: boolean; -} - -export type UserRestrictionLogsResponse = Paginated<UserRestrictionLog>;
D
apps/bot/src/feats/roblox/types/users.ts
@@ -1,77 +0,0 @@
-import type { ISO8601, RobloxUserId, UserRef } from "."; - -// -- /cloud/v2/users/{userId} ------------------------------------------------- -export interface GetUserResponse { - path: string; - createTime: ISO8601; - - id: UserRef; - name: string; - displayName: string; - - about?: string; - locale: string; - premium: boolean; - idVerified: boolean; - socialNetworkProfiles: { - facebook: string; - twitter: string; - youtube: string; - twitch: string; - guilded: string; - visibility: string; - }; -} - -// -- users.roblox.com/v1/usernames/users -------------------------------------- -export interface GetUsersByUsernamesPayload { - usernames: string[]; - excludeBannedUsers: boolean; -} -export interface GetUsersByUsernamesResponse { - data: { - requestedUsername: string; - hasVerifiedBadge: boolean; - id: RobloxUserId; - name: string; - displayName: string; - }[]; -} - -// -- users.roblox.com/v1/users/search ----------------------------------------- -export type SearchUsersResponse = { - previousPageCursor: string; - nextPageCursor: string; - data: { - previousUsernames: string[]; - hasVerifiedBadge: boolean; - id: RobloxUserId; - name: string; - displayName: string; - }[]; -}; - -// -- cloud/v2/users/{user_id}:generateThumbnail ------------------------------- -export interface GenerateThumbnailResponse { - done: true; - response: { - path: string; - imageUri?: string; - "@type": "apis.roblox.com/roblox.open_cloud.cloud.v2.GenerateUserThumbnailResponse"; - }; -} -export interface GenerateThumbnailOptions { - shape?: "SHAPE_UNSPECIFIED" | "ROUND" | "SQUARE"; - format?: "FORMAT_UNSPECIFIED" | "PNG" | "JPEG"; - size?: 48 | 50 | 60 | 75 | 100 | 110 | 150 | 180 | 352 | 420 | 720; -} - -// -- bloxlink ----------------------------------------------------------------- -export type BloxlinkError = { - error: string; -}; -export type BloxlinkSuccess = { - robloxID: string; - resolved: object; -}; -export type BloxlinkResult = [BloxlinkSuccess, null] | [null, BloxlinkError];
M
apps/bot/src/feats/roblox/user.ts
→
apps/bot/src/feats/roblox/user.ts
@@ -1,10 +1,14 @@
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";@@ -12,34 +16,206 @@
import { errorMessage, logger } from "@/lib"; import { useGetData } from "@/middleware"; -import type { ApiErrorV2, ErrorCode } from "./types"; +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 }); -import { getThumbnail, getUser, getUsersByUsernames } from "./lib"; +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<string, number> = { + 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); +} -const ERROR_CODES: Set<string> = new Set<ErrorCode>([ - "INVALID_ARGUMENT", - "PERMISSION_DENIED", - "NOT_FOUND", - "ABORTED", - "RESOURCE_EXHAUSTED", - "CANCELLED", - "INTERNAL", - "NOT_IMPLEMENTED", - "UNAVAILABLE", -]); -function isErrorCode(value: string): value is ErrorCode { - return ERROR_CODES.has(value); +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; } -export function isApiErrorV2(x: unknown): x is ApiErrorV2 { - return ( - typeof x === "object" && - x !== null && - "code" in x && - "message" in x && - typeof (x as Record<string, unknown>).code === "string" && - typeof (x as Record<string, unknown>).message === "string" && - isErrorCode((x as Record<string, unknown>).code as string) + +function resolveBannedBy( + log: UserRestrictionLog, + userProfiles: Map<string, { displayName: string; name: string }>, +): 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<string | null> { + 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<EnrichedBans[]> { + const filter = `user == 'users/${userId}'`; + const uniqueUserIds = new Set<string>(); + + 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<string, { displayName: string; name: string }>(); + 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 <t:${date}> (<t:${date}:R>)`, + ), + new TextDisplayBuilder().setContent(user.about ? `>>> ${user.about}` : "No bio provided."), + ) + .setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl)); + + const linkRow = new ActionRowBuilder<ButtonBuilder>().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" : `<t:${Math.round(duration.getTime() / 1000)}:R>`; + + const reason = log.displayReason || "No reason specified"; + const moderator = log.bannedBy; + + banContainer.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + [ + `**Banned in ${log.projectName}** - <t:${banTime}:d> (<t:${banTime}:R>)`, + `> **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<StringSelectMenuBuilder>().addComponents( + ticketSelectMenu, + ); + banContainer.addActionRowComponents(ticketRow); + } + + return banContainer; } export const robloxUserCommand = useGetData.command("user", {@@ -48,62 +224,45 @@ 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, { actor: _actor }) => { - const { username, id: userId } = args; - if (!username && !userId) - return errorMessage(interaction, "You must provide either a username or user ID."); - - let id = userId; + run: async (interaction, args) => { + const { username, id: userIdInput } = args; - if (username && !id) { - const [usernameData, usernameError] = await getUsersByUsernames([username]); - if (usernameError || (usernameData && !usernameData.data[0])) - return errorMessage(interaction, "There was an error fetching the user's ID."); - - id = usernameData.data[0]!.id; + if (!username && !userIdInput) { + return errorMessage(interaction, "You must provide either a username or user ID."); } - if (!id) return errorMessage(interaction, "You must provide a user ID."); - - const [user, error] = await getUser(id); - if (error) { - logger.error(error, `Failed to fetch user ${id}`); - return errorMessage(interaction, "There was an error fetching the user."); + const id = await resolveUserId(username, userIdInput); + if (!id) { + return errorMessage(interaction, "There was an error fetching the user's ID."); } - const [thumbnail] = await getThumbnail(id); - let thumbnailUrl = - "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"; - if (thumbnail) thumbnailUrl = thumbnail.response.imageUri ?? thumbnailUrl; + 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)), + ]); - const date = Math.round(new Date(user.createTime).getTime() / 1000); + 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 row = new ActionRowBuilder<ButtonBuilder>().addComponents( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setLabel("Open Profile") - .setURL(`https://roblox.com/users/${id}/profile`), - ); + const user = profileResponse.unwrap(); + const thumbnailUrl = + thumbnailResponse.isOk() && thumbnailResponse.unwrap() + ? thumbnailResponse.unwrap()! + : DEFAULT_AVATAR_URL; - const userSection = new SectionBuilder() - .addTextDisplayComponents( - new TextDisplayBuilder().setContent(`# ${user.displayName || user.name}`), - new TextDisplayBuilder().setContent( - `@${user.name} - ${user.id} - created <t:${date}> (<t:${date}:R>)`, - ), - new TextDisplayBuilder().setContent( - user.about ? `>>> ${user.about}` : ">>> No bio provided.", - ), - ) - .setThumbnailAccessory(new ThumbnailBuilder().setURL(thumbnailUrl)); + const profileContainer = buildProfileContainer(user, thumbnailUrl, id); - const container = new ContainerBuilder() - .addSectionComponents(userSection) - .addActionRowComponents(row); + const banContainer = bansResponse.isOk() + ? buildBanContainer(bansResponse.unwrap()) + : new ContainerBuilder().addTextDisplayComponents( + new TextDisplayBuilder().setContent("An error occurred while fetching ban history."), + ); - await interaction.reply(user.id); - await interaction.followUp({ - components: [container], + await interaction.reply({ + components: [profileContainer, banContainer], flags: ["IsComponentsV2"], }); },