1use crate::identity::IdentityAssurance;
8use crate::ids::{AttachmentId, ParticipantId, StreamId};
9use bytes::Bytes;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::fmt;
13use uuid::Uuid;
14
15#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(tag = "kind", rename_all = "kebab-case")]
23pub enum VconRef {
24 Local { uuid: Uuid },
27 Url { url: String },
30}
31
32impl fmt::Debug for VconRef {
33 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34 formatter.write_str(match self {
35 Self::Local { .. } => "VconRef::Local",
36 Self::Url { .. } => "VconRef::Url",
37 })
38 }
39}
40
41#[derive(Clone)]
42pub struct VconParty {
43 pub participant_id: ParticipantId,
44 pub display_name: Option<String>,
45 pub did: Option<String>,
47 pub stir: Option<String>,
49 pub validation: IdentityAssurance,
50}
51
52impl fmt::Debug for VconParty {
53 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54 formatter
55 .debug_struct("VconParty")
56 .field("display_name_present", &self.display_name.is_some())
57 .field("did_present", &self.did.is_some())
58 .field("stir_present", &self.stir.is_some())
59 .field("validation_kind", &self.validation.kind())
60 .finish()
61 }
62}
63
64#[derive(Clone)]
65pub struct VconDialog {
66 pub kind: VconDialogKind,
67 pub stream_id: Option<StreamId>,
68 pub started: DateTime<Utc>,
69 pub ended: Option<DateTime<Utc>>,
70 pub parties: Vec<ParticipantId>,
71 pub mediatype: Option<String>,
72 pub body: Option<Bytes>,
75 pub url: Option<String>,
78 pub content_hash: Option<String>,
79}
80
81impl fmt::Debug for VconDialog {
82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83 formatter
84 .debug_struct("VconDialog")
85 .field("kind", &self.kind)
86 .field("stream_id_present", &self.stream_id.is_some())
87 .field("started", &self.started)
88 .field("ended", &self.ended)
89 .field("party_count", &self.parties.len())
90 .field("mediatype_present", &self.mediatype.is_some())
91 .field("body_bytes", &self.body.as_ref().map_or(0, Bytes::len))
92 .field("url_present", &self.url.is_some())
93 .field("content_hash_present", &self.content_hash.is_some())
94 .finish()
95 }
96}
97
98#[derive(Clone)]
99pub enum VconDialogKind {
100 Audio,
101 Video,
102 Text,
103 Transfer,
104 Other(String),
105}
106
107impl fmt::Debug for VconDialogKind {
108 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
109 formatter.write_str(match self {
110 Self::Audio => "Audio",
111 Self::Video => "Video",
112 Self::Text => "Text",
113 Self::Transfer => "Transfer",
114 Self::Other(_) => "Other",
115 })
116 }
117}
118
119#[derive(Clone)]
120pub struct VconAnalysis {
121 pub kind: VconAnalysisKind,
122 pub vendor: String,
124 pub product: Option<String>,
125 pub body: Bytes,
126 pub mediatype: String,
127}
128
129impl fmt::Debug for VconAnalysis {
130 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131 formatter
132 .debug_struct("VconAnalysis")
133 .field("kind", &self.kind)
134 .field("vendor_present", &!self.vendor.is_empty())
135 .field("product_present", &self.product.is_some())
136 .field("body_bytes", &self.body.len())
137 .field("mediatype_present", &!self.mediatype.is_empty())
138 .finish()
139 }
140}
141
142#[derive(Clone)]
143pub enum VconAnalysisKind {
144 Transcript,
145 Sentiment,
146 Summary,
147 Other(String),
148}
149
150impl fmt::Debug for VconAnalysisKind {
151 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152 formatter.write_str(match self {
153 Self::Transcript => "Transcript",
154 Self::Sentiment => "Sentiment",
155 Self::Summary => "Summary",
156 Self::Other(_) => "Other",
157 })
158 }
159}
160
161#[derive(Clone)]
162pub struct VconAttachment {
163 pub id: AttachmentId,
164 pub started: DateTime<Utc>,
166 pub party: ParticipantId,
168 pub dialog: StreamId,
170 pub mediatype: String,
171 pub body: Bytes,
172 pub purpose: Option<String>,
173}
174
175impl fmt::Debug for VconAttachment {
176 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177 formatter
178 .debug_struct("VconAttachment")
179 .field("started", &self.started)
180 .field("mediatype_present", &!self.mediatype.is_empty())
181 .field("body_bytes", &self.body.len())
182 .field("purpose_present", &self.purpose.is_some())
183 .finish()
184 }
185}
186
187#[derive(Clone)]
188pub struct VconSnapshot {
189 pub uuid: Uuid,
191 pub created_at: DateTime<Utc>,
193 pub parties: Vec<VconParty>,
194 pub dialogs: Vec<VconDialog>,
195 pub analyses: Vec<VconAnalysis>,
196 pub attachments: Vec<VconAttachment>,
197}
198
199impl fmt::Debug for VconSnapshot {
200 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201 formatter
202 .debug_struct("VconSnapshot")
203 .field("uuid_version", &self.uuid.get_version_num())
204 .field("created_at", &self.created_at)
205 .field("party_count", &self.parties.len())
206 .field("dialog_count", &self.dialogs.len())
207 .field("analysis_count", &self.analyses.len())
208 .field("attachment_count", &self.attachments.len())
209 .finish()
210 }
211}
212
213pub trait VconBuilderHandle: Send + Sync {
217 fn add_party(&self, party: VconParty);
218 fn add_dialog(&self, dialog: VconDialog);
219 fn add_analysis(&self, analysis: VconAnalysis);
220 fn add_attachment(&self, attachment: VconAttachment);
221 fn snapshot(&self) -> VconSnapshot;
222}
223
224pub struct DefaultVconBuilder {
230 inner: std::sync::Mutex<VconSnapshot>,
231}
232
233impl DefaultVconBuilder {
234 pub fn new() -> Self {
235 Self::with_identity(new_v8_uuid(), Utc::now())
236 }
237
238 pub fn with_uuid(uuid: Uuid) -> Self {
240 Self::with_identity(uuid, Utc::now())
241 }
242
243 pub fn with_identity(uuid: Uuid, created_at: DateTime<Utc>) -> Self {
248 Self {
249 inner: std::sync::Mutex::new(VconSnapshot {
250 uuid,
251 created_at,
252 parties: Vec::new(),
253 dialogs: Vec::new(),
254 analyses: Vec::new(),
255 attachments: Vec::new(),
256 }),
257 }
258 }
259}
260
261impl Default for DefaultVconBuilder {
262 fn default() -> Self {
263 Self::new()
264 }
265}
266
267impl VconBuilderHandle for DefaultVconBuilder {
268 fn add_party(&self, party: VconParty) {
269 let mut snapshot = self.inner.lock().expect("vcon builder lock poisoned");
270 if let Some(existing) = snapshot
271 .parties
272 .iter_mut()
273 .find(|existing| existing.participant_id == party.participant_id)
274 {
275 if existing.display_name.is_none() {
276 existing.display_name = party.display_name;
277 }
278 if existing.did.is_none() {
279 existing.did = party.did;
280 }
281 if existing.stir.is_none() {
282 existing.stir = party.stir;
283 }
284 if matches!(&existing.validation, IdentityAssurance::Anonymous)
285 && !matches!(&party.validation, IdentityAssurance::Anonymous)
286 {
287 existing.validation = party.validation;
288 }
289 return;
290 }
291 snapshot.parties.push(party);
292 }
293 fn add_dialog(&self, dialog: VconDialog) {
294 self.inner
295 .lock()
296 .expect("vcon builder lock poisoned")
297 .dialogs
298 .push(dialog);
299 }
300 fn add_analysis(&self, analysis: VconAnalysis) {
301 self.inner
302 .lock()
303 .expect("vcon builder lock poisoned")
304 .analyses
305 .push(analysis);
306 }
307 fn add_attachment(&self, attachment: VconAttachment) {
308 self.inner
309 .lock()
310 .expect("vcon builder lock poisoned")
311 .attachments
312 .push(attachment);
313 }
314 fn snapshot(&self) -> VconSnapshot {
315 let g = self.inner.lock().expect("vcon builder lock poisoned");
316 VconSnapshot {
317 uuid: g.uuid,
318 created_at: g.created_at,
319 parties: g.parties.clone(),
320 dialogs: g.dialogs.clone(),
321 analyses: g.analyses.clone(),
322 attachments: g.attachments.clone(),
323 }
324 }
325}
326
327fn new_v8_uuid() -> Uuid {
328 let mut bytes = *Uuid::new_v4().as_bytes();
333 bytes[6] = (bytes[6] & 0x0f) | 0x80;
334 bytes[8] = (bytes[8] & 0x3f) | 0x80;
335 Uuid::from_bytes(bytes)
336}
337
338#[cfg(feature = "vcon")]
339impl TryFrom<&VconSnapshot> for rvoip_vcon::Vcon {
340 type Error = rvoip_vcon::VconError;
341
342 fn try_from(snapshot: &VconSnapshot) -> Result<Self, Self::Error> {
343 use rvoip_vcon::{
344 Analysis, Attachment, ContentEncoding, ContentHashes, Dialog, DialogKind, Party,
345 PartyIndices, Vcon, VconError,
346 };
347 use std::collections::{HashMap, HashSet};
348
349 let invalid = |message: String| VconError::Invalid(message);
350
351 let mut party_indices = HashMap::with_capacity(snapshot.parties.len());
352 let mut parties = Vec::with_capacity(snapshot.parties.len());
353 for (index, party) in snapshot.parties.iter().enumerate() {
354 let index = u32::try_from(index)
355 .map_err(|_| invalid("vCon contains more than u32::MAX parties".into()))?;
356 if party_indices
357 .insert(party.participant_id.clone(), index)
358 .is_some()
359 {
360 return Err(invalid(format!(
361 "duplicate participant {} in vCon snapshot",
362 party.participant_id
363 )));
364 }
365 parties.push(Party {
366 uuid: Some(party.participant_id.to_string()),
367 name: party.display_name.clone(),
368 did: party.did.clone(),
369 stir: party.stir.clone(),
370 validation: Some(party.validation.kind().to_owned()),
371 ..Party::default()
372 });
373 }
374
375 let mut dialog_indices = HashMap::with_capacity(snapshot.dialogs.len());
378 for (index, dialog) in snapshot.dialogs.iter().enumerate() {
379 if let Some(stream_id) = &dialog.stream_id {
380 let index = u32::try_from(index)
381 .map_err(|_| invalid("vCon contains more than u32::MAX dialogs".into()))?;
382 if dialog_indices.insert(stream_id.clone(), index).is_some() {
383 return Err(invalid(format!(
384 "duplicate stream {} in vCon snapshot",
385 stream_id
386 )));
387 }
388 }
389 }
390
391 let mut dialogs = Vec::with_capacity(snapshot.dialogs.len() + 1);
392 let mut recording_members = Vec::new();
393 for dialog in &snapshot.dialogs {
394 let kind = match &dialog.kind {
395 VconDialogKind::Audio | VconDialogKind::Video => DialogKind::Recording,
396 VconDialogKind::Text => DialogKind::Text,
397 VconDialogKind::Transfer => DialogKind::Transfer,
398 VconDialogKind::Other(kind) => {
399 return Err(invalid(format!(
400 "unsupported core dialog kind {kind:?}; declare a vCon extension instead"
401 )));
402 }
403 };
404
405 let duration = match dialog.ended {
406 Some(ended) if ended < dialog.started => {
407 return Err(invalid(
408 "vCon dialog ended before its start timestamp".into(),
409 ));
410 }
411 Some(ended) => {
412 let micros = (ended - dialog.started)
413 .num_microseconds()
414 .ok_or_else(|| invalid("vCon dialog duration overflowed".into()))?;
415 Some(micros as f64 / 1_000_000.0)
416 }
417 None => None,
418 };
419
420 let referenced_parties = dialog
421 .parties
422 .iter()
423 .map(|participant_id| {
424 party_indices.get(participant_id).copied().ok_or_else(|| {
425 invalid(format!(
426 "dialog references unknown participant {}",
427 participant_id
428 ))
429 })
430 })
431 .collect::<Result<Vec<_>, _>>()?;
432
433 let body = dialog
434 .body
435 .as_ref()
436 .map(|body| serde_json::Value::String(rvoip_vcon::encode_base64url(body.as_ref())));
437 let encoding = body.as_ref().map(|_| ContentEncoding::Base64Url);
438 let output_index = u32::try_from(dialogs.len())
439 .map_err(|_| invalid("vCon contains more than u32::MAX dialogs".into()))?;
440 if kind == DialogKind::Recording {
441 recording_members.push((
442 output_index,
443 dialog.started,
444 dialog.ended,
445 referenced_parties.clone(),
446 ));
447 }
448
449 dialogs.push(Dialog {
450 kind,
451 start: dialog.started,
452 duration,
453 parties: (!referenced_parties.is_empty())
454 .then_some(PartyIndices::Many(referenced_parties)),
455 mediatype: dialog.mediatype.clone(),
456 body,
457 encoding,
458 url: dialog.url.clone(),
459 content_hash: dialog.content_hash.clone().map(ContentHashes::One),
460 ..Dialog::default()
461 });
462 }
463
464 if recording_members.len() > 1 {
468 let set_index = u32::try_from(dialogs.len())
469 .map_err(|_| invalid("vCon contains more than u32::MAX dialogs".into()))?;
470 let start = recording_members
471 .iter()
472 .map(|(_, start, _, _)| *start)
473 .min()
474 .expect("recording members is non-empty");
475 let duration = if recording_members
476 .iter()
477 .all(|(_, _, ended, _)| ended.is_some())
478 {
479 let ended = recording_members
480 .iter()
481 .filter_map(|(_, _, ended, _)| ended.as_ref())
482 .max()
483 .expect("all recording members have an end");
484 let micros = (*ended - start)
485 .num_microseconds()
486 .ok_or_else(|| invalid("vCon recording-set duration overflowed".into()))?;
487 Some(micros as f64 / 1_000_000.0)
488 } else {
489 None
490 };
491
492 let mut seen_parties = HashSet::new();
493 let mut set_parties = Vec::new();
494 for (_, _, _, parties) in &recording_members {
495 for party in parties {
496 if seen_parties.insert(*party) {
497 set_parties.push(*party);
498 }
499 }
500 }
501 let recordings = recording_members
502 .iter()
503 .map(|(index, _, _, _)| *index)
504 .collect::<Vec<_>>();
505 for recording in &recordings {
506 dialogs[*recording as usize].recording_set = Some(set_index);
507 }
508 dialogs.push(Dialog {
509 kind: DialogKind::RecordingSet,
510 start,
511 duration,
512 parties: (!set_parties.is_empty()).then_some(PartyIndices::Many(set_parties)),
513 recordings,
514 ..Dialog::default()
515 });
516 }
517
518 let analysis = snapshot
519 .analyses
520 .iter()
521 .map(|item| {
522 let kind = match &item.kind {
523 VconAnalysisKind::Transcript => "transcript",
524 VconAnalysisKind::Sentiment => "sentiment",
525 VconAnalysisKind::Summary => "summary",
526 VconAnalysisKind::Other(kind) => kind,
527 };
528 Analysis {
529 kind: kind.to_owned(),
530 vendor: item.vendor.clone(),
531 product: item.product.clone(),
532 mediatype: Some(item.mediatype.clone()),
533 body: Some(serde_json::Value::String(rvoip_vcon::encode_base64url(
534 item.body.as_ref(),
535 ))),
536 encoding: Some(ContentEncoding::Base64Url),
537 ..Analysis::default()
538 }
539 })
540 .collect();
541
542 let attachments = snapshot
543 .attachments
544 .iter()
545 .map(|item| {
546 let party = party_indices.get(&item.party).copied().ok_or_else(|| {
547 invalid(format!(
548 "attachment {} references unknown participant {}",
549 item.id, item.party
550 ))
551 })?;
552 let dialog = dialog_indices.get(&item.dialog).copied().ok_or_else(|| {
553 invalid(format!(
554 "attachment {} references unknown dialog {}",
555 item.id, item.dialog
556 ))
557 })?;
558 Ok(Attachment {
559 purpose: item.purpose.clone(),
560 start: item.started,
561 party,
562 dialog,
563 mediatype: Some(item.mediatype.clone()),
564 body: Some(serde_json::Value::String(rvoip_vcon::encode_base64url(
565 item.body.as_ref(),
566 ))),
567 encoding: Some(ContentEncoding::Base64Url),
568 ..Attachment::default()
569 })
570 })
571 .collect::<Result<Vec<_>, VconError>>()?;
572
573 let vcon = Vcon {
574 uuid: snapshot.uuid,
575 vcon: Some("0.4.0".into()),
576 created_at: snapshot.created_at,
577 parties,
578 dialog: dialogs,
579 analysis,
580 attachments,
581 ..Vcon::default()
582 };
583 vcon.validate()?;
584 Ok(vcon)
585 }
586}
587
588#[cfg(feature = "vcon")]
591pub fn encode_snapshot(snapshot: &VconSnapshot) -> Result<bytes::Bytes, rvoip_vcon::VconError> {
592 let vcon = rvoip_vcon::Vcon::try_from(snapshot)?;
593 Ok(bytes::Bytes::from(serde_json::to_vec(&vcon)?))
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn vcon_ref_local_roundtrips_through_json() {
602 let uuid = Uuid::nil();
603 let v = VconRef::Local { uuid };
604 let json = serde_json::to_value(&v).expect("encode");
605 assert_eq!(json["kind"], "local");
606 assert_eq!(json["uuid"], uuid.to_string());
607 let back: VconRef = serde_json::from_value(json).expect("decode");
608 assert_eq!(v, back);
609 }
610
611 #[test]
612 fn vcon_ref_url_roundtrips_through_json() {
613 let v = VconRef::Url {
614 url: "https://vcons.example/abc123".into(),
615 };
616 let json = serde_json::to_value(&v).expect("encode");
617 assert_eq!(json["kind"], "url");
618 let back: VconRef = serde_json::from_value(json).expect("decode");
619 assert_eq!(v, back);
620 }
621
622 #[test]
623 fn builder_retains_caller_supplied_identity() {
624 let uuid = Uuid::nil();
625 let created_at = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap();
626 let snapshot = DefaultVconBuilder::with_identity(uuid, created_at).snapshot();
627 assert_eq!(snapshot.uuid, uuid);
628 assert_eq!(snapshot.created_at, created_at);
629 }
630
631 #[test]
632 fn duplicate_party_updates_fill_missing_metadata_without_downgrading() {
633 let builder = DefaultVconBuilder::new();
634 let participant_id = ParticipantId::new();
635 builder.add_party(VconParty {
636 participant_id: participant_id.clone(),
637 display_name: None,
638 did: None,
639 stir: None,
640 validation: IdentityAssurance::Anonymous,
641 });
642 builder.add_party(VconParty {
643 participant_id: participant_id.clone(),
644 display_name: Some("Alice".into()),
645 did: Some("did:example:alice".into()),
646 stir: Some("header.payload.signature".into()),
647 validation: IdentityAssurance::DtlsFingerprint {
648 algorithm: "sha-256".into(),
649 value: "AA:BB".into(),
650 },
651 });
652 builder.add_party(VconParty {
653 participant_id,
654 display_name: None,
655 did: None,
656 stir: None,
657 validation: IdentityAssurance::Anonymous,
658 });
659
660 let snapshot = builder.snapshot();
661 assert_eq!(snapshot.parties.len(), 1);
662 assert_eq!(snapshot.parties[0].display_name.as_deref(), Some("Alice"));
663 assert_eq!(
664 snapshot.parties[0].did.as_deref(),
665 Some("did:example:alice")
666 );
667 assert_eq!(
668 snapshot.parties[0].stir.as_deref(),
669 Some("header.payload.signature")
670 );
671 assert_eq!(snapshot.parties[0].validation.kind(), "dtls-fingerprint");
672 }
673}