all repos — stealth-developers @ 837b1ab337220d1cdaeacd59eb4b730ef8c92472

feat: add get info ctx menu command
willow hai@wlo.moe
Thu, 05 Jun 2025 18:08:23 +0100
commit

837b1ab337220d1cdaeacd59eb4b730ef8c92472

parent

fa9b1e5ac931cfda1d8424af3a69c93076faa90a

4 files changed, 266 insertions(+), 0 deletions(-)

jump to
M src/config.tssrc/config.ts

@@ -19,10 +19,20 @@ iconURL: z.string().optional(),

}), ); +const robloxSchema = z.object({ + apiKey: z.string(), +}); + +const bloxlinkSchema = z.object({ + token: z.string(), +}); + const schema = z.object({ discord: discordSchema, mongodb: mongodbSchema, projects: projectSchema, + bloxlink: bloxlinkSchema, + roblox: robloxSchema, trelloBoardId: z.string().optional(), });
A src/interactions/menus/getInfo.ts

@@ -0,0 +1,75 @@

+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"; + +const commandData = new ContextMenuCommandBuilder() + .setName("get account") + .setType(ApplicationCommandType.User); + +async function execute( + _client: Client, + interaction: UserContextMenuCommandInteraction, +) { + const connectedAccount = await getConnectedRobloxUser(interaction.user.id); + if ("error" in connectedAccount) + return interaction.reply({ + content: `error: ${connectedAccount.error}`, + }); + + 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], + }); +} + +export default { + data: commandData, + execute, +};
A src/types/roblox.ts

@@ -0,0 +1,132 @@

+type V2ErrorCode = + | "INVALID_ARGUMENT" + | "PERMISSION_DENIED" + | "NOT_FOUND" + | "ABORTED" + | "RESOURCE_EXHAUSTED" + | "CANCELLED" + | "INTERNAL" + | "NOT_IMPLEMENTED" + | "UNAVAILABLE"; + +interface V2ErrorResponse { + code: V2ErrorCode; + message: string; + details: Array<Record<string, unknown>>; +} + +// v1 resource err model +export type V1ErrorCode = + | "INVALID_ARGUMENT" + | "INSUFFICIENT_SCOPE" + | "PERMISSION_DENIED" + | "NOT_FOUND" + | "ABORTED" + | "RESOURCE_EXHAUSTED" + | "CANCELLED" + | "INTERNAL" + | "NOT_IMPLEMENTED" + | "UNAVAILABLE"; + +export interface V1ErrorResponse { + error: V1ErrorCode; + message: string; + errorDetails: Array<{ + errorDetailType: string; + [key: string]: unknown; + }>; +} + +// OrderedDataStores err model +export interface OrderedDataStoresErrorResponse { + code: V1ErrorCode; + message: string; +} + +// UserSearch api +export interface UserSearchParams { + keyword: string; + sessionId?: string; + limit?: 10 | 25 | 50 | 100; + cursor?: string; +} + +export type UserSearchResponse = + | UserSearchOkResponse + | UserSearchBadResponse + | UserSearchRateLimitResponse; + +export type BLApiResponse = + | { + robloxID: string; + resolved: object; + } + | { + error: "User not found"; + }; + +export type ThumbnailResponse = + | { + done: true; + response: { imageUri: string }; + } + | { + done: false; + }; + +export interface UserSearchPlayer { + previousUsernames: string[]; + hasVerifiedBadge: boolean; + id: number; + name: string; + displayName: string; +} + +export interface UserSearchOkResponse { + previousPageCursor: string | null; + nextPageCursor: string | null; + data: Array<UserSearchPlayer>; +} + +export interface UserSearchBadResponse { + statusCode: 400; + error: "INVALID_ARGUMENT"; + message: "The keyword was filtered." | "The keyword is too short."; +} + +export interface UserSearchRateLimitResponse { + statusCode: 429; + error: "RESOURCE_EXHAUSTED"; + message: "Too many requests."; +} + +export type UserSearchError = + | UserSearchBadResponse + | UserSearchRateLimitResponse; + +export interface GetUserResponse { + path: string; + /** RFC 3339 formatted date string */ + createTime: string; + id: string; + name: string; + displayName: string; + about?: string; + locale: string; + premium: boolean; + idVerified?: boolean; + socialNetworkProfiles: { + facebook?: string; + twitter?: string; + youtube?: string; + twitch?: string; + guilded?: string; + visibility: + | "SOCIAL_NETWORK_VISIBILITY_UNSPECIFIED" + | "NO_ONE" + | "FRIENDS" + | "FRIENDS_AND_FOLLOWING" + | "FRIENDS_FOLLOWING_AND_FOLLOWERS" + | "EVERYONE"; + }; +}
A src/utils/roblox.ts

@@ -0,0 +1,49 @@

+import config from "@/config"; +import type { + BLApiResponse, + GetUserResponse, + ThumbnailResponse, +} from "@/types/roblox"; + +export async function getRobloxUser(userId: string): Promise<{ + user: GetUserResponse; + thumbnail: ThumbnailResponse; +}> { + const getUserUrl = `https://apis.roblox.com/cloud/v2/users/${userId}`; + const getThumbnailUrl = `https://apis.roblox.com/cloud/v2/users/${userId}:generateThumbnail`; + const [userRes, thumbnailRes] = await Promise.all([ + fetch(getUserUrl, { + headers: { + "Content-Type": "application/json", + "x-api-key": config.data.roblox.apiKey, + }, + }), + fetch(getThumbnailUrl, { + headers: { + "Content-Type": "application/json", + "x-api-key": config.data.roblox.apiKey, + }, + }), + ]); + + const [userData, thumbnailData] = await Promise.all([ + userRes.json(), + thumbnailRes.json(), + ]); + + return { user: userData, thumbnail: thumbnailData }; +} + +export async function getConnectedRobloxUser( + discordId: string, +): Promise<BLApiResponse> { + const getUserUrl = `https://api.blox.link/v4/public/discord-to-roblox/${discordId}`; + + const response = await fetch(getUserUrl, { + headers: { + Authorization: config.data.bloxlink.token, + }, + }); + + return await response.json(); +}