Skip to main content

ironclad_channels/
lib.rs

1//! # ironclad-channels
2//!
3//! Channel adapters for user-facing chat platforms and the zero-trust
4//! agent-to-agent (A2A) communication protocol. All adapters implement the
5//! [`ChannelAdapter`] trait for unified message handling.
6//!
7//! ## Key Types
8//!
9//! - [`ChannelAdapter`] -- Async trait: `recv()`, `send()`, `platform_name()`
10//! - [`InboundMessage`] -- Normalized inbound message from any platform
11//! - [`OutboundMessage`] -- Normalized outbound message for any platform
12//!
13//! ## Modules
14//!
15//! - `telegram` -- Telegram Bot API (long-poll + webhook, Markdown V2)
16//! - `whatsapp` -- WhatsApp Cloud API (webhook, message templates)
17//! - `discord` -- Discord Gateway + REST API (slash commands, rich embeds)
18//! - `signal` -- Signal Protocol via signal-cli daemon (JSON-RPC)
19//! - `web` -- WebSocket interface (axum, JSON frames, ping/pong)
20//! - `voice` -- Voice channel (WebRTC, STT, TTS)
21//! - `email` -- Email adapter (IMAP listener + SMTP sender)
22//! - `a2a` -- Zero-trust A2A protocol (ECDH key exchange, AES-256-GCM)
23//! - `router` -- Multi-channel message routing and dispatch
24//! - `delivery` -- Outbound delivery queue with retry logic
25//! - `filter` -- Addressability filter (per-channel routing rules)
26
27pub 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// ── Multimodal attachment types ─────────────────────────────────────────
50
51/// Classification of a media attachment received from any channel.
52#[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    /// Infer media type from a MIME content-type string.
63    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/// A media attachment received from a channel adapter.
78///
79/// Stored as JSON inside `InboundMessage.metadata["attachments"]` for full
80/// backward compatibility — no changes to trait signatures or struct fields.
81#[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
97/// Maximum allowed length for the `platform` field on [`InboundMessage`].
98const MAX_PLATFORM_LEN: usize = 64;
99
100/// Strip control characters and truncate to `MAX_PLATFORM_LEN` bytes.
101/// Callers constructing an [`InboundMessage`] should pass the platform name
102/// through this function to ensure it contains only printable characters and
103/// stays within a reasonable length.
104pub 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        // Truncate to MAX_PLATFORM_LEN bytes at a char boundary
110        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    /// Sanitize fields that accept untrusted input.
130    /// Currently normalizes `platform` (strips control chars, caps length).
131    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    // 9C: Edge cases — oversized message, empty message, special chars in platform
186    #[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    // Phase 4K: Oversized message (>100KB) handled gracefully
236    #[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    // Phase 4K: Empty message platform name works
286    #[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}