Merge remote-tracking branch 'origin' into feat/elasticsearch

Signed-off-by: Zomatree <me@zomatree.live>
This commit is contained in:
Zomatree
2026-03-19 23:40:09 +00:00
41 changed files with 655 additions and 217 deletions

147
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -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.

View File

@@ -2,14 +2,14 @@
<h1> <h1>
Stoat Backend Stoat Backend
[![Stars](https://img.shields.io/github/stars/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/stargazers) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/network/members) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/pulls) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/issues) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/graphs/contributors) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/blob/main/LICENSE) [![License](https://img.shields.io/github/license/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/blob/main/LICENSE)
</h1> </h1>
The services and libraries that power the Revolt service.<br/> The services and libraries that power the Stoat service.<br/>
<br/> <br/>
| Crate | Path | Description | | | Crate | Path | Description | |
@@ -39,7 +39,7 @@ Rust 1.86.0 or higher.
## Development Guide ## 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: 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: Now you can clone and build the project:
```bash ```bash
git clone https://github.com/revoltchat/backend revolt-backend git clone https://github.com/stoatchat/stoatchat stoat-backend
cd revolt-backend cd stoat-backend
mise build mise build
``` ```
@@ -158,16 +158,11 @@ You can start a web client by doing the following:
corepack enable corepack enable
# clone the web client and run it: # clone the web client and run it:
git clone --recursive https://github.com/revoltchat/revite git clone --recursive https://github.com/stoatchat/for-web stoat-web
cd revite cd stoat-web
yarn # refer to stoat-web/README.md for startup, creating an account and loging in
yarn build:deps
echo "VITE_API_URL=http://local.revolt.chat:14702" > .env.local
yarn dev --port 14701
``` ```
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. When signing up, go to http://localhost:14080 to find confirmation/password reset emails.
## Deployment Guide ## Deployment Guide
@@ -198,7 +193,7 @@ Tag and push a new release by running:
just release 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 ## Testing
@@ -217,6 +212,6 @@ TEST_DB=MONGODB cargo nextest run
## License ## 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!** **Individual crates may supply their own licenses!**

View File

@@ -15,7 +15,7 @@ host = "127.0.0.1"
[hosts] [hosts]
# Web locations of various services # Web locations of various services
# Defaults assume all services are reverse-proxied # 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! # Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat:14701" app = "http://local.revolt.chat:14701"

View File

@@ -4,7 +4,7 @@ use futures::future::join_all;
use revolt_database::{ use revolt_database::{
events::client::{EventV1, ReadyPayloadFields}, events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery, util::permissions::DatabasePermissionQuery,
voice::get_channel_voice_state, voice::{get_channel_voice_state, UserVoiceChannel},
Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus, Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus,
}; };
use revolt_models::v0; use revolt_models::v0;
@@ -167,7 +167,9 @@ impl State {
| Channel::TextChannel { voice: Some(_), .. } | 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() { if let Some(server) = channel.server() {
let set = voice_state_server_members let set = voice_state_server_members
.entry(server.to_string()) .entry(server.to_string())

View File

@@ -11,7 +11,7 @@ redis = "redis://redis/"
[hosts] [hosts]
# Web locations of various services # Web locations of various services
# Defaults assume all services are reverse-proxied # 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! # Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat" app = "http://local.revolt.chat"
@@ -34,7 +34,7 @@ password = "rabbitpass"
[api.registration] [api.registration]
# Whether an invite should be required for 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 invite_only = false
[api.smtp] [api.smtp]

View File

@@ -463,12 +463,14 @@ pub async fn config() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap(); let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library // 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()); std::env::set_var("REDIS_URI", config.database.redis.clone());
} }
// auto-detect production nodes // 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; config.production = true;
} }

View File

@@ -687,8 +687,13 @@ impl Message {
) )
.await?; .await?;
let is_dm_or_group = matches!(
channel,
Channel::DirectMessage { .. } | Channel::Group { .. }
);
if !self.has_suppressed_notifications() 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 // send Push notifications
#[cfg(feature = "tasks")] #[cfg(feature = "tasks")]

View File

@@ -3,8 +3,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use crate::{ use crate::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File,
Database, File, Server, SystemMessage, User, Server, SystemMessage, User,
}; };
fn default_true() -> bool { fn default_true() -> bool {
@@ -45,7 +45,6 @@ auto_derived_partial!(
/// Whether the member is server-wide voice deafened /// Whether the member is server-wide voice deafened
#[serde(skip_serializing_if = "is_true", default = "default_true")] #[serde(skip_serializing_if = "is_true", default = "default_true")]
pub can_receive: bool, pub can_receive: bool,
// This value only exists in the database, not the models. // 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. // If it is not-None, the database layer should return None to member fetching queries.
// pub pending_deletion_at: Option<Timestamp> // pub pending_deletion_at: Option<Timestamp>
@@ -153,7 +152,11 @@ impl Member {
#[cfg(feature = "voice")] #[cfg(feature = "voice")]
for channel in &channels { 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) voice_states.push(voice_state)
} }
} }
@@ -175,7 +178,7 @@ impl Member {
.map(|channel| channel.into()) .map(|channel| channel.into())
.collect(), .collect(),
emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(), emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(),
voice_states voice_states,
} }
.private(user.id.clone()) .private(user.id.clone())
.await; .await;
@@ -225,14 +228,14 @@ impl Member {
pub fn remove_field(&mut self, field: &FieldsMember) { pub fn remove_field(&mut self, field: &FieldsMember) {
match field { match field {
FieldsMember::JoinedAt => {}, FieldsMember::JoinedAt => {}
FieldsMember::Avatar => self.avatar = None, FieldsMember::Avatar => self.avatar = None,
FieldsMember::Nickname => self.nickname = None, FieldsMember::Nickname => self.nickname = None,
FieldsMember::Roles => self.roles.clear(), FieldsMember::Roles => self.roles.clear(),
FieldsMember::Timeout => self.timeout = None, FieldsMember::Timeout => self.timeout = None,
FieldsMember::CanReceive => self.can_receive = true, FieldsMember::CanReceive => self.can_receive = true,
FieldsMember::CanPublish => self.can_publish = true, FieldsMember::CanPublish => self.can_publish = true,
FieldsMember::VoiceChannel => {}, FieldsMember::VoiceChannel => {}
} }
} }

View File

@@ -1,3 +1,5 @@
use std::fmt::{Display, Write};
use crate::{ use crate::{
events::client::EventV1, events::client::EventV1,
models::{Channel, User}, models::{Channel, User},
@@ -6,7 +8,11 @@ use crate::{
}; };
use iso8601_timestamp::{Duration, Timestamp}; use iso8601_timestamp::{Duration, Timestamp};
use livekit_protocol::ParticipantPermission; 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_config::FeaturesLimits;
use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState}; use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
@@ -16,16 +22,18 @@ mod voice_client;
pub use voice_client::VoiceClient; pub use voice_client::VoiceClient;
async fn get_connection() -> Result<Conn> { async fn get_connection() -> Result<Conn> {
_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<()> { pub async fn raise_if_in_voice(user: &User, channel: &UserVoiceChannel) -> Result<()> {
let mut conn = get_connection().await?; let mut conn = get_connection().await?;
if user.bot.is_some() { 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 // 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 if conn
.sismember(format!("vc:{}", &user.id), channel_id) .sismember(format!("vc:{}", &user.id), channel)
.await .await
.to_internal_error()? .to_internal_error()?
{ {
@@ -43,23 +51,31 @@ pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> {
Ok(()) 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() get_connection()
.await? .await?
.set(format!("node:{channel}"), node) .set(format!("node:{channel_id}"), node)
.await .await
.to_internal_error() .to_internal_error()
} }
pub async fn get_channel_node(channel: &str) -> Result<Option<String>> { pub async fn get_channel_node(channel_id: &str) -> Result<Option<String>> {
get_connection() get_connection()
.await? .await?
.get(format!("node:{channel}")) .get(format!("node:{channel_id}"))
.await .await
.to_internal_error() .to_internal_error()
} }
pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<String>> { pub async fn delete_channel_node(channel_id: &str) -> Result<()> {
get_connection()
.await?
.del(format!("node:{channel_id}"))
.await
.to_internal_error()
}
pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<UserVoiceChannel>> {
get_connection() get_connection()
.await? .await?
.smembers(format!("vc:{user_id}")) .smembers(format!("vc:{user_id}"))
@@ -68,14 +84,14 @@ pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<String>> {
} }
pub async fn set_user_moved_from_voice( pub async fn set_user_moved_from_voice(
old_channel: &str, old_channel_id: &str,
new_channel: &str, new_channel: &UserVoiceChannel,
user_id: &str, user_id: &str,
) -> Result<()> { ) -> Result<()> {
get_connection() get_connection()
.await? .await?
.set_ex( .set_ex(
format!("moved_from:{user_id}:{old_channel}"), format!("moved_from:{user_id}:{old_channel_id}"),
new_channel, new_channel,
10, 10,
) )
@@ -92,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( pub async fn set_user_moved_to_voice(
new_channel: &str, new_channel_id: &str,
old_channel: &str, old_channel: &UserVoiceChannel,
user_id: &str, user_id: &str,
) -> Result<()> { ) -> Result<()> {
get_connection() get_connection()
.await? .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 .await
.to_internal_error() .to_internal_error()
} }
pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result<Option<String>> { pub async fn get_user_moved_to_voice(
channel_id: &str,
user_id: &str,
) -> Result<Option<UserVoiceChannel>> {
get_connection() get_connection()
.await? .await?
.get_del(format!("moved_to:{user_id}:{channel_id}")) .get_del(format!("moved_to:{user_id}:{channel_id}"))
@@ -111,10 +134,10 @@ pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result<
.to_internal_error() .to_internal_error()
} }
pub async fn is_in_voice_channel(user_id: &str, channel_id: &str) -> Result<bool> { pub async fn is_in_voice_channel(user_id: &str, channel: &UserVoiceChannel) -> Result<bool> {
get_connection() get_connection()
.await? .await?
.sismember(format!("vc:{user_id}"), channel_id) .sismember(format!("vc:{user_id}"), channel)
.await .await
.to_internal_error() .to_internal_error()
} }
@@ -148,12 +171,15 @@ pub fn get_allowed_sources(
} }
pub async fn create_voice_state( pub async fn create_voice_state(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_id: &str, user_id: &str,
joined_at: Timestamp, joined_at: Timestamp,
) -> Result<UserVoiceState> { ) -> Result<UserVoiceState> {
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 { let voice_state = UserVoiceState {
joined_at, joined_at,
@@ -165,9 +191,9 @@ pub async fn create_voice_state(
}; };
Pipeline::new() Pipeline::new()
.sadd(format!("vc_members:{channel_id}"), user_id) .sadd(format!("vc_members:{}", &channel.id), user_id)
.sadd(format!("vc:{user_id}"), channel_id) .sadd(format!("vc:{user_id}"), channel)
.set(&unique_key, channel_id) .set(&unique_key, &channel.id)
.set( .set(
format!("joined_at:{unique_key}"), format!("joined_at:{unique_key}"),
joined_at joined_at
@@ -194,16 +220,16 @@ pub async fn create_voice_state(
Ok(voice_state) Ok(voice_state)
} }
pub async fn delete_voice_state( pub async fn delete_voice_state(channel: &UserVoiceChannel, user_id: &str) -> Result<()> {
channel_id: &str, let unique_key = format!(
server_id: Option<&str>, "{}:{}",
user_id: &str, &user_id,
) -> Result<()> { channel.server_id.as_ref().unwrap_or(&channel.id)
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); );
Pipeline::new() Pipeline::new()
.srem(format!("vc_members:{channel_id}"), user_id) .srem(format!("vc_members:{}", &channel.id), user_id)
.srem(format!("vc:{user_id}"), channel_id) .srem(format!("vc:{user_id}"), channel)
.del(&[ .del(&[
format!("joined_at:{unique_key}"), format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"), format!("is_publishing:{unique_key}"),
@@ -218,19 +244,19 @@ pub async fn delete_voice_state(
} }
pub async fn delete_channel_voice_state( pub async fn delete_channel_voice_state(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_ids: &[String], user_ids: &[String],
) -> Result<()> { ) -> 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(); let mut pipeline = Pipeline::new();
pipeline.del(format!("vc_members:{channel_id}")); pipeline.del(format!("vc_members:{}", &channel.id));
pipeline.del(format!("node:{}", &channel.id));
for user_id in user_ids { for user_id in user_ids {
let unique_key = format!("{user_id}:{parent_id}"); 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!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"), format!("is_publishing:{unique_key}"),
format!("is_receiving:{unique_key}"), format!("is_receiving:{unique_key}"),
@@ -247,8 +273,7 @@ pub async fn delete_channel_voice_state(
} }
pub async fn update_voice_state_tracks( pub async fn update_voice_state_tracks(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_id: &str, user_id: &str,
added: bool, added: bool,
track: i32, track: i32,
@@ -273,18 +298,21 @@ pub async fn update_voice_state_tracks(
_ => unreachable!(), _ => unreachable!(),
}; };
update_voice_state(channel_id, server_id, user_id, &partial).await?; update_voice_state(channel, user_id, &partial).await?;
Ok(partial) Ok(partial)
} }
pub async fn update_voice_state( pub async fn update_voice_state(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_id: &str, user_id: &str,
partial: &PartialUserVoiceState, partial: &PartialUserVoiceState,
) -> Result<()> { ) -> 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(); let mut pipeline = Pipeline::new();
@@ -310,21 +338,24 @@ pub async fn update_voice_state(
.to_internal_error() .to_internal_error()
} }
pub async fn get_voice_channel_members(channel_id: &str) -> Result<Option<Vec<String>>> { pub async fn get_voice_channel_members(channel: &UserVoiceChannel) -> Result<Option<Vec<String>>> {
get_connection() get_connection()
.await? .await?
.smembers::<_, Option<Vec<String>>>(format!("vc_members:{channel_id}")) .smembers::<_, Option<Vec<String>>>(format!("vc_members:{}", &channel.id))
.await .await
.to_internal_error() .to_internal_error()
.map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) })) .map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) }))
} }
pub async fn get_voice_state( pub async fn get_voice_state(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_id: &str, user_id: &str,
) -> Result<Option<UserVoiceState>> { ) -> Result<Option<UserVoiceState>> {
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() let (joined_at, is_publishing, is_receiving, screensharing, camera) = get_connection()
.await? .await?
@@ -365,21 +396,21 @@ pub async fn get_voice_state(
} }
} }
pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::ChannelVoiceState>> { pub async fn get_channel_voice_state(
let members = get_voice_channel_members(channel.id()).await?; channel: &UserVoiceChannel,
) -> Result<Option<v0::ChannelVoiceState>> {
let server = channel.server(); let members = get_voice_channel_members(channel).await?;
if let Some(members) = members { if let Some(members) = members {
let mut participants = Vec::with_capacity(members.len()); let mut participants = Vec::with_capacity(members.len());
for user_id in members { 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); participants.push(voice_state);
} else { } else {
log::info!("Voice state not found but member in voice channel members, removing."); 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?;
} }
} }
@@ -387,7 +418,7 @@ pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::Cha
participants.shrink_to_fit(); participants.shrink_to_fit();
Ok(Some(v0::ChannelVoiceState { Ok(Some(v0::ChannelVoiceState {
id: channel.id().to_string(), id: channel.id.clone(),
participants, participants,
})) }))
} else { } else {
@@ -395,12 +426,12 @@ pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::Cha
} }
} }
pub async fn move_user(user: &str, from: &str, to: &str) -> Result<()> { pub async fn move_user(user: &str, from_channel_id: &str, to_channel_id: &str) -> Result<()> {
get_connection() get_connection()
.await? .await?
.smove( .smove(
format!("vc-members-{from}"), format!("vc_members:{from_channel_id}"),
format!("vc-members-{to}"), format!("vc_members:{to_channel_id}"),
user, user,
) )
.await .await
@@ -414,11 +445,13 @@ pub async fn sync_voice_permissions(
server: Option<&Server>, server: Option<&Server>,
role_id: Option<&str>, role_id: Option<&str>,
) -> Result<()> { ) -> Result<()> {
let user_voice_channel = UserVoiceChannel::from_channel(channel);
let Some(node) = get_channel_node(channel.id()).await? else { let Some(node) = get_channel_node(channel.id()).await? else {
return Ok(()); return Ok(());
}; };
for user_id in get_voice_channel_members(channel.id()) for user_id in get_voice_channel_members(&user_voice_channel)
.await? .await?
.iter() .iter()
.flatten() .flatten()
@@ -458,7 +491,9 @@ pub async fn sync_user_voice_permissions(
.as_ref() .as_ref()
.is_none_or(|member| member.roles.iter().any(|r| r == role_id)) .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(()); return Ok(());
}; };
@@ -489,7 +524,7 @@ pub async fn sync_user_voice_permissions(
update_event.screensharing = voice_state.screensharing.then_some(can_video); update_event.screensharing = voice_state.screensharing.then_some(can_video);
update_event.is_publishing = voice_state.is_publishing.then_some(can_speak); 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 voice_client
.update_permissions( .update_permissions(
@@ -567,34 +602,95 @@ pub async fn get_call_notification_recipients(
.to_internal_error() .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(
for channel_id in get_user_voice_channels(user_id).await? { voice_client: &VoiceClient,
remove_user_from_voice_channel(db, voice_client, &channel_id, user_id).await?; user_id: &str,
}; ) -> Result<()> {
for channel in get_user_voice_channels(user_id).await? {
Ok(()) remove_user_from_voice_channel(voice_client, &channel, user_id).await?;
}
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;
} }
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; Ok(())
}
delete_voice_state(channel_id, channel.server(), user_id).await?; pub async fn remove_user_from_voice_channel(
voice_client: &VoiceClient,
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;
}
delete_voice_state(channel, user_id).await?;
Ok(()) Ok(())
} }
pub async fn delete_voice_channel(voice_client: &VoiceClient, channel_id: &str, server_id: Option<&str>) -> Result<()> { pub async fn delete_voice_channel(
if let Some(users) = get_voice_channel_members(channel_id).await? { voice_client: &VoiceClient,
let node = get_channel_node(channel_id).await?.unwrap(); channel: &UserVoiceChannel,
) -> Result<()> {
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, &users).await?;
delete_channel_voice_state(channel_id, server_id, &users).await?;
}; };
Ok(()) Ok(())
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomMetadata {
pub server: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserVoiceChannel {
pub id: String,
pub server_id: Option<String>,
}
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<W: ?Sized + RedisWrite>(&self, out: &mut W) {
out.write_arg_fmt(self);
}
}
impl FromRedisValue for UserVoiceChannel {
fn from_redis_value(v: &Value) -> Result<Self, RedisError> {
String::from_redis_value(v).map(UserVoiceChannel::from_string)
}
}

View File

@@ -1,5 +1,6 @@
use crate::{ use crate::{
models::{Channel, User}, models::{Channel, User},
voice::RoomMetadata,
Database, Database,
}; };
use livekit_api::{ use livekit_api::{
@@ -102,11 +103,16 @@ impl VoiceClient {
pub async fn create_room(&self, node: &str, channel: &Channel) -> Result<Room> { pub async fn create_room(&self, node: &str, channel: &Channel) -> Result<Room> {
let room = self.get_node(node)?; let room = self.get_node(node)?;
let metadata = RoomMetadata {
server: channel.server().map(|id| id.to_string()),
};
room.client room.client
.create_room( .create_room(
channel.id(), channel.id(),
CreateRoomOptions { CreateRoomOptions {
empty_timeout: 5 * 60, // 5 minutes, empty_timeout: 5 * 60, // 5 minutes,
metadata: serde_json::to_string(&metadata).to_internal_error()?,
..Default::default() ..Default::default()
}, },
) )

View File

@@ -6,4 +6,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd. This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here: Learn more about third party instances here:
https://developers.stoat.chat/faq.html https://developers.stoat.chat/faq/

View File

@@ -6,4 +6,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd. This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here: Learn more about third party instances here:
https://developers.revolt.chat/faq.html https://developers.stoat.chat/faq/

View File

@@ -107,7 +107,7 @@
<p class="" style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left"> <p class="" style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left">
Further violations may result in a permanent ban depending on Further violations may result in a permanent ban depending on
severity, please abide by the severity, please abide by the
<a href="https://revolt.chat/aup" style="color: #0d6efd;">Acceptable Usage Policy</a>. <a href="https://stoat.chat/legal/community-guidelines" style="color: #0d6efd;">Community Guidelines</a>.
</p> </p>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%"> <table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody> <tbody>

View File

@@ -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. 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. Ban evasion is prohibited and will be dealt with accordingly.

View File

@@ -3,6 +3,6 @@ Your account has been suspended, for one or more reasons:
This email is intended for {{email}} 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: Learn more about third party instances here:
https://developers.revolt.chat/faq.html https://developers.stoat.chat/faq/

View File

@@ -7,4 +7,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd. This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here: Learn more about third party instances here:
https://developers.revolt.chat/faq.html https://developers.stoat.chat/faq/

View File

@@ -141,7 +141,7 @@ pub static DEFAULT_PERMISSION: Lazy<u64> = Lazy::new(|| {
pub static DEFAULT_PERMISSION_SAVED_MESSAGES: u64 = ChannelPermission::GrantAllSafe as u64; pub static DEFAULT_PERMISSION_SAVED_MESSAGES: u64 = ChannelPermission::GrantAllSafe as u64;
pub static DEFAULT_PERMISSION_DIRECT_MESSAGE: Lazy<u64> = Lazy::new(|| { pub static DEFAULT_PERMISSION_DIRECT_MESSAGE: Lazy<u64> = Lazy::new(|| {
DEFAULT_PERMISSION.add(ChannelPermission::ManageChannel + ChannelPermission::React) DEFAULT_PERMISSION.add(ChannelPermission::ManageChannel + ChannelPermission::React + ChannelPermission::Masquerade)
}); });
pub static DEFAULT_PERMISSION_SERVER: Lazy<u64> = Lazy::new(|| { pub static DEFAULT_PERMISSION_SERVER: Lazy<u64> = Lazy::new(|| {

View File

@@ -5,6 +5,7 @@ FROM debian:12 AS debian
# Bundle Stage # Bundle Stage
FROM gcr.io/distroless/cc-debian12:nonroot FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=builder /home/rust/src/target/release/revolt-crond ./ COPY --from=builder /home/rust/src/target/release/revolt-crond ./
COPY --from=debian /usr/bin/uname /usr/bin/uname
USER nonroot USER nonroot
CMD ["./revolt-crond"] CMD ["./revolt-crond"]

View File

@@ -128,7 +128,7 @@ impl DmCallConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -101,7 +101,7 @@ impl FRAcceptedConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -101,7 +101,7 @@ impl FRReceivedConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -107,7 +107,7 @@ impl GenericConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -104,7 +104,7 @@ impl MassMessageConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -107,7 +107,7 @@ impl MessageConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());

View File

@@ -5,9 +5,9 @@ use revolt_database::{
iso8601_timestamp::{Duration, Timestamp}, iso8601_timestamp::{Duration, Timestamp},
util::reference::Reference, util::reference::Reference,
voice::{ voice::{
create_voice_state, delete_voice_state, create_voice_state, delete_channel_voice_state, delete_voice_state,
get_user_moved_from_voice, get_user_moved_to_voice, get_user_moved_from_voice, get_user_moved_to_voice, update_voice_state_tracks,
update_voice_state_tracks, VoiceClient, RoomMetadata, UserVoiceChannel, VoiceClient,
}, },
Database, AMQP, Database, AMQP,
}; };
@@ -51,27 +51,34 @@ pub async fn ingress(
let channel_id = event.room.as_ref().map(|r| &r.name); let channel_id = event.room.as_ref().map(|r| &r.name);
let user_id = event.participant.as_ref().map(|r| &r.identity); 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::<RoomMetadata>(&room.metadata).to_internal_error()?)
} else {
None
};
match event.event.as_str() { match event.event.as_str() {
// User joined a channel // User joined a channel
"participant_joined" => { "participant_joined" => {
let channel_id = channel_id.to_internal_error()?; let channel_id = channel_id.to_internal_error()?;
let user_id = user_id.to_internal_error()?; let user_id = user_id.to_internal_error()?;
let server_id = room_metadata.to_internal_error()?.server;
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; let channel = UserVoiceChannel {
id: channel_id.clone(),
server_id: server_id.clone(),
};
let joined_at = Timestamp::UNIX_EPOCH let joined_at = Timestamp::UNIX_EPOCH
.checked_add(Duration::seconds(event.created_at)) .checked_add(Duration::seconds(event.created_at))
.unwrap(); .unwrap();
let voice_state = let voice_state = create_voice_state(&channel, user_id, joined_at).await?;
create_voice_state(channel_id, channel.server(), user_id, joined_at).await?;
// Only publish one event when a user is moved from one channel to another. // 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? { if let Some(moved_from) = get_user_moved_to_voice(channel_id, user_id).await? {
EventV1::VoiceChannelMove { EventV1::VoiceChannelMove {
user: user_id.to_string(), user: user_id.to_string(),
from: moved_from, from: moved_from.id,
to: channel_id.to_string(), to: channel_id.to_string(),
state: voice_state, state: voice_state,
} }
@@ -136,10 +143,13 @@ pub async fn ingress(
"participant_left" => { "participant_left" => {
let channel_id = channel_id.to_internal_error()?; let channel_id = channel_id.to_internal_error()?;
let user_id = user_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, user_id).await?;
delete_voice_state(channel_id, channel.server(), user_id).await?;
// Dont send leave event when a user is moved // Dont send leave event when a user is moved
if get_user_moved_from_voice(channel_id, user_id) if get_user_moved_from_voice(channel_id, user_id)
@@ -203,8 +213,11 @@ pub async fn ingress(
let channel_id = channel_id.to_internal_error()?; let channel_id = channel_id.to_internal_error()?;
let user_id = user_id.to_internal_error()?; let user_id = user_id.to_internal_error()?;
let track = event.track.as_ref().to_internal_error()?; let track = event.track.as_ref().to_internal_error()?;
let server_id = room_metadata.to_internal_error()?.server;
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; let channel = UserVoiceChannel {
id: channel_id.clone(),
server_id: server_id.clone(),
};
let user = Reference::from_unchecked(user_id).as_user(db).await?; let user = Reference::from_unchecked(user_id).as_user(db).await?;
@@ -244,15 +257,14 @@ pub async fn ingress(
log::debug!("Removing user {user_id} from channel {channel_id} {event:?} due to forbidden track."); 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; 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); return Ok(EmptyResponse);
}; };
}; };
let partial = update_voice_state_tracks( let partial = update_voice_state_tracks(
channel_id, &channel,
channel.server(),
user_id, user_id,
event.event == "track_published" || event.event == "track_unmuted", // to avoid duplicating this entire case twice event.event == "track_published" || event.event == "track_unmuted", // to avoid duplicating this entire case twice
track.source, track.source,
@@ -267,6 +279,16 @@ pub async fn ingress(
.p(channel_id.clone()) .p(channel_id.clone())
.await; .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?;
}
_ => {} _ => {}
}; };

View File

@@ -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 revolt_result::{create_error, Result};
use rocket::State; use rocket::State;
use rocket_empty::EmptyResponse; use rocket_empty::EmptyResponse;
@@ -21,7 +25,7 @@ pub async fn delete_bot(
bot.delete(db).await?; 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) Ok(EmptyResponse)
} }

View File

@@ -1,7 +1,10 @@
use revolt_database::{ use revolt_database::{
AMQP, Channel, Database, PartialChannel, User, util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{ util::{permissions::DatabasePermissionQuery, reference::Reference},
VoiceClient, delete_voice_channel, is_in_voice_channel, remove_user_from_voice_channel 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_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -54,15 +57,17 @@ pub async fn delete(
) )
.await?; .await?;
if is_in_voice_channel(&user.id, channel.id()).await? { let user_voice_channel = UserVoiceChannel::from_channel(&channel);
remove_user_from_voice_channel(db, voice_client, channel.id(), &user.id).await?;
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 { .. } => { Channel::TextChannel { .. } => {
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?; permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?;
channel.delete(db, Some(amqp)).await?; channel.delete(db, Some(amqp)).await?;
delete_voice_channel(voice_client, channel.id(), channel.server()).await?; delete_voice_channel(voice_client, &UserVoiceChannel::from_channel(&channel)).await?;
} }
}; };

View File

@@ -1,6 +1,6 @@
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{delete_voice_channel, VoiceClient}, voice::{delete_voice_channel, UserVoiceChannel, VoiceClient},
Channel, Database, File, PartialChannel, SystemMessage, User, AMQP, Channel, Database, File, PartialChannel, SystemMessage, User, AMQP,
}; };
use revolt_models::v0; use revolt_models::v0;
@@ -260,7 +260,7 @@ pub async fn edit(
.await?; .await?;
if channel.voice().is_none() { 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())) Ok(Json(channel.into()))

View File

@@ -1,7 +1,5 @@
use revolt_database::{ use revolt_database::{
util::reference::Reference, AMQP, Channel, Database, User, util::reference::Reference, voice::{UserVoiceChannel, VoiceClient, is_in_voice_channel, remove_user_from_voice_channel}
voice::{is_in_voice_channel, remove_user_from_voice_channel, VoiceClient},
Channel, Database, User, AMQP,
}; };
use revolt_permissions::ChannelPermission; use revolt_permissions::ChannelPermission;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
@@ -54,8 +52,10 @@ pub async fn remove_member(
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation));
}; };
if is_in_voice_channel(&member.id, channel.id()).await? { let user_voice_channel = UserVoiceChannel::from_channel(&channel);
remove_user_from_voice_channel(db, voice_client, channel.id(), &member.id).await?;
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) Ok(EmptyResponse)

View File

@@ -3,7 +3,8 @@ use revolt_database::{
util::{permissions::perms, reference::Reference}, util::{permissions::perms, reference::Reference},
voice::{ voice::{
delete_voice_state, get_channel_node, get_user_voice_channels, get_voice_channel_members, 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, UserVoiceChannel,
VoiceClient,
}, },
Database, User, Database, User,
}; };
@@ -50,7 +51,9 @@ pub async fn call(
let current_permissions = calculate_channel_permissions(&mut permissions).await; let current_permissions = calculate_channel_permissions(&mut permissions).await;
current_permissions.throw_if_lacking_channel_permission(ChannelPermission::Connect)?; 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? .await?
.zip(voice_info.max_users) .zip(voice_info.max_users)
.is_some_and(|(ms, max_users)| ms.len() >= max_users) .is_some_and(|(ms, max_users)| ms.len() >= max_users)
@@ -60,6 +63,7 @@ pub async fn call(
} }
let existing_node = get_channel_node(channel.id()).await?; 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 let node = existing_node
.or(node) .or(node)
@@ -78,20 +82,18 @@ pub async fn call(
// Finds and disconnects any existing voice connections by the user, // Finds and disconnects any existing voice connections by the user,
// should only ever loop once but just to cover our backs. // should only ever loop once but just to cover our backs.
for channel_id in get_user_voice_channels(&user.id).await? { for previous_channel in get_user_voice_channels(&user.id).await? {
if let Some(node) = get_channel_node(&channel_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 // 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) delete_voice_state(&previous_channel, &user.id).await?;
.as_channel(db)
.await?;
delete_voice_state(&channel_id, channel.server(), &user.id).await?;
} }
} else { } else {
raise_if_in_voice(&user, channel.id()).await?; raise_if_in_voice(&user, &user_voice_channel).await?;
} }
let token = voice_client let token = voice_client
@@ -100,6 +102,10 @@ pub async fn call(
let room = voice_client.create_room(&node, &channel).await?; 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); log::debug!("Created room {}", room.name);
if let Some(recipients) = recipients { if let Some(recipients) = recipients {

View File

@@ -1,6 +1,6 @@
use revolt_database::{ use revolt_database::{
util::reference::Reference, util::reference::Reference,
voice::{get_voice_state, VoiceClient}, voice::{get_voice_state, UserVoiceChannel, VoiceClient},
Channel, Database, User, AMQP, Channel, Database, User, AMQP,
}; };
use revolt_result::{create_error, Result, ToRevoltError}; use revolt_result::{create_error, Result, ToRevoltError};
@@ -32,7 +32,13 @@ pub async fn stop_ring(
return Err(create_error!(NoEffect)); return Err(create_error!(NoEffect));
} }
if get_voice_state(channel.id(), None, &user.id) if get_voice_state(
&UserVoiceChannel {
id: channel.id().to_string(),
server_id: None,
},
&user.id,
)
.await? .await?
.is_none() .is_none()
{ {
@@ -46,7 +52,7 @@ pub async fn stop_ring(
_ => return Err(create_error!(NoEffect)), _ => 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 if let Err(e) = amqp
.dm_call_updated( .dm_call_updated(
&user.id, &user.id,

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use revolt_config::config; use revolt_config::config;
use revolt_result::Result; use revolt_result::Result;
use rocket::serde::json::Json; use rocket::serde::json::Json;
@@ -54,6 +55,80 @@ pub struct RevoltFeatures {
pub january: Feature, pub january: Feature,
/// Voice server configuration /// Voice server configuration
pub livekit: VoiceFeature, 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<String, usize>,
}
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 /// # Build Information
@@ -134,6 +209,20 @@ pub async fn root() -> Result<Json<RevoltConfig>> {
}) })
.collect(), .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, ws: config.hosts.events,
app: config.hosts.app, app: config.hosts.app,

View File

@@ -1,6 +1,9 @@
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, 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, Database, RemovalIntention, ServerBan, User,
}; };
use revolt_models::v0; use revolt_models::v0;
@@ -58,8 +61,16 @@ pub async fn ban(
.await?; .await?;
// If the member is in a voice channel while banned kick them from the voice channel // 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? { 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?; remove_user_from_voice_channel(
voice_client,
&UserVoiceChannel {
id: channel_id,
server_id: Some(server.id.clone()),
},
target.id,
)
.await?;
} }
} }

View File

@@ -9,15 +9,13 @@ use revolt_database::{
voice::{ voice::{
get_channel_node, get_user_voice_channel_in_server, set_channel_node, 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, set_user_moved_from_voice, set_user_moved_to_voice, sync_user_voice_permissions,
VoiceClient, UserVoiceChannel, VoiceClient,
}, },
Database, File, PartialMember, User, Database, File, PartialMember, User,
}; };
use revolt_models::v0::{self, FieldsMember}; use revolt_models::v0::{self, FieldsMember};
use revolt_permissions::{ use revolt_permissions::{calculate_channel_permissions, calculate_server_permissions, ChannelPermission, UserPermission};
calculate_channel_permissions, calculate_server_permissions, ChannelPermission,
};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use rocket::{form::validate::Contains, serde::json::Json, State}; use rocket::{form::validate::Contains, serde::json::Json, State};
use validator::Validate; use validator::Validate;
@@ -52,7 +50,9 @@ pub async fn edit(
let permissions = calculate_server_permissions(&mut query).await; let permissions = calculate_server_permissions(&mut query).await;
// Fetch target permissions // 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; let target_permissions = calculate_server_permissions(&mut target_query).await;
// Check permissions in server // Check permissions in server
@@ -67,8 +67,10 @@ pub async fn edit(
if data.avatar.is_some() || data.remove.contains(&v0::FieldsMember::Avatar) { if data.avatar.is_some() || data.remove.contains(&v0::FieldsMember::Avatar) {
if user.id == member.id.user { if user.id == member.id.user {
permissions.throw_if_lacking_channel_permission(ChannelPermission::ChangeAvatar)?; 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 { } else {
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation))
} }
} }
@@ -83,7 +85,7 @@ pub async fn edit(
} }
if target_permissions.has_channel_permission(ChannelPermission::TimeoutMembers) { 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) { 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) { 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?; let new_user_voice_channel = UserVoiceChannel::from_channel(&new_voice_channel);
set_user_moved_to_voice(new_voice_channel.id(), &channel, &target_user.id).await?; 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 mut query = perms(db, &target_user).channel(&new_voice_channel);
let permissions = calculate_channel_permissions(&mut query).await; let permissions = calculate_channel_permissions(&mut query).await;

View File

@@ -1,6 +1,9 @@
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, 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, Database, RemovalIntention, User,
}; };
use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_permissions::{calculate_server_permissions, ChannelPermission};
@@ -46,8 +49,16 @@ pub async fn kick(
.remove(db, &server, RemovalIntention::Kick, false) .remove(db, &server, RemovalIntention::Kick, false)
.await?; .await?;
if let Some(channel_id) = get_user_voice_channel_in_server(&target.id, &server.id).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?; remove_user_from_voice_channel(
voice_client,
&UserVoiceChannel {
id: channel_id,
server_id: Some(server.id.clone()),
},
target.id,
)
.await?;
}; };
Ok(EmptyResponse) Ok(EmptyResponse)

View File

@@ -1,7 +1,10 @@
use revolt_database::{ use revolt_database::{
AMQP, Database, RemovalIntention, User, util::reference::Reference, voice::{ util::reference::Reference,
VoiceClient, delete_voice_channel, get_user_voice_channel_in_server, remove_user_from_voice_channel voice::{
} delete_voice_channel, get_user_voice_channel_in_server, remove_user_from_voice_channel,
UserVoiceChannel, VoiceClient,
},
Database, RemovalIntention, User, AMQP,
}; };
use revolt_models::v0; use revolt_models::v0;
use revolt_result::Result; use revolt_result::Result;
@@ -27,13 +30,28 @@ pub async fn delete(
if server.owner == user.id { if server.owner == user.id {
for channel_id in &server.channels { 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, Some(amqp)).await server.delete(db, Some(amqp)).await
} else { } else {
if let Some(channel_id) = get_user_voice_channel_in_server(&user.id, &server.id).await? { 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 member

View File

@@ -5,13 +5,13 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati
## Installation ## Installation
```bash ```bash
yarn pnpm install
``` ```
## Local Development ## Local Development
```bash ```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. 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 ## Build
```bash ```bash
yarn build pnpm build
``` ```
This command generates static content into the `build` directory and can be served using any static contents hosting service. 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: Using SSH:
```bash ```bash
USE_SSH=true yarn deploy USE_SSH=true pnpm run deploy
``` ```
Not using SSH: Not using SSH:
```bash ```bash
GIT_USER=<Your GitHub username> yarn deploy GIT_USER=<Your GitHub username> 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. 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.

View File

@@ -9,15 +9,11 @@ sidebar_position: 1
You can connect to the API on the following URLs: You can connect to the API on the following URLs:
| URL | Release | Description | | URL | Release | Description |
| ----------------------------- | :--------: | --------------------------- | | ----------------------------- | :--------: | ---------------------------------- |
| `https://api.revolt.chat` | Production | Primary API endpoint | | `https://api.stoat.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 |
You can connect to the events server on the following URLs: You can connect to the events server on the following URLs:
| URL | Release | Description | | URL | Release | Description |
| ------------------------------ | :--------: | ------------------------------ | | ------------------------------ | :--------: | ------------------------------------- |
| `wss://ws.revolt.chat` | Production | Primary events endpoint | | `wss://events.stoat.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 |

View File

@@ -10,7 +10,7 @@ The Plugin API is very powerful. **Tread carefully.**
**Zero guarantees or sandboxes are provided.** Your code is run as-is. **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. This is more or less a proof of concept but can be used to achieve some simple client modifications.

View File

@@ -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: 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) - 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: When your feature is ready to release, ensure to:
- Update backend documentation (what you're reading now!) if applicable - 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) - Update the Feature Matrix (or ask someone that can to do so)
- Ensure it is properly listed in the backend release changelog - Ensure it is properly listed in the backend release changelog