1pub mod a2a;
28pub mod delivery;
29pub mod discord;
30pub mod email;
31pub mod filter;
32pub mod formatter;
33pub mod media;
34pub mod router;
35pub mod signal;
36pub mod telegram;
37pub mod voice;
38pub mod web;
39pub mod whatsapp;
40
41use std::path::PathBuf;
42
43use async_trait::async_trait;
44use chrono::{DateTime, Utc};
45use ironclad_core::Result;
46use serde::{Deserialize, Serialize};
47use serde_json::Value;
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum MediaType {
55 Image,
56 Audio,
57 Video,
58 Document,
59}
60
61impl MediaType {
62 pub fn from_content_type(ct: &str) -> Self {
64 let ct_lower = ct.to_ascii_lowercase();
65 if ct_lower.starts_with("image/") {
66 Self::Image
67 } else if ct_lower.starts_with("audio/") {
68 Self::Audio
69 } else if ct_lower.starts_with("video/") {
70 Self::Video
71 } else {
72 Self::Document
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct MediaAttachment {
83 pub media_type: MediaType,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub source_url: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub local_path: Option<PathBuf>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub filename: Option<String>,
90 pub content_type: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub size_bytes: Option<usize>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub caption: Option<String>,
95}
96
97const MAX_PLATFORM_LEN: usize = 64;
99
100pub fn sanitize_platform(raw: &str) -> String {
105 let cleaned: String = raw.chars().filter(|c| !c.is_control()).collect();
106 if cleaned.len() <= MAX_PLATFORM_LEN {
107 cleaned
108 } else {
109 let mut end = MAX_PLATFORM_LEN;
111 while end > 0 && !cleaned.is_char_boundary(end) {
112 end -= 1;
113 }
114 cleaned[..end].to_string()
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct InboundMessage {
120 pub id: String,
121 pub platform: String,
122 pub sender_id: String,
123 pub content: String,
124 pub timestamp: DateTime<Utc>,
125 pub metadata: Option<Value>,
126}
127
128impl InboundMessage {
129 pub fn sanitize(&mut self) {
132 self.platform = sanitize_platform(&self.platform);
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct OutboundMessage {
138 pub content: String,
139 pub recipient_id: String,
140 pub metadata: Option<Value>,
141}
142
143#[async_trait]
144pub trait ChannelAdapter: Send + Sync {
145 fn platform_name(&self) -> &str;
146 async fn recv(&self) -> Result<Option<InboundMessage>>;
147 async fn send(&self, msg: OutboundMessage) -> Result<()>;
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn inbound_message_roundtrip() {
156 let msg = InboundMessage {
157 id: "msg-1".into(),
158 platform: "test".into(),
159 sender_id: "user-42".into(),
160 content: "hello".into(),
161 timestamp: Utc::now(),
162 metadata: Some(serde_json::json!({"key": "value"})),
163 };
164 let json = serde_json::to_string(&msg).unwrap();
165 let decoded: InboundMessage = serde_json::from_str(&json).unwrap();
166 assert_eq!(decoded.id, "msg-1");
167 assert_eq!(decoded.platform, "test");
168 assert_eq!(decoded.content, "hello");
169 }
170
171 #[test]
172 fn outbound_message_serialization() {
173 let msg = OutboundMessage {
174 content: "response".into(),
175 recipient_id: "user-42".into(),
176 metadata: None,
177 };
178 let json = serde_json::to_string(&msg).unwrap();
179 let decoded: OutboundMessage = serde_json::from_str(&json).unwrap();
180 assert_eq!(decoded.content, "response");
181 assert_eq!(decoded.recipient_id, "user-42");
182 assert!(decoded.metadata.is_none());
183 }
184
185 #[test]
187 fn inbound_message_oversized_content() {
188 let large = "x".repeat(11_000);
189 let msg = InboundMessage {
190 id: "big-1".into(),
191 platform: "telegram".into(),
192 sender_id: "u1".into(),
193 content: large.clone(),
194 timestamp: Utc::now(),
195 metadata: None,
196 };
197 assert_eq!(msg.content.len(), 11_000);
198 let json = serde_json::to_string(&msg).unwrap();
199 let decoded: InboundMessage = serde_json::from_str(&json).unwrap();
200 assert_eq!(decoded.content.len(), 11_000);
201 }
202
203 #[test]
204 fn inbound_message_empty_content() {
205 let msg = InboundMessage {
206 id: "empty-1".into(),
207 platform: "discord".into(),
208 sender_id: "u1".into(),
209 content: String::new(),
210 timestamp: Utc::now(),
211 metadata: None,
212 };
213 assert!(msg.content.is_empty());
214 let json = serde_json::to_string(&msg).unwrap();
215 let decoded: InboundMessage = serde_json::from_str(&json).unwrap();
216 assert!(decoded.content.is_empty());
217 }
218
219 #[test]
220 fn inbound_message_special_chars_in_platform() {
221 let msg = InboundMessage {
222 id: "spec-1".into(),
223 platform: "telegram\n<script>".into(),
224 sender_id: "u1".into(),
225 content: "hi".into(),
226 timestamp: Utc::now(),
227 metadata: None,
228 };
229 assert!(msg.platform.contains('\n'));
230 let json = serde_json::to_string(&msg).unwrap();
231 let decoded: InboundMessage = serde_json::from_str(&json).unwrap();
232 assert_eq!(decoded.platform, "telegram\n<script>");
233 }
234
235 #[test]
237 fn inbound_message_oversized_100kb_handled_gracefully() {
238 let oversized = "x".repeat(100 * 1024 + 1);
239 let msg = InboundMessage {
240 id: "oversized-1".into(),
241 platform: "web".into(),
242 sender_id: "u1".into(),
243 content: oversized.clone(),
244 timestamp: Utc::now(),
245 metadata: None,
246 };
247 assert!(msg.content.len() > 100 * 1024);
248 let json = serde_json::to_string(&msg).unwrap();
249 let decoded: InboundMessage = serde_json::from_str(&json).unwrap();
250 assert_eq!(decoded.content.len(), msg.content.len());
251 }
252
253 #[test]
254 fn sanitize_platform_strips_control_chars() {
255 assert_eq!(sanitize_platform("telegram\n<script>"), "telegram<script>");
256 assert_eq!(sanitize_platform("ok\x00bad\x1F"), "okbad");
257 }
258
259 #[test]
260 fn sanitize_platform_truncates_long_input() {
261 let long = "a".repeat(200);
262 assert_eq!(sanitize_platform(&long).len(), MAX_PLATFORM_LEN);
263 }
264
265 #[test]
266 fn sanitize_platform_passes_clean_input() {
267 assert_eq!(sanitize_platform("whatsapp"), "whatsapp");
268 assert_eq!(sanitize_platform(""), "");
269 }
270
271 #[test]
272 fn inbound_message_sanitize_method() {
273 let mut msg = InboundMessage {
274 id: "s-1".into(),
275 platform: "bad\x00name\nhere".into(),
276 sender_id: "u1".into(),
277 content: "hi".into(),
278 timestamp: Utc::now(),
279 metadata: None,
280 };
281 msg.sanitize();
282 assert_eq!(msg.platform, "badnamehere");
283 }
284
285 #[test]
287 fn inbound_message_empty_platform_name_works() {
288 let msg = InboundMessage {
289 id: "ep-1".into(),
290 platform: String::new(),
291 sender_id: "u1".into(),
292 content: "hello".into(),
293 timestamp: Utc::now(),
294 metadata: None,
295 };
296 assert!(msg.platform.is_empty());
297 let json = serde_json::to_string(&msg).unwrap();
298 let decoded: InboundMessage = serde_json::from_str(&json).unwrap();
299 assert_eq!(decoded.platform, "");
300 assert_eq!(decoded.content, "hello");
301 }
302
303 #[test]
304 fn sanitize_platform_only_control_chars() {
305 assert_eq!(sanitize_platform("\x00\x01\x02\n\r\t"), "");
306 }
307
308 #[test]
309 fn sanitize_platform_mixed_control_and_printable() {
310 assert_eq!(sanitize_platform("te\x00le\ngr\x01am"), "telegram");
311 }
312
313 #[test]
314 fn sanitize_platform_exact_max_len() {
315 let exact = "a".repeat(MAX_PLATFORM_LEN);
316 assert_eq!(sanitize_platform(&exact).len(), MAX_PLATFORM_LEN);
317 }
318
319 #[test]
320 fn sanitize_platform_one_over_max_len() {
321 let over = "a".repeat(MAX_PLATFORM_LEN + 1);
322 assert_eq!(sanitize_platform(&over).len(), MAX_PLATFORM_LEN);
323 }
324
325 #[test]
326 fn inbound_message_sanitize_long_platform() {
327 let mut msg = InboundMessage {
328 id: "s-2".into(),
329 platform: "x".repeat(200),
330 sender_id: "u1".into(),
331 content: "hi".into(),
332 timestamp: Utc::now(),
333 metadata: None,
334 };
335 msg.sanitize();
336 assert_eq!(msg.platform.len(), MAX_PLATFORM_LEN);
337 }
338
339 #[test]
340 fn outbound_message_with_metadata() {
341 let msg = OutboundMessage {
342 content: "reply".into(),
343 recipient_id: "user-1".into(),
344 metadata: Some(serde_json::json!({"thread_id": "t1"})),
345 };
346 let json = serde_json::to_string(&msg).unwrap();
347 let decoded: OutboundMessage = serde_json::from_str(&json).unwrap();
348 assert_eq!(decoded.metadata.unwrap()["thread_id"], "t1");
349 }
350
351 #[test]
352 fn inbound_message_clone() {
353 let msg = InboundMessage {
354 id: "c-1".into(),
355 platform: "test".into(),
356 sender_id: "u1".into(),
357 content: "cloneable".into(),
358 timestamp: Utc::now(),
359 metadata: Some(serde_json::json!({"key": "val"})),
360 };
361 let cloned = msg.clone();
362 assert_eq!(cloned.id, msg.id);
363 assert_eq!(cloned.content, msg.content);
364 assert_eq!(cloned.metadata, msg.metadata);
365 }
366
367 #[test]
368 fn inbound_message_debug() {
369 let msg = InboundMessage {
370 id: "d-1".into(),
371 platform: "test".into(),
372 sender_id: "u1".into(),
373 content: "debug".into(),
374 timestamp: Utc::now(),
375 metadata: None,
376 };
377 let debug = format!("{:?}", msg);
378 assert!(debug.contains("d-1"));
379 assert!(debug.contains("debug"));
380 }
381
382 #[test]
383 fn outbound_message_clone() {
384 let msg = OutboundMessage {
385 content: "out".into(),
386 recipient_id: "r1".into(),
387 metadata: None,
388 };
389 let cloned = msg.clone();
390 assert_eq!(cloned.content, "out");
391 assert_eq!(cloned.recipient_id, "r1");
392 }
393}