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:
zhenyi
2026-06-11 12:11:05 +08:00
parent 06e8ee96a5
commit 821537186e
111 changed files with 10458 additions and 385 deletions
+221
View File
@@ -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(())
}
}
+75
View File
@@ -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(())
}
}
+98
View File
@@ -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(())
}
}
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))
}
}
+126
View File
@@ -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(())
}
}
+80
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
}