1use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15
16pub use smooth_operator_core::Checkpoint;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Platform {
26 Web,
27 Messenger,
28 Instagram,
29 Email,
30 Discord,
31 Phone,
32 Sms,
33 Slack,
34 Whatsapp,
35 Tiktok,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct Conversation {
43 pub id: String,
44 pub platform: Platform,
45 pub name: String,
46 pub organization_id: String,
47 pub idempotency_key: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub metadata_json: Option<Value>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub analytics_json: Option<Value>,
52 pub created_at: DateTime<Utc>,
53 pub updated_at: DateTime<Utc>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum ParticipantType {
61 User,
62 AiAgent,
63 HumanAgent,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct Participant {
71 pub id: String,
72 pub conversation_id: String,
73 pub organization_id: String,
74 #[serde(rename = "type")]
75 pub participant_type: ParticipantType,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub external_id: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub internal_id: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub browser_fingerprint: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub browser_info: Option<Value>,
84 pub name: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub email: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub phone: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub crm_contact_id: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub metadata_json: Option<Value>,
93 pub created_at: DateTime<Utc>,
94 pub updated_at: DateTime<Utc>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum Direction {
102 Inbound,
103 Outbound,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ContentItem {
111 #[serde(rename = "type")]
113 pub item_type: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub text: Option<String>,
116}
117
118impl ContentItem {
119 pub fn text(text: impl Into<String>) -> Self {
121 Self {
122 item_type: "text".to_string(),
123 text: Some(text.into()),
124 }
125 }
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct MessageContent {
133 #[serde(default)]
134 pub items: Vec<ContentItem>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub text: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub structured_response: Option<Value>,
139}
140
141impl MessageContent {
142 pub fn from_text(text: impl Into<String>) -> Self {
144 let text = text.into();
145 Self {
146 items: vec![ContentItem::text(text.clone())],
147 text: Some(text),
148 structured_response: None,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ParticipantRef {
158 pub id: String,
159 #[serde(rename = "type")]
160 pub participant_type: String,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub name: Option<String>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct Message {
170 pub id: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub external_id: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub organization_id: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub conversation_id: Option<String>,
177 pub direction: Direction,
178 pub content: MessageContent,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub from: Option<ParticipantRef>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub to: Option<ParticipantRef>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub metadata_json: Option<Value>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub analytics_json: Option<Value>,
187 pub created_at: DateTime<Utc>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub updated_at: Option<DateTime<Utc>>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum SessionStatus {
197 Active,
198 Idle,
199 Ended,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct Session {
208 pub session_id: String,
209 pub conversation_id: String,
210 pub organization_id: String,
215 pub agent_id: String,
216 pub agent_name: String,
217 pub user_participant_id: String,
218 pub agent_participant_id: String,
219 pub thread_id: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub status: Option<SessionStatus>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub token_count: Option<u64>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub message_count: Option<u64>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub metadata: Option<HashMap<String, Value>>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub created_at: Option<DateTime<Utc>>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub updated_at: Option<DateTime<Utc>>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub ended_at: Option<DateTime<Utc>>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub last_activity_at: Option<DateTime<Utc>>,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256pub struct Citation {
257 pub id: String,
260 pub title: String,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub url: Option<String>,
268 pub snippet: String,
270 pub score: f32,
273}
274
275pub const CITATION_SNIPPET_MAX_CHARS: usize = 280;
278
279impl Citation {
280 #[must_use]
293 pub fn from_knowledge_result(result: &smooth_operator_core::KnowledgeResult) -> Self {
294 Self {
295 id: result.document_id.clone(),
296 title: result.source.clone(),
297 url: web_url(&result.source),
298 snippet: truncate_snippet(&result.chunk, CITATION_SNIPPET_MAX_CHARS),
299 score: result.score,
300 }
301 }
302}
303
304impl From<&smooth_operator_core::KnowledgeResult> for Citation {
305 fn from(result: &smooth_operator_core::KnowledgeResult) -> Self {
306 Self::from_knowledge_result(result)
307 }
308}
309
310impl From<smooth_operator_core::KnowledgeResult> for Citation {
311 fn from(result: smooth_operator_core::KnowledgeResult) -> Self {
312 Self::from_knowledge_result(&result)
313 }
314}
315
316fn web_url(source: &str) -> Option<String> {
320 if source.starts_with("http://") || source.starts_with("https://") {
321 Some(source.to_string())
322 } else {
323 None
324 }
325}
326
327fn truncate_snippet(text: &str, max: usize) -> String {
330 if text.chars().count() <= max {
331 return text.to_string();
332 }
333 let mut out: String = text.chars().take(max).collect();
334 out.push('…');
335 out
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use serde_json::json;
342
343 fn ts() -> DateTime<Utc> {
344 DateTime::parse_from_rfc3339("2026-06-07T12:00:00Z")
345 .unwrap()
346 .with_timezone(&Utc)
347 }
348
349 #[test]
350 fn participant_serializes_camelcase_and_kebab_type() {
351 let p = Participant {
352 id: "p1".into(),
353 conversation_id: "c1".into(),
354 organization_id: "org1".into(),
355 participant_type: ParticipantType::AiAgent,
356 external_id: None,
357 internal_id: Some("agent-uuid".into()),
358 browser_fingerprint: None,
359 browser_info: None,
360 name: "Smantha".into(),
361 email: None,
362 phone: None,
363 crm_contact_id: None,
364 metadata_json: None,
365 created_at: ts(),
366 updated_at: ts(),
367 };
368 let v = serde_json::to_value(&p).unwrap();
369 assert!(v.get("conversationId").is_some());
371 assert!(v.get("organizationId").is_some());
372 assert!(v.get("internalId").is_some());
373 assert_eq!(v.get("type").unwrap(), &json!("ai-agent"));
375 let back: Participant = serde_json::from_value(v).unwrap();
377 assert_eq!(back.participant_type, ParticipantType::AiAgent);
378 }
379
380 #[test]
381 fn participant_type_variants_match_spec() {
382 assert_eq!(
383 serde_json::to_value(ParticipantType::User).unwrap(),
384 json!("user")
385 );
386 assert_eq!(
387 serde_json::to_value(ParticipantType::AiAgent).unwrap(),
388 json!("ai-agent")
389 );
390 assert_eq!(
391 serde_json::to_value(ParticipantType::HumanAgent).unwrap(),
392 json!("human-agent")
393 );
394 }
395
396 #[test]
397 fn message_serializes_direction_and_content_items() {
398 let m = Message {
399 id: "m1".into(),
400 external_id: None,
401 organization_id: Some("org1".into()),
402 conversation_id: Some("c1".into()),
403 direction: Direction::Inbound,
404 content: MessageContent::from_text("hello"),
405 from: Some(ParticipantRef {
406 id: "p1".into(),
407 participant_type: "user".into(),
408 name: Some("Visitor".into()),
409 }),
410 to: None,
411 metadata_json: None,
412 analytics_json: None,
413 created_at: ts(),
414 updated_at: None,
415 };
416 let v = serde_json::to_value(&m).unwrap();
417 assert_eq!(v.get("direction").unwrap(), &json!("inbound"));
418 assert_eq!(v["content"]["items"][0]["type"], json!("text"));
419 assert_eq!(v["content"]["items"][0]["text"], json!("hello"));
420 assert_eq!(v["content"]["text"], json!("hello"));
421 assert_eq!(v["from"]["type"], json!("user"));
423 let back: Message = serde_json::from_value(v).unwrap();
424 assert_eq!(back.direction, Direction::Inbound);
425 }
426
427 #[test]
428 fn session_uses_thread_id_camelcase() {
429 let s = Session {
430 session_id: "s1".into(),
431 conversation_id: "c1".into(),
432 organization_id: "org1".into(),
433 agent_id: "a1".into(),
434 agent_name: "Smantha".into(),
435 user_participant_id: "pu".into(),
436 agent_participant_id: "pa".into(),
437 thread_id: "thread-xyz".into(),
438 status: Some(SessionStatus::Active),
439 token_count: Some(0),
440 message_count: Some(0),
441 metadata: None,
442 created_at: Some(ts()),
443 updated_at: Some(ts()),
444 ended_at: None,
445 last_activity_at: Some(ts()),
446 };
447 let v = serde_json::to_value(&s).unwrap();
448 assert!(v.get("sessionId").is_some());
449 assert!(v.get("conversationId").is_some());
450 assert!(v.get("userParticipantId").is_some());
451 assert!(v.get("agentParticipantId").is_some());
452 assert_eq!(v.get("threadId").unwrap(), &json!("thread-xyz"));
453 assert_eq!(v.get("status").unwrap(), &json!("active"));
454 let back: Session = serde_json::from_value(v).unwrap();
455 assert_eq!(back.thread_id, "thread-xyz");
456 assert_eq!(back.status, Some(SessionStatus::Active));
457 }
458
459 #[test]
460 fn conversation_platform_and_camelcase() {
461 let c = Conversation {
462 id: "c1".into(),
463 platform: Platform::Web,
464 name: "Lead chat".into(),
465 organization_id: "org1".into(),
466 idempotency_key: "idem-1".into(),
467 metadata_json: Some(json!({"campaign": "spring"})),
468 analytics_json: None,
469 created_at: ts(),
470 updated_at: ts(),
471 };
472 let v = serde_json::to_value(&c).unwrap();
473 assert_eq!(v.get("platform").unwrap(), &json!("web"));
474 assert!(v.get("organizationId").is_some());
475 assert!(v.get("idempotencyKey").is_some());
476 assert_eq!(v["metadataJson"]["campaign"], json!("spring"));
477 let back: Conversation = serde_json::from_value(v).unwrap();
478 assert_eq!(back.platform, Platform::Web);
479 }
480}