//! Configurable gift-to-content menu component. //! //! Menu entries live in the component's versioned JSON settings because they //! are a small, ordered presentation configuration rather than an event log. //! The projection is authoritative for trigger matching and emits only matched //! item IDs; the browser never has to reinterpret Bilibili guard names. use std::collections::HashSet; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; use crate::{ components::{ ComponentDefinition, ComponentError, ComponentInstance, EventProjection, EventSubscription, }, domain::{ComponentMessage, LiveEvent, LiveEventKind, LiveEventPayload}, overlay::normalize_image_url, typography::{FontFamilyId, default_font_brightness, sanitize_font_brightness}, }; pub const GIFT_MENU_KIND: &str = "gift_menu"; pub const GIFT_MENU_NAME: &str = "礼物菜单"; const MAX_MENU_ITEMS: usize = 100; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum GiftMenuThemeId { #[default] JadeBanquet, MoonlitWater, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum GuardLevel { Captain, Admiral, Governor, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde( tag = "kind", rename_all = "lowercase", rename_all_fields = "camelCase" )] pub enum GiftMenuTrigger { Gift { gift_id: i64, gift_name: String, image_url: Option, unit_price: i64, }, Guard { level: GuardLevel, }, Battery { amount: i64, }, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GiftMenuItem { pub id: Uuid, pub trigger: GiftMenuTrigger, pub description: String, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct GiftMenuSettings { #[serde(default)] pub theme_id: GiftMenuThemeId, #[serde(default)] pub font_family: FontFamilyId, #[serde(default = "default_font_brightness")] pub font_brightness: u16, #[serde(default)] pub items: Vec, pub visible_rows: u8, pub row_height: u16, pub scroll_speed_pixels_per_second: u16, pub highlight_duration_ms: u16, pub font_scale: u16, pub motion_intensity: u8, pub low_performance_mode: bool, } impl Default for GiftMenuSettings { fn default() -> Self { Self { theme_id: GiftMenuThemeId::default(), font_family: FontFamilyId::default(), font_brightness: default_font_brightness(), items: Vec::new(), visible_rows: 4, row_height: 88, scroll_speed_pixels_per_second: 28, highlight_duration_ms: 3_800, font_scale: 100, motion_intensity: 78, low_performance_mode: false, } } } impl GiftMenuSettings { fn sanitize(mut self) -> Result { if self.items.len() > MAX_MENU_ITEMS { return Err(format!("gift menu accepts at most {MAX_MENU_ITEMS} items")); } let mut ids = HashSet::with_capacity(self.items.len()); let mut triggers = HashSet::with_capacity(self.items.len()); for item in &mut self.items { if item.id.is_nil() || !ids.insert(item.id) { return Err("gift menu item IDs must be unique and non-zero".into()); } item.description = normalize_text(&item.description, 200)?; let trigger_key = sanitize_trigger(&mut item.trigger)?; if !triggers.insert(trigger_key) { return Err("gift menu triggers must be unique".into()); } } self.visible_rows = self.visible_rows.clamp(1, 20); self.font_brightness = sanitize_font_brightness(self.font_brightness); self.row_height = self.row_height.clamp(44, 240); self.scroll_speed_pixels_per_second = self.scroll_speed_pixels_per_second.min(240); self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000); self.font_scale = self.font_scale.clamp(50, 220); self.motion_intensity = self.motion_intensity.min(100); Ok(self) } } fn normalize_text(value: &str, max_chars: usize) -> Result { let normalized = value.split_whitespace().collect::>().join(" "); let count = normalized.chars().count(); if count == 0 || count > max_chars { return Err(format!("text must contain 1-{max_chars} characters")); } Ok(normalized) } fn sanitize_trigger(trigger: &mut GiftMenuTrigger) -> Result { match trigger { GiftMenuTrigger::Gift { gift_id, gift_name, image_url, unit_price, } => { if *gift_id <= 0 { return Err("gift ID must be positive".into()); } *gift_name = normalize_text(gift_name, 80)?; *unit_price = (*unit_price).clamp(0, 1_000_000_000); *image_url = image_url.as_deref().and_then(normalize_image_url); Ok(format!("gift:{gift_id}")) } GiftMenuTrigger::Guard { level } => Ok(format!("guard:{level:?}")), GiftMenuTrigger::Battery { amount } => { if *amount <= 0 || *amount > 1_000_000_000 { return Err("battery amount must be between 1 and 1000000000".into()); } Ok(format!("battery:{amount}")) } } } pub struct GiftMenuDefinition; impl GiftMenuDefinition { fn parse(&self, settings: Value) -> Result { serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings { kind: GIFT_MENU_KIND.to_owned(), detail: error.to_string(), }) } } impl ComponentDefinition for GiftMenuDefinition { fn kind(&self) -> &'static str { GIFT_MENU_KIND } fn settings_version(&self) -> u32 { 1 } fn default_settings(&self) -> Value { serde_json::to_value(GiftMenuSettings::default()) .expect("GiftMenuSettings is always JSON serializable") } fn validate_settings(&self, settings: Value) -> Result { let settings = self.parse(settings)? .sanitize() .map_err(|detail| ComponentError::InvalidSettings { kind: GIFT_MENU_KIND.to_owned(), detail, })?; serde_json::to_value(settings).map_err(|error| ComponentError::InvalidSettings { kind: GIFT_MENU_KIND.to_owned(), detail: error.to_string(), }) } fn subscriptions(&self, settings: &Value) -> Result { self.parse(settings.clone())?; Ok(EventSubscription::new([ LiveEventKind::Gift, LiveEventKind::GuardPurchase, ])) } } #[derive(Default)] pub struct GiftMenuProjection; impl EventProjection for GiftMenuProjection { fn project( &self, component: &ComponentInstance, event: &LiveEvent, ) -> Result, ComponentError> { let settings: GiftMenuSettings = serde_json::from_value(component.settings.clone()) .map_err(|error| ComponentError::Projection(error.to_string()))?; let (matched, viewer) = match &event.payload { LiveEventPayload::Gift(gift) => { // Concrete gift identities take precedence over price tiers. // Only fall back to battery matching when no configured gift // ID (or name, when the event has no ID) matched. let concrete = settings .items .iter() .filter(|item| match &item.trigger { GiftMenuTrigger::Gift { gift_id, gift_name, .. } => gift .gift .id .map_or(gift.gift.name == *gift_name, |id| id == *gift_id), _ => false, }) .map(|item| item.id) .collect::>(); let matched = if concrete.is_empty() { settings .items .iter() .filter(|item| { matches!( &item.trigger, GiftMenuTrigger::Battery { amount } if gift.gift.battery_value == *amount ) }) .map(|item| item.id) .collect::>() } else { concrete }; (matched, &gift.viewer) } LiveEventPayload::GuardPurchase(guard) => { let Some(level) = guard_level(&guard.guard_name) else { return Ok(None); }; ( settings .items .iter() .filter(|item| { matches!(&item.trigger, GiftMenuTrigger::Guard { level: candidate } if *candidate == level) }) .map(|item| item.id) .collect::>(), &guard.viewer, ) } _ => return Ok(None), }; if matched.is_empty() { return Ok(None); } let mut message = ComponentMessage::from_live_event(component.id, event) .map_err(|error| ComponentError::Projection(error.to_string()))?; message.event_type = "gift-menu.triggered".into(); message.payload = json!({ "itemIds": matched, "viewer": viewer, "sourceEventId": event.id, }); Ok(Some(message)) } } fn guard_level(name: &str) -> Option { let name = name.trim().to_lowercase(); if name.contains("总督") || name.contains("governor") { Some(GuardLevel::Governor) } else if name.contains("提督") || name.contains("admiral") { Some(GuardLevel::Admiral) } else if name.contains("舰长") || name.contains("captain") { Some(GuardLevel::Captain) } else { None } } #[cfg(test)] mod tests { use super::*; use crate::domain::{ GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEventPayload, PlatformViewer, }; fn viewer() -> PlatformViewer { PlatformViewer { uid: "42".into(), name: "观众".into(), avatar_url: None, } } fn component(items: Vec) -> ComponentInstance { let settings = GiftMenuSettings { items, ..GiftMenuSettings::default() }; ComponentInstance::new( Uuid::new_v4(), Uuid::new_v4(), GIFT_MENU_KIND, GIFT_MENU_NAME, 1, serde_json::to_value(settings).unwrap(), ) } #[test] fn settings_reject_duplicate_triggers_and_bound_renderer_values() { let trigger = GiftMenuTrigger::Battery { amount: 150 }; let items = vec![ GiftMenuItem { id: Uuid::new_v4(), trigger: trigger.clone(), description: "点歌".into(), }, GiftMenuItem { id: Uuid::new_v4(), trigger, description: "学歌".into(), }, ]; let definition = GiftMenuDefinition; let settings = GiftMenuSettings { items, ..GiftMenuSettings::default() }; assert!( definition .validate_settings(serde_json::to_value(settings).unwrap()) .is_err() ); let settings = GiftMenuSettings { visible_rows: 255, row_height: 1, scroll_speed_pixels_per_second: u16::MAX, font_scale: 1, font_brightness: 1, ..GiftMenuSettings::default() }; let sanitized = definition .validate_settings(serde_json::to_value(settings).unwrap()) .unwrap(); assert_eq!(sanitized["visibleRows"], 20); assert_eq!(sanitized["rowHeight"], 44); assert_eq!(sanitized["scrollSpeedPixelsPerSecond"], 240); assert_eq!(sanitized["fontScale"], 50); assert_eq!(sanitized["fontBrightness"], 70); } #[test] fn gift_projection_prefers_a_specific_gift_over_its_battery_tier() { let specific = Uuid::new_v4(); let battery = Uuid::new_v4(); let component = component(vec![ GiftMenuItem { id: specific, trigger: GiftMenuTrigger::Gift { gift_id: 31039, gift_name: "心动盲盒".into(), image_url: None, unit_price: 15_000, }, description: "点歌".into(), }, GiftMenuItem { id: battery, trigger: GiftMenuTrigger::Battery { amount: 150 }, description: "任选挑战".into(), }, ]); let event = LiveEvent::new( component.owner_id, component.account_source_id, "bilibili", "123", LiveEventPayload::Gift(GiftEvent { viewer: viewer(), gift: GiftDetails { id: Some(31039), name: "心动盲盒".into(), coin_type: "gold".into(), battery_value: crate::overlay::gift_price_to_batteries(15_000), unit_price: 15_000, total_price: 30_000, price_cny: 30.0, image_url: None, animation_url: None, effect_type: None, stay_time: None, }, quantity: 2, source_event_id: "event".into(), }), ); let message = GiftMenuProjection .project(&component, &event) .unwrap() .unwrap(); assert_eq!(message.event_type, "gift-menu.triggered"); assert_eq!(message.payload["itemIds"], json!([specific])); } #[test] fn gift_projection_falls_back_to_the_unit_battery_value() { let battery = Uuid::new_v4(); let component = component(vec![ GiftMenuItem { id: Uuid::new_v4(), trigger: GiftMenuTrigger::Gift { gift_id: 999, gift_name: "其他礼物".into(), image_url: None, unit_price: 15_000, }, description: "其他内容".into(), }, GiftMenuItem { id: battery, trigger: GiftMenuTrigger::Battery { amount: 150 }, description: "任选挑战".into(), }, ]); let event = LiveEvent::new( component.owner_id, component.account_source_id, "bilibili", "123", LiveEventPayload::Gift(GiftEvent { viewer: viewer(), gift: GiftDetails { id: Some(31039), name: "心动盲盒".into(), coin_type: "gold".into(), battery_value: 150, unit_price: 15_000, total_price: 15_000, price_cny: 15.0, image_url: None, animation_url: None, effect_type: None, stay_time: None, }, quantity: 1, source_event_id: "event".into(), }), ); let message = GiftMenuProjection .project(&component, &event) .unwrap() .unwrap(); assert_eq!(message.payload["itemIds"], json!([battery])); } #[test] fn guard_projection_normalizes_all_membership_levels() { let id = Uuid::new_v4(); let component = component(vec![GiftMenuItem { id, trigger: GiftMenuTrigger::Guard { level: GuardLevel::Admiral, }, description: "专属节目".into(), }]); let event = LiveEvent::new( component.owner_id, component.account_source_id, "bilibili", "123", LiveEventPayload::GuardPurchase(GuardPurchaseEvent { viewer: viewer(), guard_name: "提督".into(), quantity: 1, price: 199_800, }), ); assert!( GiftMenuProjection .project(&component, &event) .unwrap() .is_some() ); } #[test] fn moonlit_water_theme_is_accepted_and_serialized() { let definition = GiftMenuDefinition; let mut settings = definition.default_settings(); settings["themeId"] = Value::from("moonlit-water"); let sanitized = definition.validate_settings(settings).unwrap(); assert_eq!(sanitized["themeId"], "moonlit-water"); } }