import config from "@/config"; import type { ApiError, ApiErrorV2, BloxlinkResult, ErrorCode, GatewayError, GenerateThumbnailOptions, GenerateThumbnailResponse, GetUserResponse, GetUsersByUsernamesPayload, GetUsersByUsernamesResponse, Result, SearchUsersResponse, UserRestrictionLog, UserRestrictionLogsResponse, } from "./types/"; const ERROR_CODES: Set = new Set([ "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).code === "string" && typeof (x as Record).message === "string" && isErrorCode((x as Record).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).errors) ); } function isApiError(x: unknown): x is ApiError { return isApiErrorV2(x) || isGatewayError(x); } function errorFromStatus(status: number, statusText: string): ApiErrorV2 { const codeMap: Record = { 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(path: string, init?: RequestInit): Promise> { 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); return [null, err]; } return [body as T, null]; } catch (e) { return [null, { code: "INTERNAL", message: String(e) }]; } } async getUserRestrictionLogs( universeId: string, userId: string, ): Promise> { const allLogs: UserRestrictionLog[] = []; let pageToken = ""; let pages = 0; const MAX_PAGES = 50; while (pages < MAX_PAGES) { 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(path); if (err) return [null, err]; allLogs.push(...data.logs); if (!data.nextPageToken) break; pageToken = data.nextPageToken; pages++; } return [allLogs, null]; } async getUser(userId: string): Promise> { const path = `/cloud/v2/users/${userId}`; const [data, err] = await this.request(path); if (err) return [null, err]; return [data, null]; } async getUsersByUsernames( usernames: string[], ): Promise> { const payload: GetUsersByUsernamesPayload = { usernames, excludeBannedUsers: false, }; const path = "https://users.roblox.com/v1/usernames/users"; const [data, err] = await this.request(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> { const path = `https://users.roblox.com/v1/users/search?keyword=${encodeURIComponent(keyword)}&limit=${params.limit}&cursor=${params.cursor}`; const [data, err] = await this.request(path); if (err) return [null, err]; return [data, null]; } async getThumbnail( userId: string, options?: GenerateThumbnailOptions, ): Promise> { 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(path); if (err) return [null, err]; return [data, null]; } async getLinkedAccount(discordId: string): Promise { const response = await fetch( `https://api.blox.link/v4/public/discord-to-roblox/${discordId}`, { headers: { Authorization: config.roblox.bloxlinkToken, }, }, ); const data = await response.json(); if (!response.ok) return [null, { error: data.error || "Unknown error" }]; return [data, null]; } } export const roblox = new RobloxClient( config.roblox.cookie, config.roblox.apiKey, );