Compare commits

...
65 changed files with 2052 additions and 231 deletions
+44 -1
View File
@@ -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",
@@ -13891,6 +13891,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 +13936,7 @@
"latitude",
"longitude",
"is_active",
"soft_connection_limit",
"vip_only",
"required_guild_features",
"allowed_guild_ids",
@@ -14227,6 +14236,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 +14281,7 @@
"latitude",
"longitude",
"is_active",
"soft_connection_limit",
"vip_only",
"required_guild_features",
"allowed_guild_ids",
@@ -14299,6 +14317,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 +14382,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 +14427,7 @@
"latitude",
"longitude",
"is_active",
"soft_connection_limit",
"vip_only",
"required_guild_features",
"allowed_guild_ids",
@@ -14428,6 +14463,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",
+1
View File
@@ -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>,
+24 -18
View File
@@ -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(&region_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(&region_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(&region_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(&region_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") {
+48 -16
View File
@@ -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(&region_name),
Some(&resp.servers),
None,
&csrf_token,
&VoiceServersPageParams {
region_id: Some(region_id),
region_name: Some(&region_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(&region_name),
None,
Some(&msg),
&csrf_token,
&VoiceServersPageParams {
region_id: Some(region_id),
region_name: Some(&region_name),
servers: None,
connection_counts: &connection_counts,
error: Some(&msg),
csrf_token: &csrf_token,
},
);
Html(markup.into_string()).into_response()
}
+109 -20
View File
@@ -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",
@@ -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))
@@ -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}&region_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}&region_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(),
},
}
}
@@ -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();
});
});
@@ -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
+6 -1
View File
@@ -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',
@@ -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,
@@ -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(() => {
+23 -17
View File
@@ -27348,8 +27348,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 +27442,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 +27526,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 +27610,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 +28041,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 +28074,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 +28104,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",
@@ -30329,8 +30335,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 +30403,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 +30471,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 +30539,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"}],
@@ -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(),
+1
View File
@@ -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;
}
}
+1
View File
@@ -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);
});
});
@@ -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
@@ -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);
@@ -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;
}
@@ -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,
});
@@ -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,
@@ -279,7 +279,6 @@ function handleMessageFetchSuccess(
channelId,
isAfter: pageState.isAfter,
messages,
tailProbeWatermarkId: tailProbe?.watermarkMessageId ?? null,
});
MessageReferences.handleMessagesFetchSuccess(channelId, messages);
void requestMissingGuildMembers(channelId, messages);
@@ -118,55 +118,32 @@ describe('ReadStates unread invariant', () => {
expect(ReadStates.hasUnread(channelId)).toBe(true);
});
it('still lets its own probe lower a watermark a passive update raised', () => {
it('never lowers the watermark when an after page comes back empty', () => {
const {channelId} = seedReadChannel();
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.ack);
expect(ReadStates.hasUnread(channelId)).toBe(false);
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: []});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
expect(ReadStates.hasUnread(channelId)).toBe(true);
});
it('lowers a watermark its own probe finds nothing behind', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.ack);
expect(ReadStates.hasUnread(channelId)).toBe(false);
});
it('keeps a watermark that is ahead when an ordinary after page comes back empty', () => {
it('keeps a watermark that points at a message no longer in the channel', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: []});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
expect(ReadStates.hasUnread(channelId)).toBe(true);
});
it('keeps a watermark that advanced while its own probe was in flight', () => {
it('acks up to the watermark so a deleted newest message cannot keep the channel unread', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.ack});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
});
it('keeps a watermark that is ahead when the page was not an after page', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, messages: [], tailProbeWatermarkId: ID.newer});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
});
it('keeps a watermark that is ahead when the window is not at the live edge', () => {
const {channelId, state} = seedReadChannel();
hasNewestMessages = false;
state.lastMessageId = ID.newer;
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: [], tailProbeWatermarkId: ID.newer});
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: []});
ReadStates.handleChannelAckWithStickyUnread({channelId});
expect(ReadStates.ackMessageId(channelId)).toBe(ID.newer);
expect(ReadStates.hasUnread(channelId)).toBe(false);
});
it('anchors the divider when a window is loaded whose ack sits outside it', () => {
@@ -439,12 +439,7 @@ class ReadStates {
});
}
handleLoadMessages(action: {
channelId: string;
isAfter?: boolean;
messages: Array<WireMessage>;
tailProbeWatermarkId?: string | null;
}): void {
handleLoadMessages(action: {channelId: string; isAfter?: boolean; messages: Array<WireMessage>}): void {
const state = this.get(action.channelId);
state.messagesLoaded = true;
const messages = Messages.getMessages(action.channelId);
@@ -453,17 +448,6 @@ class ReadStates {
state.lastMessageId = newestMessage.id;
}
const landedOnNewestWindow = messages.hasNewestMessages();
if (
action.isAfter &&
action.tailProbeWatermarkId != null &&
action.tailProbeWatermarkId === state.lastMessageId &&
action.messages.length === 0 &&
landedOnNewestWindow &&
newestMessage != null &&
isNewerMessageId(state.lastMessageId, newestMessage.id)
) {
state.lastMessageId = newestMessage.id;
}
const landedOnAck = state.ackMessageId != null && messages.jumpDestinationId === state.ackMessageId;
if (state.hasUnread() || landedOnNewestWindow || landedOnAck) {
state.rebuild();
@@ -867,7 +851,6 @@ class ReadStates {
state.ackedManually = false;
state.clearStickyUnread();
}
this.cancelPendingAckIfCovered(state.channelId, decision.messageId);
return {acked: true, messageId: decision.messageId, hadMentions: decision.hadMentions};
}
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {Channel} from '@app/features/channel/models/Channel';
import {ACK_BATCH_DELAY_MS, type GatewayReadState} from '@app/features/read_state/state/read_states/shared';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const channels = new Map<string, Channel>();
let pinnedToEnd = false;
let automaticAck = false;
const loadedMessages: Array<{id: string; author: {id: string}}> = [];
vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}}));
vi.mock('@app/features/channel/state/Channels', () => ({
default: {getChannel: (id: string) => channels.get(id)},
}));
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
default: {
getMessages: () => ({
get hasMoreBefore() {
return false;
},
get length() {
return loadedMessages.length;
},
jumpDestinationId: null,
hasNewestMessages: () => true,
has: (id: string) => loadedMessages.some((m) => m.id === id),
last: () => loadedMessages[loadedMessages.length - 1],
forEachBuffered: (cb: (m: unknown) => void) => {
for (const m of loadedMessages) cb(m);
},
}),
},
}));
vi.mock('@app/features/user/state/Users', () => ({
default: {getCurrentUser: () => ({id: 'me'}), cacheUsers: () => {}},
}));
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
default: {
isEveryoneMentionSuppressed: () => false,
isRoleMentionSuppressed: () => false,
isGuildOrChannelMuted: () => false,
},
}));
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => pinnedToEnd}}));
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
default: {isAutomaticAckEnabled: () => automaticAck, disableForChannel: () => {}},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({
http: {post: vi.fn(async () => ({body: {read_states: []}})), get: vi.fn()},
}));
const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates');
const {http} = await import('@app/features/platform/transport/RestTransport');
const CHANNEL = '1485064866382176262';
const MESSAGE = {
acked: '1547700000000000000',
unread: '1547743000000000000',
};
function user(id: string): UserPartial {
return {id, username: id, discriminator: '0', global_name: null, avatar: null, avatar_color: null, flags: 0};
}
function guildText(lastMessageId: string): WireChannel {
return {id: CHANNEL, type: ChannelTypes.GUILD_TEXT, guild_id: 'guild-1', last_message_id: lastMessageId};
}
function readState(ackMessageId: string): GatewayReadState {
return {id: CHANNEL, last_message_id: ackMessageId, mention_count: 0, version: '1'};
}
function ready(): void {
channels.clear();
channels.set(CHANNEL, new Channel(guildText(MESSAGE.unread)));
ReadStates.handleGatewayReady({readState: [readState(MESSAGE.acked)], channels: [guildText(MESSAGE.unread)]});
}
function wireMessage(id: string, authorId: string): WireMessage {
return {
id,
channel_id: CHANNEL,
author: user(authorId),
type: MessageTypes.DEFAULT,
flags: 0,
pinned: false,
tts: false,
mention_everyone: false,
content: 'test',
timestamp: '2026-09-11T00:00:00.000Z',
mentions: [],
mention_roles: [],
} as unknown as WireMessage;
}
function openChannel(): void {
loadedMessages.push({id: MESSAGE.unread, author: {id: 'tuna'}});
ReadStates.handleLoadMessages({channelId: CHANNEL, isAfter: false, messages: [{id: MESSAGE.unread}] as never});
pinnedToEnd = true;
automaticAck = true;
ReadStates.handleChannelAckWithStickyUnread({channelId: CHANNEL});
}
describe('ReadStates ack batching', () => {
beforeEach(() => {
vi.useFakeTimers();
loadedMessages.length = 0;
pinnedToEnd = false;
automaticAck = false;
vi.mocked(http.post).mockClear();
});
afterEach(() => {
ReadStates.clearAll();
vi.useRealTimers();
});
it('sends the ack one batch delay after a quiet channel is read', async () => {
ready();
expect(ReadStates.hasUnread(CHANNEL)).toBe(true);
openChannel();
expect(ReadStates.hasUnread(CHANNEL)).toBe(false);
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS - 1);
expect(http.post).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
expect(http.post).toHaveBeenCalledWith(Endpoints.READ_STATES_ACK, {
body: {read_states: [{channel_id: CHANNEL, message_id: MESSAGE.unread}]},
});
});
it('keeps the batch window anchored to the first ack while messages keep arriving', async () => {
ready();
openChannel();
let messageId = 1547743900000000000n;
for (let i = 0; i < 3; i++) {
await vi.advanceTimersByTimeAsync(900);
messageId += 1000000n;
ReadStates.handleIncomingMessage({channelId: CHANNEL, message: wireMessage(messageId.toString(), 'tuna')});
}
expect(http.post).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS - 2700);
expect(http.post).toHaveBeenCalledTimes(1);
expect(http.post).toHaveBeenCalledWith(Endpoints.READ_STATES_ACK, {
body: {read_states: [{channel_id: CHANNEL, message_id: messageId.toString()}]},
});
});
it('keeps acking a channel read for a long stretch of steady traffic', async () => {
ready();
openChannel();
let messageId = 1547743900000000000n;
for (let i = 0; i < 22; i++) {
await vi.advanceTimersByTimeAsync(900);
messageId += 1000000n;
ReadStates.handleIncomingMessage({channelId: CHANNEL, message: wireMessage(messageId.toString(), 'tuna')});
}
expect(vi.mocked(http.post).mock.calls.length).toBeGreaterThanOrEqual(3);
});
});
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {Channel} from '@app/features/channel/models/Channel';
import {ACK_BATCH_DELAY_MS, type GatewayReadState} from '@app/features/read_state/state/read_states/shared';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const channels = new Map<string, Channel>();
let pinnedToEnd = false;
let automaticAck = false;
let hasNewest = true;
const loadedMessages: Array<{id: string; author: {id: string}}> = [];
vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}}));
vi.mock('@app/features/channel/state/Channels', () => ({default: {getChannel: (id: string) => channels.get(id)}}));
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
default: {
getMessages: () => ({
get hasMoreBefore() {
return false;
},
get length() {
return loadedMessages.length;
},
jumpDestinationId: null,
hasNewestMessages: () => hasNewest,
has: (id: string) => loadedMessages.some((m) => m.id === id),
last: () => loadedMessages[loadedMessages.length - 1],
forEachBuffered: (cb: (m: unknown) => void) => {
for (const m of loadedMessages) cb(m);
},
}),
},
}));
vi.mock('@app/features/user/state/Users', () => ({
default: {getCurrentUser: () => ({id: 'me'}), cacheUsers: () => {}},
}));
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
default: {
isEveryoneMentionSuppressed: () => false,
isRoleMentionSuppressed: () => false,
isGuildOrChannelMuted: () => false,
},
}));
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => pinnedToEnd}}));
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
default: {isAutomaticAckEnabled: () => automaticAck, disableForChannel: () => {}},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({
http: {post: vi.fn(async () => ({body: {read_states: []}})), get: vi.fn()},
}));
const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates');
const {http} = await import('@app/features/platform/transport/RestTransport');
const CHANNEL = '1431490439357088063';
const PHANTOM = '1547836192152621056';
const REAL = '1546984669772255232';
const OLD_ACK = '1546000000000000000';
function guildText(lastMessageId: string): WireChannel {
return {id: CHANNEL, type: ChannelTypes.GUILD_TEXT, guild_id: '1431490056488128806', last_message_id: lastMessageId};
}
function readState(ackMessageId: string): GatewayReadState {
return {id: CHANNEL, last_message_id: ackMessageId, mention_count: 0, version: '1'};
}
function ready(ackMessageId: string): void {
channels.clear();
channels.set(CHANNEL, new Channel(guildText(PHANTOM)));
ReadStates.handleGatewayReady({readState: [readState(ackMessageId)], channels: [guildText(PHANTOM)]});
}
function lastAckedMessageId(): string | null {
const calls = vi.mocked(http.post).mock.calls;
if (calls.length === 0) return null;
const body = calls[calls.length - 1][1] as {body: {read_states: Array<{message_id: string}>}};
return body.body.read_states[0].message_id;
}
describe('channel whose newest message was deleted', () => {
beforeEach(() => {
vi.useFakeTimers();
loadedMessages.length = 0;
pinnedToEnd = false;
automaticAck = false;
hasNewest = true;
vi.mocked(http.post).mockClear();
});
afterEach(() => {
ReadStates.clearAll();
vi.useRealTimers();
});
it('acks up to the channel watermark rather than the newest surviving message', async () => {
ready(OLD_ACK);
expect(ReadStates.hasUnread(CHANNEL)).toBe(true);
loadedMessages.push({id: REAL, author: {id: 'tuna'}});
ReadStates.handleLoadMessages({channelId: CHANNEL, isAfter: false, messages: [{id: REAL}] as never});
ReadStates.handleLoadMessages({channelId: CHANNEL, isAfter: true, messages: []});
expect(ReadStates.lastMessageId(CHANNEL)).toBe(PHANTOM);
pinnedToEnd = true;
automaticAck = true;
ReadStates.handleChannelAckWithStickyUnread({channelId: CHANNEL});
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS);
expect(http.post).toHaveBeenCalledWith(Endpoints.READ_STATES_ACK, {
body: {read_states: [{channel_id: CHANNEL, message_id: PHANTOM}]},
});
expect(ReadStates.hasUnread(CHANNEL)).toBe(false);
});
it('stays read after a reload', async () => {
ready(OLD_ACK);
loadedMessages.push({id: REAL, author: {id: 'tuna'}});
ReadStates.handleLoadMessages({channelId: CHANNEL, isAfter: false, messages: [{id: REAL}] as never});
ReadStates.handleLoadMessages({channelId: CHANNEL, isAfter: true, messages: []});
pinnedToEnd = true;
automaticAck = true;
ReadStates.handleChannelAckWithStickyUnread({channelId: CHANNEL});
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS);
const acked = lastAckedMessageId();
expect(acked).not.toBeNull();
ReadStates.clearAll();
ready(acked as string);
expect(ReadStates.hasUnread(CHANNEL)).toBe(false);
});
});
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {screenShareWatchAttemptKey} from '@app/features/voice/components/useScreenShareWatchFailure';
import {
selectVoiceMediaGraphFailure,
selectVoiceMediaGraphViewerStreamKeys,
@@ -101,7 +102,7 @@ describe('VoiceMediaGraphTimerScheduler', () => {
store.transition({
type: 'watch.attemptEnsured',
streamKey: STREAM_KEY,
attemptKey: `${STREAM_KEY}:1:watch`,
attemptKey: screenShareWatchAttemptKey({streamKey: STREAM_KEY, watchGeneration: 1}),
startedAt: store.nowMs(),
});
harness.advance(WATCH_ATTEMPT_TIMEOUT_MS);
@@ -6,6 +6,7 @@ import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils';
import Channels from '@app/features/channel/state/Channels';
import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes';
import Guilds from '@app/features/guild/state/Guilds';
import Keybind from '@app/features/input/state/InputKeybind';
import {getVoiceContextEntranceSoundScope} from '@app/features/notification/utils/EntranceSoundScopes';
import {handleMediaPermissionBlocked} from '@app/features/permissions/system/commands/MacPermissionsModalCommands';
@@ -117,6 +118,7 @@ import {
getActiveVoiceProcessingMode,
resolveVoiceProcessingFromStateForDeviceLabel,
} from '@app/features/voice/utils/VoiceProcessingProfile';
import {resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
import type {
VoiceEngineV2AudioControls,
VoiceEngineV2AudioMode,
@@ -606,7 +608,9 @@ export class VoiceEngineV2AppMediaExecutionAdapter extends Store {
}
private getMicrophonePublishOptions(channelId: string | null): TrackPublishOptions | undefined {
const channelBitrate = channelId ? Channels.getChannel(channelId)?.bitrate : null;
const channel = channelId ? Channels.getChannel(channelId) : null;
const guild = channel?.guildId ? Guilds.getGuild(channel.guildId) : null;
const channelBitrate = resolveVoiceChannelBitrate(channel?.bitrate, guild?.features);
const profile = resolveVoiceProcessingFromStateForDeviceLabel(VoiceSettings, this.resolveActiveInputDeviceLabel());
return buildMicrophonePublishOptions(channelBitrate, profile.mode);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {screenShareWatchAttemptKey} from '@app/features/voice/components/useScreenShareWatchFailure';
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
import {ScreenShareWatchErrorCode, ScreenShareWatchFailures} from '@app/features/voice/state/ScreenShareWatchFailures';
import type {RemoteTrackPublication} from 'livekit-client';
@@ -104,7 +105,7 @@ describe('ScreenShareWatchFailures failure history', () => {
it('keeps a failure raised by a watch deadline after the viewer stops watching', () => {
const streamKey = 'guild-a:channel-a:connection-deadline';
const attemptKey = `${streamKey}:1:watch`;
const attemptKey = screenShareWatchAttemptKey({streamKey, watchGeneration: 1});
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
ScreenShareWatchFailures.setWatchTarget(streamKey, {videoRef: {current: null}});
@@ -122,7 +123,7 @@ describe('ScreenShareWatchFailures failure history', () => {
it('records the inbound counters of the watched publication alongside the failure', async () => {
const streamKey = 'guild-a:channel-a:connection-counters';
const attemptKey = `${streamKey}:1:watch`;
const attemptKey = screenShareWatchAttemptKey({streamKey, watchGeneration: 1});
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
ScreenShareWatchFailures.setWatchTarget(streamKey, {
@@ -160,7 +161,7 @@ describe('ScreenShareWatchFailures failure history', () => {
it('records the size and readiness of the attached video element', () => {
const streamKey = 'guild-a:channel-a:connection-tile';
const attemptKey = `${streamKey}:1:watch`;
const attemptKey = screenShareWatchAttemptKey({streamKey, watchGeneration: 1});
const video = {readyState: 0, videoWidth: 0, videoHeight: 0, clientWidth: 960, clientHeight: 540};
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
@@ -186,7 +187,7 @@ describe('ScreenShareWatchFailures failure history', () => {
it('leaves the running watch deadline alone when the watch target is registered', () => {
const streamKey = 'guild-a:channel-a:connection-target';
const attemptKey = `${streamKey}:1:watch`;
const attemptKey = screenShareWatchAttemptKey({streamKey, watchGeneration: 1});
ScreenShareWatchFailures.markWatchStarted(streamKey);
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
const deadline = watchDeadlineFor(streamKey);
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
import {describe, expect, it} from 'vitest';
import {
buildMicrophonePublishOptions,
@@ -51,6 +52,15 @@ describe('buildMicrophonePublishOptions', () => {
red: true,
});
});
it('pins a call outside a guild to 64 kbps', () => {
expect(buildMicrophonePublishOptions(resolveVoiceChannelBitrate(null, null), 'voice')).toEqual({
audioPreset: {
maxBitrate: 64000,
priority: 'high',
},
red: true,
});
});
it('does not force stereo below the stereo voice threshold', () => {
expect(buildMicrophonePublishOptions(96000, 'studio')).toEqual({
audioPreset: {
@@ -99,6 +99,8 @@ The `region_id` and `server_id` pair addresses one server, and a server belongs
A server can have its own coordinate. Placement then measures distance from that coordinate to pick the closest server for an automatically placed session. A server without one takes no part in distance comparison.
A server can also have a soft connection limit, described under [soft connection limits](#soft-connection-limits).
### Structure
| Field | Type | Description |
@@ -109,6 +111,7 @@ A server can have its own coordinate. Placement then measures distance from that
| latitude<sup>2</sup> | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null when the region coordinate is used |
| longitude<sup>2</sup> | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null when the region coordinate is used |
| is_active<sup>3</sup> | boolean | Whether the server is in rotation for new placement |
| soft_connection_limit<sup>5</sup> | ?integer | The count at which placement starts preferring another server, or null when the server has no limit (1-2147483647) |
| vip_only | boolean | Whether the guild has to hold `VIP_VOICE` |
| required_guild_features | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild, any one of which is enough (max 100) |
| allowed_guild_ids<sup>4</sup> | array[snowflake] | The guilds admitted without checking the other two guild gates (max 1000) |
@@ -124,6 +127,8 @@ A server can have its own coordinate. Placement then measures distance from that
<sup>4</sup> Duplicate entries collapse and the returned order is not the submitted order
<sup>5</sup> The limit is a preference, not a cap. It is described under [soft connection limits](#soft-connection-limits)
:::caution[Server credentials are never returned]
`api_key` and `api_secret` are stored on the record and accepted by the create and update bodies. No read returns them, so a lost secret has to be replaced with an update.
:::
@@ -138,6 +143,7 @@ A server can have its own coordinate. Placement then measures distance from that
"latitude": null,
"longitude": null,
"is_active": true,
"soft_connection_limit": null,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
@@ -147,6 +153,18 @@ A server can have its own coordinate. Placement then measures distance from that
}
```
## Soft connection limits
`soft_connection_limit` is the connection count at which a server stops being a preferred placement target. It orders the candidates for one placement and never refuses one.
Placement splits the servers it may use into those below their limit and those at or above it. It picks from the first group, and it picks from the full set when the first group is empty. A server with a null limit is always in the first group, and so is a server whose current count is unknown. A session is therefore still placed on a server past its limit when no other server can take it.
The count is the number of voice connections the gateway holds for the server, the same figure [Get voice state counts](/admin-api/gateway/#get-voice-state-counts) returns. Each API node reads it at most once every 15 seconds and places against the last reading, so a burst of placements can push a server past its limit before the next reading. A node that has no reading less than 60 seconds old places as though no server had a limit.
The count is keyed by `server_id` alone. Two servers registered in different regions under the same `server_id` share one count, and each is measured against its own limit.
The limit is read when a channel is first placed and the server is pinned for it, as [Voice](/voice/#regions) describes. Later placements into that channel inherit the pin and are not measured against the limit. Lowering or clearing a limit moves no live session.
## List voice regions
<RouteHeader method="GET" path="/v1/admin/voice/regions" />
@@ -444,6 +462,7 @@ Registers a voice server in a region and returns it. Requires `voice:server:crea
| latitude?<sup>3</sup> | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
| longitude?<sup>3</sup> | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
| is_active? | boolean | Whether the server is in rotation for new placement (default true) |
| soft_connection_limit?<sup>4</sup> | ?integer | The count at which placement starts preferring another server, or null for no limit (1-2147483647, default null) |
| vip_only? | boolean | Whether the guild has to hold `VIP_VOICE` (default false) |
| required_guild_features? | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild (max 100 items of 1-64 characters each, default empty) |
| allowed_guild_ids? | array[snowflake] | The guilds admitted without checking the other two guild gates (max 1000, default empty) |
@@ -455,6 +474,8 @@ Registers a voice server in a region and returns it. Requires `voice:server:crea
<sup>3</sup> The two coordinates are supplied together and are either both null or both a number. A mismatched pair fails body validation on the `latitude` path
<sup>4</sup> Zero and every negative value fail validation, so a server with no limit is registered by omitting the field or sending null. [Soft connection limits](#soft-connection-limits) describes what the value does
The body has no `region_id`. A `region_id` member in the body is overwritten from the path and cannot register the server under a different region.
### Response body
@@ -506,6 +527,7 @@ Every field is optional and an omitted field is left unchanged. An absent, empty
| latitude?<sup>2</sup> | ?number | The latitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
| longitude?<sup>2</sup> | ?number | The longitude replacing this server's region coordinate, in decimal degrees, or null to use the region coordinate |
| is_active? | boolean | Whether the server is in rotation for new placement |
| soft_connection_limit?<sup>4</sup> | ?integer | The count at which placement starts preferring another server, or null for no limit (1-2147483647) |
| vip_only? | boolean | Whether the guild has to hold `VIP_VOICE` |
| required_guild_features?<sup>3</sup> | array[string] | [Guild features](/http-api/guilds/#guild-features) that admit a guild (max 100 items of 1-64 characters each) |
| allowed_guild_ids?<sup>3</sup> | array[snowflake] | The guilds admitted without checking the other two guild gates (max 1000) |
@@ -517,6 +539,8 @@ Every field is optional and an omitted field is left unchanged. An absent, empty
<sup>3</sup> A supplied collection replaces the stored collection outright, so removing one entry means sending the complete remaining set and clearing a collection means sending an empty array
<sup>4</sup> An omitted limit is left unchanged and null clears it. Zero and every negative value fail validation
A `region_id` or `server_id` member in the body is overwritten from the path and cannot move the server to another region.
### Response body
@@ -370,7 +370,7 @@ Every field is optional, and an omitted field preserves its current value. The f
| topic?<sup>2</sup> | ?string | The topic of the channel (1-1,024 characters), or null to clear it |
| url?<sup>3</sup> | ?string | The destination of a guild link channel (1-2,048 characters, `http` or `https`), or null to clear it |
| parent_id?<sup>4</sup> | ?snowflake | The ID of the parent category, where null or the snowflake `0` moves the channel to the top level |
| bitrate?<sup>5</sup> | ?integer | The voice bitrate in bits per second (8,000-320,000) |
| bitrate?<sup>5</sup><sup>13</sup> | ?integer | The voice bitrate in bits per second (8,000-384,000) |
| user_limit?<sup>5</sup> | ?integer | The voice occupancy limit (0-99, where `0` configures no limit) |
| voice_connection_limit?<sup>5</sup> | ?integer | The number of simultaneous voice connections one user may hold (1-100) |
| permission_overwrites?<sup>6</sup> | array[[permission overwrite](#permission-overwrite-object) object] | The complete overwrite collection that replaces the stored one |
@@ -405,6 +405,8 @@ Every field is optional, and an omitted field preserves its current value. The f
<sup>12</sup> Applied only to a guild voice channel. Supplying the field at all, including as null, requires [UPDATE_RTC_REGION](/http-api/permissions/)
<sup>13</sup> The stored value is capped at 96,000 unless the guild holds an [audio bitrate feature](/http-api/guilds/#guild-features). A higher value is stored at the cap rather than rejected
A `url` that is not an absolute `http` or `https` URL with a host returns 400 `INVALID_FORM_BODY` with the code `INVALID_URL_FORMAT` on the path `url`.
A parent that does not exist in the same guild returns 400 `INVALID_FORM_BODY` with the code `INVALID_PARENT_CHANNEL` on the path `parent_id`. A parent that is not a category returns `PARENT_MUST_BE_CATEGORY` the same way.
@@ -157,7 +157,7 @@ Creates a guild channel and returns its [channel object](/http-api/channels/#cha
| topic? | ?string | Channel topic (1-1024 characters) |
| url?<sup>3</sup> | ?string | Destination URL for a link channel |
| parent_id?<sup>4</sup> | ?snowflake | Parent category |
| bitrate?<sup>5</sup> | ?integer | Voice channel bitrate in bits per second (8000-320000, default 64000) |
| bitrate?<sup>5</sup><sup>11</sup> | ?integer | Voice channel bitrate in bits per second (8000-384000, default 64000) |
| user_limit?<sup>5</sup><sup>6</sup> | ?integer | Voice channel occupancy limit (0-99, default 0) |
| voice_connection_limit?<sup>5</sup> | ?integer | Simultaneous voice connections permitted for one user in a voice channel (1-100, default 5) |
| rate_limit_per_user? | ?integer | Slowmode interval in seconds (0-21600, default 0) |
@@ -187,6 +187,8 @@ Creates a guild channel and returns its [channel object](/http-api/channels/#cha
<sup>10</sup> The value is trimmed, and an empty result is stored as null
<sup>11</sup> The stored value is capped at 96000 unless the guild holds an [audio bitrate feature](/http-api/guilds/#guild-features). A higher value is stored at the cap rather than rejected
A value longer than 10000 characters is rejected with `STRING_LENGTH_INVALID` before normalisation runs. A parent that does not exist in this guild returns `INVALID_PARENT_CHANNEL`, and one that is not a category returns `PARENT_MUST_BE_CATEGORY`.
The guild holds at most the instance-configured `max_guild_channels` [limit](/http-api/instance/#limit-keys), defaulting to 500, and a parent category holds at most `max_channels_per_category`, defaulting to 50. Reaching the guild limit returns 400 `MAX_GUILD_CHANNELS` and reaching the category limit returns 400 `MAX_CATEGORY_CHANNELS`. Each message has the resolved limit. The new channel is created with no RTC region.
@@ -242,7 +242,7 @@ A creation template describes the roles and channels that [Create guild](#create
| topic? | ?string | Channel topic |
| position | integer | The sort position of the channel |
| parent_id?<sup>3</sup> | ?decimal string \| integer | Template-local parent category ID |
| bitrate?<sup>4</sup> | ?integer | Voice bitrate, defaulting to 64000 for a voice channel |
| bitrate?<sup>4</sup> | ?integer | Voice bitrate, defaulting to 64000 for a voice channel and capped at 96000 |
| user_limit?<sup>4</sup> | ?integer | Voice occupancy limit, defaulting to 0 for a voice channel |
| voice_connection_limit?<sup>4</sup> | ?integer | Per-user voice connection limit, defaulting to 5 for a voice channel |
| nsfw? | boolean | Whether the channel is age restricted (default false) |
@@ -378,6 +378,9 @@ Each value in the guild's `features` array is a capability or availability flag.
| --- | --- |
| ANIMATED_ICON | Guild can use an animated icon |
| ANIMATED_BANNER | Guild can use an animated banner |
| AUDIO_BITRATE_128_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 128000 |
| AUDIO_BITRATE_256_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 256000 |
| AUDIO_BITRATE_384_KBPS<sup>6</sup> | Voice channel bitrate ceiling is raised to 384000 |
| BANNER | Guild can use a banner |
| CLONE_EMOJI_DISABLED<sup>1</sup> | The one-click emoji clone shortcut is disabled for non-members |
| CLONE_STICKER_DISABLED<sup>1</sup> | The one-click sticker clone shortcut is disabled for non-members |
@@ -415,6 +418,8 @@ Each value in the guild's `features` array is a capability or availability flag.
<sup>5</sup> The feature raises the default `max_guild_members` limit from 1000000 to 10000000 before the ordered [limit configuration](/http-api/instance/#limit-keys) is checked, so the raised ceiling applies even when no configured rule names the feature
<sup>6</sup> A guild holding none of the three caps a voice channel `bitrate` at 96000. The highest feature the guild holds wins, and a write above the cap is stored at the cap rather than rejected. A guild that loses the feature keeps the stored value, and the next write to `bitrate` lowers it to the cap
## Custom invite URL object
A custom invite URL object reports the guild's current custom invite code and how many times the invite behind it has been used. [Get guild custom invite URL](#get-guild-custom-invite-url) returns it.
@@ -79,6 +79,8 @@ A call never acks. A refused placement into a direct message or group direct mes
A guild voice channel stores its `bitrate`, `user_limit`, `voice_connection_limit`, and `rtc_region` on the [channel object](/http-api/channels/#channel-object). It also has ordinary messages, pins, and slowmode, so its text history is read and written through the [Messages resource](/http-api/messages/).
A new voice channel stores a `bitrate` of 64000. The ceiling is 96000, and the `AUDIO_BITRATE_128_KBPS`, `AUDIO_BITRATE_256_KBPS`, and `AUDIO_BITRATE_384_KBPS` [guild features](/http-api/guilds/#guild-features) raise it to 128000, 256000, and 384000. A direct message and a group direct message call carry no `bitrate` and always run at 64000.
### Permissions
| Permission | Effect on voice |
@@ -135,6 +137,8 @@ A member whose `communication_disabled_until` is still in the future is refused
The first placement in the channel pins one voice server for it, and every later placement inherits that pinned server whatever its own coordinates are. A placement that finds no usable pin takes the accessible server nearest to the `latitude` and `longitude` the placement command supplied. Where the command supplied no usable coordinates, the placement falls back to the deployment's default region, and then to the first accessible region.
A voice server can have a soft connection limit. A placement that has to choose a server prefers the servers below their limit and uses one that is at or above its limit only when no other server can take the placement. [Soft connection limits](/admin-api/voice/#soft-connection-limits) describes the rule in full.
The pin drops when the channel's `rtc_region` changes, when a call changes region, when the pinned server stops being accessible, or when the media server reports the room finished. That last case also disconnects every connection in a guild voice channel.
The literal `automatic` is not a channel region. Only the `region` field of [Modify call region](/http-api/calls/#modify-call-region) accepts it, as a synonym for null.
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {
clampVoiceChannelBitrate,
GuildFeatures,
getMaxVoiceChannelBitrate,
resolveVoiceChannelBitrate,
} from './GuildConstants';
describe('getMaxVoiceChannelBitrate', () => {
it('caps a guild holding no audio bitrate feature at 96 kbps', () => {
expect(getMaxVoiceChannelBitrate([])).toBe(96000);
expect(getMaxVoiceChannelBitrate([GuildFeatures.VIP_VOICE])).toBe(96000);
});
it('caps a call outside a guild at 96 kbps', () => {
expect(getMaxVoiceChannelBitrate(null)).toBe(96000);
expect(getMaxVoiceChannelBitrate(undefined)).toBe(96000);
});
it('raises the cap to the feature the guild holds', () => {
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_128_KBPS])).toBe(128000);
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_256_KBPS])).toBe(256000);
expect(getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_384_KBPS])).toBe(384000);
});
it('takes the highest feature when a guild holds several', () => {
expect(
getMaxVoiceChannelBitrate([GuildFeatures.AUDIO_BITRATE_384_KBPS, GuildFeatures.AUDIO_BITRATE_128_KBPS]),
).toBe(384000);
expect(
getMaxVoiceChannelBitrate(new Set([GuildFeatures.AUDIO_BITRATE_128_KBPS, GuildFeatures.AUDIO_BITRATE_256_KBPS])),
).toBe(256000);
});
});
describe('clampVoiceChannelBitrate', () => {
it('clamps above the cap the guild holds', () => {
expect(clampVoiceChannelBitrate(384000, [])).toBe(96000);
expect(clampVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_128_KBPS])).toBe(128000);
expect(clampVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_384_KBPS])).toBe(384000);
});
it('leaves a value under the cap alone', () => {
expect(clampVoiceChannelBitrate(64000, [])).toBe(64000);
expect(clampVoiceChannelBitrate(8000, [])).toBe(8000);
});
it('raises a value under the minimum', () => {
expect(clampVoiceChannelBitrate(1000, [])).toBe(8000);
});
});
describe('resolveVoiceChannelBitrate', () => {
it('falls back to 64 kbps when the channel stores no bitrate', () => {
expect(resolveVoiceChannelBitrate(null, null)).toBe(64000);
expect(resolveVoiceChannelBitrate(undefined, [])).toBe(64000);
expect(resolveVoiceChannelBitrate(0, [])).toBe(64000);
expect(resolveVoiceChannelBitrate(Number.NaN, [])).toBe(64000);
});
it('clamps a stored bitrate to what the guild currently holds', () => {
expect(resolveVoiceChannelBitrate(384000, [])).toBe(96000);
expect(resolveVoiceChannelBitrate(384000, [GuildFeatures.AUDIO_BITRATE_256_KBPS])).toBe(256000);
});
});
+42
View File
@@ -1,5 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
VOICE_CHANNEL_BITRATE_DEFAULT,
VOICE_CHANNEL_BITRATE_MAX_128_KBPS,
VOICE_CHANNEL_BITRATE_MAX_256_KBPS,
VOICE_CHANNEL_BITRATE_MAX_384_KBPS,
VOICE_CHANNEL_BITRATE_MAX_STANDARD,
VOICE_CHANNEL_BITRATE_MIN,
} from '@fluxer/constants/src/LimitConstants';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
export const GuildVerificationLevel = {
@@ -97,6 +105,9 @@ export const CONTENT_WARNING_TEXT_MAX_LENGTH = 200;
export const GuildFeatures = {
ANIMATED_ICON: 'ANIMATED_ICON',
ANIMATED_BANNER: 'ANIMATED_BANNER',
AUDIO_BITRATE_128_KBPS: 'AUDIO_BITRATE_128_KBPS',
AUDIO_BITRATE_256_KBPS: 'AUDIO_BITRATE_256_KBPS',
AUDIO_BITRATE_384_KBPS: 'AUDIO_BITRATE_384_KBPS',
BANNER: 'BANNER',
CLONE_EMOJI_DISABLED: 'CLONE_EMOJI_DISABLED',
CLONE_STICKER_DISABLED: 'CLONE_STICKER_DISABLED',
@@ -127,6 +138,37 @@ export const GuildFeatures = {
export type GuildFeature = ValueOf<typeof GuildFeatures>;
export function getMaxVoiceChannelBitrate(features: Iterable<string> | null | undefined): number {
if (features == null) {
return VOICE_CHANNEL_BITRATE_MAX_STANDARD;
}
let maximum = VOICE_CHANNEL_BITRATE_MAX_STANDARD;
for (const feature of features) {
if (feature === GuildFeatures.AUDIO_BITRATE_384_KBPS) {
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_384_KBPS);
} else if (feature === GuildFeatures.AUDIO_BITRATE_256_KBPS) {
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_256_KBPS);
} else if (feature === GuildFeatures.AUDIO_BITRATE_128_KBPS) {
maximum = Math.max(maximum, VOICE_CHANNEL_BITRATE_MAX_128_KBPS);
}
}
return maximum;
}
export function clampVoiceChannelBitrate(bitrate: number, features: Iterable<string> | null | undefined): number {
return Math.min(Math.max(bitrate, VOICE_CHANNEL_BITRATE_MIN), getMaxVoiceChannelBitrate(features));
}
export function resolveVoiceChannelBitrate(
bitrate: number | null | undefined,
features: Iterable<string> | null | undefined,
): number {
if (typeof bitrate !== 'number' || !Number.isFinite(bitrate) || bitrate <= 0) {
return VOICE_CHANNEL_BITRATE_DEFAULT;
}
return clampVoiceChannelBitrate(Math.round(bitrate), features);
}
export const JoinSourceTypes = {
CREATOR: 0,
INSTANT_INVITE: 1,
+6 -1
View File
@@ -4,7 +4,12 @@ export const MAX_GUILDS_PREMIUM = 200;
export const MAX_GUILD_CHANNELS = 500;
export const MAX_CHANNELS_PER_CATEGORY = 50;
export const VOICE_CHANNEL_BITRATE_MIN = 8000;
export const VOICE_CHANNEL_BITRATE_MAX = 320000;
export const VOICE_CHANNEL_BITRATE_DEFAULT = 64000;
export const VOICE_CHANNEL_BITRATE_MAX_STANDARD = 96000;
export const VOICE_CHANNEL_BITRATE_MAX_128_KBPS = 128000;
export const VOICE_CHANNEL_BITRATE_MAX_256_KBPS = 256000;
export const VOICE_CHANNEL_BITRATE_MAX_384_KBPS = 384000;
export const VOICE_CHANNEL_BITRATE_MAX = VOICE_CHANNEL_BITRATE_MAX_384_KBPS;
export const VOICE_CHANNEL_USER_LIMIT_MIN = 0;
export const VOICE_CHANNEL_USER_LIMIT_MAX = 99;
export const VOICE_CHANNEL_CAMERA_USER_LIMIT = 25;
@@ -21,6 +21,8 @@ function areServerCoordinatesPaired(
return latitudeIsNull === longitudeIsNull;
}
const SoftConnectionLimitType = z.number().int().min(1).max(2147483647).nullable();
export const VoiceRegionAdminResponse = z.object({
id: z.string().describe('Unique identifier for the voice region'),
name: z.string().describe('Display name of the voice region'),
@@ -45,6 +47,9 @@ export const VoiceServerAdminResponse = z.object({
latitude: z.number().nullable().describe('Optional geographic latitude override for this server'),
longitude: z.number().nullable().describe('Optional geographic longitude override for this server'),
is_active: z.boolean().describe('Whether the server is currently active'),
soft_connection_limit: SoftConnectionLimitType.describe(
'Connection count above which placement prefers another server, or null when the server has no limit',
),
vip_only: z.boolean().describe('Whether this server is restricted to VIP users'),
required_guild_features: z.array(z.string()).max(100).describe('Guild features required to use this server'),
allowed_guild_ids: z.array(SnowflakeStringType).max(1000).describe('Guild IDs explicitly allowed to use this server'),
@@ -128,6 +133,9 @@ export const CreateVoiceServerRequest = z
latitude: z.number().nullable().optional().describe('Optional geographic latitude override for this server'),
longitude: z.number().nullable().optional().describe('Optional geographic longitude override for this server'),
is_active: z.boolean().optional().default(true).describe('Whether the server is currently active'),
soft_connection_limit: SoftConnectionLimitType.optional()
.default(null)
.describe('Connection count above which placement prefers another server, or null for no limit'),
vip_only: z.boolean().optional().default(false).describe('Whether this server is restricted to VIP users'),
required_guild_features: z
.array(createStringType(1, 64))
@@ -165,6 +173,9 @@ export const UpdateVoiceServerRequest = z
latitude: z.number().nullable().optional().describe('Optional geographic latitude override for this server'),
longitude: z.number().nullable().optional().describe('Optional geographic longitude override for this server'),
is_active: z.boolean().optional().describe('Whether the server is currently active'),
soft_connection_limit: SoftConnectionLimitType.optional().describe(
'Connection count above which placement prefers another server, or null for no limit',
),
vip_only: z.boolean().optional().describe('Whether this server is restricted to VIP users'),
required_guild_features: z
.array(createStringType(1, 64))
@@ -11,6 +11,7 @@ import {
RTC_REGION_ID_MAX_LENGTH,
RTC_REGION_ID_MIN_LENGTH,
VOICE_CHANNEL_BITRATE_MAX,
VOICE_CHANNEL_BITRATE_MAX_STANDARD,
VOICE_CHANNEL_BITRATE_MIN,
VOICE_CHANNEL_CONNECTION_LIMIT_MAX,
VOICE_CHANNEL_CONNECTION_LIMIT_MIN,
@@ -59,7 +60,9 @@ const ChannelCommonBase = z.object({
.min(VOICE_CHANNEL_BITRATE_MIN)
.max(VOICE_CHANNEL_BITRATE_MAX)
.nullish()
.describe(`Voice channel bitrate in bits per second (${VOICE_CHANNEL_BITRATE_MIN}-${VOICE_CHANNEL_BITRATE_MAX})`),
.describe(
`Voice channel bitrate in bits per second (${VOICE_CHANNEL_BITRATE_MIN}-${VOICE_CHANNEL_BITRATE_MAX}), clamped to ${VOICE_CHANNEL_BITRATE_MAX_STANDARD} unless the guild holds an AUDIO_BITRATE feature`,
),
user_limit: z
.number()
.int()
@@ -41,6 +41,21 @@ export const GuildFeatureSchema = withOpenApiType(
[
[GuildFeatures.ANIMATED_ICON, 'ANIMATED_ICON', 'Guild can have an animated icon'],
[GuildFeatures.ANIMATED_BANNER, 'ANIMATED_BANNER', 'Guild can have an animated banner'],
[
GuildFeatures.AUDIO_BITRATE_128_KBPS,
'AUDIO_BITRATE_128_KBPS',
'Guild can set a voice channel bitrate of up to 128 kbps',
],
[
GuildFeatures.AUDIO_BITRATE_256_KBPS,
'AUDIO_BITRATE_256_KBPS',
'Guild can set a voice channel bitrate of up to 256 kbps',
],
[
GuildFeatures.AUDIO_BITRATE_384_KBPS,
'AUDIO_BITRATE_384_KBPS',
'Guild can set a voice channel bitrate of up to 384 kbps',
],
[GuildFeatures.BANNER, 'BANNER', 'Guild can have a banner'],
[
GuildFeatures.CLONE_EMOJI_DISABLED,
+4
View File
@@ -8883,6 +8883,10 @@
{
"name": "longitude",
"type": "double"
},
{
"name": "soft_connection_limit",
"type": "int"
}
],
"primary_key": "((region_id), server_id)",