diff --git a/mantle/rbx_api/src/lib.rs b/mantle/rbx_api/src/lib.rs index 5e6f2f5..e4a5937 100644 --- a/mantle/rbx_api/src/lib.rs +++ b/mantle/rbx_api/src/lib.rs @@ -9,6 +9,7 @@ pub mod game_passes; pub mod groups; mod helpers; pub mod models; +pub mod notifications; pub mod places; pub mod social_links; pub mod spatial_voice; diff --git a/mantle/rbx_api/src/notifications/mod.rs b/mantle/rbx_api/src/notifications/mod.rs new file mode 100644 index 0000000..a308430 --- /dev/null +++ b/mantle/rbx_api/src/notifications/mod.rs @@ -0,0 +1,110 @@ +pub mod models; + +use serde_json::json; + +use crate::{ + errors::RobloxApiResult, + helpers::{handle, handle_as_json}, + models::AssetId, + RobloxApi, +}; + +use self::models::{ + CreateNotificationResponse, ListNotificationResponse, ListNotificationsResponse, +}; + +impl RobloxApi { + pub async fn create_notification( + &self, + experience_id: AssetId, + name: String, + content: String, + ) -> RobloxApiResult { + let req = self + .client + .post("https://apis.roblox.com/notifications/v1/developer-configuration/create-notification") + .json(&json!({ + "universeId": experience_id, + "name": name, + "content": content, + })); + + handle_as_json(req).await + } + + pub async fn update_notification( + &self, + notification_id: String, + name: String, + content: String, + ) -> RobloxApiResult<()> { + let req = self + .client + .post("https://apis.roblox.com/notifications/v1/developer-configuration/update-notification") + .json(&json!({ + "id": notification_id, + "name": name, + "content": content, + })); + + handle(req).await?; + + Ok(()) + } + + pub async fn archive_notification(&self, notification_id: String) -> RobloxApiResult<()> { + let req = self + .client + .post("https://apis.roblox.com/notifications/v1/developer-configuration/archive-notification") + .json(&json!({ + "id": notification_id, + })); + + handle(req).await?; + + Ok(()) + } + + pub async fn list_notifications( + &self, + experience_id: AssetId, + count: u8, + page_cursor: Option, + ) -> RobloxApiResult { + let mut req = self + .client + .get("https://apis.roblox.com/notifications/v1/developer-configuration/experience-notifications-list") + .query(&[ + ("universeId", &experience_id.to_string()), + ("count", &count.to_string()), + ]); + if let Some(page_cursor) = page_cursor { + req = req.query(&[("cursor", &page_cursor)]); + } + + handle_as_json(req).await + } + + pub async fn get_all_notifications( + &self, + experience_id: AssetId, + ) -> RobloxApiResult> { + let mut all_notifications = Vec::new(); + + let mut page_cursor: Option = None; + loop { + let res = self + .list_notifications(experience_id, 100, page_cursor) + .await?; + all_notifications.extend(res.notification_string_configs); + + if res.next_page_cursor.is_none() { + break; + } + + page_cursor = res.next_page_cursor; + } + + Ok(all_notifications) + } +} diff --git a/mantle/rbx_api/src/notifications/models.rs b/mantle/rbx_api/src/notifications/models.rs new file mode 100644 index 0000000..4d68b7f --- /dev/null +++ b/mantle/rbx_api/src/notifications/models.rs @@ -0,0 +1,23 @@ +use serde::Deserialize; + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CreateNotificationResponse { + pub id: String, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ListNotificationsResponse { + pub notification_string_configs: Vec, + pub previous_page_cursor: Option, + pub next_page_cursor: Option, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ListNotificationResponse { + pub id: String, + pub name: String, + pub content: String, +} diff --git a/mantle/rbx_mantle/src/config.rs b/mantle/rbx_mantle/src/config.rs index 8702338..525d858 100644 --- a/mantle/rbx_mantle/src/config.rs +++ b/mantle/rbx_mantle/src/config.rs @@ -523,6 +523,40 @@ pub struct ExperienceTargetConfig { /// Spatial voice configuration. pub spatial_voice: Option, + + /// Notification strings for your experience. + /// + /// By default, the name of each notification (which is only visible to you in the creator portal) is set + /// to the label of the notification config. You can override this by setting the `name` property. + /// + /// ```yml title="Example" + /// target: + /// experience: + /// notifications: + /// customInvitePrompt: + /// content: '{displayName} is inviting you to join {experienceName}!' + /// ``` + /// + /// ```yml title="Example with custom name" + /// target: + /// experience: + /// notifications: + /// customInvitePrompt: + /// name: Custom Invite Prompt + /// content: '{displayName} is inviting you to join {experienceName}!' + /// ``` + pub notifications: Option>, +} + +#[derive(JsonSchema, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NotificationTargetConfig { + /// The display name of the notification string on the Roblox website. + pub name: Option, + + /// The content of the notification string. + /// Must include {experienceName} placeholder and may include {displayName} placeholder once. + pub content: String, } #[derive(JsonSchema, Serialize, Deserialize, Clone)] diff --git a/mantle/rbx_mantle/src/roblox_resource_manager.rs b/mantle/rbx_mantle/src/roblox_resource_manager.rs index 93ca4a1..83d51bb 100644 --- a/mantle/rbx_mantle/src/roblox_resource_manager.rs +++ b/mantle/rbx_mantle/src/roblox_resource_manager.rs @@ -18,6 +18,7 @@ use rbx_api::{ experiences::models::{CreateExperienceResponse, ExperienceConfigurationModel}, game_passes::models::{CreateGamePassResponse, GetGamePassResponse}, models::{AssetId, AssetTypeId, CreatorType, UploadImageResponse}, + notifications::models::CreateNotificationResponse, places::models::{GetPlaceResponse, PlaceConfigurationModel}, social_links::models::{CreateSocialLinkResponse, SocialLinkType}, spatial_voice::models::UpdateSpatialVoiceSettingsRequest, @@ -111,6 +112,13 @@ pub struct SpatialVoiceInputs { pub enabled: bool, } +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NotificationInputs { + pub name: String, + pub content: String, +} + #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] #[allow(clippy::large_enum_variant)] @@ -134,6 +142,7 @@ pub enum RobloxInputs { AudioAsset(FileWithGroupIdInputs), AssetAlias(AssetAliasInputs), SpatialVoice(SpatialVoiceInputs), + Notification(NotificationInputs), } #[derive(Serialize, Deserialize, Clone)] @@ -149,6 +158,12 @@ pub struct AssetOutputs { pub asset_id: AssetId, } +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NotificationOutputs { + pub id: String, +} + #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct PlaceFileOutputs { @@ -211,6 +226,7 @@ pub enum RobloxOutputs { AudioAsset(AssetOutputs), AssetAlias(AssetAliasOutputs), SpatialVoice, + Notification(NotificationOutputs), } #[derive(Serialize, Deserialize, Clone)] @@ -704,6 +720,16 @@ impl ResourceManager for RobloxResourceManager { Ok(RobloxOutputs::SpatialVoice) } + RobloxInputs::Notification(inputs) => { + let experience = single_output!(dependency_outputs, RobloxOutputs::Experience); + + let CreateNotificationResponse { id } = self + .roblox_api + .create_notification(experience.asset_id, inputs.name, inputs.content) + .await?; + + Ok(RobloxOutputs::Notification(NotificationOutputs { id })) + } } } @@ -889,6 +915,14 @@ impl ResourceManager for RobloxResourceManager { Ok(RobloxOutputs::SpatialVoice) } + (RobloxInputs::Notification(inputs), RobloxOutputs::Notification(outputs)) => { + let asset_id = outputs.id.clone(); + self.roblox_api + .update_notification(asset_id, inputs.name, inputs.content) + .await?; + + Ok(RobloxOutputs::Notification(outputs)) + } _ => unreachable!(), } } @@ -1029,6 +1063,9 @@ impl ResourceManager for RobloxResourceManager { ) .await?; } + RobloxOutputs::Notification(outputs) => { + self.roblox_api.archive_notification(outputs.id).await?; + } } Ok(()) } diff --git a/mantle/rbx_mantle/src/state/mod.rs b/mantle/rbx_mantle/src/state/mod.rs index c91b58d..4f09777 100644 --- a/mantle/rbx_mantle/src/state/mod.rs +++ b/mantle/rbx_mantle/src/state/mod.rs @@ -525,6 +525,24 @@ fn get_desired_experience_graph( )); } + if let Some(notifications) = &target_config.notifications { + for (label, notification) in notifications { + let name = match ¬ification.name { + Some(name) => name.clone(), + None => label.clone(), + }; + + resources.push(RobloxResource::new( + &format!("notification_{}", label), + RobloxInputs::Notification(NotificationInputs { + name, + content: notification.content.to_string(), + }), + &[&experience], + )); + } + } + Ok(ResourceGraph::new(&resources)) } @@ -820,6 +838,22 @@ pub async fn import_graph( )); } + logger::log("Importing notifications"); + let notifications = roblox_api.get_all_notifications(target_id).await?; + for notification in notifications { + resources.push(RobloxResource::existing( + &format!("notification_{}", notification.name), + RobloxInputs::Notification(NotificationInputs { + name: notification.name, + content: notification.content, + }), + RobloxOutputs::Notification(NotificationOutputs { + id: notification.id, + }), + &[&experience], + )); + } + Ok(ResourceGraph::new(&resources)) }