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}