Files
imks/svc/message.rs
T
zhenyi 821537186e refactor(tests): reformat code and update dependency management
- 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
2026-06-11 12:11:05 +08:00

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,
}