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
This commit is contained in:
+221
@@ -0,0 +1,221 @@
|
||||
//! Forum article event handlers on `MessageService`.
|
||||
//!
|
||||
//! Articles are long-form posts in forum channels. Creating an article
|
||||
//! creates both a `message` (with `message_type = "article"`) and a
|
||||
//! `message_article` row linked to it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::models::message::MessageType;
|
||||
use crate::models::message_article::ArticleSort;
|
||||
use crate::repo::CreateMessageInput;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `article:create` — create a forum article (message + article metadata).
|
||||
pub async fn create_article(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
let title: String = Self::parse_field(arr, "title")?;
|
||||
let body: String = Self::parse_field(arr, "body")?;
|
||||
let summary: Option<String> = Self::parse_optional(arr, "summary")?;
|
||||
let cover_url: Option<String> = Self::parse_optional(arr, "cover_url")?;
|
||||
let tags: Option<serde_json::Value> = Self::parse_optional(arr, "tags")?;
|
||||
|
||||
self.validate_channel_write(&channel_id.to_string(), &user_id.to_string())
|
||||
.await?;
|
||||
|
||||
// Create the message first (with article type)
|
||||
let input = CreateMessageInput {
|
||||
channel_id,
|
||||
author_id: user_id,
|
||||
thread_id: None,
|
||||
reply_to_message_id: None,
|
||||
message_type: MessageType::Article.as_str().into(),
|
||||
body,
|
||||
metadata: None,
|
||||
system: false,
|
||||
};
|
||||
|
||||
let message = self.repo.create(&input).await?;
|
||||
|
||||
// Create the article record
|
||||
let article = self
|
||||
.repo
|
||||
.create_article(
|
||||
message.id,
|
||||
&title,
|
||||
summary.as_deref(),
|
||||
cover_url.as_deref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
tags.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"article:created",
|
||||
serde_json::json!({
|
||||
"message": message,
|
||||
"article": article,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(article_id = %article.id, %channel_id, %user_id, "Article created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `article:update` — update article title, summary, cover, tags.
|
||||
pub async fn update_article(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let message_id: Uuid = Self::parse_field(arr, "message_id")?;
|
||||
let title: Option<String> = Self::parse_optional(arr, "title")?;
|
||||
let summary: Option<String> = Self::parse_optional(arr, "summary")?;
|
||||
let cover_url: Option<String> = Self::parse_optional(arr, "cover_url")?;
|
||||
let cover_color: Option<String> = Self::parse_optional(arr, "cover_color")?;
|
||||
let tags: Option<serde_json::Value> = Self::parse_optional(arr, "tags")?;
|
||||
|
||||
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?;
|
||||
|
||||
// Update article body if provided
|
||||
if let Ok(new_body) = Self::parse_field::<String>(arr, "body")
|
||||
&& !new_body.is_empty()
|
||||
{
|
||||
let old_body = existing.body.clone();
|
||||
self.repo.update_body(message_id, &new_body).await?;
|
||||
self.repo
|
||||
.record_edit(message_id, user_id, &old_body, &new_body)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(updated) = self
|
||||
.repo
|
||||
.update_article(
|
||||
message_id,
|
||||
title.as_deref(),
|
||||
summary.as_deref(),
|
||||
cover_url.as_deref(),
|
||||
cover_color.as_deref(),
|
||||
tags.as_ref(),
|
||||
)
|
||||
.await?
|
||||
&& let Some(ns) = self.namespaces.get_namespace(&socket.namespace)
|
||||
{
|
||||
ns.emit_to_room(
|
||||
&existing.channel_id.to_string(),
|
||||
"article:updated",
|
||||
serde_json::to_value(&updated).unwrap_or_default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `article:list` — list articles in a forum channel.
|
||||
pub async fn list_articles(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
self.ensure_readable(&channel_id.to_string(), &user_id.to_string())
|
||||
.await?;
|
||||
self.ensure_member(&channel_id.to_string(), &user_id.to_string())
|
||||
.await?;
|
||||
let before: Option<(i64, Uuid)> = None;
|
||||
let limit: Option<i64> = Self::parse_optional(arr, "limit")?;
|
||||
|
||||
let page = self
|
||||
.repo
|
||||
.list_articles(channel_id, ArticleSort::LatestActivity, before, limit)
|
||||
.await?;
|
||||
let _ = socket.emit(
|
||||
"article:loaded",
|
||||
serde_json::to_value(&page).unwrap_or_default(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `article:delete` — soft-delete an article.
|
||||
pub async fn delete_article(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let message_id: Uuid = Self::parse_field(arr, "message_id")?;
|
||||
let _channel_id: Uuid = Self::parse_field(arr, "channel_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?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(&existing.channel_id.to_string(), "article:deleted",
|
||||
serde_json::json!({"id": message_id.to_string(), "channel_id": existing.channel_id.to_string()}),
|
||||
).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Bookmark event handlers on `MessageService`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `bookmark:add` — toggle (add/update) a bookmark.
|
||||
pub async fn add_bookmark(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let message_id: Uuid = Self::parse_field(arr, "message_id")?;
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
let note: Option<String> = Self::parse_optional(arr, "note")?;
|
||||
|
||||
self.repo
|
||||
.add_bookmark(message_id, channel_id, user_id, note.as_deref())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `bookmark:remove` — remove a bookmark.
|
||||
pub async fn remove_bookmark(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let message_id: Uuid = Self::parse_field(arr, "message_id")?;
|
||||
|
||||
self.repo.remove_bookmark(message_id, user_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `bookmark:list` — list a user's bookmarks.
|
||||
pub async fn list_bookmarks(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let before: Option<Uuid> = Self::parse_optional(arr, "before")?;
|
||||
let limit: Option<i64> = Self::parse_optional(arr, "limit")?;
|
||||
|
||||
let page = self.repo.list_bookmarks(user_id, before, limit).await?;
|
||||
let _ = socket.emit(
|
||||
"bookmark:loaded",
|
||||
serde_json::to_value(&page).unwrap_or_default(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Interactive component event handlers on `MessageService`.
|
||||
//!
|
||||
//! Handles button clicks and select menu interactions on message components.
|
||||
//! When a user clicks a button, the server updates the component state and
|
||||
//! broadcasts the interaction to the channel.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `component:interact` — a user clicked a button or selected from a menu.
|
||||
pub async fn interact_component(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let component_id: Uuid = Self::parse_field(arr, "component_id")?;
|
||||
let custom_id: String = Self::parse_field(arr, "custom_id")?;
|
||||
let message_id: Uuid = Self::parse_field(arr, "message_id")?;
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
|
||||
// Get current components to verify the interaction is valid
|
||||
let components = self.repo.get_components(message_id).await?;
|
||||
let component = components.iter().find(|c| c.id == component_id);
|
||||
|
||||
if component.is_none() {
|
||||
return Err(ImksError::NotFound(format!("component {component_id}")));
|
||||
}
|
||||
|
||||
// Broadcast the interaction event to all clients in the channel.
|
||||
// The actual action (e.g., approve/deny) is handled by the bot/webhook
|
||||
// that listens for this event. The server just relays and disables the
|
||||
// component to prevent double-clicks.
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"component:interaction",
|
||||
serde_json::json!({
|
||||
"component_id": component_id.to_string(),
|
||||
"custom_id": custom_id,
|
||||
"message_id": message_id.to_string(),
|
||||
"user_id": user_id.to_string(),
|
||||
"channel_id": channel_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(%component_id, %user_id, %custom_id, "Component interaction");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `component:update` — update a component's state (e.g., disable after interaction).
|
||||
#[allow(dead_code)]
|
||||
pub async fn update_component(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let component_id: Uuid = Self::parse_field(arr, "component_id")?;
|
||||
let label: Option<String> = Self::parse_optional(arr, "label")?;
|
||||
let disabled: bool = Self::parse_optional(arr, "disabled")?.unwrap_or(true);
|
||||
|
||||
if let Some(updated) = self
|
||||
.repo
|
||||
.update_component(component_id, label.as_deref(), disabled)
|
||||
.await?
|
||||
&& let Some(ns) = self.namespaces.get_namespace(&socket.namespace)
|
||||
{
|
||||
ns.emit_to_room(
|
||||
&updated.message_id.to_string(),
|
||||
"component:updated",
|
||||
serde_json::to_value(&updated).unwrap_or_default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Server deployment configuration.
|
||||
//!
|
||||
//! Reads from environment variables to select adapter (local/redis/nats)
|
||||
//! and WebTransport settings.
|
||||
|
||||
use std::env;
|
||||
|
||||
/// Adapter + message bus configuration for multi-node scale-out.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeployConfig {
|
||||
/// "local" | "redis" | "nats"
|
||||
pub adapter_mode: String,
|
||||
/// Redis connection URL (used when adapter_mode = "redis").
|
||||
pub redis_url: String,
|
||||
/// NATS connection URL (used when adapter_mode = "nats").
|
||||
pub nats_url: String,
|
||||
/// Unique server ID for this node.
|
||||
pub server_id: String,
|
||||
/// Enable WebTransport server.
|
||||
pub webtransport_enabled: bool,
|
||||
/// WebTransport listen port.
|
||||
pub webtransport_port: u16,
|
||||
/// TLS certificate path (required for WebTransport).
|
||||
pub cert_path: String,
|
||||
/// TLS key path (required for WebTransport).
|
||||
pub key_path: String,
|
||||
}
|
||||
|
||||
impl DeployConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let server_id = env::var("IMKS_SERVER_ID").unwrap_or_else(|_| hostname());
|
||||
|
||||
Self {
|
||||
adapter_mode: env::var("IMKS_ADAPTER").unwrap_or_else(|_| "local".into()),
|
||||
redis_url: env::var("IMKS_REDIS_URL")
|
||||
.unwrap_or_else(|_| "redis://localhost:6379".into()),
|
||||
nats_url: env::var("IMKS_NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()),
|
||||
server_id,
|
||||
webtransport_enabled: env::var("IMKS_WT_ENABLED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false),
|
||||
webtransport_port: env::var("IMKS_WT_PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3001),
|
||||
cert_path: env::var("IMKS_WT_CERT_PATH").unwrap_or_default(),
|
||||
key_path: env::var("IMKS_WT_KEY_PATH").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DeployConfig {
|
||||
fn default() -> Self {
|
||||
Self::from_env()
|
||||
}
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
env::var("HOSTNAME")
|
||||
.or_else(|_| env::var("HOST"))
|
||||
.unwrap_or_else(|_| "imks-node-1".into())
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
//! Draft event handlers on `MessageService`.
|
||||
//!
|
||||
//! Drafts are per-user private data — no permission checks beyond auth needed.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `draft:save` — upsert a draft.
|
||||
pub async fn save_draft(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
let body: String = Self::parse_field(arr, "body")?;
|
||||
let thread_id: Option<Uuid> = Self::parse_optional(arr, "thread_id")?;
|
||||
let reply_to_message_id: Option<Uuid> = Self::parse_optional(arr, "reply_to_message_id")?;
|
||||
let metadata: Option<serde_json::Value> = Self::parse_optional(arr, "metadata")?;
|
||||
let channel_id_str = channel_id.to_string();
|
||||
let user_id_str = user_id.to_string();
|
||||
|
||||
self.validate_channel_write(&channel_id_str, &user_id_str)
|
||||
.await?;
|
||||
|
||||
self.repo
|
||||
.upsert_draft(
|
||||
channel_id,
|
||||
user_id,
|
||||
thread_id,
|
||||
&body,
|
||||
reply_to_message_id,
|
||||
metadata,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `draft:get` — retrieve a draft and send it back to the requesting socket.
|
||||
pub async fn get_draft(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
let thread_id: Option<Uuid> = Self::parse_optional(arr, "thread_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 draft = self.repo.get_draft(channel_id, user_id, thread_id).await?;
|
||||
if let Some(d) = draft {
|
||||
let _ = socket.emit("draft:loaded", serde_json::to_value(&d).unwrap_or_default());
|
||||
} else {
|
||||
let _ = socket.emit("draft:loaded", serde_json::json!(null));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `draft:delete` — delete a draft after sending.
|
||||
pub async fn delete_draft(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
let thread_id: Option<Uuid> = Self::parse_optional(arr, "thread_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?;
|
||||
|
||||
self.repo
|
||||
.delete_draft(channel_id, user_id, thread_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+970
@@ -0,0 +1,970 @@
|
||||
//! 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,
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
pub mod article;
|
||||
pub mod bookmark;
|
||||
pub mod component;
|
||||
pub mod deploy;
|
||||
pub mod draft;
|
||||
pub mod message;
|
||||
pub mod pin;
|
||||
pub mod poll;
|
||||
pub mod reaction;
|
||||
pub mod read_state;
|
||||
pub mod scheduled;
|
||||
pub mod thread;
|
||||
pub mod typing;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use deploy::DeployConfig;
|
||||
pub use message::MessageService;
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
//! Pin event handlers on `MessageService`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::ImksResult;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `pin:add` — pin a message, then broadcast to the channel room.
|
||||
pub async fn pin_message(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let (channel_id, message_id) = self.parse_pin_payload(data)?;
|
||||
|
||||
self.ensure_member(&channel_id.to_string(), &user_id.to_string())
|
||||
.await?;
|
||||
|
||||
self.repo
|
||||
.pin_message(channel_id, message_id, user_id)
|
||||
.await?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
let ns = ns.clone();
|
||||
let cid = channel_id.to_string();
|
||||
let mid = message_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
ns.emit_to_room(
|
||||
&cid,
|
||||
"pin:added",
|
||||
serde_json::json!({
|
||||
"channel_id": cid,
|
||||
"message_id": mid,
|
||||
"pinned_by": user_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(%channel_id, %message_id, %user_id, "Message pinned");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `pin:remove` — unpin a message.
|
||||
pub async fn unpin_message(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let (channel_id, message_id) = self.parse_pin_payload(data)?;
|
||||
|
||||
self.ensure_member(&channel_id.to_string(), &user_id.to_string())
|
||||
.await?;
|
||||
|
||||
self.repo.unpin_message(channel_id, message_id).await?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
let ns = ns.clone();
|
||||
let cid = channel_id.to_string();
|
||||
let mid = message_id.to_string();
|
||||
tokio::spawn(async move {
|
||||
ns.emit_to_room(
|
||||
&cid,
|
||||
"pin:removed",
|
||||
serde_json::json!({
|
||||
"channel_id": cid,
|
||||
"message_id": mid,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
tracing::info!(%channel_id, %message_id, %user_id, "Message unpinned");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_pin_payload(&self, data: &serde_json::Value) -> ImksResult<(Uuid, Uuid)> {
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
Ok((
|
||||
Self::parse_field(arr, "channel_id")?,
|
||||
Self::parse_field(arr, "message_id")?,
|
||||
))
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
//! Poll event handlers on `MessageService`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `poll:vote` — cast a vote on a poll option.
|
||||
pub async fn poll_vote(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let poll_id: Uuid = Self::parse_field(arr, "poll_id")?;
|
||||
let option_id: Uuid = Self::parse_field(arr, "option_id")?;
|
||||
let target = self.repo.get_poll_target(poll_id, option_id).await?;
|
||||
let channel_id = target.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 target = self
|
||||
.repo
|
||||
.cast_vote_checked(poll_id, option_id, user_id)
|
||||
.await?;
|
||||
if let Some(result) = self
|
||||
.repo
|
||||
.get_poll_result(target.message_id, user_id)
|
||||
.await?
|
||||
&& let Some(ns) = self.namespaces.get_namespace(&socket.namespace)
|
||||
{
|
||||
ns.emit_to_room(
|
||||
&target.channel_id.to_string(),
|
||||
"poll:updated",
|
||||
serde_json::to_value(&result).unwrap_or_default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `poll:vote:remove` — retract a vote.
|
||||
pub async fn poll_remove_vote(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let poll_id: Uuid = Self::parse_field(arr, "poll_id")?;
|
||||
let option_id: Uuid = Self::parse_field(arr, "option_id")?;
|
||||
let target = self.repo.get_poll_target(poll_id, option_id).await?;
|
||||
let channel_id_str = target.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?;
|
||||
|
||||
if let Some(target) = self
|
||||
.repo
|
||||
.remove_vote_checked(poll_id, option_id, user_id)
|
||||
.await?
|
||||
&& let Some(result) = self
|
||||
.repo
|
||||
.get_poll_result(target.message_id, user_id)
|
||||
.await?
|
||||
&& let Some(ns) = self.namespaces.get_namespace(&socket.namespace)
|
||||
{
|
||||
ns.emit_to_room(
|
||||
&target.channel_id.to_string(),
|
||||
"poll:updated",
|
||||
serde_json::to_value(&result).unwrap_or_default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
//! Reaction event handlers on `MessageService`.
|
||||
//!
|
||||
//! Toggle semantics: sending the same reaction again removes it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `reaction:add` — toggle (add or remove) a reaction, then broadcast.
|
||||
pub async fn toggle_reaction(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let (message_id, content) = self.parse_reaction_payload(data)?;
|
||||
let message = self
|
||||
.repo
|
||||
.get(message_id)
|
||||
.await?
|
||||
.ok_or_else(|| ImksError::NotFound(format!("message {message_id}")))?;
|
||||
let channel_id = message.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 action = if self
|
||||
.repo
|
||||
.add_reaction(message_id, channel_id, user_id, &content)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
tracing::info!(%message_id, %user_id, %content, "Reaction added");
|
||||
"add"
|
||||
} else {
|
||||
self.repo
|
||||
.remove_reaction(message_id, user_id, &content)
|
||||
.await?;
|
||||
tracing::info!(%message_id, %user_id, %content, "Reaction removed");
|
||||
"remove"
|
||||
};
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"reaction:updated",
|
||||
serde_json::json!({
|
||||
"message_id": message_id.to_string(),
|
||||
"channel_id": channel_id.to_string(),
|
||||
"user_id": user_id.to_string(),
|
||||
"content": content,
|
||||
"action": action,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `reaction:remove` — explicitly remove a reaction.
|
||||
pub async fn remove_reaction(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let (message_id, content) = self.parse_reaction_payload(data)?;
|
||||
let message = self
|
||||
.repo
|
||||
.get(message_id)
|
||||
.await?
|
||||
.ok_or_else(|| ImksError::NotFound(format!("message {message_id}")))?;
|
||||
let channel_id = message.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?;
|
||||
|
||||
self.repo
|
||||
.remove_reaction(message_id, user_id, &content)
|
||||
.await?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"reaction:updated",
|
||||
serde_json::json!({
|
||||
"message_id": message_id.to_string(),
|
||||
"channel_id": channel_id.to_string(),
|
||||
"user_id": user_id.to_string(),
|
||||
"content": content,
|
||||
"action": "remove",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_reaction_payload(
|
||||
&self,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<(Uuid, String)> {
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let content: String = Self::parse_field(arr, "content")?;
|
||||
if content.trim().is_empty() {
|
||||
return Err(ImksError::InvalidInput(
|
||||
"Reaction content cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((Self::parse_field(arr, "message_id")?, content))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Read state and notification event handlers on `MessageService`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
// Read state handlers
|
||||
|
||||
/// Handle `read_state:mark` — mark a channel as read up to a message.
|
||||
pub async fn mark_read(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
let message_id: Uuid = Self::parse_field(arr, "message_id")?;
|
||||
|
||||
let state = self.repo.mark_read(channel_id, user_id, message_id).await?;
|
||||
let _ = socket.emit(
|
||||
"read_state:updated",
|
||||
serde_json::to_value(&state).unwrap_or_default(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `read_state:get` — get read state for a channel.
|
||||
pub async fn get_read_state(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
|
||||
let state = self.repo.get_read_state(channel_id, user_id).await?;
|
||||
let _ = socket.emit(
|
||||
"read_state:loaded",
|
||||
serde_json::to_value(&state).unwrap_or_default(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Notification handlers
|
||||
|
||||
/// Handle `notification:list` — list a user's notifications.
|
||||
pub async fn list_notifications(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let before: Option<Uuid> = Self::parse_optional(arr, "before")?;
|
||||
let limit: Option<i64> = Self::parse_optional(arr, "limit")?;
|
||||
|
||||
let page = self.repo.list_notifications(user_id, before, limit).await?;
|
||||
let _ = socket.emit(
|
||||
"notification:loaded",
|
||||
serde_json::to_value(&page).unwrap_or_default(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `notification:mark_read` — mark one notification as read.
|
||||
pub async fn mark_notification_read(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let notification_id: Uuid = Self::parse_field(arr, "notification_id")?;
|
||||
|
||||
self.repo.mark_notification_read(notification_id).await?;
|
||||
|
||||
let unread = self.repo.get_unread_notification_count(user_id).await?;
|
||||
let _ = socket.emit(
|
||||
"notification:unread_count",
|
||||
serde_json::json!({ "count": unread }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `notification:mark_all_read` — mark all as read.
|
||||
pub async fn mark_all_notifications_read(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
_data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
|
||||
let affected = self.repo.mark_all_notifications_read(user_id).await?;
|
||||
tracing::info!(%user_id, %affected, "All notifications marked read");
|
||||
|
||||
let _ = socket.emit(
|
||||
"notification:unread_count",
|
||||
serde_json::json!({ "count": 0 }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Scheduled message dispatcher on `MessageService`.
|
||||
//!
|
||||
//! A background task that periodically scans for due scheduled messages
|
||||
//! and sends them through the normal message path.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::repo::CreateMessageInput;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Start the background scheduled-message dispatcher.
|
||||
/// Scans every 30 seconds for pending messages whose `scheduled_at` has passed.
|
||||
pub fn start_scheduled_dispatcher(self: std::sync::Arc<Self>) {
|
||||
tokio::spawn(async move {
|
||||
tracing::info!("Scheduled message dispatcher started (interval: 30s)");
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
|
||||
match self.process_due_scheduled().await {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!(count, "Dispatched scheduled messages");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Scheduled message dispatch failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch and dispatch all due scheduled messages.
|
||||
async fn process_due_scheduled(&self) -> crate::ImksResult<usize> {
|
||||
let due = self.repo.get_due_scheduled().await?;
|
||||
let mut dispatched = 0;
|
||||
|
||||
for scheduled in due {
|
||||
let input = CreateMessageInput {
|
||||
channel_id: scheduled.channel_id,
|
||||
author_id: scheduled.author_id,
|
||||
thread_id: scheduled.thread_id,
|
||||
reply_to_message_id: scheduled.reply_to_message_id,
|
||||
message_type: "text".into(),
|
||||
body: scheduled.body.clone(),
|
||||
metadata: scheduled.metadata.clone(),
|
||||
system: false,
|
||||
};
|
||||
|
||||
match self.repo.create(&input).await {
|
||||
Ok(message) => {
|
||||
self.repo
|
||||
.mark_scheduled_sent(scheduled.id, message.id)
|
||||
.await?;
|
||||
|
||||
// Broadcast to channel
|
||||
if let Some(ns) = self.namespaces.get_namespace("/") {
|
||||
ns.emit_to_room(
|
||||
&scheduled.channel_id.to_string(),
|
||||
"message:new",
|
||||
serde_json::to_value(&message).unwrap_or_default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
dispatched += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(scheduled_id = %scheduled.id, error = %e, "Failed to send scheduled message");
|
||||
self.repo
|
||||
.mark_scheduled_failed(scheduled.id, &e.to_string())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(dispatched)
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
//! Service layer unit tests for `MessageService`.
|
||||
//!
|
||||
//! Tests parsing, nonce dedup, rate limiting — all in-memory without
|
||||
//! requiring a real database or gRPC connection.
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::module_inception)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::svc::message::MessageService;
|
||||
|
||||
#[test]
|
||||
fn test_parse_field_valid() {
|
||||
let json = serde_json::json!({"message_id": "01909abc-def0-7000-8000-000000000001"});
|
||||
let id: Uuid = MessageService::parse_field(&json, "message_id").unwrap();
|
||||
assert_eq!(id.to_string(), "01909abc-def0-7000-8000-000000000001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_field_missing() {
|
||||
let json = serde_json::json!({});
|
||||
let result: crate::ImksResult<String> = MessageService::parse_field(&json, "missing");
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Missing required field")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_optional_present() {
|
||||
let json = serde_json::json!({"name": "alice"});
|
||||
let val: Option<String> = MessageService::parse_optional(&json, "name").unwrap();
|
||||
assert_eq!(val, Some("alice".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_optional_null() {
|
||||
let json = serde_json::json!({"name": null});
|
||||
let val: Option<String> = MessageService::parse_optional(&json, "name").unwrap();
|
||||
assert_eq!(val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_optional_missing() {
|
||||
let json = serde_json::json!({});
|
||||
let val: Option<String> = MessageService::parse_optional(&json, "name").unwrap();
|
||||
assert_eq!(val, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_payload_basic_shape() {
|
||||
let json = serde_json::json!([{
|
||||
"channel_id": "01909abc-def0-7000-8000-000000000001",
|
||||
"body": "hello world"
|
||||
}]);
|
||||
let arr = json.as_array().unwrap();
|
||||
let payload = &arr[0];
|
||||
assert_eq!(payload["body"], "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_send_payload_with_rich_content() {
|
||||
let json = serde_json::json!([{
|
||||
"channel_id": "01909abc-def0-7000-8000-000000000001",
|
||||
"body": "hey @alice",
|
||||
"thread_id": "01909def-abc0-7000-8000-000000000002",
|
||||
"nonce": "nonce-001",
|
||||
"mentioned_user_ids": ["01909abc-def0-7000-8000-000000000003"],
|
||||
"attachments": [{"filename": "img.png", "url": "https://cdn/img.png", "size": 1024, "content_type": "image/png"}],
|
||||
"embeds": [{"embed_type": "link", "title": "Example", "url": "https://example.com", "fields": [{"name": "k", "value": "v", "inline": true}]}],
|
||||
"sticker": {"sticker_id": "01909abc-def0-7000-8000-000000000004", "name": "Hype!", "image_url": "https://cdn/sticker.png"},
|
||||
"forward": {"source_message_id": "01909abc-def0-7000-8000-000000000005", "source_channel_id": "01909abc-def0-7000-8000-000000000006"}
|
||||
}]);
|
||||
let arr = json.as_array().unwrap();
|
||||
let payload = &arr[0];
|
||||
assert_eq!(payload["body"], "hey @alice");
|
||||
assert!(payload["attachments"].is_array());
|
||||
assert!(payload["embeds"].is_array());
|
||||
assert!(payload["sticker"].is_object());
|
||||
assert!(payload["forward"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_dedup_first_accepted() {
|
||||
use dashmap::DashMap;
|
||||
let nonces = Arc::new(DashMap::<String, Instant>::new());
|
||||
assert!(!nonces.contains_key("nonce-1"));
|
||||
nonces.insert("nonce-1".to_string(), Instant::now());
|
||||
assert!(nonces.contains_key("nonce-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_dedup_rejects_duplicate() {
|
||||
use dashmap::DashMap;
|
||||
let nonces = Arc::new(DashMap::<String, Instant>::new());
|
||||
nonces.insert("nonce-1".to_string(), Instant::now());
|
||||
// After insert, it should exist
|
||||
assert!(nonces.contains_key("nonce-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_within_window() {
|
||||
use dashmap::DashMap;
|
||||
let limits = Arc::new(DashMap::<(Uuid, Uuid), Vec<Instant>>::new());
|
||||
let user = Uuid::now_v7();
|
||||
let channel = Uuid::now_v7();
|
||||
// Should be empty initially
|
||||
assert!(limits.get(&(user, channel)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_approaches_threshold() {
|
||||
use dashmap::DashMap;
|
||||
let limits = Arc::new(DashMap::<(Uuid, Uuid), Vec<Instant>>::new());
|
||||
let now = Instant::now();
|
||||
let user = Uuid::now_v7();
|
||||
let channel = Uuid::now_v7();
|
||||
|
||||
let mut entry = limits.entry((user, channel)).or_default();
|
||||
for _ in 0..9 {
|
||||
entry.push(now);
|
||||
}
|
||||
assert_eq!(entry.len(), 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_exceeded() {
|
||||
use dashmap::DashMap;
|
||||
let limits = Arc::new(DashMap::<(Uuid, Uuid), Vec<Instant>>::new());
|
||||
let now = Instant::now();
|
||||
let user = Uuid::now_v7();
|
||||
let channel = Uuid::now_v7();
|
||||
|
||||
let mut entry = limits.entry((user, channel)).or_default();
|
||||
for _ in 0..10 {
|
||||
entry.push(now);
|
||||
}
|
||||
assert!(entry.len() >= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_window_expiry_eviction() {
|
||||
use dashmap::DashMap;
|
||||
let limits = Arc::new(DashMap::<(Uuid, Uuid), Vec<Instant>>::new());
|
||||
let window = Duration::from_secs(10);
|
||||
let user = Uuid::now_v7();
|
||||
let channel = Uuid::now_v7();
|
||||
|
||||
let old = Instant::now() - Duration::from_secs(15);
|
||||
let now = Instant::now();
|
||||
|
||||
let mut entry = limits.entry((user, channel)).or_default();
|
||||
entry.push(old);
|
||||
entry.push(now);
|
||||
entry.retain(|t| now.duration_since(*t) < window);
|
||||
|
||||
assert_eq!(entry.len(), 1);
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
//! Thread event handlers on `MessageService`.
|
||||
//!
|
||||
//! Threads are anchored by a root message. Participants are added when they
|
||||
//! reply, get mentioned, or explicitly join. Thread events broadcast to the
|
||||
//! channel room so all clients see thread activity updates.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::models::message_thread_participant::JoinReason;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `thread:create` — create a new thread anchored on a root message.
|
||||
pub async fn create_thread(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let root_message_id: Uuid = Self::parse_field(arr, "root_message_id")?;
|
||||
let channel_id: Uuid = Self::parse_field(arr, "channel_id")?;
|
||||
|
||||
let root_message = self
|
||||
.repo
|
||||
.get(root_message_id)
|
||||
.await?
|
||||
.ok_or_else(|| ImksError::NotFound(format!("message {root_message_id}")))?;
|
||||
if root_message.channel_id != channel_id {
|
||||
return Err(ImksError::InvalidInput(
|
||||
"Root message does not belong to channel".into(),
|
||||
));
|
||||
}
|
||||
|
||||
self.validate_channel_write(&channel_id.to_string(), &user_id.to_string())
|
||||
.await?;
|
||||
|
||||
let thread = self
|
||||
.repo
|
||||
.create_thread(root_message_id, channel_id, user_id)
|
||||
.await?;
|
||||
|
||||
// Creator is automatically a participant
|
||||
self.repo
|
||||
.add_thread_participant(thread.id, user_id, JoinReason::Reply.as_str())
|
||||
.await?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"thread:created",
|
||||
serde_json::to_value(&thread).unwrap_or_default(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(thread_id = %thread.id, %channel_id, %user_id, "Thread created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `thread:resolve` — toggle the resolved state of a thread.
|
||||
pub async fn resolve_thread(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let thread_id: Uuid = Self::parse_field(arr, "thread_id")?;
|
||||
let resolved: bool = Self::parse_field(arr, "resolved")?;
|
||||
let thread = self
|
||||
.repo
|
||||
.get_thread(thread_id)
|
||||
.await?
|
||||
.ok_or_else(|| ImksError::NotFound(format!("thread {thread_id}")))?;
|
||||
let channel_id = thread.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?;
|
||||
self.ensure_author_or_mod(thread.created_by, &channel_id_str, user_id)
|
||||
.await?;
|
||||
|
||||
self.repo
|
||||
.resolve_thread(thread_id, user_id, resolved)
|
||||
.await?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"thread:updated",
|
||||
serde_json::json!({
|
||||
"thread_id": thread_id.to_string(),
|
||||
"resolved": resolved,
|
||||
"resolved_by": user_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(%thread_id, %resolved, %user_id, "Thread resolve toggled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `thread:join` — explicitly join a thread.
|
||||
pub async fn join_thread(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let thread_id: Uuid = Self::parse_field(arr, "thread_id")?;
|
||||
let thread = self
|
||||
.repo
|
||||
.get_thread(thread_id)
|
||||
.await?
|
||||
.ok_or_else(|| ImksError::NotFound(format!("thread {thread_id}")))?;
|
||||
let channel_id = thread.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?;
|
||||
|
||||
self.repo
|
||||
.add_thread_participant(thread_id, user_id, JoinReason::Joined.as_str())
|
||||
.await?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"thread:participant_joined",
|
||||
serde_json::json!({
|
||||
"thread_id": thread_id.to_string(),
|
||||
"user_id": user_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(%thread_id, %user_id, "User joined thread");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `thread:leave` — leave a thread.
|
||||
pub async fn leave_thread(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let thread_id: Uuid = Self::parse_field(arr, "thread_id")?;
|
||||
let thread = self
|
||||
.repo
|
||||
.get_thread(thread_id)
|
||||
.await?
|
||||
.ok_or_else(|| ImksError::NotFound(format!("thread {thread_id}")))?;
|
||||
let channel_id = thread.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?;
|
||||
|
||||
self.repo
|
||||
.remove_thread_participant(thread_id, user_id)
|
||||
.await?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"thread:participant_left",
|
||||
serde_json::json!({
|
||||
"thread_id": thread_id.to_string(),
|
||||
"user_id": user_id.to_string(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(%thread_id, %user_id, "User left thread");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `thread:list` — list threads in a channel.
|
||||
pub async fn list_threads(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let channel_id: Uuid = Self::parse_field(arr, "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 threads = self.repo.list_threads(channel_id).await?;
|
||||
let _ = socket.emit(
|
||||
"thread:loaded",
|
||||
serde_json::to_value(&threads).unwrap_or_default(),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
//! Typing indicator and presence event handlers on `MessageService`.
|
||||
//!
|
||||
//! These are pure broadcast events (no persistence). Typing indicators show
|
||||
//! "user is typing…" in the channel. Presence indicates online/offline status.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ImksError;
|
||||
use crate::socket::socket::Socket;
|
||||
|
||||
use super::message::MessageService;
|
||||
|
||||
impl MessageService {
|
||||
/// Handle `typing:start` — broadcast to the channel room.
|
||||
pub async fn typing_start(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let channel_id: Uuid = self.parse_channel_id(data)?;
|
||||
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?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"typing",
|
||||
serde_json::json!({
|
||||
"channel_id": channel_id.to_string(),
|
||||
"user_id": user_id.to_string(),
|
||||
"typing": true,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `typing:stop` — broadcast to the channel room.
|
||||
pub async fn typing_stop(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let channel_id: Uuid = self.parse_channel_id(data)?;
|
||||
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?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"typing",
|
||||
serde_json::json!({
|
||||
"channel_id": channel_id.to_string(),
|
||||
"user_id": user_id.to_string(),
|
||||
"typing": false,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle `presence:update` — broadcast online status to all shared channels.
|
||||
/// In a full implementation this would track which channels a user is in
|
||||
/// and broadcast to all of them. For now it broadcasts to the specified channel.
|
||||
pub async fn presence_update(
|
||||
&self,
|
||||
socket: Arc<Socket>,
|
||||
data: &serde_json::Value,
|
||||
) -> crate::ImksResult<()> {
|
||||
let user_id = self.user_id(&socket)?;
|
||||
let channel_id: Uuid = self.parse_channel_id(data)?;
|
||||
let online: bool =
|
||||
Self::parse_optional(Self::first_payload(data)?, "online")?.unwrap_or(true);
|
||||
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?;
|
||||
|
||||
if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) {
|
||||
ns.emit_to_room(
|
||||
&channel_id.to_string(),
|
||||
"presence:update",
|
||||
serde_json::json!({
|
||||
"user_id": user_id.to_string(),
|
||||
"online": online,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_channel_id(&self, data: &serde_json::Value) -> crate::ImksResult<Uuid> {
|
||||
let arr = data
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?;
|
||||
Self::parse_field(arr, "channel_id")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user