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 = 3;
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, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase", deny_unknown_fields)]
134pub struct ActionOption {
135    pub label: String,
136    pub token: String,
137}
138
139impl ActionOption {
140    pub fn new(
141        label: impl Into<String>,
142        token: impl Into<String>,
143    ) -> Result<Self, MessagingModelError> {
144        Ok(Self {
145            label: segment(label.into(), "action label is invalid")?,
146            token: segment(token.into(), "action token is invalid")?,
147        })
148    }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase", deny_unknown_fields)]
153pub struct ActionSet {
154    pub options: Vec<ActionOption>,
155}
156
157impl ActionSet {
158    pub fn new(options: Vec<ActionOption>) -> Result<Self, MessagingModelError> {
159        if options.is_empty() {
160            return Err(MessagingModelError("action set cannot be empty"));
161        }
162        for option in &options {
163            segment_ref(&option.label, "action label is invalid")?;
164            segment_ref(&option.token, "action token is invalid")?;
165        }
166        Ok(Self { options })
167    }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum ProviderMediaKind {
173    Image,
174    Audio,
175    Video,
176    File,
177}
178
179/// Provider-scoped, short-lived media handle. It must be materialized before entering Conversation.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase", deny_unknown_fields)]
182pub struct ProviderMediaRef {
183    pub handle: String,
184    pub kind: ProviderMediaKind,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub mime_type: Option<String>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub size_bytes: Option<u64>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub file_name: Option<String>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub duration_ms: Option<u64>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub width_px: Option<u32>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub height_px: Option<u32>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub caption: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub transcript: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(
205    tag = "type",
206    rename_all = "snake_case",
207    rename_all_fields = "camelCase",
208    deny_unknown_fields
209)]
210pub enum InboundMessagePart {
211    Text { text: String },
212    Media { reference: ProviderMediaRef },
213}
214
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216#[serde(
217    tag = "type",
218    rename_all = "snake_case",
219    rename_all_fields = "camelCase",
220    deny_unknown_fields
221)]
222pub enum NormalizedInboundContent {
223    Message {
224        #[serde(default, skip_serializing_if = "Option::is_none")]
225        provider_message_id: Option<String>,
226        parts: Vec<InboundMessagePart>,
227    },
228    ActionSelected {
229        token: String,
230    },
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase", deny_unknown_fields)]
235pub struct NormalizedInbound {
236    pub schema_version: u16,
237    pub event_id: String,
238    pub address: MessagingAddress,
239    pub actor: ExternalActor,
240    pub content: NormalizedInboundContent,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub conversation_display_name: Option<String>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub occurred_at_ms: Option<i64>,
245}
246
247impl NormalizedInbound {
248    pub fn validate(self) -> Result<Self, MessagingModelError> {
249        if self.schema_version != NORMALIZED_INBOUND_SCHEMA_VERSION {
250            return Err(MessagingModelError(
251                "unsupported normalized messaging schema version",
252            ));
253        }
254        let event_id = segment(self.event_id, "messaging event id is invalid")?;
255        let address = self.address.validate()?;
256        let actor = self.actor.validate()?;
257        if actor.provider != address.provider || actor.account_id != address.account_id {
258            return Err(MessagingModelError(
259                "messaging actor and address account do not match",
260            ));
261        }
262        if self.occurred_at_ms.is_some_and(|value| value < 0) {
263            return Err(MessagingModelError(
264                "messaging occurrence time cannot be negative",
265            ));
266        }
267        validate_content(&self.content)?;
268        optional_ref(
269            &self.conversation_display_name,
270            "messaging conversation display name is invalid",
271        )?;
272        Ok(Self {
273            schema_version: self.schema_version,
274            event_id,
275            address,
276            actor,
277            content: self.content,
278            conversation_display_name: self.conversation_display_name,
279            occurred_at_ms: self.occurred_at_ms,
280        })
281    }
282}
283
284fn validate_content(content: &NormalizedInboundContent) -> Result<(), MessagingModelError> {
285    match content {
286        NormalizedInboundContent::Message {
287            provider_message_id,
288            parts,
289        } => {
290            if parts.is_empty() {
291                return Err(MessagingModelError("messaging message has no parts"));
292            }
293            optional_ref(provider_message_id, "provider message id is invalid")?;
294            for part in parts {
295                match part {
296                    InboundMessagePart::Text { text } if text.is_empty() => {
297                        return Err(MessagingModelError("messaging text is invalid"));
298                    }
299                    InboundMessagePart::Text { .. } => {}
300                    InboundMessagePart::Media { reference } => validate_media(reference)?,
301                }
302            }
303        }
304        NormalizedInboundContent::ActionSelected { token } => {
305            segment_ref(token, "action token is invalid")?;
306        }
307    }
308    Ok(())
309}
310
311fn validate_media(reference: &ProviderMediaRef) -> Result<(), MessagingModelError> {
312    segment_ref(&reference.handle, "provider media handle is invalid")?;
313    optional_ref(&reference.mime_type, "provider media MIME type is invalid")?;
314    optional_ref(&reference.file_name, "provider media file name is invalid")?;
315    optional_ref(&reference.caption, "provider media caption is invalid")?;
316    optional_ref(
317        &reference.transcript,
318        "provider media transcript is invalid",
319    )?;
320    if reference.size_bytes == Some(0)
321        || reference.duration_ms == Some(0)
322        || reference.width_px == Some(0)
323        || reference.height_px == Some(0)
324    {
325        return Err(MessagingModelError("provider media metadata is invalid"));
326    }
327    if reference.width_px.is_some() != reference.height_px.is_some() {
328        return Err(MessagingModelError(
329            "provider media dimensions must be complete",
330        ));
331    }
332    Ok(())
333}
334
335fn provider_id(value: String) -> Result<String, MessagingModelError> {
336    let normalized = value.trim().to_ascii_lowercase();
337    if normalized.is_empty()
338        || !normalized
339            .as_bytes()
340            .first()
341            .is_some_and(u8::is_ascii_lowercase)
342        || !normalized
343            .bytes()
344            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
345    {
346        return Err(MessagingModelError("messaging provider id is invalid"));
347    }
348    Ok(normalized)
349}
350
351fn segment(value: String, error: &'static str) -> Result<String, MessagingModelError> {
352    segment_ref(&value, error)?;
353    Ok(value)
354}
355
356fn optional_segment(value: String) -> Result<String, MessagingModelError> {
357    segment(value, "messaging display name is invalid")
358}
359
360fn segment_ref(value: &str, error: &'static str) -> Result<(), MessagingModelError> {
361    if value.is_empty() || value.trim() != value {
362        return Err(MessagingModelError(error));
363    }
364    Ok(())
365}
366
367fn optional_ref(value: &Option<String>, error: &'static str) -> Result<(), MessagingModelError> {
368    if let Some(value) = value {
369        segment_ref(value, error)?;
370    }
371    Ok(())
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn address_owns_lane_and_canonical_surface_mapping() {
380        let address = MessagingAddress::new(
381            "Telegram",
382            "bot:1",
383            "chat:2",
384            Some("topic:3".to_string()),
385            ConversationAudience::Shared,
386        )
387        .unwrap();
388        assert_eq!(address.provider, "telegram");
389        assert_eq!(address.base_address().lane_id, None);
390        let surface = address.conversation_surface().unwrap();
391        let route = surface.messaging_route().unwrap();
392        assert_eq!(route.lane_id, Some("topic:3"));
393        assert!(route.group);
394    }
395
396    #[test]
397    fn inbound_rejects_actor_from_another_provider_account() {
398        let inbound = NormalizedInbound {
399            schema_version: NORMALIZED_INBOUND_SCHEMA_VERSION,
400            event_id: "event".to_string(),
401            address: MessagingAddress::new(
402                "telegram",
403                "bot-a",
404                "chat",
405                None,
406                ConversationAudience::Personal,
407            )
408            .unwrap(),
409            actor: ExternalActor::new("telegram", "bot-b", "user", None).unwrap(),
410            content: NormalizedInboundContent::Message {
411                provider_message_id: None,
412                parts: vec![InboundMessagePart::Text {
413                    text: "hello".to_string(),
414                }],
415            },
416            conversation_display_name: None,
417            occurred_at_ms: None,
418        };
419        assert!(inbound.validate().is_err());
420    }
421
422    #[test]
423    fn actions_only_expose_labels_and_opaque_tokens() {
424        let actions = ActionSet::new(vec![
425            ActionOption::new("Approve", "route-approve").unwrap(),
426            ActionOption::new("Reject", "route-reject").unwrap(),
427        ])
428        .unwrap();
429        assert_eq!(actions.options.len(), 2);
430        assert!(ActionSet::new(Vec::new()).is_err());
431        assert!(ActionOption::new("Approve", " ").is_err());
432    }
433
434    #[test]
435    fn inbound_text_preserves_whitespace_allowed_by_the_schema() {
436        let inbound = NormalizedInbound {
437            schema_version: NORMALIZED_INBOUND_SCHEMA_VERSION,
438            event_id: "event".to_string(),
439            address: MessagingAddress::new(
440                "telegram",
441                "bot",
442                "chat",
443                None,
444                ConversationAudience::Personal,
445            )
446            .unwrap(),
447            actor: ExternalActor::new("telegram", "bot", "user", None).unwrap(),
448            content: NormalizedInboundContent::Message {
449                provider_message_id: None,
450                parts: vec![InboundMessagePart::Text {
451                    text: " message with spacing ".to_string(),
452                }],
453            },
454            conversation_display_name: None,
455            occurred_at_ms: None,
456        };
457        assert!(inbound.validate().is_ok());
458    }
459}