mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172791316b | ||
|
|
b30a4f5d14 | ||
|
|
f254ed679b | ||
|
|
258fe6f742 | ||
|
|
cfa20b7093 | ||
|
|
569146c5bc | ||
|
|
1b1d48b05e | ||
|
|
cadca2c18e | ||
|
|
2e3f78b3c6 | ||
|
|
b57545b1a4 | ||
|
|
e490be2f35 | ||
|
|
ab07fd23cf | ||
|
|
c1fd2234b8 | ||
|
|
3af43b3366 | ||
|
|
0e470f532e | ||
|
|
c541b86c00 | ||
|
|
53b3fa2f4a | ||
|
|
67e01be34a | ||
|
|
81fd8c9aad | ||
|
|
bcef7b3123 | ||
|
|
a98d8ef679 | ||
|
|
a5af857564 | ||
|
|
2830221949 | ||
|
|
84aa8880f5 | ||
|
|
395ec1d60f | ||
|
|
e6ee3b8059 | ||
|
|
61a13e1c1a | ||
|
|
fc0e2628a4 | ||
|
|
87fdfd9c34 | ||
|
|
88a5ff9c45 | ||
|
|
320949a79d | ||
|
|
3a862f1484 | ||
|
|
5da256df12 | ||
|
|
baf2cbf3fd | ||
|
|
74782dc4f2 | ||
|
|
7d8778495f | ||
|
|
53399ffb44 | ||
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f | ||
|
|
0421c86039 | ||
|
|
d17f320bd7 |
@@ -525,6 +525,7 @@ jobs:
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
@@ -602,7 +603,9 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
@@ -660,3 +663,8 @@ jobs:
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
- name: Publish payload metadata to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_payload_metadata
|
||||
|
||||
@@ -9801,7 +9801,7 @@
|
||||
},
|
||||
"GuildFeatureSchema": {
|
||||
"type": "string",
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
},
|
||||
"AddGuildMembersAdminBulkJobCreateRequest": {
|
||||
"type": "object",
|
||||
@@ -11882,6 +11882,8 @@
|
||||
]
|
||||
},
|
||||
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigSchema"},
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigSchema"},
|
||||
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigSchema"},
|
||||
"registration": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -12268,6 +12270,8 @@
|
||||
"required": [
|
||||
"sso",
|
||||
"gateway_rollout",
|
||||
"voice_noise_suppression",
|
||||
"experiment_delivery",
|
||||
"registration",
|
||||
"self_hosted",
|
||||
"app_public",
|
||||
@@ -12290,6 +12294,62 @@
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"config_version": {"type": "integer", "minimum": 0, "maximum": 9007199254740991, "format": "int53"},
|
||||
"default_backend": {
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string"
|
||||
},
|
||||
"enabled_backends": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 7
|
||||
},
|
||||
"allow_user_override": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000, "format": "int32"},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"maxItems": 1000
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"maxItems": 1000
|
||||
},
|
||||
"guild_overrides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"backend": {
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["guild_id", "backend"]
|
||||
},
|
||||
"maxItems": 200
|
||||
},
|
||||
"stereo_enabled": {"type": "boolean"},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100, "format": "int32"}
|
||||
}
|
||||
},
|
||||
"ExperimentDeliveryConfigSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"poll_interval_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "format": "int32"},
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50, "format": "int32"}
|
||||
}
|
||||
},
|
||||
"InstanceConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -12297,6 +12357,14 @@
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/GatewayRolloutConfigUpdateRequest"}]
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}]
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"nullable": true,
|
||||
"allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}]
|
||||
},
|
||||
"registration": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
@@ -12530,6 +12598,61 @@
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}
|
||||
}
|
||||
},
|
||||
"VoiceNoiseSuppressionConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"default_backend": {
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string"
|
||||
},
|
||||
"enabled_backends": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 7
|
||||
},
|
||||
"allow_user_override": {"type": "boolean"},
|
||||
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000, "format": "int32"},
|
||||
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"included_user_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"maxItems": 1000
|
||||
},
|
||||
"excluded_user_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"maxItems": 1000
|
||||
},
|
||||
"guild_overrides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"backend": {
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["guild_id", "backend"]
|
||||
},
|
||||
"maxItems": 200
|
||||
},
|
||||
"stereo_enabled": {"type": "boolean"},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100, "format": "int32"}
|
||||
}
|
||||
},
|
||||
"ExperimentDeliveryConfigUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"poll_interval_seconds": {"type": "integer", "minimum": 60, "maximum": 86400, "format": "int32"},
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50, "format": "int32"}
|
||||
}
|
||||
},
|
||||
"BrandingAssetUploadRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13891,6 +14014,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -13928,6 +14059,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14227,6 +14359,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14264,6 +14404,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14299,6 +14440,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null for no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14356,6 +14505,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14393,6 +14550,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14428,6 +14586,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null for no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct InstanceConfigResponse {
|
||||
pub integrations: InstanceIntegrationsResponse,
|
||||
#[serde(default)]
|
||||
pub media: InstanceMediaResponse,
|
||||
#[serde(default)]
|
||||
pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -436,6 +440,192 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
}
|
||||
|
||||
pub const VOICE_NS_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const VOICE_NS_MAX_GUILD_OVERRIDES: usize = 200;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NoiseSuppressionBackend {
|
||||
None,
|
||||
#[default]
|
||||
Standard,
|
||||
Gate,
|
||||
Speex,
|
||||
Rnnoise,
|
||||
Gtcrn,
|
||||
DeepFilter,
|
||||
}
|
||||
|
||||
impl NoiseSuppressionBackend {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::None,
|
||||
Self::Standard,
|
||||
Self::Gate,
|
||||
Self::Speex,
|
||||
Self::Rnnoise,
|
||||
Self::Gtcrn,
|
||||
Self::DeepFilter,
|
||||
];
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "none",
|
||||
Self::Standard => "standard",
|
||||
Self::Gate => "gate",
|
||||
Self::Speex => "speex",
|
||||
Self::Rnnoise => "rnnoise",
|
||||
Self::Gtcrn => "gtcrn",
|
||||
Self::DeepFilter => "deep_filter",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None (pass-through)",
|
||||
Self::Standard => "Standard (WebRTC)",
|
||||
Self::Gate => "Noise gate",
|
||||
Self::Speex => "Speex",
|
||||
Self::Rnnoise => "RNNoise",
|
||||
Self::Gtcrn => "GTCRN",
|
||||
Self::DeepFilter => "DeepFilterNet",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(value: &str) -> Option<Self> {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|backend| backend.as_str() == value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionGuildOverride {
|
||||
pub guild_id: String,
|
||||
pub backend: NoiseSuppressionBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub config_version: u64,
|
||||
#[serde(default)]
|
||||
pub default_backend: NoiseSuppressionBackend,
|
||||
#[serde(default = "default_voice_noise_suppression_enabled_backends")]
|
||||
pub enabled_backends: Vec<NoiseSuppressionBackend>,
|
||||
#[serde(default = "default_voice_noise_suppression_allow_user_override")]
|
||||
pub allow_user_override: bool,
|
||||
#[serde(default)]
|
||||
pub rollout_basis_points: u32,
|
||||
#[serde(default = "default_voice_noise_suppression_rollout_salt")]
|
||||
pub rollout_salt: String,
|
||||
#[serde(default)]
|
||||
pub included_user_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
#[serde(default)]
|
||||
pub stereo_enabled: bool,
|
||||
#[serde(default = "default_voice_noise_suppression_strength")]
|
||||
pub suppression_strength: u32,
|
||||
}
|
||||
|
||||
impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
default_backend: NoiseSuppressionBackend::Standard,
|
||||
enabled_backends: default_voice_noise_suppression_enabled_backends(),
|
||||
allow_user_override: default_voice_noise_suppression_allow_user_override(),
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: default_voice_noise_suppression_rollout_salt(),
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
guild_overrides: Vec::new(),
|
||||
stereo_enabled: false,
|
||||
suppression_strength: default_voice_noise_suppression_strength(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_voice_noise_suppression_enabled_backends() -> Vec<NoiseSuppressionBackend> {
|
||||
NoiseSuppressionBackend::ALL.to_vec()
|
||||
}
|
||||
|
||||
fn default_voice_noise_suppression_allow_user_override() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_voice_noise_suppression_rollout_salt() -> String {
|
||||
"voice-ns-v1".to_owned()
|
||||
}
|
||||
|
||||
fn default_voice_noise_suppression_strength() -> u32 {
|
||||
80
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_backend: Option<NoiseSuppressionBackend>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_backends: Option<Vec<NoiseSuppressionBackend>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allow_user_override: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stereo_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ExperimentDeliveryConfigResponse {
|
||||
#[serde(default = "default_experiment_poll_interval_seconds")]
|
||||
pub poll_interval_seconds: u64,
|
||||
#[serde(default = "default_experiment_poll_jitter_percent")]
|
||||
pub poll_jitter_percent: u32,
|
||||
}
|
||||
|
||||
impl Default for ExperimentDeliveryConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
poll_interval_seconds: default_experiment_poll_interval_seconds(),
|
||||
poll_jitter_percent: default_experiment_poll_jitter_percent(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_experiment_poll_interval_seconds() -> u64 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_experiment_poll_jitter_percent() -> u32 {
|
||||
15
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct ExperimentDeliveryConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_interval_seconds: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_jitter_percent: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct InstanceRegistrationResponse {
|
||||
pub mode: RegistrationMode,
|
||||
@@ -525,6 +715,10 @@ pub struct InstanceConfigUpdateRequest {
|
||||
pub integrations: Option<InstanceIntegrationsUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<InstanceMediaUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_noise_suppression: Option<VoiceNoiseSuppressionConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -826,3 +1020,65 @@ pub struct CreateRegistrationUrlResponse {
|
||||
pub code: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_SCHEMA: &str = include_str!(
|
||||
"../../../../packages/schema/src/domains/admin/VoiceNoiseSuppressionSchemas.ts"
|
||||
);
|
||||
|
||||
fn schema_backend_ids() -> Vec<String> {
|
||||
let (_, rest) = VOICE_NOISE_SUPPRESSION_SCHEMA
|
||||
.split_once("export const VOICE_NOISE_SUPPRESSION_BACKENDS = [")
|
||||
.expect("backend list start");
|
||||
let (block, _) = rest.split_once(']').expect("backend list end");
|
||||
block
|
||||
.split(',')
|
||||
.map(|entry| entry.trim().trim_matches('\'').to_owned())
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_ids_are_the_documented_list() {
|
||||
assert_eq!(
|
||||
NoiseSuppressionBackend::ALL
|
||||
.iter()
|
||||
.map(NoiseSuppressionBackend::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_ids_match_the_schema_contract() {
|
||||
assert_eq!(
|
||||
NoiseSuppressionBackend::ALL
|
||||
.iter()
|
||||
.map(|backend| backend.as_str().to_owned())
|
||||
.collect::<Vec<_>>(),
|
||||
schema_backend_ids()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_suppression_backend_round_trips_every_id() {
|
||||
for backend in NoiseSuppressionBackend::ALL {
|
||||
assert_eq!(
|
||||
NoiseSuppressionBackend::from_value(backend.as_str()),
|
||||
Some(backend)
|
||||
);
|
||||
}
|
||||
assert_eq!(NoiseSuppressionBackend::from_value("deepfilter"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct VoiceServer {
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub is_active: Option<bool>,
|
||||
pub soft_connection_limit: Option<i64>,
|
||||
pub vip_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub required_guild_features: Vec<String>,
|
||||
|
||||
@@ -55,15 +55,13 @@ impl AdminApiClient {
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceRegionResponse> {
|
||||
let region_id = required_field(params, "id")?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_voice_region(®ion_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequest>(params)?;
|
||||
self.patch_with_reason(
|
||||
&format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)),
|
||||
Some(params),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
|
||||
@@ -121,15 +119,17 @@ impl AdminApiClient {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
let server_id = required_field(params, "server_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_voice_server(®ion_id, &server_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
validate_against::<generated_types::UpdateVoiceServerRequest>(params)?;
|
||||
self.patch_with_reason(
|
||||
&format!(
|
||||
"/admin/voice/regions/{}/servers/{}",
|
||||
urlencoding::encode(®ion_id),
|
||||
urlencoding::encode(&server_id)
|
||||
),
|
||||
Some(params),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_voice_server(
|
||||
@@ -150,6 +150,12 @@ fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
|
||||
serde_json::from_value::<T>(params.clone())
|
||||
.map(drop)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
|
||||
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
|
||||
if has_coordinate("latitude") == has_coordinate("longitude") {
|
||||
|
||||
@@ -7,7 +7,8 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
DeferredPhoneGateUpdateRequest, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
@@ -16,8 +17,10 @@ use crate::{
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PremiumMode, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VoiceE2eeScope,
|
||||
LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest,
|
||||
VoiceNoiseSuppressionGuildOverride,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -200,6 +203,14 @@ pub async fn instance_config_post(
|
||||
let update = build_media_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_voice_noise_suppression" => {
|
||||
let update = build_voice_noise_suppression_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"update_experiment_delivery" => {
|
||||
let update = build_experiment_delivery_update(&form);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"test_smtp" => match build_smtp_test_request(&form) {
|
||||
Ok(request) => match client.test_instance_smtp_config(&request).await {
|
||||
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
|
||||
@@ -407,6 +418,8 @@ fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +456,183 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
const VOICE_NS_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_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
||||
const EXPERIMENT_DEFAULT_POLL_INTERVAL_SECONDS: u64 = 300;
|
||||
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
const EXPERIMENT_DEFAULT_POLL_JITTER_PERCENT: u32 = 15;
|
||||
|
||||
fn parse_clamped_form_number<T>(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
empty_value: T,
|
||||
min: T,
|
||||
max: T,
|
||||
) -> Option<T>
|
||||
where
|
||||
T: std::str::FromStr + Ord,
|
||||
{
|
||||
let raw = form.first(key)?.trim();
|
||||
let value = if raw.is_empty() {
|
||||
empty_value
|
||||
} else {
|
||||
raw.parse::<T>().unwrap_or(empty_value)
|
||||
};
|
||||
Some(value.clamp(min, max))
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_rollout_salt(form: &MultiValueForm) -> Option<String> {
|
||||
let salt: String = form
|
||||
.clean("voice_ns_rollout_salt")?
|
||||
.chars()
|
||||
.take(VOICE_NS_MAX_ROLLOUT_SALT_CHARS)
|
||||
.collect();
|
||||
clean_string(&salt)
|
||||
}
|
||||
|
||||
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str) -> Vec<String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for candidate in value.split([',', '\n', '\r']) {
|
||||
let candidate = candidate.trim();
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
continue;
|
||||
}
|
||||
if ids.iter().any(|existing| existing == candidate) {
|
||||
continue;
|
||||
}
|
||||
ids.push(candidate.to_owned());
|
||||
if ids.len() == VOICE_NS_MAX_TARGETED_USERS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_guild_overrides(
|
||||
value: &str,
|
||||
) -> Vec<VoiceNoiseSuppressionGuildOverride> {
|
||||
let mut overrides: Vec<VoiceNoiseSuppressionGuildOverride> = Vec::new();
|
||||
for line in value.lines() {
|
||||
let Some((guild_id, backend)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_voice_noise_suppression_snowflake(guild_id) {
|
||||
continue;
|
||||
}
|
||||
if overrides
|
||||
.iter()
|
||||
.any(|existing| existing.guild_id == guild_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(backend) = NoiseSuppressionBackend::from_value(backend.trim()) else {
|
||||
continue;
|
||||
};
|
||||
overrides.push(VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: guild_id.to_owned(),
|
||||
backend,
|
||||
});
|
||||
if overrides.len() == VOICE_NS_MAX_GUILD_OVERRIDES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
overrides
|
||||
}
|
||||
|
||||
fn build_voice_noise_suppression_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let selected =
|
||||
form.list_values_any(&["voice_ns_enabled_backends[]", "voice_ns_enabled_backends"]);
|
||||
let enabled_backends = NoiseSuppressionBackend::ALL
|
||||
.into_iter()
|
||||
.filter(|backend| selected.iter().any(|value| value == backend.as_str()))
|
||||
.collect();
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: Some(VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_ns_enabled")),
|
||||
default_backend: form
|
||||
.first("voice_ns_default_backend")
|
||||
.and_then(NoiseSuppressionBackend::from_value),
|
||||
enabled_backends: Some(enabled_backends),
|
||||
allow_user_override: Some(form.bool_value("voice_ns_allow_user_override")),
|
||||
rollout_basis_points: parse_clamped_form_number(
|
||||
form,
|
||||
"voice_ns_rollout_basis_points",
|
||||
0,
|
||||
0,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
),
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form),
|
||||
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
)),
|
||||
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
)),
|
||||
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
|
||||
form.first("voice_ns_guild_overrides").unwrap_or_default(),
|
||||
)),
|
||||
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
|
||||
suppression_strength: parse_clamped_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
0,
|
||||
0,
|
||||
VOICE_NS_SUPPRESSION_STRENGTH_MAX,
|
||||
),
|
||||
}),
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_experiment_delivery_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: Some(ExperimentDeliveryConfigUpdateRequest {
|
||||
poll_interval_seconds: parse_clamped_form_number(
|
||||
form,
|
||||
"experiment_delivery_poll_interval_seconds",
|
||||
EXPERIMENT_DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
EXPERIMENT_MIN_POLL_INTERVAL_SECONDS,
|
||||
EXPERIMENT_MAX_POLL_INTERVAL_SECONDS,
|
||||
),
|
||||
poll_jitter_percent: parse_clamped_form_number(
|
||||
form,
|
||||
"experiment_delivery_poll_jitter_percent",
|
||||
EXPERIMENT_DEFAULT_POLL_JITTER_PERCENT,
|
||||
0,
|
||||
EXPERIMENT_MAX_POLL_JITTER_PERCENT,
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +656,8 @@ fn build_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +686,8 @@ fn build_app_public_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +709,8 @@ fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +730,8 @@ fn build_app_registration_update(form: &MultiValueForm) -> InstanceConfigUpdateR
|
||||
policy: None,
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,6 +761,8 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,6 +872,8 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
}),
|
||||
}),
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -700,6 +902,8 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
renew_window_days: form.parse_u32("media_attachment_decay_renew_window_days"),
|
||||
}),
|
||||
}),
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,6 +946,8 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
voice_noise_suppression: None,
|
||||
experiment_delivery: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1066,6 +1272,326 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_collects_backends_and_clamps_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_enabled=true&voice_ns_allow_user_override=on&voice_ns_default_backend=rnnoise&voice_ns_enabled_backends%5B%5D=deep_filter&voice_ns_enabled_backends%5B%5D=none&voice_ns_enabled_backends%5B%5D=bogus&voice_ns_rollout_basis_points=99999&voice_ns_suppression_strength=250&voice_ns_rollout_salt=%20voice-ns-v2%20",
|
||||
);
|
||||
let request = build_voice_noise_suppression_update(&form);
|
||||
let update = request
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.allow_user_override, Some(true));
|
||||
assert_eq!(update.stereo_enabled, Some(false));
|
||||
assert_eq!(
|
||||
update.default_backend,
|
||||
Some(NoiseSuppressionBackend::Rnnoise)
|
||||
);
|
||||
assert_eq!(
|
||||
update.enabled_backends,
|
||||
Some(vec![
|
||||
NoiseSuppressionBackend::None,
|
||||
NoiseSuppressionBackend::DeepFilter
|
||||
])
|
||||
);
|
||||
assert_eq!(update.rollout_basis_points, Some(10_000));
|
||||
assert_eq!(update.suppression_strength, Some(100));
|
||||
assert_eq!(update.rollout_salt, Some("voice-ns-v2".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_voice_noise_suppression_update(&form);
|
||||
let update = request
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.enabled, Some(false));
|
||||
assert_eq!(update.allow_user_override, Some(false));
|
||||
assert_eq!(update.stereo_enabled, Some(false));
|
||||
assert_eq!(update.default_backend, None);
|
||||
assert_eq!(update.enabled_backends, Some(Vec::new()));
|
||||
assert_eq!(update.rollout_basis_points, None);
|
||||
assert_eq!(update.rollout_salt, None);
|
||||
assert_eq!(update.included_user_ids, Some(Vec::new()));
|
||||
assert_eq!(update.excluded_user_ids, Some(Vec::new()));
|
||||
assert_eq!(update.guild_overrides, Some(Vec::new()));
|
||||
assert_eq!(update.suppression_strength, None);
|
||||
assert!(request.gateway_rollout.is_none());
|
||||
assert!(request.policy.is_none());
|
||||
assert!(request.media.is_none());
|
||||
assert!(request.experiment_delivery.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_reads_user_id_textareas() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_included_user_ids=1500000000000000001%0A1500000000000000002&voice_ns_excluded_user_ids=1500000000000000003%2C%201500000000000000004",
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000001".to_owned(),
|
||||
"1500000000000000002".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec![
|
||||
"1500000000000000003".to_owned(),
|
||||
"1500000000000000004".to_owned()
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 "),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
"2".to_owned(),
|
||||
"3".to_owned(),
|
||||
"4".to_owned(),
|
||||
"5".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
assert!(
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
"abc,12a,-1,1.0, ,999999999999999999999,<script>"
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_truncates_at_cap() {
|
||||
let value = (0..1_200)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids = parse_voice_noise_suppression_user_ids(&value);
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_skips_malformed_lines() {
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(
|
||||
" 1600000000000000001 = rnnoise \n1600000000000000002\n=gate\nnot-a-guild=gate\n1600000000000000003=unknown_backend\n1600000000000000004=deep_filter\n",
|
||||
);
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
},
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000004".to_owned(),
|
||||
backend: NoiseSuppressionBackend::DeepFilter,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_treats_blank_numbers_as_explicit_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_rollout_basis_points=&voice_ns_suppression_strength=%20%20",
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(0));
|
||||
assert_eq!(update.suppression_strength, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_treats_unparseable_numbers_as_explicit_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"voice_ns_rollout_basis_points=abc&voice_ns_suppression_strength=-5",
|
||||
);
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(0));
|
||||
assert_eq!(update.suppression_strength, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(b"voice_ns_rollout_basis_points=%20250%20");
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_basis_points, Some(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_truncates_an_overlong_rollout_salt() {
|
||||
let salt = "\u{e9}".repeat(80);
|
||||
let form = MultiValueForm::parse(format!("voice_ns_rollout_salt={salt}").as_bytes());
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
let salt = update.rollout_salt.expect("rollout salt");
|
||||
assert_eq!(salt.chars().count(), VOICE_NS_MAX_ROLLOUT_SALT_CHARS);
|
||||
assert_eq!(salt, "\u{e9}".repeat(VOICE_NS_MAX_ROLLOUT_SALT_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_voice_noise_suppression_update_never_sends_an_empty_rollout_salt() {
|
||||
let form = MultiValueForm::parse(b"voice_ns_rollout_salt=%20%20%20");
|
||||
let update = build_voice_noise_suppression_update(&form)
|
||||
.voice_noise_suppression
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.rollout_salt, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_dedupes_keeping_the_first_backend() {
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(
|
||||
"1600000000000000001=rnnoise\n1600000000000000001=gate\n1600000000000000002=speex\n1600000000000000001=deep_filter\n",
|
||||
);
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec![
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
},
|
||||
VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000002".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Speex,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_guild_overrides_truncates_at_cap() {
|
||||
let value = (0..300)
|
||||
.map(|index| format!("{index}=gate"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let overrides = parse_voice_noise_suppression_guild_overrides(&value);
|
||||
assert_eq!(overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES);
|
||||
assert_eq!(
|
||||
overrides.last().map(|entry| entry.guild_id.as_str()),
|
||||
Some("199")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_experiment_delivery_update(&form);
|
||||
let update = request
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(update.poll_interval_seconds, None);
|
||||
assert_eq!(update.poll_jitter_percent, None);
|
||||
assert!(request.voice_noise_suppression.is_none());
|
||||
assert!(request.gateway_rollout.is_none());
|
||||
assert!(request.policy.is_none());
|
||||
assert!(request.media.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_treats_blank_numbers_as_explicit_defaults() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=&experiment_delivery_poll_jitter_percent=%20%20",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(
|
||||
update.poll_interval_seconds,
|
||||
Some(EXPERIMENT_DEFAULT_POLL_INTERVAL_SECONDS)
|
||||
);
|
||||
assert_eq!(
|
||||
update.poll_jitter_percent,
|
||||
Some(EXPERIMENT_DEFAULT_POLL_JITTER_PERCENT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_treats_unparseable_numbers_as_explicit_defaults() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=12.5&experiment_delivery_poll_jitter_percent=abc",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(
|
||||
update.poll_interval_seconds,
|
||||
Some(EXPERIMENT_DEFAULT_POLL_INTERVAL_SECONDS)
|
||||
);
|
||||
assert_eq!(
|
||||
update.poll_jitter_percent,
|
||||
Some(EXPERIMENT_DEFAULT_POLL_JITTER_PERCENT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_clamps_numbers_to_their_bounds() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=5&experiment_delivery_poll_jitter_percent=90",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(
|
||||
update.poll_interval_seconds,
|
||||
Some(EXPERIMENT_MIN_POLL_INTERVAL_SECONDS)
|
||||
);
|
||||
assert_eq!(
|
||||
update.poll_jitter_percent,
|
||||
Some(EXPERIMENT_MAX_POLL_JITTER_PERCENT)
|
||||
);
|
||||
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=999999&experiment_delivery_poll_jitter_percent=0",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(
|
||||
update.poll_interval_seconds,
|
||||
Some(EXPERIMENT_MAX_POLL_INTERVAL_SECONDS)
|
||||
);
|
||||
assert_eq!(update.poll_jitter_percent, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=%20900%20&experiment_delivery_poll_jitter_percent=%2025%20",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(update.poll_interval_seconds, Some(900));
|
||||
assert_eq!(update.poll_jitter_percent, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_limit_rule_values_reads_checked_limit_keys() {
|
||||
let form = MultiValueForm::parse(b"message_send=1&traits%5B%5D=trial");
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
api::client::AdminApiClient,
|
||||
middleware::{auth::AuthContext, csrf},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, pages::voice_servers::VoiceServersPageParams},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -13,12 +13,34 @@ use axum::{
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VoiceServersQuery {
|
||||
region_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
|
||||
let response = match client.get_gateway_voice_state_counts().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: load voice state counts");
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
servers
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let server_id = entry.get("server_id")?.as_str()?.to_owned();
|
||||
let count = entry.get("voice_state_count")?.as_i64()?;
|
||||
Some((server_id, count))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -79,17 +101,21 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: None,
|
||||
region_name: None,
|
||||
servers: None,
|
||||
connection_counts: &HashMap::new(),
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
return Html(markup.into_string()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let connection_counts = load_server_connection_counts(&client).await;
|
||||
|
||||
let region_name = match client.get_voice_region(region_id, false).await {
|
||||
Ok(resp) => resp
|
||||
@@ -107,11 +133,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
Some(&resp.servers),
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: Some(&resp.servers),
|
||||
connection_counts: &connection_counts,
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -120,11 +149,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
None,
|
||||
Some(&msg),
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: None,
|
||||
connection_counts: &connection_counts,
|
||||
error: Some(&msg),
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
}
|
||||
body.insert("is_default".into(), form.bool_value("is_default").into());
|
||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||
body.insert(
|
||||
"required_guild_features".into(),
|
||||
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
|
||||
.into(),
|
||||
);
|
||||
body.insert(
|
||||
"allowed_guild_ids".into(),
|
||||
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
|
||||
.into(),
|
||||
);
|
||||
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||
serde_json::Value::Object(body)
|
||||
}
|
||||
|
||||
fn insert_submitted_list(
|
||||
body: &mut serde_json::Map<String, serde_json::Value>,
|
||||
form: &MultiValueForm,
|
||||
field: &str,
|
||||
) {
|
||||
let repeated = format!("{field}[]");
|
||||
if !form.contains_key(&repeated) && !form.contains_key(field) {
|
||||
return;
|
||||
}
|
||||
body.insert(
|
||||
field.to_owned(),
|
||||
form.list_values_any(&[repeated.as_str(), field]).into(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(v) = form.clean("region_id") {
|
||||
@@ -92,17 +99,19 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
body.insert("longitude".into(), lng.into());
|
||||
}
|
||||
body.insert("is_active".into(), form.bool_value("is_active").into());
|
||||
if let Some(raw) = form.first("soft_connection_limit") {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
|
||||
} else if let Ok(limit) = trimmed.parse::<i64>()
|
||||
&& limit > 0
|
||||
{
|
||||
body.insert("soft_connection_limit".into(), limit.into());
|
||||
}
|
||||
}
|
||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||
body.insert(
|
||||
"required_guild_features".into(),
|
||||
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
|
||||
.into(),
|
||||
);
|
||||
body.insert(
|
||||
"allowed_guild_ids".into(),
|
||||
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
|
||||
.into(),
|
||||
);
|
||||
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||
serde_json::Value::Object(body)
|
||||
}
|
||||
|
||||
@@ -255,6 +264,86 @@ mod tests {
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
|
||||
);
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["required_guild_features"], serde_json::json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
|
||||
);
|
||||
let body = build_server_body(&form);
|
||||
let object = body.as_object().unwrap();
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
assert_eq!(body["is_active"], serde_json::json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
|
||||
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
|
||||
let body = build_region_body(&form);
|
||||
assert_eq!(body["required_guild_features"], serde_json::json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
|
||||
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
|
||||
let body = build_region_body(&form);
|
||||
let object = body.as_object().unwrap();
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
|
||||
let form =
|
||||
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
|
||||
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
|
||||
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
|
||||
assert!(
|
||||
!build_server_body(&absent)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
let invalid =
|
||||
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
|
||||
assert!(
|
||||
!build_server_body(&invalid)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
|
||||
assert!(
|
||||
!build_server_body(&zero)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_preserves_single_and_repeated_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
|
||||
@@ -147,6 +147,9 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
|
||||
@@ -14,6 +14,9 @@ use maud::{Markup, html};
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
use crate::{
|
||||
api::types::{
|
||||
AppPublicConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse,
|
||||
AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse,
|
||||
InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse,
|
||||
InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse,
|
||||
NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse,
|
||||
SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS,
|
||||
VoiceNoiseSuppressionConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -42,6 +44,17 @@ fn format_decimal(value: f64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_count_hint(count: usize, cap: usize) -> Markup {
|
||||
html! {
|
||||
p class="text-xs text-neutral-500" {
|
||||
(count) " of " (cap) " stored"
|
||||
@if count >= cap {
|
||||
" (at the cap; extra lines are dropped on save)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn number_field(
|
||||
name: &str,
|
||||
label: &str,
|
||||
@@ -134,6 +147,8 @@ pub fn instance_config_page(
|
||||
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
||||
html! {
|
||||
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
||||
(voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression))
|
||||
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
||||
@if let Some(limit_config) = limit_config {
|
||||
(limit_config_section(base, limit_config))
|
||||
} @else {
|
||||
@@ -953,6 +968,249 @@ fn gateway_rollout_section(
|
||||
)
|
||||
}
|
||||
|
||||
fn voice_noise_suppression_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
) -> Markup {
|
||||
let status = if voice_noise_suppression.enabled {
|
||||
("Live", BadgeVariant::Success)
|
||||
} else {
|
||||
("Inert", BadgeVariant::Default)
|
||||
};
|
||||
let backend_options = NoiseSuppressionBackend::ALL
|
||||
.iter()
|
||||
.map(|backend| (backend.as_str(), backend.label()))
|
||||
.collect::<Vec<_>>();
|
||||
let included_user_ids = voice_noise_suppression.included_user_ids.join("\n");
|
||||
let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n");
|
||||
let guild_overrides = voice_noise_suppression
|
||||
.guild_overrides
|
||||
.iter()
|
||||
.map(|entry| format!("{}={}", entry.guild_id, entry.backend.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
section_card_with_description(
|
||||
"Voice Noise Suppression",
|
||||
"Pick which noise suppression backend targeted clients load in voice calls, and how many \
|
||||
of them are targeted. While the master switch below is off nothing on this form reaches \
|
||||
any client: every user keeps the audio pipeline they have today, whatever the rest of \
|
||||
these fields say.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
||||
(badge(status.0, status.1))
|
||||
span class="text-xs text-neutral-500" {
|
||||
"Config version " (voice_noise_suppression.config_version)
|
||||
}
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_enabled",
|
||||
"true",
|
||||
"Serve noise suppression assignments to clients",
|
||||
voice_noise_suppression.enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Off is the safe state. With this unchecked every client is told the \
|
||||
feature is inert and keeps its current behavior, so the rollout, targeting \
|
||||
and override fields below have no effect at all."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Backends" }
|
||||
(select_input(
|
||||
"voice_ns_default_backend",
|
||||
"Default Backend",
|
||||
&backend_options,
|
||||
voice_noise_suppression.default_backend.as_str(),
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"The backend handed to every targeted user. A default that is not ticked \
|
||||
below is treated as unavailable and nobody is targeted."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-2 sm:grid-cols-2" {
|
||||
@for backend in NoiseSuppressionBackend::ALL {
|
||||
(checkbox(
|
||||
"voice_ns_enabled_backends[]",
|
||||
backend.as_str(),
|
||||
backend.label(),
|
||||
voice_noise_suppression.enabled_backends.contains(&backend),
|
||||
true,
|
||||
))
|
||||
}
|
||||
}
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Backends clients are allowed to load. Unticking one withdraws it from \
|
||||
every user, including anyone who picked it themselves."
|
||||
}
|
||||
(checkbox(
|
||||
"voice_ns_allow_user_override",
|
||||
"true",
|
||||
"Let users pick their own backend from the ticked list",
|
||||
voice_noise_suppression.allow_user_override,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Applies only to users who are already targeted. It never pulls anyone \
|
||||
into the rollout."
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
||||
(number_field(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout (basis points)",
|
||||
&voice_noise_suppression.rollout_basis_points.to_string(),
|
||||
Some(0), Some(10000), "1",
|
||||
Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
||||
))
|
||||
div class="flex flex-col gap-2" {
|
||||
(text_input(
|
||||
"voice_ns_rollout_salt",
|
||||
"Rollout Salt",
|
||||
&voice_noise_suppression.rollout_salt,
|
||||
"voice-ns-v1",
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
||||
inside the percentage above. Leave it alone to keep the current \
|
||||
cohort stable."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_included_user_ids",
|
||||
"Always-on User IDs",
|
||||
"1500000000000000001\n1500000000000000002",
|
||||
&included_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.included_user_ids.len(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One snowflake per line, or comma separated. These users are targeted \
|
||||
regardless of the percentage above. Anything that is not a plain \
|
||||
numeric ID is dropped."
|
||||
}
|
||||
}
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_excluded_user_ids",
|
||||
"Never-on User IDs",
|
||||
"1500000000000000003\n1500000000000000004",
|
||||
&excluded_user_ids,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.excluded_user_ids.len(),
|
||||
VOICE_NS_MAX_TARGETED_USERS,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Same format. Exclusion wins over both the always-on list and the \
|
||||
percentage, so this is the per-user kill switch."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" }
|
||||
div class="flex flex-col gap-2" {
|
||||
(textarea_input(
|
||||
"voice_ns_guild_overrides",
|
||||
"Guild Overrides",
|
||||
"1600000000000000001=rnnoise\n1600000000000000002=deep_filter",
|
||||
&guild_overrides,
|
||||
4,
|
||||
false,
|
||||
))
|
||||
(entry_count_hint(
|
||||
voice_noise_suppression.guild_overrides.len(),
|
||||
VOICE_NS_MAX_GUILD_OVERRIDES,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"One per line as guild_id=backend, first line per guild wins. Targeted \
|
||||
users in that guild get that backend instead of the default. Lines \
|
||||
that are not a numeric guild ID and a ticked backend are dropped \
|
||||
without failing the save."
|
||||
}
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
|
||||
(checkbox(
|
||||
"voice_ns_stereo_enabled",
|
||||
"true",
|
||||
"Process stereo input instead of downmixing to mono",
|
||||
voice_noise_suppression.stereo_enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Costs more CPU on the client. Leave off unless you are testing stereo \
|
||||
capture."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"voice_ns_suppression_strength",
|
||||
"Suppression Strength",
|
||||
&voice_noise_suppression.suppression_strength.to_string(),
|
||||
Some(0), Some(100), "1",
|
||||
Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."),
|
||||
))
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Voice Noise Suppression Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn experiment_delivery_section(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
experiment_delivery: &ExperimentDeliveryConfigResponse,
|
||||
) -> Markup {
|
||||
section_card_with_description(
|
||||
"Experiment Delivery",
|
||||
"How often every client revalidates its experiment assignments. This is instance-wide \
|
||||
and covers every experiment, not just the one above. Raising the interval sheds \
|
||||
request volume and makes a change take longer to reach a client. Raising the jitter \
|
||||
spreads a fleet that has synchronised on one tick back out across the interval.",
|
||||
html! {
|
||||
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-6" {
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"experiment_delivery_poll_interval_seconds",
|
||||
"Assignment Poll Interval (s)",
|
||||
&experiment_delivery.poll_interval_seconds.to_string(),
|
||||
Some(60), Some(86400), "1",
|
||||
Some("How often a client re-reads its assignments, 60 to 86400 seconds. Lower values pick up changes sooner at the cost of more requests."),
|
||||
))
|
||||
(number_field(
|
||||
"experiment_delivery_poll_jitter_percent",
|
||||
"Assignment Poll Jitter (%)",
|
||||
&experiment_delivery.poll_jitter_percent.to_string(),
|
||||
Some(0), Some(50), "1",
|
||||
Some("How far each client spreads its poll around the interval, 0 to 50 percent. Raise it to break up a fleet that polls on the same tick, set it to 0 for an exact interval."),
|
||||
))
|
||||
}
|
||||
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Experiment Delivery Configuration"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn registration_config_section(
|
||||
config: &AdminConfig,
|
||||
csrf_token: &str,
|
||||
@@ -1522,3 +1780,49 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api::types::VoiceNoiseSuppressionGuildOverride;
|
||||
|
||||
fn rendered_voice_noise_suppression_section(
|
||||
voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse,
|
||||
) -> String {
|
||||
voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_shows_list_counts_and_caps() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: vec!["1500000000000000001".to_owned()],
|
||||
excluded_user_ids: vec![
|
||||
"1500000000000000002".to_owned(),
|
||||
"1500000000000000003".to_owned(),
|
||||
],
|
||||
guild_overrides: vec![VoiceNoiseSuppressionGuildOverride {
|
||||
guild_id: "1600000000000000001".to_owned(),
|
||||
backend: NoiseSuppressionBackend::Rnnoise,
|
||||
}],
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
assert!(markup.contains("1 of 1000 stored"));
|
||||
assert!(markup.contains("2 of 1000 stored"));
|
||||
assert!(markup.contains("1 of 200 stored"));
|
||||
assert!(!markup.contains("at the cap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_noise_suppression_section_flags_a_list_at_its_cap() {
|
||||
let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect(),
|
||||
..VoiceNoiseSuppressionConfigResponse::default()
|
||||
};
|
||||
let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression);
|
||||
assert!(markup.contains("1000 of 1000 stored"));
|
||||
assert!(markup.contains("at the cap"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,19 +18,33 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::voice_servers_forms::{create_server_form, edit_server_form};
|
||||
|
||||
pub struct VoiceServersPageParams<'a> {
|
||||
pub region_id: Option<&'a str>,
|
||||
pub region_name: Option<&'a str>,
|
||||
pub servers: Option<&'a [VoiceServer]>,
|
||||
pub connection_counts: &'a HashMap<String, i64>,
|
||||
pub error: Option<&'a str>,
|
||||
pub csrf_token: &'a str,
|
||||
}
|
||||
|
||||
pub fn voice_servers_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
region_id: Option<&str>,
|
||||
region_name: Option<&str>,
|
||||
servers: Option<&[VoiceServer]>,
|
||||
error: Option<&str>,
|
||||
csrf_token: &str,
|
||||
p: &VoiceServersPageParams<'_>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let VoiceServersPageParams {
|
||||
region_id,
|
||||
region_name,
|
||||
servers,
|
||||
connection_counts,
|
||||
error,
|
||||
csrf_token,
|
||||
} = *p;
|
||||
let options = LayoutOptions {
|
||||
csrf_token,
|
||||
inspected_voice_region_id: region_id,
|
||||
@@ -67,7 +81,7 @@ pub fn voice_servers_page(
|
||||
html! {},
|
||||
))
|
||||
@if let Some(servers) = servers {
|
||||
(servers_list(config, rid, servers, csrf_token))
|
||||
(servers_list(config, rid, servers, connection_counts, csrf_token))
|
||||
}
|
||||
div id="create" class="mt-8" {
|
||||
(create_server_form(config, rid, csrf_token))
|
||||
@@ -109,6 +123,7 @@ fn servers_list(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
servers: &[VoiceServer],
|
||||
connection_counts: &HashMap<String, i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
if servers.is_empty() {
|
||||
@@ -121,7 +136,7 @@ fn servers_list(
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
@for server in servers {
|
||||
(server_card(config, region_id, server, csrf_token))
|
||||
(server_card(config, region_id, server, connection_counts.get(&server.server_id).copied(), csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +146,7 @@ fn server_card(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
server: &VoiceServer,
|
||||
connection_count: Option<i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
@@ -145,6 +161,15 @@ fn server_card(
|
||||
let lng_str = server
|
||||
.longitude
|
||||
.map_or_else(|| "Region default".to_string(), |v| v.to_string());
|
||||
let soft_limit_str = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(|| "No limit".to_string(), |v| v.to_string());
|
||||
let connections_str =
|
||||
connection_count.map_or_else(|| "Unavailable".to_string(), |v| v.to_string());
|
||||
let at_soft_limit = matches!(
|
||||
(server.soft_connection_limit, connection_count),
|
||||
(Some(limit), Some(count)) if limit > 0 && count >= limit
|
||||
);
|
||||
|
||||
card(html! {
|
||||
div class="mb-4 flex flex-col gap-1" {
|
||||
@@ -155,6 +180,9 @@ fn server_card(
|
||||
} @else {
|
||||
(badge("INACTIVE", BadgeVariant::Default))
|
||||
}
|
||||
@if at_soft_limit {
|
||||
(badge("AT SOFT LIMIT", BadgeVariant::Warning))
|
||||
}
|
||||
(voice_status_badges(vip_only, has_features, has_guild_ids))
|
||||
}
|
||||
p class="text-sm text-neutral-500" { (endpoint) }
|
||||
@@ -164,6 +192,8 @@ fn server_card(
|
||||
(data_field_text("Status", if is_active { "Active" } else { "Inactive" }))
|
||||
(data_field_text("Latitude", &lat_str))
|
||||
(data_field_text("Longitude", &lng_str))
|
||||
(data_field_text("Soft connection limit", &soft_limit_str))
|
||||
(data_field_text("Live connections", &connections_str))
|
||||
}
|
||||
(voice_features_list(&server.required_guild_features))
|
||||
(voice_guild_ids_list(&server.allowed_guild_ids))
|
||||
|
||||
@@ -26,6 +26,9 @@ pub fn edit_server_form(
|
||||
let lat_val = server.latitude.map_or_else(String::new, |v| v.to_string());
|
||||
let lng_val = server.longitude.map_or_else(String::new, |v| v.to_string());
|
||||
let is_active = server.is_active.unwrap_or(false);
|
||||
let soft_limit_val = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(String::new, |v| v.to_string());
|
||||
let vip_only = server.vip_only.unwrap_or(false);
|
||||
let features_csv = server.required_guild_features.join(", ");
|
||||
let guild_ids_csv = server.allowed_guild_ids.join(", ");
|
||||
@@ -57,6 +60,15 @@ pub fn edit_server_form(
|
||||
"Optional per-server coordinate override",
|
||||
))
|
||||
}
|
||||
(form_field_with_helper(
|
||||
"Soft Connection Limit",
|
||||
&format!("{id_prefix}-soft-connection-limit"),
|
||||
"soft_connection_limit",
|
||||
"number",
|
||||
&soft_limit_val,
|
||||
"Leave empty for no limit",
|
||||
"Placement prefers another server once this server holds this many connections",
|
||||
))
|
||||
(form_field_with_helper(
|
||||
"API Key",
|
||||
&format!("{id_prefix}-api-key"),
|
||||
@@ -105,6 +117,15 @@ pub fn create_server_form(config: &AdminConfig, region_id: &str, csrf_token: &st
|
||||
(form_field_with_id("API Secret", "new-server-api-secret", "api_secret", "password", "", "LiveKit API secret", true))
|
||||
(form_field_with_id("Latitude (optional)", "new-server-latitude", "latitude", "number", "", "40.7128", false))
|
||||
(form_field_with_id("Longitude (optional)", "new-server-longitude", "longitude", "number", "", "-74.0060", false))
|
||||
(form_field_with_helper(
|
||||
"Soft Connection Limit (optional)",
|
||||
"new-server-soft-connection-limit",
|
||||
"soft_connection_limit",
|
||||
"number",
|
||||
"",
|
||||
"Leave empty for no limit",
|
||||
"Placement prefers another server once this server holds this many connections",
|
||||
))
|
||||
}
|
||||
div class="space-y-3" {
|
||||
(checkbox("is_active", "true", "Server is active", true, true))
|
||||
|
||||
@@ -430,6 +430,8 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
&[
|
||||
"/instance-config?action=update_gateway_rollout",
|
||||
"/instance-config?action=update_sso",
|
||||
"/instance-config?action=update_voice_noise_suppression",
|
||||
"/instance-config?action=update_experiment_delivery",
|
||||
][..],
|
||||
),
|
||||
];
|
||||
@@ -1081,6 +1083,32 @@ fn instance_config() -> Value {
|
||||
"max_concurrent_guild_starts": 16,
|
||||
"voice_e2ee_scope": "guild_feature_only"
|
||||
},
|
||||
"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"config_version": 0,
|
||||
"default_backend": "standard",
|
||||
"enabled_backends": [
|
||||
"none",
|
||||
"standard",
|
||||
"gate",
|
||||
"speex",
|
||||
"rnnoise",
|
||||
"gtcrn",
|
||||
"deep_filter"
|
||||
],
|
||||
"allow_user_override": true,
|
||||
"rollout_basis_points": 0,
|
||||
"rollout_salt": "voice-ns-v1",
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"experiment_delivery": {
|
||||
"poll_interval_seconds": 300,
|
||||
"poll_jitter_percent": 15
|
||||
},
|
||||
"registration": registration_config(),
|
||||
"self_hosted": false
|
||||
})
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::State,
|
||||
http::{Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "voice-restriction-writes-test-secret";
|
||||
const REGION_ID: &str = "europe-north";
|
||||
const SERVER_ID: &str = "europe-north-server-1";
|
||||
|
||||
type CapturedBodies = Arc<Mutex<Vec<(String, Value)>>>;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_the_restriction_fields_reaches_the_api_as_empty_lists() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-servers?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}®ion_id={REGION_ID}&server_id={SERVER_ID}\
|
||||
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=true\
|
||||
&required_guild_features=&allowed_guild_ids=&soft_connection_limit="
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(
|
||||
&app,
|
||||
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
|
||||
);
|
||||
assert_eq!(body["required_guild_features"], json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], json!([]));
|
||||
assert_eq!(body["soft_connection_limit"], Value::Null);
|
||||
assert_eq!(body["vip_only"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activating_a_server_leaves_the_restriction_fields_untouched() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-servers?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}®ion_id={REGION_ID}&server_id={SERVER_ID}\
|
||||
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=false&vip_only=true"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(
|
||||
&app,
|
||||
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
|
||||
);
|
||||
let object = body.as_object().expect("object body");
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
assert_eq!(body["is_active"], json!(false));
|
||||
assert_eq!(body["vip_only"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_the_region_restriction_fields_reaches_the_api_as_empty_lists() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-regions?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}&id={REGION_ID}&name=Northern%20Europe&emoji=%F0%9F%87%B8%F0%9F%87%AA\
|
||||
&latitude=59.33&longitude=18.06&required_guild_features=&allowed_guild_ids="
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(&app, "PATCH /admin/voice/regions/europe-north");
|
||||
assert_eq!(body["required_guild_features"], json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], json!([]));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
session_cookie: String,
|
||||
captured: CapturedBodies,
|
||||
}
|
||||
|
||||
async fn setup() -> TestApp {
|
||||
let captured: CapturedBodies = Arc::new(Mutex::new(Vec::new()));
|
||||
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
|
||||
let router = build_router(test_config(api_endpoint));
|
||||
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
|
||||
TestApp {
|
||||
router,
|
||||
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
captured,
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_body(app: &TestApp, route: &str) -> Value {
|
||||
let captured = app.captured.lock().expect("captured bodies");
|
||||
captured
|
||||
.iter()
|
||||
.find(|(seen, _)| seen == route)
|
||||
.map(|(_, body)| body.clone())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"no request captured for {route}, saw {:?}",
|
||||
captured.iter().map(|(seen, _)| seen).collect::<Vec<_>>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn csrf_token(app: &TestApp) -> String {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/voice-regions")
|
||||
.header(header::COOKIE, &app.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
let pair = value.split(';').next()?;
|
||||
let token = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
})
|
||||
.expect("csrf_token cookie")
|
||||
}
|
||||
|
||||
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(
|
||||
header::COOKIE,
|
||||
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
|
||||
)
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
}
|
||||
|
||||
async fn spawn_mock_api(captured: CapturedBodies) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
Router::new().fallback(mock_api).with_state(captured),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(
|
||||
State(captured): State<CapturedBodies>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
let path = uri.path().to_owned();
|
||||
if method == Method::PATCH {
|
||||
let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap();
|
||||
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
captured
|
||||
.lock()
|
||||
.expect("captured bodies")
|
||||
.push((format!("PATCH {path}"), body));
|
||||
}
|
||||
match (method, path.as_str()) {
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::PATCH, "/admin/voice/regions/europe-north") => {
|
||||
Json(json!({ "region": region() })).into_response()
|
||||
}
|
||||
(Method::PATCH, "/admin/voice/regions/europe-north/servers/europe-north-server-1") => {
|
||||
Json(json!({ "server": server() })).into_response()
|
||||
}
|
||||
(Method::GET, "/admin/voice/regions") => {
|
||||
Json(json!({ "regions": [region()] })).into_response()
|
||||
}
|
||||
_ => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn region() -> Value {
|
||||
json!({
|
||||
"id": REGION_ID,
|
||||
"name": "Northern Europe",
|
||||
"emoji": "flag",
|
||||
"latitude": 59.33,
|
||||
"longitude": 18.06,
|
||||
"is_default": true,
|
||||
"vip_only": false,
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"created_at": null,
|
||||
"updated_at": null
|
||||
})
|
||||
}
|
||||
|
||||
fn server() -> Value {
|
||||
json!({
|
||||
"region_id": REGION_ID,
|
||||
"server_id": SERVER_ID,
|
||||
"endpoint": "wss://voice.example.com",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"is_active": true,
|
||||
"soft_connection_limit": null,
|
||||
"vip_only": false,
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"created_at": null,
|
||||
"updated_at": null
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_user() -> Value {
|
||||
json!({
|
||||
"id": "1500000000000000000",
|
||||
"username": "AdminUser",
|
||||
"discriminator": 1,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"global_name": "AdminUser",
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"has_verified_phone": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint,
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -372,17 +372,29 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
|
||||
monthlyEur: master.integrations.stripe.prices.monthly_eur,
|
||||
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
|
||||
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
|
||||
monthlyInr: master.integrations.stripe.prices.monthly_inr,
|
||||
monthlyNok: master.integrations.stripe.prices.monthly_nok,
|
||||
monthlyPln: master.integrations.stripe.prices.monthly_pln,
|
||||
monthlySek: master.integrations.stripe.prices.monthly_sek,
|
||||
monthlyTry: master.integrations.stripe.prices.monthly_try,
|
||||
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
|
||||
yearlyEur: master.integrations.stripe.prices.yearly_eur,
|
||||
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
|
||||
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
|
||||
yearlyInr: master.integrations.stripe.prices.yearly_inr,
|
||||
yearlyNok: master.integrations.stripe.prices.yearly_nok,
|
||||
yearlyPln: master.integrations.stripe.prices.yearly_pln,
|
||||
yearlySek: master.integrations.stripe.prices.yearly_sek,
|
||||
yearlyTry: master.integrations.stripe.prices.yearly_try,
|
||||
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
|
||||
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
|
||||
gift1MonthSek: master.integrations.stripe.prices.gift_1_month_sek,
|
||||
gift1YearSek: master.integrations.stripe.prices.gift_1_year_sek,
|
||||
gift1MonthDkk: master.integrations.stripe.prices.gift_1_month_dkk,
|
||||
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
|
||||
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
|
||||
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
|
||||
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
|
||||
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
|
||||
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {VoiceNoiseSuppressionConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
|
||||
import type {Context} from 'hono';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
@@ -51,9 +53,19 @@ function omitUndefinedFields<T extends object>(value: T): Partial<T> {
|
||||
|
||||
async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
const instanceConfigRepository = getInstanceConfigRepository();
|
||||
const [ssoConfig, gatewayRollout, registrationConfig, registrationUrls, pendingRegistrations] = await Promise.all([
|
||||
const [
|
||||
ssoConfig,
|
||||
gatewayRollout,
|
||||
voiceNoiseSuppression,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
pendingRegistrations,
|
||||
] = await Promise.all([
|
||||
instanceConfigRepository.getSsoConfig(),
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
instanceConfigRepository.getPendingRegistrations(),
|
||||
@@ -83,6 +95,8 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
redirect_uri: deriveSsoRedirectUri(Config.endpoints.webApp),
|
||||
},
|
||||
gateway_rollout: gatewayRollout,
|
||||
voice_noise_suppression: voiceNoiseSuppression,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
urls: registrationUrls,
|
||||
@@ -214,6 +228,23 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
await instanceConfigRepository.setGatewayRolloutConfig(validated);
|
||||
await getGatewayRolloutConfigPublisher().publish(validated);
|
||||
}
|
||||
if (data.voice_noise_suppression) {
|
||||
const currentNoiseSuppression = await instanceConfigRepository.getVoiceNoiseSuppressionConfig();
|
||||
const validated = VoiceNoiseSuppressionConfigSchema.parse({
|
||||
...currentNoiseSuppression,
|
||||
...data.voice_noise_suppression,
|
||||
config_version: currentNoiseSuppression.config_version + 1,
|
||||
});
|
||||
await instanceConfigRepository.setVoiceNoiseSuppressionConfig(validated);
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const currentExperimentDelivery = await instanceConfigRepository.getExperimentDeliveryConfig();
|
||||
const validated = ExperimentDeliveryConfigSchema.parse({
|
||||
...currentExperimentDelivery,
|
||||
...data.experiment_delivery,
|
||||
});
|
||||
await instanceConfigRepository.setExperimentDeliveryConfig(validated);
|
||||
}
|
||||
if (data.sso) {
|
||||
const sso = data.sso;
|
||||
const current = await instanceConfigRepository.getSsoConfig({includeSecret: true});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {createGuildID, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {isSyntheticUserId} from '../../constants/Core';
|
||||
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '../../SearchFactory';
|
||||
@@ -129,12 +130,11 @@ export class AdminSearchService {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const query = data.query?.trim() || '';
|
||||
const isIdQuery = /^\d+$/.test(query);
|
||||
const directUserId = /^\d+$/.test(query) ? createUserID(BigInt(query)) : null;
|
||||
const canResolveDirectUser = directUserId !== null && !isSyntheticUserId(directUserId) && data.offset === 0;
|
||||
const [searchResult, directUser] = await Promise.all([
|
||||
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
|
||||
isIdQuery && data.offset === 0
|
||||
? userRepository.findUnique(createUserID(BigInt(query))).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
canResolveDirectUser ? userRepository.findUnique(directUserId).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
const {hits, total} = searchResult;
|
||||
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {isSyntheticUserId} from '../../constants/Core';
|
||||
import {Logger} from '../../Logger';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
|
||||
@@ -32,7 +33,7 @@ export class AdminUserLookupService {
|
||||
} else if (/^\d+$/.test(query)) {
|
||||
try {
|
||||
const userId = createUserID(BigInt(query));
|
||||
user = await userRepository.findUnique(userId);
|
||||
user = isSyntheticUserId(userId) ? null : await userRepository.findUnique(userId);
|
||||
} catch (error) {
|
||||
Logger.debug({query, error}, 'Failed to lookup user by numeric ID, invalid ID format');
|
||||
user = null;
|
||||
|
||||
@@ -235,6 +235,7 @@ export class AdminVoiceService {
|
||||
serverId: data.server_id,
|
||||
endpoint: data.endpoint,
|
||||
isActive: data.is_active ?? true,
|
||||
softConnectionLimit: data.soft_connection_limit ?? null,
|
||||
apiKey: data.api_key ?? null,
|
||||
apiSecret: data.api_secret ?? null,
|
||||
latitude: data.latitude ?? null,
|
||||
@@ -271,6 +272,7 @@ export class AdminVoiceService {
|
||||
if (data.latitude !== undefined) updates.latitude = data.latitude;
|
||||
if (data.longitude !== undefined) updates.longitude = data.longitude;
|
||||
if (data.is_active !== undefined) updates.isActive = data.is_active;
|
||||
if (data.soft_connection_limit !== undefined) updates.softConnectionLimit = data.soft_connection_limit;
|
||||
updates.restrictions = patchVoiceRestrictions(existing.restrictions, data);
|
||||
updates.updatedAt = new Date();
|
||||
await voiceRepository.upsertServer(updates);
|
||||
@@ -339,6 +341,7 @@ export class AdminVoiceService {
|
||||
latitude: server.latitude ?? null,
|
||||
longitude: server.longitude ?? null,
|
||||
is_active: server.isActive,
|
||||
soft_connection_limit: server.softConnectionLimit ?? null,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: allowedGuildIds,
|
||||
|
||||
@@ -21,6 +21,8 @@ interface UserListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
const SYNTHETIC_USER_IDS = ['0', '1'];
|
||||
|
||||
async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: string): Promise<void> {
|
||||
await createBuilder(harness, `${token}`)
|
||||
.get('/users/@me')
|
||||
@@ -170,5 +172,35 @@ describe('Admin user directory', () => {
|
||||
expect(result.users.map((user) => user.id)).toEqual([target.userId]);
|
||||
expect(result.users[0]?.email).toBeNull();
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('omits the synthetic account %s from the resolve selector', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users?resolve=${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('omits the synthetic account %s from the q selector', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users?q=${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users.map((user) => user.id)).not.toContain(userId);
|
||||
});
|
||||
});
|
||||
describe('GET /admin/users/:user_id', () => {
|
||||
test.each(SYNTHETIC_USER_IDS)('reports no user for the synthetic account %s', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,4 +266,161 @@ describe('VoiceAdminController', () => {
|
||||
expect(persisted?.apiKey).toBe(fixture.initialApiKey);
|
||||
expect(persisted?.apiSecret).toBe(fixture.initialApiSecret);
|
||||
});
|
||||
test('stores, keeps, and clears a voice server soft connection limit', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_LIST,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-soft-limit';
|
||||
const serverId = 'voice-server-soft-limit';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const created = await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-soft-limit.example.com/socket',
|
||||
api_key: 'soft-limit-api-key',
|
||||
api_secret: 'soft-limit-api-secret',
|
||||
soft_connection_limit: 250,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(created.server.soft_connection_limit).toBe(250);
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
|
||||
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({endpoint: 'https://voice-soft-limit-2.example.com/socket'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
|
||||
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({soft_connection_limit: null})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared.server.soft_connection_limit).toBeNull();
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBeNull();
|
||||
});
|
||||
test('clears voice server restriction lists when empty arrays are supplied', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-clear-restrictions';
|
||||
const serverId = 'voice-server-clear-restrictions';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-clear.example.com/socket',
|
||||
api_key: 'clear-api-key',
|
||||
api_secret: 'clear-api-secret',
|
||||
required_guild_features: ['VIP_VOICE'],
|
||||
allowed_guild_ids: [1234567890123456789n.toString()],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const stored = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(Array.from(stored?.restrictions.requiredGuildFeatures ?? [])).toEqual(['VIP_VOICE']);
|
||||
expect(stored?.restrictions.allowedGuildIds.size).toBe(1);
|
||||
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({required_guild_features: [], allowed_guild_ids: []})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared.server.required_guild_features).toEqual([]);
|
||||
expect(cleared.server.allowed_guild_ids).toEqual([]);
|
||||
const persisted = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(persisted?.restrictions.requiredGuildFeatures.size).toBe(0);
|
||||
expect(persisted?.restrictions.allowedGuildIds.size).toBe(0);
|
||||
});
|
||||
test('leaves voice server restriction lists unchanged when they are omitted', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-keep-restrictions';
|
||||
const serverId = 'voice-server-keep-restrictions';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-keep.example.com/socket',
|
||||
api_key: 'keep-api-key',
|
||||
api_secret: 'keep-api-secret',
|
||||
required_guild_features: ['VIP_VOICE'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({is_active: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const persisted = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(Array.from(persisted?.restrictions.requiredGuildFeatures ?? [])).toEqual(['VIP_VOICE']);
|
||||
expect(persisted?.isActive).toBe(false);
|
||||
});
|
||||
test('rejects a voice server soft connection limit below one', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_REGION_CREATE, AdminACLs.VOICE_SERVER_CREATE]);
|
||||
const regionId = 'voice-region-soft-limit-invalid';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: 'voice-server-soft-limit-invalid',
|
||||
endpoint: 'https://voice-soft-limit-invalid.example.com/socket',
|
||||
api_key: 'soft-limit-invalid-api-key',
|
||||
api_secret: 'soft-limit-invalid-api-secret',
|
||||
soft_connection_limit: 0,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {APIConfig} from '../config/APIConfig';
|
||||
import {ConnectionController} from '../connection/ConnectionController';
|
||||
import {DonationController} from '../donation/DonationController';
|
||||
import {DownloadController} from '../download/DownloadController';
|
||||
import {ExperimentController} from '../experiment/ExperimentController';
|
||||
import {FavoriteGifController} from '../favorite_gif/FavoriteGifController';
|
||||
import {FavoriteMemeController} from '../favorite_meme/FavoriteMemeController';
|
||||
import {GatewayController} from '../gateway/GatewayController';
|
||||
@@ -50,6 +51,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
|
||||
InstanceController(routes);
|
||||
OpenAPIController(routes);
|
||||
DownloadController(routes);
|
||||
ExperimentController(routes);
|
||||
FavoriteGifController(routes);
|
||||
FavoriteMemeController(routes);
|
||||
InviteController(routes);
|
||||
|
||||
@@ -52,7 +52,18 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
|
||||
);
|
||||
applyMiddlewareStack(routes, {
|
||||
requestId: {},
|
||||
cors: {origins: corsOrigins, exposedHeaders: [HttpHeaders.X_FLUXER_VERSION]},
|
||||
cors: {
|
||||
origins: corsOrigins,
|
||||
allowedHeaders: [
|
||||
HttpHeaders.CONTENT_TYPE,
|
||||
HttpHeaders.AUTHORIZATION,
|
||||
'X-Requested-With',
|
||||
'Accept-Language',
|
||||
HttpHeaders.X_REQUEST_ID,
|
||||
HttpHeaders.IF_NONE_MATCH,
|
||||
],
|
||||
exposedHeaders: [HttpHeaders.X_FLUXER_VERSION, HttpHeaders.ETAG],
|
||||
},
|
||||
skipLogger: true,
|
||||
skipErrorHandler: true,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES,
|
||||
Permissions,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
@@ -260,13 +260,17 @@ export class ChannelOperationsService {
|
||||
validateCapacity: requestedParentId !== null && requestedParentId !== (channel.parentId ?? null),
|
||||
});
|
||||
}
|
||||
let nextBitrate = channel.bitrate;
|
||||
if (data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
nextBitrate = data.bitrate === null ? null : clampVoiceChannelBitrate(data.bitrate, guild.features ?? []);
|
||||
}
|
||||
const updatedChannelData = {
|
||||
...channel.toRow(),
|
||||
name: channelName,
|
||||
topic: data.topic !== undefined ? data.topic : channel.topic,
|
||||
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
|
||||
parent_id: requestedParentId,
|
||||
bitrate: data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.bitrate : channel.bitrate,
|
||||
bitrate: nextBitrate,
|
||||
user_limit:
|
||||
data.user_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
||||
? data.user_limit
|
||||
|
||||
@@ -226,17 +226,29 @@ export interface APIConfig {
|
||||
monthlyUsd?: string;
|
||||
monthlyEur?: string;
|
||||
monthlyBrl?: string;
|
||||
monthlyDkk?: string;
|
||||
monthlyInr?: string;
|
||||
monthlyNok?: string;
|
||||
monthlyPln?: string;
|
||||
monthlySek?: string;
|
||||
monthlyTry?: string;
|
||||
yearlyUsd?: string;
|
||||
yearlyEur?: string;
|
||||
yearlyBrl?: string;
|
||||
yearlyDkk?: string;
|
||||
yearlyInr?: string;
|
||||
yearlyNok?: string;
|
||||
yearlyPln?: string;
|
||||
yearlySek?: string;
|
||||
yearlyTry?: string;
|
||||
gift1MonthUsd?: string;
|
||||
gift1MonthEur?: string;
|
||||
gift1MonthSek?: string;
|
||||
gift1YearSek?: string;
|
||||
gift1MonthDkk?: string;
|
||||
gift1YearDkk?: string;
|
||||
gift1MonthNok?: string;
|
||||
gift1YearNok?: string;
|
||||
gift1MonthBrl?: string;
|
||||
gift1MonthInr?: string;
|
||||
gift1MonthPln?: string;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID} from '../BrandedTypes';
|
||||
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
|
||||
export const SYSTEM_USER_ID = createUserID(0n);
|
||||
|
||||
export function isSyntheticUserId(userId: UserID): boolean {
|
||||
return userId === SYSTEM_USER_ID || userId === DELETED_USER_ID;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface VoiceServerRow {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
is_active: boolean | null;
|
||||
soft_connection_limit: number | null;
|
||||
vip_only: boolean | null;
|
||||
required_guild_features: Set<string> | null;
|
||||
allowed_guild_ids: Set<bigint> | null;
|
||||
@@ -56,6 +57,7 @@ export const VOICE_SERVER_COLUMNS = [
|
||||
'latitude',
|
||||
'longitude',
|
||||
'is_active',
|
||||
'soft_connection_limit',
|
||||
'vip_only',
|
||||
'required_guild_features',
|
||||
'allowed_guild_ids',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
StorageObjectRangeNotSatisfiableError,
|
||||
} from '../infrastructure/IStorageService';
|
||||
import {Logger} from '../Logger';
|
||||
import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
|
||||
import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {
|
||||
parseDesktopArtifactScope,
|
||||
parseDesktopReleaseDescriptor,
|
||||
@@ -58,6 +58,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
|
||||
const MAX_DESKTOP_RELEASE_CANDIDATES = 10;
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
|
||||
@@ -198,6 +199,14 @@ export type GitHubDesktopReleaseResolution =
|
||||
| {kind: 'awaiting_release'}
|
||||
| {kind: 'ready'; location: string};
|
||||
|
||||
type DesktopReleaseState = {kind: 'untracked'} | {kind: 'unpublished'} | {kind: 'published'; descriptorText: string};
|
||||
|
||||
type ListedDesktopVersion = {
|
||||
version: string;
|
||||
pub_date: Date;
|
||||
files: Map<DesktopFormat, {filename: string; sha256Key: string | null}>;
|
||||
};
|
||||
|
||||
export class DownloadService {
|
||||
constructor(private readonly storageService: IStorageService) {}
|
||||
|
||||
@@ -216,41 +225,28 @@ export class DownloadService {
|
||||
) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
|
||||
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
|
||||
if (descriptorText == null) {
|
||||
const release = await this.readDesktopReleaseState(scope.channel, manifest.version);
|
||||
if (release.kind === 'untracked') {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
|
||||
if (release.kind === 'unpublished') {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonRecord(release.descriptorText));
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.channel !== scope.channel ||
|
||||
descriptor.version !== manifest.version ||
|
||||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
|
||||
throw new Error(
|
||||
`Invalid GitHub desktop release descriptor: ${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`,
|
||||
);
|
||||
}
|
||||
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
|
||||
if (!releaseAsset) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
|
||||
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
|
||||
if (marker == null) {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(marker);
|
||||
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== descriptor.channel ||
|
||||
readiness.version !== descriptor.version ||
|
||||
readiness.release_tag !== descriptor.release_tag ||
|
||||
readiness.source_sha !== descriptor.source_sha ||
|
||||
readiness.descriptor_sha256 !== descriptorSha256
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
|
||||
@@ -269,18 +265,22 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const manifestKey = `${prefix}/manifest.json`;
|
||||
const releasability = new Map<string, boolean>();
|
||||
try {
|
||||
const manifest = await this.readJsonObjectFromStorage(manifestKey);
|
||||
if (!isDesktopManifest(manifest)) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
|
||||
) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const entry = manifest.files[params.format];
|
||||
if (!entry) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const filename = this.extractFilename(entry);
|
||||
if (filename.trim().length === 0) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const resolvedFilename = await this.resolveManifestFilename({
|
||||
channel: params.channel,
|
||||
@@ -291,7 +291,11 @@ export class DownloadService {
|
||||
test: params.test,
|
||||
});
|
||||
if (!resolvedFilename) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(resolvedFilename, params.channel, params.plat, params.arch);
|
||||
if (parsed && parsed.version !== manifest.version) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
return this.buildDesktopArtifactKey({
|
||||
channel: params.channel,
|
||||
@@ -302,7 +306,7 @@ export class DownloadService {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -320,10 +324,14 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const manifestKey = `${prefix}/manifest.json`;
|
||||
const releasability = new Map<string, boolean>();
|
||||
try {
|
||||
const manifest = await this.readJsonObjectFromStorage(manifestKey);
|
||||
if (!isDesktopManifest(manifest)) {
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
|
||||
) {
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
const result = await this.getLatestDesktopVersionFromManifest(params, manifest);
|
||||
if (result) {
|
||||
@@ -331,11 +339,11 @@ export class DownloadService {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
|
||||
async listDesktopVersions(params: {
|
||||
@@ -351,158 +359,17 @@ export class DownloadService {
|
||||
versions: Array<VersionInfo>;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const basePrefix = desktopArtifactPrefix(params);
|
||||
if (!basePrefix) {
|
||||
return {versions: [], hasMore: false};
|
||||
let listedVersions = await this.listDesktopVersionFiles(params);
|
||||
if (params.before) {
|
||||
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.before ?? '') > 0);
|
||||
}
|
||||
const prefix = `${basePrefix}/`;
|
||||
try {
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
if (objects.length === 0) {
|
||||
return {versions: [], hasMore: false};
|
||||
}
|
||||
const versionMap = new Map<
|
||||
string,
|
||||
{
|
||||
pub_date: Date;
|
||||
files: Map<
|
||||
DesktopFormat,
|
||||
{
|
||||
filename: string;
|
||||
sha256Key: string | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>();
|
||||
const sha256Files = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
if (obj.key.endsWith('.sha256')) {
|
||||
sha256Files.add(obj.key);
|
||||
}
|
||||
}
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const {version, format} = parsed;
|
||||
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
|
||||
if (!versionMap.has(version)) {
|
||||
versionMap.set(version, {
|
||||
pub_date: obj.lastModified ?? new Date(),
|
||||
files: new Map(),
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(version);
|
||||
if (entry) {
|
||||
if (!entry.files.has(format)) {
|
||||
entry.files.set(format, {filename, sha256Key});
|
||||
}
|
||||
if (obj.lastModified && obj.lastModified > entry.pub_date) {
|
||||
entry.pub_date = obj.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sortedVersions = Array.from(versionMap.keys()).sort(this.compareVersions);
|
||||
let filteredVersions = sortedVersions;
|
||||
if (params.before) {
|
||||
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.before ?? '') > 0);
|
||||
}
|
||||
if (params.after) {
|
||||
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.after ?? '') < 0);
|
||||
}
|
||||
const hasMore = filteredVersions.length > params.limit;
|
||||
const paginatedVersions = filteredVersions.slice(0, params.limit);
|
||||
const sha256Promises: Array<
|
||||
Promise<{
|
||||
key: string;
|
||||
hash: string | null;
|
||||
}>
|
||||
> = [];
|
||||
for (const version of paginatedVersions) {
|
||||
const entry = versionMap.get(version);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
for (const [, fileInfo] of entry.files) {
|
||||
if (fileInfo.sha256Key) {
|
||||
sha256Promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: fileInfo.sha256Key as string,
|
||||
});
|
||||
if (streamResult) {
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
|
||||
}
|
||||
} catch {
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
}
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sha256Results = await Promise.all(sha256Promises);
|
||||
const sha256Map = new Map<string, string | null>();
|
||||
for (const result of sha256Results) {
|
||||
sha256Map.set(result.key, result.hash);
|
||||
}
|
||||
const versions: Array<VersionInfo> = [];
|
||||
for (const version of paginatedVersions) {
|
||||
const entry = versionMap.get(version);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const files: Record<string, VersionFile> = {};
|
||||
for (const [format, fileInfo] of entry.files) {
|
||||
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
|
||||
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
|
||||
files[format] = {
|
||||
url: this.buildDesktopVersionUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
}),
|
||||
sha256: validSha256,
|
||||
checksum_url: validSha256
|
||||
? this.buildDesktopVersionChecksumUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
versions.push({
|
||||
version,
|
||||
pub_date: entry.pub_date.toISOString(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
return {versions, hasMore};
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return {versions: [], hasMore: false};
|
||||
}
|
||||
throw error;
|
||||
if (params.after) {
|
||||
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.after ?? '') < 0);
|
||||
}
|
||||
return {
|
||||
versions: await this.buildListedDesktopVersions(params, listedVersions.slice(0, params.limit)),
|
||||
hasMore: listedVersions.length > params.limit,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveVersionedDesktopKey(params: {
|
||||
@@ -558,10 +425,10 @@ export class DownloadService {
|
||||
}): Promise<DesktopChecksumFile | null> {
|
||||
const version = await this.getLatestDesktopVersion(params);
|
||||
const file = version?.files[params.format];
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
if (!version || !file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
const key = await this.resolveLatestDesktopKey(params);
|
||||
const key = await this.resolveVersionedDesktopKey({...params, version: version.version});
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
@@ -586,8 +453,16 @@ export class DownloadService {
|
||||
if (objectSha256) {
|
||||
return this.buildDesktopChecksumFile(key, filename, objectSha256);
|
||||
}
|
||||
const latest = await this.getLatestDesktopVersion(params);
|
||||
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
|
||||
const prefix = desktopArtifactPrefix(params);
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
const manifest = await this.readOptionalJsonObjectFromStorage(`${prefix}/manifest.json`);
|
||||
const versionInfo =
|
||||
isDesktopManifest(manifest) && manifest.version === params.version
|
||||
? await this.getLatestDesktopVersionFromManifest(params, manifest)
|
||||
: null;
|
||||
const file = versionInfo?.files[params.format];
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
@@ -768,6 +643,77 @@ export class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
private async readDesktopReleaseState(channel: DesktopChannel, version: string): Promise<DesktopReleaseState> {
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.json`;
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.ready.json`;
|
||||
const [descriptorText, markerText] = await Promise.all([
|
||||
this.readOptionalTextFromStorage(descriptorKey),
|
||||
this.readOptionalTextFromStorage(markerKey),
|
||||
]);
|
||||
if (descriptorText == null) {
|
||||
return {kind: 'untracked'};
|
||||
}
|
||||
if (markerText == null) {
|
||||
return {kind: 'unpublished'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(parseJsonRecord(markerText));
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== channel ||
|
||||
readiness.version !== version ||
|
||||
readiness.release_tag !== `fluxer-desktop-${channel}@${version}` ||
|
||||
readiness.descriptor_sha256 !== createHash('sha256').update(descriptorText).digest('hex')
|
||||
) {
|
||||
Logger.error({key: markerKey}, 'Invalid GitHub desktop release readiness marker');
|
||||
return {kind: 'unpublished'};
|
||||
}
|
||||
return {kind: 'published', descriptorText};
|
||||
}
|
||||
|
||||
private async isReleasableDesktopVersion(
|
||||
params: {channel: DesktopChannel; test?: boolean},
|
||||
version: string,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<boolean> {
|
||||
if (params.test || Config.instance.selfHosted) {
|
||||
return true;
|
||||
}
|
||||
const checked = releasability.get(version);
|
||||
if (checked !== undefined) {
|
||||
return checked;
|
||||
}
|
||||
let releasable = true;
|
||||
try {
|
||||
releasable = (await this.readDesktopReleaseState(params.channel, version)).kind !== 'unpublished';
|
||||
} catch (error) {
|
||||
Logger.error({error, channel: params.channel, version}, 'Failed to read desktop release readiness');
|
||||
}
|
||||
releasability.set(version, releasable);
|
||||
return releasable;
|
||||
}
|
||||
|
||||
private async findNewestReleasableDesktopVersion<T extends {version: string}>(
|
||||
params: {channel: DesktopChannel; test?: boolean},
|
||||
candidates: ReadonlyArray<T>,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<T | null> {
|
||||
const newestCandidates = candidates.slice(0, MAX_DESKTOP_RELEASE_CANDIDATES);
|
||||
for (const candidate of newestCandidates) {
|
||||
if (await this.isReleasableDesktopVersion(params, candidate.version, releasability)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const [newest] = newestCandidates;
|
||||
if (!newest) {
|
||||
return null;
|
||||
}
|
||||
Logger.error(
|
||||
{channel: params.channel, version: newest.version},
|
||||
'No recent desktop version has a published release',
|
||||
);
|
||||
return newest;
|
||||
}
|
||||
|
||||
private isValidSha256(value: string): boolean {
|
||||
return /^[a-f0-9]{64}$/u.test(value);
|
||||
}
|
||||
@@ -789,7 +735,13 @@ export class DownloadService {
|
||||
) {
|
||||
return manifestFilename;
|
||||
}
|
||||
return this.findLatestFilenameForRequestedArch(params);
|
||||
for (const entry of await this.listDesktopVersionFiles(params)) {
|
||||
const file = entry.files.get(params.format);
|
||||
if (file) {
|
||||
return file.filename;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
|
||||
@@ -826,44 +778,145 @@ export class DownloadService {
|
||||
return params.filename.toLowerCase().endsWith('.exe');
|
||||
}
|
||||
|
||||
private async findLatestFilenameForRequestedArch(params: LatestFilenameLookupParams): Promise<string | null> {
|
||||
private async listDesktopVersionFiles(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
test?: boolean;
|
||||
}): Promise<Array<ListedDesktopVersion>> {
|
||||
const basePrefix = desktopArtifactPrefix(params);
|
||||
if (!basePrefix) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
const prefix = `${basePrefix}/`;
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
if (objects.length === 0) {
|
||||
return null;
|
||||
try {
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
const versionMap = new Map<string, ListedDesktopVersion>();
|
||||
const sha256Files = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
if (obj.key.endsWith('.sha256')) {
|
||||
sha256Files.add(obj.key);
|
||||
}
|
||||
}
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const {version, format} = parsed;
|
||||
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
|
||||
if (!versionMap.has(version)) {
|
||||
versionMap.set(version, {
|
||||
version,
|
||||
pub_date: obj.lastModified ?? new Date(),
|
||||
files: new Map(),
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(version);
|
||||
if (entry) {
|
||||
if (!entry.files.has(format)) {
|
||||
entry.files.set(format, {filename, sha256Key});
|
||||
}
|
||||
if (obj.lastModified && obj.lastModified > entry.pub_date) {
|
||||
entry.pub_date = obj.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(versionMap.values()).sort((left, right) => this.compareVersions(left.version, right.version));
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let latestFilename: string | null = null;
|
||||
let latestVersion: string | null = null;
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
filename.includes('/') ||
|
||||
filename.endsWith('.sha256') ||
|
||||
filename.endsWith('.blockmap') ||
|
||||
filename.endsWith('.yml') ||
|
||||
filename === 'manifest.json' ||
|
||||
filename === 'RELEASES.json' ||
|
||||
filename === 'releases.json'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed || parsed.format !== params.format) {
|
||||
continue;
|
||||
}
|
||||
if (!latestVersion || this.compareVersions(parsed.version, latestVersion) < 0) {
|
||||
latestVersion = parsed.version;
|
||||
latestFilename = filename;
|
||||
}
|
||||
|
||||
private async buildListedDesktopVersions(
|
||||
params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
},
|
||||
listedVersions: ReadonlyArray<ListedDesktopVersion>,
|
||||
): Promise<Array<VersionInfo>> {
|
||||
const sha256Promises: Array<
|
||||
Promise<{
|
||||
key: string;
|
||||
hash: string | null;
|
||||
}>
|
||||
> = [];
|
||||
for (const entry of listedVersions) {
|
||||
for (const [, fileInfo] of entry.files) {
|
||||
if (fileInfo.sha256Key) {
|
||||
sha256Promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: fileInfo.sha256Key as string,
|
||||
});
|
||||
if (streamResult) {
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
|
||||
}
|
||||
} catch {
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
}
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return latestFilename;
|
||||
const sha256Results = await Promise.all(sha256Promises);
|
||||
const sha256Map = new Map<string, string | null>();
|
||||
for (const result of sha256Results) {
|
||||
sha256Map.set(result.key, result.hash);
|
||||
}
|
||||
const versions: Array<VersionInfo> = [];
|
||||
for (const entry of listedVersions) {
|
||||
const files: Record<string, VersionFile> = {};
|
||||
for (const [format, fileInfo] of entry.files) {
|
||||
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
|
||||
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
|
||||
files[format] = {
|
||||
url: this.buildDesktopVersionUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version: entry.version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
}),
|
||||
sha256: validSha256,
|
||||
checksum_url: validSha256
|
||||
? this.buildDesktopVersionChecksumUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version: entry.version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
versions.push({
|
||||
version: entry.version,
|
||||
pub_date: entry.pub_date.toISOString(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
@@ -1030,16 +1083,25 @@ export class DownloadService {
|
||||
return key;
|
||||
}
|
||||
|
||||
private async resolveLatestDesktopKeyFromObjects(params: LatestFilenameLookupParams): Promise<string | null> {
|
||||
const filename = await this.findLatestFilenameForRequestedArch(params);
|
||||
if (!filename) {
|
||||
private async resolveLatestDesktopKeyFromObjects(
|
||||
params: LatestFilenameLookupParams,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<string | null> {
|
||||
const listedVersions = await this.listDesktopVersionFiles(params);
|
||||
const latest = await this.findNewestReleasableDesktopVersion(
|
||||
params,
|
||||
listedVersions.filter((entry) => entry.files.has(params.format)),
|
||||
releasability,
|
||||
);
|
||||
const file = latest?.files.get(params.format);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
return this.buildDesktopArtifactKey({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
filename,
|
||||
filename: file.filename,
|
||||
test: params.test,
|
||||
});
|
||||
}
|
||||
@@ -1189,22 +1251,23 @@ export class DownloadService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getLatestDesktopVersionFromObjects(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
}): Promise<VersionInfo | null> {
|
||||
const {versions} = await this.listDesktopVersions({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
limit: 1,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
});
|
||||
return versions[0] ?? null;
|
||||
private async getLatestDesktopVersionFromObjects(
|
||||
params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
},
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<VersionInfo | null> {
|
||||
const listedVersions = await this.listDesktopVersionFiles(params);
|
||||
const latest = await this.findNewestReleasableDesktopVersion(params, listedVersions, releasability);
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
const [versionInfo] = await this.buildListedDesktopVersions(params, [latest]);
|
||||
return versionInfo ?? null;
|
||||
}
|
||||
|
||||
private async resolveDesktopFileSha256(params: {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Readable} from 'node:stream';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getConfig} from '../../Config';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
|
||||
const RELEASES_PREFIX = 'desktop/canary/github-releases';
|
||||
const SOURCE_SHA = 'b'.repeat(40);
|
||||
const V904 = '2026.904.135113';
|
||||
const V908 = '2026.908.173325';
|
||||
const V909 = '2026.909.202036';
|
||||
|
||||
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
|
||||
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
|
||||
|
||||
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
|
||||
['darwin', 'arm64', 4],
|
||||
['darwin', 'x64', 4],
|
||||
['linux', 'arm64', 4],
|
||||
['linux', 'x64', 4],
|
||||
['win32', 'arm64', 6],
|
||||
['win32', 'x64', 6],
|
||||
];
|
||||
|
||||
type StoredObjects = Map<string, string>;
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
|
||||
const prefix = options.prefix ?? PREFIX;
|
||||
const filename = appImageFilename(version);
|
||||
objects.set(`${prefix}/${filename}`, filename);
|
||||
if (options.checksum !== false) {
|
||||
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
|
||||
}
|
||||
objects.set(
|
||||
`${prefix}/manifest.json`,
|
||||
JSON.stringify({
|
||||
channel: 'canary',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
version,
|
||||
pub_date: '2026-09-08T18:06:00Z',
|
||||
files: {appimage: {filename, sha256: sha256Hex(filename)}},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
|
||||
const assets = routes.flatMap(([plat, arch, count]) =>
|
||||
Array.from({length: count}, (_, index) => {
|
||||
const filename =
|
||||
plat === 'linux' && arch === 'x64' && index === 0
|
||||
? appImageFilename(version)
|
||||
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
|
||||
return {
|
||||
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
|
||||
release_asset: filename,
|
||||
sha256: sha256Hex(filename),
|
||||
size: 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const descriptor = JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
assets,
|
||||
});
|
||||
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
|
||||
objects.set(
|
||||
`${RELEASES_PREFIX}/${version}.ready.json`,
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
descriptor_sha256: sha256Hex(descriptor),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function releaseBuild(objects: StoredObjects, version: string) {
|
||||
const descriptor = publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
publishMarker(objects, version, descriptor);
|
||||
}
|
||||
|
||||
function incidentObjects(): StoredObjects {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
return objects;
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
|
||||
const reads: Array<string> = [];
|
||||
const listings: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
onRead?.(params.key);
|
||||
const body = objects.get(params.key);
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) => {
|
||||
listings.push(params.prefix);
|
||||
return Array.from(objects.keys())
|
||||
.filter((key) => key.startsWith(params.prefix))
|
||||
.sort()
|
||||
.map((key) => ({key}));
|
||||
},
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads, listings};
|
||||
}
|
||||
|
||||
async function resolveLatest(service: DownloadService, test?: boolean) {
|
||||
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
|
||||
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
|
||||
return {version: metadata?.version, key, checksum: checksum?.body};
|
||||
}
|
||||
|
||||
function latestOf(version: string, prefix = PREFIX) {
|
||||
const filename = appImageFilename(version);
|
||||
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
|
||||
}
|
||||
|
||||
describe('desktop release readiness', () => {
|
||||
it('offers a published manifest version after reading only its release state', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
releaseBuild(objects, V909);
|
||||
const {service, reads, listings} = createService(objects);
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.ready.json`,
|
||||
]);
|
||||
expect(listings).toEqual([]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
});
|
||||
|
||||
it('falls back to the newest published version while the manifest version awaits its release', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
|
||||
const {service, reads, listings} = createService(incidentObjects());
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.ready.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.ready.json`,
|
||||
`${PREFIX}/${appImageFilename(V904)}.sha256`,
|
||||
]);
|
||||
expect(listings).toEqual([`${PREFIX}/`]);
|
||||
});
|
||||
|
||||
it('offers a manifest version that has no release descriptor', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
uploadBuild(objects, V904);
|
||||
uploadBuild(objects, V908);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, 'another descriptor');
|
||||
const {service} = createService(objects);
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const descriptor = publishDescriptor(
|
||||
objects,
|
||||
V908,
|
||||
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
|
||||
);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, descriptor);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
|
||||
'Invalid GitHub desktop release descriptor',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
|
||||
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
|
||||
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
|
||||
const {service} = createService(incidentObjects(), (key) => {
|
||||
if (key === failingKey) {
|
||||
throw new S3ServiceException({
|
||||
name: 'SlowDown',
|
||||
$fault: 'server',
|
||||
$metadata: {httpStatusCode: 503},
|
||||
message: 'Please reduce your request rate.',
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('still resolves the unpublished version through versioned routes', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {checksum: false});
|
||||
const {service} = createService(objects);
|
||||
const params = {...APPIMAGE_PARAMS, version: V908};
|
||||
const filename = appImageFilename(V908);
|
||||
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
|
||||
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
|
||||
sha256: sha256Hex(filename),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps offering the manifest version on self-hosted instances', async () => {
|
||||
const config = getConfig();
|
||||
const originalSelfHosted = config.instance.selfHosted;
|
||||
config.instance.selfHosted = true;
|
||||
try {
|
||||
const {service, reads} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
} finally {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps offering the newest test build', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
|
||||
const objects = incidentObjects();
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
publishMarker(objects, V909, descriptor);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
|
||||
kind: 'ready',
|
||||
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the newest version when ten unpublished versions hide a published one', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const newest = '2026.908.170009';
|
||||
for (let build = 0; build < 10; build++) {
|
||||
const version = `2026.908.${170000 + build}`;
|
||||
publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
|
||||
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
|
||||
});
|
||||
|
||||
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
|
||||
const objects = incidentObjects();
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
let manifestReads = 0;
|
||||
const {service} = createService(objects, (key) => {
|
||||
if (key !== `${PREFIX}/manifest.json`) {
|
||||
return;
|
||||
}
|
||||
manifestReads += 1;
|
||||
if (manifestReads === 2) {
|
||||
publishMarker(objects, V909, descriptor);
|
||||
}
|
||||
});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
|
||||
expect(checksum?.body).toBe(latestOf(V904).checksum);
|
||||
});
|
||||
|
||||
it('lists an unpublished version while latest skips it', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
|
||||
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const BASE_URL = 'https://api.example.test';
|
||||
const V1 = '2026.901.100000';
|
||||
const V2 = '2026.902.100000';
|
||||
const V3 = '2026.903.100000';
|
||||
const V4 = '2026.904.100000';
|
||||
const V5 = '2026.905.100000';
|
||||
|
||||
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
|
||||
|
||||
type StoredObject = {body?: string; lastModified?: Date};
|
||||
type StoredObjects = Map<string, StoredObject>;
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function debFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-amd64.deb`;
|
||||
}
|
||||
|
||||
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
|
||||
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
|
||||
if (options.sha256 !== undefined) {
|
||||
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
|
||||
}
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects) {
|
||||
const reads: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
const object = objects.get(params.key);
|
||||
if (object?.body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(object.body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) =>
|
||||
Array.from(objects.entries())
|
||||
.filter(([key]) => key.startsWith(params.prefix))
|
||||
.sort(([left], [right]) => (left < right ? -1 : 1))
|
||||
.map(([key, object]) => ({key, lastModified: object.lastModified})),
|
||||
getObjectMetadata: async () => null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads};
|
||||
}
|
||||
|
||||
function versionNumbers(versions: Array<{version: string}>): Array<string> {
|
||||
return versions.map((entry) => entry.version);
|
||||
}
|
||||
|
||||
describe('desktop version listing', () => {
|
||||
it('lists versions newest first with the files of each version', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
|
||||
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
|
||||
expect(listed.hasMore).toBe(false);
|
||||
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
|
||||
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
|
||||
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
|
||||
});
|
||||
|
||||
it('excludes names that are not artefacts for the requested coordinate', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
|
||||
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
|
||||
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
|
||||
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V3]);
|
||||
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
|
||||
});
|
||||
|
||||
it('pages with limit, before and after and reports whether more remain', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version));
|
||||
}
|
||||
const {service} = createService(objects);
|
||||
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
|
||||
expect(firstPage.hasMore).toBe(true);
|
||||
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
|
||||
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
|
||||
expect(olderPage.hasMore).toBe(false);
|
||||
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
|
||||
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
|
||||
expect(newerPage.hasMore).toBe(false);
|
||||
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
|
||||
expect(versionNumbers(between.versions)).toEqual([V4]);
|
||||
expect(between.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
|
||||
const hash = 'b'.repeat(64);
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: hash});
|
||||
addArtifact(objects, appImageFilename(V2));
|
||||
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(listed.versions[0].files.appimage).toEqual({
|
||||
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
|
||||
sha256: hash,
|
||||
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
|
||||
});
|
||||
expect(listed.versions[1].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a checksum only for the versions it returns', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveVoiceNoiseSuppressionAssignment} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {LoginRequired} from '../middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoApp} from '../types/HonoEnv';
|
||||
import {entityTagMatches} from '../utils/EntityTag';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
app.get(
|
||||
'/experiments',
|
||||
RateLimitMiddleware(RateLimitConfigs.DEFAULT),
|
||||
LoginRequired,
|
||||
OpenAPI({
|
||||
operationId: 'get_experiments',
|
||||
summary: 'Get the experiment assignments',
|
||||
description:
|
||||
'Returns the polling cadence and every experiment assignment resolved for the authenticated user from the instance configuration. Clients revalidate with If-None-Match and receive 304 when nothing changed.',
|
||||
responseSchema: ExperimentAssignmentsResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken', 'botToken'],
|
||||
tags: ['Experiments'],
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, voiceConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
]);
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, ctx.get('user').id.toString()),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
ctx.header(HttpHeaders.ETAG, etag);
|
||||
ctx.header(HttpHeaders.CACHE_CONTROL, 'private, no-cache');
|
||||
ctx.header('Vary', 'Authorization');
|
||||
const ifNoneMatch = ctx.req.header(HttpHeaders.IF_NONE_MATCH);
|
||||
if (ifNoneMatch !== undefined && entityTagMatches(ifNoneMatch, etag)) {
|
||||
return ctx.body(null, 304);
|
||||
}
|
||||
return ctx.json(body);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
type ExperimentAssignmentsResponse,
|
||||
type ExperimentDeliveryConfigResponse,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
|
||||
const NOT_MODIFIED = 304;
|
||||
const ENDPOINT = '/experiments';
|
||||
|
||||
describe('GET /experiments', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
it('rejects an unauthenticated caller', async () => {
|
||||
await createBuilderWithoutAuth(harness).get(ENDPOINT).expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
it('returns the default delivery cadence and the inert assignment while the feature is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body).toEqual({
|
||||
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the inert assignment while the stored config is disabled but populated', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: false,
|
||||
config_version: 9,
|
||||
rollout_basis_points: 10000,
|
||||
included_user_ids: [account.userId],
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.assignments.voice_noise_suppression).toEqual({
|
||||
...INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
config_version: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('populates the voice assignment key even when the rollout is disabled', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'voice_noise_suppression')).toBe(true);
|
||||
expect(readVoiceNoiseSuppressionAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('serves the delivery cadence from the delivery config and not from the voice config', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
poll_interval_seconds: 7200,
|
||||
poll_jitter_percent: 45,
|
||||
});
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(body.poll_interval_seconds).toBe(7200);
|
||||
expect(body.poll_jitter_percent).toBe(45);
|
||||
expect(body.assignments.voice_noise_suppression).toMatchObject({enabled: true, config_version: 3});
|
||||
expect(body.assignments.voice_noise_suppression).not.toHaveProperty('poll_interval_seconds');
|
||||
expect(body.assignments.voice_noise_suppression).not.toHaveProperty('poll_jitter_percent');
|
||||
});
|
||||
|
||||
it('echoes the config version and resolves the caller through the allowlist', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
default_backend: 'rnnoise',
|
||||
rollout_basis_points: 0,
|
||||
included_user_ids: [targeted.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
user_targeted: true,
|
||||
backend: 'rnnoise',
|
||||
source: 'user_rule',
|
||||
});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 14,
|
||||
user_targeted: false,
|
||||
backend: null,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const etag = first.response.headers.get('etag');
|
||||
expect(etag).toMatch(/^"[0-9a-f]{64}"$/);
|
||||
expect(first.response.headers.get('cache-control')).toBe('private, no-cache');
|
||||
expect(first.response.headers.get('vary')).toBe('Authorization');
|
||||
|
||||
const revalidated = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', etag as string)
|
||||
.expect(NOT_MODIFIED)
|
||||
.executeWithResponse();
|
||||
expect(revalidated.response.status).toBe(NOT_MODIFIED);
|
||||
expect(revalidated.json).toBeUndefined();
|
||||
expect(revalidated.response.headers.get('etag')).toBe(etag);
|
||||
});
|
||||
|
||||
it('lets a cross-origin client send If-None-Match and read the etag back', async () => {
|
||||
const preflight = await harness.requestJson({path: ENDPOINT, method: 'OPTIONS'});
|
||||
|
||||
expect(preflight.headers.get('access-control-allow-headers')).toContain('If-None-Match');
|
||||
expect(preflight.headers.get('access-control-expose-headers')).toContain('ETag');
|
||||
});
|
||||
|
||||
it('serves a fresh body once the voice config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
rollout_basis_points: 10000,
|
||||
});
|
||||
|
||||
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', staleEtag)
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
user_targeted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('serves a fresh body once the delivery config changes', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const first = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
const staleEtag = first.response.headers.get('etag') as string;
|
||||
|
||||
await getInstanceConfigRepository().setExperimentDeliveryConfig({
|
||||
poll_interval_seconds: 1800,
|
||||
poll_jitter_percent: 5,
|
||||
});
|
||||
|
||||
const refreshed = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.header('If-None-Match', staleEtag)
|
||||
.executeWithResponse();
|
||||
expect(refreshed.response.status).toBe(HTTP_STATUS.OK);
|
||||
expect(refreshed.response.headers.get('etag')).not.toBe(staleEtag);
|
||||
expect(refreshed.json?.poll_interval_seconds).toBe(1800);
|
||||
expect(refreshed.json?.poll_jitter_percent).toBe(5);
|
||||
});
|
||||
|
||||
it('bumps the config version on every admin update without the client sending one', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: true, rollout_basis_points: 10000}})
|
||||
.execute();
|
||||
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {suppression_strength: 42}})
|
||||
.execute();
|
||||
expect(afterSecond.voice_noise_suppression).toMatchObject({config_version: 2, enabled: true});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.voice_noise_suppression).toMatchObject({
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
suppression_strength: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the config version alone for an admin update that sets no field', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: true}})
|
||||
.execute();
|
||||
expect(afterFirst.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterEmpty = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {}})
|
||||
.execute();
|
||||
expect(afterEmpty.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterUndefined = await createBuilder<{voice_noise_suppression: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
.patch('/admin/instance/config')
|
||||
.body({voice_noise_suppression: {enabled: undefined}})
|
||||
.execute();
|
||||
expect(afterUndefined.voice_noise_suppression).toMatchObject({config_version: 1, enabled: true});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence an admin set through the instance config', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const updated = await createBuilder<{experiment_delivery: ExperimentDeliveryConfigResponse}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({experiment_delivery: {poll_interval_seconds: 3600}})
|
||||
.execute();
|
||||
expect(updated.experiment_delivery).toEqual({
|
||||
poll_interval_seconds: 3600,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
});
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.poll_interval_seconds).toBe(3600);
|
||||
expect(body.poll_jitter_percent).toBe(DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentWarningLevel, GuildFeatures, resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
MAX_CHANNELS_PER_CATEGORY,
|
||||
MAX_GUILD_CHANNELS,
|
||||
@@ -119,12 +119,16 @@ export class ChannelOperationsService {
|
||||
);
|
||||
}
|
||||
let channelName = params.data.name;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
let guildFeatures: Array<string> | null = null;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_VOICE) {
|
||||
const guildData = await this.gatewayService.getGuildData({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
});
|
||||
const hasFlexibleNamesEnabled = guildData.features.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
guildFeatures = guildData.features;
|
||||
}
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
const hasFlexibleNamesEnabled = (guildFeatures ?? []).includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
if (!hasFlexibleNamesEnabled) {
|
||||
channelName = ChannelNameType.parse(channelName);
|
||||
}
|
||||
@@ -156,7 +160,10 @@ export class ChannelOperationsService {
|
||||
content_warning_level: requestedContentWarningLevel,
|
||||
content_warning_text: requestedContentWarningText,
|
||||
rate_limit_per_user: params.data.rate_limit_per_user ?? 0,
|
||||
bitrate: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.bitrate ?? 64000) : null,
|
||||
bitrate:
|
||||
params.data.type === ChannelTypes.GUILD_VOICE
|
||||
? resolveVoiceChannelBitrate(params.data.bitrate, guildFeatures)
|
||||
: null,
|
||||
user_limit: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.user_limit ?? 0) : null,
|
||||
voice_connection_limit:
|
||||
params.data.type === ChannelTypes.GUILD_VOICE
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
GuildSplashCardAlignment,
|
||||
GuildVerificationLevel,
|
||||
JoinSourceTypes,
|
||||
resolveVoiceChannelBitrate,
|
||||
SystemChannelFlags,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
MAX_GUILD_CHANNELS,
|
||||
MAX_GUILD_ROLES,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -934,7 +936,14 @@ export class GuildOperationsService {
|
||||
addChannel(textCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_TEXT_CATEGORY_NAME, null, 0);
|
||||
addChannel(voiceCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_VOICE_CATEGORY_NAME, null, 1);
|
||||
addChannel(generalChannelId, ChannelTypes.GUILD_TEXT, DEFAULT_TEXT_CHANNEL_NAME, textCategoryId, 0);
|
||||
addChannel(generalVoiceId, ChannelTypes.GUILD_VOICE, DEFAULT_VOICE_CHANNEL_NAME, voiceCategoryId, 0, 64000);
|
||||
addChannel(
|
||||
generalVoiceId,
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
DEFAULT_VOICE_CHANNEL_NAME,
|
||||
voiceCategoryId,
|
||||
0,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildRoles.insert({
|
||||
guild_id: guildId,
|
||||
@@ -1105,7 +1114,7 @@ export class GuildOperationsService {
|
||||
content_warning_level: null,
|
||||
content_warning_text: null,
|
||||
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
|
||||
bitrate: isVoice ? (channel.bitrate ?? 64000) : null,
|
||||
bitrate: isVoice ? resolveVoiceChannelBitrate(channel.bitrate, null) : null,
|
||||
user_limit: isVoice ? (channel.user_limit ?? 0) : null,
|
||||
voice_connection_limit: isVoice
|
||||
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
@@ -25,6 +26,14 @@ describe('Guild Channel Management', () => {
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
async function addGuildFeaturesForTesting(guildId: string, features: Array<string>): Promise<void> {
|
||||
await createBuilder<{
|
||||
success: boolean;
|
||||
}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: features})
|
||||
.execute();
|
||||
}
|
||||
describe('Channel Name Updates', () => {
|
||||
test('should normalize channel name with spaces to hyphens', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -582,7 +591,7 @@ describe('Guild Channel Management', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('should reject bitrate above maximum (320000)', async () => {
|
||||
test('should reject bitrate above maximum (384000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
@@ -594,7 +603,7 @@ describe('Guild Channel Management', () => {
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320001})
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384001})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
@@ -678,7 +687,7 @@ describe('Guild Channel Management', () => {
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(8000);
|
||||
});
|
||||
test('should accept maximum bitrate (320000)', async () => {
|
||||
test('should clamp bitrate to 96000 without an audio bitrate feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
@@ -690,9 +699,74 @@ describe('Guild Channel Management', () => {
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320000})
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(320000);
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
test('should clamp bitrate to the feature the guild holds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_256_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(256000);
|
||||
});
|
||||
test('should accept maximum bitrate (384000) with the 384 kbps feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_384_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(384000);
|
||||
});
|
||||
test('should store the default bitrate on a new voice channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
expect(voiceChannel.bitrate).toBe(64000);
|
||||
});
|
||||
test('should clamp bitrate on create without an audio bitrate feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
test('should keep bitrate on create with the 128 kbps feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 128000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(128000);
|
||||
});
|
||||
test('should accept maximum user limit (99)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -734,6 +808,7 @@ describe('Guild Channel Management', () => {
|
||||
test('should update both bitrate and user limit together', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
type ExperimentDeliveryConfig,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {setCassandraQueryExecutorForTesting} from '../database/CassandraQueryExecution';
|
||||
import type {PreparedQuery} from '../database/CassandraTypes';
|
||||
@@ -11,6 +19,9 @@ import {
|
||||
type InstanceRegistrationConfig,
|
||||
} from './InstanceConfigRepository';
|
||||
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
|
||||
class CountingInMemoryCassandraQueryExecutor extends InMemoryCassandraQueryExecutor {
|
||||
instanceConfigSelects = 0;
|
||||
|
||||
@@ -147,6 +158,148 @@ describe('InstanceConfigRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default voice noise suppression config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'unparseable text', stored: 'not-json'},
|
||||
{name: 'a json array', stored: '[]'},
|
||||
{name: 'out-of-range values', stored: '{"rollout_basis_points":99999}'},
|
||||
{name: 'an unknown backend', stored: '{"default_backend":"magic"}'},
|
||||
])('falls back to the default voice noise suppression config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored voice noise suppression config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: VoiceNoiseSuppressionConfig = {
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 3,
|
||||
default_backend: 'rnnoise',
|
||||
enabled_backends: ['none', 'standard', 'rnnoise'],
|
||||
allow_user_override: false,
|
||||
rollout_basis_points: 2500,
|
||||
rollout_salt: 'voice-ns-v2',
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
guild_overrides: [{guild_id: '2400000000000000001', backend: 'rnnoise'}],
|
||||
stereo_enabled: true,
|
||||
suppression_strength: 55,
|
||||
};
|
||||
await repository.setVoiceNoiseSuppressionConfig(config);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills newly added voice noise suppression fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(
|
||||
VOICE_NOISE_SUPPRESSION_CONFIG_KEY,
|
||||
JSON.stringify({enabled: true, config_version: 2, rollout_basis_points: 1000}),
|
||||
);
|
||||
|
||||
await expect(repository.getVoiceNoiseSuppressionConfig()).resolves.toEqual({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 2,
|
||||
rollout_basis_points: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the default experiment delivery config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await expect(repository.getExperimentDeliveryConfig()).resolves.toEqual(DEFAULT_EXPERIMENT_DELIVERY_CONFIG);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'unparseable text', stored: 'not-json'},
|
||||
{name: 'a json array', stored: '[]'},
|
||||
{name: 'an out-of-range poll interval', stored: '{"poll_interval_seconds":1}'},
|
||||
{name: 'an out-of-range jitter', stored: '{"poll_jitter_percent":99}'},
|
||||
{name: 'a non-numeric poll interval', stored: '{"poll_interval_seconds":"often"}'},
|
||||
])('falls back to the default experiment delivery config for $name', async ({stored}) => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getExperimentDeliveryConfig()).resolves.toEqual(DEFAULT_EXPERIMENT_DELIVERY_CONFIG);
|
||||
});
|
||||
|
||||
it('round-trips a stored experiment delivery config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
const config: ExperimentDeliveryConfig = {poll_interval_seconds: 900, poll_jitter_percent: 0};
|
||||
await repository.setExperimentDeliveryConfig(config);
|
||||
|
||||
await expect(repository.getExperimentDeliveryConfig()).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it('fills missing experiment delivery fields from the schema defaults', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const repository = createRepository(kvProvider);
|
||||
|
||||
await repository.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify({poll_interval_seconds: 3600}));
|
||||
|
||||
await expect(repository.getExperimentDeliveryConfig()).resolves.toEqual({
|
||||
...DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
poll_interval_seconds: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes a refresh so another repository observes the voice noise suppression config', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
const kvProvider = new MockKVProvider();
|
||||
const reader = createRepository(kvProvider);
|
||||
const writer = createRepository(kvProvider);
|
||||
|
||||
await expect(reader.getVoiceNoiseSuppressionConfig()).resolves.toEqual(DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG);
|
||||
|
||||
await writer.setVoiceNoiseSuppressionConfig({
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled: true,
|
||||
config_version: 1,
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect(await reader.getVoiceNoiseSuppressionConfig()).toMatchObject({enabled: true, config_version: 1});
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the registration URL id as the admin-visible registration code', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -6,6 +6,16 @@ import {
|
||||
type GatewayRolloutConfig,
|
||||
GatewayRolloutConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {
|
||||
DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
type VoiceNoiseSuppressionConfig,
|
||||
VoiceNoiseSuppressionConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
type ExperimentDeliveryConfig,
|
||||
ExperimentDeliveryConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {Config} from '../Config';
|
||||
import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '../config/APIConfig';
|
||||
@@ -21,6 +31,8 @@ import {getDefaultDateOfBirthCollection, setCachedDateOfBirthCollection} from '.
|
||||
import {normalizeSsoAllowedEmailDomains} from './SsoConfigValidation';
|
||||
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const VOICE_NOISE_SUPPRESSION_CONFIG_KEY = 'voice_noise_suppression_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const REGISTRATION_CONFIG_KEY = 'registration_config';
|
||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
|
||||
@@ -43,6 +55,20 @@ const DEFAULT_GATEWAY_ROLLOUT_CONFIG: GatewayRolloutConfig = {
|
||||
gateway_dispatch_relay_max_queue: 50000,
|
||||
voice_e2ee_scope: 'guild_feature_only',
|
||||
};
|
||||
|
||||
function cloneDefaultVoiceNoiseSuppressionConfig(): VoiceNoiseSuppressionConfig {
|
||||
return {
|
||||
...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG,
|
||||
enabled_backends: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.enabled_backends],
|
||||
included_user_ids: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.included_user_ids],
|
||||
excluded_user_ids: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.excluded_user_ids],
|
||||
guild_overrides: [...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG.guild_overrides],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneDefaultExperimentDeliveryConfig(): ExperimentDeliveryConfig {
|
||||
return {...DEFAULT_EXPERIMENT_DELIVERY_CONFIG};
|
||||
}
|
||||
export type InstanceRegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export interface InstanceRegistrationConfig {
|
||||
mode: InstanceRegistrationMode;
|
||||
@@ -1050,6 +1076,48 @@ export class InstanceConfigRepository {
|
||||
await this.setConfig(GATEWAY_ROLLOUT_CONFIG_KEY, JSON.stringify(config));
|
||||
}
|
||||
|
||||
async getVoiceNoiseSuppressionConfig(): Promise<VoiceNoiseSuppressionConfig> {
|
||||
const raw = await this.getConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return cloneDefaultVoiceNoiseSuppressionConfig();
|
||||
}
|
||||
const parsed = parseJsonRecord(raw);
|
||||
if (!parsed) {
|
||||
return cloneDefaultVoiceNoiseSuppressionConfig();
|
||||
}
|
||||
const result = VoiceNoiseSuppressionConfigSchema.safeParse({...DEFAULT_VOICE_NOISE_SUPPRESSION_CONFIG, ...parsed});
|
||||
if (!result.success) {
|
||||
Logger.error({error: result.error}, 'Invalid voice noise suppression config');
|
||||
return cloneDefaultVoiceNoiseSuppressionConfig();
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async setVoiceNoiseSuppressionConfig(config: VoiceNoiseSuppressionConfig): Promise<void> {
|
||||
await this.setConfig(VOICE_NOISE_SUPPRESSION_CONFIG_KEY, JSON.stringify(config));
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return cloneDefaultExperimentDeliveryConfig();
|
||||
}
|
||||
const parsed = parseJsonRecord(raw);
|
||||
if (!parsed) {
|
||||
return cloneDefaultExperimentDeliveryConfig();
|
||||
}
|
||||
const result = ExperimentDeliveryConfigSchema.safeParse({...DEFAULT_EXPERIMENT_DELIVERY_CONFIG, ...parsed});
|
||||
if (!result.success) {
|
||||
Logger.error({error: result.error}, 'Invalid experiment delivery config');
|
||||
return cloneDefaultExperimentDeliveryConfig();
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async setExperimentDeliveryConfig(config: ExperimentDeliveryConfig): Promise<void> {
|
||||
await this.setConfig(EXPERIMENT_DELIVERY_CONFIG_KEY, JSON.stringify(config));
|
||||
}
|
||||
|
||||
async hasLimitConfig(): Promise<boolean> {
|
||||
const raw = await this.getConfig('limit_config');
|
||||
return raw !== null;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {setInjectedSearchProvider} from '../SearchFactory';
|
||||
import type {ISearchProvider} from '../search/ISearchProvider';
|
||||
import {VoiceAvailabilityService} from '../voice/VoiceAvailabilityService';
|
||||
import {VoiceRepository} from '../voice/VoiceRepository';
|
||||
import {VoiceServerLoadTracker} from '../voice/VoiceServerLoad';
|
||||
import {VoiceTopology} from '../voice/VoiceTopology';
|
||||
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
|
||||
|
||||
@@ -289,7 +290,10 @@ export async function ensureVoiceResourcesInitialized(): Promise<void> {
|
||||
const topology = new VoiceTopology(voiceRepository, voiceConfigSubscriber);
|
||||
await topology.initialize();
|
||||
voiceTopology = topology;
|
||||
voiceAvailabilityService = new VoiceAvailabilityService(topology);
|
||||
voiceAvailabilityService = new VoiceAvailabilityService(
|
||||
topology,
|
||||
new VoiceServerLoadTracker({gatewayService: getGatewayService()}),
|
||||
);
|
||||
liveKitServiceInstance = new LiveKitService(topology);
|
||||
voiceRoomStoreInstance = new VoiceRoomStore(getKVClient());
|
||||
})().finally(() => {
|
||||
|
||||
@@ -6504,6 +6504,73 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/experiments": {
|
||||
"get": {
|
||||
"operationId": "get_experiments",
|
||||
"summary": "Get the experiment assignments",
|
||||
"tags": ["Experiments"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ExperimentAssignmentsResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Get the experiment assignments"}},
|
||||
"description": "Returns the polling cadence and every experiment assignment resolved for the authenticated user from the instance configuration. Clients revalidate with If-None-Match and receive 304 when nothing changed.",
|
||||
"security": [{"sessionToken": []}, {"botToken": []}]
|
||||
}
|
||||
},
|
||||
"/gateway/bot": {
|
||||
"get": {
|
||||
"operationId": "get_gateway_bot",
|
||||
@@ -27348,8 +27415,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27442,8 +27509,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27526,8 +27593,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27610,8 +27677,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -28041,6 +28108,9 @@
|
||||
"x-enumNames": [
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
@@ -28071,6 +28141,9 @@
|
||||
"x-enumDescriptions": [
|
||||
"Guild can have an animated icon",
|
||||
"Guild can have an animated banner",
|
||||
"Guild can set a voice channel bitrate of up to 128 kbps",
|
||||
"Guild can set a voice channel bitrate of up to 256 kbps",
|
||||
"Guild can set a voice channel bitrate of up to 384 kbps",
|
||||
"Guild can have a banner",
|
||||
"Guild has the in-app one-click emoji clone shortcut disabled for non-members",
|
||||
"Guild has the in-app one-click sticker clone shortcut disabled for non-members",
|
||||
@@ -28098,7 +28171,7 @@
|
||||
"Guild has large guild overrides enabled",
|
||||
"Guild has increased member capacity enabled"
|
||||
],
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
},
|
||||
"ChannelPartialResponse": {
|
||||
"type": "object",
|
||||
@@ -29187,6 +29260,82 @@
|
||||
},
|
||||
"required": ["id", "guild_id", "name", "animated", "allow_cloning"]
|
||||
},
|
||||
"ExperimentAssignmentsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"poll_interval_seconds": {"type": "integer", "format": "int53"},
|
||||
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50, "format": "int32"},
|
||||
"assignments": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionAssignmentResponse"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["poll_interval_seconds", "poll_jitter_percent", "assignments"]
|
||||
},
|
||||
"VoiceNoiseSuppressionAssignmentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"config_version": {"type": "integer", "format": "int53"},
|
||||
"user_targeted": {"type": "boolean"},
|
||||
"backend": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"source": {
|
||||
"anyOf": [
|
||||
{"enum": ["user_rule", "canary"], "type": "string", "x-enumNames": ["user_rule", "canary"]},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"guild_overrides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guild_id": {"type": "string", "pattern": "^\\d{1,20}$"},
|
||||
"backend": {
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
|
||||
}
|
||||
},
|
||||
"required": ["guild_id", "backend"]
|
||||
}
|
||||
},
|
||||
"enabled_backends": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"enum": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["none", "standard", "gate", "speex", "rnnoise", "gtcrn", "deep_filter"]
|
||||
}
|
||||
},
|
||||
"allow_user_override": {"type": "boolean"},
|
||||
"stereo_enabled": {"type": "boolean"},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100, "format": "int32"}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"config_version",
|
||||
"user_targeted",
|
||||
"backend",
|
||||
"source",
|
||||
"guild_overrides",
|
||||
"enabled_backends",
|
||||
"allow_user_override",
|
||||
"stereo_enabled",
|
||||
"suppression_strength"
|
||||
]
|
||||
},
|
||||
"GatewayBotResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -30329,8 +30478,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30397,8 +30546,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30465,8 +30614,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30533,8 +30682,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -32064,9 +32213,9 @@
|
||||
"description": "The amount the user is actually charged, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of the charged amount"
|
||||
},
|
||||
"billing_cycle": {
|
||||
@@ -32345,9 +32494,9 @@
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -32418,9 +32567,9 @@
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -32662,15 +32811,15 @@
|
||||
"description": "Gift 1 year price amount in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency for the prices"
|
||||
},
|
||||
"gift_currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency for gift prices"
|
||||
}
|
||||
},
|
||||
@@ -32749,9 +32898,9 @@
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
@@ -32788,9 +32937,9 @@
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
@@ -37881,6 +38030,7 @@
|
||||
{"name": "Connections"},
|
||||
{"name": "Messages"},
|
||||
{"name": "Donations"},
|
||||
{"name": "Experiments"},
|
||||
{"name": "Geolocation"},
|
||||
{"name": "GIFs"},
|
||||
{"name": "GIFs (Deprecated)"},
|
||||
|
||||
@@ -56,8 +56,11 @@ const LEGACY_SLOT_CURRENCIES: Record<string, Currency | undefined> = {
|
||||
usd: 'USD',
|
||||
eur: 'EUR',
|
||||
brl: 'BRL',
|
||||
dkk: 'DKK',
|
||||
inr: 'INR',
|
||||
nok: 'NOK',
|
||||
pln: 'PLN',
|
||||
sek: 'SEK',
|
||||
try: 'TRY',
|
||||
};
|
||||
|
||||
@@ -109,6 +112,14 @@ export class ProductRegistry {
|
||||
currency: 'BRL',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyDkk, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'DKK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyInr, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -117,6 +128,14 @@ export class ProductRegistry {
|
||||
currency: 'INR',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyNok, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'NOK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyPln, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -125,6 +144,14 @@ export class ProductRegistry {
|
||||
currency: 'PLN',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlySek, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: false,
|
||||
currency: 'SEK',
|
||||
billingCycle: 'monthly',
|
||||
});
|
||||
this.registerProduct(prices.monthlyTry, {
|
||||
type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -157,6 +184,14 @@ export class ProductRegistry {
|
||||
currency: 'BRL',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyDkk, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'DKK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyInr, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -165,6 +200,14 @@ export class ProductRegistry {
|
||||
currency: 'INR',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyNok, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'NOK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyPln, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -173,6 +216,14 @@ export class ProductRegistry {
|
||||
currency: 'PLN',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlySek, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: false,
|
||||
currency: 'SEK',
|
||||
billingCycle: 'yearly',
|
||||
});
|
||||
this.registerProduct(prices.yearlyTry, {
|
||||
type: ProductType.YEARLY_SUBSCRIPTION,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -209,6 +260,48 @@ export class ProductRegistry {
|
||||
isGift: true,
|
||||
currency: 'INR',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthDkk, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'DKK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearDkk, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'DKK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthNok, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'NOK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearNok, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'NOK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthSek, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 1,
|
||||
isGift: true,
|
||||
currency: 'SEK',
|
||||
});
|
||||
this.registerProduct(prices.gift1YearSek, {
|
||||
type: ProductType.GIFT_1_YEAR,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
durationMonths: 12,
|
||||
isGift: true,
|
||||
currency: 'SEK',
|
||||
});
|
||||
this.registerProduct(prices.gift1MonthPln, {
|
||||
type: ProductType.GIFT_1_MONTH,
|
||||
premiumType: UserPremiumTypes.SUBSCRIPTION,
|
||||
@@ -319,12 +412,21 @@ export class ProductRegistry {
|
||||
if (normalizedCurrency === 'brl') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyBrl ?? null) : (prices.yearlyBrl ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'dkk') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyDkk ?? null) : (prices.yearlyDkk ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'inr') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyInr ?? null) : (prices.yearlyInr ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'nok') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyNok ?? null) : (prices.yearlyNok ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'pln') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyPln ?? null) : (prices.yearlyPln ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'sek') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlySek ?? null) : (prices.yearlySek ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'try') {
|
||||
return billingCycle === 'monthly' ? (prices.monthlyTry ?? null) : (prices.yearlyTry ?? null);
|
||||
}
|
||||
@@ -349,6 +451,15 @@ export class ProductRegistry {
|
||||
if (normalizedCurrency === 'inr') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthInr ?? null) : (prices.gift1YearInr ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'dkk') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthDkk ?? null) : (prices.gift1YearDkk ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'nok') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthNok ?? null) : (prices.gift1YearNok ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'sek') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthSek ?? null) : (prices.gift1YearSek ?? null);
|
||||
}
|
||||
if (normalizedCurrency === 'pln') {
|
||||
return duration === 'gift_1_month' ? (prices.gift1MonthPln ?? null) : (prices.gift1YearPln ?? null);
|
||||
}
|
||||
|
||||
@@ -104,8 +104,11 @@ function normalizeCurrency(value: string | null | undefined): Currency | null {
|
||||
currency === 'USD' ||
|
||||
currency === 'EUR' ||
|
||||
currency === 'BRL' ||
|
||||
currency === 'DKK' ||
|
||||
currency === 'INR' ||
|
||||
currency === 'NOK' ||
|
||||
currency === 'PLN' ||
|
||||
currency === 'SEK' ||
|
||||
currency === 'TRY'
|
||||
) {
|
||||
return currency;
|
||||
|
||||
@@ -1108,6 +1108,15 @@ export class StripeCheckoutService {
|
||||
yearly: prices.yearlyBrl,
|
||||
currency,
|
||||
};
|
||||
case 'DKK':
|
||||
if (!prices.monthlyDkk || !prices.yearlyDkk) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyDkk,
|
||||
yearly: prices.yearlyDkk,
|
||||
currency,
|
||||
};
|
||||
case 'INR':
|
||||
if (!prices.monthlyInr || !prices.yearlyInr) {
|
||||
return null;
|
||||
@@ -1117,6 +1126,15 @@ export class StripeCheckoutService {
|
||||
yearly: prices.yearlyInr,
|
||||
currency,
|
||||
};
|
||||
case 'NOK':
|
||||
if (!prices.monthlyNok || !prices.yearlyNok) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlyNok,
|
||||
yearly: prices.yearlyNok,
|
||||
currency,
|
||||
};
|
||||
case 'PLN':
|
||||
if (!prices.monthlyPln || !prices.yearlyPln) {
|
||||
return null;
|
||||
@@ -1126,6 +1144,15 @@ export class StripeCheckoutService {
|
||||
yearly: prices.yearlyPln,
|
||||
currency,
|
||||
};
|
||||
case 'SEK':
|
||||
if (!prices.monthlySek || !prices.yearlySek) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
monthly: prices.monthlySek,
|
||||
yearly: prices.yearlySek,
|
||||
currency,
|
||||
};
|
||||
case 'TRY':
|
||||
if (!prices.monthlyTry || !prices.yearlyTry) {
|
||||
return null;
|
||||
@@ -1175,6 +1202,33 @@ export class StripeCheckoutService {
|
||||
gift_1_year: prices.gift1YearInr,
|
||||
gift_currency: 'INR',
|
||||
};
|
||||
case 'DKK':
|
||||
if (!prices.gift1MonthDkk || !prices.gift1YearDkk) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthDkk,
|
||||
gift_1_year: prices.gift1YearDkk,
|
||||
gift_currency: 'DKK',
|
||||
};
|
||||
case 'NOK':
|
||||
if (!prices.gift1MonthNok || !prices.gift1YearNok) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthNok,
|
||||
gift_1_year: prices.gift1YearNok,
|
||||
gift_currency: 'NOK',
|
||||
};
|
||||
case 'SEK':
|
||||
if (!prices.gift1MonthSek || !prices.gift1YearSek) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
gift_1_month: prices.gift1MonthSek,
|
||||
gift_1_year: prices.gift1YearSek,
|
||||
gift_currency: 'SEK',
|
||||
};
|
||||
case 'PLN':
|
||||
if (!prices.gift1MonthPln || !prices.gift1YearPln) {
|
||||
return null;
|
||||
|
||||
@@ -119,16 +119,22 @@ describe('ProductRegistry - legacy prices', () => {
|
||||
monthly_usd: ['legacy_usd'],
|
||||
monthly_eur: ['legacy_eur'],
|
||||
monthly_brl: ['legacy_brl'],
|
||||
monthly_dkk: ['legacy_dkk'],
|
||||
monthly_inr: ['legacy_inr'],
|
||||
monthly_nok: ['legacy_nok'],
|
||||
monthly_pln: ['legacy_pln'],
|
||||
monthly_sek: ['legacy_sek'],
|
||||
monthly_try: ['legacy_try'],
|
||||
},
|
||||
);
|
||||
expect(registry.getProduct('legacy_usd')?.currency).toBe('USD');
|
||||
expect(registry.getProduct('legacy_eur')?.currency).toBe('EUR');
|
||||
expect(registry.getProduct('legacy_brl')?.currency).toBe('BRL');
|
||||
expect(registry.getProduct('legacy_dkk')?.currency).toBe('DKK');
|
||||
expect(registry.getProduct('legacy_inr')?.currency).toBe('INR');
|
||||
expect(registry.getProduct('legacy_nok')?.currency).toBe('NOK');
|
||||
expect(registry.getProduct('legacy_pln')?.currency).toBe('PLN');
|
||||
expect(registry.getProduct('legacy_sek')?.currency).toBe('SEK');
|
||||
expect(registry.getProduct('legacy_try')?.currency).toBe('TRY');
|
||||
});
|
||||
|
||||
@@ -205,7 +211,7 @@ describe('ProductRegistry - legacy prices', () => {
|
||||
test('no legacy price id is ever returned by either price getter, for any cycle or currency', () => {
|
||||
const registry = buildRegistry(MOCK_PRICES, MOCK_LEGACY_PRICES);
|
||||
const legacyIds = new Set(Object.values(MOCK_LEGACY_PRICES).flatMap((ids) => ids ?? []));
|
||||
const currencies = ['USD', 'EUR', 'BRL', 'INR', 'PLN', 'TRY'];
|
||||
const currencies = ['USD', 'EUR', 'BRL', 'DKK', 'INR', 'NOK', 'PLN', 'SEK', 'TRY'];
|
||||
|
||||
const offered: Array<string> = [];
|
||||
for (const currency of currencies) {
|
||||
|
||||
@@ -112,6 +112,29 @@ describe('StripeCheckoutCountryEnforcement', () => {
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects a localized gift price even from inside that market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('accepts the base gift price from inside a localized market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthUsd, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('accepts the localized price for a request that geolocates to that market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
PriceIdsResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type {GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers, type StripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
|
||||
const {lookupGeoipMock} = vi.hoisted(() => ({
|
||||
lookupGeoipMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/IpUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/IpUtils')>()),
|
||||
lookupGeoip: lookupGeoipMock,
|
||||
}));
|
||||
|
||||
const MOCK_PRICES = {
|
||||
monthlyUsd: 'price_nordic_monthly_usd',
|
||||
yearlyUsd: 'price_nordic_yearly_usd',
|
||||
monthlyEur: 'price_nordic_monthly_eur',
|
||||
yearlyEur: 'price_nordic_yearly_eur',
|
||||
monthlyDkk: 'price_nordic_monthly_dkk',
|
||||
yearlyDkk: 'price_nordic_yearly_dkk',
|
||||
monthlyNok: 'price_nordic_monthly_nok',
|
||||
yearlyNok: 'price_nordic_yearly_nok',
|
||||
monthlyPln: 'price_nordic_monthly_pln',
|
||||
yearlyPln: 'price_nordic_yearly_pln',
|
||||
monthlySek: 'price_nordic_monthly_sek',
|
||||
yearlySek: 'price_nordic_yearly_sek',
|
||||
gift1MonthUsd: 'price_nordic_gift_1_month_usd',
|
||||
gift1YearUsd: 'price_nordic_gift_1_year_usd',
|
||||
gift1MonthEur: 'price_nordic_gift_1_month_eur',
|
||||
gift1YearEur: 'price_nordic_gift_1_year_eur',
|
||||
};
|
||||
|
||||
const MOCK_PRICE_SEEDS = {
|
||||
[MOCK_PRICES.monthlyUsd]: {unit_amount: 499, currency: 'usd', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyUsd]: {unit_amount: 4999, currency: 'usd', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyEur]: {unit_amount: 499, currency: 'eur', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyEur]: {unit_amount: 4999, currency: 'eur', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyDkk]: {unit_amount: 3700, currency: 'dkk', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyDkk]: {unit_amount: 37000, currency: 'dkk', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyNok]: {unit_amount: 5400, currency: 'nok', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyNok]: {unit_amount: 54000, currency: 'nok', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlyPln]: {unit_amount: 1900, currency: 'pln', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlyPln]: {unit_amount: 19000, currency: 'pln', interval: 'year' as const},
|
||||
[MOCK_PRICES.monthlySek]: {unit_amount: 5400, currency: 'sek', interval: 'month' as const},
|
||||
[MOCK_PRICES.yearlySek]: {unit_amount: 54000, currency: 'sek', interval: 'year' as const},
|
||||
};
|
||||
|
||||
const EUR_SUBSCRIPTION_ID = 'sub_nordic_legacy_eur';
|
||||
|
||||
function geoipCountry(countryCode: string | null): GeoipResult {
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: '203.0.113.10',
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Nordic localized currencies', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let stripeHandlers: StripeApiHandlers;
|
||||
let originalPrices: typeof Config.stripe.prices | undefined;
|
||||
|
||||
async function createPurchaser(): Promise<string> {
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true})
|
||||
.execute();
|
||||
return account.token;
|
||||
}
|
||||
|
||||
function getPriceIds(countryCode: string | null): Promise<PriceIdsResponse> {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(countryCode));
|
||||
return createBuilder<PriceIdsResponse>(harness, '').get('/premium/price-ids').expect(HTTP_STATUS.OK).execute();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
originalPrices = Config.stripe.prices;
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
Config.stripe.prices = originalPrices;
|
||||
});
|
||||
beforeEach(() => {
|
||||
Config.stripe.prices = MOCK_PRICES;
|
||||
lookupGeoipMock.mockReset();
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry(null));
|
||||
stripeHandlers = createStripeApiHandlers({
|
||||
prices: MOCK_PRICE_SEEDS,
|
||||
subscriptionsListEmpty: true,
|
||||
subscriptions: {
|
||||
[EUR_SUBSCRIPTION_ID]: {
|
||||
customer: `cus_${EUR_SUBSCRIPTION_ID}`,
|
||||
price_id: MOCK_PRICES.monthlyEur,
|
||||
unit_amount: 499,
|
||||
currency: 'eur',
|
||||
interval: 'month',
|
||||
item_id: `si_${EUR_SUBSCRIPTION_ID}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
server.use(...stripeHandlers.handlers);
|
||||
});
|
||||
|
||||
describe('country routing', () => {
|
||||
test('Sweden resolves to the SEK subscription catalog', async () => {
|
||||
const priceIds = await getPriceIds('SE');
|
||||
expect(priceIds.currency).toBe('SEK');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlySek);
|
||||
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlySek);
|
||||
});
|
||||
|
||||
test('Denmark resolves to the DKK subscription catalog', async () => {
|
||||
const priceIds = await getPriceIds('DK');
|
||||
expect(priceIds.currency).toBe('DKK');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyDkk);
|
||||
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyDkk);
|
||||
});
|
||||
|
||||
test('Norway resolves to the NOK subscription catalog', async () => {
|
||||
const priceIds = await getPriceIds('NO');
|
||||
expect(priceIds.currency).toBe('NOK');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyNok);
|
||||
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyNok);
|
||||
});
|
||||
|
||||
test('Poland still resolves to the PLN subscription catalog', async () => {
|
||||
const priceIds = await getPriceIds('PL');
|
||||
expect(priceIds.currency).toBe('PLN');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyPln);
|
||||
});
|
||||
|
||||
test.each(['DE', 'NL', 'FI', 'IE', 'IS'])('%s still resolves to the EUR subscription catalog', async (country) => {
|
||||
const priceIds = await getPriceIds(country);
|
||||
expect(priceIds.currency).toBe('EUR');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
|
||||
expect(priceIds.yearly).toBe(MOCK_PRICES.yearlyEur);
|
||||
});
|
||||
|
||||
test.each(['SE', 'DK', 'NO'])('%s keeps gifts on the base currency', async (country) => {
|
||||
const priceIds = await getPriceIds(country);
|
||||
expect(priceIds.gift_currency).toBe('EUR');
|
||||
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthEur);
|
||||
expect(priceIds.gift_1_year).toBe(MOCK_PRICES.gift1YearEur);
|
||||
});
|
||||
|
||||
test('a Nordic gift falls through to USD when no EUR gift price is configured', async () => {
|
||||
Config.stripe.prices = {
|
||||
...MOCK_PRICES,
|
||||
gift1MonthEur: undefined,
|
||||
gift1YearEur: undefined,
|
||||
};
|
||||
const priceIds = await getPriceIds('SE');
|
||||
expect(priceIds.currency).toBe('SEK');
|
||||
expect(priceIds.gift_currency).toBe('USD');
|
||||
expect(priceIds.gift_1_month).toBe(MOCK_PRICES.gift1MonthUsd);
|
||||
});
|
||||
|
||||
test('Sweden falls back to EUR while the SEK prices are still unconfigured', async () => {
|
||||
Config.stripe.prices = {
|
||||
...MOCK_PRICES,
|
||||
monthlySek: undefined,
|
||||
yearlySek: undefined,
|
||||
};
|
||||
const priceIds = await getPriceIds('SE');
|
||||
expect(priceIds.currency).toBe('EUR');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyEur);
|
||||
expect(priceIds.gift_currency).toBe('EUR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkout enforcement', () => {
|
||||
test('rejects the EUR subscription price for a purchase that geolocates to Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyEur, country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects the USD subscription price for a purchase that geolocates to Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects a neighbouring Nordic price for a purchase that geolocates to Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyDkk, country_code: 'DK'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('accepts the SEK subscription price for a purchase that geolocates to Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlySek, country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('accepts the base gift price from inside Sweden', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthEur, country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rejects the SEK subscription price on the gift endpoint', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.monthlySek, country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('existing subscribers', () => {
|
||||
test('a Swedish customer billed in EUR keeps the EUR price and is not repriced to SEK', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('SE'));
|
||||
const account = await createTestAccount(harness);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
stripe_subscription_id: EUR_SUBSCRIPTION_ID,
|
||||
premium_type: 1,
|
||||
premium_billing_cycle: 'monthly',
|
||||
premium_until: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
premium_will_cancel: false,
|
||||
})
|
||||
.execute();
|
||||
const price = await createBuilder<CurrentSubscriptionPriceResponse>(harness, account.token)
|
||||
.get('/premium/current-subscription-price')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(price).toMatchObject({
|
||||
price_id: MOCK_PRICES.monthlyEur,
|
||||
currency: 'EUR',
|
||||
billing_cycle: 'monthly',
|
||||
is_grandfathered: false,
|
||||
list_price_id: MOCK_PRICES.monthlyEur,
|
||||
});
|
||||
expect(stripeHandlers.spies.updatedSubscriptions).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.createdSubscriptionSchedules).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -113,7 +113,10 @@ export function setupSyncStripeWebhookWorker(): void {
|
||||
setInjectedWorkerService(new SyncTaskWorkerService({processStripeWebhook}));
|
||||
}
|
||||
|
||||
let originalWebhookSecretDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
|
||||
originalWebhookSecretDescriptor ??= Object.getOwnPropertyDescriptor(Config.stripe, 'webhookSecret');
|
||||
Object.defineProperty(Config.stripe, 'webhookSecret', {
|
||||
get: () => secret,
|
||||
configurable: true,
|
||||
@@ -121,6 +124,11 @@ export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
|
||||
}
|
||||
|
||||
export function restoreStripeWebhookSecret(): void {
|
||||
if (originalWebhookSecretDescriptor) {
|
||||
Object.defineProperty(Config.stripe, 'webhookSecret', originalWebhookSecretDescriptor);
|
||||
originalWebhookSecretDescriptor = undefined;
|
||||
return;
|
||||
}
|
||||
delete (
|
||||
Config.stripe as {
|
||||
webhookSecret?: string;
|
||||
|
||||
@@ -353,6 +353,16 @@ interface MockStripeSubscriptionSchedule {
|
||||
}>;
|
||||
}
|
||||
|
||||
const PRICE_ID_CURRENCY_MARKERS = ['eur', 'brl', 'dkk', 'inr', 'nok', 'pln', 'sek', 'try'] as const;
|
||||
|
||||
function inferPriceIdCurrency(normalizedPriceId: string): string {
|
||||
return PRICE_ID_CURRENCY_MARKERS.find((marker) => normalizedPriceId.includes(marker)) ?? 'usd';
|
||||
}
|
||||
|
||||
function inferPriceIdInterval(normalizedPriceId: string): 'month' | 'year' {
|
||||
return normalizedPriceId.includes('year') ? 'year' : 'month';
|
||||
}
|
||||
|
||||
function parseFormDataToObject<T extends object = Record<string, unknown>>(formData: FormData): T {
|
||||
const result: Record<string, unknown> = {};
|
||||
const isArrayIndex = (segment: string): boolean => /^\d+$/.test(segment);
|
||||
@@ -797,18 +807,8 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
function inferSubscriptionPriceState(priceId: string): Pick<MockStripeSubscriptionState, 'currency' | 'interval'> {
|
||||
const normalizedPriceId = priceId.toLowerCase();
|
||||
return {
|
||||
currency: normalizedPriceId.includes('eur')
|
||||
? 'eur'
|
||||
: normalizedPriceId.includes('brl')
|
||||
? 'brl'
|
||||
: normalizedPriceId.includes('inr')
|
||||
? 'inr'
|
||||
: normalizedPriceId.includes('pln')
|
||||
? 'pln'
|
||||
: normalizedPriceId.includes('try')
|
||||
? 'try'
|
||||
: 'usd',
|
||||
interval: normalizedPriceId.includes('year') ? 'year' : 'month',
|
||||
currency: inferPriceIdCurrency(normalizedPriceId),
|
||||
interval: inferPriceIdInterval(normalizedPriceId),
|
||||
};
|
||||
}
|
||||
function createDefaultSubscriptionState(): MockStripeSubscriptionState {
|
||||
@@ -1718,23 +1718,11 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
|
||||
id,
|
||||
object: 'price',
|
||||
active: true,
|
||||
currency:
|
||||
overrides?.currency ??
|
||||
(normalizedPriceId.includes('eur')
|
||||
? 'eur'
|
||||
: normalizedPriceId.includes('brl')
|
||||
? 'brl'
|
||||
: normalizedPriceId.includes('inr')
|
||||
? 'inr'
|
||||
: normalizedPriceId.includes('pln')
|
||||
? 'pln'
|
||||
: normalizedPriceId.includes('try')
|
||||
? 'try'
|
||||
: 'usd'),
|
||||
unit_amount: overrides?.unit_amount ?? (normalizedPriceId.includes('year') ? 4999 : 499),
|
||||
currency: overrides?.currency ?? inferPriceIdCurrency(normalizedPriceId),
|
||||
unit_amount: overrides?.unit_amount ?? (inferPriceIdInterval(normalizedPriceId) === 'year' ? 4999 : 499),
|
||||
type: 'recurring',
|
||||
recurring: {
|
||||
interval: overrides?.interval ?? (normalizedPriceId.includes('year') ? 'year' : 'month'),
|
||||
interval: overrides?.interval ?? inferPriceIdInterval(normalizedPriceId),
|
||||
interval_count: 1,
|
||||
},
|
||||
product: overrides?.product ?? 'prod_test_1',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {isEuEeaCountryCode} from '@fluxer/constants/src/EuropeanEconomicArea';
|
||||
|
||||
export type Currency = 'USD' | 'EUR' | 'BRL' | 'INR' | 'PLN' | 'TRY';
|
||||
export type Currency = 'USD' | 'EUR' | 'BRL' | 'DKK' | 'INR' | 'NOK' | 'PLN' | 'SEK' | 'TRY';
|
||||
|
||||
export function getCurrency(countryCode: string | null | undefined): Currency {
|
||||
return getCurrencyPreferences(countryCode)[0];
|
||||
@@ -16,12 +16,21 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
|
||||
if (upperCode === 'BR') {
|
||||
return ['BRL', 'USD', 'EUR'];
|
||||
}
|
||||
if (upperCode === 'DK') {
|
||||
return ['DKK', 'EUR', 'USD'];
|
||||
}
|
||||
if (upperCode === 'IN') {
|
||||
return ['INR', 'USD', 'EUR'];
|
||||
}
|
||||
if (upperCode === 'NO') {
|
||||
return ['NOK', 'EUR', 'USD'];
|
||||
}
|
||||
if (upperCode === 'PL') {
|
||||
return ['PLN', 'EUR', 'USD'];
|
||||
}
|
||||
if (upperCode === 'SE') {
|
||||
return ['SEK', 'EUR', 'USD'];
|
||||
}
|
||||
if (upperCode === 'TR') {
|
||||
return ['TRY', 'USD', 'EUR'];
|
||||
}
|
||||
@@ -31,6 +40,10 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
|
||||
return ['USD', 'EUR'];
|
||||
}
|
||||
|
||||
const GIFT_ELIGIBLE_LOCALIZED_CURRENCIES = new Set<Currency>(['DKK', 'NOK', 'SEK']);
|
||||
|
||||
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
return getCurrencyPreferences(countryCode);
|
||||
return getCurrencyPreferences(countryCode).filter(
|
||||
(currency) => currency === 'USD' || currency === 'EUR' || GIFT_ELIGIBLE_LOCALIZED_CURRENCIES.has(currency),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getCurrency} from '../CurrencyUtils';
|
||||
import {getCurrency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../CurrencyUtils';
|
||||
|
||||
describe('getCurrency', () => {
|
||||
describe('returns USD for non-EEA countries', () => {
|
||||
@@ -55,11 +55,11 @@ describe('getCurrency', () => {
|
||||
it('returns EUR for Finland', () => {
|
||||
expect(getCurrency('FI')).toBe('EUR');
|
||||
});
|
||||
it('returns EUR for Sweden', () => {
|
||||
expect(getCurrency('SE')).toBe('EUR');
|
||||
it('returns SEK for Sweden', () => {
|
||||
expect(getCurrency('SE')).toBe('SEK');
|
||||
});
|
||||
it('returns EUR for Denmark', () => {
|
||||
expect(getCurrency('DK')).toBe('EUR');
|
||||
it('returns DKK for Denmark', () => {
|
||||
expect(getCurrency('DK')).toBe('DKK');
|
||||
});
|
||||
it('returns PLN for Poland', () => {
|
||||
expect(getCurrency('PL')).toBe('PLN');
|
||||
@@ -76,8 +76,8 @@ describe('getCurrency', () => {
|
||||
it('returns EUR for Romania', () => {
|
||||
expect(getCurrency('RO')).toBe('EUR');
|
||||
});
|
||||
it('returns EUR for Norway (EEA but not EU)', () => {
|
||||
expect(getCurrency('NO')).toBe('EUR');
|
||||
it('returns NOK for Norway (EEA but not EU)', () => {
|
||||
expect(getCurrency('NO')).toBe('NOK');
|
||||
});
|
||||
it('returns EUR for Iceland (EEA but not EU)', () => {
|
||||
expect(getCurrency('IS')).toBe('EUR');
|
||||
@@ -128,7 +128,6 @@ describe('getCurrency', () => {
|
||||
'HR',
|
||||
'CY',
|
||||
'CZ',
|
||||
'DK',
|
||||
'EE',
|
||||
'FI',
|
||||
'FR',
|
||||
@@ -147,10 +146,8 @@ describe('getCurrency', () => {
|
||||
'SK',
|
||||
'SI',
|
||||
'ES',
|
||||
'SE',
|
||||
'IS',
|
||||
'LI',
|
||||
'NO',
|
||||
];
|
||||
for (const country of eeaCountries) {
|
||||
it(`returns EUR for ${country}`, () => {
|
||||
@@ -160,5 +157,45 @@ describe('getCurrency', () => {
|
||||
it('uses local currency for Poland', () => {
|
||||
expect(getCurrency('PL')).toBe('PLN');
|
||||
});
|
||||
it('uses local currency for Sweden', () => {
|
||||
expect(getCurrency('SE')).toBe('SEK');
|
||||
});
|
||||
it('uses local currency for Denmark', () => {
|
||||
expect(getCurrency('DK')).toBe('DKK');
|
||||
});
|
||||
it('uses local currency for Norway', () => {
|
||||
expect(getCurrency('NO')).toBe('NOK');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGiftCurrencyPreferences', () => {
|
||||
it('never offers a localized currency that is cheaper than the base price', () => {
|
||||
for (const country of ['BR', 'IN', 'PL', 'TR']) {
|
||||
expect(getGiftCurrencyPreferences(country)).not.toContain(getCurrencyPreferences(country)[0]);
|
||||
}
|
||||
});
|
||||
it('offers the localized currency where it is not cheaper than the base price', () => {
|
||||
expect(getGiftCurrencyPreferences('SE')).toEqual(['SEK', 'EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('DK')).toEqual(['DKK', 'EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('NO')).toEqual(['NOK', 'EUR', 'USD']);
|
||||
});
|
||||
it('uses EUR for other EEA countries', () => {
|
||||
expect(getGiftCurrencyPreferences('DE')).toEqual(['EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('PL')).toEqual(['EUR', 'USD']);
|
||||
});
|
||||
it('uses USD everywhere else', () => {
|
||||
expect(getGiftCurrencyPreferences('BR')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('IN')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('TR')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('US')).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
it('uses USD when the country is unknown', () => {
|
||||
expect(getGiftCurrencyPreferences(null)).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences(undefined)).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
it('is case insensitive', () => {
|
||||
expect(getGiftCurrencyPreferences('se')).toEqual(['SEK', 'EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('br')).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildID, UserID} from '../BrandedTypes';
|
||||
import type {VoiceRegionAvailability, VoiceRegionMetadata, VoiceRegionRecord, VoiceServerRecord} from './VoiceModel';
|
||||
import {preferServersUnderSoftLimit} from './VoiceRegionSelection';
|
||||
import type {VoiceServerLoadSource} from './VoiceServerLoad';
|
||||
import type {VoiceTopology} from './VoiceTopology';
|
||||
|
||||
export interface VoiceAccessContext {
|
||||
@@ -11,10 +13,19 @@ export interface VoiceAccessContext {
|
||||
guildFeatures?: Set<string>;
|
||||
}
|
||||
|
||||
const EMPTY_CONNECTION_COUNTS: ReadonlyMap<string, number> = new Map();
|
||||
|
||||
export class VoiceAvailabilityService {
|
||||
private rotationIndex: Map<string, number> = new Map();
|
||||
|
||||
constructor(private topology: VoiceTopology) {}
|
||||
constructor(
|
||||
private topology: VoiceTopology,
|
||||
private loadSource: VoiceServerLoadSource | null = null,
|
||||
) {}
|
||||
|
||||
getServerConnectionCounts(): ReadonlyMap<string, number> {
|
||||
return this.loadSource?.getConnectionCounts() ?? EMPTY_CONNECTION_COUNTS;
|
||||
}
|
||||
|
||||
getRegionMetadata(): Array<VoiceRegionMetadata> {
|
||||
return this.topology.getRegionMetadataList();
|
||||
@@ -140,9 +151,10 @@ export class VoiceAvailabilityService {
|
||||
if (accessibleServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const candidateServers = preferServersUnderSoftLimit(accessibleServers, this.getServerConnectionCounts());
|
||||
const index = this.rotationIndex.get(regionId) ?? 0;
|
||||
const server = accessibleServers[index % accessibleServers.length];
|
||||
this.rotationIndex.set(regionId, (index + 1) % accessibleServers.length);
|
||||
const server = candidateServers[index % candidateServers.length];
|
||||
this.rotationIndex.set(regionId, (index + 1) % candidateServers.length);
|
||||
return server;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export class VoiceDataInitializer {
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
isActive: true,
|
||||
softConnectionLimit: null,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface VoiceServerRecord {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
isActive: boolean;
|
||||
softConnectionLimit: number | null;
|
||||
restrictions: VoiceRestriction;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
|
||||
@@ -77,12 +77,14 @@ export function selectVoiceRegionId({
|
||||
export function selectClosestPseudoRegionServer({
|
||||
mode,
|
||||
accessibleServers,
|
||||
connectionCounts,
|
||||
latitude,
|
||||
longitude,
|
||||
selectionKey,
|
||||
}: {
|
||||
mode: VoiceRegionPreference['mode'];
|
||||
accessibleServers: Array<VoiceServerRecord>;
|
||||
connectionCounts: ReadonlyMap<string, number>;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
selectionKey: string;
|
||||
@@ -95,10 +97,31 @@ export function selectClosestPseudoRegionServer({
|
||||
if (userLat === null || userLon === null) {
|
||||
return null;
|
||||
}
|
||||
const closestServers = findClosestServers(accessibleServers, userLat, userLon);
|
||||
const preferredServers = preferServersUnderSoftLimit(accessibleServers, connectionCounts);
|
||||
const closestServers = findClosestServers(preferredServers, userLat, userLon);
|
||||
return selectBalancedServer(closestServers, selectionKey);
|
||||
}
|
||||
|
||||
export function preferServersUnderSoftLimit(
|
||||
servers: Array<VoiceServerRecord>,
|
||||
connectionCounts: ReadonlyMap<string, number>,
|
||||
): Array<VoiceServerRecord> {
|
||||
const serversUnderLimit = servers.filter((server) => !isServerAtSoftLimit(server, connectionCounts));
|
||||
return serversUnderLimit.length > 0 ? serversUnderLimit : servers;
|
||||
}
|
||||
|
||||
function isServerAtSoftLimit(server: VoiceServerRecord, connectionCounts: ReadonlyMap<string, number>): boolean {
|
||||
const limit = server.softConnectionLimit;
|
||||
if (limit === null || limit <= 0) {
|
||||
return false;
|
||||
}
|
||||
const connectionCount = connectionCounts.get(server.serverId);
|
||||
if (connectionCount === undefined) {
|
||||
return false;
|
||||
}
|
||||
return connectionCount >= limit;
|
||||
}
|
||||
|
||||
function findClosestRegionIds(
|
||||
latitude: string | undefined,
|
||||
longitude: string | undefined,
|
||||
|
||||
@@ -147,6 +147,7 @@ export class VoiceRepository implements IVoiceRepository {
|
||||
latitude: server.latitude ?? null,
|
||||
longitude: server.longitude ?? null,
|
||||
is_active: server.isActive,
|
||||
soft_connection_limit: server.softConnectionLimit ?? null,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: new Set(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: new Set(Array.from(server.restrictions.allowedGuildIds).map((id) => BigInt(id))),
|
||||
@@ -190,6 +191,7 @@ export class VoiceRepository implements IVoiceRepository {
|
||||
latitude: row.latitude ?? null,
|
||||
longitude: row.longitude ?? null,
|
||||
isActive: row.is_active ?? true,
|
||||
softConnectionLimit: row.soft_connection_limit ?? null,
|
||||
restrictions: {
|
||||
vipOnly: row.vip_only ?? false,
|
||||
requiredGuildFeatures: new Set(toIterable<string>(row.required_guild_features)),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IGatewayService} from '../infrastructure/IGatewayService';
|
||||
import {Logger} from '../Logger';
|
||||
|
||||
const DEFAULT_REFRESH_INTERVAL_MS = 15000;
|
||||
const STALE_REFRESH_INTERVALS = 4;
|
||||
const EMPTY_CONNECTION_COUNTS: ReadonlyMap<string, number> = new Map();
|
||||
|
||||
export interface VoiceServerLoadSource {
|
||||
getConnectionCounts(): ReadonlyMap<string, number>;
|
||||
}
|
||||
|
||||
export class VoiceServerLoadTracker implements VoiceServerLoadSource {
|
||||
private readonly gatewayService: IGatewayService;
|
||||
private readonly refreshIntervalMs: number;
|
||||
private readonly staleAfterMs: number;
|
||||
private readonly now: () => number;
|
||||
private connectionCounts: ReadonlyMap<string, number> = EMPTY_CONNECTION_COUNTS;
|
||||
private lastAttemptAt = 0;
|
||||
private lastSuccessAt = 0;
|
||||
private refreshing: Promise<void> | null = null;
|
||||
|
||||
constructor(options: {
|
||||
gatewayService: IGatewayService;
|
||||
refreshIntervalMs?: number;
|
||||
now?: () => number;
|
||||
}) {
|
||||
this.gatewayService = options.gatewayService;
|
||||
this.refreshIntervalMs = options.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
|
||||
this.staleAfterMs = this.refreshIntervalMs * STALE_REFRESH_INTERVALS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
getConnectionCounts(): ReadonlyMap<string, number> {
|
||||
const now = this.now();
|
||||
if (now - this.lastAttemptAt >= this.refreshIntervalMs) {
|
||||
void this.refresh();
|
||||
}
|
||||
if (this.lastSuccessAt === 0 || now - this.lastSuccessAt > this.staleAfterMs) {
|
||||
return EMPTY_CONNECTION_COUNTS;
|
||||
}
|
||||
return this.connectionCounts;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (this.refreshing) {
|
||||
return this.refreshing;
|
||||
}
|
||||
this.lastAttemptAt = this.now();
|
||||
this.refreshing = this.gatewayService
|
||||
.getVoiceStateCounts()
|
||||
.then((counts) => {
|
||||
const nextCounts = new Map<string, number>();
|
||||
for (const server of counts.servers) {
|
||||
nextCounts.set(server.server_id, server.voice_state_count);
|
||||
}
|
||||
this.connectionCounts = nextCounts;
|
||||
this.lastSuccessAt = this.now();
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn({error}, 'Failed to refresh voice server connection counts');
|
||||
})
|
||||
.finally(() => {
|
||||
this.refreshing = null;
|
||||
});
|
||||
return this.refreshing;
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,7 @@ export class VoiceService {
|
||||
const pseudoRegionServer = selectClosestPseudoRegionServer({
|
||||
mode: regionPreference.mode,
|
||||
accessibleServers,
|
||||
connectionCounts: this.voiceAvailabilityService.getServerConnectionCounts(),
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
selectionKey,
|
||||
|
||||
@@ -38,6 +38,7 @@ function createMockServer(overrides: Partial<VoiceServerRecord> = {}): VoiceServ
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
isActive: true,
|
||||
softConnectionLimit: null,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
@@ -384,5 +385,37 @@ describe('VoiceAvailabilityService', () => {
|
||||
expect(first!.serverId).toBe('server-1');
|
||||
expect(second!.serverId).toBe('server-2');
|
||||
});
|
||||
it('rotates only between servers below their soft connection limit', () => {
|
||||
const region = createMockRegion();
|
||||
const server1 = createMockServer({serverId: 'server-1', softConnectionLimit: 50});
|
||||
const server2 = createMockServer({serverId: 'server-2'});
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server1, server2]]]));
|
||||
service = new VoiceAvailabilityService(topology, {
|
||||
getConnectionCounts: () => new Map([['server-1', 50]]),
|
||||
});
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
|
||||
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
|
||||
});
|
||||
it('rotates across every server when all of them are at their soft connection limit', () => {
|
||||
const region = createMockRegion();
|
||||
const server1 = createMockServer({serverId: 'server-1', softConnectionLimit: 50});
|
||||
const server2 = createMockServer({serverId: 'server-2', softConnectionLimit: 50});
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server1, server2]]]));
|
||||
service = new VoiceAvailabilityService(topology, {
|
||||
getConnectionCounts: () =>
|
||||
new Map([
|
||||
['server-1', 90],
|
||||
['server-2', 90],
|
||||
]),
|
||||
});
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
expect(service.selectServer('us-default', context)!.serverId).toBe('server-1');
|
||||
expect(service.selectServer('us-default', context)!.serverId).toBe('server-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {VoiceRegionAvailability, VoiceServerRecord} from '../VoiceModel';
|
||||
import {
|
||||
preferServersUnderSoftLimit,
|
||||
resolveVoiceRegionPreference,
|
||||
selectClosestPseudoRegionServer,
|
||||
selectVoiceRegionId,
|
||||
@@ -45,11 +46,13 @@ function createVoiceServer({
|
||||
serverId,
|
||||
latitude,
|
||||
longitude,
|
||||
softConnectionLimit = null,
|
||||
}: {
|
||||
regionId: string;
|
||||
serverId: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
softConnectionLimit?: number | null;
|
||||
}): VoiceServerRecord {
|
||||
return {
|
||||
regionId,
|
||||
@@ -60,6 +63,7 @@ function createVoiceServer({
|
||||
latitude,
|
||||
longitude,
|
||||
isActive: true,
|
||||
softConnectionLimit,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
@@ -123,6 +127,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverA, serverB],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
@@ -136,6 +141,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedFromForwardOrder = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverB, serverA],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
@@ -143,6 +149,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedFromReverseOrder = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverA, serverB],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
@@ -150,6 +157,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedForAnotherRoom = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverB, serverA],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:2',
|
||||
@@ -164,6 +172,7 @@ describe('VoiceRegionSelection', () => {
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'explicit',
|
||||
accessibleServers: [serverA, serverB],
|
||||
connectionCounts: new Map(),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
@@ -206,4 +215,91 @@ describe('VoiceRegionSelection', () => {
|
||||
expect(selectedFromReverseOrder).toBe('b');
|
||||
expect(selectedForAnotherRoom).toBe('a');
|
||||
});
|
||||
it('skips a pseudo-region server that reached its soft connection limit', () => {
|
||||
const nearServer = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: 51,
|
||||
longitude: 51,
|
||||
softConnectionLimit: 100,
|
||||
});
|
||||
const farServer = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: 0, longitude: 0});
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [nearServer, farServer],
|
||||
connectionCounts: new Map([['a1', 100]]),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
});
|
||||
expect(selectedServer?.serverId).toBe('b1');
|
||||
});
|
||||
it('keeps a pseudo-region server that is still below its soft connection limit', () => {
|
||||
const nearServer = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: 51,
|
||||
longitude: 51,
|
||||
softConnectionLimit: 100,
|
||||
});
|
||||
const farServer = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: 0, longitude: 0});
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [nearServer, farServer],
|
||||
connectionCounts: new Map([['a1', 99]]),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
});
|
||||
expect(selectedServer?.serverId).toBe('a1');
|
||||
});
|
||||
it('falls back to a server over its soft connection limit when every candidate is over', () => {
|
||||
const serverA = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: 51,
|
||||
longitude: 51,
|
||||
softConnectionLimit: 10,
|
||||
});
|
||||
const serverB = createVoiceServer({
|
||||
regionId: 'b',
|
||||
serverId: 'b1',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
softConnectionLimit: 10,
|
||||
});
|
||||
const selectedServer = selectClosestPseudoRegionServer({
|
||||
mode: 'automatic',
|
||||
accessibleServers: [serverA, serverB],
|
||||
connectionCounts: new Map([
|
||||
['a1', 40],
|
||||
['b1', 40],
|
||||
]),
|
||||
latitude: '50',
|
||||
longitude: '50',
|
||||
selectionKey: 'guild:1:channel:1',
|
||||
});
|
||||
expect(selectedServer?.serverId).toBe('a1');
|
||||
});
|
||||
it('ignores a soft connection limit when no count is known for the server', () => {
|
||||
const serverA = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
softConnectionLimit: 1,
|
||||
});
|
||||
const serverB = createVoiceServer({regionId: 'b', serverId: 'b1', latitude: null, longitude: null});
|
||||
expect(preferServersUnderSoftLimit([serverA, serverB], new Map())).toEqual([serverA, serverB]);
|
||||
});
|
||||
it('ignores a soft connection limit that is not positive', () => {
|
||||
const serverA = createVoiceServer({
|
||||
regionId: 'a',
|
||||
serverId: 'a1',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
softConnectionLimit: 0,
|
||||
});
|
||||
expect(preferServersUnderSoftLimit([serverA], new Map([['a1', 500]]))).toEqual([serverA]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {GatewayVoiceStateCounts, IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import {VoiceServerLoadTracker} from '../VoiceServerLoad';
|
||||
|
||||
function createGatewayService(respond: () => Promise<GatewayVoiceStateCounts>): {
|
||||
gatewayService: IGatewayService;
|
||||
callCount: () => number;
|
||||
} {
|
||||
let calls = 0;
|
||||
const gatewayService = {
|
||||
getVoiceStateCounts: () => {
|
||||
calls += 1;
|
||||
return respond();
|
||||
},
|
||||
} as IGatewayService;
|
||||
return {gatewayService, callCount: () => calls};
|
||||
}
|
||||
|
||||
function counts(servers: Array<{server_id: string; voice_state_count: number}>): GatewayVoiceStateCounts {
|
||||
return {
|
||||
total_voice_states: servers.reduce((total, server) => total + server.voice_state_count, 0),
|
||||
regions: [],
|
||||
servers,
|
||||
};
|
||||
}
|
||||
|
||||
describe('VoiceServerLoadTracker', () => {
|
||||
it('reports no counts until the first refresh resolves', () => {
|
||||
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
|
||||
const tracker = new VoiceServerLoadTracker({gatewayService});
|
||||
expect(tracker.getConnectionCounts().size).toBe(0);
|
||||
});
|
||||
it('reports the counts the gateway returned', async () => {
|
||||
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
|
||||
const tracker = new VoiceServerLoadTracker({gatewayService});
|
||||
await tracker.refresh();
|
||||
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
|
||||
});
|
||||
it('keeps the last counts when a refresh fails', async () => {
|
||||
let shouldFail = false;
|
||||
const {gatewayService} = createGatewayService(async () => {
|
||||
if (shouldFail) {
|
||||
throw new Error('gateway unavailable');
|
||||
}
|
||||
return counts([{server_id: 'server-1', voice_state_count: 7}]);
|
||||
});
|
||||
const tracker = new VoiceServerLoadTracker({gatewayService});
|
||||
await tracker.refresh();
|
||||
shouldFail = true;
|
||||
await tracker.refresh();
|
||||
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
|
||||
});
|
||||
it('refreshes no more often than the refresh interval', async () => {
|
||||
let currentTime = 1000;
|
||||
const {gatewayService, callCount} = createGatewayService(async () =>
|
||||
counts([{server_id: 'server-1', voice_state_count: 7}]),
|
||||
);
|
||||
const tracker = new VoiceServerLoadTracker({
|
||||
gatewayService,
|
||||
refreshIntervalMs: 5000,
|
||||
now: () => currentTime,
|
||||
});
|
||||
await tracker.refresh();
|
||||
tracker.getConnectionCounts();
|
||||
currentTime += 4999;
|
||||
tracker.getConnectionCounts();
|
||||
expect(callCount()).toBe(1);
|
||||
currentTime += 1;
|
||||
tracker.getConnectionCounts();
|
||||
expect(callCount()).toBe(2);
|
||||
});
|
||||
it('drops counts that are too old to place against', async () => {
|
||||
let currentTime = 1000;
|
||||
const {gatewayService} = createGatewayService(async () => counts([{server_id: 'server-1', voice_state_count: 7}]));
|
||||
const tracker = new VoiceServerLoadTracker({
|
||||
gatewayService,
|
||||
refreshIntervalMs: 5000,
|
||||
now: () => currentTime,
|
||||
});
|
||||
await tracker.refresh();
|
||||
expect(tracker.getConnectionCounts().get('server-1')).toBe(7);
|
||||
currentTime += 20001;
|
||||
expect(tracker.getConnectionCounts().size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const MODULE_REGISTRY_TEST_FILES = [
|
||||
'src/api/gif/GifRequestCountry.test.ts',
|
||||
'src/api/risk/__tests__/AccountPolicyService.test.ts',
|
||||
'src/api/stripe/tests/StripeCheckoutCountryEnforcement.test.ts',
|
||||
'src/api/stripe/tests/StripeNordicCurrencies.test.ts',
|
||||
];
|
||||
|
||||
const INSTANCE_POLICY_TEST_FILES = [
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"@radix-ui/react-checkbox": "catalog:",
|
||||
"@radix-ui/react-radio-group": "catalog:",
|
||||
"@radix-ui/react-switch": "catalog:",
|
||||
"@sapphi-red/web-noise-suppressor": "catalog:",
|
||||
"@simplewebauthn/browser": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"animejs": "4.5.0",
|
||||
|
||||
@@ -284,6 +284,14 @@ export default () => {
|
||||
wrappedContextCritical: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /[\\/]@sapphi-red[\\/]web-noise-suppressor[\\/]dist[\\/][^\\/]+[\\/]workletProcessor\.js$/,
|
||||
type: 'asset/resource',
|
||||
use: [{loader: path.join(ROOT_DIR, 'scripts/build/rspack/noise-suppressor-worklet-loader.cjs')}],
|
||||
generator: {
|
||||
filename: isProduction ? 'assets/[contenthash:16].worklet.js' : 'assets/[name].[hash].worklet.js',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(tsx|ts|jsx|js)$/,
|
||||
exclude: /node_modules/,
|
||||
@@ -591,6 +599,7 @@ export default () => {
|
||||
compress: true,
|
||||
mangle: true,
|
||||
format: {comments: false},
|
||||
exclude: /\.worklet\.js$/,
|
||||
}),
|
||||
new LightningCssMinimizerRspackPlugin(),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const PROCESS_SIGNATURE =
|
||||
'process(e,t,n){return e.length===0||!e[0]||e[0]?.length===0||!this.processor||this.processor.process(e[0],t[0]),!0}';
|
||||
|
||||
const PASSTHROUGH_PROCESS =
|
||||
'process(e,t,n){' +
|
||||
'const __in=e[0],__out=t[0];' +
|
||||
'if(!__in||__in.length===0||!__out)return!0;' +
|
||||
'if(!this.processor){' +
|
||||
'for(let __ch=0;__ch<__out.length;__ch++){' +
|
||||
'const __src=__in[Math.min(__ch,__in.length-1)];' +
|
||||
'if(__src&&__out[__ch])__out[__ch].set(__src)' +
|
||||
'}' +
|
||||
'return!0' +
|
||||
'}' +
|
||||
'return this.processor.process(__in,__out),!0' +
|
||||
'}';
|
||||
|
||||
const PORT_LISTENER = 'this.port.addEventListener(`message`,e=>{e.data===`destroy`&&this.destroy()})';
|
||||
const PORT_LISTENER_STARTED = `${PORT_LISTENER},this.port.start()`;
|
||||
|
||||
const READY_MARKER = 'this.destroyed&&this.destroy()})()}';
|
||||
const READY_MARKER_SIGNALLED =
|
||||
'this.destroyed&&this.destroy(),this.destroyed||this.port.postMessage({type:"ready"})})()' +
|
||||
'.catch(__err=>{this.port.postMessage({type:"error",message:String(__err)})})}';
|
||||
|
||||
const GATE_CONSTRUCTOR_TAIL = 'this.processor=i(e.processorOptions,t)}';
|
||||
const GATE_CONSTRUCTOR_TAIL_SIGNALLED = 'this.processor=i(e.processorOptions,t),this.port.postMessage({type:"ready"})}';
|
||||
|
||||
function replaceExactlyOnce(source, needle, replacement, resourcePath) {
|
||||
const occurrences = source.split(needle).length - 1;
|
||||
if (occurrences !== 1) {
|
||||
throw new Error(
|
||||
`noise-suppressor-worklet-loader: expected exactly one occurrence of the upstream snippet in ${resourcePath}, found ${occurrences}. The @sapphi-red/web-noise-suppressor build changed shape; update this loader.`,
|
||||
);
|
||||
}
|
||||
return source.replace(needle, replacement);
|
||||
}
|
||||
|
||||
module.exports = function noiseSuppressorWorkletLoader(source) {
|
||||
const resourcePath = this.resourcePath;
|
||||
if (resourcePath.includes('noiseGate')) {
|
||||
return replaceExactlyOnce(source, GATE_CONSTRUCTOR_TAIL, GATE_CONSTRUCTOR_TAIL_SIGNALLED, resourcePath);
|
||||
}
|
||||
let output = replaceExactlyOnce(source, PORT_LISTENER, PORT_LISTENER_STARTED, resourcePath);
|
||||
output = replaceExactlyOnce(output, READY_MARKER, READY_MARKER_SIGNALLED, resourcePath);
|
||||
return replaceExactlyOnce(output, PROCESS_SIGNATURE, PASSTHROUGH_PROCESS, resourcePath);
|
||||
};
|
||||
@@ -55,7 +55,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
|
||||
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
|
||||
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
|
||||
import {useWindowEventListeners} from '@app/features/window/hooks/useWindowEventListeners';
|
||||
@@ -149,7 +148,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => startScreenSharePiPController(), []);
|
||||
useEffect(() => startMediaDeviceStartupPreload(), []);
|
||||
useServiceWorkerBadge();
|
||||
useKeybindManager(i18n);
|
||||
useDesktopElectronBridges();
|
||||
|
||||
-1
@@ -7,7 +7,6 @@
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
-6
@@ -49,12 +49,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
|
||||
+1
-7
@@ -19,7 +19,7 @@ interface PurchaseHistorySectionProps {
|
||||
export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = observer(
|
||||
({premiumState, loadingPortal, handleOpenCustomerPortal}) => {
|
||||
const invoices = premiumState?.billing.invoices ?? [];
|
||||
const [invoicesExpanded, setInvoicesExpanded] = useState(invoices.length > 0);
|
||||
const [invoicesExpanded, setInvoicesExpanded] = useState(false);
|
||||
return (
|
||||
<section className={styles.section} data-flx="app.plutonium.purchase-history-section.section">
|
||||
<div className={styles.card} data-flx="app.plutonium.purchase-history-section.card">
|
||||
@@ -28,12 +28,6 @@ export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = obs
|
||||
<h3 className={styles.title} data-flx="app.plutonium.purchase-history-section.title">
|
||||
<Trans>Purchase history</Trans>
|
||||
</h3>
|
||||
<p className={styles.description} data-flx="app.plutonium.purchase-history-section.description">
|
||||
<Trans>
|
||||
Your recent invoices. To change the payment method for your subscription, add or choose one in the
|
||||
billing portal and make it the default.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {MediaDeviceStartupPreloadManager} from '@app/features/voice/components/MediaDeviceStartupPreloadManager';
|
||||
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
|
||||
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
|
||||
import {clsx} from 'clsx';
|
||||
@@ -35,6 +36,9 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
|
||||
{isAuthenticated && (
|
||||
<MediaDeviceStartupPreloadManager data-flx="app.app-layout.media-device-startup-preload-manager" />
|
||||
)}
|
||||
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
|
||||
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
|
||||
<div
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {UpdaterDownloadOption} from '@app/features/platform/types/Electron';
|
||||
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
||||
import type {UpdaterEvent} from '@app/types/electron.d';
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const {pushUpdateReadyModal} = vi.hoisted(() => ({pushUpdateReadyModal: vi.fn()}));
|
||||
interface ManualUpdatePrompt {
|
||||
options: ReadonlyArray<UpdaterDownloadOption>;
|
||||
onDownload: (option: UpdaterDownloadOption) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const {
|
||||
pushManualUpdateAvailableModal,
|
||||
pushUnsupportedUpdateModal,
|
||||
pushUpdateCheckFailedModal,
|
||||
pushUpdateReadyModal,
|
||||
pushUpToDateModal,
|
||||
} = vi.hoisted(() => ({
|
||||
pushManualUpdateAvailableModal: vi.fn<(prompt: ManualUpdatePrompt) => void>(),
|
||||
pushUnsupportedUpdateModal: vi.fn(),
|
||||
pushUpdateCheckFailedModal: vi.fn(),
|
||||
pushUpdateReadyModal: vi.fn(),
|
||||
pushUpToDateModal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/updater/commands/UpdaterModalCommands', () => ({
|
||||
pushDesktopUpdateDownloadFailedModal: vi.fn(),
|
||||
pushDesktopUpdateInstallFailedModal: vi.fn(),
|
||||
pushManualUpdateAvailableModal: vi.fn(),
|
||||
pushUnsupportedUpdateModal: vi.fn(),
|
||||
pushManualUpdateAvailableModal,
|
||||
pushUnsupportedUpdateModal,
|
||||
pushUpdateAvailableModal: vi.fn(),
|
||||
pushUpdateCheckFailedModal: vi.fn(),
|
||||
pushUpdateCheckFailedModal,
|
||||
pushUpdateReadyModal,
|
||||
pushUpToDateModal: vi.fn(),
|
||||
pushUpToDateModal,
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({
|
||||
@@ -29,15 +47,30 @@ vi.mock('@app/features/platform/utils/ClientInfo', () => ({
|
||||
|
||||
installVoiceMenuTestBootstrap();
|
||||
|
||||
const REPORTED_VERSION = '2026.908.173325';
|
||||
const NEWER_VERSION = '2026.909.202036';
|
||||
const CANARY_X64 = 'https://api.canary.fluxer.app/dl/desktop/canary/linux/x64';
|
||||
const REPORTED_DEB_DOWNLOAD = [
|
||||
`${CANARY_X64}/${REPORTED_VERSION}/deb`,
|
||||
`Fluxer-Canary-${REPORTED_VERSION}-linux-amd64.deb`,
|
||||
];
|
||||
const NEWER_DEB_DOWNLOAD = [`${CANARY_X64}/${NEWER_VERSION}/deb`, `Fluxer-Canary-${NEWER_VERSION}-linux-amd64.deb`];
|
||||
|
||||
let nativeEventListener: ((event: UpdaterEvent) => void) | null = null;
|
||||
let onUpdaterCheck: (() => void) | null = null;
|
||||
let onUpdaterCheck: ((context: string) => void | Promise<void>) | null = null;
|
||||
let loadedUpdater: {dispose: () => void} | null = null;
|
||||
|
||||
function installElectronApi(): void {
|
||||
const updaterCheck = vi.fn((context: string) => Promise.resolve(onUpdaterCheck?.(context)));
|
||||
const downloadFile = vi.fn<(url: string, suggestedName: string) => Promise<{success: boolean}>>(() =>
|
||||
Promise.resolve({success: true}),
|
||||
);
|
||||
const openExternal = vi.fn<(url: string) => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
function installElectronApi(platform: string): void {
|
||||
nativeEventListener = null;
|
||||
onUpdaterCheck = null;
|
||||
(window as unknown as {electron: unknown}).electron = {
|
||||
platform: 'win32',
|
||||
platform,
|
||||
buildChannel: 'canary',
|
||||
onUpdaterEvent: (listener: (event: UpdaterEvent) => void) => {
|
||||
nativeEventListener = listener;
|
||||
@@ -45,12 +78,11 @@ function installElectronApi(): void {
|
||||
nativeEventListener = null;
|
||||
};
|
||||
},
|
||||
updaterCheck: () => {
|
||||
onUpdaterCheck?.();
|
||||
return Promise.resolve();
|
||||
},
|
||||
updaterCheck,
|
||||
updaterDownload: () => Promise.resolve(),
|
||||
updaterInstall: () => Promise.resolve(),
|
||||
downloadFile,
|
||||
openExternal,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,16 +91,16 @@ function emit(event: UpdaterEvent): void {
|
||||
nativeEventListener(event);
|
||||
}
|
||||
|
||||
async function loadUpdater() {
|
||||
async function loadUpdater(platform = 'win32') {
|
||||
vi.resetModules();
|
||||
installElectronApi();
|
||||
installElectronApi(platform);
|
||||
const {default: Updater} = await import('@app/features/app/state/Updater');
|
||||
loadedUpdater = Updater;
|
||||
await vi.waitFor(() => {
|
||||
expect(nativeEventListener).not.toBeNull();
|
||||
expect(Updater.lastCheckedAt).not.toBeNull();
|
||||
});
|
||||
pushUpdateReadyModal.mockClear();
|
||||
vi.clearAllMocks();
|
||||
return Updater;
|
||||
}
|
||||
|
||||
@@ -77,7 +109,37 @@ function emitUserDownloadCompletion(version: string): void {
|
||||
emit({type: 'downloaded', context: 'user', version});
|
||||
}
|
||||
|
||||
function emitLinuxManualUpdate(context: 'user' | 'background', version: string): void {
|
||||
emit({
|
||||
type: 'available',
|
||||
context,
|
||||
version,
|
||||
downloadStarted: false,
|
||||
downloadUrl: `${CANARY_X64}/latest/appimage`,
|
||||
downloadOptions: [
|
||||
{
|
||||
format: 'deb',
|
||||
label: 'DEB package',
|
||||
url: `${CANARY_X64}/latest/deb`,
|
||||
suggestedName: `Fluxer-Canary-${version}-linux-amd64.deb`,
|
||||
sha256: `deb-${version}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function openLinuxManualUpdatePrompt() {
|
||||
const Updater = await loadUpdater('linux');
|
||||
emitLinuxManualUpdate('background', REPORTED_VERSION);
|
||||
await Updater.applyUpdate();
|
||||
const prompt = pushManualUpdateAvailableModal.mock.lastCall?.[0];
|
||||
const debOption = prompt?.options.find((option) => option.format === 'deb');
|
||||
if (!prompt || !debOption) throw new Error('Manual update prompt never offered a deb package');
|
||||
return {Updater, download: () => Promise.resolve(prompt.onDownload(debOption))};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
loadedUpdater?.dispose();
|
||||
loadedUpdater = null;
|
||||
});
|
||||
@@ -110,3 +172,142 @@ describe('updater update-ready surface', () => {
|
||||
expect(pushUpdateReadyModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updater manual Linux package download', () => {
|
||||
test('refreshes the check as the user and saves a newer build under its own name', async () => {
|
||||
const {Updater, download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => {
|
||||
emit({type: 'checking', context: 'user'});
|
||||
emitLinuxManualUpdate('user', NEWER_VERSION);
|
||||
};
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
|
||||
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
|
||||
expect(Updater.displayVersion).toBe(NEWER_VERSION);
|
||||
expect(Updater.isChecking).toBe(false);
|
||||
});
|
||||
|
||||
test('saves the build the prompt named when the refresh fails', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves the build the prompt named when the refresh finds no update', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emit({type: 'not-available', context: 'user'});
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(pushUpToDateModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves the build the prompt named when the refresh reports an unsupported install', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emit({type: 'unsupported', context: 'user', reason: 'managed-package'});
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(pushUnsupportedUpdateModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves the build the prompt named when the refresh does not answer in time', async () => {
|
||||
const {Updater, download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => new Promise<void>(() => {});
|
||||
vi.useFakeTimers();
|
||||
const downloaded = download();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await downloaded;
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(Updater.isChecking).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['a newer build', () => emitLinuxManualUpdate('user', NEWER_VERSION)],
|
||||
['no update', () => emit({type: 'not-available', context: 'user'})],
|
||||
['a failure', () => emit({type: 'error', context: 'user', message: 'fetch failed'})],
|
||||
])('stays quiet when a timed out refresh answers with %s after the download', async (_answer, answerLate) => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => new Promise<void>(() => {});
|
||||
vi.useFakeTimers();
|
||||
const downloaded = download();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await downloaded;
|
||||
answerLate();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
|
||||
expect(pushUpToDateModal).not.toHaveBeenCalled();
|
||||
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('saves the refreshed build when a late failure from an earlier refresh lands inside the next one', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => new Promise<void>(() => {});
|
||||
vi.useFakeTimers();
|
||||
const firstDownload = download();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await firstDownload;
|
||||
onUpdaterCheck = () => {
|
||||
emit({type: 'error', context: 'user', message: 'fetch failed'});
|
||||
emitLinuxManualUpdate('user', NEWER_VERSION);
|
||||
};
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD, NEWER_DEB_DOWNLOAD]);
|
||||
expect(pushManualUpdateAvailableModal).toHaveBeenCalledTimes(1);
|
||||
expect(pushUpdateCheckFailedModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('still shows the failure of a user check made while a package is being saved', async () => {
|
||||
const {Updater, download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
|
||||
downloadFile.mockImplementationOnce(() => new Promise<{success: boolean}>(() => {}));
|
||||
void download();
|
||||
await vi.waitFor(() => expect(downloadFile).toHaveBeenCalledTimes(1));
|
||||
onUpdaterCheck = () => emit({type: 'error', context: 'user', message: 'fetch failed'});
|
||||
await Updater.checkForUpdates(true, true);
|
||||
expect(updaterCheck.mock.calls).toEqual([['user'], ['user']]);
|
||||
expect(pushUpdateCheckFailedModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not refresh when a check is already running at the click', async () => {
|
||||
const {Updater, download} = await openLinuxManualUpdatePrompt();
|
||||
void Updater.checkForUpdates(true);
|
||||
await download();
|
||||
expect(updaterCheck).not.toHaveBeenCalled();
|
||||
expect(downloadFile.mock.calls).toEqual([REPORTED_DEB_DOWNLOAD]);
|
||||
});
|
||||
|
||||
test('saves one package when the button is pressed twice during the refresh', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
let answerRefresh = () => {};
|
||||
onUpdaterCheck = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
answerRefresh = () => {
|
||||
emitLinuxManualUpdate('user', NEWER_VERSION);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
const firstClick = download();
|
||||
const secondClick = download();
|
||||
answerRefresh();
|
||||
await Promise.all([firstClick, secondClick]);
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(downloadFile.mock.calls).toEqual([NEWER_DEB_DOWNLOAD]);
|
||||
});
|
||||
|
||||
test('opens the pinned link and never the download page when saving fails', async () => {
|
||||
const {download} = await openLinuxManualUpdatePrompt();
|
||||
onUpdaterCheck = () => emitLinuxManualUpdate('user', REPORTED_VERSION);
|
||||
downloadFile.mockResolvedValueOnce({success: false});
|
||||
await download();
|
||||
expect(updaterCheck.mock.calls).toEqual([['user']]);
|
||||
expect(openExternal.mock.calls).toEqual([[`${CANARY_X64}/${REPORTED_VERSION}/deb`]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ export const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
|
||||
const logger = new Logger('Updater');
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
|
||||
const VERSION_ENDPOINT = '/version.json';
|
||||
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
|
||||
const ALLOWED_WEB_UPDATE_HOSTS = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
|
||||
@@ -132,6 +133,7 @@ class Updater {
|
||||
private backgroundCheckCleanups: Array<() => void> = [];
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private updateReadyNagbarDismissedVersion: string | null = null;
|
||||
private pendingManualDownloadRefreshes = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -288,7 +290,17 @@ class Updater {
|
||||
}
|
||||
|
||||
private handleNativeEvent(event: UpdaterEvent): void {
|
||||
const isUserCheck = event.context === 'user';
|
||||
const isManualDownloadRefreshResult =
|
||||
event.context === 'user' &&
|
||||
this.pendingManualDownloadRefreshes > 0 &&
|
||||
(event.type === 'available' ||
|
||||
event.type === 'not-available' ||
|
||||
event.type === 'error' ||
|
||||
event.type === 'unsupported');
|
||||
if (isManualDownloadRefreshResult) {
|
||||
this.pendingManualDownloadRefreshes -= 1;
|
||||
}
|
||||
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
|
||||
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
|
||||
const shouldSurfaceNativeDesktopUpdate = this.shouldSurfaceNativeDesktopUpdate();
|
||||
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
|
||||
@@ -643,7 +655,7 @@ class Updater {
|
||||
currentVersion: this.currentVersion,
|
||||
version: this.updateInfo.native.version,
|
||||
options: this.nativeManualDownloadOptions,
|
||||
onDownload: (option) => this.downloadManualNativeUpdateOrOpen(option.url, option.suggestedName),
|
||||
onDownload: (option) => this.downloadManualNativeUpdateOption(option),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -651,6 +663,42 @@ class Updater {
|
||||
pushUpdateAvailableModal(this.updateInfo.native.version, () => this.downloadManualNativeUpdateOrOpen(url));
|
||||
}
|
||||
|
||||
private async refreshManualNativeDownloadOption(option: UpdaterDownloadOption): Promise<UpdaterDownloadOption> {
|
||||
if (this.checkInProgress) {
|
||||
return option;
|
||||
}
|
||||
this.transition({type: 'check.started'});
|
||||
let timeoutId: number | undefined;
|
||||
const timedOut = new Promise<boolean>((resolve) => {
|
||||
timeoutId = window.setTimeout(() => resolve(false), MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
this.pendingManualDownloadRefreshes += 1;
|
||||
const checked = await Promise.race([this.checkNativeUpdate('user'), timedOut]);
|
||||
if (!checked) {
|
||||
return option;
|
||||
}
|
||||
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
this.transition({type: 'check.finished', now: Date.now()});
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadManualNativeUpdateOption(option: UpdaterDownloadOption): Promise<void> {
|
||||
if (this.manualNativeDownloadInFlight) {
|
||||
return;
|
||||
}
|
||||
this.transition({type: 'manualDownload.started'});
|
||||
let currentOption = option;
|
||||
try {
|
||||
currentOption = await this.refreshManualNativeDownloadOption(option);
|
||||
} finally {
|
||||
this.transition({type: 'manualDownload.finished'});
|
||||
}
|
||||
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName);
|
||||
}
|
||||
|
||||
private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise<void> {
|
||||
if (this.manualNativeDownloadInFlight) {
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
import type {UserPrivate} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
@@ -123,6 +124,7 @@ class Authentication {
|
||||
|
||||
@action
|
||||
handleLogout(options?: {skipRedirect?: boolean}): void {
|
||||
ExperimentAssignments.reset();
|
||||
this.loginState = LoginState.Default;
|
||||
this.mfaTicket = null;
|
||||
this.mfaMethods = null;
|
||||
|
||||
@@ -92,7 +92,11 @@ import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
|
||||
import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentPermissionUtils';
|
||||
import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils';
|
||||
import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils';
|
||||
import {hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import {
|
||||
canSubmitComposerContent,
|
||||
getComposerMessageContent,
|
||||
hasVisibleMessageContent,
|
||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
|
||||
import {
|
||||
resolveTypedEmojiShortcodes,
|
||||
@@ -267,6 +271,7 @@ export const LexicalChannelTextareaContent = observer(
|
||||
const referencedMessage = MessageReply.getReferencedMessage(channel.id);
|
||||
const editingMessage = editingMobileMessageId ? Messages.getMessage(channel.id, editingMobileMessageId) : null;
|
||||
const editingMessageForComposer = editingMessage === undefined ? null : editingMessage;
|
||||
const isEditingMessageOnMobile = editingMessageForComposer !== null && mobileLayout.enabled;
|
||||
const maxMessageLength = Limits.getMaxMessageLength();
|
||||
const premiumMaxLength = Limits.getStockValue('max_message_length', maxMessageLength);
|
||||
const maxAttachments = Limits.getMaxAttachmentsPerMessage();
|
||||
@@ -496,6 +501,7 @@ export const LexicalChannelTextareaContent = observer(
|
||||
isSlotMenu,
|
||||
onCursorMove,
|
||||
handleSelect,
|
||||
specialMentionsAllowed,
|
||||
} = useLexicalAutocomplete({
|
||||
channel,
|
||||
handleRef,
|
||||
@@ -526,7 +532,11 @@ export const LexicalChannelTextareaContent = observer(
|
||||
() => resolveTypedEmojiContent(wireValue.trim()),
|
||||
[resolveTypedEmojiContent, wireValue],
|
||||
);
|
||||
const hasMessageContent = useMemo(() => hasVisibleMessageContent(trimmedMessageContent), [trimmedMessageContent]);
|
||||
const composerMessageContent = useMemo(
|
||||
() => getComposerMessageContent(trimmedMessageContent, isEditingMessageOnMobile),
|
||||
[isEditingMessageOnMobile, trimmedMessageContent],
|
||||
);
|
||||
const hasMessageContent = useMemo(() => hasVisibleMessageContent(composerMessageContent), [composerMessageContent]);
|
||||
const isSubmissionBlockedBySlowmode = useMemo(() => {
|
||||
if (!isSlowmodeActive || isEditingMessageInComposer) {
|
||||
return false;
|
||||
@@ -708,12 +718,16 @@ export const LexicalChannelTextareaContent = observer(
|
||||
}, [channel.id, hasAttachments, hasPendingSticker]);
|
||||
const showAttachments = hasAttachments;
|
||||
const showStickers = hasPendingSticker;
|
||||
const isOverCharacterLimit = trimmedMessageContent.length > maxMessageLength;
|
||||
const canSubmit =
|
||||
!textareaInputDisabled &&
|
||||
!isSubmissionBlockedBySlowmode &&
|
||||
!isOverCharacterLimit &&
|
||||
(hasMessageContent || hasAttachments || hasPendingSticker);
|
||||
const isOverCharacterLimit = composerMessageContent.length > maxMessageLength;
|
||||
const canSubmit = canSubmitComposerContent({
|
||||
inputDisabled: textareaInputDisabled,
|
||||
isSubmissionBlockedBySlowmode,
|
||||
isOverCharacterLimit,
|
||||
hasMessageContent,
|
||||
hasAttachments,
|
||||
hasPendingSticker,
|
||||
isEditingMessageOnMobile,
|
||||
});
|
||||
const {onSubmit} = useTextareaSubmit({
|
||||
channelId: channel.id,
|
||||
guildId: channel.guildId === undefined ? null : channel.guildId,
|
||||
@@ -1343,9 +1357,11 @@ export const LexicalChannelTextareaContent = observer(
|
||||
initialSegments={initialDraftRef.current.segments}
|
||||
slotResolvers={slotResolvers}
|
||||
emojiShortcodeResolver={composerEmojiResolver}
|
||||
specialMentionsAllowed={specialMentionsAllowed}
|
||||
channelId={channel.id}
|
||||
guildId={channel.guildId}
|
||||
submitOnEnter={!mobileLayout.enabled}
|
||||
silentMessagePrefix={!isEditingMessageOnMobile}
|
||||
focusRingTarget={containerRef}
|
||||
focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing}
|
||||
className={lexicalStyles.composerEditable}
|
||||
@@ -1406,7 +1422,7 @@ export const LexicalChannelTextareaContent = observer(
|
||||
styles.inputSection,
|
||||
)}
|
||||
<MessageCharacterCounter
|
||||
currentLength={trimmedMessageContent.length}
|
||||
currentLength={composerMessageContent.length}
|
||||
maxLength={maxMessageLength}
|
||||
canUpgrade={maxMessageLength < premiumMaxLength}
|
||||
premiumMaxLength={premiumMaxLength}
|
||||
|
||||
@@ -19,6 +19,7 @@ import Emoji from '@app/features/emoji/state/Emoji';
|
||||
import {checkEmojiAvailability} from '@app/features/expressions/utils/ExpressionPermissionUtils';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
||||
@@ -29,6 +30,7 @@ import {hasStyleableMessageText} from '@app/features/messaging/utils/FailedMessa
|
||||
import {
|
||||
buildExistingAttachmentEditReferences,
|
||||
canSubmitEmptyMessageEdit,
|
||||
isAttachmentOnlyMessage,
|
||||
} from '@app/features/messaging/utils/MessageEditContentUtils';
|
||||
import {retryFailedMessage} from '@app/features/messaging/utils/MessageRetryUtils';
|
||||
import {NodeType} from '@app/features/messaging/utils/markdown/parser/Enums';
|
||||
@@ -193,13 +195,13 @@ export const UserMessage = observer(() => {
|
||||
if (message.messageSnapshots) {
|
||||
return;
|
||||
}
|
||||
const content = (actualContent ?? '').trim();
|
||||
const content = dropTrailingEmptyBlockquoteLines(actualContent ?? '').trim();
|
||||
if (!content) {
|
||||
if (isAttachmentOnlyMessage(message)) {
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
if (canSubmitEmptyMessageEdit(message)) {
|
||||
if (message.content.length === 0) {
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
finishEditing();
|
||||
void MessageCommands.edit(
|
||||
channel.id,
|
||||
|
||||
+13
-6
@@ -16,8 +16,10 @@ import {MatureContentSection} from '@app/features/channel/components/modals/chan
|
||||
import {RtcRegionSelect} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect';
|
||||
import {SlowmodeControl} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl';
|
||||
import {
|
||||
BITRATE_KBPS_DEFAULT,
|
||||
CHANNEL_OVERVIEW_TAB_ID,
|
||||
type FormInputs,
|
||||
getMaxBitrateKbps,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
|
||||
import {
|
||||
VoiceConnectionLimitControl,
|
||||
@@ -81,6 +83,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
guildId !== null ? Permission.can(Permissions.UPDATE_RTC_REGION, {guildId, channelId}) : false;
|
||||
const canManageChannel = guildId !== null ? Permission.can(Permissions.MANAGE_CHANNELS, {guildId, channelId}) : false;
|
||||
const isVoiceChannel = channel?.type === ChannelTypes.GUILD_VOICE;
|
||||
const maxBitrateKbps = getMaxBitrateKbps(guild?.features);
|
||||
const [rtcRegions, setRtcRegions] = useState<Array<ChannelRtcRegion>>([]);
|
||||
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
|
||||
const form = useForm<FormInputs>({
|
||||
@@ -92,7 +95,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
nsfw_override: null,
|
||||
content_warning_level: ContentWarningLevel.INHERIT,
|
||||
content_warning_text: '',
|
||||
bitrate: 64,
|
||||
bitrate: BITRATE_KBPS_DEFAULT,
|
||||
user_limit: 0,
|
||||
voice_connection_limit: VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
rtc_region: null,
|
||||
@@ -107,7 +110,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
nsfw_override: channel.nsfwOverride,
|
||||
content_warning_level: channel.contentWarningLevel ?? ContentWarningLevel.INHERIT,
|
||||
content_warning_text: channel.contentWarningText ?? '',
|
||||
bitrate: channel.bitrate ? Math.round(channel.bitrate / 1000) : 64,
|
||||
bitrate: Math.min(channel.bitrate ? Math.round(channel.bitrate / 1000) : BITRATE_KBPS_DEFAULT, maxBitrateKbps),
|
||||
user_limit: channel.userLimit ?? 0,
|
||||
voice_connection_limit: channel.voiceConnectionLimit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
rtc_region: channel.rtcRegion ?? null,
|
||||
@@ -184,7 +187,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
updateData.rate_limit_per_user = data.slowmode;
|
||||
}
|
||||
if (channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
updateData.bitrate = (data.bitrate ?? 64) * 1000;
|
||||
updateData.bitrate = Math.min(data.bitrate ?? BITRATE_KBPS_DEFAULT, maxBitrateKbps) * 1000;
|
||||
updateData.user_limit = data.user_limit;
|
||||
updateData.voice_connection_limit = data.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT;
|
||||
} else if (channel.type === ChannelTypes.GUILD_LINK) {
|
||||
@@ -216,7 +219,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
nsfw_override: data.nsfw_override,
|
||||
content_warning_level: data.content_warning_level,
|
||||
content_warning_text: data.content_warning_text ?? '',
|
||||
bitrate: data.bitrate ?? currentValues.bitrate ?? 64,
|
||||
bitrate: Math.min(data.bitrate ?? currentValues.bitrate ?? BITRATE_KBPS_DEFAULT, maxBitrateKbps),
|
||||
user_limit: data.user_limit ?? currentValues.user_limit ?? 0,
|
||||
voice_connection_limit:
|
||||
data.voice_connection_limit ?? currentValues.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
@@ -224,7 +227,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
});
|
||||
ToastCommands.createToast({type: 'success', children: <Trans>Channel updated</Trans>});
|
||||
},
|
||||
[canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues],
|
||||
[canManageChannel, canUpdateRtcRegion, channel, form, commitRemoteValues, maxBitrateKbps],
|
||||
);
|
||||
const {handleSubmit: handleSave} = useFormSubmit({
|
||||
form,
|
||||
@@ -309,7 +312,11 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
{showVoiceSection && (
|
||||
<div className={styles.settingsGroup} data-flx="channel.channel-tabs.channel-overview-tab.settings-group--3">
|
||||
{canManageChannel && (
|
||||
<VoiceSettings form={form} data-flx="channel.channel-tabs.channel-overview-tab.voice-settings" />
|
||||
<VoiceSettings
|
||||
form={form}
|
||||
maxBitrateKbps={maxBitrateKbps}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings"
|
||||
/>
|
||||
)}
|
||||
{canUpdateRtcRegion && (
|
||||
<RtcRegionSelect
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
||||
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
||||
import {dropTrailingEmptyBlockquoteLines} from '@app/features/lexical/composer/blockquoteLines';
|
||||
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
|
||||
@@ -145,7 +146,7 @@ export const ChannelOverviewTopicEditor = observer(
|
||||
});
|
||||
}, [actualTopic, form, isTopicInitialized]);
|
||||
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
||||
setActualTopic(wire);
|
||||
setActualTopic(dropTrailingEmptyBlockquoteLines(wire));
|
||||
}, []);
|
||||
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
|
||||
const composer = composerRef.current;
|
||||
|
||||
+14
-13
@@ -3,10 +3,10 @@
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
|
||||
import {SettingsControlRow} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SettingsControlRow';
|
||||
import {
|
||||
BITRATE_KBPS_MARKERS,
|
||||
BITRATE_KBPS_MAX,
|
||||
BITRATE_KBPS_DEFAULT,
|
||||
BITRATE_KBPS_MIN,
|
||||
type FormInputs,
|
||||
getBitrateKbpsMarkers,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
|
||||
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
|
||||
import {
|
||||
@@ -50,6 +50,7 @@ const CONNECTION_LIMIT_VALUE_DESCRIPTOR = msg({
|
||||
|
||||
interface VoiceSettingsProps {
|
||||
form: UseFormReturn<FormInputs>;
|
||||
maxBitrateKbps: number;
|
||||
}
|
||||
|
||||
const formatIntegerValue = (value: number): string => String(Math.round(value));
|
||||
@@ -66,15 +67,14 @@ const formatConnectionLimitMarker = (value: number): string | null => {
|
||||
|
||||
const VoiceBitrateSlider: React.FC<{
|
||||
value: number | undefined;
|
||||
maxBitrateKbps: number;
|
||||
onChange: (value: number) => void;
|
||||
}> = ({value, onChange}) => {
|
||||
}> = ({value, maxBitrateKbps, onChange}) => {
|
||||
const {i18n} = useLingui();
|
||||
const voiceQualityLabel = i18n._(VOICE_QUALITY_DESCRIPTOR);
|
||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||
let currentValue = value;
|
||||
if (typeof currentValue !== 'number') {
|
||||
currentValue = 64;
|
||||
}
|
||||
const factoryDefaultValue = Math.min(BITRATE_KBPS_DEFAULT, maxBitrateKbps);
|
||||
const currentValue = Math.min(typeof value === 'number' ? value : BITRATE_KBPS_DEFAULT, maxBitrateKbps);
|
||||
return (
|
||||
<SettingsControlRow
|
||||
label={voiceQualityLabel}
|
||||
@@ -88,18 +88,18 @@ const VoiceBitrateSlider: React.FC<{
|
||||
<Slider
|
||||
value={currentValue}
|
||||
defaultValue={currentValue}
|
||||
factoryDefaultValue={64}
|
||||
factoryDefaultValue={factoryDefaultValue}
|
||||
minValue={BITRATE_KBPS_MIN}
|
||||
maxValue={BITRATE_KBPS_MAX}
|
||||
maxValue={maxBitrateKbps}
|
||||
step={1}
|
||||
markers={[...BITRATE_KBPS_MARKERS]}
|
||||
markers={getBitrateKbpsMarkers(maxBitrateKbps)}
|
||||
ariaLabel={voiceQualityLabel}
|
||||
ariaValueText={i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(currentValue)})}
|
||||
onMarkerRender={formatIntegerValue}
|
||||
onValueRender={(kilobits) => i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(kilobits)})}
|
||||
onValueChange={(kilobits) => onChange(Math.round(kilobits))}
|
||||
showResetButton={true}
|
||||
onReset={() => onChange(64)}
|
||||
onReset={() => onChange(factoryDefaultValue)}
|
||||
resetTooltip={resetSliderLabel}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider.slider"
|
||||
/>
|
||||
@@ -108,7 +108,7 @@ const VoiceBitrateSlider: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form, maxBitrateKbps}) => {
|
||||
const {i18n} = useLingui();
|
||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||
const participantLimitLabel = i18n._(PARTICIPANT_LIMIT_LABEL_DESCRIPTOR);
|
||||
@@ -121,6 +121,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
render={({field}) => (
|
||||
<VoiceBitrateSlider
|
||||
value={field.value}
|
||||
maxBitrateKbps={maxBitrateKbps}
|
||||
onChange={field.onChange}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider.change"
|
||||
/>
|
||||
@@ -172,7 +173,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const VoiceConnectionLimitControl: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
export const VoiceConnectionLimitControl: React.FC<{form: UseFormReturn<FormInputs>}> = ({form}) => {
|
||||
const {i18n} = useLingui();
|
||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||
const connectionLimitLabel = i18n._(CONNECTION_LIMIT_LABEL_DESCRIPTOR);
|
||||
|
||||
+13
-3
@@ -2,7 +2,8 @@
|
||||
|
||||
import type {ChannelRtcRegion} from '@app/features/channel/commands/ChannelCommands';
|
||||
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||
import {VOICE_CHANNEL_BITRATE_MAX, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
|
||||
import {getMaxVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import {VOICE_CHANNEL_BITRATE_DEFAULT, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
|
||||
|
||||
export interface FormInputs {
|
||||
name: string;
|
||||
@@ -20,8 +21,17 @@ export interface FormInputs {
|
||||
|
||||
export const CHANNEL_OVERVIEW_TAB_ID = 'overview';
|
||||
export const BITRATE_KBPS_MIN = VOICE_CHANNEL_BITRATE_MIN / 1000;
|
||||
export const BITRATE_KBPS_MAX = VOICE_CHANNEL_BITRATE_MAX / 1000;
|
||||
export const BITRATE_KBPS_MARKERS: ReadonlyArray<number> = [BITRATE_KBPS_MIN, 64, 128, 256, BITRATE_KBPS_MAX];
|
||||
export const BITRATE_KBPS_DEFAULT = VOICE_CHANNEL_BITRATE_DEFAULT / 1000;
|
||||
|
||||
export function getMaxBitrateKbps(guildFeatures: Iterable<string> | null | undefined): number {
|
||||
return getMaxVoiceChannelBitrate(guildFeatures) / 1000;
|
||||
}
|
||||
|
||||
export function getBitrateKbpsMarkers(maxKbps: number): Array<number> {
|
||||
const markers = [BITRATE_KBPS_MIN, BITRATE_KBPS_DEFAULT, 128, 256].filter((marker) => marker < maxKbps);
|
||||
markers.push(maxKbps);
|
||||
return markers;
|
||||
}
|
||||
export const MAX_TOPIC_LENGTH = 1024;
|
||||
|
||||
export interface RtcRegionOption extends ComboboxOption<string | null> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {FavoriteGifEntry} from './FavoriteGifTypes';
|
||||
import {buildGifPickerGridData} from './GifPickerGridData';
|
||||
import type {FavoriteAwareGif} from './GifPickerTypes';
|
||||
|
||||
@@ -19,7 +20,84 @@ function gif(id: string, width = 200, height = 120): FavoriteAwareGif {
|
||||
};
|
||||
}
|
||||
|
||||
const OPAQUE_PROXY_URL =
|
||||
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
|
||||
|
||||
function slimFavorite(overrides: Partial<FavoriteGifEntry> = {}): FavoriteGifEntry {
|
||||
return {
|
||||
url: 'https://klipy.com/gifs/doc-brown-bttf',
|
||||
proxy_url: OPAQUE_PROXY_URL,
|
||||
width: 640,
|
||||
height: 360,
|
||||
media: {},
|
||||
content_type: 'video/webm',
|
||||
placeholder: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GifPickerGridData', () => {
|
||||
it('carries the stored content type of a slimmed favorite into its tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'favorites',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [slimFavorite()],
|
||||
favoriteMemes: [],
|
||||
useSavedMediaForGifFavorites: false,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({
|
||||
type: 'gif',
|
||||
gif: {proxy_src: OPAQUE_PROXY_URL, contentType: 'video/webm'},
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the stored content type of a slimmed favorite into the featured favorites tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [gif('featured')], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [slimFavorite()],
|
||||
favoriteMemes: [],
|
||||
useSavedMediaForGifFavorites: false,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({
|
||||
type: 'category',
|
||||
previewProxySrc: OPAQUE_PROXY_URL,
|
||||
previewContentType: 'video/webm',
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the content type of the saved media standing in for an empty favorites tile', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
loading: false,
|
||||
columns: 3,
|
||||
provider: 'klipy',
|
||||
featured: {gifs: [gif('featured')], categories: []},
|
||||
gifs: [],
|
||||
favoriteGifs: [],
|
||||
favoriteMemes: [{contentType: 'video/mp4', url: 'meme-0'}],
|
||||
useSavedMediaForGifFavorites: true,
|
||||
featuredFavoritePreviewSeed: 0,
|
||||
favoriteTitle: 'Favorites',
|
||||
trendingTitle: 'Trending',
|
||||
});
|
||||
expect(data[0]).toMatchObject({type: 'category', previewProxySrc: 'meme-0', previewContentType: 'video/mp4'});
|
||||
});
|
||||
|
||||
it('keeps featured data deterministic while choosing preview tiles', () => {
|
||||
const data = buildGifPickerGridData({
|
||||
surface: 'featured',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type FavoriteGifEntry,
|
||||
inferFormatContentType,
|
||||
pickBestPreviewFormat,
|
||||
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
@@ -72,6 +73,7 @@ function buildFavoriteGifItems(
|
||||
const previewProxySrc = best?.format.proxy_src ?? fallbackSrc;
|
||||
const previewWidth = best?.format.width ?? entry.width;
|
||||
const previewHeight = best?.format.height ?? entry.height;
|
||||
const previewContentType = best ? inferFormatContentType(best.key) : entry.content_type;
|
||||
items.push({
|
||||
type: 'gif',
|
||||
key: entry.url,
|
||||
@@ -86,6 +88,7 @@ function buildFavoriteGifItems(
|
||||
width: previewWidth > 0 ? previewWidth : DEFAULT_GIF_SIZE,
|
||||
height: previewHeight > 0 ? previewHeight : DEFAULT_GIF_SIZE,
|
||||
media: entry.media,
|
||||
contentType: previewContentType,
|
||||
favoriteGifLookup: {url: entry.url},
|
||||
},
|
||||
});
|
||||
@@ -102,21 +105,21 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
|
||||
const favoriteGifPreviewEntry =
|
||||
favoriteGifPreviewIndex >= 0 ? input.favoriteGifs[favoriteGifPreviewIndex] : undefined;
|
||||
const favoriteGifPreview = pickBestPreviewFormat(favoriteGifPreviewEntry?.media);
|
||||
const favoriteMemePreview =
|
||||
gifvMemes.length > 0
|
||||
? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)]?.url ?? '')
|
||||
: '';
|
||||
const favoriteTilePreview =
|
||||
input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0
|
||||
? favoriteMemePreview
|
||||
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileProxyPreview =
|
||||
input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0
|
||||
? favoriteTilePreview
|
||||
: favoriteGifPreview?.format.proxy_src ||
|
||||
favoriteGifPreviewEntry?.proxy_url ||
|
||||
favoriteGifPreviewEntry?.url ||
|
||||
'';
|
||||
const favoriteMemeCandidate =
|
||||
gifvMemes.length > 0 ? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)] ?? null) : null;
|
||||
const favoriteMemePreview = favoriteMemeCandidate?.url ?? '';
|
||||
const usesFavoriteMemePreview = input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0;
|
||||
const favoriteTilePreview = usesFavoriteMemePreview
|
||||
? favoriteMemePreview
|
||||
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileProxyPreview = usesFavoriteMemePreview
|
||||
? favoriteTilePreview
|
||||
: favoriteGifPreview?.format.proxy_src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
const favoriteTileContentType = (() => {
|
||||
if (usesFavoriteMemePreview) return favoriteMemeCandidate?.contentType ?? '';
|
||||
if (favoriteGifPreview) return inferFormatContentType(favoriteGifPreview.key);
|
||||
return favoriteGifPreviewEntry?.proxy_url ? favoriteGifPreviewEntry.content_type : '';
|
||||
})();
|
||||
const favoritesTile: Array<GifPickerGridItemData> =
|
||||
(input.includeFavoritesTile ?? true)
|
||||
? [
|
||||
@@ -128,6 +131,7 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
|
||||
title: input.favoriteTitle,
|
||||
previewUrl: favoriteTilePreview,
|
||||
previewProxySrc: favoriteTileProxyPreview,
|
||||
previewContentType: favoriteTileContentType,
|
||||
width: CATEGORY_TILE_WIDTH,
|
||||
height: CATEGORY_TILE_HEIGHT,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import styles from '@app/features/channel/components/GifPicker.module.css';
|
||||
import {safePause, safePlay, useGifVideoPool} from '@app/features/channel/components/GifVideoPool';
|
||||
import {resolvesToVideo} from '@app/features/channel/components/pickers/gif/GifPickerMediaKind';
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
import {PickerThumbnail} from '@app/features/channel/components/pickers/shared/PickerThumbnail';
|
||||
import {usePooledVideo} from '@app/features/channel/components/pickers/shared/usePooledVideo';
|
||||
@@ -52,30 +53,6 @@ const SELECT_GIF_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Button label in the gif picker grid item when picking a GIF for an avatar, banner, or video background. Keep it concise. Preserve {title}; it is inserted by code.',
|
||||
});
|
||||
const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
|
||||
const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu;
|
||||
|
||||
function testSourcePath(value: string, pattern: RegExp): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return pattern.test(url.pathname);
|
||||
} catch {
|
||||
return pattern.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoSourceUrl(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function statesItsMediaKind(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function resolvesToVideo(proxySrc: string, mediaSourceUrl: string | null): boolean {
|
||||
if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc);
|
||||
return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl);
|
||||
}
|
||||
|
||||
export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
item,
|
||||
@@ -126,6 +103,11 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
if (item.type === 'category') return item.previewUrl;
|
||||
return null;
|
||||
})();
|
||||
const mediaContentType = (() => {
|
||||
if (item.type === 'gif') return item.gif.contentType ?? '';
|
||||
if (item.type === 'category') return item.previewContentType ?? '';
|
||||
return '';
|
||||
})();
|
||||
const thumbnailPlaceholder = (() => {
|
||||
if (item.type !== 'gif') return null;
|
||||
if (item.gif.placeholder) return item.gif.placeholder;
|
||||
@@ -133,7 +115,10 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
return FavoriteGif.findByUrl(lookupUrl)?.placeholder ?? null;
|
||||
})();
|
||||
const usesVideoElement =
|
||||
!isSkeleton && proxySrc !== null && proxySrc.length > 0 && resolvesToVideo(proxySrc, mediaSourceUrl);
|
||||
!isSkeleton &&
|
||||
proxySrc !== null &&
|
||||
proxySrc.length > 0 &&
|
||||
resolvesToVideo(mediaContentType, proxySrc, mediaSourceUrl);
|
||||
const videoThumbHashURL = decodeThumbHashDataURL(usesVideoElement ? thumbnailPlaceholder : null);
|
||||
const hasThumbnailContent = (proxySrc !== null && proxySrc.length > 0) || thumbnailPlaceholder !== null;
|
||||
const videoRef = usePooledVideo({
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {resolvesToVideo} from './GifPickerMediaKind';
|
||||
|
||||
const OPAQUE_PROXY_SRC =
|
||||
'https://media.test/external/sig/v2/aHR0cHM6Ly9zdGF0aWMua2xpcHkuY29tL2lpLzhjLzZhL2E4LzVsU2U1SFBCLndlYm0';
|
||||
const PLAIN_PROXY_SRC = 'https://media.test/external/sig/https/static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm';
|
||||
|
||||
describe('resolvesToVideo', () => {
|
||||
it('trusts a stored video content type when no url states its kind', () => {
|
||||
expect(resolvesToVideo('video/webm', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(true);
|
||||
});
|
||||
|
||||
it('trusts a stored image content type when no url states its kind', () => {
|
||||
expect(resolvesToVideo('image/gif', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to an image when nothing states the kind', () => {
|
||||
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, OPAQUE_PROXY_SRC)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the extension off a plain proxy path', () => {
|
||||
expect(resolvesToVideo('', PLAIN_PROXY_SRC, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the extension off the media source when the proxy path is opaque', () => {
|
||||
expect(resolvesToVideo('', OPAQUE_PROXY_SRC, 'https://static.klipy.com/ii/8c/6a/a8/5lSe5HPB.webm')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps images as images when the proxy path states its kind', () => {
|
||||
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.gif', null)).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a query string when reading the extension', () => {
|
||||
expect(resolvesToVideo('', 'https://media.test/external/sig/https/cdn.test/a.mp4?width=200', null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const VIDEO_FILE_EXTENSION_REGEX = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
|
||||
const IMAGE_FILE_EXTENSION_REGEX = /\.(gif|webp|png|jpe?g|avif)(?:$|\?)/iu;
|
||||
|
||||
function testSourcePath(value: string, pattern: RegExp): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return pattern.test(url.pathname);
|
||||
} catch {
|
||||
return pattern.test(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoSourceUrl(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
function statesItsMediaKind(value: string): boolean {
|
||||
return testSourcePath(value, VIDEO_FILE_EXTENSION_REGEX) || testSourcePath(value, IMAGE_FILE_EXTENSION_REGEX);
|
||||
}
|
||||
|
||||
export function resolvesToVideo(contentType: string, proxySrc: string, mediaSourceUrl: string | null): boolean {
|
||||
if (contentType.startsWith('video/')) return true;
|
||||
if (contentType.startsWith('image/')) return false;
|
||||
if (statesItsMediaKind(proxySrc)) return isVideoSourceUrl(proxySrc);
|
||||
return mediaSourceUrl !== null && isVideoSourceUrl(mediaSourceUrl);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export interface FavoriteGifLookup {
|
||||
|
||||
export type FavoriteAwareGif = Gif & {
|
||||
favoriteGifLookup?: FavoriteGifLookup;
|
||||
contentType?: string;
|
||||
};
|
||||
export type GifPickerGridItemData =
|
||||
| {
|
||||
@@ -20,6 +21,7 @@ export type GifPickerGridItemData =
|
||||
categoryKind: 'favorites' | 'trending' | 'category';
|
||||
previewUrl: string;
|
||||
previewProxySrc: string;
|
||||
previewContentType?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import ContextMenuState from '@app/features/ui/state/ContextMenu';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical';
|
||||
import {$addUpdateTag, COMMAND_PRIORITY_HIGH, PASTE_COMMAND, PASTE_TAG} from 'lexical';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect} from 'react';
|
||||
|
||||
@@ -233,6 +233,7 @@ export function useChannelComposerPaste({
|
||||
if (!insertPastedText(pastedText)) {
|
||||
return false;
|
||||
}
|
||||
$addUpdateTag(PASTE_TAG);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {selectChannel} from '@app/features/navigation/commands/NavigationCommands';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants';
|
||||
import {
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
|
||||
export interface FormInputs {
|
||||
name: string;
|
||||
@@ -52,7 +55,7 @@ export async function createChannel(guildId: string, data: FormInputs, parentId?
|
||||
url: data.url,
|
||||
type: channelType,
|
||||
parent_id: parentId || null,
|
||||
bitrate: channelType === ChannelTypes.GUILD_VOICE ? 64000 : null,
|
||||
bitrate: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_BITRATE_DEFAULT : null,
|
||||
user_limit: channelType === ChannelTypes.GUILD_VOICE ? 0 : null,
|
||||
voice_connection_limit: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
|
||||
import type {VoiceNoiseSuppressionAssignmentResponse} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
|
||||
import {
|
||||
type ExperimentAssignmentsResponse,
|
||||
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
readVoiceNoiseSuppressionAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/platform/utils/AppLogger', () => ({
|
||||
Logger: class {
|
||||
debug = vi.fn();
|
||||
info = vi.fn();
|
||||
warn = vi.fn();
|
||||
error = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({
|
||||
http: {get: vi.fn(), post: vi.fn()},
|
||||
}));
|
||||
|
||||
const {http} = await import('@app/features/platform/transport/RestTransport');
|
||||
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
|
||||
|
||||
const GUILD_ID = '1485064866382176262';
|
||||
|
||||
const CANARY_ASSIGNMENT: VoiceNoiseSuppressionAssignmentResponse = {
|
||||
enabled: true,
|
||||
config_version: 7,
|
||||
user_targeted: true,
|
||||
backend: 'rnnoise',
|
||||
source: 'canary',
|
||||
guild_overrides: [{guild_id: GUILD_ID, backend: 'speex'}],
|
||||
enabled_backends: ['none', 'speex', 'rnnoise', 'gtcrn'],
|
||||
allow_user_override: true,
|
||||
stereo_enabled: false,
|
||||
suppression_strength: 80,
|
||||
};
|
||||
|
||||
const CANARY_ENVELOPE: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: 300,
|
||||
poll_jitter_percent: 15,
|
||||
assignments: {voice_noise_suppression: CANARY_ASSIGNMENT},
|
||||
};
|
||||
|
||||
interface ScheduleRecorder {
|
||||
delays: Array<number>;
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
function recordScheduledDelays(): ScheduleRecorder {
|
||||
const delays: Array<number> = [];
|
||||
const original = globalThis.setTimeout;
|
||||
const patched = (...args: Array<unknown>) => {
|
||||
const timeout = args[1];
|
||||
delays.push(typeof timeout === 'number' ? timeout : 0);
|
||||
return (original as unknown as (...callArgs: Array<unknown>) => NodeJS.Timeout)(...args);
|
||||
};
|
||||
globalThis.setTimeout = patched as unknown as typeof globalThis.setTimeout;
|
||||
return {
|
||||
delays,
|
||||
restore: () => {
|
||||
globalThis.setTimeout = original;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let recorder: ScheduleRecorder;
|
||||
let visibility: DocumentVisibilityState = 'visible';
|
||||
const unsubscribes: Array<() => void> = [];
|
||||
|
||||
function reply(status: number, body: unknown, headers: Record<string, string> = {}): RestResponse<unknown> {
|
||||
return {ok: status >= 200 && status < 300, status, statusText: '', headers, body};
|
||||
}
|
||||
|
||||
function requestHeaders(index: number): Record<string, string> {
|
||||
return vi.mocked(http.get).mock.calls[index]?.[1]?.headers ?? {};
|
||||
}
|
||||
|
||||
function lastScheduledDelayMs(): number {
|
||||
return recorder.delays[recorder.delays.length - 1] ?? Number.NaN;
|
||||
}
|
||||
|
||||
function setVisibility(next: DocumentVisibilityState): void {
|
||||
visibility = next;
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
const unsubscribe = ExperimentAssignments.subscribe(listener);
|
||||
unsubscribes.push(unsubscribe);
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
function voiceConfigVersion(): number {
|
||||
return readVoiceNoiseSuppressionAssignment(ExperimentAssignments.response).config_version;
|
||||
}
|
||||
|
||||
function deferredReply(): {resolve: (response: RestResponse<unknown>) => void} {
|
||||
let resolve: (response: RestResponse<unknown>) => void = () => {};
|
||||
const pending = new Promise<RestResponse<unknown>>((settleWith) => {
|
||||
resolve = settleWith;
|
||||
});
|
||||
vi.mocked(http.get).mockReturnValue(pending);
|
||||
return {
|
||||
resolve: (response) => {
|
||||
resolve(response);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function adopt(response: ExperimentAssignmentsResponse): Promise<void> {
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, response, {etag: 'W/"seed"'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
visibility = 'visible';
|
||||
Object.defineProperty(document, 'visibilityState', {configurable: true, get: () => visibility});
|
||||
vi.useFakeTimers();
|
||||
recorder = recordScheduledDelays();
|
||||
vi.mocked(http.get).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const unsubscribe of unsubscribes.splice(0)) {
|
||||
unsubscribe();
|
||||
}
|
||||
ExperimentAssignments.reset();
|
||||
recorder.restore();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('ExperimentAssignments cold start', () => {
|
||||
it('keeps the inert envelope when the endpoint is unreachable', async () => {
|
||||
vi.mocked(http.get).mockRejectedValue(new Error('network unreachable'));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
|
||||
expect(ExperimentAssignments.response.assignments.voice_noise_suppression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the inert envelope while unauthenticated and retries later', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(reply(401, {message: '401: Unauthorized'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
|
||||
expect(lastScheduledDelayMs()).toBeLessThanOrEqual(690_000);
|
||||
await vi.advanceTimersByTimeAsync(700_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExperimentAssignments response handling', () => {
|
||||
it('adopts a valid envelope', async () => {
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
expect(readVoiceNoiseSuppressionAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT);
|
||||
});
|
||||
|
||||
it('requests the shared experiment endpoint', async () => {
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(vi.mocked(http.get).mock.calls[0]?.[0]).toBe('/experiments');
|
||||
});
|
||||
|
||||
it('discards a malformed envelope and keeps the previous value', async () => {
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
vi.mocked(http.get).mockResolvedValue(
|
||||
reply(200, {
|
||||
...CANARY_ENVELOPE,
|
||||
assignments: {voice_noise_suppression: {...CANARY_ASSIGNMENT, backend: 'telepathy'}},
|
||||
}),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(2);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
});
|
||||
|
||||
it('discards a partial envelope rather than merging it', async () => {
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, {assignments: {}}));
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
});
|
||||
|
||||
it('accepts an envelope that carries no voice noise suppression assignment', async () => {
|
||||
await adopt({poll_interval_seconds: 600, poll_jitter_percent: 0, assignments: {}});
|
||||
expect(ExperimentAssignments.response.assignments.voice_noise_suppression).toBeUndefined();
|
||||
expect(lastScheduledDelayMs()).toBe(600_000);
|
||||
});
|
||||
|
||||
it('keeps the value and the stored etag across a 304', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(reply(200, CANARY_ENVELOPE, {etag: 'W/"v7"'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(requestHeaders(0)['If-None-Match']).toBeUndefined();
|
||||
vi.mocked(http.get).mockResolvedValue(reply(304, undefined));
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(requestHeaders(1)['If-None-Match']).toBe('W/"v7"');
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(requestHeaders(2)['If-None-Match']).toBe('W/"v7"');
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExperimentAssignments scheduling', () => {
|
||||
it('takes the poll interval from the envelope and jitters it by fifteen percent', async () => {
|
||||
const observed: Array<number> = [];
|
||||
for (let run = 0; run < 64; run++) {
|
||||
ExperimentAssignments.reset();
|
||||
await adopt({...CANARY_ENVELOPE, poll_interval_seconds: 600});
|
||||
observed.push(lastScheduledDelayMs());
|
||||
}
|
||||
for (const delay of observed) {
|
||||
expect(delay).toBeGreaterThanOrEqual(510_000);
|
||||
expect(delay).toBeLessThanOrEqual(690_000);
|
||||
}
|
||||
expect(new Set(observed).size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('takes the jitter ratio from the envelope', async () => {
|
||||
const observed: Array<number> = [];
|
||||
for (let run = 0; run < 64; run++) {
|
||||
ExperimentAssignments.reset();
|
||||
await adopt({...CANARY_ENVELOPE, poll_interval_seconds: 600, poll_jitter_percent: 50});
|
||||
observed.push(lastScheduledDelayMs());
|
||||
}
|
||||
for (const delay of observed) {
|
||||
expect(delay).toBeGreaterThanOrEqual(300_000);
|
||||
expect(delay).toBeLessThanOrEqual(900_000);
|
||||
}
|
||||
expect(observed.some((delay) => delay < 510_000 || delay > 690_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('schedules an exact interval when the server disables jitter', async () => {
|
||||
for (let run = 0; run < 8; run++) {
|
||||
ExperimentAssignments.reset();
|
||||
await adopt({...CANARY_ENVELOPE, poll_interval_seconds: 600, poll_jitter_percent: 0});
|
||||
expect(lastScheduledDelayMs()).toBe(600_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('never schedules a poll below half the minimum interval', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
await adopt({...CANARY_ENVELOPE, poll_interval_seconds: 60, poll_jitter_percent: 50});
|
||||
expect(lastScheduledDelayMs()).toBeGreaterThanOrEqual(30_000);
|
||||
});
|
||||
|
||||
it('clamps an out-of-range poll interval into the supported window', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
await adopt({...CANARY_ENVELOPE, poll_interval_seconds: 1});
|
||||
expect(lastScheduledDelayMs()).toBe(60_000);
|
||||
ExperimentAssignments.reset();
|
||||
await adopt({...CANARY_ENVELOPE, poll_interval_seconds: 999_999});
|
||||
expect(lastScheduledDelayMs()).toBe(86_400_000);
|
||||
});
|
||||
|
||||
it('schedules one timer for repeated starts and cancels it on stop', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, CANARY_ENVELOPE, {etag: 'W/"v7"'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
ExperimentAssignments.stop();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(1_000_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('grows the retry delay on failure and resets it on success', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
vi.mocked(http.get).mockRejectedValue(new Error('network unreachable'));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(lastScheduledDelayMs()).toBe(600_000);
|
||||
await vi.advanceTimersByTimeAsync(600_000);
|
||||
expect(lastScheduledDelayMs()).toBe(1_200_000);
|
||||
await vi.advanceTimersByTimeAsync(1_200_000);
|
||||
expect(lastScheduledDelayMs()).toBe(1_200_000);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, CANARY_ENVELOPE, {etag: 'W/"v7"'}));
|
||||
await vi.advanceTimersByTimeAsync(1_200_000);
|
||||
expect(lastScheduledDelayMs()).toBe(300_000);
|
||||
});
|
||||
|
||||
it('backs off on a server error the same way it does on a transport failure', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(500, {message: '500: Internal Server Error'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(lastScheduledDelayMs()).toBe(600_000);
|
||||
await vi.advanceTimersByTimeAsync(600_000);
|
||||
expect(lastScheduledDelayMs()).toBe(1_200_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExperimentAssignments visibility', () => {
|
||||
it('keeps polling while the document is hidden at four times the interval', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
visibility = 'hidden';
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, CANARY_ENVELOPE, {etag: 'W/"v7"'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).not.toHaveBeenCalled();
|
||||
expect(lastScheduledDelayMs()).toBe(1_200_000);
|
||||
await vi.advanceTimersByTimeAsync(1_200_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
expect(lastScheduledDelayMs()).toBe(1_200_000);
|
||||
await vi.advanceTimersByTimeAsync(1_200_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
setVisibility('visible');
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(3);
|
||||
expect(lastScheduledDelayMs()).toBe(300_000);
|
||||
});
|
||||
|
||||
it('fetches once the document becomes visible', async () => {
|
||||
visibility = 'hidden';
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, CANARY_ENVELOPE, {etag: 'W/"v7"'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
setVisibility('visible');
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
});
|
||||
|
||||
it('collapses repeated visibility flips inside thirty seconds into one fetch', async () => {
|
||||
visibility = 'hidden';
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, CANARY_ENVELOPE, {etag: 'W/"v7"'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
setVisibility('visible');
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
setVisibility('hidden');
|
||||
await vi.advanceTimersByTimeAsync(29_000);
|
||||
setVisibility('visible');
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
setVisibility('hidden');
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
setVisibility('visible');
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops listening for visibility changes after stop', async () => {
|
||||
visibility = 'hidden';
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, CANARY_ENVELOPE, {etag: 'W/"v7"'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
ExperimentAssignments.stop();
|
||||
setVisibility('visible');
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExperimentAssignments lifecycle', () => {
|
||||
it('discards a response that lands after reset', async () => {
|
||||
const deferred = deferredReply();
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
ExperimentAssignments.reset();
|
||||
deferred.resolve(reply(200, CANARY_ENVELOPE, {etag: 'W/"stale"'}));
|
||||
await settle();
|
||||
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(2_000_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not carry the etag or the backoff of the previous session across a reset', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(500, {message: '500: Internal Server Error'}));
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(lastScheduledDelayMs()).toBe(600_000);
|
||||
ExperimentAssignments.reset();
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, CANARY_ENVELOPE, {etag: 'W/"fresh"'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(requestHeaders(2)['If-None-Match']).toBeUndefined();
|
||||
expect(lastScheduledDelayMs()).toBe(300_000);
|
||||
});
|
||||
|
||||
it('discards a response that lands after stop and does not reschedule', async () => {
|
||||
const deferred = deferredReply();
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
ExperimentAssignments.stop();
|
||||
deferred.resolve(reply(200, CANARY_ENVELOPE, {etag: 'W/"stale"'}));
|
||||
await settle();
|
||||
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExperimentAssignments subscribe', () => {
|
||||
it('fires once per envelope change and stops after unsubscribe', async () => {
|
||||
const seen: Array<number> = [];
|
||||
const unsubscribe = subscribe(() => seen.push(voiceConfigVersion()));
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(seen).toEqual([7]);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(304, undefined));
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(seen).toEqual([7]);
|
||||
vi.mocked(http.get).mockResolvedValue(
|
||||
reply(
|
||||
200,
|
||||
{...CANARY_ENVELOPE, assignments: {voice_noise_suppression: {...CANARY_ASSIGNMENT, config_version: 8}}},
|
||||
{etag: 'W/"v8"'},
|
||||
),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
expect(seen).toEqual([7, 8]);
|
||||
ExperimentAssignments.reset();
|
||||
expect(seen).toEqual([7, 8, 0]);
|
||||
unsubscribe();
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(seen).toEqual([7, 8, 0]);
|
||||
});
|
||||
|
||||
it('does not fire when a malformed or rejected response is discarded', async () => {
|
||||
let calls = 0;
|
||||
subscribe(() => {
|
||||
calls += 1;
|
||||
});
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(calls).toBe(1);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, {assignments: {}}));
|
||||
await vi.advanceTimersByTimeAsync(400_000);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(401, {message: '401: Unauthorized'}));
|
||||
await vi.advanceTimersByTimeAsync(700_000);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps notifying the remaining listeners when one throws', async () => {
|
||||
const seen: Array<string> = [];
|
||||
subscribe(() => {
|
||||
seen.push('first');
|
||||
throw new Error('listener exploded');
|
||||
});
|
||||
subscribe(() => {
|
||||
seen.push('second');
|
||||
});
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
expect(seen).toEqual(['first', 'second']);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExperimentAssignments endpoint failures', () => {
|
||||
it('backs off while the session is rejected', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
await adopt(CANARY_ENVELOPE);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(401, {message: '401: Unauthorized'}));
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
expect(lastScheduledDelayMs()).toBe(600_000);
|
||||
await vi.advanceTimersByTimeAsync(600_000);
|
||||
expect(lastScheduledDelayMs()).toBe(1_200_000);
|
||||
await vi.advanceTimersByTimeAsync(1_200_000);
|
||||
expect(lastScheduledDelayMs()).toBe(1_200_000);
|
||||
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
|
||||
});
|
||||
|
||||
it('backs off while the session is forbidden', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(403, {message: '403: Forbidden'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(lastScheduledDelayMs()).toBe(600_000);
|
||||
await vi.advanceTimersByTimeAsync(600_000);
|
||||
expect(lastScheduledDelayMs()).toBe(1_200_000);
|
||||
});
|
||||
|
||||
it('stops polling for the session after three consecutive 404s', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(404, {message: '404: Not Found'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(600_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1_200_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(3);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(10_000_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(3);
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
setVisibility('visible');
|
||||
await settle();
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(3);
|
||||
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
|
||||
});
|
||||
|
||||
it('keeps polling when a good response interrupts the 404 streak', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(404, {message: '404: Not Found'}));
|
||||
ExperimentAssignments.start();
|
||||
await settle();
|
||||
await vi.advanceTimersByTimeAsync(600_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(2);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(200, CANARY_ENVELOPE, {etag: 'W/"v7"'}));
|
||||
await vi.advanceTimersByTimeAsync(1_200_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(3);
|
||||
vi.mocked(http.get).mockResolvedValue(reply(404, {message: '404: Not Found'}));
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
await vi.advanceTimersByTimeAsync(600_000);
|
||||
expect(vi.mocked(http.get)).toHaveBeenCalledTimes(5);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {MS_PER_SECOND} from '@fluxer/date_utils/src/DateConstants';
|
||||
import {
|
||||
EXPERIMENT_MAX_POLL_INTERVAL_SECONDS,
|
||||
EXPERIMENT_MAX_POLL_JITTER_PERCENT,
|
||||
EXPERIMENT_MIN_POLL_INTERVAL_SECONDS,
|
||||
ExperimentAssignmentsResponse,
|
||||
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {makeAutoObservable, observable, runInAction} from 'mobx';
|
||||
|
||||
const logger = new Logger('ExperimentAssignments');
|
||||
|
||||
export const EXPERIMENT_ASSIGNMENTS_PATH = '/experiments';
|
||||
|
||||
export const EXPERIMENT_ASSIGNMENTS_VISIBILITY_REFRESH_MIN_INTERVAL_MS = 30 * MS_PER_SECOND;
|
||||
export const EXPERIMENT_ASSIGNMENTS_HIDDEN_POLL_INTERVAL_MULTIPLIER = 4;
|
||||
export const EXPERIMENT_ASSIGNMENTS_MAX_NOT_FOUND_STREAK = 3;
|
||||
|
||||
const MAX_BACKOFF_DOUBLINGS = 2;
|
||||
const EXPERIMENT_ASSIGNMENTS_MIN_POLL_DELAY_MS = (EXPERIMENT_MIN_POLL_INTERVAL_SECONDS * MS_PER_SECOND) / 2;
|
||||
|
||||
type UnobservedField =
|
||||
| 'etag'
|
||||
| 'failureStreak'
|
||||
| 'generation'
|
||||
| 'inFlight'
|
||||
| 'lastFetchStartedAt'
|
||||
| 'listeners'
|
||||
| 'listening'
|
||||
| 'notFoundStreak'
|
||||
| 'pollTimerId'
|
||||
| 'pollingDisabled'
|
||||
| 'started';
|
||||
|
||||
function clampPollIntervalSeconds(seconds: number): number {
|
||||
if (!Number.isFinite(seconds)) {
|
||||
return EXPERIMENT_MIN_POLL_INTERVAL_SECONDS;
|
||||
}
|
||||
return Math.min(
|
||||
Math.max(Math.round(seconds), EXPERIMENT_MIN_POLL_INTERVAL_SECONDS),
|
||||
EXPERIMENT_MAX_POLL_INTERVAL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
function clampPollJitterRatio(percent: number): number {
|
||||
if (!Number.isFinite(percent)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(Math.max(Math.round(percent), 0), EXPERIMENT_MAX_POLL_JITTER_PERCENT) / 100;
|
||||
}
|
||||
|
||||
function isDocumentHidden(): boolean {
|
||||
try {
|
||||
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class ExperimentAssignmentsStore {
|
||||
response: ExperimentAssignmentsResponse = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
|
||||
private started = false;
|
||||
private etag: string | null = null;
|
||||
private listening = false;
|
||||
private pollTimerId: NodeJS.Timeout | null = null;
|
||||
private inFlight: Promise<void> | null = null;
|
||||
private failureStreak = 0;
|
||||
private notFoundStreak = 0;
|
||||
private pollingDisabled = false;
|
||||
private lastFetchStartedAt = 0;
|
||||
private generation = 0;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable<this, UnobservedField>(
|
||||
this,
|
||||
{
|
||||
response: observable.ref,
|
||||
etag: false,
|
||||
failureStreak: false,
|
||||
generation: false,
|
||||
inFlight: false,
|
||||
lastFetchStartedAt: false,
|
||||
listeners: false,
|
||||
listening: false,
|
||||
notFoundStreak: false,
|
||||
pollTimerId: false,
|
||||
pollingDisabled: false,
|
||||
started: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.started || this.pollingDisabled) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
try {
|
||||
this.addVisibilityListener();
|
||||
if (isDocumentHidden()) {
|
||||
this.schedulePoll();
|
||||
return;
|
||||
}
|
||||
void this.refreshAndReschedule();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to start experiment assignment polling:', err);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false;
|
||||
this.generation += 1;
|
||||
try {
|
||||
this.removeVisibilityListener();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to detach experiment assignment listeners:', err);
|
||||
}
|
||||
this.clearPollTimer();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.stop();
|
||||
this.etag = null;
|
||||
this.inFlight = null;
|
||||
this.failureStreak = 0;
|
||||
this.notFoundStreak = 0;
|
||||
this.pollingDisabled = false;
|
||||
this.lastFetchStartedAt = 0;
|
||||
this.setResponse(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private setResponse(next: ExperimentAssignmentsResponse): void {
|
||||
if (this.response === next) {
|
||||
return;
|
||||
}
|
||||
runInAction(() => {
|
||||
this.response = next;
|
||||
});
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener();
|
||||
} catch (err) {
|
||||
logger.warn('Experiment assignment listener failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePoll(): void {
|
||||
if (!this.started) {
|
||||
return;
|
||||
}
|
||||
this.clearPollTimer();
|
||||
this.pollTimerId = setTimeout(() => {
|
||||
this.pollTimerId = null;
|
||||
void this.refreshAndReschedule();
|
||||
}, this.nextDelayMs());
|
||||
}
|
||||
|
||||
private nextDelayMs(): number {
|
||||
const seconds = clampPollIntervalSeconds(this.response.poll_interval_seconds);
|
||||
const multiplier = isDocumentHidden() ? EXPERIMENT_ASSIGNMENTS_HIDDEN_POLL_INTERVAL_MULTIPLIER : 1;
|
||||
const target = seconds * MS_PER_SECOND * multiplier * 2 ** this.failureStreak;
|
||||
const jitter = target * clampPollJitterRatio(this.response.poll_jitter_percent) * (Math.random() * 2 - 1);
|
||||
return Math.max(EXPERIMENT_ASSIGNMENTS_MIN_POLL_DELAY_MS, Math.round(target + jitter));
|
||||
}
|
||||
|
||||
private async refreshAndReschedule(): Promise<void> {
|
||||
const generation = this.generation;
|
||||
try {
|
||||
await this.refresh();
|
||||
} finally {
|
||||
if (generation === this.generation) {
|
||||
this.schedulePoll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private refresh(): Promise<void> {
|
||||
const existing = this.inFlight;
|
||||
if (existing !== null) {
|
||||
return existing;
|
||||
}
|
||||
const pending = this.fetchAssignments(this.generation).finally(() => {
|
||||
if (this.inFlight === pending) {
|
||||
this.inFlight = null;
|
||||
}
|
||||
});
|
||||
this.inFlight = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
private async fetchAssignments(generation: number): Promise<void> {
|
||||
this.lastFetchStartedAt = Date.now();
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.etag !== null) {
|
||||
headers['If-None-Match'] = this.etag;
|
||||
}
|
||||
try {
|
||||
const response = await http.get<unknown>(EXPERIMENT_ASSIGNMENTS_PATH, {
|
||||
headers,
|
||||
mode: 'silent',
|
||||
parse: 'json',
|
||||
});
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
this.applyResponse(response);
|
||||
} catch (err) {
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
logger.warn('Experiment assignment request failed:', err);
|
||||
this.noteFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private applyResponse(response: RestResponse<unknown>): void {
|
||||
if (response.status === 404) {
|
||||
this.noteNotFound();
|
||||
return;
|
||||
}
|
||||
this.notFoundStreak = 0;
|
||||
if (response.status === 304) {
|
||||
this.failureStreak = 0;
|
||||
return;
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logger.warn('Experiment assignment request was rejected with status:', response.status);
|
||||
this.noteFailure();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
logger.warn('Experiment assignment request returned status:', response.status);
|
||||
this.noteFailure();
|
||||
return;
|
||||
}
|
||||
const parsed = ExperimentAssignmentsResponse.safeParse(response.body);
|
||||
if (!parsed.success) {
|
||||
logger.warn('Discarding malformed experiment assignments:', parsed.error.message);
|
||||
this.failureStreak = 0;
|
||||
return;
|
||||
}
|
||||
const etag = response.headers.etag ?? null;
|
||||
this.etag = etag;
|
||||
this.failureStreak = 0;
|
||||
this.setResponse(parsed.data);
|
||||
}
|
||||
|
||||
private noteFailure(): void {
|
||||
this.failureStreak = Math.min(this.failureStreak + 1, MAX_BACKOFF_DOUBLINGS);
|
||||
}
|
||||
|
||||
private noteNotFound(): void {
|
||||
this.notFoundStreak += 1;
|
||||
this.noteFailure();
|
||||
if (this.notFoundStreak < EXPERIMENT_ASSIGNMENTS_MAX_NOT_FOUND_STREAK) {
|
||||
return;
|
||||
}
|
||||
logger.warn('Experiment assignment endpoint is unavailable; stopping polling for this session.');
|
||||
this.pollingDisabled = true;
|
||||
this.stop();
|
||||
}
|
||||
|
||||
private handleVisibilityChange(): void {
|
||||
if (!this.started || isDocumentHidden()) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - this.lastFetchStartedAt < EXPERIMENT_ASSIGNMENTS_VISIBILITY_REFRESH_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
this.clearPollTimer();
|
||||
void this.refreshAndReschedule();
|
||||
}
|
||||
|
||||
private addVisibilityListener(): void {
|
||||
if (this.listening) {
|
||||
return;
|
||||
}
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
||||
this.listening = true;
|
||||
}
|
||||
|
||||
private removeVisibilityListener(): void {
|
||||
if (!this.listening) {
|
||||
return;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
||||
this.listening = false;
|
||||
}
|
||||
|
||||
private clearPollTimer(): void {
|
||||
if (this.pollTimerId === null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.pollTimerId);
|
||||
this.pollTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const ExperimentAssignments = new ExperimentAssignmentsStore();
|
||||
|
||||
export default ExperimentAssignments;
|
||||
@@ -10,6 +10,7 @@ import Channels from '@app/features/channel/state/Channels';
|
||||
import UserConnection from '@app/features/connection/state/UserConnection';
|
||||
import Emoji from '@app/features/emoji/state/Emoji';
|
||||
import Sticker from '@app/features/emoji/state/EmojiSticker';
|
||||
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
|
||||
import type {FavoriteMemeWire} from '@app/features/expressions/models/FavoriteMeme';
|
||||
import FavoriteMemes from '@app/features/expressions/state/FavoriteMemes';
|
||||
import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRouter';
|
||||
@@ -128,6 +129,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
|
||||
if (data.rtc_regions) {
|
||||
RtcRegions.setRegions(data.rtc_regions);
|
||||
}
|
||||
ExperimentAssignments.start();
|
||||
Users.handleGatewayReady(data.user);
|
||||
if (data.users && data.users.length > 0) {
|
||||
Users.cacheUsers(data.users);
|
||||
|
||||
@@ -143,6 +143,21 @@ const VERIFIED_GUILD_DESCRIPTOR = msg({
|
||||
message: 'Verified community',
|
||||
comment: 'Audit log community feature label for verified communities.',
|
||||
});
|
||||
const AUDIO_BITRATE_128_KBPS_DESCRIPTOR = msg({
|
||||
message: '128 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const AUDIO_BITRATE_256_KBPS_DESCRIPTOR = msg({
|
||||
message: '256 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const AUDIO_BITRATE_384_KBPS_DESCRIPTOR = msg({
|
||||
message: '384 kbps voice quality',
|
||||
comment:
|
||||
'Audit log community feature label for a raised voice channel bitrate ceiling. kbps means kilobits per second.',
|
||||
});
|
||||
const VIP_VOICE_DESCRIPTOR = msg({
|
||||
message: 'VIP voice',
|
||||
comment: 'Audit log community feature label for elevated voice capacity/quality.',
|
||||
@@ -404,6 +419,9 @@ export function getSplashAlignmentLabel(value: unknown, i18n: I18n): string | nu
|
||||
const featureLabelMap: Record<string, MessageDescriptor> = {
|
||||
[GuildFeatures.ANIMATED_ICON]: ANIMATED_ICON_DESCRIPTOR,
|
||||
[GuildFeatures.ANIMATED_BANNER]: ANIMATED_BANNER_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_128_KBPS]: AUDIO_BITRATE_128_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_256_KBPS]: AUDIO_BITRATE_256_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.AUDIO_BITRATE_384_KBPS]: AUDIO_BITRATE_384_KBPS_DESCRIPTOR,
|
||||
[GuildFeatures.BANNER]: BANNER_DESCRIPTOR,
|
||||
[GuildFeatures.CLONE_EMOJI_DISABLED]: EMOJI_CLONING_DISABLED_DESCRIPTOR,
|
||||
[GuildFeatures.CLONE_STICKER_DISABLED]: STICKER_CLONING_DISABLED_DESCRIPTOR,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1380,6 +1380,9 @@
|
||||
{
|
||||
"msgid": "Branding asset preview"
|
||||
},
|
||||
{
|
||||
"msgid": "Built-in suppression from your browser or system."
|
||||
},
|
||||
{
|
||||
"msgid": "Camera access is needed for device previews"
|
||||
},
|
||||
@@ -1416,9 +1419,15 @@
|
||||
{
|
||||
"msgid": "Clear command"
|
||||
},
|
||||
{
|
||||
"msgid": "Clear message text"
|
||||
},
|
||||
{
|
||||
"msgid": "Clearer text"
|
||||
},
|
||||
{
|
||||
"msgid": "Clearing the text of a message with attachments asks to delete the message unless this is on"
|
||||
},
|
||||
{
|
||||
"msgid": "Closed"
|
||||
},
|
||||
@@ -1473,6 +1482,9 @@
|
||||
{
|
||||
"msgid": "Custom emoji {emojiName} from {communityName}"
|
||||
},
|
||||
{
|
||||
"msgid": "Cuts audio below a loudness threshold, lowest CPU cost."
|
||||
},
|
||||
{
|
||||
"msgid": "Deafen other members in this voice channel, so they cannot hear or speak."
|
||||
},
|
||||
@@ -1482,6 +1494,9 @@
|
||||
{
|
||||
"msgid": "Delete other members' messages in this channel. Pinning is controlled separately."
|
||||
},
|
||||
{
|
||||
"msgid": "Delete prompt"
|
||||
},
|
||||
{
|
||||
"msgid": "Delete user"
|
||||
},
|
||||
@@ -1518,6 +1533,9 @@
|
||||
{
|
||||
"msgid": "Email us and a person will review your account. Tell us you cannot verify by SMS and we will take it from there. Replies come by email, so this is not instant."
|
||||
},
|
||||
{
|
||||
"msgid": "Empty edit"
|
||||
},
|
||||
{
|
||||
"msgid": "Emulate decode codec (testing)"
|
||||
},
|
||||
@@ -1641,6 +1659,12 @@
|
||||
{
|
||||
"msgid": "Keep Neko still"
|
||||
},
|
||||
{
|
||||
"msgid": "Keep attachments"
|
||||
},
|
||||
{
|
||||
"msgid": "Keep attachments when clearing an edit"
|
||||
},
|
||||
{
|
||||
"msgid": "Kicked {targetUserTag} from the community"
|
||||
},
|
||||
@@ -1659,6 +1683,9 @@
|
||||
{
|
||||
"msgid": "Let people search and send GIFs powered by Klipy."
|
||||
},
|
||||
{
|
||||
"msgid": "Lightweight classic filter, works on any device."
|
||||
},
|
||||
{
|
||||
"msgid": "Loading instance configuration"
|
||||
},
|
||||
@@ -1674,6 +1701,9 @@
|
||||
{
|
||||
"msgid": "Mention everyone or any role in this channel (even if the role isn't set to be mentionable)."
|
||||
},
|
||||
{
|
||||
"msgid": "Message editing"
|
||||
},
|
||||
{
|
||||
"msgid": "Microphone ({deviceLabel})"
|
||||
},
|
||||
@@ -1692,6 +1722,12 @@
|
||||
{
|
||||
"msgid": "Neko cat that chases your cursor"
|
||||
},
|
||||
{
|
||||
"msgid": "Neural filter that removes most background noise."
|
||||
},
|
||||
{
|
||||
"msgid": "Neural filter, full audio bandwidth."
|
||||
},
|
||||
{
|
||||
"msgid": "New price scheduled for <0>{pendingChangeDate}</0>."
|
||||
},
|
||||
@@ -1956,6 +1992,12 @@
|
||||
{
|
||||
"msgid": "Send messages in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Sends both channels of a stereo microphone. Works only with no suppression or standard suppression."
|
||||
},
|
||||
{
|
||||
"msgid": "Sends your microphone audio with no suppression."
|
||||
},
|
||||
{
|
||||
"msgid": "Sequential send"
|
||||
},
|
||||
@@ -2052,6 +2094,9 @@
|
||||
{
|
||||
"msgid": "Static Neko"
|
||||
},
|
||||
{
|
||||
"msgid": "Stereo microphone"
|
||||
},
|
||||
{
|
||||
"msgid": "Still Neko"
|
||||
},
|
||||
@@ -2067,6 +2112,9 @@
|
||||
{
|
||||
"msgid": "Stream quality"
|
||||
},
|
||||
{
|
||||
"msgid": "Strongest neural filter for speech, limits audio to the speech range."
|
||||
},
|
||||
{
|
||||
"msgid": "Switch price"
|
||||
},
|
||||
@@ -2295,6 +2343,22 @@
|
||||
{
|
||||
"msgid": "{remaining, plural, one {+# more} other {+# more}}"
|
||||
},
|
||||
{
|
||||
"msgctxt": "noise-suppression-option",
|
||||
"msgid": "Balanced"
|
||||
},
|
||||
{
|
||||
"msgctxt": "noise-suppression-option",
|
||||
"msgid": "Classic"
|
||||
},
|
||||
{
|
||||
"msgctxt": "noise-suppression-option",
|
||||
"msgid": "Focused"
|
||||
},
|
||||
{
|
||||
"msgctxt": "noise-suppression-option",
|
||||
"msgid": "Noise gate"
|
||||
},
|
||||
{
|
||||
"msgctxt": "screen-share",
|
||||
"msgid": "Your screen share could not be published with a compatible video codec, so it was stopped. Try sharing again."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user