feat: roblox api client
vi v@vt3e.cat
Fri, 27 Feb 2026 00:23:29 +0000
5 files changed,
384 insertions(+),
0 deletions(-)
M
src/config.ts
→
src/config.ts
@@ -4,6 +4,11 @@
import { loggers } from "@/utils/logging"; const logger = loggers.config; +const robloxSchema = z.object({ + cookie: z.string(), + apiKey: z.string(), +}); + const discordSchema = z.object({ token: z.string(), app_id: z.string(),@@ -41,6 +46,7 @@ const schema = z.object({
discord: discordSchema, s3: s3Schema, projects: projectSchema, + roblox: robloxSchema, trelloBoardId: z.string().optional(), developerId: z.string(), terminology: z.string().default("project"),
A
src/roblox/client.ts
@@ -0,0 +1,192 @@
+import config from "@/config"; +import type { + ApiError, + ApiErrorV2, + ErrorCode, + GatewayError, + GetUserResponse, + GetUsersByUsernamesPayload, + GetUsersByUsernamesResponse, + Result, + SearchUsersResponse, + UserRestrictionLog, + UserRestrictionLogsResponse, +} from "./types/"; + +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 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 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, + }; +} + +class RobloxClient { + private readonly baseUrl = "https://apis.roblox.com"; + private readonly cookie: string; + private readonly apiKey: string; + + constructor(cookie: string, apiKey: string) { + this.cookie = cookie; + this.apiKey = apiKey; + } + + async request<T>(path: string, init?: RequestInit): Promise<Result<T>> { + let baseUrl = this.baseUrl; + if (path.startsWith("https://")) { + baseUrl = ""; + } + + try { + const res = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + Cookie: this.cookie, + "x-api-key": `${this.apiKey}`, + 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); + + console.log(res); + return [null, err]; + } + + return [body as T, null]; + } catch (e) { + return [null, { code: "INTERNAL", message: String(e) }]; + } + } + + async getUserRestrictionLogs( + universeId: string, + userId: string, + ): Promise<Result<UserRestrictionLog[]>> { + const allLogs: UserRestrictionLog[] = []; + let pageToken = ""; + + while (true) { + const filter = encodeURIComponent( + `place == '' && user == 'users/${userId}'`, + ); + const path = `/user/cloud/v2/universes/${universeId}/user-restrictions:listLogs?filter=${filter}&pageToken=${pageToken}`; + + const [data, err] = await this.request<UserRestrictionLogsResponse>(path); + if (err) return [null, err]; + + allLogs.push(...data.logs); + + if (!data.nextPageToken) break; + pageToken = data.nextPageToken; + } + + return [allLogs, null]; + } + + async getUser(userId: string): Promise<Result<GetUserResponse>> { + const path = `/cloud/v2/users/${userId}`; + + const [data, err] = await this.request<GetUserResponse>(path); + if (err) return [null, err]; + + return [data, null]; + } + + async getUsersByUsernames( + usernames: string[], + ): Promise<Result<GetUsersByUsernamesResponse>> { + const payload: GetUsersByUsernamesPayload = { + usernames, + excludeBannedUsers: false, + }; + + const path = "https://users.roblox.com/v1/usernames/users"; + + const [data, err] = await this.request<GetUsersByUsernamesResponse>(path, { + method: "POST", + body: JSON.stringify(payload), + }); + if (err) return [null, err]; + + return [data, null]; + } + + async searchUsers( + keyword: string, + params: { limit: number; cursor: string } = { limit: 10, cursor: "" }, + ): 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]; + } +} + +export const roblox = new RobloxClient( + config.roblox.cookie, + config.roblox.apiKey, +);
A
src/roblox/types/index.ts
@@ -0,0 +1,102 @@
+// -- 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 Result<T> = [T, null] | [null, ApiError]; + +// -- exports -------------------------------------------------------------------------------------- +export * from "./user-restriction"; +export * from "./users";
A
src/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
src/roblox/types/users.ts
@@ -0,0 +1,52 @@
+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; + }[]; +};