feat: user slowmode events (#760)

* feat: user slowmode events

Signed-off-by: ispik <ispik@ispik.dev>

* fix: remove debug print statement for slowmodes

Signed-off-by: ispik <ispik@ispik.dev>

* refactor: Send user slowmodes as websocket connects instead of trying to send it in ready payload

Signed-off-by: ispik <ispik@ispik.dev>

* refactor: optimize user slowmode handling with bulk operations

Signed-off-by: ispik <ispik@ispik.dev>

* chore: specify release version

Release-As: 0.13.6

---------

Signed-off-by: ispik <ispik@ispik.dev>
This commit is contained in:
İspik
2026-05-19 00:51:43 +03:00
committed by GitHub
parent acbc087982
commit af0d8aad14
5 changed files with 166 additions and 24 deletions

View File

@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use futures::future::join_all;
use redis_kiss::AsyncCommands;
use revolt_database::{
events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery,

View File

@@ -13,7 +13,7 @@ use futures::{
stream::{SplitSink, SplitStream},
FutureExt, SinkExt, StreamExt, TryStreamExt,
};
use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use redis_kiss::{get_connection, AsyncCommands, PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use revolt_config::report_internal_error;
use revolt_database::{
events::{client::EventV1, server::ClientMessage},
@@ -32,6 +32,7 @@ use sentry::Level;
use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
use crate::events::state::{State, SubscriptionStateChange};
use revolt_models::v0;
type WsReader = SplitStream<WebSocketStream<TcpStream>>;
type WsWriter = SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>;
@@ -128,6 +129,14 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
return;
}
let slowmodes = fetch_user_slowmodes(&user_id).await.unwrap_or_default();
if !slowmodes.is_empty() {
let event = EventV1::UserSlowmodes { slowmodes };
if report_internal_error!(write.send(config.encode(&event)).await).is_err() {
return;
}
}
// Create presence session.
let (first_session, session_id) = create_session(&user_id, 0).await;
@@ -527,4 +536,43 @@ async fn worker(
}
}
}
}
}
async fn fetch_user_slowmodes(user_id: &str) -> Option<Vec<v0::ChannelSlowmode>> {
let mut conn = get_connection().await.ok()?.into_inner();
let idx_key = format!("slowmode_idx:{}", user_id);
let channel_ids: Vec<String> = conn.smembers(&idx_key).await.unwrap_or_default();
if channel_ids.is_empty() {
return Some(vec![]);
}
// Bulk fetch all TTLs in one round trip
let mut pipe = redis_kiss::redis::pipe();
for channel_id in &channel_ids {
pipe.ttl(format!("slowmode:{}:{}", user_id, channel_id));
}
let ttls: Vec<i64> = pipe.query_async(&mut conn).await.unwrap_or_default();
// Partition into alive/expired in one pass
let mut slowmodes = vec![];
let mut expired = vec![];
for (channel_id, ttl) in channel_ids.iter().zip(ttls.iter()) {
if *ttl > 0 {
slowmodes.push(v0::ChannelSlowmode {
channel_id: channel_id.clone(),
duration: *ttl as u64,
retry_after: *ttl as u64,
});
} else {
expired.push(channel_id.as_str());
}
}
// Bulk remove all expired members in one SREM call
if !expired.is_empty() {
conn.srem::<_, _, ()>(&idx_key, expired).await.ok();
}
Some(slowmodes)
}

View File

@@ -3,7 +3,12 @@ use revolt_result::Error;
use serde::{Deserialize, Serialize};
use revolt_models::v0::{
AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
AppendMessage, Channel, ChannelSlowmode, ChannelUnread, ChannelVoiceState, Emoji,
FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser,
FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji,
PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState,
PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings,
UserVoiceState, Webhook,
};
use crate::Database;
@@ -51,9 +56,13 @@ impl Default for ReadyPayloadFields {
#[serde(tag = "type")]
pub enum EventV1 {
/// Multiple events
Bulk { v: Vec<EventV1> },
Bulk {
v: Vec<EventV1>,
},
/// Error event
Error { data: Error },
Error {
data: Error,
},
/// Successfully authenticated
Authenticated,
@@ -84,7 +93,9 @@ pub enum EventV1 {
},
/// Ping response
Pong { data: Ping },
Pong {
data: Ping,
},
/// New message
Message(Message),
@@ -105,7 +116,10 @@ pub enum EventV1 {
},
/// Delete message
MessageDelete { id: String, channel: String },
MessageDelete {
id: String,
channel: String,
},
/// New reaction to a message
MessageReact {
@@ -131,7 +145,10 @@ pub enum EventV1 {
},
/// Bulk delete messages
BulkMessageDelete { channel: String, ids: Vec<String> },
BulkMessageDelete {
channel: String,
ids: Vec<String>,
},
/// New server
ServerCreate {
@@ -139,7 +156,7 @@ pub enum EventV1 {
server: Server,
channels: Vec<Channel>,
emojis: Vec<Emoji>,
voice_states: Vec<ChannelVoiceState>
voice_states: Vec<ChannelVoiceState>,
},
/// Update existing server
@@ -151,7 +168,9 @@ pub enum EventV1 {
},
/// Delete server
ServerDelete { id: String },
ServerDelete {
id: String,
},
/// Update existing server member
ServerMemberUpdate {
@@ -187,10 +206,16 @@ pub enum EventV1 {
},
/// Server role deleted
ServerRoleDelete { id: String, role_id: String },
ServerRoleDelete {
id: String,
role_id: String,
},
/// Server roles ranks updated
ServerRoleRanksUpdate { id: String, ranks: Vec<String> },
ServerRoleRanksUpdate {
id: String,
ranks: Vec<String>,
},
/// Update existing user
UserUpdate {
@@ -202,9 +227,15 @@ pub enum EventV1 {
},
/// Relationship with another user changed
UserRelationship { id: String, user: User },
UserRelationship {
id: String,
user: User,
},
/// Settings updated remotely
UserSettingsUpdate { id: String, update: UserSettings },
UserSettingsUpdate {
id: String,
update: UserSettings,
},
/// User has been platform banned or deleted their account
///
@@ -215,7 +246,10 @@ pub enum EventV1 {
/// - Server Memberships
///
/// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear.
UserPlatformWipe { user_id: String, flags: i32 },
UserPlatformWipe {
user_id: String,
flags: i32,
},
/// New emoji
EmojiCreate(Emoji),
@@ -226,7 +260,9 @@ pub enum EventV1 {
},
/// Delete emoji
EmojiDelete { id: String },
EmojiDelete {
id: String,
},
/// New report
ReportCreate(Report),
@@ -242,19 +278,33 @@ pub enum EventV1 {
},
/// Delete channel
ChannelDelete { id: String },
ChannelDelete {
id: String,
},
/// User joins a group
ChannelGroupJoin { id: String, user: String },
ChannelGroupJoin {
id: String,
user: String,
},
/// User leaves a group
ChannelGroupLeave { id: String, user: String },
ChannelGroupLeave {
id: String,
user: String,
},
/// User started typing in a channel
ChannelStartTyping { id: String, user: String },
ChannelStartTyping {
id: String,
user: String,
},
/// User stopped typing in a channel
ChannelStopTyping { id: String, user: String },
ChannelStopTyping {
id: String,
user: String,
},
/// User acknowledged message in channel
ChannelAck {
@@ -274,7 +324,9 @@ pub enum EventV1 {
},
/// Delete webhook
WebhookDelete { id: String },
WebhookDelete {
id: String,
},
/// Auth events
Auth(AuthifierEvent),
@@ -292,7 +344,7 @@ pub enum EventV1 {
user: String,
from: String,
to: String,
state: UserVoiceState
state: UserVoiceState,
},
UserVoiceStateUpdate {
id: String,
@@ -304,7 +356,11 @@ pub enum EventV1 {
from: String,
to: String,
token: String,
}
},
/// User's active slowmodes
UserSlowmodes {
slowmodes: Vec<ChannelSlowmode>,
},
}
impl EventV1 {

View File

@@ -314,6 +314,12 @@ auto_derived!(
/// Only used when the user is the first one connected.
pub recipients: Option<Vec<String>>,
}
pub struct ChannelSlowmode {
pub channel_id: String,
pub duration: u64,
pub retry_after: u64,
}
);
impl Channel {

View File

@@ -1,12 +1,14 @@
use std::time::Duration;
use redis_kiss::{get_connection, redis, AsyncCommands};
use revolt_database::events::client::EventV1;
use revolt_database::util::permissions::DatabasePermissionQuery;
use revolt_database::{
util::idempotency::IdempotencyKey, util::reference::Reference, Database, User,
};
use revolt_database::{Channel, Interactions, Message, AMQP};
use revolt_models::v0;
use revolt_models::v0::ChannelSlowmode;
use revolt_permissions::PermissionQuery;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
@@ -84,6 +86,16 @@ pub async fn message_send(
.await
.unwrap_or(None);
if set_result.is_some() {
let idx_key = format!("slowmode_idx:{}", user.id);
conn.sadd::<_, _, ()>(&idx_key, channel_id.as_str())
.await
.ok();
conn.expire::<_, ()>(&idx_key, *channel_slowmode as usize)
.await
.ok();
}
// 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() {
@@ -92,10 +104,29 @@ pub async fn message_send(
// Redis returns positive integers for valid TTLs
if ttl > 0 {
EventV1::UserSlowmodes {
slowmodes: vec![ChannelSlowmode {
channel_id: channel_id.to_string(),
duration: *channel_slowmode,
retry_after: ttl as u64,
}],
}
.private(user.id.clone())
.await;
return Err(create_error!(InSlowmode {
retry_after: ttl as u64
}));
}
} else {
EventV1::UserSlowmodes {
slowmodes: vec![ChannelSlowmode {
channel_id: channel_id.to_string(),
duration: *channel_slowmode,
retry_after: *channel_slowmode,
}],
}
.private(user.id.clone())
.await;
}
}
// If Redis connection fails, just skip the slowmode check