1pub mod conference;
2mod loopback;
3pub mod media;
4
5use std::collections::{HashMap, HashSet};
6use std::str::FromStr;
7
8use freeswitch_types::variables::VariableName;
9use freeswitch_types::{BridgeDialString, CallDirection, DialString};
10
11use crate::line::parse_line;
12use crate::message::{classify_message, MessageKind};
13use crate::stream::{Block, LogEntry, LogStream, ParseStats, UnclassifiedLine};
14use conference::{ConferenceEvent, ConferenceMembership, ConferenceRegistry};
15use media::SessionMedia;
16
17type SessionHook = Box<dyn Fn(&LogEntry, &mut SessionState) + Send>;
18
19#[derive(Debug, Clone, Default)]
25pub struct SessionState {
26 pub channel_name: Option<String>,
28 pub channel_state: Option<String>,
30 pub initial_context: Option<String>,
32 pub initial_destination: Option<String>,
35 pub dialplan_context: Option<String>,
37 pub dialplan_from: Option<String>,
39 pub dialplan_to: Option<String>,
41 pub call_direction: Option<CallDirection>,
43 pub caller_id_number: Option<String>,
45 pub caller_id_name: Option<String>,
47 pub destination_number: Option<String>,
49 pub hangup_cause: Option<String>,
51 pub answered_at: Option<String>,
53 pub other_leg_uuid: Option<String>,
56 pub conference: Option<ConferenceMembership>,
58 pub media: SessionMedia,
60 pub(crate) pending_bridge_target: Option<String>,
62 pub variables: HashMap<String, String>,
64}
65
66#[derive(Default)]
69struct IndexedFieldChanges {
70 channel_name: Option<(Option<String>, Option<String>)>,
71 pending_bridge_target: Option<(Option<String>, Option<String>)>,
72 other_leg_uuid: Option<(Option<String>, Option<String>)>,
73 conference: Option<(Option<ConferenceMembership>, Option<ConferenceMembership>)>,
74}
75
76#[derive(Default)]
78struct IndexedFields {
79 channel_name: Option<String>,
80 pending_bridge_target: Option<String>,
81 other_leg_uuid: Option<String>,
82 conference: Option<ConferenceMembership>,
83}
84
85impl IndexedFields {
86 fn of(state: &SessionState) -> Self {
87 IndexedFields {
88 channel_name: state.channel_name.clone(),
89 pending_bridge_target: state.pending_bridge_target.clone(),
90 other_leg_uuid: state.other_leg_uuid.clone(),
91 conference: state.conference.clone(),
92 }
93 }
94}
95
96impl IndexedFieldChanges {
97 fn diff(old: IndexedFields, state: &SessionState) -> Self {
98 let IndexedFields {
99 channel_name: old_channel_name,
100 pending_bridge_target: old_pending_bridge_target,
101 other_leg_uuid: old_other_leg_uuid,
102 conference: old_conference,
103 } = old;
104 let mut changes = IndexedFieldChanges::default();
105 if state.conference != old_conference {
106 changes.conference = Some((old_conference, state.conference.clone()));
107 }
108 if state.channel_name != old_channel_name {
109 changes.channel_name = Some((old_channel_name, state.channel_name.clone()));
110 }
111 if state.pending_bridge_target != old_pending_bridge_target {
112 changes.pending_bridge_target = Some((
113 old_pending_bridge_target,
114 state.pending_bridge_target.clone(),
115 ));
116 }
117 if state.other_leg_uuid != old_other_leg_uuid {
118 changes.other_leg_uuid = Some((old_other_leg_uuid, state.other_leg_uuid.clone()));
119 }
120 changes
121 }
122}
123
124#[derive(Debug, Clone)]
129pub struct SessionSnapshot {
130 pub channel_name: Option<String>,
131 pub channel_state: Option<String>,
132 pub initial_context: Option<String>,
133 pub initial_destination: Option<String>,
134 pub dialplan_context: Option<String>,
135 pub dialplan_from: Option<String>,
136 pub dialplan_to: Option<String>,
137 pub call_direction: Option<CallDirection>,
138 pub caller_id_number: Option<String>,
139 pub caller_id_name: Option<String>,
140 pub destination_number: Option<String>,
141 pub hangup_cause: Option<String>,
142 pub answered_at: Option<String>,
143 pub other_leg_uuid: Option<String>,
144 pub conference: Option<ConferenceMembership>,
145 pub media: SessionMedia,
146}
147
148impl SessionState {
149 pub fn variable<V: VariableName>(&self, var: V) -> Option<&str> {
155 self.variables.get(var.as_str()).map(String::as_str)
156 }
157
158 fn snapshot(&self) -> SessionSnapshot {
159 SessionSnapshot {
160 channel_name: self.channel_name.clone(),
161 channel_state: self.channel_state.clone(),
162 initial_context: self.initial_context.clone(),
163 initial_destination: self.initial_destination.clone(),
164 dialplan_context: self.dialplan_context.clone(),
165 dialplan_from: self.dialplan_from.clone(),
166 dialplan_to: self.dialplan_to.clone(),
167 call_direction: self.call_direction,
168 caller_id_number: self.caller_id_number.clone(),
169 caller_id_name: self.caller_id_name.clone(),
170 destination_number: self.destination_number.clone(),
171 hangup_cause: self.hangup_cause.clone(),
172 answered_at: self.answered_at.clone(),
173 other_leg_uuid: self.other_leg_uuid.clone(),
174 conference: self.conference.clone(),
175 media: self.media.clone(),
176 }
177 }
178
179 fn update_from_entry(&mut self, entry: &LogEntry) {
180 let block_has_channel_data = matches!(entry.block, Some(Block::ChannelData { .. }));
181 if let Some(Block::ChannelData { fields, variables }) = &entry.block {
182 for (name, value) in fields {
183 match name.as_str() {
184 "Channel-Name" => self.channel_name = Some(value.clone()),
185 "Channel-State" => self.channel_state = Some(value.clone()),
186 "Call-Direction" => {
187 self.call_direction = CallDirection::from_str(value).ok();
188 }
189 "Caller-Caller-ID-Number" => {
190 self.caller_id_number = Some(value.clone());
191 }
192 "Caller-Caller-ID-Name" => {
193 self.caller_id_name = Some(value.clone());
194 }
195 "Caller-Destination-Number" => {
196 self.destination_number = Some(value.clone());
197 }
198 "Other-Leg-Unique-ID" => {
199 self.other_leg_uuid = Some(value.clone());
200 }
201 _ => {}
202 }
203 }
204 for (name, value) in variables {
205 let var_name = name.strip_prefix("variable_").unwrap_or(name);
206 self.variables.insert(var_name.to_string(), value.clone());
207 }
208 }
209
210 match &entry.message_kind {
211 MessageKind::Execute {
212 application,
213 arguments,
214 ..
215 } => match application.as_str() {
216 "set" | "export" => {
217 if let Some((name, value)) = arguments.split_once('=') {
218 self.variables.insert(name.to_string(), value.to_string());
219 }
220 }
221 "bridge" => {
222 if let Some(info) = parse_bridge_args(arguments) {
223 if let Some(uuid) = &info.origination_uuid {
224 self.other_leg_uuid = Some(uuid.clone());
225 }
226 self.pending_bridge_target = Some(info.target_channel);
227 }
228 }
229 _ => {}
230 },
231 MessageKind::ChannelLifecycle { detail } => {
232 if let Some(name) = parse_new_channel(detail) {
233 if self.channel_name.is_none() {
234 self.channel_name = Some(name);
235 }
236 }
237 if let Some(cause) = parse_hangup(detail) {
238 self.hangup_cause = Some(cause);
239 }
240 if is_answered(detail) && self.answered_at.is_none() {
241 self.answered_at = Some(entry.timestamp.clone());
242 }
243 }
244 kind => self.apply_kind(kind),
245 }
246
247 self.apply_processing(&entry.message);
248 self.media.update_from_entry(entry);
249
250 for attached in &entry.attached {
251 let parsed = parse_line(attached);
252 self.update_from_message(parsed.message, block_has_channel_data);
253 }
254 }
255
256 fn apply_kind(&mut self, kind: &MessageKind) {
260 match kind {
261 MessageKind::Dialplan { detail, .. } => {
262 if let Some(dp) = parse_dialplan_context(detail) {
263 self.initial_context.get_or_insert(dp.context.clone());
264 self.dialplan_context = Some(dp.context);
265 self.dialplan_from = Some(dp.from);
266 self.dialplan_to = Some(dp.to);
267 }
268 }
269 MessageKind::Variable { name, value } => {
270 let var_name = name.strip_prefix("variable_").unwrap_or(name);
271 self.variables.insert(var_name.to_string(), value.clone());
272 }
273 MessageKind::ChannelField { name, value } => match name.as_str() {
274 "Channel-Name" => self.channel_name = Some(value.clone()),
275 "Channel-State" => self.channel_state = Some(value.clone()),
276 _ => {}
277 },
278 MessageKind::StateChange { detail } => {
279 if let Some(new_state) = parse_state_change(detail) {
280 self.channel_state = Some(new_state);
281 }
282 }
283 _ => {}
284 }
285 }
286
287 fn apply_processing(&mut self, msg: &str) {
291 if msg.contains("Processing ") && msg.contains(" in context ") {
292 if let Some(dp) = parse_processing_line(msg) {
293 self.initial_context.get_or_insert(dp.context.clone());
294 self.initial_destination.get_or_insert(dp.to.clone());
295 self.dialplan_context = Some(dp.context);
296 self.dialplan_from = Some(dp.from);
297 self.dialplan_to = Some(dp.to);
298 }
299 }
300 }
301
302 fn update_from_message(&mut self, msg: &str, block_provides_channel_data: bool) {
303 let kind = classify_message(msg);
304 match &kind {
305 MessageKind::Variable { .. } | MessageKind::ChannelField { .. }
309 if block_provides_channel_data => {}
310 kind => self.apply_kind(kind),
311 }
312 self.apply_processing(msg);
313 }
314}
315
316struct DialplanContext {
317 from: String,
318 to: String,
319 context: String,
320}
321
322fn parse_dialplan_context(detail: &str) -> Option<DialplanContext> {
323 if !detail.starts_with("parsing [") {
324 return None;
325 }
326 let rest = &detail["parsing [".len()..];
327 let bracket_end = rest.find(']')?;
328 let inner = &rest[..bracket_end];
329
330 let arrow = inner.find("->")?;
331 let from_part = &inner[..arrow];
332 let to_part = &inner[arrow + 2..];
333
334 Some(DialplanContext {
335 from: from_part.to_string(),
336 to: to_part.to_string(),
337 context: from_part.to_string(),
338 })
339}
340
341fn parse_processing_line(msg: &str) -> Option<DialplanContext> {
347 let proc_idx = msg.find("Processing ")?;
348 let after_proc = &msg[proc_idx + "Processing ".len()..];
349
350 let ctx_idx = after_proc.rfind(" in context ")?;
351 let head = &after_proc[..ctx_idx];
352 let context = after_proc[ctx_idx + " in context ".len()..]
353 .split_whitespace()
354 .next()?;
355
356 let (from, to) = match head.rfind(">->") {
357 Some(i) => (&head[..i + 1], &head[i + ">->".len()..]),
358 None => {
359 let i = head.rfind("->")?;
360 (&head[..i], &head[i + "->".len()..])
361 }
362 };
363
364 Some(DialplanContext {
365 from: from.to_string(),
366 to: to.to_string(),
367 context: context.to_string(),
368 })
369}
370
371fn parse_new_channel(detail: &str) -> Option<String> {
372 let rest = detail.strip_prefix("New Channel ")?;
373 let bracket = rest.rfind(" [")?;
374 Some(rest[..bracket].to_string())
375}
376
377fn parse_state_change(detail: &str) -> Option<String> {
378 let arrow = detail.find(" -> ")?;
379 Some(detail[arrow + 4..].trim().to_string())
380}
381
382fn parse_hangup(detail: &str) -> Option<String> {
383 if !detail.contains("Hangup ") {
384 return None;
385 }
386 let start = detail.rfind('[')?;
387 let end = detail[start..].find(']')?;
388 Some(detail[start + 1..start + end].to_string())
389}
390
391fn is_answered(detail: &str) -> bool {
392 detail.contains("has been answered")
393}
394
395pub fn parse_bridge_args(arguments: &str) -> Option<BridgeInfo> {
402 let dial = BridgeDialString::from_str(arguments).ok()?;
403 let first_ep = dial.groups().first()?.first()?;
404 let origination_uuid = first_ep
407 .variables()
408 .and_then(|v| v.get("origination_uuid"))
409 .or_else(|| dial.variables().and_then(|v| v.get("origination_uuid")))
410 .map(|s| s.to_string());
411 let mut bare = first_ep.clone();
412 bare.set_variables(None);
413 let target_channel = bare.to_string();
414 Some(BridgeInfo {
415 origination_uuid,
416 target_channel,
417 })
418}
419
420#[derive(Debug, Clone, PartialEq, Eq)]
422#[non_exhaustive]
423pub struct BridgeInfo {
424 pub origination_uuid: Option<String>,
426 pub target_channel: String,
429}
430
431fn parse_originate_success(msg: &str) -> Option<String> {
433 let marker = "Peer UUID: ";
434 let idx = msg.find(marker)?;
435 let uuid = msg[idx + marker.len()..].trim();
436 if uuid.is_empty() {
437 None
438 } else {
439 Some(uuid.to_string())
440 }
441}
442
443fn parse_originate_channel(msg: &str) -> Option<&str> {
447 let start = msg.find(" [")? + 2;
448 let end = msg[start..].find(']')?;
449 let chan = &msg[start..start + end];
450 if chan.is_empty() {
451 None
452 } else {
453 Some(chan)
454 }
455}
456
457fn is_terminal_channel_state(state: Option<&str>) -> bool {
465 matches!(
466 state,
467 Some("CS_HANGUP" | "CS_REPORTING" | "CS_DESTROY" | "CS_NONE" | "HANGUP")
468 )
469}
470
471#[derive(Debug)]
473pub struct EnrichedEntry {
474 pub entry: LogEntry,
475 pub session: Option<SessionSnapshot>,
477}
478
479pub struct SessionTracker<I> {
486 inner: LogStream<I>,
487 sessions: HashMap<String, SessionState>,
488 by_channel_name: HashMap<String, HashSet<String>>,
489 by_pending_target: HashMap<String, String>,
490 by_other_leg: HashMap<String, String>,
491 conferences: ConferenceRegistry,
492 pre_hook: Option<SessionHook>,
493 post_hook: Option<SessionHook>,
494}
495
496impl<I: Iterator<Item = String>> SessionTracker<I> {
497 pub fn new(inner: LogStream<I>) -> Self {
499 SessionTracker {
500 inner,
501 sessions: HashMap::new(),
502 by_channel_name: HashMap::new(),
503 by_pending_target: HashMap::new(),
504 by_other_leg: HashMap::new(),
505 conferences: ConferenceRegistry::default(),
506 pre_hook: None,
507 post_hook: None,
508 }
509 }
510
511 pub fn with_pre_hook<F>(mut self, hook: F) -> Self
519 where
520 F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
521 {
522 self.pre_hook = Some(Box::new(hook));
523 self
524 }
525
526 pub fn with_post_hook<F>(mut self, hook: F) -> Self
551 where
552 F: Fn(&LogEntry, &mut SessionState) + Send + 'static,
553 {
554 self.post_hook = Some(Box::new(hook));
555 self
556 }
557
558 pub fn sessions(&self) -> &HashMap<String, SessionState> {
560 &self.sessions
561 }
562
563 pub fn conference_members<'a>(&'a self, instance: &'a str) -> impl Iterator<Item = &'a str> {
566 self.conferences.members(instance)
567 }
568
569 pub fn remove_session(&mut self, uuid: &str) -> Option<SessionState> {
572 let state = self.sessions.remove(uuid)?;
573 if let Some(chan) = &state.channel_name {
574 if let Some(set) = self.by_channel_name.get_mut(chan) {
575 set.remove(uuid);
576 if set.is_empty() {
577 self.by_channel_name.remove(chan);
578 }
579 }
580 }
581 if let Some(target) = &state.pending_bridge_target {
582 self.by_pending_target.remove(target);
583 }
584 if let Some(other) = &state.other_leg_uuid {
585 self.by_other_leg.remove(other);
586 }
587 if let Some(conf) = &state.conference {
588 self.conferences.leave(&conf.name, uuid);
589 }
590 Some(state)
591 }
592
593 pub fn stats(&self) -> &ParseStats {
595 self.inner.stats()
596 }
597
598 pub fn drain_unclassified(&mut self) -> Vec<UnclassifiedLine> {
600 self.inner.drain_unclassified()
601 }
602
603 fn apply_index_changes(&mut self, uuid: &str, changes: &IndexedFieldChanges) {
604 if let Some((old, new)) = &changes.channel_name {
605 if let Some(old_name) = old {
606 if let Some(set) = self.by_channel_name.get_mut(old_name) {
607 set.remove(uuid);
608 if set.is_empty() {
609 self.by_channel_name.remove(old_name);
610 }
611 }
612 }
613 if let Some(new_name) = new {
614 self.by_channel_name
615 .entry(new_name.clone())
616 .or_default()
617 .insert(uuid.to_string());
618 }
619 }
620 if let Some((old, new)) = &changes.pending_bridge_target {
621 if let Some(old_target) = old {
622 self.by_pending_target.remove(old_target);
623 }
624 if let Some(new_target) = new {
625 self.by_pending_target
626 .insert(new_target.clone(), uuid.to_string());
627 }
628 }
629 if let Some((old, new)) = &changes.other_leg_uuid {
630 match new {
631 Some(new_leg) => self.index_other_leg(uuid, old.clone(), new_leg),
632 None => {
633 if let Some(old_leg) = old {
634 self.by_other_leg.remove(old_leg);
635 }
636 }
637 }
638 }
639 if let Some((old, new)) = &changes.conference {
640 let same_instance =
641 matches!((old, new), (Some(o), Some(n)) if o.instance == n.instance);
642 if !same_instance {
643 if let Some(old_conf) = old {
644 self.conferences.leave(&old_conf.name, uuid);
645 }
646 if let Some(new_conf) = new {
647 self.conferences
648 .join(&new_conf.name, &new_conf.instance, uuid);
649 }
650 }
651 }
652 }
653
654 fn index_other_leg(&mut self, uuid: &str, old_leg: Option<String>, new_leg: &str) {
658 if let Some(old) = old_leg {
659 if old != new_leg {
660 self.by_other_leg.remove(&old);
661 }
662 }
663 self.by_other_leg
664 .insert(new_leg.to_string(), uuid.to_string());
665 }
666
667 fn update_conference(&mut self, uuid: &str, entry: &LogEntry) {
672 let target = match conference::detect(entry) {
673 Some(ConferenceEvent::Leave) => {
674 if let Some(state) = self.sessions.get_mut(uuid) {
675 state.conference = None;
676 }
677 return;
678 }
679 Some(ConferenceEvent::Join(target)) => Some(target),
680 None => None,
681 };
682
683 let Some(state) = self.sessions.get(uuid) else {
684 return;
685 };
686 let Some(target) = target.or_else(|| conference::target_from_variables(&state.variables))
687 else {
688 if let Some(state) = self.sessions.get_mut(uuid) {
689 let SessionState {
690 conference,
691 variables,
692 ..
693 } = state;
694 if let Some(membership) = conference {
695 conference::refresh(membership, variables);
696 }
697 }
698 return;
699 };
700
701 let instance = match state.conference.as_ref() {
705 Some(current) if current.name == target.name => current.instance.clone(),
706 _ => self
707 .conferences
708 .instance_for(&target.name)
709 .map(str::to_string)
710 .unwrap_or_else(|| uuid.to_string()),
711 };
712
713 let Some(state) = self.sessions.get_mut(uuid) else {
714 return;
715 };
716 let SessionState {
717 conference,
718 variables,
719 ..
720 } = state;
721 let joining_elsewhere = conference.as_ref().is_none_or(|c| c.name != target.name);
722 if joining_elsewhere {
723 *conference = Some(ConferenceMembership {
724 name: target.name,
725 profile: target.profile.clone(),
726 instance,
727 member_id: None,
728 conference_uuid: None,
729 });
730 }
731 let Some(membership) = conference.as_mut() else {
732 return;
733 };
734 if target.profile.is_some() {
735 membership.profile = target.profile;
736 }
737 conference::refresh(membership, variables);
738 }
739
740 fn loopback_a_leg(&self, b_channel: &str, b_uuid: &str) -> Option<String> {
745 let a_channel = loopback::a_leg_name(b_channel)?;
746 let candidates: Vec<&String> = self
747 .by_channel_name
748 .get(&a_channel)?
749 .iter()
750 .filter(|u| *u != b_uuid)
751 .filter(|u| {
752 self.sessions
753 .get(*u)
754 .map(|s| !is_terminal_channel_state(s.channel_state.as_deref()))
755 .unwrap_or(false)
756 })
757 .collect();
758 match candidates.as_slice() {
759 [a_uuid] => Some((*a_uuid).clone()),
760 _ => None,
761 }
762 }
763
764 fn link_legs(&mut self, uuid: &str, entry: &LogEntry) {
767 if entry.message.contains("Originate Resulted in Success") {
769 let a_uuid = uuid.to_string();
770 if let Some(peer_uuid) = parse_originate_success(&entry.message) {
771 let a_old_pending = self
772 .sessions
773 .get(&a_uuid)
774 .and_then(|s| s.pending_bridge_target.clone());
775
776 let mut a_old_leg = None;
777 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
778 a_old_leg = a_state.other_leg_uuid.replace(peer_uuid.clone());
779 a_state.pending_bridge_target = None;
780 }
781 self.index_other_leg(&a_uuid, a_old_leg, &peer_uuid);
782 if let Some(old_target) = a_old_pending {
783 self.by_pending_target.remove(&old_target);
784 }
785
786 let b_state = self.sessions.entry(peer_uuid.clone()).or_default();
787 let b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
788 self.index_other_leg(&peer_uuid, b_old_leg, &a_uuid);
789 } else if let Some(chan) = parse_originate_channel(&entry.message) {
790 let candidates: Vec<String> = self
795 .by_channel_name
796 .get(chan)
797 .map(|set| {
798 set.iter()
799 .filter(|u| *u != &a_uuid)
800 .filter(|u| {
801 self.sessions
802 .get(*u)
803 .map(|s| !is_terminal_channel_state(s.channel_state.as_deref()))
804 .unwrap_or(false)
805 })
806 .cloned()
807 .collect()
808 })
809 .unwrap_or_default();
810
811 if let [b_uuid] = candidates.as_slice() {
812 let b_uuid = b_uuid.clone();
813 let a_old_pending = self
814 .sessions
815 .get(&a_uuid)
816 .and_then(|s| s.pending_bridge_target.clone());
817
818 let mut a_old_leg = None;
819 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
820 a_old_leg = a_state.other_leg_uuid.replace(b_uuid.clone());
821 a_state.pending_bridge_target = None;
822 }
823 let mut b_old_leg = None;
824 if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
825 b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
826 }
827
828 self.index_other_leg(&a_uuid, a_old_leg, &b_uuid);
829 self.index_other_leg(&b_uuid, b_old_leg, &a_uuid);
830 if let Some(old_target) = a_old_pending {
831 self.by_pending_target.remove(&old_target);
832 }
833 }
834 }
835 return;
836 }
837
838 if let MessageKind::ChannelLifecycle { detail } = &entry.message_kind {
841 if let Some(channel_name) = parse_new_channel(detail) {
842 let b_uuid = uuid.to_string();
843
844 let a_uuid_found = self
846 .by_other_leg
847 .get(&b_uuid)
848 .cloned()
849 .or_else(|| self.by_pending_target.get(&channel_name).cloned())
850 .or_else(|| self.loopback_a_leg(&channel_name, &b_uuid))
851 .filter(|a| a != &b_uuid);
852
853 if let Some(a_uuid) = a_uuid_found {
854 let a_old_pending = self
855 .sessions
856 .get(&a_uuid)
857 .and_then(|s| s.pending_bridge_target.clone());
858
859 let mut a_old_leg = None;
860 if let Some(a_state) = self.sessions.get_mut(&a_uuid) {
861 a_old_leg = a_state.other_leg_uuid.replace(b_uuid.clone());
862 a_state.pending_bridge_target = None;
863 }
864 let mut b_old_leg = None;
865 if let Some(b_state) = self.sessions.get_mut(&b_uuid) {
866 b_old_leg = b_state.other_leg_uuid.replace(a_uuid.clone());
867 }
868
869 self.index_other_leg(&a_uuid, a_old_leg, &b_uuid);
870 self.index_other_leg(&b_uuid, b_old_leg, &a_uuid);
871 if let Some(old_target) = a_old_pending {
872 self.by_pending_target.remove(&old_target);
873 }
874 }
875 }
876 }
877 }
878}
879
880impl<I: Iterator<Item = String>> Iterator for SessionTracker<I> {
881 type Item = EnrichedEntry;
882
883 fn next(&mut self) -> Option<EnrichedEntry> {
884 let entry = self.inner.next()?;
885
886 if entry.uuid.is_empty() {
887 return Some(EnrichedEntry {
888 entry,
889 session: None,
890 });
891 }
892
893 let uuid = entry.uuid.clone();
894 let state = self.sessions.entry(uuid.clone()).or_default();
895
896 let old = IndexedFields::of(state);
900
901 if let Some(hook) = &self.pre_hook {
902 hook(&entry, state);
903 }
904
905 state.update_from_entry(&entry);
906
907 self.update_conference(&uuid, &entry);
908 self.link_legs(&uuid, &entry);
909
910 if let Some(hook) = &self.post_hook {
914 let state = self.sessions.entry(uuid.clone()).or_default();
915 hook(&entry, state);
916 }
917
918 let state = self.sessions.entry(uuid.clone()).or_default();
919 let changes = IndexedFieldChanges::diff(old, state);
920 let snapshot = state.snapshot();
921 self.apply_index_changes(&uuid, &changes);
922
923 Some(EnrichedEntry {
924 entry,
925 session: Some(snapshot),
926 })
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use freeswitch_types::variables::SofiaVariable;
933 use freeswitch_types::ChannelVariable;
934
935 use super::*;
936
937 const UUID1: &str = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
938 const UUID2: &str = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
939 const UUID3: &str = "c3d4e5f6-a7b8-9012-cdef-234567890123";
940 const TS1: &str = "2025-01-15 10:30:45.123456";
941 const TS2: &str = "2025-01-15 10:30:46.234567";
942
943 fn full_line(uuid: &str, ts: &str, msg: &str) -> String {
944 format!("{uuid} {ts} 95.97% [DEBUG] sofia.c:100 {msg}")
945 }
946
947 fn collect_enriched(lines: Vec<String>) -> Vec<EnrichedEntry> {
948 let stream = LogStream::new(lines.into_iter());
949 SessionTracker::new(stream).collect()
950 }
951
952 #[test]
953 fn system_line_no_session() {
954 let lines = vec![format!(
955 "{TS1} 95.97% [INFO] mod_event_socket.c:1772 Event Socket command"
956 )];
957 let entries = collect_enriched(lines);
958 assert_eq!(entries.len(), 1);
959 assert!(entries[0].session.is_none());
960 }
961
962 #[test]
963 fn dialplan_context_propagation() {
964 let lines = vec![
965 full_line(UUID1, TS1, "CHANNEL_DATA:"),
966 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
967 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 answer"),
968 format!("{UUID1} Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public->global] continue=true"),
969 full_line(UUID1, TS2, "Some later event"),
970 ];
971 let entries = collect_enriched(lines);
972 let last = entries.last().unwrap();
973 let session = last.session.as_ref().unwrap();
974 assert_eq!(session.dialplan_context.as_deref(), Some("public"));
975 assert_eq!(session.dialplan_from.as_deref(), Some("public"));
976 assert_eq!(session.dialplan_to.as_deref(), Some("global"));
977 }
978
979 #[test]
980 fn processing_line_extracts_context() {
981 let lines = vec![full_line(
982 UUID1,
983 TS1,
984 "Processing 5551234567->5559876543 in context public",
985 )];
986 let entries = collect_enriched(lines);
987 let session = entries[0].session.as_ref().unwrap();
988 assert_eq!(session.dialplan_context.as_deref(), Some("public"));
989 assert_eq!(session.dialplan_from.as_deref(), Some("5551234567"));
990 assert_eq!(session.dialplan_to.as_deref(), Some("5559876543"));
991 }
992
993 #[test]
994 fn initial_context_preserved_across_transfers() {
995 let lines = vec![
996 full_line(
997 UUID1,
998 TS1,
999 "Processing 5551234567->5559876543 in context public",
1000 ),
1001 full_line(
1002 UUID1,
1003 TS2,
1004 "Processing 5551234567->start_recording in context recordings",
1005 ),
1006 ];
1007 let stream = LogStream::new(lines.into_iter());
1008 let mut tracker = SessionTracker::new(stream);
1009 let entries: Vec<_> = tracker.by_ref().collect();
1010
1011 let first = entries[0].session.as_ref().unwrap();
1012 assert_eq!(
1013 first.initial_context.as_deref(),
1014 Some("public"),
1015 "initial_context set on first Processing line"
1016 );
1017 assert_eq!(first.dialplan_context.as_deref(), Some("public"));
1018
1019 let state = tracker.sessions().get(UUID1).unwrap();
1020 assert_eq!(
1021 state.initial_context.as_deref(),
1022 Some("public"),
1023 "initial_context keeps the first context seen"
1024 );
1025 assert_eq!(
1026 state.dialplan_context.as_deref(),
1027 Some("recordings"),
1028 "dialplan_context tracks the current context"
1029 );
1030 assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
1031 }
1032
1033 #[test]
1034 fn new_channel_sets_channel_name() {
1035 let lines = vec![full_line(
1036 UUID1,
1037 TS1,
1038 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1039 )];
1040 let entries = collect_enriched(lines);
1041 let session = entries[0].session.as_ref().unwrap();
1042 assert_eq!(
1043 session.channel_name.as_deref(),
1044 Some("sofia/internal-v4/sos")
1045 );
1046 }
1047
1048 #[test]
1049 fn originate_success_links_both_legs() {
1050 let lines = vec![
1053 full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1054 full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/esinet1-v6-tcp/sip:target.example.com] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901"),
1055 ];
1056 let stream = LogStream::new(lines.into_iter());
1057 let mut tracker = SessionTracker::new(stream);
1058 let _: Vec<_> = tracker.by_ref().collect();
1059
1060 let a_leg = tracker.sessions().get(UUID1).unwrap();
1061 assert_eq!(
1062 a_leg.other_leg_uuid.as_deref(),
1063 Some(UUID2),
1064 "A-leg other_leg_uuid set from Originate Resulted in Success"
1065 );
1066
1067 let b_leg = tracker.sessions().get(UUID2).unwrap();
1068 assert_eq!(
1069 b_leg.other_leg_uuid.as_deref(),
1070 Some(UUID1),
1071 "B-leg other_leg_uuid points back to A-leg"
1072 );
1073 }
1074
1075 #[test]
1076 fn originate_success_channel_fallback_links_legs() {
1077 let lines = vec![
1081 full_line(
1082 UUID2,
1083 TS1,
1084 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1085 ),
1086 full_line(
1087 UUID1,
1088 TS2,
1089 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1090 ),
1091 ];
1092 let stream = LogStream::new(lines.into_iter());
1093 let mut tracker = SessionTracker::new(stream);
1094 let _: Vec<_> = tracker.by_ref().collect();
1095
1096 let a_leg = tracker.sessions().get(UUID1).unwrap();
1097 assert_eq!(
1098 a_leg.other_leg_uuid.as_deref(),
1099 Some(UUID2),
1100 "A-leg linked to B-leg via channel-name fallback when Peer UUID absent"
1101 );
1102
1103 let b_leg = tracker.sessions().get(UUID2).unwrap();
1104 assert_eq!(
1105 b_leg.other_leg_uuid.as_deref(),
1106 Some(UUID1),
1107 "B-leg linked back to A-leg"
1108 );
1109 }
1110
1111 #[test]
1112 fn originate_success_peer_uuid_wins_over_channel_fallback() {
1113 let lines = vec![
1116 full_line(
1117 UUID2,
1118 TS1,
1119 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1120 ),
1121 full_line(
1122 UUID3,
1123 TS1,
1124 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
1125 ),
1126 full_line(
1127 UUID1,
1128 TS2,
1129 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: b2c3d4e5-f6a7-8901-bcde-f12345678901",
1130 ),
1131 ];
1132 let stream = LogStream::new(lines.into_iter());
1133 let mut tracker = SessionTracker::new(stream);
1134 let _: Vec<_> = tracker.by_ref().collect();
1135
1136 let a_leg = tracker.sessions().get(UUID1).unwrap();
1137 assert_eq!(
1138 a_leg.other_leg_uuid.as_deref(),
1139 Some(UUID2),
1140 "Peer UUID wins over channel-name match"
1141 );
1142
1143 let decoy = tracker.sessions().get(UUID3).unwrap();
1144 assert_eq!(
1145 decoy.other_leg_uuid, None,
1146 "Decoy session sharing channel name is not touched"
1147 );
1148 }
1149
1150 #[test]
1151 fn originate_success_channel_fallback_skips_when_ambiguous() {
1152 let lines = vec![
1155 full_line(
1156 UUID2,
1157 TS1,
1158 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1159 ),
1160 full_line(
1161 UUID3,
1162 TS1,
1163 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
1164 ),
1165 full_line(
1166 UUID1,
1167 TS2,
1168 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1169 ),
1170 ];
1171 let stream = LogStream::new(lines.into_iter());
1172 let mut tracker = SessionTracker::new(stream);
1173 let _: Vec<_> = tracker.by_ref().collect();
1174
1175 let a_leg = tracker.sessions().get(UUID1).unwrap();
1176 assert_eq!(
1177 a_leg.other_leg_uuid, None,
1178 "Ambiguous channel name yields no link"
1179 );
1180 assert_eq!(tracker.sessions().get(UUID2).unwrap().other_leg_uuid, None);
1181 assert_eq!(tracker.sessions().get(UUID3).unwrap().other_leg_uuid, None);
1182 }
1183
1184 #[test]
1185 fn originate_success_channel_fallback_skips_terminated_candidates() {
1186 let lines = vec![
1191 full_line(
1192 UUID2,
1193 TS1,
1194 "New Channel sofia/internal/6244@192.0.2.72:50744 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
1195 ),
1196 full_line(
1197 UUID2,
1198 TS1,
1199 "(sofia/internal/6244@192.0.2.72:50744) State Change CS_EXECUTE -> CS_DESTROY",
1200 ),
1201 full_line(
1202 UUID3,
1203 TS1,
1204 "New Channel sofia/internal/6244@192.0.2.72:50744 [c3d4e5f6-a7b8-9012-cdef-234567890123]",
1205 ),
1206 full_line(
1207 UUID1,
1208 TS2,
1209 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1210 ),
1211 ];
1212 let stream = LogStream::new(lines.into_iter());
1213 let mut tracker = SessionTracker::new(stream);
1214 let _: Vec<_> = tracker.by_ref().collect();
1215
1216 let a_leg = tracker.sessions().get(UUID1).unwrap();
1217 assert_eq!(
1218 a_leg.other_leg_uuid.as_deref(),
1219 Some(UUID3),
1220 "Live b-leg wins over CS_DESTROY straggler"
1221 );
1222
1223 let live_b = tracker.sessions().get(UUID3).unwrap();
1224 assert_eq!(
1225 live_b.other_leg_uuid.as_deref(),
1226 Some(UUID1),
1227 "Live b-leg points back to a-leg"
1228 );
1229
1230 let stale_b = tracker.sessions().get(UUID2).unwrap();
1231 assert_eq!(
1232 stale_b.other_leg_uuid, None,
1233 "Terminated b-leg is not touched"
1234 );
1235 }
1236
1237 #[test]
1238 fn originate_success_channel_fallback_skips_when_no_match() {
1239 let lines = vec![full_line(
1242 UUID1,
1243 TS2,
1244 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744]",
1245 )];
1246 let stream = LogStream::new(lines.into_iter());
1247 let mut tracker = SessionTracker::new(stream);
1248 let _: Vec<_> = tracker.by_ref().collect();
1249
1250 let a_leg = tracker.sessions().get(UUID1).unwrap();
1251 assert_eq!(a_leg.other_leg_uuid, None);
1252 assert_eq!(a_leg.pending_bridge_target, None);
1253 }
1254
1255 #[test]
1256 fn bridge_origination_uuid_links_a_leg_immediately() {
1257 let lines = vec![
1260 full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal-v6/1232@[2001:db8::10] bridge([origination_uuid=b2c3d4e5-f6a7-8901-bcde-f12345678901,leg_timeout=2]sofia/esinet1-v6-tcp/sip:target.example.com)"),
1261 full_line(UUID2, TS1, "New Channel sofia/esinet1-v6-tcp/sip:target.example.com [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1262 ];
1263 let stream = LogStream::new(lines.into_iter());
1264 let mut tracker = SessionTracker::new(stream);
1265 let _: Vec<_> = tracker.by_ref().collect();
1266
1267 let a_leg = tracker.sessions().get(UUID1).unwrap();
1268 assert_eq!(
1269 a_leg.other_leg_uuid.as_deref(),
1270 Some(UUID2),
1271 "A-leg knows B-leg UUID from origination_uuid in bridge args"
1272 );
1273
1274 let b_leg = tracker.sessions().get(UUID2).unwrap();
1275 assert_eq!(
1276 b_leg.other_leg_uuid.as_deref(),
1277 Some(UUID1),
1278 "B-leg knows A-leg once New Channel correlates"
1279 );
1280 }
1281
1282 #[test]
1283 fn bridge_target_matches_new_channel() {
1284 let lines = vec![
1287 full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1288 full_line(UUID1, TS1, "Parsing session specific variables"),
1289 full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1290 ];
1291 let stream = LogStream::new(lines.into_iter());
1292 let mut tracker = SessionTracker::new(stream);
1293 let _: Vec<_> = tracker.by_ref().collect();
1294
1295 let a_leg = tracker.sessions().get(UUID1).unwrap();
1296 assert_eq!(
1297 a_leg.other_leg_uuid.as_deref(),
1298 Some(UUID2),
1299 "A-leg linked to B-leg via bridge target matching New Channel"
1300 );
1301
1302 let b_leg = tracker.sessions().get(UUID2).unwrap();
1303 assert_eq!(
1304 b_leg.other_leg_uuid.as_deref(),
1305 Some(UUID1),
1306 "B-leg linked back to A-leg"
1307 );
1308 }
1309
1310 #[test]
1311 fn originate_success_corrects_wrong_target_match() {
1312 let lines = vec![
1315 full_line(UUID1, TS1, "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 bridge(sofia/gateway/carrier/+15559876543)"),
1316 full_line(UUID2, TS1, "New Channel sofia/gateway/carrier/+15559876543 [b2c3d4e5-f6a7-8901-bcde-f12345678901]"),
1317 full_line(UUID1, TS2, "Originate Resulted in Success: [sofia/gateway/carrier/+15559876543] Peer UUID: c3d4e5f6-a7b8-9012-cdef-234567890123"),
1318 ];
1319 let stream = LogStream::new(lines.into_iter());
1320 let mut tracker = SessionTracker::new(stream);
1321 let _: Vec<_> = tracker.by_ref().collect();
1322
1323 let a_leg = tracker.sessions().get(UUID1).unwrap();
1324 assert_eq!(
1325 a_leg.other_leg_uuid.as_deref(),
1326 Some(UUID3),
1327 "Originate success overrides earlier target-match guess"
1328 );
1329
1330 let real_b_leg = tracker.sessions().get(UUID3).unwrap();
1331 assert_eq!(
1332 real_b_leg.other_leg_uuid.as_deref(),
1333 Some(UUID1),
1334 "Real B-leg points back to A-leg"
1335 );
1336 }
1337
1338 #[test]
1339 fn channel_data_other_leg_uuid() {
1340 let lines = vec![
1342 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1343 format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1344 ];
1345 let stream = LogStream::new(lines.into_iter());
1346 let mut tracker = SessionTracker::new(stream);
1347 let _: Vec<_> = tracker.by_ref().collect();
1348
1349 let state = tracker.sessions().get(UUID1).unwrap();
1350 assert_eq!(
1351 state.other_leg_uuid.as_deref(),
1352 Some(UUID2),
1353 "other_leg_uuid set from Other-Leg-Unique-ID CHANNEL_DATA field"
1354 );
1355 }
1356
1357 #[test]
1358 fn relink_removes_stale_by_other_leg_entry() {
1359 let lines = vec![
1364 full_line(UUID2, TS1, "CHANNEL_DATA:"),
1365 format!("{UUID2} Other-Leg-Unique-ID: [{UUID3}]"),
1366 full_line(
1367 UUID1,
1368 TS2,
1369 &format!(
1370 "Originate Resulted in Success: [sofia/internal/6244@192.0.2.72:50744] Peer UUID: {UUID2}"
1371 ),
1372 ),
1373 full_line(
1374 UUID3,
1375 TS2,
1376 &format!("New Channel sofia/external/dest@192.0.2.9 [{UUID3}]"),
1377 ),
1378 ];
1379 let stream = LogStream::new(lines.into_iter());
1380 let mut tracker = SessionTracker::new(stream);
1381 let _: Vec<_> = tracker.by_ref().collect();
1382
1383 let a_leg = tracker.sessions().get(UUID1).unwrap();
1384 assert_eq!(a_leg.other_leg_uuid.as_deref(), Some(UUID2));
1385
1386 let b_leg = tracker.sessions().get(UUID2).unwrap();
1387 assert_eq!(
1388 b_leg.other_leg_uuid.as_deref(),
1389 Some(UUID1),
1390 "authoritative Peer UUID link must survive the unrelated New Channel"
1391 );
1392
1393 let c_leg = tracker.sessions().get(UUID3).unwrap();
1394 assert_eq!(
1395 c_leg.other_leg_uuid, None,
1396 "New Channel on C must not back-link via the superseded index entry"
1397 );
1398 }
1399
1400 #[test]
1401 fn channel_data_populates_session() {
1402 let lines = vec![
1403 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1404 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1405 format!("{UUID1} Channel-State: [CS_EXECUTE]"),
1406 "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1407 "variable_direction: [inbound]".to_string(),
1408 ];
1409 let entries = collect_enriched(lines);
1410 assert_eq!(entries.len(), 1);
1411 let session = entries[0].session.as_ref().unwrap();
1412 assert_eq!(
1413 session.channel_name.as_deref(),
1414 Some("sofia/internal/+15550001234@192.0.2.1")
1415 );
1416 assert_eq!(session.channel_state.as_deref(), Some("CS_EXECUTE"));
1417 }
1418
1419 #[test]
1420 fn variables_learned_from_channel_data() {
1421 let lines = vec![
1422 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1423 "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1424 "variable_direction: [inbound]".to_string(),
1425 ];
1426 let stream = LogStream::new(lines.into_iter());
1427 let mut tracker = SessionTracker::new(stream);
1428 let _: Vec<_> = tracker.by_ref().collect();
1429 let state = tracker.sessions().get(UUID1).unwrap();
1430 assert_eq!(
1431 state.variables.get("sip_call_id").map(|s| s.as_str()),
1432 Some("test123@192.0.2.1")
1433 );
1434 assert_eq!(
1435 state.variables.get("direction").map(|s| s.as_str()),
1436 Some("inbound")
1437 );
1438 }
1439
1440 #[test]
1441 fn typed_variable_accessor() {
1442 let lines = vec![
1443 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1444 "variable_sip_call_id: [test123@192.0.2.1]".to_string(),
1445 ];
1446 let stream = LogStream::new(lines.into_iter());
1447 let mut tracker = SessionTracker::new(stream);
1448 let _: Vec<_> = tracker.by_ref().collect();
1449 let state = tracker.sessions().get(UUID1).unwrap();
1450 assert_eq!(
1451 state.variable(SofiaVariable::SipCallId),
1452 Some("test123@192.0.2.1")
1453 );
1454 assert_eq!(state.variable(ChannelVariable::Direction), None);
1455 }
1456
1457 #[test]
1458 fn multi_line_variable_survives_attached_rescan() {
1459 let lines = vec![
1462 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1463 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1464 format!("{UUID1} variable_switch_r_sdp: [v=0"),
1465 "o=FreeSWITCH 1737000000 1737000001 IN IP4 192.0.2.10".to_string(),
1466 "s=FreeSWITCH".to_string(),
1467 "c=IN IP4 192.0.2.10".to_string(),
1468 "m=audio 30000 RTP/AVP 0 101".to_string(),
1469 "]".to_string(),
1470 format!("{UUID1} variable_direction: [inbound]"),
1471 ];
1472 let stream = LogStream::new(lines.into_iter());
1473 let mut tracker = SessionTracker::new(stream);
1474 let _: Vec<_> = tracker.by_ref().collect();
1475
1476 let state = tracker.sessions().get(UUID1).unwrap();
1477 let sdp = state
1478 .variables
1479 .get("switch_r_sdp")
1480 .expect("switch_r_sdp variable present");
1481 assert!(
1482 sdp.contains('\n'),
1483 "expected full reassembled value, got fragment: {sdp:?}"
1484 );
1485 assert!(sdp.starts_with("v=0\n"));
1486 assert!(sdp.contains("m=audio 30000 RTP/AVP 0 101"));
1487 assert_eq!(
1488 state.variables.get("direction").map(|s| s.as_str()),
1489 Some("inbound")
1490 );
1491 assert_eq!(
1492 state.channel_name.as_deref(),
1493 Some("sofia/internal/+15550001234@192.0.2.1")
1494 );
1495 }
1496
1497 #[test]
1498 fn attached_processing_line_updates_context() {
1499 let lines = vec![
1502 full_line(UUID1, TS1, "Ring-Ready sofia/internal-v4/sos!"),
1503 format!(
1504 "{UUID1} Processing Extension 1263 <1263>->start_recording in context recordings"
1505 ),
1506 ];
1507 let stream = LogStream::new(lines.into_iter());
1508 let mut tracker = SessionTracker::new(stream);
1509 let _: Vec<_> = tracker.by_ref().collect();
1510
1511 let state = tracker.sessions().get(UUID1).unwrap();
1512 assert_eq!(state.dialplan_context.as_deref(), Some("recordings"));
1513 assert_eq!(
1514 state.dialplan_from.as_deref(),
1515 Some("Extension 1263 <1263>")
1516 );
1517 assert_eq!(state.dialplan_to.as_deref(), Some("start_recording"));
1518 assert_eq!(
1519 state.initial_destination.as_deref(),
1520 Some("start_recording")
1521 );
1522 }
1523
1524 #[test]
1525 fn variables_learned_from_set_execute() {
1526 let lines = vec![
1527 full_line(UUID1, TS1, "First"),
1528 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(call_direction=inbound)"),
1529 full_line(UUID1, TS2, "After set"),
1530 ];
1531 let stream = LogStream::new(lines.into_iter());
1532 let mut tracker = SessionTracker::new(stream);
1533 let entries: Vec<_> = tracker.by_ref().collect();
1534 assert_eq!(entries.len(), 3);
1535 let state = tracker.sessions().get(UUID1).unwrap();
1536 assert_eq!(
1537 state.variables.get("call_direction").map(|s| s.as_str()),
1538 Some("inbound")
1539 );
1540 }
1541
1542 #[test]
1543 fn variables_learned_from_export_execute() {
1544 let lines = vec![
1545 full_line(UUID1, TS1, "First"),
1546 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 export(originate_timeout=3600)"),
1547 ];
1548 let stream = LogStream::new(lines.into_iter());
1549 let mut tracker = SessionTracker::new(stream);
1550 let _: Vec<_> = tracker.by_ref().collect();
1551 let state = tracker.sessions().get(UUID1).unwrap();
1552 assert_eq!(
1553 state.variables.get("originate_timeout").map(|s| s.as_str()),
1554 Some("3600")
1555 );
1556 }
1557
1558 #[test]
1559 fn session_isolation_between_uuids() {
1560 let lines = vec![
1561 full_line(
1562 UUID1,
1563 TS1,
1564 "Processing 5551111111->5552222222 in context public",
1565 ),
1566 full_line(
1567 UUID2,
1568 TS2,
1569 "Processing 5553333333->5554444444 in context private",
1570 ),
1571 ];
1572 let stream = LogStream::new(lines.into_iter());
1573 let mut tracker = SessionTracker::new(stream);
1574 let _: Vec<_> = tracker.by_ref().collect();
1575 let s1 = tracker.sessions().get(UUID1).unwrap();
1576 let s2 = tracker.sessions().get(UUID2).unwrap();
1577 assert_eq!(s1.dialplan_context.as_deref(), Some("public"));
1578 assert_eq!(s2.dialplan_context.as_deref(), Some("private"));
1579 assert_eq!(s1.dialplan_from.as_deref(), Some("5551111111"));
1580 assert_eq!(s2.dialplan_from.as_deref(), Some("5553333333"));
1581 }
1582
1583 #[test]
1584 fn processing_line_with_regex_type_and_angle_bracket_caller() {
1585 let lines = vec![full_line(
1586 UUID1,
1587 TS1,
1588 "Processing Emergency S R <5550001234>->start_recording in context recordings",
1589 )];
1590 let entries = collect_enriched(lines);
1591 let session = entries[0].session.as_ref().unwrap();
1592 assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1593 assert_eq!(session.dialplan_context.as_deref(), Some("recordings"));
1594 assert_eq!(
1595 session.dialplan_from.as_deref(),
1596 Some("Emergency S R <5550001234>")
1597 );
1598 assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1599 }
1600
1601 #[test]
1602 fn processing_line_extension_format() {
1603 let lines = vec![full_line(
1604 UUID1,
1605 TS1,
1606 "Processing Extension 1263 <1263>->start_recording in context recordings",
1607 )];
1608 let entries = collect_enriched(lines);
1609 let session = entries[0].session.as_ref().unwrap();
1610 assert_eq!(session.initial_context.as_deref(), Some("recordings"));
1611 assert_eq!(
1612 session.dialplan_from.as_deref(),
1613 Some("Extension 1263 <1263>")
1614 );
1615 assert_eq!(session.dialplan_to.as_deref(), Some("start_recording"));
1616 }
1617
1618 #[test]
1619 fn parse_processing_line_anchors_on_last_arrow() {
1620 let dest = |msg: &str| parse_processing_line(msg).map(|dp| dp.to);
1621 assert_eq!(
1622 dest("Processing Anonymous <anonymous>->5550001234 in context public").as_deref(),
1623 Some("5550001234"),
1624 );
1625 assert_eq!(
1626 dest("Processing 5550009999 <5550009999>->5550001234 in context public").as_deref(),
1627 Some("5550001234"),
1628 );
1629 assert_eq!(
1630 dest("Processing Jane Doe <5550009999>->5550001234 in context internal").as_deref(),
1631 Some("5550001234"),
1632 );
1633 assert_eq!(
1635 dest("Processing Weird -> Name <5550009999>->5550001234 in context internal")
1636 .as_deref(),
1637 Some("5550001234"),
1638 );
1639 assert_eq!(
1641 dest("Processing Jane Doe <5550009999>->start_recording in context features")
1642 .as_deref(),
1643 Some("start_recording"),
1644 );
1645 }
1646
1647 #[test]
1648 fn initial_destination_first_wins() {
1649 let lines = vec![
1650 full_line(
1651 UUID1,
1652 TS1,
1653 "Processing Jane Doe <5550009999>->5550001234 in context public",
1654 ),
1655 full_line(
1656 UUID1,
1657 TS2,
1658 "Processing Jane Doe <5550009999>->5550001234 in context transit",
1659 ),
1660 full_line(
1661 UUID1,
1662 TS2,
1663 "Processing Jane Doe <5550009999>->start_recording in context features",
1664 ),
1665 full_line(
1666 UUID1,
1667 TS2,
1668 "Processing Jane Doe <5550009999>->check_end_call in context features",
1669 ),
1670 ];
1671 let stream = LogStream::new(lines.into_iter());
1672 let mut tracker = SessionTracker::new(stream);
1673 let _: Vec<_> = tracker.by_ref().collect();
1674
1675 let state = tracker.sessions().get(UUID1).unwrap();
1676 assert_eq!(
1677 state.initial_destination.as_deref(),
1678 Some("5550001234"),
1679 "initial_destination keeps the dialed number from the first Processing line"
1680 );
1681 assert_eq!(
1682 state.dialplan_to.as_deref(),
1683 Some("check_end_call"),
1684 "dialplan_to is last-wins and gets clobbered by feature-context routing"
1685 );
1686 }
1687
1688 #[test]
1689 fn state_change_updates_channel_state() {
1690 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1691 let entries = collect_enriched(lines);
1692 let session = entries[0].session.as_ref().unwrap();
1693 assert_eq!(session.channel_state.as_deref(), Some("CS_ROUTING"));
1694 }
1695
1696 #[test]
1697 fn callstate_change_updates_channel_state() {
1698 let lines = vec![full_line(
1699 UUID1,
1700 TS1,
1701 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1702 )];
1703 let entries = collect_enriched(lines);
1704 let session = entries[0].session.as_ref().unwrap();
1705 assert_eq!(session.channel_state.as_deref(), Some("RINGING"));
1706 }
1707
1708 #[test]
1709 fn state_change_overrides_callstate() {
1710 let lines = vec![
1711 full_line(
1712 UUID1,
1713 TS1,
1714 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1715 ),
1716 full_line(
1717 UUID1,
1718 TS2,
1719 "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1720 ),
1721 ];
1722 let entries = collect_enriched(lines);
1723 assert_eq!(
1724 entries[0]
1725 .session
1726 .as_ref()
1727 .unwrap()
1728 .channel_state
1729 .as_deref(),
1730 Some("RINGING")
1731 );
1732 assert_eq!(
1733 entries[1]
1734 .session
1735 .as_ref()
1736 .unwrap()
1737 .channel_state
1738 .as_deref(),
1739 Some("CS_EXCHANGE_MEDIA")
1740 );
1741 }
1742
1743 #[test]
1744 fn bleg_lifecycle_extracts_data_from_processing() {
1745 let lines = vec![
1746 full_line(
1747 UUID1,
1748 TS1,
1749 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1750 ),
1751 full_line(
1752 UUID1,
1753 TS1,
1754 "(sofia/internal-v4/sos) State Change CS_NEW -> CS_INIT",
1755 ),
1756 full_line(
1757 UUID1,
1758 TS1,
1759 "(sofia/internal-v4/sos) State Change CS_INIT -> CS_ROUTING",
1760 ),
1761 full_line(
1762 UUID1,
1763 TS1,
1764 "(sofia/internal-v4/sos) State Change CS_ROUTING -> CS_CONSUME_MEDIA",
1765 ),
1766 full_line(
1767 UUID1,
1768 TS1,
1769 "(sofia/internal-v4/sos) Callstate Change DOWN -> RINGING",
1770 ),
1771 full_line(
1772 UUID1,
1773 TS2,
1774 "(sofia/internal-v4/sos) State Change CS_CONSUME_MEDIA -> CS_EXCHANGE_MEDIA",
1775 ),
1776 full_line(
1777 UUID1,
1778 TS2,
1779 "Processing Emergency S R <5550001234>->start_recording in context recordings",
1780 ),
1781 full_line(
1782 UUID1,
1783 TS2,
1784 "(sofia/internal-v4/sos) State Change CS_EXCHANGE_MEDIA -> CS_HANGUP",
1785 ),
1786 ];
1787 let entries = collect_enriched(lines);
1788
1789 let after_ringing = entries[4].session.as_ref().unwrap();
1790 assert_eq!(after_ringing.channel_state.as_deref(), Some("RINGING"));
1791 assert!(after_ringing.initial_context.is_none());
1792
1793 let after_processing = entries[6].session.as_ref().unwrap();
1794 assert_eq!(
1795 after_processing.channel_state.as_deref(),
1796 Some("CS_EXCHANGE_MEDIA")
1797 );
1798 assert_eq!(
1799 after_processing.initial_context.as_deref(),
1800 Some("recordings")
1801 );
1802 assert_eq!(
1803 after_processing.dialplan_from.as_deref(),
1804 Some("Emergency S R <5550001234>")
1805 );
1806 assert_eq!(
1807 after_processing.dialplan_to.as_deref(),
1808 Some("start_recording")
1809 );
1810
1811 let after_hangup = entries[7].session.as_ref().unwrap();
1812 assert_eq!(after_hangup.channel_state.as_deref(), Some("CS_HANGUP"));
1813 assert_eq!(after_hangup.initial_context.as_deref(), Some("recordings"));
1814 }
1815
1816 #[test]
1817 fn channel_name_from_new_channel() {
1818 let lines = vec![full_line(
1819 UUID1,
1820 TS1,
1821 "New Channel sofia/internal-v4/sos [a1b2c3d4-e5f6-7890-abcd-ef1234567890]",
1822 )];
1823 let entries = collect_enriched(lines);
1824 let session = entries[0].session.as_ref().unwrap();
1825 assert_eq!(
1826 session.channel_name.as_deref(),
1827 Some("sofia/internal-v4/sos")
1828 );
1829 }
1830
1831 #[test]
1832 fn remove_session() {
1833 let lines = vec![full_line(
1834 UUID1,
1835 TS1,
1836 "Processing 5551111111->5552222222 in context public",
1837 )];
1838 let stream = LogStream::new(lines.into_iter());
1839 let mut tracker = SessionTracker::new(stream);
1840 let _: Vec<_> = tracker.by_ref().collect();
1841 assert!(tracker.sessions().contains_key(UUID1));
1842 let removed = tracker.remove_session(UUID1).unwrap();
1843 assert_eq!(removed.dialplan_context.as_deref(), Some("public"));
1844 assert!(!tracker.sessions().contains_key(UUID1));
1845 }
1846
1847 #[test]
1848 fn stats_delegation() {
1849 let lines = vec![
1850 full_line(UUID1, TS1, "First"),
1851 full_line(UUID1, TS2, "Second"),
1852 ];
1853 let stream = LogStream::new(lines.into_iter());
1854 let mut tracker = SessionTracker::new(stream);
1855 let _: Vec<_> = tracker.by_ref().collect();
1856 assert_eq!(tracker.stats().lines_processed, 2);
1857 }
1858
1859 #[test]
1860 fn snapshot_reflects_cumulative_state() {
1861 let lines = vec![
1862 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1863 format!("{UUID1} Channel-Name: [sofia/internal/+15550001234@192.0.2.1]"),
1864 format!("{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(foo=bar)"),
1865 full_line(
1866 UUID1,
1867 TS2,
1868 "Processing 5551111111->5552222222 in context public",
1869 ),
1870 ];
1871 let entries = collect_enriched(lines);
1872 assert_eq!(entries.len(), 3);
1873 let first = entries[0].session.as_ref().unwrap();
1874 assert_eq!(
1875 first.channel_name.as_deref(),
1876 Some("sofia/internal/+15550001234@192.0.2.1"),
1877 );
1878 assert!(first.dialplan_context.is_none());
1879
1880 let last = entries[2].session.as_ref().unwrap();
1881 assert_eq!(
1882 last.channel_name.as_deref(),
1883 Some("sofia/internal/+15550001234@192.0.2.1"),
1884 );
1885 assert_eq!(last.dialplan_context.as_deref(), Some("public"));
1886 }
1887
1888 #[test]
1889 fn post_hook_sets_other_leg_uuid() {
1890 let lines = vec![
1891 full_line(UUID1, TS1, "First entry"),
1892 format!(
1893 "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2} Job-UUID: ...)"
1894 ),
1895 ];
1896 let stream = LogStream::new(lines.into_iter());
1897 let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1898 if let MessageKind::Execute {
1899 application,
1900 arguments,
1901 ..
1902 } = &entry.message_kind
1903 {
1904 if application == "set" {
1905 if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1906 let uuid = value.split_whitespace().next().unwrap_or("");
1907 if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1908 state.other_leg_uuid = Some(uuid.to_string());
1909 }
1910 }
1911 }
1912 }
1913 });
1914
1915 let entries: Vec<_> = tracker.by_ref().collect();
1916 assert_eq!(entries.len(), 2);
1917
1918 let session = entries[1].session.as_ref().unwrap();
1919 assert_eq!(
1920 session.other_leg_uuid.as_deref(),
1921 Some(UUID2),
1922 "post_hook should detect uuid_bridge API result"
1923 );
1924 }
1925
1926 #[test]
1927 fn post_hook_does_not_override_builtin() {
1928 let lines = vec![
1929 full_line(UUID1, TS1, "CHANNEL_DATA:"),
1930 format!("{UUID1} Other-Leg-Unique-ID: [{UUID2}]"),
1931 format!(
1932 "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID3} Job-UUID: ...)"
1933 ),
1934 ];
1935 let stream = LogStream::new(lines.into_iter());
1936 let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
1937 if let MessageKind::Execute {
1938 application,
1939 arguments,
1940 ..
1941 } = &entry.message_kind
1942 {
1943 if application == "set" {
1944 if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
1945 let uuid = value.split_whitespace().next().unwrap_or("");
1946 if uuid.len() == 36 && state.other_leg_uuid.is_none() {
1947 state.other_leg_uuid = Some(uuid.to_string());
1948 }
1949 }
1950 }
1951 }
1952 });
1953
1954 let entries: Vec<_> = tracker.by_ref().collect();
1955 assert_eq!(entries.len(), 2);
1956
1957 let session = entries[1].session.as_ref().unwrap();
1958 assert_eq!(
1959 session.other_leg_uuid.as_deref(),
1960 Some(UUID2),
1961 "built-in Other-Leg-Unique-ID takes precedence over hook"
1962 );
1963 }
1964
1965 #[test]
1966 fn pre_hook_runs_before_builtin() {
1967 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1968 let stream = LogStream::new(lines.into_iter());
1969 let mut tracker = SessionTracker::new(stream).with_pre_hook(|_entry, state| {
1970 state.channel_state = Some("PRE_SET".to_string());
1971 });
1972 let entries: Vec<_> = tracker.by_ref().collect();
1973 assert_eq!(
1974 entries[0]
1975 .session
1976 .as_ref()
1977 .unwrap()
1978 .channel_state
1979 .as_deref(),
1980 Some("CS_ROUTING"),
1981 "built-in overwrites pre_hook value when no guard"
1982 );
1983 }
1984
1985 #[test]
1986 fn post_hook_runs_after_builtin() {
1987 let lines = vec![full_line(UUID1, TS1, "State Change CS_INIT -> CS_ROUTING")];
1988 let stream = LogStream::new(lines.into_iter());
1989 let mut tracker = SessionTracker::new(stream).with_post_hook(|_entry, state| {
1990 if state.channel_state.as_deref() == Some("CS_ROUTING") {
1991 state
1992 .variables
1993 .insert("routing_seen".to_string(), "true".to_string());
1994 }
1995 });
1996 let _: Vec<_> = tracker.by_ref().collect();
1997 let state = tracker.sessions().get(UUID1).unwrap();
1998 assert_eq!(
1999 state.variables.get("routing_seen").map(|s| s.as_str()),
2000 Some("true"),
2001 "post_hook can read fields set by built-in"
2002 );
2003 }
2004
2005 #[test]
2006 fn post_hook_other_leg_uuid_maintains_index_for_backlink() {
2007 let lines = vec![
2010 full_line(UUID1, TS1, "First entry"),
2011 format!(
2012 "{UUID1} EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 set(api_result=+OK {UUID2})"
2013 ),
2014 full_line(
2015 UUID2,
2016 TS2,
2017 "New Channel sofia/internal/target@192.0.2.9 [b2c3d4e5-f6a7-8901-bcde-f12345678901]",
2018 ),
2019 ];
2020 let stream = LogStream::new(lines.into_iter());
2021 let mut tracker = SessionTracker::new(stream).with_post_hook(|entry, state| {
2022 if let MessageKind::Execute {
2023 application,
2024 arguments,
2025 ..
2026 } = &entry.message_kind
2027 {
2028 if application == "set" {
2029 if let Some(value) = arguments.strip_prefix("api_result=+OK ") {
2030 let uuid = value.split_whitespace().next().unwrap_or("");
2031 if uuid.len() == 36 && state.other_leg_uuid.is_none() {
2032 state.other_leg_uuid = Some(uuid.to_string());
2033 }
2034 }
2035 }
2036 }
2037 });
2038 let _: Vec<_> = tracker.by_ref().collect();
2039
2040 let a_leg = tracker.sessions().get(UUID1).unwrap();
2041 assert_eq!(a_leg.other_leg_uuid.as_deref(), Some(UUID2));
2042
2043 let b_leg = tracker.sessions().get(UUID2).unwrap();
2044 assert_eq!(
2045 b_leg.other_leg_uuid.as_deref(),
2046 Some(UUID1),
2047 "B-leg back-links via by_other_leg index populated by the hook"
2048 );
2049 }
2050
2051 #[test]
2052 fn pre_hook_channel_name_maintains_index_for_originate_fallback() {
2053 let lines = vec![
2056 full_line(UUID2, TS1, "custom-channel-announce sofia/custom/6244"),
2057 full_line(
2058 UUID1,
2059 TS2,
2060 "Originate Resulted in Success: [sofia/custom/6244]",
2061 ),
2062 ];
2063 let stream = LogStream::new(lines.into_iter());
2064 let mut tracker = SessionTracker::new(stream).with_pre_hook(|entry, state| {
2065 if let Some(chan) = entry.message.strip_prefix("custom-channel-announce ") {
2066 state.channel_name = Some(chan.to_string());
2067 }
2068 });
2069 let _: Vec<_> = tracker.by_ref().collect();
2070
2071 let a_leg = tracker.sessions().get(UUID1).unwrap();
2072 assert_eq!(
2073 a_leg.other_leg_uuid.as_deref(),
2074 Some(UUID2),
2075 "fallback finds hook-named B-leg via by_channel_name index"
2076 );
2077 let b_leg = tracker.sessions().get(UUID2).unwrap();
2078 assert_eq!(b_leg.other_leg_uuid.as_deref(), Some(UUID1));
2079 }
2080
2081 #[test]
2082 fn parse_hangup_extracts_cause() {
2083 assert_eq!(
2084 parse_hangup("Hangup sofia/internal/1234 [NORMAL_CLEARING]"),
2085 Some("NORMAL_CLEARING".to_string())
2086 );
2087 assert_eq!(
2088 parse_hangup("Hangup sofia/internal/1234 [USER_BUSY]"),
2089 Some("USER_BUSY".to_string())
2090 );
2091 assert_eq!(parse_hangup("Some other message"), None);
2092 assert_eq!(parse_hangup("New Channel sofia/internal/1234 [uuid]"), None);
2093 }
2094
2095 #[test]
2096 fn is_answered_detects_answer_event() {
2097 assert!(is_answered("sofia/internal/1234 has been answered"));
2098 assert!(!is_answered("sofia/internal/1234 is ringing"));
2099 assert!(!is_answered("New Channel sofia/internal/1234"));
2100 }
2101
2102 #[test]
2103 fn hangup_cause_from_lifecycle() {
2104 let lines = vec![full_line(
2105 UUID1,
2106 TS1,
2107 "Hangup sofia/internal/+15550001234@192.0.2.1 [NORMAL_CLEARING]",
2108 )];
2109 let entries = collect_enriched(lines);
2110 let session = entries[0].session.as_ref().unwrap();
2111 assert_eq!(
2112 session.hangup_cause.as_deref(),
2113 Some("NORMAL_CLEARING"),
2114 "hangup_cause extracted from ChannelLifecycle Hangup"
2115 );
2116 }
2117
2118 #[test]
2119 fn answered_at_from_lifecycle() {
2120 let lines = vec![full_line(
2121 UUID1,
2122 TS1,
2123 "sofia/internal/+15550001234@192.0.2.1 has been answered",
2124 )];
2125 let entries = collect_enriched(lines);
2126 let session = entries[0].session.as_ref().unwrap();
2127 assert_eq!(
2128 session.answered_at.as_deref(),
2129 Some(TS1),
2130 "answered_at captures timestamp when 'has been answered' seen"
2131 );
2132 }
2133
2134 #[test]
2135 fn answered_at_not_overwritten() {
2136 let lines = vec![
2137 full_line(
2138 UUID1,
2139 TS1,
2140 "sofia/internal/+15550001234@192.0.2.1 has been answered",
2141 ),
2142 full_line(
2143 UUID1,
2144 TS2,
2145 "sofia/internal/+15550001234@192.0.2.1 has been answered",
2146 ),
2147 ];
2148 let entries = collect_enriched(lines);
2149 let session = entries[1].session.as_ref().unwrap();
2150 assert_eq!(
2151 session.answered_at.as_deref(),
2152 Some(TS1),
2153 "answered_at preserves first answer timestamp"
2154 );
2155 }
2156
2157 #[test]
2158 fn caller_id_name_from_channel_data() {
2159 let lines = vec![
2160 full_line(UUID1, TS1, "CHANNEL_DATA:"),
2161 format!("{UUID1} Caller-Caller-ID-Name: [Test Caller Name]"),
2162 ];
2163 let entries = collect_enriched(lines);
2164 let session = entries[0].session.as_ref().unwrap();
2165 assert_eq!(
2166 session.caller_id_name.as_deref(),
2167 Some("Test Caller Name"),
2168 "caller_id_name extracted from CHANNEL_DATA"
2169 );
2170 }
2171
2172 fn track(lines: Vec<String>) -> SessionTracker<std::vec::IntoIter<String>> {
2173 let mut tracker = SessionTracker::new(LogStream::new(lines.into_iter()));
2174 for _ in tracker.by_ref() {}
2175 tracker
2176 }
2177
2178 #[test]
2179 fn conference_execute_shares_one_instance() {
2180 let tracker = track(vec![
2181 format!("{UUID1} EXECUTE [depth=0] loopback/tty-a conference(835)"),
2182 format!("{UUID2} EXECUTE [depth=0] sofia/internal/1000 conference(835)"),
2183 format!("{UUID3} EXECUTE [depth=0] sofia/internal/1001 conference(844)"),
2184 ]);
2185
2186 let first = tracker.sessions()[UUID1].conference.clone().unwrap();
2187 let second = tracker.sessions()[UUID2].conference.clone().unwrap();
2188 let other = tracker.sessions()[UUID3].conference.clone().unwrap();
2189
2190 assert_eq!(first.name, "835");
2191 assert_eq!(first.instance, UUID1, "first joiner names the instance");
2192 assert_eq!(second.instance, UUID1);
2193 assert_eq!(other.name, "844");
2194 assert_ne!(other.instance, first.instance);
2195
2196 let mut members: Vec<&str> = tracker.conference_members(&first.instance).collect();
2197 members.sort_unstable();
2198 assert_eq!(members, [UUID1, UUID2]);
2199 }
2200
2201 #[test]
2202 fn conference_transfer_line_joins() {
2203 let tracker = track(vec![full_line(
2204 UUID1,
2205 TS1,
2206 "Transfer loopback/tty-a to inline[conference:835@default]",
2207 )]);
2208 let membership = tracker.sessions()[UUID1].conference.clone().unwrap();
2209 assert_eq!(membership.name, "835");
2210 assert_eq!(
2211 membership.profile, None,
2212 "the transfer context is not a conference profile"
2213 );
2214 }
2215
2216 #[test]
2217 fn conference_variables_fill_member_id() {
2218 let tracker = track(vec![
2219 full_line(UUID1, TS1, "CHANNEL_DATA:"),
2220 format!("{UUID1} variable_conference_name: [835]"),
2221 format!("{UUID1} variable_conference_member_id: [3]"),
2222 format!("{UUID1} variable_conference_uuid: [{UUID2}]"),
2223 full_line(UUID1, TS2, "later"),
2224 ]);
2225 let membership = tracker.sessions()[UUID1].conference.clone().unwrap();
2226 assert_eq!(membership.name, "835");
2227 assert_eq!(membership.member_id, Some(3));
2228 assert_eq!(membership.conference_uuid.as_deref(), Some(UUID2));
2229 }
2230
2231 #[test]
2232 fn reused_name_after_the_last_leave_is_a_new_instance() {
2233 let tracker = track(vec![
2234 format!("{UUID1} EXECUTE [depth=0] loopback/tty-a conference(835)"),
2235 full_line(
2236 UUID1,
2237 TS1,
2238 "Channel leaving conference, cause: NORMAL_CLEARING",
2239 ),
2240 format!("{UUID2} EXECUTE [depth=0] sofia/internal/1000 conference(835)"),
2241 ]);
2242
2243 assert!(
2244 tracker.sessions()[UUID1].conference.is_none(),
2245 "leaving clears the membership"
2246 );
2247 let rejoined = tracker.sessions()[UUID2].conference.clone().unwrap();
2248 assert_eq!(rejoined.instance, UUID2);
2249 }
2250
2251 #[test]
2252 fn media_keeps_the_outcome_and_the_deduped_offer_set() {
2253 let tracker = track(vec![
2254 full_line(
2255 UUID1,
2256 TS1,
2257 "Audio Codec Compare [opus:102:16000:20:0:1]/[G722:9:16000:20:64000:1]",
2258 ),
2259 full_line(
2260 UUID1,
2261 TS1,
2262 "Audio Codec Compare [opus:102:16000:20:0:1]/[opus:116:16000:20:0:1]",
2263 ),
2264 full_line(
2265 UUID1,
2266 TS1,
2267 "Audio Codec Compare [opus:116:16000:20:0:1] ++++ is saved as a match",
2268 ),
2269 full_line(UUID1, TS2, "Video Codec Compare [H264:109]/[H263:34]"),
2270 full_line(
2271 UUID1,
2272 TS2,
2273 "Video Codec Compare [H264:109] +++ is saved as a match",
2274 ),
2275 full_line(
2276 UUID1,
2277 TS2,
2278 "Set Codec sofia/internal/1000 opus/16000 20 ms 320 samples 0 bits 1 channels",
2279 ),
2280 full_line(
2281 UUID1,
2282 TS2,
2283 "sofia/internal/1000 Original read codec set to opus:116",
2284 ),
2285 ]);
2286 let media = &tracker.sessions()[UUID1].media;
2287
2288 assert_eq!(
2289 media.audio.offered.len(),
2290 1,
2291 "the same remote offer compared twice is one entry: {:?}",
2292 media.audio.offered
2293 );
2294 assert_eq!(media.audio.negotiated.as_ref().unwrap().name, "opus");
2295 assert_eq!(media.audio.negotiated.as_ref().unwrap().payload_type, 116);
2296
2297 assert_eq!(media.video.negotiated.as_ref().unwrap().name, "H264");
2298 assert!(
2299 media.audio.negotiated != media.video.negotiated,
2300 "audio and video outcomes are tracked apart"
2301 );
2302
2303 assert_eq!(media.read_codec.as_ref().unwrap().payload_type, 116);
2304 assert_eq!(media.active_audio.as_ref().unwrap().clock_rate, Some(16000));
2305 }
2306
2307 #[test]
2308 fn loopback_b_leg_links_to_its_a_leg() {
2309 let tracker = track(vec![
2310 full_line(UUID1, TS1, "New Channel loopback/tty-a [ignored]"),
2311 full_line(UUID2, TS1, "New Channel loopback/tty-b [ignored]"),
2312 ]);
2313 assert_eq!(
2314 tracker.sessions()[UUID1].other_leg_uuid.as_deref(),
2315 Some(UUID2)
2316 );
2317 assert_eq!(
2318 tracker.sessions()[UUID2].other_leg_uuid.as_deref(),
2319 Some(UUID1)
2320 );
2321 }
2322
2323 #[test]
2324 fn concurrent_loopbacks_to_one_destination_do_not_link() {
2325 let tracker = track(vec![
2326 full_line(UUID1, TS1, "New Channel loopback/tty-a [ignored]"),
2327 full_line(UUID2, TS1, "New Channel loopback/tty-a [ignored]"),
2328 full_line(UUID3, TS1, "New Channel loopback/tty-b [ignored]"),
2329 ]);
2330 assert!(
2331 tracker.sessions()[UUID3].other_leg_uuid.is_none(),
2332 "two live A legs share the name; picking one would be a guess"
2333 );
2334 }
2335
2336 #[test]
2337 fn a_member_still_present_holds_the_instance_open() {
2338 let tracker = track(vec![
2339 format!("{UUID1} EXECUTE [depth=0] loopback/tty-a conference(835)"),
2340 format!("{UUID2} EXECUTE [depth=0] sofia/internal/1000 conference(835)"),
2341 full_line(
2342 UUID1,
2343 TS1,
2344 "Channel leaving conference, cause: NORMAL_CLEARING",
2345 ),
2346 format!("{UUID3} EXECUTE [depth=0] sofia/internal/1001 conference(835)"),
2347 ]);
2348 assert_eq!(
2349 tracker.sessions()[UUID3]
2350 .conference
2351 .as_ref()
2352 .unwrap()
2353 .instance,
2354 UUID1
2355 );
2356 }
2357}