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#[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}