diff --git a/crates/core/database/src/models/channels/model.rs b/crates/core/database/src/models/channels/model.rs index d8a485b9..025b7934 100644 --- a/crates/core/database/src/models/channels/model.rs +++ b/crates/core/database/src/models/channels/model.rs @@ -110,6 +110,10 @@ auto_derived!( /// Voice Information for when this channel is also a voice channel #[serde(skip_serializing_if = "Option::is_none")] voice: Option, + + /// The channel's slowmode delay in seconds + #[serde(skip_serializing_if = "Option::is_none")] + slowmode: Option, }, } @@ -146,6 +150,8 @@ auto_derived!( pub last_message_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub voice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slowmode: Option, } /// Optional fields on channel object @@ -206,6 +212,7 @@ impl Channel { role_permissions: HashMap::new(), nsfw: data.nsfw.unwrap_or(false), voice: data.voice.map(|voice| voice.into()), + slowmode: None }, v0::LegacyServerChannelType::Voice => Channel::TextChannel { id: id.clone(), @@ -218,6 +225,7 @@ impl Channel { role_permissions: HashMap::new(), nsfw: data.nsfw.unwrap_or(false), voice: Some(data.voice.unwrap_or_default().into()), + slowmode: None }, }; diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index 0b2a55ef..e5e888d6 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -190,6 +190,7 @@ impl From for Channel { role_permissions, nsfw, voice, + slowmode } => Channel::TextChannel { id, server, @@ -201,6 +202,7 @@ impl From for Channel { role_permissions, nsfw, voice: voice.map(|voice| voice.into()), + slowmode }, } } @@ -254,6 +256,7 @@ impl From for crate::Channel { role_permissions, nsfw, voice, + slowmode } => crate::Channel::TextChannel { id, server, @@ -265,6 +268,7 @@ impl From for crate::Channel { role_permissions, nsfw, voice: voice.map(|voice| voice.into()), + slowmode }, } } @@ -283,7 +287,8 @@ impl From for PartialChannel { role_permissions: value.role_permissions, default_permissions: value.default_permissions, last_message_id: value.last_message_id, - voice: value.voice.map(|voice| voice.into()) + voice: value.voice.map(|voice| voice.into()), + slowmode: value.slowmode, } } } @@ -301,7 +306,8 @@ impl From for crate::PartialChannel { role_permissions: value.role_permissions, default_permissions: value.default_permissions, last_message_id: value.last_message_id, - voice: value.voice.map(|voice| voice.into()) + voice: value.voice.map(|voice| voice.into()), + slowmode: value.slowmode } } } diff --git a/crates/core/models/src/v0/channels.rs b/crates/core/models/src/v0/channels.rs index d8b4e228..d9fe8e14 100644 --- a/crates/core/models/src/v0/channels.rs +++ b/crates/core/models/src/v0/channels.rs @@ -112,6 +112,10 @@ auto_derived!( /// Voice Information for when this channel is also a voice channel #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] voice: Option, + + /// The channel's slowmode delay in seconds + #[serde(skip_serializing_if = "Option::is_none")] + slowmode: Option, }, } @@ -150,6 +154,8 @@ auto_derived!( pub last_message_id: Option, #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub voice: Option, + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub slowmode: Option, } /// Optional fields on channel object @@ -189,6 +195,10 @@ auto_derived!( /// Voice Information for voice channels pub voice: Option, + /// The channel's slow mode delay in seconds, up to 6 hours + #[cfg_attr(feature = "validator", validate(range(min = 0, max = 21600)))] + pub slowmode: Option, + /// Fields to remove from channel #[cfg_attr(feature = "serde", serde(default))] pub remove: Vec, diff --git a/crates/core/permissions/src/models/channel.rs b/crates/core/permissions/src/models/channel.rs index 450fda64..fc6fe49a 100644 --- a/crates/core/permissions/src/models/channel.rs +++ b/crates/core/permissions/src/models/channel.rs @@ -75,6 +75,8 @@ pub enum ChannelPermission { Masquerade = 1 << 28, /// React to messages with emojis React = 1 << 29, + /// Bypass slowmode + BypassSlowmode = 1 << 39, // * Voice permissions /// Connect to a voice channel @@ -99,7 +101,7 @@ pub enum ChannelPermission { MentionRoles = 1 << 38, // * Misc. permissions - // % Bits 38 to 52: free area + // % Bits 39 to 52: free area // % Bits 53 to 64: do not use // * Grant all permissions diff --git a/crates/core/result/src/axum.rs b/crates/core/result/src/axum.rs index 1145009c..23b09f7b 100644 --- a/crates/core/result/src/axum.rs +++ b/crates/core/result/src/axum.rs @@ -36,6 +36,9 @@ impl IntoResponse for Error { ErrorType::NotInGroup => StatusCode::NOT_FOUND, ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST, ErrorType::NotPinned => StatusCode::BAD_REQUEST, + ErrorType::InSlowmode { + retry_after: _, + } => StatusCode::TOO_MANY_REQUESTS, ErrorType::CantCreateServers => StatusCode::FORBIDDEN, ErrorType::UnknownServer => StatusCode::NOT_FOUND, diff --git a/crates/core/result/src/lib.rs b/crates/core/result/src/lib.rs index 17093f96..e4907a6b 100644 --- a/crates/core/result/src/lib.rs +++ b/crates/core/result/src/lib.rs @@ -102,6 +102,9 @@ pub enum ErrorType { NotInGroup, AlreadyPinned, NotPinned, + InSlowmode { + retry_after: u64, + }, // ? Server related errors CantCreateServers, diff --git a/crates/core/result/src/rocket.rs b/crates/core/result/src/rocket.rs index 744d09cf..5d2904d0 100644 --- a/crates/core/result/src/rocket.rs +++ b/crates/core/result/src/rocket.rs @@ -42,6 +42,9 @@ impl<'r> Responder<'r, 'static> for Error { ErrorType::NotInGroup => Status::NotFound, ErrorType::AlreadyPinned => Status::BadRequest, ErrorType::NotPinned => Status::BadRequest, + ErrorType::InSlowmode { + retry_after: _, + } => Status::TooManyRequests, ErrorType::InvalidFlagValue => Status::BadRequest, ErrorType::CantCreateServers => Status::Forbidden, diff --git a/crates/delta/src/routes/channels/channel_edit.rs b/crates/delta/src/routes/channels/channel_edit.rs index e60664e0..25ead30b 100644 --- a/crates/delta/src/routes/channels/channel_edit.rs +++ b/crates/delta/src/routes/channels/channel_edit.rs @@ -41,6 +41,7 @@ pub async fn edit( && data.nsfw.is_none() && data.owner.is_none() && data.voice.is_none() + && data.slowmode.is_none() && data.remove.is_empty() { return Ok(Json(channel.into())); @@ -200,6 +201,7 @@ pub async fn edit( icon, nsfw, voice, + slowmode, .. } => { if data.remove.contains(&v0::FieldsChannel::Icon) { @@ -247,6 +249,11 @@ pub async fn edit( *voice = Some(new_voice.clone().into()); partial.voice = Some(new_voice.into()); } + + if let Some(new_slowmode) = data.slowmode { + *slowmode = Some(new_slowmode); + partial.slowmode = Some(new_slowmode); + } } _ => return Err(create_error!(InvalidOperation)), }; diff --git a/crates/delta/src/routes/channels/message_send.rs b/crates/delta/src/routes/channels/message_send.rs index d0479fa1..51dd5713 100644 --- a/crates/delta/src/routes/channels/message_send.rs +++ b/crates/delta/src/routes/channels/message_send.rs @@ -1,9 +1,10 @@ use chrono::{Duration, Utc}; +use redis_kiss::{get_connection, redis, AsyncCommands}; use revolt_database::util::permissions::DatabasePermissionQuery; use revolt_database::{ util::idempotency::IdempotencyKey, util::reference::Reference, Database, User, }; -use revolt_database::{Interactions, Message, AMQP}; +use revolt_database::{Channel, Interactions, Message, AMQP}; use revolt_models::v0; use revolt_permissions::PermissionQuery; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -57,6 +58,50 @@ pub async fn message_send( permissions.throw_if_lacking_channel_permission(ChannelPermission::UploadFiles)?; } + if !permissions.has_channel_permission(ChannelPermission::BypassSlowmode) { + if let Channel::TextChannel { + slowmode: Some(channel_slowmode), + id: channel_id, + .. + } = &channel + { + if *channel_slowmode > 0 { + if let Ok(conn) = get_connection().await { + let mut conn = conn.into_inner(); + + let slowmode_key = format!("slowmode:{}:{}", user.id, channel_id); + + // Atomic check-and-set: only set if absent and apply expiry in one command. + let set_result: Option = conn + .set_options( + &slowmode_key, + "1", // The value doesn't matter, only the key's existence + redis::SetOptions::default() + .conditional_set(redis::ExistenceCheck::NX) + .with_expiration(redis::SetExpiry::EX(*channel_slowmode as usize)), + ) + .await + .unwrap_or(None); + + // If `set_result` is None, the `NX` condition failed because the key already exists. + // This means the user is currently in slowmode. + if set_result.is_none() { + // Fetch the remaining TTL to accurately populate the retry_after field + let ttl: i64 = conn.ttl(&slowmode_key).await.unwrap_or(0); + + // Redis returns positive integers for valid TTLs + if ttl > 0 { + return Err(create_error!(InSlowmode { + retry_after: ttl as u64 + })); + } + } + } + // If Redis connection fails, just skip the slowmode check + } + } + } + // Ensure interactions information is correct if let Some(interactions) = &data.interactions { let interactions: Interactions = interactions.clone().into(); @@ -186,6 +231,7 @@ mod test { }), last_message_id: None, voice: None, + slowmode: None, }; locked_channel .update(&harness.db, partial, vec![])