all repos — stealth-developers @ 1e965c6fd5d5899f87df016caf45fdd1f2e6474e

feat: fetch & search users
vi v@vt3e.cat
Sat, 28 Feb 2026 17:50:06 +0000
commit

1e965c6fd5d5899f87df016caf45fdd1f2e6474e

parent

0996f523cf428a2a7cda4b5116820a26bb06efe2

A src/interactions/commands/roblox/getUser.ts

@@ -0,0 +1,136 @@

+import { + type ChatInputCommandInteraction, + type Client, + type GuildMember, + SlashCommandBuilder, +} from "discord.js"; + +import { isApiErrorV2, roblox } from "@/roblox/client"; +import type { RobloxUserId } from "@/roblox/types"; +import { hasManagerPermissions } from "@/utils/permissions"; +import { constructUserContainer } from "@/utils/profile"; + +const commandData = new SlashCommandBuilder() + .setName("user") + .setDescription("Get information about a Roblox user") + .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), + ), + ) + .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), + ), + ); + +async function execute( + _client: Client, + interaction: ChatInputCommandInteraction, +) { + const subcommand = interaction.options.getSubcommand(); + const isModerator = await hasManagerPermissions( + interaction.member as GuildMember, + ); + await interaction.deferReply(); + + try { + if (subcommand === "discord") { + await interaction.editReply("Not implemented"); + return; + } + + const input = interaction.options.getString("input", true); + const isId = input.startsWith("id:"); + let id: RobloxUserId; + + 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 [userRes, 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; + } + const user = userRes; + + 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; + + await interaction.editReply(`Fetching bans for user with ID ${id}...`); + const [bansRes] = await roblox.getUserRestrictionLogs("21449357", id); + const bans = bansRes ?? 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, + bans: { container: bansContainer }, + } = await constructUserContainer(user, { + bans, + thumbnailUri, + showPrivate: isModerator, + }); + + 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, +};
A src/interactions/commands/roblox/searchUsers.ts

@@ -0,0 +1,67 @@

+import { roblox } from "@/roblox/client"; +import { text } from "@/utils/components"; +import { + type ChatInputCommandInteraction, + type Client, + ContainerBuilder, + SlashCommandBuilder, +} from "discord.js"; + +const commandData = new SlashCommandBuilder() + .setName("search") + .setDescription("Search for Roblox users") + .addStringOption((option) => + option + .setName("query") + .setDescription("The query to search for") + .setRequired(true), + ); + +async function execute( + _client: Client, + interaction: ChatInputCommandInteraction, +) { + await interaction.deferReply(); + const query = interaction.options.getString("query", true); + + const [searchRes, searchErr] = await roblox.searchUsers(query, { + limit: 10, + cursor: "", + }); + if (searchErr) { + await interaction.editReply("Error searching for users."); + console.error(searchErr); + return; + } + + const container = new ContainerBuilder(); + + const title = text(`## ${searchRes.data.length} Results for "${query}"`); + + const resultString = searchRes.data + .map((user) => + [ + "*", + user.displayName, + `[@${user.name}](https://www.roblox.com/users/${user.id}/profile)`, + `(${user.id})`, + user.previousUsernames.length + ? `(${user.previousUsernames.join(", ")})` + : "", + ].join(" "), + ) + .join("\n"); + const resultText = text(resultString); + + container.addTextDisplayComponents(title, resultText); + + await interaction.editReply({ + flags: ["IsComponentsV2"], + components: [container], + }); +} + +export default { + data: commandData, + execute, +};
M src/roblox/client.tssrc/roblox/client.ts

@@ -4,6 +4,8 @@ ApiError,

ApiErrorV2, ErrorCode, GatewayError, + GenerateThumbnailOptions, + GenerateThumbnailResponse, GetUserResponse, GetUsersByUsernamesPayload, GetUsersByUsernamesResponse,

@@ -29,7 +31,7 @@ function isErrorCode(value: string): value is ErrorCode {

return ERROR_CODES.has(value); } -function isApiErrorV2(x: unknown): x is ApiErrorV2 { +export function isApiErrorV2(x: unknown): x is ApiErrorV2 { return ( typeof x === "object" && x !== null &&

@@ -41,7 +43,7 @@ isErrorCode((x as Record<string, unknown>).code as string)

); } -function isGatewayError(x: unknown): x is GatewayError { +export function isGatewayError(x: unknown): x is GatewayError { return ( typeof x === "object" && x !== null &&

@@ -180,6 +182,27 @@ ): Promise<Result<SearchUsersResponse>> {

const path = `https://users.roblox.com/v1/users/search?keyword=${encodeURIComponent(keyword)}&limit=${params.limit}&cursor=${params.cursor}`; const [data, err] = await this.request<SearchUsersResponse>(path); + if (err) return [null, err]; + + return [data, null]; + } + + async getThumbnail( + userId: string, + options?: GenerateThumbnailOptions, + ): Promise<Result<GenerateThumbnailResponse>> { + 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()}`; + + const [data, err] = await this.request<GenerateThumbnailResponse>(path); if (err) return [null, err]; return [data, null];
M src/roblox/types/users.tssrc/roblox/types/users.ts

@@ -9,7 +9,7 @@ id: UserRef;

name: string; displayName: string; - about: string; + about?: string; locale: string; premium: boolean; idVerified: boolean;

@@ -50,3 +50,17 @@ name: string;

displayName: string; }[]; }; + +// -- cloud/v2/users/{user_id}:generateThumbnail ------------------------------- +export interface GenerateThumbnailResponse { + done: true; + response: { + 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; +}
A src/utils/profile.ts

@@ -0,0 +1,274 @@

+import { roblox } from "@/roblox/client"; +import type { + GetUserResponse, + RobloxUserId, + UserRestrictionLog, +} from "@/roblox/types"; +import { + ContainerBuilder, + SectionBuilder, + SeparatorBuilder, + TextDisplayBuilder, +} from "discord.js"; +import { text, thumbnail } from "./components"; + +// -- utils ---------------------------------------------------------------------------------------- + +// ---- timing ----------------------------------------------------------------- +export function tsExact(d: string | Date) { + const date = new Date(d); + return `<t:${Math.floor(date.getTime() / 1000)}:f>`; +} + +export function tsRelative(d: string | Date) { + const date = new Date(d); + return `<t:${Math.floor(date.getTime() / 1000)}:R>`; +} + +export function durationToMs(duration?: string | undefined): number | null { + if (!duration) return null; + const m = duration.trim().match(/^(-?\d+(?:\.\d+)?)(ms|s|m|h|d)$/i); + if (!m) return null; + const value = Number.parseFloat(m[1]); + const unit = m[2].toLowerCase(); + + switch (unit) { + case "ms": + return value; + case "s": + return value * 1000; + case "m": + return value * 60 * 1000; + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + default: + return null; + } +} + +export function formatDurationShort(duration?: string | null) { + if (!duration) return "permanent"; + const ms = durationToMs(duration); + if (ms == null) return "permanent"; + + if (ms < 1000) return `${ms}ms`; + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + return `${days}d`; +} + +export function getDurationString(opts: { + duration?: string; + startDate?: Date; + inclDuration?: boolean; +}): { + endRelative: string; + endAbsolute: string; +} { + const { duration, startDate } = opts; + const perm = { + endRelative: "permanent", + endAbsolute: "permanent", + }; + if (!duration) return perm; + if (!startDate) return perm; + + const durationMs = durationToMs(duration); + if (!durationMs) return perm; + + const startMs = startDate.getTime(); + const endMs = startMs + durationMs; + const endDate = new Date(endMs); + + return { + endAbsolute: tsExact(endDate), + endRelative: tsRelative(endDate), + }; +} + +// ---- moderators ------------------------------------------------------------- +const ModeratorCache = new Map<RobloxUserId, string>(); + +export async function moderatorMention( + moderator: + | { robloxUser: `users/${RobloxUserId}` } + | { gameServerScript: unknown }, +) { + if ("gameServerScript" in moderator) { + return "Banned in-game"; + } + if ("robloxUser" in moderator) { + const id = moderator.robloxUser.split("/").pop(); + if (!id) return moderator.robloxUser; + + let moderatorUsername = ModeratorCache.get(id); + if (!moderatorUsername) { + const [userRes] = await roblox.getUser(id); + if (!userRes) return id; + + moderatorUsername = userRes.displayName; + ModeratorCache.set(id, moderatorUsername); + } + + const url = `https://www.roblox.com/users/${id}/profile`; + return `[${moderatorUsername || id}](${url})`; + } + return "Unknown"; +} + +// -- cards ---------------------------------------------------------------------------------------- + +// ---- bans ------------------------------------------------------------------- +const STATUS_MARKERS: Record<string, string> = { + ACTIVE: "🟥 ACTIVE", + SUPERSEDED: "⚠️ SUPERSEDED", + EXPIRED: "✅ EXPIRED", +}; +type Status = "ACTIVE" | "SUPERSEDED" | "EXPIRED" | string; + +export async function constructBansContainer( + bans: UserRestrictionLog[] | null, + opts: { limit?: number; showPrivate?: boolean; userId?: string } = {}, +) { + const limit = opts.limit ?? 5; + const showPrivate = Boolean(opts.showPrivate); + + if (!bans || bans.length === 0) { + const c = new ContainerBuilder().addTextDisplayComponents( + new TextDisplayBuilder().setContent( + "### Bans\n-# No restriction logs found", + ), + ); + 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 sorted = enriched.sort((a, b) => b.startMs - a.startMs); + + const newerIntervals: { startMs: number; endMs: number }[] = []; + const statuses = new Map<string, "ACTIVE" | "SUPERSEDED" | "EXPIRED">(); + + for (const log of sorted) { + const isSuperseded = newerIntervals.some( + (n) => n.startMs <= log.endMs && n.endMs >= log.startMs, + ); + + if (isSuperseded) statuses.set(log.createTime, "SUPERSEDED"); + else if (log.nowActive && log.active) + statuses.set(log.createTime, "ACTIVE"); + else statuses.set(log.createTime, "EXPIRED"); + + newerIntervals.push({ startMs: log.startMs, endMs: log.endMs }); + } + + const shown = sorted.slice(0, limit); + + const lines = shown.map(async (log) => { + if (!log) return undefined; + + const status = (statuses.get(log.createTime) ?? "EXPIRED") as Status; + const statusMarker = + STATUS_MARKERS[status ?? "EXPIRED"] ?? STATUS_MARKERS.EXPIRED; + + 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), + }); + + return [ + `- ${statusMarker} • banned ${startRel} by ${moderator === "Banned in-game" ? "ingame script" : moderator}`, + ` - **Reason:** ${reason}`, + showPrivate + ? ` - **Private Reason:** ${log.privateReason || "none"}` + : undefined, + ` - **End:** ${endAbsolute} (${endRelative})`, + ] + .filter(Boolean) + .join("\n"); + }); + const awaitedLines = await Promise.all(lines).then((lines) => + lines.filter(Boolean), + ); + + 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 }; +} + +// ---- user ------------------------------------------------------------------- +export async function constructUserContainer( + profile: GetUserResponse, + extras?: { + bans: UserRestrictionLog[] | null; + thumbnailUri: string | null; + showPrivate?: boolean; + }, +) { + const createdAt = new Date(profile.createTime); + const createdAtMs = Math.round(createdAt.getTime() / 1000); + const createdAtString = `<t:${createdAtMs}> (<t:${createdAtMs}:R>)`; + + const profileUrl = `https://www.roblox.com/users/${profile.id}/profile`; + + const bodyText = text( + [ + `## ${profile.displayName}`, + `[@${profile.name}](${profileUrl}) - ${profile.id} - created ${createdAtString}`, + ].join("\n"), + ); + const aboutText = text( + ["### About", profile.about ? profile.about : "-# No bio provided"].join( + "\n", + ), + ); + + const section = new SectionBuilder().addTextDisplayComponents(bodyText); + const icon = thumbnail(extras?.thumbnailUri ?? undefined); + if (icon) section.setThumbnailAccessory(icon); + + const mainContainer = new ContainerBuilder() + .addSectionComponents(section) + .addSeparatorComponents(new SeparatorBuilder()) + .addTextDisplayComponents(aboutText); + + const bans = await constructBansContainer(extras?.bans ?? null, { + limit: 5, + userId: profile.id, + showPrivate: extras?.showPrivate ?? false, + }); + + return { mainContainer, bans }; +}