diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 1f975e7d9..429cdb04d 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -10563,6 +10563,7 @@ "gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"}, "voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"}, "experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"}, + "message_hover_tracking": {"$ref": "#/components/schemas/MessageHoverTrackingConfigResponse"}, "registration": { "type": "object", "properties": { @@ -10966,6 +10967,7 @@ "gateway_rollout", "voice_noise_suppression", "experiment_delivery", + "message_hover_tracking", "registration", "self_hosted", "app_public", @@ -11103,6 +11105,10 @@ "nullable": true, "allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}] }, + "message_hover_tracking": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/MessageHoverTrackingConfigUpdateRequest"}] + }, "registration": { "nullable": true, "type": "object", @@ -15159,6 +15165,24 @@ "enum": ["open", "approval", "closed"], "type": "string" }, + "MessageHoverTrackingConfigUpdateRequest": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, + "rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64}, + "included_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + } + } + }, "ExperimentDeliveryConfigUpdateRequest": { "type": "object", "properties": { @@ -15223,6 +15247,36 @@ "type": "string", "enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"] }, + "MessageHoverTrackingConfigResponse": { + "type": "object", + "properties": { + "enabled": {"default": false, "type": "boolean"}, + "config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, + "rollout_salt": {"default": "message-hover-tracking-v1", "type": "string", "minLength": 1, "maxLength": 64}, + "included_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + } + }, + "required": [ + "enabled", + "config_version", + "rollout_basis_points", + "rollout_salt", + "included_user_ids", + "excluded_user_ids" + ], + "additionalProperties": false + }, "ExperimentDeliveryConfigResponse": { "type": "object", "properties": { diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index d60227fba..cc0958f44 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -24,6 +24,8 @@ pub struct InstanceConfigResponse { pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse, #[serde(default)] pub experiment_delivery: ExperimentDeliveryConfigResponse, + #[serde(default)] + pub message_hover_tracking: MessageHoverTrackingConfigResponse, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -537,6 +539,44 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest { pub suppression_strength: Option, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct MessageHoverTrackingConfigResponse { + pub enabled: bool, + pub config_version: u64, + pub rollout_basis_points: u32, + pub rollout_salt: String, + pub included_user_ids: Vec, + pub excluded_user_ids: Vec, +} + +impl Default for MessageHoverTrackingConfigResponse { + fn default() -> Self { + Self { + enabled: false, + config_version: 0, + rollout_basis_points: 0, + rollout_salt: "message-hover-tracking-v1".to_owned(), + included_user_ids: Vec::new(), + excluded_user_ids: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct MessageHoverTrackingConfigUpdateRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_basis_points: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_salt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub included_user_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_user_ids: Option>, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(default)] pub struct ExperimentDeliveryConfigResponse { @@ -654,6 +694,8 @@ pub struct InstanceConfigUpdateRequest { pub voice_noise_suppression: Option, #[serde(skip_serializing_if = "Option::is_none")] pub experiment_delivery: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_hover_tracking: Option, } #[derive(Clone, Debug, Default, Serialize)] @@ -988,12 +1030,19 @@ mod tests { .expect("default noise config"); let delivery = serde_json::from_value::(json!({})) .expect("default delivery config"); + let hover = serde_json::from_value::(json!({})) + .expect("default message hover tracking config"); let noise = serde_json::to_value(noise).expect("serializable noise config"); let delivery = serde_json::to_value(delivery).expect("serializable delivery config"); + let hover = + serde_json::to_value(hover).expect("serializable message hover tracking config"); let generated_noise: generated_types::VoiceNoiseSuppressionConfigResponse = serde_json::from_value(noise.clone()).expect("generated noise config contract"); let generated_delivery: generated_types::ExperimentDeliveryConfigResponse = serde_json::from_value(delivery.clone()).expect("generated delivery config contract"); + let generated_hover: generated_types::MessageHoverTrackingConfigResponse = + serde_json::from_value(hover.clone()) + .expect("generated message hover tracking config contract"); assert_eq!( serde_json::to_value(generated_noise).expect("serializable generated noise config"), noise @@ -1003,9 +1052,15 @@ mod tests { .expect("serializable generated delivery config"), delivery ); + assert_eq!( + serde_json::to_value(generated_hover) + .expect("serializable generated message hover tracking config"), + hover + ); for (name, value) in [ ("VoiceNoiseSuppressionConfigResponse", noise), ("ExperimentDeliveryConfigResponse", delivery), + ("MessageHoverTrackingConfigResponse", hover), ] { for (field, value) in value.as_object().expect("config object") { assert_eq!( @@ -1016,6 +1071,29 @@ mod tests { } } + #[test] + fn message_hover_tracking_update_preserves_empty_lists_and_omitted_fields() { + let update = MessageHoverTrackingConfigUpdateRequest { + included_user_ids: Some(Vec::new()), + excluded_user_ids: Some(Vec::new()), + ..Default::default() + }; + let value = serde_json::to_value(update).expect("serializable update"); + serde_json::from_value::( + value.clone(), + ) + .expect("generated update contract"); + assert_eq!( + value, + json!({"included_user_ids": [], "excluded_user_ids": []}) + ); + assert_eq!( + serde_json::to_value(MessageHoverTrackingConfigUpdateRequest::default()) + .expect("serializable update"), + json!({}) + ); + } + #[test] fn noise_suppression_update_preserves_empty_lists_and_omitted_fields() { let update = VoiceNoiseSuppressionConfigUpdateRequest { diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 047f561ee..d86824c8a 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -17,9 +17,9 @@ use crate::{ InstanceMediaUpdateRequest, InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule, - LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode, - SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, - VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest, + LimitRuleFilters, MessageHoverTrackingConfigUpdateRequest, NoiseSuppressionBackend, + PremiumMode, RegistrationMode, SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, + VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride, }, }, @@ -207,6 +207,10 @@ pub async fn instance_config_post( Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, + "update_message_hover_tracking" => match build_message_hover_tracking_update(&form) { + Ok(update) => instance_config_result(client.update_instance_config(&update).await), + Err(message) => FlashData::error(message), + }, "update_experiment_delivery" => match build_experiment_delivery_update(&form) { Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), @@ -447,10 +451,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe } } -const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000; +const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000; const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100; -const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64; -const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20; +const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64; +const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20; const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60; const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400; const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50; @@ -476,35 +480,36 @@ where Ok(Some(value)) } -fn parse_voice_noise_suppression_rollout_salt( +fn parse_experiment_rollout_salt( form: &MultiValueForm, + key: &str, ) -> Result, String> { - let Some(raw) = form.first("voice_ns_rollout_salt") else { + let Some(raw) = form.first(key) else { return Ok(None); }; let salt = raw.trim(); - if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS { + if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS { return Err(format!( - "Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters" + "Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters" )); } Ok(Some(salt.to_owned())) } -fn is_voice_noise_suppression_snowflake(value: &str) -> bool { +fn is_experiment_snowflake(value: &str) -> bool { !value.is_empty() - && value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH + && value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH && value.bytes().all(|byte| byte.is_ascii_digit()) } -fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result, String> { +fn parse_experiment_user_ids(value: &str, label: &str) -> Result, String> { let mut ids: Vec = Vec::new(); for (index, candidate) in value.split([',', '\n', '\r']).enumerate() { let candidate = candidate.trim(); if candidate.is_empty() { continue; } - if !is_voice_noise_suppression_snowflake(candidate) { + if !is_experiment_snowflake(candidate) { return Err(format!( "{label} entry {} must contain 1 to 20 decimal digits", index + 1 @@ -536,7 +541,7 @@ fn parse_voice_noise_suppression_guild_overrides( format!("Guild overrides line {line_number} must use guild_id=backend") })?; let guild_id = guild_id.trim(); - if !is_voice_noise_suppression_snowflake(guild_id) { + if !is_experiment_snowflake(guild_id) { return Err(format!( "Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits" )); @@ -602,14 +607,14 @@ fn build_voice_noise_suppression_update( "voice_ns_rollout_basis_points", "Rollout basis points", 0, - VOICE_NS_ROLLOUT_BASIS_POINTS_MAX, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, )?, - rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?, - included_user_ids: Some(parse_voice_noise_suppression_user_ids( + rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?, + included_user_ids: Some(parse_experiment_user_ids( form.first("voice_ns_included_user_ids").unwrap_or_default(), "Included user IDs", )?), - excluded_user_ids: Some(parse_voice_noise_suppression_user_ids( + excluded_user_ids: Some(parse_experiment_user_ids( form.first("voice_ns_excluded_user_ids").unwrap_or_default(), "Excluded user IDs", )?), @@ -629,6 +634,35 @@ fn build_voice_noise_suppression_update( }) } +fn build_message_hover_tracking_update( + form: &MultiValueForm, +) -> Result { + Ok(InstanceConfigUpdateRequest { + message_hover_tracking: Some(MessageHoverTrackingConfigUpdateRequest { + enabled: Some(form.bool_value("message_hover_enabled")), + rollout_basis_points: parse_form_number( + form, + "message_hover_rollout_basis_points", + "Rollout basis points", + 0, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, + )?, + rollout_salt: parse_experiment_rollout_salt(form, "message_hover_rollout_salt")?, + included_user_ids: Some(parse_experiment_user_ids( + form.first("message_hover_included_user_ids") + .unwrap_or_default(), + "Included user IDs", + )?), + excluded_user_ids: Some(parse_experiment_user_ids( + form.first("message_hover_excluded_user_ids") + .unwrap_or_default(), + "Excluded user IDs", + )?), + }), + ..Default::default() + }) +} + fn build_experiment_delivery_update( form: &MultiValueForm, ) -> Result { @@ -1305,9 +1339,9 @@ mod tests { } #[test] - fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() { + fn parse_experiment_user_ids_splits_newlines_and_commas() { assert_eq!( - parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs") + parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs") .expect("valid IDs"), vec![ "1".to_owned(), @@ -1320,16 +1354,15 @@ mod tests { } #[test] - fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() { + fn parse_experiment_user_ids_dedupes_preserving_order() { assert_eq!( - parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs") - .expect("valid IDs"), + parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"), vec!["20".to_owned(), "10".to_owned(), "30".to_owned()] ); } #[test] - fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() { + fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() { for value in [ "abc", "12a", @@ -1339,11 +1372,8 @@ mod tests { "