1use chrono::{DateTime, Utc};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use crate::identity::ConversationId;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
12pub struct MessageId(pub String);
13
14impl MessageId {
15 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
29pub struct ThreadId(pub String);
30
31impl ThreadId {
32 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct ChannelMessage {
47 pub id: MessageId,
49 pub conversation: ConversationId,
51 pub author: String,
53 pub content: MessageContent,
55 pub thread_id: Option<ThreadId>,
57 pub reply_to: Option<MessageId>,
59 pub timestamp: DateTime<Utc>,
61 pub attachments: Vec<Attachment>,
63 pub metadata: HashMap<String, String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub enum MessageContent {
70 Text(String),
72 RichText {
74 markdown: String,
76 fallback_plain: String,
78 },
79 Media(MediaPayload),
81 Embed(EmbedPayload),
83 Mixed(Vec<MessageContent>),
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
89pub struct Attachment {
90 pub filename: String,
92 pub content_type: String,
94 pub url: String,
96 pub size_bytes: Option<u64>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
102pub struct MediaPayload {
103 pub media_type: MediaType,
105 pub url: String,
107 pub caption: Option<String>,
109 pub thumbnail_url: Option<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115pub enum MediaType {
116 Image,
118 Video,
120 Audio,
122 Document,
124 Sticker,
126 GIF,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132pub struct EmbedPayload {
133 pub title: Option<String>,
135 pub description: Option<String>,
137 pub url: Option<String>,
139 pub color: Option<u32>,
141 pub fields: Vec<EmbedField>,
143 pub thumbnail: Option<String>,
145 pub footer: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
151pub struct EmbedField {
152 pub name: String,
154 pub value: String,
156 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}