Skip to main content

messaging_api/
model.rs

1use conversation_api::ConversationSurface;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5pub const NORMALIZED_INBOUND_SCHEMA_VERSION: u16 = 1;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ConversationAudience {
10    Personal,
11    Shared,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16pub struct MessagingAddress {
17    pub provider: String,
18    pub account_id: String,
19    pub conversation_id: String,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub lane_id: Option<String>,
22    pub audience: ConversationAudience,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct MessagingModelError(&'static str);
27
28impl fmt::Display for MessagingModelError {
29    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30        formatter.write_str(self.0)
31    }
32}
33
34impl std::error::Error for MessagingModelError {}
35
36impl MessagingAddress {
37    pub fn new(
38        provider: impl Into<String>,
39        account_id: impl Into<String>,
40        conversation_id: impl Into<String>,
41        lane_id: Option<String>,
42        audience: ConversationAudience,
43    ) -> Result<Self, MessagingModelError> {
44        Ok(Self {
45            provider: provider_id(provider.into())?,
46            account_id: segment(account_id.into(), "messaging account id is invalid")?,
47            conversation_id: segment(
48                conversation_id.into(),
49                "messaging conversation id is invalid",
50            )?,
51            lane_id: lane_id
52                .map(|value| segment(value, "messaging lane id is invalid"))
53                .transpose()?,
54            audience,
55        })
56    }
57
58    #[must_use]
59    pub fn base_address(&self) -> Self {
60        let mut base = self.clone();
61        base.lane_id = None;
62        base
63    }
64
65    pub fn conversation_surface(&self) -> Result<ConversationSurface, MessagingModelError> {
66        let result = match self.audience {
67            ConversationAudience::Personal => ConversationSurface::messaging_personal(
68                &self.provider,
69                &self.account_id,
70                &self.conversation_id,
71                self.lane_id.clone(),
72            ),
73            ConversationAudience::Shared => ConversationSurface::messaging_group(
74                &self.provider,
75                &self.account_id,
76                &self.conversation_id,
77                self.lane_id.clone(),
78            ),
79        };
80        result.map_err(|_| MessagingModelError("messaging address cannot form a surface"))
81    }
82
83    pub fn validate(self) -> Result<Self, MessagingModelError> {
84        Self::new(
85            self.provider,
86            self.account_id,
87            self.conversation_id,
88            self.lane_id,
89            self.audience,
90        )
91    }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "camelCase", deny_unknown_fields)]
96pub struct ExternalActor {
97    pub provider: String,
98    pub account_id: String,
99    pub external_user_id: String,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub display_name: Option<String>,
102}
103
104impl ExternalActor {
105    pub fn new(
106        provider: impl Into<String>,
107        account_id: impl Into<String>,
108        external_user_id: impl Into<String>,
109        display_name: Option<String>,
110    ) -> Result<Self, MessagingModelError> {
111        Ok(Self {
112            provider: provider_id(provider.into())?,
113            account_id: segment(account_id.into(), "messaging actor account id is invalid")?,
114            external_user_id: segment(
115                external_user_id.into(),
116                "messaging external user id is invalid",
117            )?,
118            display_name: display_name.map(optional_segment).transpose()?,
119        })
120    }
121
122    pub fn validate(self) -> Result<Self, MessagingModelError> {
123        Self::new(
124            self.provider,
125            self.account_id,
126            self.external_user_id,
127            self.display_name,
128        )
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum InteractionDecision {
135    Approve,
136    Reject,
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140#[serde(
141    tag = "type",
142    rename_all = "snake_case",
143    rename_all_fields = "camelCase",
144    deny_unknown_fields
145)]
146pub enum NormalizedInboundContent {
147    Text {
148        text: String,
149        #[serde(default, skip_serializing_if = "Option::is_none")]
150        provider_message_id: Option<String>,
151    },
152    Audio {
153        provider_message_id: String,
154        provider_file_id: String,
155        #[serde(default, skip_serializing_if = "Option::is_none")]
156        duration_seconds: Option<u32>,
157    },
158    Interaction {
159        action_id: String,
160        token: String,
161        decision: InteractionDecision,
162    },
163    InteractionChoice {
164        decision: InteractionDecision,
165    },
166    Selection {
167        action_id: String,
168        selection_id: String,
169        option_index: u32,
170    },
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", deny_unknown_fields)]
175pub struct NormalizedInbound {
176    pub schema_version: u16,
177    pub event_id: String,
178    pub address: MessagingAddress,
179    pub actor: ExternalActor,
180    pub content: NormalizedInboundContent,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub conversation_display_name: Option<String>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub occurred_at_ms: Option<i64>,
185}
186
187impl NormalizedInbound {
188    pub fn validate(self) -> Result<Self, MessagingModelError> {
189        if self.schema_version != NORMALIZED_INBOUND_SCHEMA_VERSION {
190            return Err(MessagingModelError(
191                "unsupported normalized messaging schema version",
192            ));
193        }
194        let event_id = segment(self.event_id, "messaging event id is invalid")?;
195        let address = self.address.validate()?;
196        let actor = self.actor.validate()?;
197        if actor.provider != address.provider || actor.account_id != address.account_id {
198            return Err(MessagingModelError(
199                "messaging actor and address account do not match",
200            ));
201        }
202        if self.occurred_at_ms.is_some_and(|value| value < 0) {
203            return Err(MessagingModelError(
204                "messaging occurrence time cannot be negative",
205            ));
206        }
207        validate_content(&self.content)?;
208        optional_ref(
209            &self.conversation_display_name,
210            "messaging conversation display name is invalid",
211        )?;
212        Ok(Self {
213            schema_version: self.schema_version,
214            event_id,
215            address,
216            actor,
217            content: self.content,
218            conversation_display_name: self.conversation_display_name,
219            occurred_at_ms: self.occurred_at_ms,
220        })
221    }
222}
223
224fn validate_content(content: &NormalizedInboundContent) -> Result<(), MessagingModelError> {
225    match content {
226        NormalizedInboundContent::Text {
227            text,
228            provider_message_id,
229        } => {
230            segment_ref(text, "messaging text is invalid")?;
231            optional_ref(provider_message_id, "provider message id is invalid")?;
232        }
233        NormalizedInboundContent::Audio {
234            provider_message_id,
235            provider_file_id,
236            ..
237        } => {
238            segment_ref(provider_message_id, "provider message id is invalid")?;
239            segment_ref(provider_file_id, "provider file id is invalid")?;
240        }
241        NormalizedInboundContent::Interaction {
242            action_id, token, ..
243        } => {
244            segment_ref(action_id, "interaction action id is invalid")?;
245            segment_ref(token, "interaction token is invalid")?;
246        }
247        NormalizedInboundContent::InteractionChoice { .. } => {}
248        NormalizedInboundContent::Selection {
249            action_id,
250            selection_id,
251            ..
252        } => {
253            segment_ref(action_id, "selection action id is invalid")?;
254            segment_ref(selection_id, "selection id is invalid")?;
255        }
256    }
257    Ok(())
258}
259
260fn provider_id(value: String) -> Result<String, MessagingModelError> {
261    let normalized = value.trim().to_ascii_lowercase();
262    if normalized.is_empty()
263        || !normalized
264            .as_bytes()
265            .first()
266            .is_some_and(u8::is_ascii_lowercase)
267        || !normalized
268            .bytes()
269            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
270    {
271        return Err(MessagingModelError("messaging provider id is invalid"));
272    }
273    Ok(normalized)
274}
275
276fn segment(value: String, error: &'static str) -> Result<String, MessagingModelError> {
277    segment_ref(&value, error)?;
278    Ok(value)
279}
280
281fn optional_segment(value: String) -> Result<String, MessagingModelError> {
282    segment(value, "messaging display name is invalid")
283}
284
285fn segment_ref(value: &str, error: &'static str) -> Result<(), MessagingModelError> {
286    if value.is_empty() || value.trim() != value {
287        return Err(MessagingModelError(error));
288    }
289    Ok(())
290}
291
292fn optional_ref(value: &Option<String>, error: &'static str) -> Result<(), MessagingModelError> {
293    if let Some(value) = value {
294        segment_ref(value, error)?;
295    }
296    Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn address_owns_lane_and_canonical_surface_mapping() {
305        let address = MessagingAddress::new(
306            "Telegram",
307            "bot:1",
308            "chat:2",
309            Some("topic:3".to_string()),
310            ConversationAudience::Shared,
311        )
312        .unwrap();
313        assert_eq!(address.provider, "telegram");
314        assert_eq!(address.base_address().lane_id, None);
315        let surface = address.conversation_surface().unwrap();
316        let route = surface.messaging_route().unwrap();
317        assert_eq!(route.lane_id, Some("topic:3"));
318        assert!(route.group);
319    }
320
321    #[test]
322    fn inbound_rejects_actor_from_another_provider_account() {
323        let inbound = NormalizedInbound {
324            schema_version: NORMALIZED_INBOUND_SCHEMA_VERSION,
325            event_id: "event".to_string(),
326            address: MessagingAddress::new(
327                "telegram",
328                "bot-a",
329                "chat",
330                None,
331                ConversationAudience::Personal,
332            )
333            .unwrap(),
334            actor: ExternalActor::new("telegram", "bot-b", "user", None).unwrap(),
335            content: NormalizedInboundContent::Text {
336                text: "hello".to_string(),
337                provider_message_id: None,
338            },
339            conversation_display_name: None,
340            occurred_at_ms: None,
341        };
342        assert!(inbound.validate().is_err());
343    }
344}