//! Scheduled message handler on `MessageService`. //! //! Provides: //! - Client-facing CRUD: schedule, cancel, list pending scheduled messages //! - Background dispatcher: periodically scans for due scheduled messages //! and sends them through the normal message path. use std::sync::Arc; use std::time::Duration; use chrono::{DateTime, Utc}; use uuid::Uuid; use crate::repo::CreateMessageInput; use crate::socket::socket::Socket; use crate::{ImksError, ImksResult}; use super::message::MessageService; impl MessageService { // ── Client-facing scheduled message CRUD ── /// Handle `message:schedule` — schedule a message to be sent at a future time. pub async fn schedule_message( &self, socket: Arc, 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 body: String = Self::parse_field(payload, "body")?; let thread_id: Option = Self::parse_optional(payload, "thread_id")?; let reply_to_message_id: Option = Self::parse_optional(payload, "reply_to_message_id")?; let metadata: Option = Self::parse_optional(payload, "metadata")?; let scheduled_at_str: String = Self::parse_field(payload, "scheduled_at")?; let scheduled_at: DateTime = chrono::DateTime::parse_from_rfc3339(&scheduled_at_str) .map_err(|e| ImksError::InvalidInput(format!("Invalid scheduled_at: {e}")))? .into(); let channel_id_str = channel_id.to_string(); let user_id_str = user_id.to_string(); self.validate_body_size(&body)?; self.ensure_readable(&channel_id_str, &user_id_str).await?; self.ensure_member(&channel_id_str, &user_id_str).await?; // Validate scheduled_at is in the future if scheduled_at <= Utc::now() { return Err(ImksError::InvalidInput( "scheduled_at must be in the future".into(), )); } let scheduled = self .repo .schedule_message( channel_id, user_id, thread_id, reply_to_message_id, &body, metadata, scheduled_at, ) .await?; tracing::info!( scheduled_id = %scheduled.id, channel_id = %channel_id, user_id = %user_id, scheduled_at = %scheduled_at, "Message scheduled" ); Ok(()) } /// Handle `message:cancel_scheduled` — cancel a pending scheduled message. pub async fn cancel_scheduled( &self, socket: Arc, data: &serde_json::Value, ) -> ImksResult<()> { let user_id = self.user_id(&socket)?; let payload = Self::first_payload(data)?; let scheduled_id: Uuid = Self::parse_field(payload, "scheduled_id")?; let cancelled = self.repo.cancel_scheduled(scheduled_id).await?; if !cancelled { return Err(ImksError::NotFound(format!( "scheduled message {scheduled_id} not found or already processed" ))); } tracing::info!(%scheduled_id, %user_id, "Scheduled message cancelled"); Ok(()) } /// Handle `message:list_scheduled` — list pending scheduled messages for a channel. pub async fn list_scheduled( &self, socket: Arc, 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?; let scheduled = self.repo.list_scheduled(channel_id, user_id).await?; let _ = socket.emit( "scheduled:loaded", serde_json::to_value(&scheduled).unwrap_or_default(), ); Ok(()) } // ── Background dispatcher ── /// 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) { 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 { 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?; 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) } }