Files
imks/svc/scheduled.rs
T
zhenyi 0dbac480ae feat(telemetry): integrate OpenTelemetry observability stack with health metrics
- Add OpenTelemetry SDK, OTLP exporter, Prometheus integration
- Implement connection tracking with active/total/disconnection metrics
- Add health endpoint with uptime and connection counts
- Integrate tracing spans for socket events and engine messages
- Add metrics collection for event handling duration
- Update health endpoint to include live runtime state
- Add graceful telemetry shutdown in main function
- Implement engine session active metrics tracking
- Add namespace-specific attributes to connection metrics
- Introduce message edit history retrieval endpoint
- Add scheduled message CRUD operations and dispatcher
- Update Socket.IO event registration with observability
- Refactor component update to remove dead code allowance
- Add comprehensive environment variables documentation
- Implement detailed development guidelines in AGENTS.md
2026-06-11 13:53:29 +08:00

197 lines
6.7 KiB
Rust

//! 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<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 body: String = Self::parse_field(payload, "body")?;
let thread_id: Option<Uuid> = Self::parse_optional(payload, "thread_id")?;
let reply_to_message_id: Option<Uuid> =
Self::parse_optional(payload, "reply_to_message_id")?;
let metadata: Option<serde_json::Value> =
Self::parse_optional(payload, "metadata")?;
let scheduled_at_str: String = Self::parse_field(payload, "scheduled_at")?;
let scheduled_at: DateTime<Utc> = 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<Socket>,
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<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?;
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<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?;
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)
}
}