bot,config: roblox profile command
vi did:web:vt3e.cat
Mon, 29 Jun 2026 22:04:43 +0100
10 files changed,
472 insertions(+),
1 deletions(-)
jump to
M
apps/bot/src/feats/index.ts
→
apps/bot/src/feats/index.ts
@@ -1,6 +1,7 @@
import { bugCommand, bugModal } from "./bugs"; import { miscCommands } from "./misc"; +import { robloxUserCommand } from "./roblox"; import { ticketCommands, ticketComponents } from "./tickets"; -export const commands = [...ticketCommands, ...miscCommands, bugCommand]; +export const commands = [...ticketCommands, ...miscCommands, bugCommand, robloxUserCommand]; export const components = [...ticketComponents, bugModal];
A
apps/bot/src/feats/roblox/lib.ts
@@ -0,0 +1,139 @@
+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), + }); +}
A
apps/bot/src/feats/roblox/types/index.ts
@@ -0,0 +1,97 @@
+// -- 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";
A
apps/bot/src/feats/roblox/types/user-restriction.ts
@@ -0,0 +1,32 @@
+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>;
A
apps/bot/src/feats/roblox/types/users.ts
@@ -0,0 +1,77 @@
+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];
A
apps/bot/src/feats/roblox/user.ts
@@ -0,0 +1,110 @@
+import { option } from "@purrkit/router"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ContainerBuilder, + SectionBuilder, + TextDisplayBuilder, + ThumbnailBuilder, +} from "discord.js"; + +import { errorMessage, logger } from "@/lib"; +import { useGetData } from "@/middleware"; + +import type { ApiErrorV2, ErrorCode } from "./types"; + +import { getThumbnail, getUser, getUsersByUsernames } from "./lib"; + +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 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, { 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; + + 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 (!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 [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 date = Math.round(new Date(user.createTime).getTime() / 1000); + + const row = new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setLabel("Open Profile") + .setURL(`https://roblox.com/users/${id}/profile`), + ); + + 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 container = new ContainerBuilder() + .addSectionComponents(userSection) + .addActionRowComponents(row); + + await interaction.reply(user.id); + await interaction.followUp({ + components: [container], + flags: ["IsComponentsV2"], + }); + }, +});
M
apps/bot/src/index.ts
→
apps/bot/src/index.ts
@@ -29,4 +29,11 @@ const guilds = await client.guilds.fetch();
logger.info(`serving ${guilds.size} guilds!`); }); +process.on("unhandledRejection", (error) => { + logger.error(error); +}); +process.on("uncaughtException", (error) => { + logger.error(error); +}); + void main();
M
pkgs/config/src/schema.ts
→
pkgs/config/src/schema.ts
@@ -26,6 +26,11 @@ auth: z.string().optional(),
}), ]); +const robloxSchema = z.object({ + apiKey: z.string(), + cookie: z.string(), +}); + const apiSchema = z.object({ port: z.number().default(3000), });@@ -45,6 +50,7 @@ discord: discordSchema,
db: dbSchema, api: apiSchema, s3: s3Schema, + roblox: robloxSchema, }) .transform((config) => { return {