all repos — stealth-developers @ e604da912b78daf1316aee1ad1545196c6ba317d

feat: add search and getinfo commands
willow hai@wlo.moe
Tue, 08 Jul 2025 01:31:46 +0100
commit

e604da912b78daf1316aee1ad1545196c6ba317d

parent

d816a1cab318fbc6d5205897c2902c49574710fa

M src/config.tssrc/config.ts

@@ -31,6 +31,7 @@ );

const robloxSchema = z.object({ apiKey: z.string(), + cookie: z.string().optional(), }); const bloxlinkSchema = z.object({
A src/interactions/commands/search.ts

@@ -0,0 +1,143 @@

+import { + ActionRowBuilder, + ButtonBuilder, + type ButtonInteraction, + ButtonStyle, + type ChatInputCommandInteraction, + type Client, + ContainerBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + SlashCommandBuilder, + TextDisplayBuilder, + escapeMarkdown, +} from "discord.js"; + +import config from "@/config"; +import { getRobloxUser, searchRobloxUsers } from "@/utils/roblox"; +import { formatUserInfo } from "@/utils/userInfo"; + +const commandData = new SlashCommandBuilder() + .setName("search") + .setDescription("search for roblox users") + .addStringOption((option) => + option + .setName("query") + .setDescription("the username to search for") + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName("limit") + .setDescription("number of results to show (1-25)") + .setMinValue(1) + .setMaxValue(25) + .setRequired(false), + ); + +async function execute( + _client: Client, + interaction: ChatInputCommandInteraction, +) { + const query = interaction.options.getString("query", true); + const limit = interaction.options.getInteger("limit") || 10; + + await interaction.deferReply(); + + const { + users: usersResult, + error, + code, + } = await searchRobloxUsers(query, limit); + + if (error) { + if (code === 429) + return interaction.editReply({ + content: "❌ ran into a rate limit, wait a minute or so and retry", + }); + + return interaction.editReply({ + content: `❌ error while searching: ${error}`, + }); + } + + if (usersResult.length === 0) { + await interaction.editReply({ + content: `❌ no users found for "${query}"`, + }); + return; + } + + const userContainers = Promise.all( + usersResult.map(async (user) => { + const userId = user.id; + const userResult = await getRobloxUser(String(userId), 75); + if (!userResult) return null; + + const container = new ContainerBuilder().setAccentColor([203, 166, 247]); + const avatarParent = new MediaGalleryBuilder(); + if (userResult.thumbnail.done) { + const avatar = new MediaGalleryItemBuilder().setURL( + userResult.thumbnail.response.imageUri, + ); + avatarParent.addItems(avatar); + } + + const title = new TextDisplayBuilder().setContent( + `## ${userResult.user.name} (${userResult.user.id})`, + ); + + const description = new TextDisplayBuilder().setContent( + escapeMarkdown(userResult.user.about || "no description provided"), + ); + + const profileLink = new ButtonBuilder() + .setLabel("view profile") + .setStyle(ButtonStyle.Link) + .setURL(`https://www.roblox.com/users/${userResult.user.id}/profile`); + const showMore = new ButtonBuilder() + .setLabel("show more") + .setStyle(ButtonStyle.Primary) + .setCustomId(`search:${userResult.user.id}`); + + const actionRow = new ActionRowBuilder<ButtonBuilder>().addComponents( + profileLink, + showMore, + ); + + container.addMediaGalleryComponents(avatarParent); + container.addTextDisplayComponents(title, description); + container.addActionRowComponents(actionRow); + + return container; + }), + ); + const usersData = await userContainers; + const nonNull = usersData.filter((userData) => userData !== null); + await interaction.followUp({ + components: nonNull, + flags: ["IsComponentsV2"], + }); +} + +async function buttonExecute(_client: Client, interaction: ButtonInteraction) { + if (!interaction.isButton()) return; + + const [action, userId] = interaction.customId.split(":"); + if (action !== "search") return; + + const { user, thumbnail } = await getRobloxUser(userId, 420); + const { embed } = formatUserInfo( + user, + thumbnail.done ? thumbnail.response.imageUri : null, + ); + + await interaction.reply({ embeds: [embed], flags: ["Ephemeral"] }); +} + +export default { + enabled: true, + data: commandData, + execute, + buttonExecute, +};
A src/interactions/commands/user.ts

@@ -0,0 +1,85 @@

+import { + type ChatInputCommandInteraction, + type Client, + SlashCommandBuilder, +} from "discord.js"; + +import config from "@/config"; +import { + getUserInfoFromDiscord, + getUserInfoFromRoblox, + getUserInfoFromRobloxUsername, +} from "@/utils/userInfo"; + +const commandData = new SlashCommandBuilder() + .setName("user") + .setDescription("get roblox user information") + .addSubcommand((subcommand) => + subcommand + .setName("discord") + .setDescription("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 user id or username to look up") + .setRequired(true), + ), + ); + +async function execute( + _client: Client, + interaction: ChatInputCommandInteraction, +) { + const subcommand = interaction.options.getSubcommand(); + + await interaction.deferReply(); + + let result: Awaited<ReturnType<typeof getUserInfoFromDiscord>>; + + if (subcommand === "discord") { + const discordUser = interaction.options.getUser("user", true); + result = await getUserInfoFromDiscord(discordUser.id); + } else if (subcommand === "roblox") { + const input = interaction.options.getString("input", true); + + if (/^\d+$/.test(input)) { + result = await getUserInfoFromRoblox(input); + } else { + result = await getUserInfoFromRobloxUsername(input); + } + } else { + await interaction.editReply({ + content: "❌ unknown subcommand.", + }); + return; + } + + if ("error" in result) { + await interaction.editReply({ + content: `❌ ${result.error}`, + }); + return; + } + + await interaction.editReply({ + embeds: [result.embed], + components: result.components, + }); +} + +export default { + enabled: config.data.roblox?.apiKey && (config.data.bloxlink?.token || true), + data: commandData, + execute, +};
M src/interactions/menus/getInfo.tssrc/interactions/menus/getInfo.ts

@@ -1,19 +1,12 @@

import { - type ActionRow, ApplicationCommandType, - ButtonBuilder, - type ButtonComponent, - ButtonStyle, type Client, ContextMenuCommandBuilder, - EmbedBuilder, type UserContextMenuCommandInteraction, } from "discord.js"; import config from "@/config"; -import type { GetUserResponse } from "@/types/roblox"; -import { getConnectedRobloxUser, getRobloxUser } from "@/utils/roblox"; -import { ActionRowBuilder } from "discord.js"; +import { getUserInfoFromDiscord } from "@/utils/userInfo"; const commandData = new ContextMenuCommandBuilder() .setName("get account")

@@ -23,49 +16,18 @@ async function execute(

_client: Client, interaction: UserContextMenuCommandInteraction, ) { - const connectedAccount = await getConnectedRobloxUser(interaction.user.id); - if ("error" in connectedAccount) + const result = await getUserInfoFromDiscord(interaction.targetId); + + if ("error" in result) { return interaction.reply({ - content: `error: ${connectedAccount.error}`, + content: `❌ ${result.error}`, + flags: ["Ephemeral"], }); - - const { user, thumbnail } = await getRobloxUser(connectedAccount.robloxID); - const createdAt = new Date(user.createTime); - const timestamp = Math.floor(createdAt.getTime() / 1000); - - const embed = new EmbedBuilder() - .setAuthor({ - name: user.displayName, - url: `https://www.roblox.com/users/${user.id}/profile`, - }) - .setThumbnail(thumbnail.done ? thumbnail.response.imageUri : "") - .setDescription(user.about || null) - .addFields( - { - name: "id", - value: user.id, - }, - { - name: "username", - value: user.name, - }, - { - name: "created", - value: `<t:${timestamp}> (<t:${timestamp}:R>)`, - }, - ) - .setColor(0x00aaff); - - const buttonRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setURL(`https://www.roblox.com/users/${user.id}/profile`) - .setLabel("view profile") - .setStyle(ButtonStyle.Link), - ) as unknown as ActionRow<ButtonComponent>; + } await interaction.reply({ - embeds: [embed], - components: [buttonRow], + embeds: [result.embed], + components: result.components, }); }
M src/types/roblox.tssrc/types/roblox.ts

@@ -130,3 +130,30 @@ | "FRIENDS_FOLLOWING_AND_FOLLOWERS"

| "EVERYONE"; }; } + +export interface RobloxSearchUser { + previousUsernames: string[]; + hasVerifiedBadge: boolean; + id: number; + name: string; + displayName: string; +} + +export interface RobloxSearchResponse { + previousPageCursor: string | null; + nextPageCursor: string | null; + data: RobloxSearchUser[]; +} + +export type AvatarSize = + | 48 + | 50 + | 60 + | 75 + | 100 + | 110 + | 150 + | 180 + | 352 + | 420 + | 720;
M src/utils/roblox.tssrc/utils/roblox.ts

@@ -1,18 +1,24 @@

import config from "@/config"; import type { + AvatarSize, BLApiResponse, GetUserResponse, + RobloxSearchResponse, + RobloxSearchUser, ThumbnailResponse, } from "@/types/roblox"; -export async function getRobloxUser(userId: string): Promise<{ +export async function getRobloxUser( + userId: string, + size: AvatarSize = 420, +): Promise<{ user: GetUserResponse; thumbnail: ThumbnailResponse; }> { if (!config.data.roblox) throw new Error("roblox API key is not configured"); const getUserUrl = `https://apis.roblox.com/cloud/v2/users/${userId}`; - const getThumbnailUrl = `https://apis.roblox.com/cloud/v2/users/${userId}:generateThumbnail`; + const getThumbnailUrl = `https://apis.roblox.com/cloud/v2/users/${userId}:generateThumbnail?size=${size}`; const [userRes, thumbnailRes] = await Promise.all([ fetch(getUserUrl, { headers: {

@@ -53,3 +59,66 @@ );

return await response.json(); } + +export async function getRobloxIdFromUsername( + username: string, +): Promise<string | null> { + try { + const response = await fetch( + "https://users.roblox.com/v1/usernames/users", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + usernames: [username], + excludeBannedUsers: true, + }), + }, + ); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + + if (data.data && data.data.length > 0 && data.data[0].id) { + return data.data[0].id.toString(); + } + + return null; + } catch (error) { + return null; + } +} + +export async function searchRobloxUsers( + keyword: string, + limit = 10, +): Promise<{ users: RobloxSearchUser[]; error?: string; code?: number }> { + try { + const response = await fetch( + `https://users.roblox.com/v1/users/search?keyword=${encodeURIComponent(keyword)}&limit=${limit}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + ...(config.data.roblox?.cookie + ? { Cookie: config.data.roblox.cookie } + : {}), + }, + }, + ); + + if (!response.ok) { + return { users: [], error: response.statusText, code: response.status }; + } + + const data: RobloxSearchResponse = await response.json(); + return { users: data.data || [] }; + } catch (error) { + return { users: [], error: "failed to search users" }; + } +}
A src/utils/userInfo.ts

@@ -0,0 +1,116 @@

+import type { GetUserResponse } from "@/types/roblox"; +import { + getConnectedRobloxUser, + getRobloxIdFromUsername, + getRobloxUser, +} from "@/utils/roblox"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, +} from "discord.js"; + +export type AvatarSize = + | 48 + | 50 + | 60 + | 75 + | 100 + | 110 + | 150 + | 180 + | 352 + | 420 + | 720; + +export interface UserInfoResult { + embed: EmbedBuilder; + components: ActionRowBuilder<ButtonBuilder>[]; +} + +export async function getUserInfoFromRobloxUsername( + username: string, +): Promise<UserInfoResult | { error: string }> { + const robloxId = await getRobloxIdFromUsername(username); + + if (!robloxId) { + return { error: "user not found or username is invalid" }; + } + + return getUserInfoFromRoblox(robloxId); +} + +export async function getUserInfoFromDiscord( + discordId: string, +): Promise<UserInfoResult | { error: string }> { + const connectedAccount = await getConnectedRobloxUser(discordId); + if ("error" in connectedAccount) { + return { error: connectedAccount.error }; + } + + return getUserInfoFromRoblox(connectedAccount.robloxID); +} + +export async function getUserInfoFromRoblox( + robloxId: string, + avatarSize: AvatarSize = 420, +): Promise<UserInfoResult | { error: string }> { + try { + const { user, thumbnail } = await getRobloxUser(robloxId, avatarSize); + return formatUserInfo( + user, + thumbnail.done ? thumbnail.response.imageUri : null, + ); + } catch (error) { + return { + error: + error instanceof Error ? error.message : "failed to fetch user data", + }; + } +} + +export function formatUserInfo( + user: GetUserResponse, + thumbnailUrl: string | null, +): UserInfoResult { + const createdAt = new Date(user.createTime); + const timestamp = Math.floor(createdAt.getTime() / 1000); + + const embed = new EmbedBuilder() + .setAuthor({ + name: user.displayName, + url: `https://www.roblox.com/users/${user.id}/profile`, + }) + .setDescription(user.about || null) + .addFields( + { + name: "id", + value: user.id, + }, + { + name: "username", + value: user.name, + }, + { + name: "created", + value: `<t:${timestamp}> (<t:${timestamp}:R>)`, + }, + ) + .setColor(0x00aaff); + + if (thumbnailUrl) { + embed.setThumbnail(thumbnailUrl); + } + + const components = [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setURL(`https://www.roblox.com/users/${user.id}/profile`) + .setLabel("view profile") + .setStyle(ButtonStyle.Link), + ), + ]; + + return { embed, components }; +}