From 6bd045eb79f76711e9bfaf65d9fe402768a26f38 Mon Sep 17 00:00:00 2001 From: "stoat-tofu[bot]" <242700035+stoat-tofu[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:45:25 +0000 Subject: [PATCH 01/14] chore: modify .github/workflows/release-webhook.yml --- .github/workflows/release-webhook.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-webhook.yml b/.github/workflows/release-webhook.yml index 2262162c..46504b1a 100644 --- a/.github/workflows/release-webhook.yml +++ b/.github/workflows/release-webhook.yml @@ -20,4 +20,4 @@ jobs: RELEASE_URL="https://github.com/${REPOSITORY}/releases/tag/${TAG_NAME}" curl -X POST "$WEBHOOK_URL" \ -H "Content-Type: application/json" \ - -d "{\"content\": \"$RELEASE_URL\"}" + -d "{\"content\": \"$RELEASE_URL\"}" \ No newline at end of file From 0b522ebddc17f2e3f792ff5e2347793e9849fa23 Mon Sep 17 00:00:00 2001 From: Christopher Hultin Date: Sun, 1 Mar 2026 12:03:49 -0700 Subject: [PATCH 02/14] feat: expose global and user limits in root API response (#644) * feat: expose global and user limits in root API response Signed-off-by: Chris Hultin * add missing file upload limits Signed-off-by: Chris Hultin * extract logic to generate "UserLimits" struct from "config" Signed-off-by: Chris Hultin * convert user limits from feature limits function to impl function Signed-off-by: Chris Hultin * picking nits Signed-off-by: Chris Hultin --------- Signed-off-by: Chris Hultin --- crates/delta/src/routes/root.rs | 89 +++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/delta/src/routes/root.rs b/crates/delta/src/routes/root.rs index 3ddbfcc7..44b697d4 100644 --- a/crates/delta/src/routes/root.rs +++ b/crates/delta/src/routes/root.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use revolt_config::config; use revolt_result::Result; use rocket::serde::json::Json; @@ -54,6 +55,80 @@ pub struct RevoltFeatures { pub january: Feature, /// Voice server configuration pub livekit: VoiceFeature, + /// Limits + pub limits: LimitsConfig, +} + +/// # Limits For Users +#[derive(Serialize, JsonSchema, Debug)] +pub struct LimitsConfig { + /// Global Limits + pub global: GlobalLimits, + /// New User Limits + pub new_user: UserLimits, + /// Default User Limits + pub default: UserLimits, +} + +/// # Global limits +#[derive(Serialize, JsonSchema, Debug)] +pub struct GlobalLimits { + /// max group size + group_size: i64, + /// max message embeds + message_embeds: i64, + /// max replies + message_replies: i64, + /// max reactions per message + message_reactions: i64, + /// max server emoji + server_emoji: i64, + /// max server roles + server_roles: i64, + /// max server channels + server_channels: i64, + body_limit_size: i64, +} + +/// # User Limits +#[derive(Serialize, JsonSchema, Debug)] +pub struct UserLimits { + /// Max Outgoing Friend Requests + pub outgoing_friend_requests: i64, + /// Max Owned Bots + pub bots: i64, + /// Max message content length + pub message_length: i64, + /// max message attachments + pub message_attachments: i64, + /// max servers + pub servers: i64, + /// max audio quality + pub voice_quality: i64, + /// video streaming enabled + pub video: bool, + /// max video resolution (vertical, horizontal) + pub video_resolution: [i64; 2], + /// min/max aspect ratios + pub video_aspect_ratio: [f64; 2], + pub file_upload_size_limits: HashMap, +} + +impl UserLimits { + fn from_feature_limits(fl: revolt_config::FeaturesLimits) -> UserLimits { + UserLimits { + outgoing_friend_requests: fl.outgoing_friend_requests as i64, + bots: fl.bots as i64, + message_length: fl.message_length as i64, + message_attachments: fl.message_attachments as i64, + servers: fl.servers as i64, + voice_quality: fl.voice_quality as i64, + video: fl.video, + video_resolution: [fl.video_resolution[0] as i64, fl.video_resolution[1] as i64], + video_aspect_ratio: [fl.video_aspect_ratio[0] as f64, fl.video_aspect_ratio[1] as f64], + file_upload_size_limits: fl.file_upload_size_limit, + } + } } /// # Build Information @@ -134,6 +209,20 @@ pub async fn root() -> Result> { }) .collect(), }, + limits: LimitsConfig { + global: GlobalLimits { + group_size: config.features.limits.global.group_size as i64, + message_embeds: config.features.limits.global.message_embeds as i64, + message_replies: config.features.limits.global.message_replies as i64, + message_reactions: config.features.limits.global.message_reactions as i64, + server_emoji: config.features.limits.global.server_emoji as i64, + server_roles: config.features.limits.global.server_roles as i64, + server_channels: config.features.limits.global.server_channels as i64, + body_limit_size: config.features.limits.global.body_limit_size as i64, + }, + new_user: UserLimits::from_feature_limits(config.features.limits.new_user), + default: UserLimits::from_feature_limits(config.features.limits.default), + } }, ws: config.hosts.events, app: config.hosts.app, From a80ad1cbe58b8af5e45751e51d94d93c1cea1c9f Mon Sep 17 00:00:00 2001 From: Jacob Schlecht Date: Sun, 1 Mar 2026 12:06:13 -0700 Subject: [PATCH 03/14] fix: Fix typo for p256dh in vapid notification flow (#622) * fix: Fix typo for p256dh in vapid notification flow This commit was made without the use of generative AI. Signed-off-by: Jacob Schlecht * fix: Revert .clone() change as it works fine without that change This commit was made without the use of generative AI. Signed-off-by: Jacob Schlecht --------- Signed-off-by: Jacob Schlecht --- crates/daemons/pushd/src/consumers/inbound/dm_call.rs | 2 +- crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs | 2 +- crates/daemons/pushd/src/consumers/inbound/fr_received.rs | 2 +- crates/daemons/pushd/src/consumers/inbound/generic.rs | 2 +- crates/daemons/pushd/src/consumers/inbound/mass_mention.rs | 2 +- crates/daemons/pushd/src/consumers/inbound/message.rs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/daemons/pushd/src/consumers/inbound/dm_call.rs b/crates/daemons/pushd/src/consumers/inbound/dm_call.rs index b5b89dc1..873b55cc 100644 --- a/crates/daemons/pushd/src/consumers/inbound/dm_call.rs +++ b/crates/daemons/pushd/src/consumers/inbound/dm_call.rs @@ -128,7 +128,7 @@ impl DmCallConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs b/crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs index d525138c..115e4a26 100644 --- a/crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs +++ b/crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs @@ -101,7 +101,7 @@ impl FRAcceptedConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/fr_received.rs b/crates/daemons/pushd/src/consumers/inbound/fr_received.rs index f64bc92b..c611dfaf 100644 --- a/crates/daemons/pushd/src/consumers/inbound/fr_received.rs +++ b/crates/daemons/pushd/src/consumers/inbound/fr_received.rs @@ -101,7 +101,7 @@ impl FRReceivedConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/generic.rs b/crates/daemons/pushd/src/consumers/inbound/generic.rs index aa3950d7..302b1700 100644 --- a/crates/daemons/pushd/src/consumers/inbound/generic.rs +++ b/crates/daemons/pushd/src/consumers/inbound/generic.rs @@ -107,7 +107,7 @@ impl GenericConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/mass_mention.rs b/crates/daemons/pushd/src/consumers/inbound/mass_mention.rs index bf5dd453..cb6f3c44 100644 --- a/crates/daemons/pushd/src/consumers/inbound/mass_mention.rs +++ b/crates/daemons/pushd/src/consumers/inbound/mass_mention.rs @@ -104,7 +104,7 @@ impl MassMessageConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/message.rs b/crates/daemons/pushd/src/consumers/inbound/message.rs index 6b9d00fe..420c3707 100644 --- a/crates/daemons/pushd/src/consumers/inbound/message.rs +++ b/crates/daemons/pushd/src/consumers/inbound/message.rs @@ -107,7 +107,7 @@ impl MessageConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); From f777e2863c6ca50057c8b5d0a5be14915d287724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= <31723128+kris6673@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:16:45 +0100 Subject: [PATCH 04/14] feat: add bug report template for issue tracking (#627) Signed-off-by: Bobby <31723128+kris6673@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/bug.yml | 147 +++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..867978dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,147 @@ +name: Bug Report +description: Report a bug in Stoat — please fill out every required field to help us triage quickly. +title: "[Bug]: " +labels: + - bug + - unconfirmed + +body: + # ── Thank you section ──────────────────────────────────────────────────────── + - type: markdown + attributes: + value: | + **Thank you for taking the time to report a bug for Stoat!** + + Please follow the instructions below and provide as much detail as possible to help us understand and reproduce the issue. + + # ── Pre-flight checks ──────────────────────────────────────────────────────── + - type: checkboxes + id: preflight + attributes: + label: Pre-flight checklist + description: Please confirm all of the following before submitting. + options: + - label: I am on the latest available version of Stoat. + required: true + - label: I searched existing issues and did not find a duplicate. + required: true + - label: This is a bug report, not a support question or feature request. + required: true + + # ── Bug description ────────────────────────────────────────────────────────── + - type: textarea + id: description + attributes: + label: Bug description + description: > + What happened? What did you expect to happen instead? + Keep this brief — detailed steps go in the next field. + placeholder: "Example: When I open a DM thread, new messages from the other person do not appear unless I reload the page." + validations: + required: true + + # ── Steps to reproduce ─────────────────────────────────────────────────────── + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: > + Provide a numbered list of every step needed to trigger the bug. + The clearer this is, the faster we can fix it. + placeholder: | + 1. Log in to Stoat on the web client. + 2. Open a direct message conversation. + 3. Ask the other person to send a message. + 4. Observe that the message does not appear without reloading. + validations: + required: true + + # ── Expected behavior ──────────────────────────────────────────────────────── + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What should have happened? + placeholder: "New messages should appear in real time without a page reload." + validations: + required: true + + # ── Client / platform ──────────────────────────────────────────────────────── + - type: dropdown + id: platform + attributes: + label: Client / platform + description: Which Stoat client are you using? + options: + - Web (browser) + - Stoat for Desktop + - Android + - iOS + - Self-hosted server + validations: + required: true + + # ── Client version ─────────────────────────────────────────────────────────── + - type: input + id: version + attributes: + label: Client version + description: > + Find this in **Settings**. For the web client, include the build + number shown in the footer or About page. + placeholder: "e.g. Stoat for Desktop 1.3.0 or 0.2.1 (2025-10-10)" + validations: + required: true + + # ── OS / browser details ───────────────────────────────────────────────────── + - type: input + id: os + attributes: + label: OS / browser details + description: Your operating system and, for web bugs, your browser and its version. + placeholder: "e.g. Windows 11, Chrome 121 or macOS 14.3, Stoat for Desktop 1.3.0 or Android 14" + validations: + required: true + + # ── Reproducibility ────────────────────────────────────────────────────────── + - type: dropdown + id: reproducibility + attributes: + label: Reproducibility + description: How consistently does this bug occur? + options: + - Always + - Usually + - Rarely + - Only once + - Unknown + validations: + required: true + + # ── Screenshots or video ───────────────────────────────────────────────────── + - type: textarea + id: screenshots + attributes: + label: Screenshots or video + description: Paste images or drag-and-drop a screen recording here. GitHub accepts common image and video formats. + validations: + required: false + + # ── Additional context ─────────────────────────────────────────────────────── + - type: textarea + id: additional + attributes: + label: Additional context + description: > + Anything else that might help: workarounds you found, links to related issues, + self-hosted configuration details (Redis, MongoDB, LiveKit, SMTP), network environment, etc. + validations: + required: false + + # ── Closing note ───────────────────────────────────────────────────────────── + - type: markdown + attributes: + value: | + --- + A maintainer will review your report. Please watch for follow-up questions — issues + that go unanswered are harder to resolve and may be closed after 30 days of inactivity. From 24d0d2b7266f6f8a692d0a52704acfecf517674c Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 2 Mar 2026 20:45:42 -0800 Subject: [PATCH 05/14] fix: no node state set on channel creation (#653) * fix: no node state set on channel creation Signed-off-by: IAmTomahawkx * Update "join_call" logic to handle issue of "user is already connected to a channel that cannot be found" Fixes stoatchat/for-web#951 Signed-off-by: Chris Hultin * feat: remove node key when channel is empty or deleted --------- Signed-off-by: IAmTomahawkx Signed-off-by: Chris Hultin Co-authored-by: Chris Hultin --- crates/core/database/src/voice/mod.rs | 36 +++++++++++++++---- crates/daemons/voice-ingress/src/api.rs | 7 ++-- .../delta/src/routes/channels/voice_join.rs | 13 +++++-- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/core/database/src/voice/mod.rs b/crates/core/database/src/voice/mod.rs index dde70a71..1705bece 100644 --- a/crates/core/database/src/voice/mod.rs +++ b/crates/core/database/src/voice/mod.rs @@ -16,7 +16,9 @@ mod voice_client; pub use voice_client::VoiceClient; async fn get_connection() -> Result { - _get_connection().await.map_err(|_| create_error!(InternalError)) + _get_connection() + .await + .map_err(|_| create_error!(InternalError)) } pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> { @@ -59,6 +61,14 @@ pub async fn get_channel_node(channel: &str) -> Result> { .to_internal_error() } +pub async fn delete_channel_node(channel: &str) -> Result<()> { + get_connection() + .await? + .del(format!("node:{channel}")) + .await + .to_internal_error() +} + pub async fn get_user_voice_channels(user_id: &str) -> Result> { get_connection() .await? @@ -226,6 +236,7 @@ pub async fn delete_channel_voice_state( let mut pipeline = Pipeline::new(); pipeline.del(format!("vc_members:{channel_id}")); + pipeline.del(format!("node:{channel_id}")); for user_id in user_ids { let unique_key = format!("{user_id}:{parent_id}"); @@ -567,15 +578,24 @@ pub async fn get_call_notification_recipients( .to_internal_error() } -pub async fn remove_user_from_voice_channels(db: &Database, voice_client: &VoiceClient, user_id: &str) -> Result<()> { +pub async fn remove_user_from_voice_channels( + db: &Database, + voice_client: &VoiceClient, + user_id: &str, +) -> Result<()> { for channel_id in get_user_voice_channels(user_id).await? { remove_user_from_voice_channel(db, voice_client, &channel_id, user_id).await?; - }; + } Ok(()) } -pub async fn remove_user_from_voice_channel(db: &Database, voice_client: &VoiceClient, channel_id: &str, user_id: &str) -> Result<()> { +pub async fn remove_user_from_voice_channel( + db: &Database, + voice_client: &VoiceClient, + channel_id: &str, + user_id: &str, +) -> Result<()> { if let Some(node) = get_channel_node(channel_id).await? { let _ = voice_client.remove_user(&node, user_id, channel_id).await; } @@ -587,7 +607,11 @@ pub async fn remove_user_from_voice_channel(db: &Database, voice_client: &VoiceC Ok(()) } -pub async fn delete_voice_channel(voice_client: &VoiceClient, channel_id: &str, server_id: Option<&str>) -> Result<()> { +pub async fn delete_voice_channel( + voice_client: &VoiceClient, + channel_id: &str, + server_id: Option<&str>, +) -> Result<()> { if let Some(users) = get_voice_channel_members(channel_id).await? { let node = get_channel_node(channel_id).await?.unwrap(); @@ -597,4 +621,4 @@ pub async fn delete_voice_channel(voice_client: &VoiceClient, channel_id: &str, }; Ok(()) -} \ No newline at end of file +} diff --git a/crates/daemons/voice-ingress/src/api.rs b/crates/daemons/voice-ingress/src/api.rs index 2af3ab26..56e5b2f7 100644 --- a/crates/daemons/voice-ingress/src/api.rs +++ b/crates/daemons/voice-ingress/src/api.rs @@ -5,9 +5,8 @@ use revolt_database::{ iso8601_timestamp::{Duration, Timestamp}, util::reference::Reference, voice::{ - create_voice_state, delete_voice_state, - get_user_moved_from_voice, get_user_moved_to_voice, - update_voice_state_tracks, VoiceClient, + create_voice_state, delete_channel_node, delete_voice_state, get_user_moved_from_voice, + get_user_moved_to_voice, get_voice_channel_members, update_voice_state_tracks, VoiceClient, }, Database, AMQP, }; @@ -160,6 +159,8 @@ pub async fn ingress( // let members = get_voice_channel_members(channel_id).await?; // if members.is_none_or(|m| m.is_empty()) { + // delete_channel_node(channel_id).await?; + // // // The channel is empty so send out an "end" message for ringing // if let Err(e) = amqp // .dm_call_updated(user_id, channel_id, None, true, None) diff --git a/crates/delta/src/routes/channels/voice_join.rs b/crates/delta/src/routes/channels/voice_join.rs index 670c5032..fe585f6f 100644 --- a/crates/delta/src/routes/channels/voice_join.rs +++ b/crates/delta/src/routes/channels/voice_join.rs @@ -3,7 +3,7 @@ use revolt_database::{ util::{permissions::perms, reference::Reference}, voice::{ delete_voice_state, get_channel_node, get_user_voice_channels, get_voice_channel_members, - raise_if_in_voice, set_call_notification_recipients, VoiceClient, + raise_if_in_voice, set_call_notification_recipients, set_channel_node, VoiceClient, }, Database, User, }; @@ -60,6 +60,7 @@ pub async fn call( } let existing_node = get_channel_node(channel.id()).await?; + let has_existing_node = existing_node.is_some(); // we move existing_node in the next statement so this is the quickest way to know if we need to set it. let node = existing_node .or(node) @@ -86,9 +87,11 @@ pub async fn call( let channel = Reference::from_unchecked(&channel_id) .as_channel(db) - .await?; + .await; - delete_voice_state(&channel_id, channel.server(), &user.id).await?; + if channel.is_ok() { + delete_voice_state(&channel_id, channel.unwrap().server(), &user.id).await?; + } } } else { raise_if_in_voice(&user, channel.id()).await?; @@ -100,6 +103,10 @@ pub async fn call( let room = voice_client.create_room(&node, &channel).await?; + if !has_existing_node { + set_channel_node(channel.id(), &node).await?; + } + log::debug!("Created room {}", room.name); if let Some(recipients) = recipients { From d629e89304be2f0011e189293b278f07d346aa7d Mon Sep 17 00:00:00 2001 From: Damocles <106018783+Damocles078@users.noreply.github.com> Date: Sun, 8 Mar 2026 02:28:50 +0100 Subject: [PATCH 06/14] fix: replace some links and Revolt mentions to current Stoat (#515) --- README.md | 35 ++++++++----------- Revolt.toml | 2 +- crates/core/config/Revolt.toml | 4 +-- .../templates/deletion.whitelabel.txt | 2 +- .../database/templates/reset.whitelabel.txt | 2 +- .../core/database/templates/suspension.html | 2 +- crates/core/database/templates/suspension.txt | 2 +- .../templates/suspension.whitelabel.txt | 4 +-- .../database/templates/verify.whitelabel.txt | 2 +- docs/docs/developers/endpoints.md | 16 ++++----- docs/docs/developers/legacy-plugin-api.md | 2 +- docs/docs/developing/backend/new_features.md | 4 +-- 12 files changed, 34 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index d21bb2c4..c80f4595 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@

Stoat Backend - [![Stars](https://img.shields.io/github/stars/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/stargazers) - [![Forks](https://img.shields.io/github/forks/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/network/members) - [![Pull Requests](https://img.shields.io/github/issues-pr/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/pulls) - [![Issues](https://img.shields.io/github/issues/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/issues) - [![Contributors](https://img.shields.io/github/contributors/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/graphs/contributors) - [![License](https://img.shields.io/github/license/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/blob/main/LICENSE) + [![Stars](https://img.shields.io/github/stars/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/stargazers) + [![Forks](https://img.shields.io/github/forks/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/network/members) + [![Pull Requests](https://img.shields.io/github/issues-pr/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/pulls) + [![Issues](https://img.shields.io/github/issues/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/issues) + [![Contributors](https://img.shields.io/github/contributors/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/graphs/contributors) + [![License](https://img.shields.io/github/license/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/blob/main/LICENSE)

-The services and libraries that power the Revolt service.
+The services and libraries that power the Stoat service.

| Crate | Path | Description | | @@ -39,7 +39,7 @@ Rust 1.86.0 or higher. ## Development Guide -Before contributing, make yourself familiar with [our contribution guidelines](https://developers.revolt.chat/contrib.html) and the [technical documentation for this project](https://revoltchat.github.io/backend/). +Before contributing, make yourself familiar with [our contribution guidelines](https://developers.stoat.chat/developing/contrib/) and the [technical documentation for this project](https://stoatchat.github.io/stoatchat/). Before getting started, you'll want to install: @@ -70,8 +70,8 @@ As a heads-up, the development environment uses the following ports: Now you can clone and build the project: ```bash -git clone https://github.com/revoltchat/backend revolt-backend -cd revolt-backend +git clone https://github.com/stoatchat/stoatchat stoat-backend +cd stoat-backend mise build ``` @@ -158,16 +158,11 @@ You can start a web client by doing the following: corepack enable # clone the web client and run it: -git clone --recursive https://github.com/revoltchat/revite -cd revite -yarn -yarn build:deps -echo "VITE_API_URL=http://local.revolt.chat:14702" > .env.local -yarn dev --port 14701 +git clone --recursive https://github.com/stoatchat/for-web stoat-web +cd stoat-web +# refer to stoat-web/README.md for startup, creating an account and loging in ``` -Then go to http://local.revolt.chat:14701 to create an account/login. - When signing up, go to http://localhost:14080 to find confirmation/password reset emails. ## Deployment Guide @@ -198,7 +193,7 @@ Tag and push a new release by running: just release ``` -If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/revoltchat/backend/releases/new) to create a changelog. +If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/stoatchat/stoatchat/releases/new) to create a changelog. ## Testing @@ -217,6 +212,6 @@ TEST_DB=MONGODB cargo nextest run ## License -The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE). +The Stoat backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/stoatchat/stoatchat/blob/master/LICENSE). **Individual crates may supply their own licenses!** diff --git a/Revolt.toml b/Revolt.toml index 0a824e2d..7398ed53 100644 --- a/Revolt.toml +++ b/Revolt.toml @@ -15,7 +15,7 @@ host = "127.0.0.1" [hosts] # Web locations of various services # Defaults assume all services are reverse-proxied -# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile +# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile # # Remember to change these to https/wss where appropriate in production! app = "http://local.revolt.chat:14701" diff --git a/crates/core/config/Revolt.toml b/crates/core/config/Revolt.toml index 13028ccc..a64778c4 100644 --- a/crates/core/config/Revolt.toml +++ b/crates/core/config/Revolt.toml @@ -11,7 +11,7 @@ redis = "redis://redis/" [hosts] # Web locations of various services # Defaults assume all services are reverse-proxied -# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile +# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile # # Remember to change these to https/wss where appropriate in production! app = "http://local.revolt.chat" @@ -34,7 +34,7 @@ password = "rabbitpass" [api.registration] # Whether an invite should be required for registration -# See https://github.com/revoltchat/self-hosted#making-your-instance-invite-only +# See https://github.com/stoatchat/self-hosted#making-your-instance-invite-only invite_only = false [api.smtp] diff --git a/crates/core/database/templates/deletion.whitelabel.txt b/crates/core/database/templates/deletion.whitelabel.txt index 3447b351..b040a51e 100644 --- a/crates/core/database/templates/deletion.whitelabel.txt +++ b/crates/core/database/templates/deletion.whitelabel.txt @@ -6,4 +6,4 @@ This email is intended for {{email}} This email has no association with Stoat or Revolt Platforms Ltd. Learn more about third party instances here: -https://developers.stoat.chat/faq.html +https://developers.stoat.chat/faq/ diff --git a/crates/core/database/templates/reset.whitelabel.txt b/crates/core/database/templates/reset.whitelabel.txt index b8c4adc9..10e8ca2d 100644 --- a/crates/core/database/templates/reset.whitelabel.txt +++ b/crates/core/database/templates/reset.whitelabel.txt @@ -6,4 +6,4 @@ This email is intended for {{email}} This email has no association with Stoat or Revolt Platforms Ltd. Learn more about third party instances here: -https://developers.revolt.chat/faq.html +https://developers.stoat.chat/faq/ diff --git a/crates/core/database/templates/suspension.html b/crates/core/database/templates/suspension.html index 2e4c2298..27b7331e 100644 --- a/crates/core/database/templates/suspension.html +++ b/crates/core/database/templates/suspension.html @@ -107,7 +107,7 @@

Further violations may result in a permanent ban depending on severity, please abide by the - Acceptable Usage Policy. + Community Guidelines.

diff --git a/crates/core/database/templates/suspension.txt b/crates/core/database/templates/suspension.txt index d247eae3..293f8a0c 100644 --- a/crates/core/database/templates/suspension.txt +++ b/crates/core/database/templates/suspension.txt @@ -3,7 +3,7 @@ Your account has been suspended, for one or more reasons: You will be able to use your account again in {{duration}} days. -Further violations may result in a permanent ban depending on severity, please abide by the Acceptable Usage Policy (https://revolt.chat/aup). +Further violations may result in a permanent ban depending on severity, please abide by the Community Guidelines (https://stoat.chat/legal/community-guidelines). Ban evasion is prohibited and will be dealt with accordingly. diff --git a/crates/core/database/templates/suspension.whitelabel.txt b/crates/core/database/templates/suspension.whitelabel.txt index 45d9aa56..bd82145d 100644 --- a/crates/core/database/templates/suspension.whitelabel.txt +++ b/crates/core/database/templates/suspension.whitelabel.txt @@ -3,6 +3,6 @@ Your account has been suspended, for one or more reasons: This email is intended for {{email}} -This email has no association with Revolt or Revolt Platforms Ltd. +This email has no association with Stoat or Revolt Platforms Ltd. Learn more about third party instances here: -https://developers.revolt.chat/faq.html +https://developers.stoat.chat/faq/ diff --git a/crates/core/database/templates/verify.whitelabel.txt b/crates/core/database/templates/verify.whitelabel.txt index 547128b9..16f1c4b1 100644 --- a/crates/core/database/templates/verify.whitelabel.txt +++ b/crates/core/database/templates/verify.whitelabel.txt @@ -7,4 +7,4 @@ This email is intended for {{email}} This email has no association with Stoat or Revolt Platforms Ltd. Learn more about third party instances here: -https://developers.revolt.chat/faq.html +https://developers.stoat.chat/faq/ diff --git a/docs/docs/developers/endpoints.md b/docs/docs/developers/endpoints.md index b22c64e2..a89b4d3e 100644 --- a/docs/docs/developers/endpoints.md +++ b/docs/docs/developers/endpoints.md @@ -8,16 +8,12 @@ sidebar_position: 1 You can connect to the API on the following URLs: -| URL | Release | Description | -| ----------------------------- | :--------: | --------------------------- | -| `https://api.revolt.chat` | Production | Primary API endpoint | -| `https://app.revolt.chat/api` | Production | API endpoint for old client | -| `https://revolt.chat/api` | Staging | API endpoint for new client | +| URL | Release | Description | +| ----------------------------- | :--------: | ---------------------------------- | +| `https://api.stoat.chat` | Production | Primary API endpoint | You can connect to the events server on the following URLs: -| URL | Release | Description | -| ------------------------------ | :--------: | ------------------------------ | -| `wss://ws.revolt.chat` | Production | Primary events endpoint | -| `wss://app.revolt.chat/events` | Production | Events endpoint for old client | -| `wss://revolt.chat/events` | Staging | Events endpoint for new client | +| URL | Release | Description | +| ------------------------------ | :--------: | ------------------------------------- | +| `wss://events.stoat.chat` | Production | Primary events endpoint | diff --git a/docs/docs/developers/legacy-plugin-api.md b/docs/docs/developers/legacy-plugin-api.md index ac5c58fc..d2c452c8 100644 --- a/docs/docs/developers/legacy-plugin-api.md +++ b/docs/docs/developers/legacy-plugin-api.md @@ -10,7 +10,7 @@ The Plugin API is very powerful. **Tread carefully.** **Zero guarantees or sandboxes are provided.** Your code is run as-is. ::: -This document details the very experimental plugin API available in [Revite](https://github.com/revoltchat/revite). +This document details the very experimental plugin API available in [Revite](https://github.com/stoatchat/for-legacy-web). This is more or less a proof of concept but can be used to achieve some simple client modifications. diff --git a/docs/docs/developing/backend/new_features.md b/docs/docs/developing/backend/new_features.md index c9517180..029e1325 100644 --- a/docs/docs/developing/backend/new_features.md +++ b/docs/docs/developing/backend/new_features.md @@ -4,12 +4,12 @@ New API features must be documented where appropriate, this document aims to cov Before writing new API features, generally a good idea to: -- Consult with other developers in the [Revolt Developers space](https://rvlt.gg/API) +- Consult with other developers in the [Stoat Developers space](https://stt.gg/API) - If it's a relatively big feature, also [write an RFC](https://github.com/revoltchat/rfcs) When your feature is ready to release, ensure to: - Update backend documentation (what you're reading now!) if applicable -- Update the [developers documentation](https://github.com/revoltchat/wiki) if applicable +- Update the [developers documentation](https://github.com/stoatchat/developer-wiki) if applicable - Update the Feature Matrix (or ask someone that can to do so) - Ensure it is properly listed in the backend release changelog From b0b728fb0dbc9ee28360301de1c3ea501bbbff1d Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 10 Mar 2026 15:00:06 -0700 Subject: [PATCH 07/14] fix: redis_url vs redis_uri in config (#666) --- crates/core/config/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 8b68ad4f..7e26fb20 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -449,7 +449,7 @@ pub async fn config() -> Settings { let mut config = read().await.try_deserialize::().unwrap(); // inject REDIS_URI for redis-kiss library - if std::env::var("REDIS_URL").is_err() { + if std::env::var("REDIS_URI").is_err() { std::env::set_var("REDIS_URI", config.database.redis.clone()); } From 49c628958070e4f0a5edc764d3b48158589219d9 Mon Sep 17 00:00:00 2001 From: Angelo Kontaxis Date: Tue, 10 Mar 2026 22:14:32 +0000 Subject: [PATCH 08/14] fix: store server id in redis and in room metadata to be able to delete voice state in all scenarios (#656) * fix: store server id in redis values and in room metadata to be able to fully delete voice state in all scenarios * fix: undo logging change --- crates/bonfire/src/events/impl.rs | 6 +- .../src/models/server_members/model.rs | 17 +- crates/core/database/src/voice/mod.rs | 232 ++++++++++++------ .../core/database/src/voice/voice_client.rs | 6 + crates/daemons/voice-ingress/src/api.rs | 55 +++-- crates/delta/src/routes/bots/delete.rs | 8 +- .../src/routes/channels/channel_delete.rs | 17 +- .../delta/src/routes/channels/channel_edit.rs | 4 +- .../routes/channels/group_remove_member.rs | 10 +- .../delta/src/routes/channels/voice_join.rs | 25 +- .../src/routes/channels/voice_stop_ring.rs | 16 +- crates/delta/src/routes/servers/ban_create.rs | 17 +- .../delta/src/routes/servers/member_edit.rs | 25 +- .../delta/src/routes/servers/member_remove.rs | 17 +- .../delta/src/routes/servers/server_delete.rs | 21 +- 15 files changed, 322 insertions(+), 154 deletions(-) diff --git a/crates/bonfire/src/events/impl.rs b/crates/bonfire/src/events/impl.rs index 0fbe1624..c6d07d2f 100644 --- a/crates/bonfire/src/events/impl.rs +++ b/crates/bonfire/src/events/impl.rs @@ -4,7 +4,7 @@ use futures::future::join_all; use revolt_database::{ events::client::{EventV1, ReadyPayloadFields}, util::permissions::DatabasePermissionQuery, - voice::get_channel_voice_state, + voice::{get_channel_voice_state, UserVoiceChannel}, Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus, }; use revolt_models::v0; @@ -167,7 +167,9 @@ impl State { | Channel::TextChannel { voice: Some(_), .. } ) }) { - if let Ok(Some(voice_state)) = get_channel_voice_state(channel).await { + if let Ok(Some(voice_state)) = + get_channel_voice_state(&UserVoiceChannel::from_channel(channel)).await + { if let Some(server) = channel.server() { let set = voice_state_server_members .entry(server.to_string()) diff --git a/crates/core/database/src/models/server_members/model.rs b/crates/core/database/src/models/server_members/model.rs index 0062a270..e242c62f 100644 --- a/crates/core/database/src/models/server_members/model.rs +++ b/crates/core/database/src/models/server_members/model.rs @@ -3,8 +3,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use crate::{ - events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, - Database, File, Server, SystemMessage, User, + events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File, + Server, SystemMessage, User, }; fn default_true() -> bool { @@ -45,7 +45,6 @@ auto_derived_partial!( /// Whether the member is server-wide voice deafened #[serde(skip_serializing_if = "is_true", default = "default_true")] pub can_receive: bool, - // This value only exists in the database, not the models. // If it is not-None, the database layer should return None to member fetching queries. // pub pending_deletion_at: Option @@ -153,7 +152,11 @@ impl Member { #[cfg(feature = "voice")] for channel in &channels { - if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state(channel).await { + if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state( + &crate::voice::UserVoiceChannel::from_channel(channel), + ) + .await + { voice_states.push(voice_state) } } @@ -175,7 +178,7 @@ impl Member { .map(|channel| channel.into()) .collect(), emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(), - voice_states + voice_states, } .private(user.id.clone()) .await; @@ -225,14 +228,14 @@ impl Member { pub fn remove_field(&mut self, field: &FieldsMember) { match field { - FieldsMember::JoinedAt => {}, + FieldsMember::JoinedAt => {} FieldsMember::Avatar => self.avatar = None, FieldsMember::Nickname => self.nickname = None, FieldsMember::Roles => self.roles.clear(), FieldsMember::Timeout => self.timeout = None, FieldsMember::CanReceive => self.can_receive = true, FieldsMember::CanPublish => self.can_publish = true, - FieldsMember::VoiceChannel => {}, + FieldsMember::VoiceChannel => {} } } diff --git a/crates/core/database/src/voice/mod.rs b/crates/core/database/src/voice/mod.rs index 1705bece..836de92a 100644 --- a/crates/core/database/src/voice/mod.rs +++ b/crates/core/database/src/voice/mod.rs @@ -1,3 +1,5 @@ +use std::fmt::{Display, Write}; + use crate::{ events::client::EventV1, models::{Channel, User}, @@ -6,7 +8,11 @@ use crate::{ }; use iso8601_timestamp::{Duration, Timestamp}; use livekit_protocol::ParticipantPermission; -use redis_kiss::{get_connection as _get_connection, redis::Pipeline, AsyncCommands, Conn}; +use redis_kiss::{ + get_connection as _get_connection, + redis::{FromRedisValue, Pipeline, RedisError, RedisWrite, ToRedisArgs, Value}, + AsyncCommands, Conn, +}; use revolt_config::FeaturesLimits; use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue}; @@ -21,13 +27,13 @@ async fn get_connection() -> Result { .map_err(|_| create_error!(InternalError)) } -pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> { +pub async fn raise_if_in_voice(user: &User, channel: &UserVoiceChannel) -> Result<()> { let mut conn = get_connection().await?; if user.bot.is_some() { // bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to if conn - .sismember(format!("vc:{}", &user.id), channel_id) + .sismember(format!("vc:{}", &user.id), channel) .await .to_internal_error()? { @@ -45,31 +51,31 @@ pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> { Ok(()) } -pub async fn set_channel_node(channel: &str, node: &str) -> Result<()> { +pub async fn set_channel_node(channel_id: &str, node: &str) -> Result<()> { get_connection() .await? - .set(format!("node:{channel}"), node) + .set(format!("node:{channel_id}"), node) .await .to_internal_error() } -pub async fn get_channel_node(channel: &str) -> Result> { +pub async fn get_channel_node(channel_id: &str) -> Result> { get_connection() .await? - .get(format!("node:{channel}")) + .get(format!("node:{channel_id}")) .await .to_internal_error() } -pub async fn delete_channel_node(channel: &str) -> Result<()> { +pub async fn delete_channel_node(channel_id: &str) -> Result<()> { get_connection() .await? - .del(format!("node:{channel}")) + .del(format!("node:{channel_id}")) .await .to_internal_error() } -pub async fn get_user_voice_channels(user_id: &str) -> Result> { +pub async fn get_user_voice_channels(user_id: &str) -> Result> { get_connection() .await? .smembers(format!("vc:{user_id}")) @@ -78,14 +84,14 @@ pub async fn get_user_voice_channels(user_id: &str) -> Result> { } pub async fn set_user_moved_from_voice( - old_channel: &str, - new_channel: &str, + old_channel_id: &str, + new_channel: &UserVoiceChannel, user_id: &str, ) -> Result<()> { get_connection() .await? .set_ex( - format!("moved_from:{user_id}:{old_channel}"), + format!("moved_from:{user_id}:{old_channel_id}"), new_channel, 10, ) @@ -102,18 +108,25 @@ pub async fn get_user_moved_from_voice(channel_id: &str, user_id: &str) -> Resul } pub async fn set_user_moved_to_voice( - new_channel: &str, - old_channel: &str, + new_channel_id: &str, + old_channel: &UserVoiceChannel, user_id: &str, ) -> Result<()> { get_connection() .await? - .set_ex(format!("moved_to:{user_id}:{new_channel}"), old_channel, 10) + .set_ex( + format!("moved_to:{user_id}:{new_channel_id}"), + old_channel, + 10, + ) .await .to_internal_error() } -pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result> { +pub async fn get_user_moved_to_voice( + channel_id: &str, + user_id: &str, +) -> Result> { get_connection() .await? .get_del(format!("moved_to:{user_id}:{channel_id}")) @@ -121,10 +134,10 @@ pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result< .to_internal_error() } -pub async fn is_in_voice_channel(user_id: &str, channel_id: &str) -> Result { +pub async fn is_in_voice_channel(user_id: &str, channel: &UserVoiceChannel) -> Result { get_connection() .await? - .sismember(format!("vc:{user_id}"), channel_id) + .sismember(format!("vc:{user_id}"), channel) .await .to_internal_error() } @@ -158,12 +171,15 @@ pub fn get_allowed_sources( } pub async fn create_voice_state( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_id: &str, joined_at: Timestamp, ) -> Result { - let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); + let unique_key = format!( + "{}:{}", + &user_id, + channel.server_id.as_ref().unwrap_or(&channel.id) + ); let voice_state = UserVoiceState { joined_at, @@ -175,9 +191,9 @@ pub async fn create_voice_state( }; Pipeline::new() - .sadd(format!("vc_members:{channel_id}"), user_id) - .sadd(format!("vc:{user_id}"), channel_id) - .set(&unique_key, channel_id) + .sadd(format!("vc_members:{}", &channel.id), user_id) + .sadd(format!("vc:{user_id}"), channel) + .set(&unique_key, &channel.id) .set( format!("joined_at:{unique_key}"), joined_at @@ -204,16 +220,16 @@ pub async fn create_voice_state( Ok(voice_state) } -pub async fn delete_voice_state( - channel_id: &str, - server_id: Option<&str>, - user_id: &str, -) -> Result<()> { - let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); +pub async fn delete_voice_state(channel: &UserVoiceChannel, user_id: &str) -> Result<()> { + let unique_key = format!( + "{}:{}", + &user_id, + channel.server_id.as_ref().unwrap_or(&channel.id) + ); Pipeline::new() - .srem(format!("vc_members:{channel_id}"), user_id) - .srem(format!("vc:{user_id}"), channel_id) + .srem(format!("vc_members:{}", &channel.id), user_id) + .srem(format!("vc:{user_id}"), channel) .del(&[ format!("joined_at:{unique_key}"), format!("is_publishing:{unique_key}"), @@ -228,20 +244,19 @@ pub async fn delete_voice_state( } pub async fn delete_channel_voice_state( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_ids: &[String], ) -> Result<()> { - let parent_id = server_id.unwrap_or(channel_id); + let parent_id = channel.server_id.as_ref().unwrap_or(&channel.id); let mut pipeline = Pipeline::new(); - pipeline.del(format!("vc_members:{channel_id}")); - pipeline.del(format!("node:{channel_id}")); + pipeline.del(format!("vc_members:{}", &channel.id)); + pipeline.del(format!("node:{}", &channel.id)); for user_id in user_ids { let unique_key = format!("{user_id}:{parent_id}"); - pipeline.srem(format!("vc:{user_id}"), channel_id).del(&[ + pipeline.srem(format!("vc:{user_id}"), channel).del(&[ format!("joined_at:{unique_key}"), format!("is_publishing:{unique_key}"), format!("is_receiving:{unique_key}"), @@ -258,8 +273,7 @@ pub async fn delete_channel_voice_state( } pub async fn update_voice_state_tracks( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_id: &str, added: bool, track: i32, @@ -284,18 +298,21 @@ pub async fn update_voice_state_tracks( _ => unreachable!(), }; - update_voice_state(channel_id, server_id, user_id, &partial).await?; + update_voice_state(channel, user_id, &partial).await?; Ok(partial) } pub async fn update_voice_state( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_id: &str, partial: &PartialUserVoiceState, ) -> Result<()> { - let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); + let unique_key = format!( + "{}:{}", + &user_id, + channel.server_id.as_ref().unwrap_or(&channel.id) + ); let mut pipeline = Pipeline::new(); @@ -321,21 +338,24 @@ pub async fn update_voice_state( .to_internal_error() } -pub async fn get_voice_channel_members(channel_id: &str) -> Result>> { +pub async fn get_voice_channel_members(channel: &UserVoiceChannel) -> Result>> { get_connection() .await? - .smembers::<_, Option>>(format!("vc_members:{channel_id}")) + .smembers::<_, Option>>(format!("vc_members:{}", &channel.id)) .await .to_internal_error() .map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) })) } pub async fn get_voice_state( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_id: &str, ) -> Result> { - let unique_key = format!("{}:{}", user_id, server_id.unwrap_or(channel_id)); + let unique_key = format!( + "{}:{}", + &user_id, + channel.server_id.as_ref().unwrap_or(&channel.id) + ); let (joined_at, is_publishing, is_receiving, screensharing, camera) = get_connection() .await? @@ -376,21 +396,21 @@ pub async fn get_voice_state( } } -pub async fn get_channel_voice_state(channel: &Channel) -> Result> { - let members = get_voice_channel_members(channel.id()).await?; - - let server = channel.server(); +pub async fn get_channel_voice_state( + channel: &UserVoiceChannel, +) -> Result> { + let members = get_voice_channel_members(channel).await?; if let Some(members) = members { let mut participants = Vec::with_capacity(members.len()); for user_id in members { - if let Some(voice_state) = get_voice_state(channel.id(), server, &user_id).await? { + if let Some(voice_state) = get_voice_state(channel, &user_id).await? { participants.push(voice_state); } else { log::info!("Voice state not found but member in voice channel members, removing."); - delete_voice_state(channel.id(), server, &user_id).await?; + delete_voice_state(channel, &user_id).await?; } } @@ -398,7 +418,7 @@ pub async fn get_channel_voice_state(channel: &Channel) -> Result Result Result<()> { +pub async fn move_user(user: &str, from_channel_id: &str, to_channel_id: &str) -> Result<()> { get_connection() .await? .smove( - format!("vc-members-{from}"), - format!("vc-members-{to}"), + format!("vc_members:{from_channel_id}"), + format!("vc_members:{to_channel_id}"), user, ) .await @@ -425,11 +445,13 @@ pub async fn sync_voice_permissions( server: Option<&Server>, role_id: Option<&str>, ) -> Result<()> { + let user_voice_channel = UserVoiceChannel::from_channel(channel); + let Some(node) = get_channel_node(channel.id()).await? else { return Ok(()); }; - for user_id in get_voice_channel_members(channel.id()) + for user_id in get_voice_channel_members(&user_voice_channel) .await? .iter() .flatten() @@ -469,7 +491,9 @@ pub async fn sync_user_voice_permissions( .as_ref() .is_none_or(|member| member.roles.iter().any(|r| r == role_id)) }) { - let Some(voice_state) = get_voice_state(channel_id, server_id, &user.id).await? else { + let user_voice_channel = UserVoiceChannel::from_channel(channel); + + let Some(voice_state) = get_voice_state(&user_voice_channel, &user.id).await? else { return Ok(()); }; @@ -500,7 +524,7 @@ pub async fn sync_user_voice_permissions( update_event.screensharing = voice_state.screensharing.then_some(can_video); update_event.is_publishing = voice_state.is_publishing.then_some(can_speak); - update_voice_state(channel_id, server_id, &user.id, &update_event).await?; + update_voice_state(&user_voice_channel, &user.id, &update_event).await?; voice_client .update_permissions( @@ -579,46 +603,94 @@ pub async fn get_call_notification_recipients( } pub async fn remove_user_from_voice_channels( - db: &Database, voice_client: &VoiceClient, user_id: &str, ) -> Result<()> { - for channel_id in get_user_voice_channels(user_id).await? { - remove_user_from_voice_channel(db, voice_client, &channel_id, user_id).await?; + for channel in get_user_voice_channels(user_id).await? { + remove_user_from_voice_channel(voice_client, &channel, user_id).await?; } Ok(()) } pub async fn remove_user_from_voice_channel( - db: &Database, voice_client: &VoiceClient, - channel_id: &str, + channel: &UserVoiceChannel, user_id: &str, ) -> Result<()> { - if let Some(node) = get_channel_node(channel_id).await? { - let _ = voice_client.remove_user(&node, user_id, channel_id).await; + if let Some(node) = get_channel_node(&channel.id).await? { + let _ = voice_client.remove_user(&node, user_id, &channel.id).await; } - let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; - - delete_voice_state(channel_id, channel.server(), user_id).await?; + delete_voice_state(channel, user_id).await?; Ok(()) } pub async fn delete_voice_channel( voice_client: &VoiceClient, - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, ) -> Result<()> { - if let Some(users) = get_voice_channel_members(channel_id).await? { - let node = get_channel_node(channel_id).await?.unwrap(); + if let Some(users) = get_voice_channel_members(channel).await? { + let node = get_channel_node(&channel.id).await?.unwrap(); + voice_client.delete_room(&node, &channel.id).await?; - voice_client.delete_room(&node, channel_id).await?; - - delete_channel_voice_state(channel_id, server_id, &users).await?; + delete_channel_voice_state(channel, &users).await?; }; Ok(()) } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoomMetadata { + pub server: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserVoiceChannel { + pub id: String, + pub server_id: Option, +} + +impl UserVoiceChannel { + pub fn from_string(input: String) -> Self { + let mut parts = input.splitn(2, '-'); + + Self { + id: parts.next().unwrap().to_string(), + server_id: parts.next().map(ToString::to_string), + } + } + + pub fn from_channel(channel: &Channel) -> Self { + Self { + id: channel.id().to_string(), + server_id: channel.server().map(ToString::to_string), + } + } +} + +impl Display for UserVoiceChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.id)?; + + if let Some(server_id) = &self.server_id { + f.write_char('-')?; + f.write_str(server_id)? + }; + + Ok(()) + } +} + +impl ToRedisArgs for UserVoiceChannel { + fn write_redis_args(&self, out: &mut W) { + out.write_arg_fmt(self); + } +} + +impl FromRedisValue for UserVoiceChannel { + fn from_redis_value(v: &Value) -> Result { + String::from_redis_value(v).map(UserVoiceChannel::from_string) + } +} diff --git a/crates/core/database/src/voice/voice_client.rs b/crates/core/database/src/voice/voice_client.rs index 967f3fef..94d1f22b 100644 --- a/crates/core/database/src/voice/voice_client.rs +++ b/crates/core/database/src/voice/voice_client.rs @@ -1,5 +1,6 @@ use crate::{ models::{Channel, User}, + voice::RoomMetadata, Database, }; use livekit_api::{ @@ -102,11 +103,16 @@ impl VoiceClient { pub async fn create_room(&self, node: &str, channel: &Channel) -> Result { let room = self.get_node(node)?; + let metadata = RoomMetadata { + server: channel.server().map(|id| id.to_string()), + }; + room.client .create_room( channel.id(), CreateRoomOptions { empty_timeout: 5 * 60, // 5 minutes, + metadata: serde_json::to_string(&metadata).to_internal_error()?, ..Default::default() }, ) diff --git a/crates/daemons/voice-ingress/src/api.rs b/crates/daemons/voice-ingress/src/api.rs index 56e5b2f7..dbe8934e 100644 --- a/crates/daemons/voice-ingress/src/api.rs +++ b/crates/daemons/voice-ingress/src/api.rs @@ -5,8 +5,9 @@ use revolt_database::{ iso8601_timestamp::{Duration, Timestamp}, util::reference::Reference, voice::{ - create_voice_state, delete_channel_node, delete_voice_state, get_user_moved_from_voice, - get_user_moved_to_voice, get_voice_channel_members, update_voice_state_tracks, VoiceClient, + create_voice_state, delete_channel_voice_state, delete_voice_state, + get_user_moved_from_voice, get_user_moved_to_voice, update_voice_state_tracks, + RoomMetadata, UserVoiceChannel, VoiceClient, }, Database, AMQP, }; @@ -50,27 +51,34 @@ pub async fn ingress( let channel_id = event.room.as_ref().map(|r| &r.name); let user_id = event.participant.as_ref().map(|r| &r.identity); + let room_metadata = if let Some(room) = event.room.as_ref() { + Some(serde_json::from_str::(&room.metadata).to_internal_error()?) + } else { + None + }; match event.event.as_str() { // User joined a channel "participant_joined" => { let channel_id = channel_id.to_internal_error()?; let user_id = user_id.to_internal_error()?; - - let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + let server_id = room_metadata.to_internal_error()?.server; + let channel = UserVoiceChannel { + id: channel_id.clone(), + server_id: server_id.clone(), + }; let joined_at = Timestamp::UNIX_EPOCH .checked_add(Duration::seconds(event.created_at)) .unwrap(); - let voice_state = - create_voice_state(channel_id, channel.server(), user_id, joined_at).await?; + let voice_state = create_voice_state(&channel, user_id, joined_at).await?; // Only publish one event when a user is moved from one channel to another. if let Some(moved_from) = get_user_moved_to_voice(channel_id, user_id).await? { EventV1::VoiceChannelMove { user: user_id.to_string(), - from: moved_from, + from: moved_from.id, to: channel_id.to_string(), state: voice_state, } @@ -135,10 +143,13 @@ pub async fn ingress( "participant_left" => { let channel_id = channel_id.to_internal_error()?; let user_id = user_id.to_internal_error()?; + let server_id = room_metadata.to_internal_error()?.server; + let channel = UserVoiceChannel { + id: channel_id.clone(), + server_id: server_id.clone(), + }; - let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; - - delete_voice_state(channel_id, channel.server(), user_id).await?; + delete_voice_state(&channel, user_id).await?; // Dont send leave event when a user is moved if get_user_moved_from_voice(channel_id, user_id) @@ -159,8 +170,6 @@ pub async fn ingress( // let members = get_voice_channel_members(channel_id).await?; // if members.is_none_or(|m| m.is_empty()) { - // delete_channel_node(channel_id).await?; - // // // The channel is empty so send out an "end" message for ringing // if let Err(e) = amqp // .dm_call_updated(user_id, channel_id, None, true, None) @@ -204,8 +213,11 @@ pub async fn ingress( let channel_id = channel_id.to_internal_error()?; let user_id = user_id.to_internal_error()?; let track = event.track.as_ref().to_internal_error()?; - - let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + let server_id = room_metadata.to_internal_error()?.server; + let channel = UserVoiceChannel { + id: channel_id.clone(), + server_id: server_id.clone(), + }; let user = Reference::from_unchecked(user_id).as_user(db).await?; @@ -245,15 +257,14 @@ pub async fn ingress( log::debug!("Removing user {user_id} from channel {channel_id} {event:?} due to forbidden track."); let _ = voice_client.remove_user(node, user_id, channel_id).await; - delete_voice_state(channel_id, channel.server(), user_id).await?; + delete_voice_state(&channel, user_id).await?; return Ok(EmptyResponse); }; }; let partial = update_voice_state_tracks( - channel_id, - channel.server(), + &channel, user_id, event.event == "track_published" || event.event == "track_unmuted", // to avoid duplicating this entire case twice track.source, @@ -268,6 +279,16 @@ pub async fn ingress( .p(channel_id.clone()) .await; } + "room_finished" => { + let channel_id = channel_id.to_internal_error()?; + let server_id = room_metadata.to_internal_error()?.server; + let channel = UserVoiceChannel { + id: channel_id.clone(), + server_id: server_id.clone(), + }; + + delete_channel_voice_state(&channel, &[]).await?; + } _ => {} }; diff --git a/crates/delta/src/routes/bots/delete.rs b/crates/delta/src/routes/bots/delete.rs index 73a00156..11132f27 100644 --- a/crates/delta/src/routes/bots/delete.rs +++ b/crates/delta/src/routes/bots/delete.rs @@ -1,4 +1,8 @@ -use revolt_database::{Database, User, util::reference::Reference, voice::{VoiceClient, remove_user_from_voice_channels}}; +use revolt_database::{ + util::reference::Reference, + voice::{remove_user_from_voice_channels, VoiceClient}, + Database, User, +}; use revolt_result::{create_error, Result}; use rocket::State; use rocket_empty::EmptyResponse; @@ -21,7 +25,7 @@ pub async fn delete_bot( bot.delete(db).await?; - remove_user_from_voice_channels(db, voice_client, &bot.id).await?; + remove_user_from_voice_channels(voice_client, &bot.id).await?; Ok(EmptyResponse) } diff --git a/crates/delta/src/routes/channels/channel_delete.rs b/crates/delta/src/routes/channels/channel_delete.rs index f6cc8b75..82ed4137 100644 --- a/crates/delta/src/routes/channels/channel_delete.rs +++ b/crates/delta/src/routes/channels/channel_delete.rs @@ -1,7 +1,10 @@ use revolt_database::{ - AMQP, Channel, Database, PartialChannel, User, util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{ - VoiceClient, delete_voice_channel, is_in_voice_channel, remove_user_from_voice_channel - } + util::{permissions::DatabasePermissionQuery, reference::Reference}, + voice::{ + delete_voice_channel, is_in_voice_channel, remove_user_from_voice_channel, + UserVoiceChannel, VoiceClient, + }, + Channel, Database, PartialChannel, User, AMQP, }; use revolt_models::v0; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -54,15 +57,17 @@ pub async fn delete( ) .await?; - if is_in_voice_channel(&user.id, channel.id()).await? { - remove_user_from_voice_channel(db, voice_client, channel.id(), &user.id).await?; + let user_voice_channel = UserVoiceChannel::from_channel(&channel); + + if is_in_voice_channel(&user.id, &user_voice_channel).await? { + remove_user_from_voice_channel(voice_client, &user_voice_channel, &user.id).await?; }; } Channel::TextChannel { .. } => { permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?; channel.delete(db).await?; - delete_voice_channel(voice_client, channel.id(), channel.server()).await?; + delete_voice_channel(voice_client, &UserVoiceChannel::from_channel(&channel)).await?; } }; diff --git a/crates/delta/src/routes/channels/channel_edit.rs b/crates/delta/src/routes/channels/channel_edit.rs index 64b3b06a..e60664e0 100644 --- a/crates/delta/src/routes/channels/channel_edit.rs +++ b/crates/delta/src/routes/channels/channel_edit.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - voice::{delete_voice_channel, VoiceClient}, + voice::{delete_voice_channel, UserVoiceChannel, VoiceClient}, Channel, Database, File, PartialChannel, SystemMessage, User, AMQP, }; use revolt_models::v0; @@ -260,7 +260,7 @@ pub async fn edit( .await?; if channel.voice().is_none() { - delete_voice_channel(voice_client, channel.id(), channel.server()).await?; + delete_voice_channel(voice_client, &UserVoiceChannel::from_channel(&channel)).await?; } Ok(Json(channel.into())) diff --git a/crates/delta/src/routes/channels/group_remove_member.rs b/crates/delta/src/routes/channels/group_remove_member.rs index 995991f9..083bb44e 100644 --- a/crates/delta/src/routes/channels/group_remove_member.rs +++ b/crates/delta/src/routes/channels/group_remove_member.rs @@ -1,7 +1,5 @@ use revolt_database::{ - util::reference::Reference, - voice::{is_in_voice_channel, remove_user_from_voice_channel, VoiceClient}, - Channel, Database, User, AMQP, + AMQP, Channel, Database, User, util::reference::Reference, voice::{UserVoiceChannel, VoiceClient, is_in_voice_channel, remove_user_from_voice_channel} }; use revolt_permissions::ChannelPermission; use revolt_result::{create_error, Result}; @@ -54,8 +52,10 @@ pub async fn remove_member( return Err(create_error!(InvalidOperation)); }; - if is_in_voice_channel(&member.id, channel.id()).await? { - remove_user_from_voice_channel(db, voice_client, channel.id(), &member.id).await?; + let user_voice_channel = UserVoiceChannel::from_channel(&channel); + + if is_in_voice_channel(member.id, &user_voice_channel).await? { + remove_user_from_voice_channel(voice_client, &user_voice_channel, member.id).await?; }; Ok(EmptyResponse) diff --git a/crates/delta/src/routes/channels/voice_join.rs b/crates/delta/src/routes/channels/voice_join.rs index fe585f6f..0d8aecdd 100644 --- a/crates/delta/src/routes/channels/voice_join.rs +++ b/crates/delta/src/routes/channels/voice_join.rs @@ -3,7 +3,8 @@ use revolt_database::{ util::{permissions::perms, reference::Reference}, voice::{ delete_voice_state, get_channel_node, get_user_voice_channels, get_voice_channel_members, - raise_if_in_voice, set_call_notification_recipients, set_channel_node, VoiceClient, + raise_if_in_voice, set_call_notification_recipients, set_channel_node, UserVoiceChannel, + VoiceClient, }, Database, User, }; @@ -50,7 +51,9 @@ pub async fn call( let current_permissions = calculate_channel_permissions(&mut permissions).await; current_permissions.throw_if_lacking_channel_permission(ChannelPermission::Connect)?; - if get_voice_channel_members(channel.id()) + let user_voice_channel = UserVoiceChannel::from_channel(&channel); + + if get_voice_channel_members(&user_voice_channel) .await? .zip(voice_info.max_users) .is_some_and(|(ms, max_users)| ms.len() >= max_users) @@ -79,22 +82,18 @@ pub async fn call( // Finds and disconnects any existing voice connections by the user, // should only ever loop once but just to cover our backs. - for channel_id in get_user_voice_channels(&user.id).await? { - if let Some(node) = get_channel_node(&channel_id).await? { + for previous_channel in get_user_voice_channels(&user.id).await? { + if let Some(node) = get_channel_node(&previous_channel.id).await? { // if this errors its just a mismatching state - ignore and proceed to still delete our state - let _ = voice_client.remove_user(&node, &user.id, &channel_id).await; + let _ = voice_client + .remove_user(&node, &user.id, &previous_channel.id) + .await; }; - let channel = Reference::from_unchecked(&channel_id) - .as_channel(db) - .await; - - if channel.is_ok() { - delete_voice_state(&channel_id, channel.unwrap().server(), &user.id).await?; - } + delete_voice_state(&previous_channel, &user.id).await?; } } else { - raise_if_in_voice(&user, channel.id()).await?; + raise_if_in_voice(&user, &user_voice_channel).await?; } let token = voice_client diff --git a/crates/delta/src/routes/channels/voice_stop_ring.rs b/crates/delta/src/routes/channels/voice_stop_ring.rs index f8db8fc4..768b861a 100644 --- a/crates/delta/src/routes/channels/voice_stop_ring.rs +++ b/crates/delta/src/routes/channels/voice_stop_ring.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::reference::Reference, - voice::{get_voice_state, VoiceClient}, + voice::{get_voice_state, UserVoiceChannel, VoiceClient}, Channel, Database, User, AMQP, }; use revolt_result::{create_error, Result, ToRevoltError}; @@ -32,9 +32,15 @@ pub async fn stop_ring( return Err(create_error!(NoEffect)); } - if get_voice_state(channel.id(), None, &user.id) - .await? - .is_none() + if get_voice_state( + &UserVoiceChannel { + id: channel.id().to_string(), + server_id: None, + }, + &user.id, + ) + .await? + .is_none() { return Err(create_error!(NotConnected)); } @@ -46,7 +52,7 @@ pub async fn stop_ring( _ => return Err(create_error!(NoEffect)), }; - if members.iter().any(|m| &target_user.id == m) { + if members.iter().any(|m| target_user.id == m) { if let Err(e) = amqp .dm_call_updated( &user.id, diff --git a/crates/delta/src/routes/servers/ban_create.rs b/crates/delta/src/routes/servers/ban_create.rs index 17589dad..4ff10329 100644 --- a/crates/delta/src/routes/servers/ban_create.rs +++ b/crates/delta/src/routes/servers/ban_create.rs @@ -1,6 +1,9 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - voice::{get_user_voice_channel_in_server, remove_user_from_voice_channel, VoiceClient}, + voice::{ + get_user_voice_channel_in_server, remove_user_from_voice_channel, UserVoiceChannel, + VoiceClient, + }, Database, RemovalIntention, ServerBan, User, }; use revolt_models::v0; @@ -58,8 +61,16 @@ pub async fn ban( .await?; // If the member is in a voice channel while banned kick them from the voice channel - if let Some(channel_id) = get_user_voice_channel_in_server(&target.id, &server.id).await? { - remove_user_from_voice_channel(db, voice_client, &channel_id, &target.id).await?; + if let Some(channel_id) = get_user_voice_channel_in_server(target.id, &server.id).await? { + remove_user_from_voice_channel( + voice_client, + &UserVoiceChannel { + id: channel_id, + server_id: Some(server.id.clone()), + }, + target.id, + ) + .await?; } } diff --git a/crates/delta/src/routes/servers/member_edit.rs b/crates/delta/src/routes/servers/member_edit.rs index ecc1d919..9c6a1d58 100644 --- a/crates/delta/src/routes/servers/member_edit.rs +++ b/crates/delta/src/routes/servers/member_edit.rs @@ -9,7 +9,7 @@ use revolt_database::{ voice::{ get_channel_node, get_user_voice_channel_in_server, set_channel_node, set_user_moved_from_voice, set_user_moved_to_voice, sync_user_voice_permissions, - VoiceClient, + UserVoiceChannel, VoiceClient, }, Database, File, PartialMember, User, }; @@ -52,7 +52,9 @@ pub async fn edit( let permissions = calculate_server_permissions(&mut query).await; // Fetch target permissions - let mut target_query = DatabasePermissionQuery::new(db, &target_user).server(&server).member(&member); + let mut target_query = DatabasePermissionQuery::new(db, &target_user) + .server(&server) + .member(&member); let target_permissions = calculate_server_permissions(&mut target_query).await; // Check permissions in server @@ -83,7 +85,7 @@ pub async fn edit( } if target_permissions.has_channel_permission(ChannelPermission::TimeoutMembers) { - return Err(create_error!(IsElevated)) + return Err(create_error!(IsElevated)); } } @@ -99,7 +101,7 @@ pub async fn edit( } if data.voice_channel.is_some() && data.remove.contains(&FieldsMember::VoiceChannel) { - return Err(create_error!(InvalidOperation)) + return Err(create_error!(InvalidOperation)); } if data.voice_channel.is_some() || data.remove.contains(&FieldsMember::VoiceChannel) { @@ -217,8 +219,19 @@ pub async fn edit( } }; - set_user_moved_from_voice(&channel, new_voice_channel.id(), &target_user.id).await?; - set_user_moved_to_voice(new_voice_channel.id(), &channel, &target_user.id).await?; + let new_user_voice_channel = UserVoiceChannel::from_channel(&new_voice_channel); + let old_user_voice_channel = UserVoiceChannel { + id: channel.clone(), + server_id: new_user_voice_channel.server_id.clone(), + }; + + set_user_moved_from_voice(&channel, &new_user_voice_channel, &target_user.id).await?; + set_user_moved_to_voice( + new_voice_channel.id(), + &old_user_voice_channel, + &target_user.id, + ) + .await?; let mut query = perms(db, &target_user).channel(&new_voice_channel); let permissions = calculate_channel_permissions(&mut query).await; diff --git a/crates/delta/src/routes/servers/member_remove.rs b/crates/delta/src/routes/servers/member_remove.rs index 510da42e..de5b0254 100644 --- a/crates/delta/src/routes/servers/member_remove.rs +++ b/crates/delta/src/routes/servers/member_remove.rs @@ -1,6 +1,9 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - voice::{get_user_voice_channel_in_server, remove_user_from_voice_channel, VoiceClient}, + voice::{ + get_user_voice_channel_in_server, remove_user_from_voice_channel, UserVoiceChannel, + VoiceClient, + }, Database, RemovalIntention, User, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -46,8 +49,16 @@ pub async fn kick( .remove(db, &server, RemovalIntention::Kick, false) .await?; - if let Some(channel_id) = get_user_voice_channel_in_server(&target.id, &server.id).await? { - remove_user_from_voice_channel(db, voice_client, &channel_id, &target.id).await?; + if let Some(channel_id) = get_user_voice_channel_in_server(target.id, &server.id).await? { + remove_user_from_voice_channel( + voice_client, + &UserVoiceChannel { + id: channel_id, + server_id: Some(server.id.clone()), + }, + target.id, + ) + .await?; }; Ok(EmptyResponse) diff --git a/crates/delta/src/routes/servers/server_delete.rs b/crates/delta/src/routes/servers/server_delete.rs index 475765ca..b99f172b 100644 --- a/crates/delta/src/routes/servers/server_delete.rs +++ b/crates/delta/src/routes/servers/server_delete.rs @@ -2,7 +2,7 @@ use revolt_database::{ util::reference::Reference, voice::{ delete_voice_channel, get_user_voice_channel_in_server, remove_user_from_voice_channel, - VoiceClient, + UserVoiceChannel, VoiceClient, }, Database, RemovalIntention, User, }; @@ -29,13 +29,28 @@ pub async fn delete( if server.owner == user.id { for channel_id in &server.channels { - delete_voice_channel(voice_client, channel_id, Some(&server.id)).await?; + delete_voice_channel( + voice_client, + &UserVoiceChannel { + id: channel_id.clone(), + server_id: Some(server.id.clone()), + }, + ) + .await?; } server.delete(db).await } else { if let Some(channel_id) = get_user_voice_channel_in_server(&user.id, &server.id).await? { - remove_user_from_voice_channel(db, voice_client, &channel_id, &user.id).await?; + remove_user_from_voice_channel( + voice_client, + &UserVoiceChannel { + id: channel_id, + server_id: Some(server.id.clone()), + }, + &user.id, + ) + .await?; }; member From 52c0d2f266b76d8975bba2d5e75c62bb30149c45 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 11 Mar 2026 05:15:00 +0700 Subject: [PATCH 09/14] fix: send push notifications for DM and group messages (#660) DMs and group messages never triggered push notifications because the condition only checked for explicit @mentions. DMs don't use mentions, so users were never notified of new direct messages. Signed-off-by: sanasol --- crates/core/database/src/models/messages/model.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/core/database/src/models/messages/model.rs b/crates/core/database/src/models/messages/model.rs index 11bc2eac..2db57b12 100644 --- a/crates/core/database/src/models/messages/model.rs +++ b/crates/core/database/src/models/messages/model.rs @@ -687,8 +687,13 @@ impl Message { ) .await?; + let is_dm_or_group = matches!( + channel, + Channel::DirectMessage { .. } | Channel::Group { .. } + ); + if !self.has_suppressed_notifications() - && (self.mentions.is_some() || self.contains_mass_push_mention()) + && (is_dm_or_group || self.mentions.is_some() || self.contains_mass_push_mention()) { // send Push notifications #[cfg(feature = "tasks")] From d56135e0cbc713884c9378832952f7ad490fa315 Mon Sep 17 00:00:00 2001 From: Christopher Hultin Date: Tue, 10 Mar 2026 16:38:41 -0600 Subject: [PATCH 10/14] fix: Check for appropriate permission for removing other users avatar (#657) * Check for appropriate permission for removing other users avatar Signed-off-by: Chris Hultin * limit check for non-self to just removal Signed-off-by: Chris Hultin * else if change Signed-off-by: Chris Hultin --------- Signed-off-by: Chris Hultin --- crates/delta/src/routes/servers/member_edit.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/delta/src/routes/servers/member_edit.rs b/crates/delta/src/routes/servers/member_edit.rs index 9c6a1d58..87b45524 100644 --- a/crates/delta/src/routes/servers/member_edit.rs +++ b/crates/delta/src/routes/servers/member_edit.rs @@ -15,9 +15,7 @@ use revolt_database::{ }; use revolt_models::v0::{self, FieldsMember}; -use revolt_permissions::{ - calculate_channel_permissions, calculate_server_permissions, ChannelPermission, -}; +use revolt_permissions::{calculate_channel_permissions, calculate_server_permissions, ChannelPermission, UserPermission}; use revolt_result::{create_error, Result}; use rocket::{form::validate::Contains, serde::json::Json, State}; use validator::Validate; @@ -69,8 +67,10 @@ pub async fn edit( if data.avatar.is_some() || data.remove.contains(&v0::FieldsMember::Avatar) { if user.id == member.id.user { permissions.throw_if_lacking_channel_permission(ChannelPermission::ChangeAvatar)?; + } else if data.remove.contains(&v0::FieldsMember::Avatar) { + permissions.throw_if_lacking_channel_permission(ChannelPermission::RemoveAvatars)?; } else { - return Err(create_error!(InvalidOperation)); + return Err(create_error!(InvalidOperation)) } } From ab525699bd6663333f0e9fed6d2455e482e6a09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0spik?= Date: Sat, 14 Mar 2026 10:41:31 +0300 Subject: [PATCH 11/14] fix: add masquerade permission to default direct message settings (#665) Signed-off-by: arsabutispik --- crates/core/permissions/src/models/channel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/permissions/src/models/channel.rs b/crates/core/permissions/src/models/channel.rs index 9e916026..450fda64 100644 --- a/crates/core/permissions/src/models/channel.rs +++ b/crates/core/permissions/src/models/channel.rs @@ -141,7 +141,7 @@ pub static DEFAULT_PERMISSION: Lazy = Lazy::new(|| { pub static DEFAULT_PERMISSION_SAVED_MESSAGES: u64 = ChannelPermission::GrantAllSafe as u64; pub static DEFAULT_PERMISSION_DIRECT_MESSAGE: Lazy = Lazy::new(|| { - DEFAULT_PERMISSION.add(ChannelPermission::ManageChannel + ChannelPermission::React) + DEFAULT_PERMISSION.add(ChannelPermission::ManageChannel + ChannelPermission::React + ChannelPermission::Masquerade) }); pub static DEFAULT_PERMISSION_SERVER: Lazy = Lazy::new(|| { From f2fc1ee00c229c2cddd61d62a7a9cfd9b414be32 Mon Sep 17 00:00:00 2001 From: Damocles <106018783+Damocles078@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:53:51 +0100 Subject: [PATCH 12/14] docs: documentation generation (#575) Signed-off-by: Damocles078 --- docs/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index b28211a9..a31d8713 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,13 +5,13 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati ## Installation ```bash -yarn +pnpm install ``` ## Local Development ```bash -yarn start +pnpm start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. @@ -19,7 +19,7 @@ This command starts a local development server and opens up a browser window. Mo ## Build ```bash -yarn build +pnpm build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. @@ -29,13 +29,13 @@ This command generates static content into the `build` directory and can be serv Using SSH: ```bash -USE_SSH=true yarn deploy +USE_SSH=true pnpm run deploy ``` Not using SSH: ```bash -GIT_USER= yarn deploy +GIT_USER= pnpm run deploy ``` If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. From dc4438bc3c7b2cad8d442b3cd438afb9ed566a5e Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 15 Mar 2026 09:04:37 -0700 Subject: [PATCH 13/14] fix: uname is missing from crond (#675) Signed-off-by: IAmTomahawkx --- crates/core/config/src/lib.rs | 4 +++- crates/daemons/crond/Dockerfile | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 7e26fb20..391b97a3 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -454,7 +454,9 @@ pub async fn config() -> Settings { } // auto-detect production nodes - if config.hosts.api.contains("https") && config.hosts.api.contains("revolt.chat") { + if config.hosts.api.contains("https") + && (config.hosts.api.contains("revolt.chat") | config.hosts.api.contains("stoat.chat")) + { config.production = true; } diff --git a/crates/daemons/crond/Dockerfile b/crates/daemons/crond/Dockerfile index c1bdf80f..f1852457 100644 --- a/crates/daemons/crond/Dockerfile +++ b/crates/daemons/crond/Dockerfile @@ -5,6 +5,7 @@ FROM debian:12 AS debian # Bundle Stage FROM gcr.io/distroless/cc-debian12:nonroot COPY --from=builder /home/rust/src/target/release/revolt-crond ./ +COPY --from=debian /usr/bin/uname /usr/bin/uname USER nonroot CMD ["./revolt-crond"] \ No newline at end of file From 5701b5c18c513f796af365169ceaea372a22638c Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 15 Mar 2026 09:15:54 -0700 Subject: [PATCH 14/14] fix: don't use a bitop for OR (#676) * fix: uname is missing from crond Signed-off-by: IAmTomahawkx * fix: don't use a bitop lol, two of us missed that. --------- Signed-off-by: IAmTomahawkx --- crates/core/config/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 391b97a3..5edf24c2 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -455,7 +455,7 @@ pub async fn config() -> Settings { // auto-detect production nodes if config.hosts.api.contains("https") - && (config.hosts.api.contains("revolt.chat") | config.hosts.api.contains("stoat.chat")) + && (config.hosts.api.contains("revolt.chat") || config.hosts.api.contains("stoat.chat")) { config.production = true; }