Skip to main content

clawft_types/
event.rs

1//! Message event types for the channel bus.
2//!
3//! [`InboundMessage`] represents user input arriving from a channel,
4//! while [`OutboundMessage`] represents agent responses heading back out.
5
6use std::collections::HashMap;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// An inbound message received from a chat channel.
12///
13/// Carries the raw user input plus channel-specific metadata.
14/// Use [`session_key`](InboundMessage::session_key) to derive a
15/// stable session identifier from the channel + chat_id pair.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct InboundMessage {
18    /// Channel name (e.g. "telegram", "slack", "discord").
19    pub channel: String,
20
21    /// Sender identifier within the channel.
22    pub sender_id: String,
23
24    /// Chat / conversation identifier within the channel.
25    pub chat_id: String,
26
27    /// Message text content.
28    pub content: String,
29
30    /// When the message was received.
31    #[serde(default = "Utc::now")]
32    pub timestamp: DateTime<Utc>,
33
34    /// URLs or identifiers for attached media.
35    #[serde(default)]
36    pub media: Vec<String>,
37
38    /// Arbitrary channel-specific metadata.
39    #[serde(default)]
40    pub metadata: HashMap<String, serde_json::Value>,
41}
42
43impl InboundMessage {
44    /// Unique key for session identification: `"{channel}:{chat_id}"`.
45    pub fn session_key(&self) -> String {
46        format!("{}:{}", self.channel, self.chat_id)
47    }
48}
49
50/// An outbound message to send to a chat channel.
51///
52/// Produced by the agent pipeline and dispatched to the
53/// appropriate channel adapter.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct OutboundMessage {
56    /// Target channel name.
57    pub channel: String,
58
59    /// Target chat / conversation identifier.
60    pub chat_id: String,
61
62    /// Message text content.
63    pub content: String,
64
65    /// Optional message ID to reply to.
66    #[serde(default)]
67    pub reply_to: Option<String>,
68
69    /// URLs or identifiers for attached media.
70    #[serde(default)]
71    pub media: Vec<String>,
72
73    /// Arbitrary channel-specific metadata.
74    #[serde(default)]
75    pub metadata: HashMap<String, serde_json::Value>,
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn inbound_session_key() {
84        let msg = InboundMessage {
85            channel: "telegram".into(),
86            sender_id: "user123".into(),
87            chat_id: "chat456".into(),
88            content: "hello".into(),
89            timestamp: Utc::now(),
90            media: vec![],
91            metadata: HashMap::new(),
92        };
93        assert_eq!(msg.session_key(), "telegram:chat456");
94    }
95
96    #[test]
97    fn inbound_serde_roundtrip() {
98        let msg = InboundMessage {
99            channel: "slack".into(),
100            sender_id: "U12345".into(),
101            chat_id: "C67890".into(),
102            content: "test message".into(),
103            timestamp: Utc::now(),
104            media: vec!["https://example.com/image.png".into()],
105            metadata: {
106                let mut m = HashMap::new();
107                m.insert("thread_ts".into(), serde_json::json!("123.456"));
108                m
109            },
110        };
111        let json = serde_json::to_string(&msg).unwrap();
112        let restored: InboundMessage = serde_json::from_str(&json).unwrap();
113        assert_eq!(restored.channel, "slack");
114        assert_eq!(restored.sender_id, "U12345");
115        assert_eq!(restored.chat_id, "C67890");
116        assert_eq!(restored.content, "test message");
117        assert_eq!(restored.media.len(), 1);
118        assert!(restored.metadata.contains_key("thread_ts"));
119    }
120
121    #[test]
122    fn inbound_defaults_on_missing_fields() {
123        let json = r#"{
124            "channel": "discord",
125            "sender_id": "u1",
126            "chat_id": "c1",
127            "content": "hi"
128        }"#;
129        let msg: InboundMessage = serde_json::from_str(json).unwrap();
130        assert!(msg.media.is_empty());
131        assert!(msg.metadata.is_empty());
132    }
133
134    #[test]
135    fn outbound_serde_roundtrip() {
136        let msg = OutboundMessage {
137            channel: "telegram".into(),
138            chat_id: "chat456".into(),
139            content: "reply".into(),
140            reply_to: Some("msg789".into()),
141            media: vec![],
142            metadata: HashMap::new(),
143        };
144        let json = serde_json::to_string(&msg).unwrap();
145        let restored: OutboundMessage = serde_json::from_str(&json).unwrap();
146        assert_eq!(restored.channel, "telegram");
147        assert_eq!(restored.reply_to.as_deref(), Some("msg789"));
148    }
149
150    #[test]
151    fn outbound_reply_to_optional() {
152        let json = r#"{
153            "channel": "slack",
154            "chat_id": "c1",
155            "content": "msg"
156        }"#;
157        let msg: OutboundMessage = serde_json::from_str(json).unwrap();
158        assert!(msg.reply_to.is_none());
159        assert!(msg.media.is_empty());
160    }
161}