diff --git a/src/docs/.vitepress/config.ts b/src/docs/.vitepress/config.ts index 93b0895..f1302e3 100644 --- a/src/docs/.vitepress/config.ts +++ b/src/docs/.vitepress/config.ts @@ -38,6 +38,7 @@ export default defineConfig({ items: [ { text: "Users", link: "/guide/resources/users" }, { text: "Groups", link: "/guide/resources/groups" }, + { text: "Universes", link: "/guide/resources/universes" }, ] } ], diff --git a/src/docs/guide/resources/universes.md b/src/docs/guide/resources/universes.md new file mode 100644 index 0000000..c1f4ea4 --- /dev/null +++ b/src/docs/guide/resources/universes.md @@ -0,0 +1,186 @@ +# Universes Resource + +The Universes resource provides methods to interact with Roblox universe (game) data, including settings, user restrictions, messaging, translation, and speech generation. + +## Getting Universe Information + +Retrieve universe information by universe ID: + +```typescript +const universe = await client.universes.get("123456789"); + +console.log(universe.displayName); +console.log(universe.voiceChatEnabled); +console.log(universe.desktopEnabled); +console.log(universe.privateServerPriceRobux); +``` + +## Updating Universe Settings + +Update platform availability and other settings: + +```typescript +await client.universes.update("123456789", { + voiceChatEnabled: true, + desktopEnabled: true, + mobileEnabled: true, + privateServerPriceRobux: 100 +}); +``` + +## Managing User Restrictions + +### Listing User Restrictions + +Get all banned users in your universe: + +```typescript +const restrictions = await client.universes.listUserRestrictions("123456789"); + +for (const restriction of restrictions.userRestrictions) { + console.log(restriction.user); + console.log(restriction.gameJoinRestriction.active); + console.log(restriction.gameJoinRestriction.displayReason); +} +``` + +### Getting a User Restriction + +Check restriction details for a specific user: + +```typescript +const restriction = await client.universes.getUserRestriction( + "123456789", // Universe ID + "987654321" // User ID +); + +console.log(restriction.gameJoinRestriction.duration); +console.log(restriction.gameJoinRestriction.displayReason); +``` + +### Banning a User + +Apply a temporary or permanent ban: + +```typescript +await client.universes.updateUserRestriction( + "123456789", + "987654321", + { + active: true, + duration: "86400s", // 24 hours in seconds + privateReason: "Cheating detected", + displayReason: "Violation of game rules", + excludeAltAccounts: true + } +); +``` + +### Unbanning a User + +Remove a user restriction: + +```typescript +await client.universes.updateUserRestriction( + "123456789", + "987654321", + { + active: false, + duration: "0s", + privateReason: "Ban appeal approved", + displayReason: "Restriction lifted", + excludeAltAccounts: false + } +); +``` + +### Viewing Restriction Logs + +Track all restriction changes: + +```typescript +const logs = await client.universes.listUserRestrictionLogs("123456789"); + +for (const log of logs.logs) { + console.log(`User: ${log.user}`); + console.log(`Moderator: ${log.moderator.robloxUser}`); + console.log(`Reason: ${log.displayReason}`); +} +``` + +Filter logs by user or place: + +```typescript +const userLogs = await client.universes.listUserRestrictionLogs("123456789", { + filter: "user == 'users/987654321'" +}); +``` + +## Publishing Messages + +Send messages to subscribed game servers: + +```typescript +await client.universes.publishMessage("123456789", { + topic: "server-announcements", + message: JSON.stringify({ + type: "maintenance", + scheduledTime: "2024-11-15T03:00:00Z" + }) +}); +``` + +::: tip +Use MessagingService in your game to subscribe to topics and receive these messages. +::: + +## Translating Text + +Translate strings into multiple languages: + +```typescript +const translation = await client.universes.translateText("123456789", { + text: "Welcome to the game!", + sourceLanguageCode: "en-us", + targetLanguageCodes: ["es-es", "fr-fr"] +}); + +console.log(translation.translations["es-es"]); +console.log(translation.translations["fr-fr"]); +``` + +## Generating Speech Assets + +Create AI-generated speech from text: + +```typescript +const speechAsset = await client.universes.generateSpeechAsset("123456789", { + text: "Welcome to the game!", + speechStyle: { + voiceId: "en_us_001", + pitch: 1.0, + speed: 1.0 + } +}); + +if (speechAsset.response.moderationResult.moderationState === "Approved") { + console.log(`Asset ID: ${speechAsset.response.assetId}`); +} +``` + +Customize voice characteristics: + +```typescript +const deepVoice = await client.universes.generateSpeechAsset("123456789", { + text: "This is a deep voice", + speechStyle: { + voiceId: "en_us_001", + pitch: 0.7, // Lower pitch + speed: 0.8 // Slower speed + } +}); +``` + +::: warning +Generated speech assets require moderation approval before use. Check the `moderationState` field. +::: diff --git a/src/index.ts b/src/index.ts index cd57e51..24d4b7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { HttpClient, HttpOptions } from "./http"; import { Groups } from "./resources/groups"; +import { Universes } from "./resources/universes"; import { Users } from "./resources/users"; import type { AuthConfig } from "./types/auth"; @@ -45,8 +46,9 @@ export interface OpenCloudConfig { * ``` */ export class OpenCloud { - public users!: Users; public groups!: Groups; + public universes!: Universes; + public users!: Users; private http!: HttpClient; /** @@ -77,8 +79,9 @@ export class OpenCloud { * @private */ private initializeResources(http: HttpClient): void { - this.users = new Users(http); this.groups = new Groups(http); + this.universes = new Universes(http); + this.users = new Users(http); } /** @@ -126,3 +129,4 @@ export type { HttpOptions } from "./http"; export { Users } from "./resources/users"; export { Groups } from "./resources/groups"; +export { Universes } from "./resources/universes"; diff --git a/src/resources/groups.ts b/src/resources/groups.ts index 858c496..0ff0c29 100644 --- a/src/resources/groups.ts +++ b/src/resources/groups.ts @@ -12,7 +12,6 @@ import type { /** * API client for Roblox Group endpoints. - * Provides methods to retrieve group information, shouts, and group memberships. * * @see https://create.roblox.com/docs/cloud/reference/Group */ diff --git a/src/resources/universes.ts b/src/resources/universes.ts new file mode 100644 index 0000000..dd67e52 --- /dev/null +++ b/src/resources/universes.ts @@ -0,0 +1,401 @@ +import { HttpClient } from "../http"; +import { ListOptions } from "../types"; +import { + GameJoinRestriction, + PublishMessageBody, + SpeechAssetBody, + SpeechAssetOperation, + SpeechAssetResponse, + TranslateTextBody, + TranslateTextResponse, + Universe, + UniverseBody, + UserRestriction, + UserRestrictionLogPage, + UserRestrictionPage, +} from "../types"; +import { buildFieldMask } from "../utils/fieldMask"; +import { generateIdempotencyKey } from "../utils/idempotency"; + +/** + * API client for Roblox universe endpoints. + * + * @see https://create.roblox.com/docs/cloud/reference/Universe + */ +export class Universes { + /** + * Creates a new Universes API client. + * + * @param http - HTTP client for making API requests + */ + constructor(private http: HttpClient) {} + + /** + * Retrieves a universe's information by universe ID. + * + * @param universeId - The unique universe ID (numeric string) + * @returns Promise resolving to the universe's data + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universe is not found or other API error occurs + * + * @example + * ```typescript + * const universe = await client.universes.get('123456789'); + * console.log(universe.displayName); // "Roblox" + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/universe#Cloud_Getuniverse + */ + async get(universeId: string): Promise { + return this.http.request(`/cloud/v2/universes/${universeId}`); + } + + /** + * Updates universe by universe ID. + * + * @param universeId - The unique universe ID (numeric string) + * @param body - The universe data to update + * @returns Promise resolving to the updated universe's data + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universe is not found or other API error occurs + * + * @example + * ```typescript + * const updatedUniverse = await client.universes.update('123456789', { + * voiceChatEnabled: true, + * desktopEnabled: true + * }); + * console.log(updatedUniverse.voiceChatEnabled); // true + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/universe#Cloud_UpdateUniverse + */ + async update(universeId: string, body: UniverseBody): Promise { + const searchParams = new URLSearchParams(); + + const fieldMask = buildFieldMask(body); + searchParams.set("updateMask", fieldMask); + + return this.http.request(`/cloud/v2/universes/${universeId}`, { + method: "PATCH", + body: JSON.stringify(body), + searchParams, + }); + } + + /** + * List user restrictions for users that have ever been banned in either a universe or a specific place. + * + * @param universeId - The unique universe ID (numeric string) + * @param options - List options for pagination + * @param options.maxPageSize - Maximum items per page (default set by API) + * @param options.pageToken - Token from previous response for next page + * @returns Promise resolving to a page of user restrictions + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universe is not found or other API error occurs + * + * @example + * ```typescript + * const userRestrictions = await client.universes.listUserRestrictions('123456789'); + * console.log(userRestrictions); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_ListUserRestrictions__Using_Universes + */ + async listUserRestrictions( + universeId: string, + options: ListOptions = {}, + ): Promise { + const searchParams = new URLSearchParams(); + if (options.maxPageSize) + searchParams.set("maxPageSize", options.maxPageSize.toString()); + if (options.pageToken) searchParams.set("pageToken", options.pageToken); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions`, + { + method: "GET", + searchParams, + }, + ); + } + + /** + * Get the user restriction. + * + * *Requires `universe.user-restriction:read` scope.* + * + * @param universeId - The universe ID. (numeric string) + * @param userRestrictionId - The user ID. (numeric string) + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If an API error occurs + * + * @example + * ```typescript + * const userRestriction = await client.universes.getUserRestriction('123456789', '123456789'); + * console.log(userRestriction); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_GetUserRestriction__Using_Universes + */ + async getUserRestriction( + universeId: string, + userRestrictionId: string, + ): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions/${userRestrictionId}`, + { + method: "GET", + }, + ); + } + + /** + * Update the user restriction. + * + * *Requires `universe.user-restriction:write` scope.* + * + * @param universeId - The universe ID (numeric string) + * @param userRestrictionId - The user ID (numeric string) + * @param body - The user restriction data to update + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If an API error occurs + * + * @example + * ```typescript + * const userRestriction = await client.universes.updateUserRestriction('123456789', '123456789', { + * active: true, + * duration: "3s", + * privateReason: "some private reason", + * displayReason: "some display reason", + * excludeAltAccounts: true + * }); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_UpdateUserRestriction__Using_Universes_Places + */ + async updateUserRestriction( + universeId: string, + userRestrictionId: string, + body: GameJoinRestriction, + ): Promise { + const searchParams = new URLSearchParams(); + + searchParams.set("updateMask", "game_join_restriction"); + + const idempotencyKey = generateIdempotencyKey(); + const firstSent = new Date().toISOString(); + + searchParams.set("idempotencyKey.key", idempotencyKey); + searchParams.set("idempotencyKey.firstSent", firstSent); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions/${userRestrictionId}`, + { + method: "PATCH", + body: JSON.stringify(body), + searchParams, + }, + ); + } + + /** + * List changes to UserRestriction resources within a given universe. This includes both universe-level and place-level restrictions. + * For universe-level restriction logs, the place field will be empty. + * + * *Requires `universe.user-restriction:read` scope.* + * + * @param universeId - The universe ID (numeric string) + * @param options - List options including pagination and filtering + * @param options.maxPageSize - Maximum items per page (default set by API) + * @param options.pageToken - Token from previous response for next page + * @param options.filter - Filter expression (e.g., "user == 'users/123'" && "place == 'places/456'") + * @returns Promise resolving to the user restriction response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * const userRestrictionLogs = await client.universes.listUserRestrictionLogs('123456789', { + * maxPageSize: 50, + * filter: `"user == 'users/123'" && "place == 'places/456'"` + * }); + * console.log(userRestrictionLogs); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/UserRestriction#Cloud_ListUserRestrictionLogs + */ + async listUserRestrictionLogs( + universeId: string, + options: ListOptions & { filter?: string } = {}, + ): Promise { + const searchParams = new URLSearchParams(); + if (options.maxPageSize) + searchParams.set("maxPageSize", options.maxPageSize.toString()); + if (options.pageToken) searchParams.set("pageToken", options.pageToken); + if (options.filter) searchParams.set("filter", options.filter); + + return this.http.request( + `/cloud/v2/universes/${universeId}/user-restrictions:listLogs`, + { + method: "GET", + searchParams, + }, + ); + } + + /** + * Generates an English speech audio asset from the specified text. + * + * This endpoint requires the `asset:read` and `asset:write` scopes in addition to the `universe:write` scope. + * + * @param universeId - The universe ID (numeric string) + * @param body - The speech asset generation request body + * @returns Promise resolving to the speech asset response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * const speechAsset = await client.universes.generateSpeechAsset('123456789', { + * text: "Hello, world!", + * speechStyle: { + * voiceId: "rbx_voice_001", + * pitch: 1, + * speed: 1 + * } + * }); + * console.log(speechAsset); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/Universe#Cloud_GenerateSpeechAsset + */ + async generateSpeechAsset(universeId: string, body: SpeechAssetBody) { + const speechOperation = await this.http.request( + `/cloud/v2/universes/${universeId}:generateSpeechAsset`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + + return this.http.request( + `/assets/v1/${speechOperation.path}`, + ); + } + + /** + * Publishes a message to the universe's live servers. + * Servers can consume messages via MessagingService. + * + * @param universeId The universe ID (numeric string) + * @param body The publish message request body + * @returns Promise resolving to void + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * await client.universes.publishMessage('123456789', { + * topic: "UpdateAvailable", + * message: "New update has been deployed!" + * }); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/Universe#Cloud_PublishUniverseMessage + */ + async publishMessage( + universeId: string, + body: PublishMessageBody, + ): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}:publishMessage`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + } + + /** + * Restarts all active servers for a specific universe if and only if a new version of the experience has been published. + * Used for releasing experience updates. + * + * @param universeId The universe ID (numeric string) + * @returns Promise resolving to void + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * await client.universes.restartUniverseServers('123456789'); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/Universe#Cloud_RestartUniverseServers + */ + async restartUniverseServers(universeId: string): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}:restartServers`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); + } + + /** + * Translates the provided text from one language to another. + * + * The language codes are represented as IETF BCP-47 language tags. The currently supported language codes are: + * - English (en-us) + * - French (fr-fr) + * - Vietnamese (vi-vn) + * - Thai (th-th) + * - Turkish (tr-tr) + * - Russian (ru-ru) + * - Spanish (es-es) + * - Portuguese (pt-br) + * - Korean (ko-kr) + * - Japanese (ja-jp) + * - Chinese Simplified (zh-cn) + * - Chinese Traditional (zh-tw) + * - German (de-de) + * - Polish (pl-pl) + * - Italian (it-it) + * - Indonesian (id-id). + * + * If a source language code is not provided, the API will attempt to auto-detect the source language. + * + * @param universeId The universe ID (numeric string) + * @param body The translate text request body + * @returns Promise resolving to the text translation response + * @throws {AuthError} If API key is invalid + * @throws {OpenCloudError} If the universeId is invalid or other API error occurs + * + * @example + * ```typescript + * const translation = await client.universes.translateText('123456789', { + * text: "Hello, world!", + * sourceLanguageCode: "en-us", + * targetLanguageCodes: ["fr-fr", "ja-jp"] + * }); + * console.log(translation); + * ``` + * + * @see https://create.roblox.com/docs/cloud/reference/Universe#Cloud_TranslateText + */ + async translateText( + universeId: string, + body: TranslateTextBody, + ): Promise { + return this.http.request( + `/cloud/v2/universes/${universeId}:translateText`, + { + method: "POST", + body: JSON.stringify(body), + }, + ); + } +} diff --git a/src/resources/users.ts b/src/resources/users.ts index e29bea7..97630c1 100644 --- a/src/resources/users.ts +++ b/src/resources/users.ts @@ -12,7 +12,6 @@ import type { /** * API client for Roblox Users endpoints. - * Provides methods to retrieve user information, inventory, and asset quotas. * * @see https://create.roblox.com/docs/cloud/reference/User */ diff --git a/src/types/index.ts b/src/types/index.ts index 404c2af..8154dfa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,3 +2,4 @@ export * from "./auth"; export * from "./common"; export * from "./users"; export * from "./groups"; +export * from "./universes"; diff --git a/src/types/universes.ts b/src/types/universes.ts new file mode 100644 index 0000000..173fc22 --- /dev/null +++ b/src/types/universes.ts @@ -0,0 +1,163 @@ +import { Page } from "./common"; + +export interface Universe { + path: string; + createTime: string; + updateTime: string; + displayName: string; + description: string; + owner: Owner; + visibility: Visibility; + facebookSocialLink: SocialLink; + twitterSocialLink: SocialLink; + youtubeSocialLink: SocialLink; + twitchSocialLink: SocialLink; + discordSocialLink: SocialLink; + robloxGroupSocialLink: SocialLink; + guildedSocialLink: SocialLink; + voiceChatEnabled: boolean; + ageRating: AgeRating; + privateServerPriceRobux: number; + desktopEnabled: boolean; + mobileEnabled: boolean; + tabletEnabled: boolean; + consoleEnabled: boolean; + vrEnabled: boolean; +} + +export interface UniverseBody { + facebookSocialLink?: SocialLink; + twitterSocialLink?: SocialLink; + youtubeSocialLink?: SocialLink; + twitchSocialLink?: SocialLink; + discordSocialLink?: SocialLink; + robloxGroupSocialLink?: SocialLink; + guildedSocialLink?: SocialLink; + voiceChatEnabled?: boolean; + privateServerPriceRobux?: number; + desktopEnabled?: boolean; + mobileEnabled?: boolean; + tabletEnabled?: boolean; + consoleEnabled?: boolean; + vrEnabled?: boolean; +} + +export interface SocialLink { + title: string; + uri: string; +} + +export interface SpeechAssetOperation { + path: string; + done: boolean; +} + +export interface SpeechAssetBody { + text: string; + speechStyle: { + voiceId: string; + pitch: number; + speed: number; + }; +} + +export interface SpeechAssetResponse extends SpeechAssetOperation { + operationId: string; + response: { + path: string; + revisionId: string; + revisionCreateTime: string; + assetId: string; + displayName: string; + description: string; + assetType: string; + creationContext: { + creator: { + userId?: string; + groupId?: string; + }; + }; + moderationResult: { + moderationState: "Reviewing" | "Rejected" | "Approved"; + }; + state: string; + }; +} + +export interface GameJoinRestriction { + active: boolean; + duration: string; + privateReason: string; + displayReason: string; + excludeAltAccounts: boolean; +} + +export interface GameJoinRestrictionResponse extends GameJoinRestriction { + startTime: string; + inherited: boolean; +} + +export interface UserRestriction { + path: string; + updateTime: string; + user: string; + gameJoinRestriction: GameJoinRestrictionResponse; +} + +export interface UserRestrictionLog extends GameJoinRestriction { + user: string; + place: string; + moderator: { + robloxUser: string; + }; + createTime: string; + startTime: string; + restrictionType: { + gameJoinRestriction: object; + }; +} + +export interface PublishMessageBody { + topic: string; + message: string; +} + +export interface TranslateTextBody { + text: string; + sourceLanguageCode: LanguageCode; + targetLanguageCodes: LanguageCode[]; +} + +export interface TranslateTextResponse { + sourceLanguageCode: LanguageCode; + translations: Partial>; +} + +export type UserRestrictionPage = Page; +export type UserRestrictionLogPage = Page; +export type Owner = "user" | "group"; +export type Visibility = "VISIBILITY_UNSPECIFIED" | "PUBLIC" | "PRIVATE"; +export type AgeRating = + | "AGE_RATING_UNSPECIFIED" + | "AGE_RATING_ALL" + | "AGE_RATING_9_PLUS" + | "AGE_RATING_13_PLUS" + | "AGE_RATING_17_PLUS"; +export type LanguageCode = + | "en-us" + | "fr-fr" + | "vi-vn" + | "th-th" + | "tr-tr" + | "ru-ru" + | "es-es" + | "pt-br" + | "ko-kr" + | "ja-jp" + | "zh-cn" + | "zh-tw" + | "de-de" + | "pl-pl" + | "it-it" + | "id-id" + | ""; diff --git a/src/utils/idempotency.ts b/src/utils/idempotency.ts new file mode 100644 index 0000000..efa8c97 --- /dev/null +++ b/src/utils/idempotency.ts @@ -0,0 +1,19 @@ +/** + * Generates a unique idempotency key using timestamp and random values. + * + * Format: `{timestamp}-{random1}-{random2}-{random3}-{random4}` + * Example: `1730073600000-a3f9-b2c8-d4e1-f5a7` + * + * @returns A unique idempotency key string + */ +export function generateIdempotencyKey(): string { + const timestamp = Date.now(); + + const segments: string[] = []; + for (let i = 0; i < 4; i++) { + const randomValue = Math.floor(Math.random() * 0x10000); + segments.push(randomValue.toString(16).padStart(4, "0")); + } + + return `${timestamp}-${segments.join("-")}`; +} diff --git a/test/idempotency.test.ts b/test/idempotency.test.ts new file mode 100644 index 0000000..30e8481 --- /dev/null +++ b/test/idempotency.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { generateIdempotencyKey } from "../src/utils/idempotency"; + +describe("generateIdempotencyKey", () => { + it("should generate a unique key", () => { + const key1 = generateIdempotencyKey(); + const key2 = generateIdempotencyKey(); + + expect(key1).not.toBe(key2); + }); + + it("should return a string in the correct format", () => { + const key = generateIdempotencyKey(); + + // Format: {timestamp}-{hex1}-{hex2}-{hex3}-{hex4} + const parts = key.split("-"); + + expect(parts).toHaveLength(5); + + // First part should be a timestamp (numeric) + expect(Number(parts[0])).toBeGreaterThan(0); + expect(Number(parts[0])).toBeLessThanOrEqual(Date.now()); + + // Next 4 parts should be 4-character hex strings + for (let i = 1; i < 5; i++) { + expect(parts[i]).toMatch(/^[0-9a-f]{4}$/); + } + }); + + it("should generate multiple unique keys", () => { + const keys = new Set(); + const count = 1000; + + for (let i = 0; i < count; i++) { + keys.add(generateIdempotencyKey()); + } + + // All keys should be unique + expect(keys.size).toBe(count); + }); + + it("should include current timestamp", () => { + const beforeTimestamp = Date.now(); + const key = generateIdempotencyKey(); + const afterTimestamp = Date.now(); + + const keyTimestamp = Number(key.split("-")[0]); + + expect(keyTimestamp).toBeGreaterThanOrEqual(beforeTimestamp); + expect(keyTimestamp).toBeLessThanOrEqual(afterTimestamp); + }); +}); diff --git a/test/universes.test.ts b/test/universes.test.ts new file mode 100644 index 0000000..9ded4c1 --- /dev/null +++ b/test/universes.test.ts @@ -0,0 +1,533 @@ +import { describe, it, expect } from "vitest"; +import { OpenCloud } from "../src"; +import { makeFetchMock } from "./_utils"; +import type { + Universe, + UserRestrictionPage, + UserRestriction, + UserRestrictionLogPage, + GameJoinRestriction, + SpeechAssetBody, + SpeechAssetOperation, + SpeechAssetResponse, + PublishMessageBody, + TranslateTextBody, + TranslateTextResponse, +} from "../src/types"; + +const baseUrl = "https://apis.roblox.com"; + +describe("Universes", () => { + it("GET /universes/{id}", async () => { + const mockUniverse: Universe = { + path: "universes/123456789", + createTime: "2020-01-15T10:30:00.000Z", + updateTime: "2024-10-15T12:00:00.000Z", + displayName: "Test Universe", + description: "This is a test universe for unit testing", + owner: "user", + visibility: "PUBLIC", + facebookSocialLink: { + title: "Facebook", + uri: "https://facebook.com/test", + }, + twitterSocialLink: { title: "Twitter", uri: "https://twitter.com/test" }, + youtubeSocialLink: { title: "YouTube", uri: "https://youtube.com/test" }, + twitchSocialLink: { title: "Twitch", uri: "https://twitch.tv/test" }, + discordSocialLink: { title: "Discord", uri: "https://discord.gg/test" }, + robloxGroupSocialLink: { + title: "Group", + uri: "https://roblox.com/groups/123", + }, + guildedSocialLink: { title: "Guilded", uri: "https://guilded.gg/test" }, + voiceChatEnabled: true, + ageRating: "AGE_RATING_13_PLUS", + privateServerPriceRobux: 100, + desktopEnabled: true, + mobileEnabled: true, + tabletEnabled: true, + consoleEnabled: false, + vrEnabled: false, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUniverse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.get("123456789"); + + expect(result.displayName).toBe("Test Universe"); + expect(result.voiceChatEnabled).toBe(true); + expect(result.desktopEnabled).toBe(true); + expect(result.privateServerPriceRobux).toBe(100); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789`, + ); + }); + + it("PATCH /universes/{id}", async () => { + const mockUniverse: Universe = { + path: "universes/123456789", + createTime: "2020-01-15T10:30:00.000Z", + updateTime: "2024-10-15T12:00:00.000Z", + displayName: "Test Universe", + description: "Updated description", + owner: "user", + visibility: "PUBLIC", + facebookSocialLink: { title: "", uri: "" }, + twitterSocialLink: { title: "", uri: "" }, + youtubeSocialLink: { title: "", uri: "" }, + twitchSocialLink: { title: "", uri: "" }, + discordSocialLink: { title: "", uri: "" }, + robloxGroupSocialLink: { title: "", uri: "" }, + guildedSocialLink: { title: "", uri: "" }, + voiceChatEnabled: false, + ageRating: "AGE_RATING_13_PLUS", + privateServerPriceRobux: 200, + desktopEnabled: false, + mobileEnabled: true, + tabletEnabled: true, + consoleEnabled: true, + vrEnabled: true, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUniverse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.update("123456789", { + voiceChatEnabled: false, + desktopEnabled: false, + privateServerPriceRobux: 200, + consoleEnabled: true, + vrEnabled: true, + }); + + expect(result.voiceChatEnabled).toBe(false); + expect(result.desktopEnabled).toBe(false); + expect(result.privateServerPriceRobux).toBe(200); + expect(result.consoleEnabled).toBe(true); + expect(result.vrEnabled).toBe(true); + + const url = calls[0]?.url.toString(); + expect(url).toContain(`${baseUrl}/cloud/v2/universes/123456789`); + expect(url).toContain("updateMask="); + expect(calls[0]?.init?.method).toBe("PATCH"); + }); + + it("GET /universes/{id}/user-restrictions without options", async () => { + const mockUserRestrictions: UserRestrictionPage = { + userRestrictions: [ + { + path: "universes/123456789/user-restrictions/111111111", + updateTime: "2024-10-10T08:00:00.000Z", + user: "users/111111111", + gameJoinRestriction: { + active: true, + duration: "3600s", + privateReason: "Cheating detected", + displayReason: "Violation of terms", + excludeAltAccounts: true, + startTime: "2024-10-10T08:00:00.000Z", + inherited: false, + }, + }, + { + path: "universes/123456789/user-restrictions/222222222", + updateTime: "2024-10-11T09:15:00.000Z", + user: "users/222222222", + gameJoinRestriction: { + active: false, + duration: "7200s", + privateReason: "Harassment", + displayReason: "Community guidelines violation", + excludeAltAccounts: false, + startTime: "2024-10-11T09:15:00.000Z", + inherited: true, + }, + }, + ], + nextPageToken: "restriction-token-123", + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUserRestrictions }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.listUserRestrictions("123456789"); + + expect(result.userRestrictions).toHaveLength(2); + expect(result.userRestrictions[0]?.user).toBe("users/111111111"); + expect(result.userRestrictions[0]?.gameJoinRestriction.active).toBe(true); + expect(result.nextPageToken).toBe("restriction-token-123"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions`, + ); + }); + + it("GET /universes/{id}/user-restrictions with pagination", async () => { + const mockUserRestrictions: UserRestrictionPage = { + userRestrictions: [ + { + path: "universes/123456789/user-restrictions/333333333", + updateTime: "2024-10-12T10:30:00.000Z", + user: "users/333333333", + gameJoinRestriction: { + active: true, + duration: "1800s", + privateReason: "Spam", + displayReason: "Spamming", + excludeAltAccounts: true, + startTime: "2024-10-12T10:30:00.000Z", + inherited: false, + }, + }, + ], + nextPageToken: "restriction-token-456", + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUserRestrictions }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.listUserRestrictions("123456789", { + maxPageSize: 50, + pageToken: "previous-token", + }); + + expect(result.userRestrictions).toHaveLength(1); + const url = calls[0]?.url.toString(); + expect(url).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions?maxPageSize=50&pageToken=previous-token`, + ); + }); + + it("GET /universes/{id}/user-restrictions/{userRestrictionId}", async () => { + const mockUserRestriction: UserRestriction = { + path: "universes/123456789/user-restrictions/111111111", + updateTime: "2024-10-10T08:00:00.000Z", + user: "users/111111111", + gameJoinRestriction: { + active: true, + duration: "3600s", + privateReason: "Cheating detected", + displayReason: "Violation of terms", + excludeAltAccounts: true, + startTime: "2024-10-10T08:00:00.000Z", + inherited: false, + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUserRestriction }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.getUserRestriction( + "123456789", + "111111111", + ); + + expect(result.user).toBe("users/111111111"); + expect(result.gameJoinRestriction.active).toBe(true); + expect(result.gameJoinRestriction.duration).toBe("3600s"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions/111111111`, + ); + }); + + it("PATCH /universes/{id}/user-restrictions/{userRestrictionId}", async () => { + const mockUserRestriction: UserRestriction = { + path: "universes/123456789/user-restrictions/111111111", + updateTime: "2024-10-22T15:00:00.000Z", + user: "users/111111111", + gameJoinRestriction: { + active: true, + duration: "7200s", + privateReason: "Updated reason", + displayReason: "Updated display reason", + excludeAltAccounts: false, + startTime: "2024-10-22T15:00:00.000Z", + inherited: false, + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockUserRestriction }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const body: GameJoinRestriction = { + active: true, + duration: "7200s", + privateReason: "Updated reason", + displayReason: "Updated display reason", + excludeAltAccounts: false, + }; + + const result = await openCloud.universes.updateUserRestriction( + "123456789", + "111111111", + body, + ); + + expect(result.user).toBe("users/111111111"); + expect(result.gameJoinRestriction.active).toBe(true); + expect(result.gameJoinRestriction.duration).toBe("7200s"); + + const url = calls[0]?.url.toString(); + expect(url).toContain( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions/111111111`, + ); + expect(url).toContain("updateMask=game_join_restriction"); + expect(url).toContain("idempotencyKey.key="); + expect(url).toContain("idempotencyKey.firstSent="); + expect(calls[0]?.init?.method).toBe("PATCH"); + }); + + it("GET /universes/{id}/user-restrictions:listLogs without options", async () => { + const mockLogs: UserRestrictionLogPage = { + logs: [ + { + user: "users/111111111", + place: "places/456789", + moderator: { + robloxUser: "users/999999999", + }, + createTime: "2024-10-10T08:00:00.000Z", + startTime: "2024-10-10T08:00:00.000Z", + active: true, + duration: "3600s", + privateReason: "Cheating", + displayReason: "Terms violation", + excludeAltAccounts: true, + restrictionType: { + gameJoinRestriction: {}, + }, + }, + ], + nextPageToken: "log-token-123", + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockLogs }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = + await openCloud.universes.listUserRestrictionLogs("123456789"); + + expect(result.logs).toHaveLength(1); + expect(result.logs[0]?.user).toBe("users/111111111"); + expect(result.nextPageToken).toBe("log-token-123"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions:listLogs`, + ); + }); + + it("GET /universes/{id}/user-restrictions:listLogs with all options", async () => { + const mockLogs: UserRestrictionLogPage = { + logs: [], + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockLogs }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const result = await openCloud.universes.listUserRestrictionLogs( + "123456789", + { + maxPageSize: 25, + pageToken: "token-abc", + filter: "user == 'users/123' && place == 'places/456'", + }, + ); + + expect(result.logs).toHaveLength(0); + const url = calls[0]?.url.toString(); + expect(url).toBe( + `${baseUrl}/cloud/v2/universes/123456789/user-restrictions:listLogs?maxPageSize=25&pageToken=token-abc&filter=user+%3D%3D+%27users%2F123%27+%26%26+place+%3D%3D+%27places%2F456%27`, + ); + }); + + it("POST /universes/{id}:generateSpeechAsset", async () => { + const mockOperation: SpeechAssetOperation = { + path: "operations/speech-123", + done: true, + }; + + const mockResponse: SpeechAssetResponse = { + path: "operations/speech-123", + done: true, + operationId: "speech-123", + response: { + path: "assets/12345", + revisionId: "1", + revisionCreateTime: "2024-10-22T15:00:00.000Z", + assetId: "12345", + displayName: "Generated Speech", + description: "AI generated speech", + assetType: "Audio", + creationContext: { + creator: { + userId: "123456789", + }, + }, + moderationResult: { + moderationState: "Approved", + }, + state: "Active", + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockOperation }, + { status: 200, body: mockResponse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const body: SpeechAssetBody = { + text: "Hello, world!", + speechStyle: { + voiceId: "en_us_001", + pitch: 1.0, + speed: 1.0, + }, + }; + + const result = await openCloud.universes.generateSpeechAsset( + "123456789", + body, + ); + + expect(result.operationId).toBe("speech-123"); + expect(result.response.assetId).toBe("12345"); + expect(result.response.moderationResult.moderationState).toBe("Approved"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789:generateSpeechAsset`, + ); + expect(calls[0]?.init?.method).toBe("POST"); + expect(calls[1]?.url.toString()).toBe( + `${baseUrl}/assets/v1/operations/speech-123`, + ); + }); + + it("POST /universes/{id}:publishMessage", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: undefined }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const body: PublishMessageBody = { + topic: "game-updates", + message: "Server maintenance at 3 PM", + }; + + await openCloud.universes.publishMessage("123456789", body); + + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789:publishMessage`, + ); + expect(calls[0]?.init?.method).toBe("POST"); + expect(calls[0]?.init?.body).toContain("game-updates"); + expect(calls[0]?.init?.body).toContain("Server maintenance at 3 PM"); + }); + + it("POST /universes/{id}:restartServers", async () => { + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: undefined }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + await openCloud.universes.restartUniverseServers("123456789"); + + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789:restartServers`, + ); + expect(calls[0]?.init?.method).toBe("POST"); + expect(calls[0]?.init?.body).toBe("{}"); + }); + + it("POST /universes/{id}:translateText", async () => { + const mockResponse: TranslateTextResponse = { + sourceLanguageCode: "en-us", + translations: { + "es-es": "Hola mundo", + }, + }; + + const { fetchMock, calls } = makeFetchMock([ + { status: 200, body: mockResponse }, + ]); + const openCloud = new OpenCloud({ + apiKey: "test-api-key", + baseUrl, + fetchImpl: fetchMock, + }); + + const body: TranslateTextBody = { + text: "Hello world", + sourceLanguageCode: "en-us", + targetLanguageCodes: ["es-es"], + }; + + const result = await openCloud.universes.translateText("123456789", body); + + expect(result.sourceLanguageCode).toBe("en-us"); + expect(result.translations["es-es"]).toBe("Hola mundo"); + expect(calls[0]?.url.toString()).toBe( + `${baseUrl}/cloud/v2/universes/123456789:translateText`, + ); + expect(calls[0]?.init?.method).toBe("POST"); + expect(calls[0]?.init?.body).toContain("Hello world"); + }); +});