Skip to main content

brainwires_channels/
message.rs

1//! Core message types for channel communication.
2
3use chrono::{DateTime, Utc};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use crate::identity::ConversationId;
9
10/// A unique message identifier.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
12pub struct MessageId(pub String);
13
14impl MessageId {
15    /// Create a new `MessageId` from a string.
16    pub fn new(id: impl Into<String>) -> Self {
17        Self(id.into())
18    }
19}
20
21impl std::fmt::Display for MessageId {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        write!(f, "{}", self.0)
24    }
25}
26
27/// A unique thread identifier.
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
29pub struct ThreadId(pub String);
30
31impl ThreadId {
32    /// Create a new `ThreadId` from a string.
33    pub fn new(id: impl Into<String>) -> Self {
34        Self(id.into())
35    }
36}
37
38impl std::fmt::Display for ThreadId {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", self.0)
41    }
42}
43
44/// A message sent or received on a messaging channel.
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct ChannelMessage {
47    /// Unique identifier for this message.
48    pub id: MessageId,
49    /// The conversation this message belongs to.
50    pub conversation: ConversationId,
51    /// The author of the message (display name or user identifier).
52    pub author: String,
53    /// The message content.
54    pub content: MessageContent,
55    /// Optional thread this message belongs to.
56    pub thread_id: Option<ThreadId>,
57    /// Optional message this is a reply to.
58    pub reply_to: Option<MessageId>,
59    /// When the message was created.
60    pub timestamp: DateTime<Utc>,
61    /// File or media attachments.
62    pub attachments: Vec<Attachment>,
63    /// Arbitrary key-value metadata.
64    pub metadata: HashMap<String, String>,
65}
66
67/// The content of a channel message.
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub enum MessageContent {
70    /// Plain text content.
71    Text(String),
72    /// Rich text with markdown and a plain-text fallback.
73    RichText {
74        /// Markdown-formatted content.
75        markdown: String,
76        /// Plain-text fallback for channels that don't support markdown.
77        fallback_plain: String,
78    },
79    /// A media payload (image, video, audio, etc.).
80    Media(MediaPayload),
81    /// An embedded rich card.
82    Embed(EmbedPayload),
83    /// A combination of multiple content items.
84    Mixed(Vec<MessageContent>),
85}
86
87/// A file or media attachment on a message.
88#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
89pub struct Attachment {
90    /// The filename of the attachment.
91    pub filename: String,
92    /// MIME content type (e.g., "image/png").
93    pub content_type: String,
94    /// URL where the attachment can be downloaded.
95    pub url: String,
96    /// Size of the attachment in bytes, if known.
97    pub size_bytes: Option<u64>,
98}
99
100/// A media payload embedded in a message.
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
102pub struct MediaPayload {
103    /// The type of media.
104    pub media_type: MediaType,
105    /// URL of the media resource.
106    pub url: String,
107    /// Optional caption for the media.
108    pub caption: Option<String>,
109    /// Optional thumbnail URL.
110    pub thumbnail_url: Option<String>,
111}
112
113/// The type of media in a `MediaPayload`.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115pub enum MediaType {
116    /// A still image.
117    Image,
118    /// A video clip.
119    Video,
120    /// An audio recording.
121    Audio,
122    /// A document file (PDF, Word, etc.).
123    Document,
124    /// A sticker.
125    Sticker,
126    /// An animated GIF.
127    GIF,
128}
129
130/// An embedded rich card in a message.
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132pub struct EmbedPayload {
133    /// Title of the embed.
134    pub title: Option<String>,
135    /// Description text.
136    pub description: Option<String>,
137    /// URL the embed links to.
138    pub url: Option<String>,
139    /// Color of the embed sidebar (as a hex integer, e.g., 0xFF5733).
140    pub color: Option<u32>,
141    /// Structured fields within the embed.
142    pub fields: Vec<EmbedField>,
143    /// Thumbnail URL for the embed.
144    pub thumbnail: Option<String>,
145    /// Footer text for the embed.
146    pub footer: Option<String>,
147}
148
149/// A single field within an `EmbedPayload`.
150#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
151pub struct EmbedField {
152    /// The field name/title.
153    pub name: String,
154    /// The field value.
155    pub value: String,
156    /// Whether this field should be displayed inline.
157    pub inline: bool,
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use std::collections::HashMap;
164
165    fn sample_message() -> ChannelMessage {
166        ChannelMessage {
167            id: MessageId::new("msg-001"),
168            conversation: ConversationId {
169                platform: "discord".to_string(),
170                channel_id: "general".to_string(),
171                server_id: Some("srv-1".to_string()),
172            },
173            author: "alice".to_string(),
174            content: MessageContent::Text("Hello, world!".to_string()),
175            thread_id: None,
176            reply_to: None,
177            timestamp: Utc::now(),
178            attachments: vec![],
179            metadata: HashMap::new(),
180        }
181    }
182
183    #[test]
184    fn channel_message_serde_roundtrip() {
185        let msg = sample_message();
186        let json = serde_json::to_string(&msg).unwrap();
187        let deserialized: ChannelMessage = serde_json::from_str(&json).unwrap();
188        assert_eq!(deserialized.id, msg.id);
189        assert_eq!(deserialized.author, msg.author);
190    }
191
192    #[test]
193    fn rich_text_serde_roundtrip() {
194        let content = MessageContent::RichText {
195            markdown: "**bold**".to_string(),
196            fallback_plain: "bold".to_string(),
197        };
198        let json = serde_json::to_string(&content).unwrap();
199        let deserialized: MessageContent = serde_json::from_str(&json).unwrap();
200        match deserialized {
201            MessageContent::RichText {
202                markdown,
203                fallback_plain,
204            } => {
205                assert_eq!(markdown, "**bold**");
206                assert_eq!(fallback_plain, "bold");
207            }
208            _ => panic!("expected RichText variant"),
209        }
210    }
211
212    #[test]
213    fn mixed_content_serde_roundtrip() {
214        let content = MessageContent::Mixed(vec![
215            MessageContent::Text("check this out".to_string()),
216            MessageContent::Media(MediaPayload {
217                media_type: MediaType::Image,
218                url: "https://example.com/image.png".to_string(),
219                caption: Some("A cool image".to_string()),
220                thumbnail_url: None,
221            }),
222        ]);
223        let json = serde_json::to_string(&content).unwrap();
224        let deserialized: MessageContent = serde_json::from_str(&json).unwrap();
225        match deserialized {
226            MessageContent::Mixed(items) => assert_eq!(items.len(), 2),
227            _ => panic!("expected Mixed variant"),
228        }
229    }
230
231    #[test]
232    fn embed_serde_roundtrip() {
233        let embed = EmbedPayload {
234            title: Some("Title".to_string()),
235            description: Some("Description".to_string()),
236            url: Some("https://example.com".to_string()),
237            color: Some(0xFF5733),
238            fields: vec![EmbedField {
239                name: "Field".to_string(),
240                value: "Value".to_string(),
241                inline: true,
242            }],
243            thumbnail: None,
244            footer: Some("Footer".to_string()),
245        };
246        let json = serde_json::to_string(&embed).unwrap();
247        let deserialized: EmbedPayload = serde_json::from_str(&json).unwrap();
248        assert_eq!(deserialized.title, embed.title);
249        assert_eq!(deserialized.fields.len(), 1);
250    }
251
252    #[test]
253    fn attachment_serde_roundtrip() {
254        let att = Attachment {
255            filename: "report.pdf".to_string(),
256            content_type: "application/pdf".to_string(),
257            url: "https://example.com/report.pdf".to_string(),
258            size_bytes: Some(1024),
259        };
260        let json = serde_json::to_string(&att).unwrap();
261        let deserialized: Attachment = serde_json::from_str(&json).unwrap();
262        assert_eq!(deserialized.filename, "report.pdf");
263    }
264}