821537186e
- Reorganized import statements in adapter tests for better readability - Replaced or_insert_with(Vec::new) with or_default() in test closures - Updated Cargo.lock with new dependency versions and checksums - Added TLS features to tonic dependency configuration - Included sqlx, chrono, and uuid dependencies with specific features - Added jsonwebtoken and arc-swap as project dependencies - Reformatted assertion statements to comply with line length limits - Adjusted base64 import order in engine codec module - Updated protobuf include statement formatting
971 lines
32 KiB
Rust
971 lines
32 KiB
Rust
//! Message service — the business logic layer connecting auth, permissions,
|
|
//! persistence, and real-time broadcast.
|
|
//!
|
|
//! Validates every operation through the gRPC permission chain before
|
|
//! touching the database or broadcasting to the room.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use chrono::Utc;
|
|
use dashmap::DashMap;
|
|
use tracing;
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::Authenticator;
|
|
use crate::models::message::Message;
|
|
use crate::pb::im::{
|
|
CheckPermissionRequest, EnsureReadableRequest, ImPermission, IsMemberRequest,
|
|
ResolveChannelRequest,
|
|
};
|
|
use crate::repo::message_repo::MessageRepo;
|
|
use crate::rpc::AppksClients;
|
|
use crate::socket::namespace::NamespaceManager;
|
|
use crate::socket::socket::Socket;
|
|
use crate::{ImksError, ImksResult};
|
|
|
|
/// Central business-logic service for message operations.
|
|
///
|
|
/// Every mutating operation performs the following checks in order:
|
|
/// 1. JWT authentication (via `Authenticator`)
|
|
/// 2. Nonce deduplication (prevent duplicate sends)
|
|
/// 3. Rate limiting (per-user, per-channel sliding window)
|
|
/// 4. Message body size validation (max 100 KB)
|
|
/// 5. Channel readability (`PermissionService.EnsureReadable`)
|
|
/// 6. Channel status (`ResolveChannel` → read_only / archived)
|
|
/// 7. Channel membership (`MemberService.IsMember`)
|
|
/// 8. Operation-specific permission (`PermissionService.CheckPermission`)
|
|
/// 9. Ownership validation (for edit/delete of others' messages)
|
|
///
|
|
/// Only after all gates pass does the operation reach the database
|
|
/// and the broadcast adapter.
|
|
#[derive(Clone)]
|
|
pub struct MessageService {
|
|
pub(crate) repo: MessageRepo,
|
|
pub(crate) auth: Arc<Authenticator>,
|
|
pub(crate) clients: AppksClients,
|
|
pub(crate) namespaces: Arc<NamespaceManager>,
|
|
/// Rate limiter: stores timestamps of recent sends per (user, channel).
|
|
rate_limits: Arc<DashMap<(Uuid, Uuid), Vec<Instant>>>,
|
|
/// Nonce dedup cache: nonce → first-seen timestamp. Uses TTL eviction.
|
|
nonces: Arc<DashMap<String, Instant>>,
|
|
/// Max message body length in bytes.
|
|
max_body_size: usize,
|
|
/// Per-user, per-channel messages allowed in the rate window.
|
|
rate_limit_count: usize,
|
|
/// Rate-limiting window duration.
|
|
rate_window: Duration,
|
|
/// Nonce TTL before reuse is allowed again.
|
|
nonce_ttl: Duration,
|
|
}
|
|
|
|
impl MessageService {
|
|
/// Create a new message service.
|
|
///
|
|
/// Internally initializes the JWT `Authenticator` from the token client
|
|
/// so that `authenticate_socket()` can verify JWT tokens during `on_connect`.
|
|
pub async fn new(
|
|
repo: MessageRepo,
|
|
clients: AppksClients,
|
|
namespaces: Arc<NamespaceManager>,
|
|
) -> ImksResult<Self> {
|
|
let auth = Arc::new(Authenticator::new(clients.token.clone()).await?);
|
|
Ok(Self {
|
|
repo,
|
|
auth,
|
|
clients,
|
|
namespaces,
|
|
rate_limits: Arc::new(DashMap::new()),
|
|
nonces: Arc::new(DashMap::new()),
|
|
max_body_size: 100_000,
|
|
rate_limit_count: 10,
|
|
rate_window: Duration::from_secs(10),
|
|
nonce_ttl: Duration::from_secs(300),
|
|
})
|
|
}
|
|
|
|
pub fn namespaces(&self) -> &NamespaceManager {
|
|
&self.namespaces
|
|
}
|
|
|
|
/// Verify a JWT token and attach the authenticated `user_id` to the socket.
|
|
/// Call this from `on_connect` before registering any event handlers.
|
|
pub fn authenticate_socket(
|
|
&self,
|
|
socket: &Socket,
|
|
auth_data: Option<&serde_json::Value>,
|
|
) -> ImksResult<()> {
|
|
let token = auth_data
|
|
.and_then(|v| v.get("token"))
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| ImksError::Auth("Missing auth token".into()))?;
|
|
|
|
let claims = self.auth.verify_local(token)?;
|
|
if !claims.has_scope("im:read") && !claims.has_scope("im:write") {
|
|
return Err(ImksError::Auth("Token lacks im scope".into()));
|
|
}
|
|
|
|
let user_id = Uuid::parse_str(&claims.sub)
|
|
.map_err(|_| ImksError::Auth(format!("Invalid user ID in token: {}", claims.sub)))?;
|
|
|
|
socket.set_user_id(user_id);
|
|
tracing::info!(user_id = %user_id, socket_sid = %socket.sid, "Socket authenticated");
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle `channel:join` event by adding the socket to the channel room.
|
|
pub async fn join_channel(
|
|
&self,
|
|
socket: Arc<Socket>,
|
|
data: &serde_json::Value,
|
|
) -> ImksResult<()> {
|
|
let user_id = self.user_id(&socket)?;
|
|
let payload = Self::first_payload(data)?;
|
|
let channel_id: Uuid = Self::parse_field(payload, "channel_id")?;
|
|
let channel_id_str = channel_id.to_string();
|
|
let user_id_str = user_id.to_string();
|
|
|
|
self.ensure_readable(&channel_id_str, &user_id_str).await?;
|
|
self.ensure_member(&channel_id_str, &user_id_str).await?;
|
|
|
|
let namespace = self
|
|
.namespaces
|
|
.get_namespace(&socket.namespace)
|
|
.ok_or_else(|| {
|
|
ImksError::Namespace(format!("namespace {} not found", socket.namespace))
|
|
})?;
|
|
namespace.join_room(&socket.sid, &channel_id_str).await?;
|
|
tracing::info!(socket_sid = %socket.sid, %channel_id, %user_id, "Socket joined channel room");
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle `channel:leave` event by removing the socket from the channel room.
|
|
pub async fn leave_channel(
|
|
&self,
|
|
socket: Arc<Socket>,
|
|
data: &serde_json::Value,
|
|
) -> ImksResult<()> {
|
|
let _user_id = self.user_id(&socket)?;
|
|
let payload = Self::first_payload(data)?;
|
|
let channel_id: Uuid = Self::parse_field(payload, "channel_id")?;
|
|
let channel_id_str = channel_id.to_string();
|
|
|
|
if let Some(namespace) = self.namespaces.get_namespace(&socket.namespace) {
|
|
namespace.leave_room(&socket.sid, &channel_id_str).await?;
|
|
}
|
|
tracing::info!(socket_sid = %socket.sid, %channel_id, "Socket left channel room");
|
|
Ok(())
|
|
}
|
|
|
|
/// Handle `message:send` event from a connected socket.
|
|
///
|
|
/// Expected `data` shape (Socket.IO event array tail):
|
|
/// ```json
|
|
/// [{
|
|
/// "channel_id": "...",
|
|
/// "body": "hello world",
|
|
/// "thread_id": null,
|
|
/// "reply_to_message_id": null,
|
|
/// "nonce": "optional-client-idempotency-key"
|
|
/// }]
|
|
/// ```
|
|
pub async fn send_message(
|
|
&self,
|
|
socket: Arc<Socket>,
|
|
data: &serde_json::Value,
|
|
) -> ImksResult<Message> {
|
|
let user_id = socket
|
|
.user_id()
|
|
.ok_or_else(|| ImksError::Auth("Socket not authenticated".into()))?;
|
|
|
|
let payload = self.parse_send_payload(data)?;
|
|
let channel_id = payload.channel_id;
|
|
let channel_id_str = channel_id.to_string();
|
|
let user_id_str = user_id.to_string();
|
|
|
|
self.validate_send_payload(&payload.body, &payload.nonce, user_id, channel_id)?;
|
|
self.validate_channel_write(&channel_id_str, &user_id_str)
|
|
.await?;
|
|
|
|
let nonce_key = match payload.nonce.as_deref() {
|
|
Some(nonce) => Some(self.reserve_nonce(nonce, user_id, channel_id)?),
|
|
None => None,
|
|
};
|
|
|
|
let result = self
|
|
.create_and_dispatch(payload, user_id, &socket.namespace)
|
|
.await;
|
|
if result.is_err() {
|
|
self.release_nonce(nonce_key);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Handle `message:edit` event.
|
|
///
|
|
/// The author can edit their own message. Users with `MANAGE_MESSAGES`
|
|
/// permission can edit any message in the channel.
|
|
pub async fn edit_message(
|
|
&self,
|
|
socket: Arc<Socket>,
|
|
data: &serde_json::Value,
|
|
) -> ImksResult<Message> {
|
|
let user_id = socket
|
|
.user_id()
|
|
.ok_or_else(|| ImksError::Auth("Socket not authenticated".into()))?;
|
|
|
|
let payload = Self::first_payload(data)?;
|
|
let message_id: Uuid = Self::parse_field(payload, "message_id")?;
|
|
let new_body: String = Self::parse_field(payload, "body")?;
|
|
|
|
let existing = self
|
|
.repo
|
|
.get(message_id)
|
|
.await?
|
|
.ok_or_else(|| ImksError::NotFound(format!("message {message_id}")))?;
|
|
|
|
self.ensure_author_or_mod(
|
|
existing.author_id,
|
|
&existing.channel_id.to_string(),
|
|
user_id,
|
|
)
|
|
.await?;
|
|
|
|
let old_body = existing.body.clone();
|
|
let updated = self.repo.update_body(message_id, &new_body).await?;
|
|
self.repo
|
|
.record_edit(message_id, user_id, &old_body, &new_body)
|
|
.await?;
|
|
|
|
let namespace = self.namespaces.get_namespace(&socket.namespace);
|
|
if let Some(ns) = namespace {
|
|
ns.emit_to_room(
|
|
&existing.channel_id.to_string(),
|
|
"message:updated",
|
|
serde_json::to_value(&updated).unwrap_or_default(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
tracing::info!(message_id = %message_id, user_id = %user_id, "Message edited");
|
|
Ok(updated)
|
|
}
|
|
|
|
/// Handle `message:delete` event (soft-delete).
|
|
///
|
|
/// Same ownership / moderator rules as edit.
|
|
pub async fn delete_message(
|
|
&self,
|
|
socket: Arc<Socket>,
|
|
data: &serde_json::Value,
|
|
) -> ImksResult<()> {
|
|
let user_id = socket
|
|
.user_id()
|
|
.ok_or_else(|| ImksError::Auth("Socket not authenticated".into()))?;
|
|
|
|
let payload = Self::first_payload(data)?;
|
|
let message_id: Uuid = Self::parse_field(payload, "message_id")?;
|
|
|
|
let existing = self
|
|
.repo
|
|
.get(message_id)
|
|
.await?
|
|
.ok_or_else(|| ImksError::NotFound(format!("message {message_id}")))?;
|
|
|
|
self.ensure_author_or_mod(
|
|
existing.author_id,
|
|
&existing.channel_id.to_string(),
|
|
user_id,
|
|
)
|
|
.await?;
|
|
|
|
self.repo.soft_delete(message_id).await?;
|
|
|
|
let namespace = self.namespaces.get_namespace(&socket.namespace);
|
|
if let Some(ns) = namespace {
|
|
ns.emit_to_room(
|
|
&existing.channel_id.to_string(),
|
|
"message:deleted",
|
|
serde_json::json!({ "id": message_id.to_string(), "channel_id": existing.channel_id.to_string() }),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
tracing::info!(message_id = %message_id, user_id = %user_id, "Message deleted");
|
|
Ok(())
|
|
}
|
|
|
|
// Permission validation helpers
|
|
|
|
/// Full write-access gate: resolve channel + readability + membership + SEND_MESSAGE.
|
|
pub(crate) async fn validate_channel_write(
|
|
&self,
|
|
channel_id: &str,
|
|
user_id: &str,
|
|
) -> ImksResult<()> {
|
|
// Check read-only / archived first (fast gRPC, returns early)
|
|
let channel_info = self.resolve_channel(channel_id).await?;
|
|
if channel_info.read_only {
|
|
return Err(ImksError::Auth(format!(
|
|
"Channel {channel_id} is read-only"
|
|
)));
|
|
}
|
|
if channel_info.archived {
|
|
return Err(ImksError::Auth(format!("Channel {channel_id} is archived")));
|
|
}
|
|
|
|
self.ensure_readable(channel_id, user_id).await?;
|
|
self.ensure_member(channel_id, user_id).await?;
|
|
self.ensure_permission(channel_id, user_id, ImPermission::SendMessage)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify the user can read this channel at all.
|
|
pub(crate) async fn ensure_readable(&self, channel_id: &str, user_id: &str) -> ImksResult<()> {
|
|
let mut client = self.clients.permission.clone();
|
|
let resp = client
|
|
.ensure_readable(EnsureReadableRequest {
|
|
channel_id: channel_id.to_string(),
|
|
user_id: user_id.to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let inner = resp.into_inner();
|
|
if !inner.allowed {
|
|
return Err(ImksError::Auth(format!(
|
|
"User {user_id} cannot read channel {channel_id}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn user_id(&self, socket: &Socket) -> crate::ImksResult<Uuid> {
|
|
socket
|
|
.user_id()
|
|
.ok_or_else(|| ImksError::Auth("Socket not authenticated".into()))
|
|
}
|
|
|
|
pub(crate) async fn ensure_member(
|
|
&self,
|
|
channel_id: &str,
|
|
user_id: &str,
|
|
) -> crate::ImksResult<()> {
|
|
let mut client = self.clients.member.clone();
|
|
let resp = client
|
|
.is_member(IsMemberRequest {
|
|
channel_id: channel_id.to_string(),
|
|
user_id: user_id.to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let inner = resp.into_inner();
|
|
if !inner.is_member {
|
|
return Err(ImksError::Auth(format!(
|
|
"User {user_id} is not a member of channel {channel_id}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify a specific permission.
|
|
async fn ensure_permission(
|
|
&self,
|
|
channel_id: &str,
|
|
user_id: &str,
|
|
permission: ImPermission,
|
|
) -> ImksResult<()> {
|
|
let allowed = self
|
|
.check_permission(channel_id, user_id, permission)
|
|
.await?;
|
|
|
|
if !allowed {
|
|
return Err(ImksError::Auth(format!(
|
|
"User {user_id} lacks permission {permission:?} in channel {channel_id}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify the user is the message author or has ManageMessages permission.
|
|
pub(crate) async fn ensure_author_or_mod(
|
|
&self,
|
|
message_author_id: Uuid,
|
|
channel_id: &str,
|
|
user_id: Uuid,
|
|
) -> ImksResult<()> {
|
|
if message_author_id == user_id {
|
|
return Ok(());
|
|
}
|
|
let allowed = self
|
|
.check_permission(
|
|
channel_id,
|
|
&user_id.to_string(),
|
|
ImPermission::ManageMessages,
|
|
)
|
|
.await?;
|
|
if !allowed {
|
|
return Err(ImksError::Auth(
|
|
"Only the author or a moderator can modify this message".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Low-level permission check returning a boolean.
|
|
pub(crate) async fn check_permission(
|
|
&self,
|
|
channel_id: &str,
|
|
user_id: &str,
|
|
permission: ImPermission,
|
|
) -> ImksResult<bool> {
|
|
let mut client = self.clients.permission.clone();
|
|
let resp = client
|
|
.check_permission(CheckPermissionRequest {
|
|
channel_id: channel_id.to_string(),
|
|
user_id: user_id.to_string(),
|
|
permission: permission as i32,
|
|
})
|
|
.await?;
|
|
|
|
Ok(resp.into_inner().allowed)
|
|
}
|
|
|
|
/// Resolve channel metadata via gRPC to check read_only / archived status.
|
|
async fn resolve_channel(
|
|
&self,
|
|
channel_id: &str,
|
|
) -> ImksResult<crate::pb::im::ResolveChannelResponse> {
|
|
let mut client = self.clients.permission.clone();
|
|
let resp = client
|
|
.resolve_channel(ResolveChannelRequest {
|
|
channel_id: channel_id.to_string(),
|
|
})
|
|
.await?;
|
|
Ok(resp.into_inner())
|
|
}
|
|
|
|
// Rate limiting & dedup
|
|
|
|
/// Reserve a nonce atomically. Nonces expire after `nonce_ttl`.
|
|
fn reserve_nonce(&self, nonce: &str, user_id: Uuid, channel_id: Uuid) -> ImksResult<String> {
|
|
let now = Instant::now();
|
|
let key = format!("{user_id}:{channel_id}:{nonce}");
|
|
|
|
// Cleanup expired nonces periodically (probabilistic, ~1/64 chance per check)
|
|
if rand::random::<u8>().is_multiple_of(64) {
|
|
self.nonces
|
|
.retain(|_, t| now.duration_since(*t) < self.nonce_ttl);
|
|
}
|
|
|
|
match self.nonces.entry(key.clone()) {
|
|
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
|
|
if now.duration_since(*entry.get()) < self.nonce_ttl {
|
|
return Err(ImksError::InvalidInput(
|
|
"Duplicate message: this nonce was already used".into(),
|
|
));
|
|
}
|
|
entry.insert(now);
|
|
}
|
|
dashmap::mapref::entry::Entry::Vacant(entry) => {
|
|
entry.insert(now);
|
|
}
|
|
}
|
|
Ok(key)
|
|
}
|
|
|
|
fn release_nonce(&self, nonce_key: Option<String>) {
|
|
if let Some(key) = nonce_key {
|
|
self.nonces.remove(&key);
|
|
}
|
|
}
|
|
|
|
fn validate_body_size(&self, body: &str) -> ImksResult<()> {
|
|
if body.len() > self.max_body_size {
|
|
return Err(ImksError::InvalidInput(format!(
|
|
"Message body exceeds max size of {} bytes (got {})",
|
|
self.max_body_size,
|
|
body.len()
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Check per-user, per-channel rate limit using a sliding window.
|
|
fn check_rate_limit(&self, user_id: Uuid, channel_id: Uuid) -> ImksResult<()> {
|
|
let key = (user_id, channel_id);
|
|
let now = Instant::now();
|
|
|
|
let mut entry = self.rate_limits.entry(key).or_default();
|
|
|
|
// Evict timestamps outside the window
|
|
entry.retain(|t| now.duration_since(*t) < self.rate_window);
|
|
|
|
if entry.len() >= self.rate_limit_count {
|
|
return Err(ImksError::InvalidInput(format!(
|
|
"Rate limit exceeded: max {} messages per {}s",
|
|
self.rate_limit_count,
|
|
self.rate_window.as_secs()
|
|
)));
|
|
}
|
|
|
|
entry.push(now);
|
|
Ok(())
|
|
}
|
|
|
|
/// Combined safety checks for message sending: body size and rate limit.
|
|
fn validate_send_payload(
|
|
&self,
|
|
body: &str,
|
|
_nonce: &Option<String>,
|
|
user_id: Uuid,
|
|
channel_id: Uuid,
|
|
) -> ImksResult<()> {
|
|
self.validate_body_size(body)?;
|
|
self.check_rate_limit(user_id, channel_id)?;
|
|
Ok(())
|
|
}
|
|
|
|
// Payload parsers
|
|
|
|
/// Parse attachment inputs from a JSON payload.
|
|
fn parse_attachments(value: &serde_json::Value) -> Vec<AttachmentInput> {
|
|
value
|
|
.get("attachments")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|a| {
|
|
Some(AttachmentInput {
|
|
filename: a.get("filename")?.as_str()?.to_string(),
|
|
url: a.get("url")?.as_str()?.to_string(),
|
|
size: a.get("size")?.as_i64()?,
|
|
content_type: a
|
|
.get("content_type")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from),
|
|
})
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Parse embed inputs from a JSON payload.
|
|
fn parse_embeds(value: &serde_json::Value) -> Vec<EmbedInput> {
|
|
value
|
|
.get("embeds")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|e| {
|
|
let fields: Vec<(String, String, bool)> = e
|
|
.get("fields")
|
|
.and_then(|v| v.as_array())
|
|
.map(|fa| {
|
|
fa.iter()
|
|
.filter_map(|f| {
|
|
Some((
|
|
f.get("name")?.as_str()?.to_string(),
|
|
f.get("value")?.as_str()?.to_string(),
|
|
f.get("inline")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false),
|
|
))
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
Some(EmbedInput {
|
|
embed_type: e.get("embed_type")?.as_str()?.to_string(),
|
|
title: e.get("title").and_then(|v| v.as_str()).map(String::from),
|
|
description: e
|
|
.get("description")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from),
|
|
url: e.get("url").and_then(|v| v.as_str()).map(String::from),
|
|
image_url: e
|
|
.get("image_url")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from),
|
|
fields,
|
|
})
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Parse a sticker input from a JSON payload (single object or null).
|
|
fn parse_sticker(value: &serde_json::Value) -> Option<StickerInput> {
|
|
value.get("sticker").and_then(|s| {
|
|
Some(StickerInput {
|
|
sticker_id: MessageService::parse_field(s, "sticker_id").ok()?,
|
|
name: MessageService::parse_field(s, "name").ok()?,
|
|
image_url: MessageService::parse_field(s, "image_url").ok()?,
|
|
format_type: s
|
|
.get("format_type")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("png")
|
|
.to_string(),
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Parse a forward input from a JSON payload (single object or null).
|
|
fn parse_forward(value: &serde_json::Value) -> Option<ForwardInput> {
|
|
value.get("forward").and_then(|f| {
|
|
Some(ForwardInput {
|
|
source_message_id: MessageService::parse_field(f, "source_message_id").ok()?,
|
|
source_channel_id: MessageService::parse_field(f, "source_channel_id").ok()?,
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Parse the `message:send` event payload including optional rich content.
|
|
fn parse_send_payload(&self, data: &serde_json::Value) -> ImksResult<SendPayload> {
|
|
let arr = data
|
|
.as_array()
|
|
.ok_or_else(|| ImksError::InvalidInput("Event data must be a JSON array".into()))?;
|
|
|
|
if arr.is_empty() {
|
|
return Err(ImksError::InvalidInput("Empty event data".into()));
|
|
}
|
|
|
|
let payload = &arr[0];
|
|
|
|
let channel_id: Uuid = Self::parse_field(payload, "channel_id")?;
|
|
let body: String = Self::parse_field(payload, "body")?;
|
|
let thread_id = Self::parse_optional(payload, "thread_id")?;
|
|
let reply_to_message_id = Self::parse_optional(payload, "reply_to_message_id")?;
|
|
let nonce: Option<String> = Self::parse_optional(payload, "nonce")?;
|
|
let mentioned_user_ids: Vec<Uuid> =
|
|
Self::parse_optional(payload, "mentioned_user_ids")?.unwrap_or_default();
|
|
|
|
let attachments = Self::parse_attachments(payload);
|
|
let embeds = Self::parse_embeds(payload);
|
|
let sticker = Self::parse_sticker(payload);
|
|
let forward = Self::parse_forward(payload);
|
|
|
|
Ok(SendPayload {
|
|
channel_id,
|
|
body,
|
|
thread_id,
|
|
reply_to_message_id,
|
|
nonce,
|
|
mentioned_user_ids,
|
|
attachments,
|
|
embeds,
|
|
sticker,
|
|
forward,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn first_payload(data: &serde_json::Value) -> ImksResult<&serde_json::Value> {
|
|
data.as_array()
|
|
.and_then(|a| a.first())
|
|
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))
|
|
}
|
|
|
|
pub(crate) fn parse_field<T: serde::de::DeserializeOwned>(
|
|
value: &serde_json::Value,
|
|
field: &str,
|
|
) -> crate::ImksResult<T> {
|
|
let field_value = value
|
|
.get(field)
|
|
.ok_or_else(|| ImksError::InvalidInput(format!("Missing required field: {field}")))?;
|
|
|
|
serde_json::from_value(field_value.clone())
|
|
.map_err(|e| ImksError::InvalidInput(format!("Invalid field {field}: {e}")))
|
|
}
|
|
|
|
pub(crate) fn parse_optional<T: serde::de::DeserializeOwned>(
|
|
value: &serde_json::Value,
|
|
field: &str,
|
|
) -> ImksResult<Option<T>> {
|
|
match value.get(field) {
|
|
Some(v) if v.is_null() => Ok(None),
|
|
Some(v) => serde_json::from_value(v.clone())
|
|
.map(Some)
|
|
.map_err(|e| ImksError::InvalidInput(format!("Invalid field {field}: {e}"))),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// Create the message and all rich content in one database transaction,
|
|
/// then broadcast to the channel room after commit.
|
|
async fn create_and_dispatch(
|
|
&self,
|
|
payload: SendPayload,
|
|
user_id: Uuid,
|
|
namespace_path: &str,
|
|
) -> ImksResult<Message> {
|
|
let mut tx = self.repo.pool().begin().await?;
|
|
let message_id = Uuid::now_v7();
|
|
let now = Utc::now();
|
|
|
|
let message = sqlx::query_as::<_, Message>(
|
|
r#"
|
|
INSERT INTO message (
|
|
id, channel_id, author_id, thread_id, reply_to_message_id,
|
|
message_type, body, metadata, pinned, system,
|
|
edited_at, deleted_at, created_at, updated_at
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5,
|
|
'text', $6, NULL, FALSE, FALSE,
|
|
NULL, NULL, $7, $7
|
|
)
|
|
RETURNING *
|
|
"#,
|
|
)
|
|
.bind(message_id)
|
|
.bind(payload.channel_id)
|
|
.bind(user_id)
|
|
.bind(payload.thread_id)
|
|
.bind(payload.reply_to_message_id)
|
|
.bind(&payload.body)
|
|
.bind(now)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
|
|
if let Some(thread_id) = payload.thread_id {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE message_thread
|
|
SET replies_count = replies_count + 1,
|
|
last_reply_message_id = $1,
|
|
last_reply_at = $2,
|
|
updated_at = $2
|
|
WHERE id = $3
|
|
"#,
|
|
)
|
|
.bind(message_id)
|
|
.bind(now)
|
|
.bind(thread_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO message_thread_participant (id, thread_id, user_id, joined_reason, joined_at)
|
|
VALUES ($1, $2, $3, 'reply', $4)
|
|
ON CONFLICT (thread_id, user_id) DO UPDATE SET joined_reason = EXCLUDED.joined_reason
|
|
"#,
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(thread_id)
|
|
.bind(user_id)
|
|
.bind(now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
"UPDATE message_thread SET participants_count = (SELECT COUNT(*) FROM message_thread_participant WHERE thread_id = $1) WHERE id = $1",
|
|
)
|
|
.bind(thread_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
for att in &payload.attachments {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO message_attachment (
|
|
id, message_id, filename, content_type, size, url,
|
|
storage_key, width, height, spoiler
|
|
) VALUES ($1, $2, $3, $4, $5, $6, NULL, NULL, NULL, FALSE)
|
|
"#,
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(message_id)
|
|
.bind(&att.filename)
|
|
.bind(att.content_type.as_deref())
|
|
.bind(att.size)
|
|
.bind(&att.url)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
for emb in &payload.embeds {
|
|
let embed_id = Uuid::now_v7();
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO message_embed (
|
|
id, message_id, embed_type, title, description, url, color,
|
|
image_url, author_name, author_url, footer_text, provider_name
|
|
) VALUES ($1, $2, $3, $4, $5, $6, NULL, $7, NULL, NULL, NULL, NULL)
|
|
"#,
|
|
)
|
|
.bind(embed_id)
|
|
.bind(message_id)
|
|
.bind(&emb.embed_type)
|
|
.bind(emb.title.as_deref())
|
|
.bind(emb.description.as_deref())
|
|
.bind(emb.url.as_deref())
|
|
.bind(emb.image_url.as_deref())
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
for (position, (name, value, inline)) in emb.fields.iter().enumerate() {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO message_embed_field (id, embed_id, name, value, inline, position)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
"#,
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(embed_id)
|
|
.bind(name)
|
|
.bind(value)
|
|
.bind(*inline)
|
|
.bind(position as i32)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
}
|
|
|
|
if let Some(sticker) = &payload.sticker {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO message_sticker (id, message_id, sticker_id, name, image_url, format_type, pack_name, tags)
|
|
VALUES ($1, $2, $3, $4, $5, $6, NULL, NULL)
|
|
"#,
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(message_id)
|
|
.bind(sticker.sticker_id)
|
|
.bind(&sticker.name)
|
|
.bind(&sticker.image_url)
|
|
.bind(&sticker.format_type)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
if let Some(forward) = &payload.forward {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO message_forward (id, message_id, source_message_id, source_channel_id, forwarded_by)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#,
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(message_id)
|
|
.bind(forward.source_message_id)
|
|
.bind(forward.source_channel_id)
|
|
.bind(user_id)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
for mentioned_id in &payload.mentioned_user_ids {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO message_mention (id, message_id, channel_id, mentioned_user_id, mentioned_by, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
"#,
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(message_id)
|
|
.bind(payload.channel_id)
|
|
.bind(*mentioned_id)
|
|
.bind(user_id)
|
|
.bind(now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO message_notification (
|
|
id, message_id, channel_id, user_id, reason, status, delivery_channel, created_at
|
|
) VALUES ($1, $2, $3, $4, 'mention', 'pending', NULL, $5)
|
|
"#,
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(message_id)
|
|
.bind(payload.channel_id)
|
|
.bind(*mentioned_id)
|
|
.bind(now)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
}
|
|
|
|
tx.commit().await?;
|
|
|
|
self.broadcast_new_message(&message, payload.channel_id, user_id, namespace_path)
|
|
.await;
|
|
|
|
Ok(message)
|
|
}
|
|
|
|
/// Broadcast a newly created message to the channel room and log.
|
|
async fn broadcast_new_message(
|
|
&self,
|
|
message: &Message,
|
|
channel_id: Uuid,
|
|
user_id: Uuid,
|
|
namespace_path: &str,
|
|
) {
|
|
let namespace = self.namespaces.get_namespace(namespace_path);
|
|
if let Some(ns) = namespace {
|
|
ns.emit_to_room(
|
|
&channel_id.to_string(),
|
|
"message:new",
|
|
serde_json::to_value(message).unwrap_or_default(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
tracing::info!(
|
|
message_id = %message.id,
|
|
channel_id = %channel_id,
|
|
user_id = %user_id,
|
|
"Message sent"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Rich content input types for parsing
|
|
|
|
pub(crate) struct SendPayload {
|
|
channel_id: Uuid,
|
|
body: String,
|
|
thread_id: Option<Uuid>,
|
|
reply_to_message_id: Option<Uuid>,
|
|
nonce: Option<String>,
|
|
mentioned_user_ids: Vec<Uuid>,
|
|
attachments: Vec<AttachmentInput>,
|
|
embeds: Vec<EmbedInput>,
|
|
sticker: Option<StickerInput>,
|
|
forward: Option<ForwardInput>,
|
|
}
|
|
|
|
pub(crate) struct AttachmentInput {
|
|
filename: String,
|
|
url: String,
|
|
size: i64,
|
|
content_type: Option<String>,
|
|
}
|
|
|
|
pub(crate) struct EmbedInput {
|
|
embed_type: String,
|
|
title: Option<String>,
|
|
description: Option<String>,
|
|
url: Option<String>,
|
|
image_url: Option<String>,
|
|
fields: Vec<(String, String, bool)>,
|
|
}
|
|
|
|
pub(crate) struct StickerInput {
|
|
sticker_id: Uuid,
|
|
name: String,
|
|
image_url: String,
|
|
format_type: String,
|
|
}
|
|
|
|
pub(crate) struct ForwardInput {
|
|
source_message_id: Uuid,
|
|
source_channel_id: Uuid,
|
|
}
|