use std::sync::Arc;
use std::time::Instant;
use serde_json::Value as JsonValue;
use tokio::task::JoinHandle;
use toolkit_macros::domain_model;
use tracing::{info, instrument, warn};
use uuid::Uuid;
use crate::domain::error::{ChatEngineError, Result};
use crate::domain::message::MessageRole;
use crate::domain::ports::MessageRepo;
use crate::domain::ports::ReactionRepo;
use crate::domain::ports::SessionRepo;
use crate::domain::ports::SessionTypeRepo;
use crate::domain::reaction::{MessageReaction, MessageReactionEvent, ReactionType};
use crate::domain::service::plugin_service::PluginService;
use crate::domain::service::session_service::Identity;
use crate::domain::session::Session;
pub const CAPABILITY_FEEDBACK: &str = "feedback";
#[domain_model]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetReactionResponse {
pub message_id: Uuid,
pub reaction_type: ReactionType,
pub applied: bool,
}
#[domain_model]
#[derive(Debug, Clone)]
pub struct ReactionsListing {
pub message_id: Uuid,
pub reactions: Vec<MessageReaction>,
}
#[domain_model]
#[derive(Clone)]
pub struct ReactionService {
sessions: Arc<dyn SessionRepo>,
session_types: Arc<dyn SessionTypeRepo>,
messages: Arc<dyn MessageRepo>,
reactions: Arc<dyn ReactionRepo>,
plugins: PluginService,
}
impl ReactionService {
#[must_use]
pub fn new(
sessions: Arc<dyn SessionRepo>,
session_types: Arc<dyn SessionTypeRepo>,
messages: Arc<dyn MessageRepo>,
reactions: Arc<dyn ReactionRepo>,
plugins: PluginService,
) -> Self {
Self {
sessions,
session_types,
messages,
reactions,
plugins,
}
}
#[instrument(skip(self), fields(
session_id = %session_id,
message_id = %message_id,
reaction = reaction_type.as_str(),
))]
pub async fn set_reaction(
&self,
identity: &Identity,
session_id: Uuid,
message_id: Uuid,
reaction_type: ReactionType,
) -> Result<(SetReactionResponse, ReactionMutation)> {
let started = Instant::now();
let (session, _message) = self
.validate_access_for_reaction_target(identity, session_id, message_id)
.await?;
ensure_feedback_capability(&session)?;
let (response, mutation) = match reaction_type {
ReactionType::Like | ReactionType::Dislike => {
let outcome = self
.reactions
.upsert(message_id, &identity.user_id, reaction_type)
.await?;
let duration_ms = started.elapsed().as_millis() as u64;
info!(
target: "chat_engine::reaction",
session_id = %session_id,
message_id = %message_id,
user_id = %identity.user_id,
reaction = reaction_type.as_str(),
previous = ?outcome.previous_reaction_type.as_ref().map(ReactionType::as_str),
duration_ms,
"reaction upserted"
);
(
SetReactionResponse {
message_id,
reaction_type,
applied: true,
},
ReactionMutation {
session_id,
message_id,
user_id: identity.user_id.clone(),
reaction_type,
previous_reaction_type: outcome.previous_reaction_type,
session_type_id: session.session_type_id,
},
)
}
ReactionType::None => {
let outcome = self.reactions.delete(message_id, &identity.user_id).await?;
let duration_ms = started.elapsed().as_millis() as u64;
info!(
target: "chat_engine::reaction",
session_id = %session_id,
message_id = %message_id,
user_id = %identity.user_id,
reaction = "none",
applied = outcome.applied,
previous = ?outcome.previous_reaction_type.as_ref().map(ReactionType::as_str),
duration_ms,
"reaction removed"
);
(
SetReactionResponse {
message_id,
reaction_type: ReactionType::None,
applied: outcome.applied,
},
ReactionMutation {
session_id,
message_id,
user_id: identity.user_id.clone(),
reaction_type: ReactionType::None,
previous_reaction_type: outcome.previous_reaction_type,
session_type_id: session.session_type_id,
},
)
}
};
Ok((response, mutation))
}
#[instrument(skip(self), fields(
session_id = %session_id,
message_id = %message_id,
))]
pub async fn list_reactions(
&self,
identity: &Identity,
session_id: Uuid,
message_id: Uuid,
) -> Result<ReactionsListing> {
let _ = self
.validate_access_for_reaction_target(identity, session_id, message_id)
.await?;
let reactions = self.reactions.list_by_message(message_id).await?;
Ok(ReactionsListing {
message_id,
reactions,
})
}
pub fn spawn_plugin_notification(&self, mutation: ReactionMutation) -> JoinHandle<()> {
let session_types = Arc::clone(&self.session_types);
let plugins = self.plugins.clone();
tokio::spawn(async move {
let event = MessageReactionEvent::new(
mutation.session_id,
mutation.message_id,
mutation.user_id.clone(),
mutation.reaction_type,
mutation.previous_reaction_type,
);
let Some(session_type_id) = mutation.session_type_id else {
info!(
target: "chat_engine::reaction::notify",
session_id = %mutation.session_id,
message_id = %mutation.message_id,
"no session_type bound; skipping fire-and-forget reaction event"
);
return;
};
let plugin_instance_id = match session_types.find_by_id(session_type_id).await {
Ok(Some(st)) => st.plugin_instance_id,
Ok(None) => None,
Err(err) => {
warn!(
target: "chat_engine::reaction::notify",
session_id = %mutation.session_id,
message_id = %mutation.message_id,
error = %err,
"failed to resolve session_type for plugin notification (swallowed)"
);
return;
}
};
let Some(plugin_instance_id) = plugin_instance_id else {
info!(
target: "chat_engine::reaction::notify",
session_id = %mutation.session_id,
message_id = %mutation.message_id,
"session_type has no plugin_instance_id; skipping reaction event"
);
return;
};
match plugins.resolve(&plugin_instance_id) {
Ok(_plugin) => {
let payload = serde_json::to_value(&event).unwrap_or(JsonValue::Null);
info!(
target: "chat_engine::reaction::notify",
plugin_instance_id = %plugin_instance_id,
session_id = %mutation.session_id,
message_id = %mutation.message_id,
reaction = mutation.reaction_type.as_str(),
event = MessageReactionEvent::EVENT_KIND,
payload = %payload,
"fire-and-forget reaction event ready (plugin resolved)"
);
}
Err(err) => {
warn!(
target: "chat_engine::reaction::notify",
plugin_instance_id = %plugin_instance_id,
session_id = %mutation.session_id,
message_id = %mutation.message_id,
reaction = mutation.reaction_type.as_str(),
error = %err,
"failed to resolve plugin for reaction event (swallowed)"
);
}
}
})
}
async fn validate_access_for_reaction_target(
&self,
identity: &Identity,
session_id: Uuid,
message_id: Uuid,
) -> Result<(Session, crate::domain::message::Message)> {
let session_row = self
.sessions
.find_by_id(&identity.tenant_id, &identity.user_id, session_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("session", session_id))?;
let session = session_row;
let message = self
.messages
.find_message_in_session(session_id, message_id)
.await?
.ok_or_else(|| ChatEngineError::not_found("message", message_id))?;
if !matches!(message.role, MessageRole::Assistant) {
return Err(ChatEngineError::bad_request(
"reactions are only allowed on assistant messages",
));
}
Ok((session, message))
}
}
#[domain_model]
#[derive(Debug, Clone)]
pub struct ReactionMutation {
pub session_id: Uuid,
pub message_id: Uuid,
pub user_id: String,
pub reaction_type: ReactionType,
pub previous_reaction_type: Option<ReactionType>,
pub session_type_id: Option<Uuid>,
}
fn ensure_feedback_capability(session: &Session) -> Result<()> {
let JsonValue::Array(arr) = session
.enabled_capabilities
.as_ref()
.unwrap_or(&JsonValue::Null)
else {
return Err(ChatEngineError::conflict(
"feature 'feedback' is disabled for this session type",
));
};
let has_feedback = arr.iter().any(|entry| {
entry
.get("name")
.and_then(JsonValue::as_str)
.is_some_and(|n| n == CAPABILITY_FEEDBACK)
});
if has_feedback {
Ok(())
} else {
Err(ChatEngineError::conflict(
"feature 'feedback' is disabled for this session type",
))
}
}
#[cfg(test)]
#[path = "reaction_service_tests.rs"]
mod reaction_service_tests;