This commit is contained in:
2026-08-17 01:12:07 +02:00
parent 07f8d5fe73
commit 429d82e64c
21 changed files with 733 additions and 6 deletions
+253
View File
@@ -0,0 +1,253 @@
use crate::domain::dto::reaction::ReactionGroupResponse;
use crate::domain::events::message::{MessageReactionAddedEvent, MessageReactionRemovedEvent};
use crate::http::error::HTTPError;
use crate::models::{emoji, message_reaction};
use crate::services::ServicesContext;
use event_bus::Scope;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct MessageReactionService {
context: Arc<ServicesContext>,
}
impl MessageReactionService {
pub fn new(context: Arc<ServicesContext>) -> Self {
Self { context }
}
fn normalize_skin_tone(skin_tone: Option<u8>) -> Result<i32, HTTPError> {
match skin_tone {
None => Ok(0),
Some(tone @ 1..=5) => Ok(tone as i32),
Some(_) => Err(HTTPError::BadRequest(
"skin_tone must be between 1 and 5".into(),
)),
}
}
async fn validate(
&self,
message_id: Uuid,
emoji_id: Uuid,
skin_tone: Option<u8>,
) -> Result<
(
crate::models::message::Model,
emoji::Model,
i32,
Option<Uuid>,
),
HTTPError,
> {
let message = self
.context
.repositories
.message
.get_by_id(message_id)
.await?
.ok_or(HTTPError::NotFound)?;
let channel = self
.context
.repositories
.channel
.get_by_id(message.channel_id)
.await?
.ok_or(HTTPError::NotFound)?;
let emoji = self
.context
.repositories
.emoji
.get_by_id(emoji_id)
.await?
.ok_or_else(|| HTTPError::BadRequest("Emoji not found".into()))?;
if emoji.server_id.is_some() && emoji.server_id != channel.server_id {
return Err(HTTPError::BadRequest(
"Emoji is not available in this message scope".into(),
));
}
let normalized_tone = Self::normalize_skin_tone(skin_tone)?;
if normalized_tone != 0 && (emoji.emoji_type != "unicode" || !emoji.supports_skin_tone) {
return Err(HTTPError::BadRequest(
"This emoji does not support skin tones".into(),
));
}
Ok((message, emoji, normalized_tone, channel.server_id))
}
pub async fn add(
&self,
message_id: Uuid,
user_id: Uuid,
emoji_id: Uuid,
skin_tone: Option<u8>,
) -> Result<(message_reaction::Model, bool), HTTPError> {
let (message, _, skin_tone, server_id) =
self.validate(message_id, emoji_id, skin_tone).await?;
if let Some(existing) = self
.context
.repositories
.message_reaction
.find(message_id, user_id, emoji_id, skin_tone)
.await?
{
return Ok((existing, false));
}
let reaction = self
.context
.repositories
.message_reaction
.create(message_reaction::ActiveModel {
id: Set(Uuid::now_v7()),
message_id: Set(message_id),
user_id: Set(user_id),
emoji_id: Set(emoji_id),
skin_tone: Set(skin_tone),
..Default::default()
})
.await?;
let mut scopes = vec![Scope::uuid("channel", message.channel_id)];
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
self.context.event_bus.emit_scoped(
"message_reaction_added",
scopes,
MessageReactionAddedEvent {
server_id,
channel_id: message.channel_id,
reaction: reaction.clone(),
},
);
Ok((reaction, true))
}
pub async fn remove(
&self,
message_id: Uuid,
user_id: Uuid,
emoji_id: Uuid,
skin_tone: Option<u8>,
) -> Result<message_reaction::Model, HTTPError> {
let (message, _, skin_tone, server_id) =
self.validate(message_id, emoji_id, skin_tone).await?;
let reaction = self
.context
.repositories
.message_reaction
.delete(message_id, user_id, emoji_id, skin_tone)
.await?
.ok_or(HTTPError::NotFound)?;
let mut scopes = vec![Scope::uuid("channel", message.channel_id)];
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
self.context.event_bus.emit_scoped(
"message_reaction_removed",
scopes,
MessageReactionRemovedEvent {
server_id,
channel_id: message.channel_id,
reaction: reaction.clone(),
},
);
Ok(reaction)
}
pub async fn grouped_for_messages(
&self,
message_ids: &[Uuid],
) -> Result<HashMap<Uuid, Vec<ReactionGroupResponse>>, HTTPError> {
let reactions = self
.context
.repositories
.message_reaction
.list_by_message_ids(message_ids)
.await?;
if reactions.is_empty() {
return Ok(HashMap::new());
}
let emoji_ids: Vec<_> = reactions.iter().map(|reaction| reaction.emoji_id).collect();
let emojis: HashMap<_, _> = emoji::Entity::find()
.filter(emoji::Column::Id.is_in(emoji_ids))
.all(&self.context.repositories.emoji.context.db)
.await?
.into_iter()
.map(|emoji| (emoji.id, emoji))
.collect();
let mut grouped: HashMap<Uuid, HashMap<(Uuid, i32), ReactionGroupResponse>> =
HashMap::new();
for reaction in reactions {
let Some(emoji) = emojis.get(&reaction.emoji_id) else {
continue;
};
let groups = grouped.entry(reaction.message_id).or_default();
let group = groups
.entry((reaction.emoji_id, reaction.skin_tone))
.or_insert_with(|| ReactionGroupResponse {
emoji_id: emoji.id,
name: emoji.name.clone(),
unicode_sequence: emoji.unicode_sequence.clone(),
asset_url: emoji
.file_path
.as_ref()
.map(|_| format!("/api/emojis/{}/asset", emoji.id)),
skin_tone: (reaction.skin_tone != 0).then_some(reaction.skin_tone as u8),
count: 0,
user_ids: Vec::new(),
});
group.count += 1;
group.user_ids.push(reaction.user_id);
}
Ok(grouped
.into_iter()
.map(|(message_id, groups)| {
let mut groups: Vec<_> = groups.into_values().collect();
for group in &mut groups {
group.user_ids.sort_unstable();
}
groups.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then(left.skin_tone.cmp(&right.skin_tone))
});
(message_id, groups)
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::MessageReactionService;
#[test]
fn skin_tone_validation_accepts_only_the_supported_range() {
assert_eq!(
MessageReactionService::normalize_skin_tone(None).unwrap(),
0
);
for tone in 1..=5 {
assert_eq!(
MessageReactionService::normalize_skin_tone(Some(tone)).unwrap(),
tone as i32
);
}
assert!(MessageReactionService::normalize_skin_tone(Some(0)).is_err());
assert!(MessageReactionService::normalize_skin_tone(Some(6)).is_err());
}
}
+5
View File
@@ -3,6 +3,7 @@ use crate::services::category::CategoryService;
use crate::services::channel::ChannelService;
use crate::services::emoji::EmojiService;
use crate::services::message::MessageService;
use crate::services::message_reaction::MessageReactionService;
use crate::services::permission::PermissionService;
use crate::services::permission_sync::PermissionSyncService;
use crate::services::role::RoleService;
@@ -16,6 +17,7 @@ pub mod category;
pub mod channel;
pub mod emoji;
pub mod message;
pub mod message_reaction;
mod permission;
pub mod permission_sync;
pub mod realtime_registry;
@@ -40,6 +42,7 @@ pub struct Services {
pub server: Arc<ServerService>,
pub category: Arc<CategoryService>,
pub message: Arc<MessageService>,
pub message_reaction: Arc<MessageReactionService>,
pub user: Arc<UserService>,
pub role: Arc<RoleService>,
pub permission: Arc<PermissionService>,
@@ -60,6 +63,7 @@ impl Services {
let server = Arc::new(ServerService::new(service_context.clone()));
let category = Arc::new(CategoryService::new(service_context.clone()));
let message = Arc::new(MessageService::new(service_context.clone()));
let message_reaction = Arc::new(MessageReactionService::new(service_context.clone()));
let user = Arc::new(UserService::new(service_context.clone()));
let role = Arc::new(RoleService::new(service_context.clone()));
let permission = Arc::new(PermissionService::new(service_context.clone()));
@@ -73,6 +77,7 @@ impl Services {
server,
category,
message,
message_reaction,
user,
role,
permission,