//! Core message model — maps to PostgreSQL `message` table. //! //! Discord-style: `body` holds plain text / markdown, rich content lives in //! companion tables (attachment, embed, poll, reaction, mention). //! IDs are UUID v7 (time-ordered) so `ORDER BY id` = chronological order. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Discriminator for system / event messages vs. regular user messages. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum MessageType { /// Regular user message (text / markdown). #[default] Text, /// System-generated notice (e.g. "user joined the channel"). System, /// Channel event (pinned a message, changed topic, etc.). Event, /// Forum article / long-form post displayed as waterfall cards. Article, } impl MessageType { pub fn as_str(&self) -> &'static str { match self { Self::Text => "text", Self::System => "system", Self::Event => "event", Self::Article => "article", } } pub fn from_str_lossy(s: &str) -> Self { match s { "system" => Self::System, "event" => Self::Event, "article" => Self::Article, _ => Self::Text, } } } impl std::fmt::Display for MessageType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// Direct mapping of the `message` table row. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct Message { /// UUID v7 — time-ordered primary key. pub id: Uuid, pub channel_id: Uuid, pub author_id: Uuid, /// Thread this message belongs to (NULL = not threaded). pub thread_id: Option, /// Direct reply reference (NULL = top-level message). pub reply_to_message_id: Option, /// "text" | "system" | "event" pub message_type: String, /// Plain text or markdown body. pub body: String, /// Extensible metadata (flags, locale, interaction ref, etc.). pub metadata: Option, pub pinned: bool, /// True for bot / system generated messages. pub system: bool, pub edited_at: Option>, pub deleted_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } /// Lightweight author info embedded in [`MessageDetail`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthorInfo { pub id: Uuid, pub username: String, pub display_name: Option, pub avatar_url: Option, pub is_bot: bool, } /// Message with resolved author and reaction/attachment aggregates. /// Returned by read APIs; never stored directly. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MessageDetail { #[serde(flatten)] pub message: Message, pub author: AuthorInfo, /// Aggregated reaction counts: `{ "👍": 3, "🎉": 1 }`. pub reactions: std::collections::HashMap, pub attachment_count: i64, pub embed_count: i64, /// Whether the current user has bookmarked this message. pub bookmarked: bool, /// Reply chain depth (0 = top-level). pub reply_depth: i32, } /// Generate a new UUID v7 (time-ordered) for message IDs. pub fn new_message_id() -> Uuid { Uuid::now_v7() } #[cfg(test)] mod tests { use super::*; #[test] fn test_message_type_roundtrip() { let t = MessageType::Text; assert_eq!(t.as_str(), "text"); assert_eq!(MessageType::from_str_lossy("text"), MessageType::Text); assert_eq!(MessageType::from_str_lossy("system"), MessageType::System); assert_eq!(MessageType::from_str_lossy("unknown"), MessageType::Text); } #[test] fn test_message_id_ordering() { // UUID v7 IDs generated later should sort after earlier ones. let a = new_message_id(); std::thread::sleep(std::time::Duration::from_millis(2)); let b = new_message_id(); assert!(b > a, "UUID v7 should be time-ordered"); } #[test] fn test_message_detail_serialize() { let msg = Message { id: Uuid::now_v7(), channel_id: Uuid::now_v7(), author_id: Uuid::now_v7(), thread_id: None, reply_to_message_id: None, message_type: "text".to_string(), body: "hello world".to_string(), metadata: None, pinned: false, system: false, edited_at: None, deleted_at: None, created_at: Utc::now(), updated_at: Utc::now(), }; let detail = MessageDetail { message: msg, author: AuthorInfo { id: Uuid::now_v7(), username: "alice".to_string(), display_name: Some("Alice".to_string()), avatar_url: None, is_bot: false, }, reactions: std::collections::HashMap::from([ ("👍".to_string(), 3), ("🎉".to_string(), 1), ]), attachment_count: 0, embed_count: 0, bookmarked: false, reply_depth: 0, }; let json = serde_json::to_value(&detail).unwrap(); assert_eq!(json["body"], "hello world"); assert_eq!(json["author"]["username"], "alice"); assert_eq!(json["reactions"]["👍"], 3); } }