1use std::fmt;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum SdpDirection {
6 Local,
7 LocalRing,
9 Remote,
10 Unknown,
12}
13
14#[non_exhaustive]
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SipInviteDirection {
18 Receiving,
20 Sending,
22}
23
24impl fmt::Display for SdpDirection {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 match self {
27 SdpDirection::Local => f.pad("local"),
28 SdpDirection::LocalRing => f.pad("local-ring"),
29 SdpDirection::Remote => f.pad("remote"),
30 SdpDirection::Unknown => f.pad("unknown"),
31 }
32 }
33}
34
35#[non_exhaustive]
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum MessageKind {
42 Execute {
44 depth: u32,
45 channel: String,
46 application: String,
47 arguments: String,
48 },
49 Dialplan { channel: String, detail: String },
51 ChannelData,
53 ChannelField { name: String, value: String },
55 Variable { name: String, value: String },
57 SdpMarker { direction: SdpDirection },
59 StateChange { detail: String },
61 CodecNegotiation,
63 Media { detail: String },
65 ChannelLifecycle { detail: String },
67 SipInvite {
75 direction: SipInviteDirection,
76 profile: String,
78 call_id: Option<String>,
83 },
84 EventSocket { detail: String },
86 General,
88 FileChange,
90 DateChange,
92}
93
94impl MessageKind {
95 pub const ALL_LABELS: &[&str] = &[
97 "execute",
98 "dialplan",
99 "channel-data",
100 "channel-field",
101 "variable",
102 "sdp-marker",
103 "state-change",
104 "codec-negotiation",
105 "media",
106 "channel-lifecycle",
107 "sip-invite",
108 "event-socket",
109 "general",
110 "file-change",
111 "date-change",
112 ];
113
114 pub fn label(&self) -> &'static str {
116 match self {
117 MessageKind::Execute { .. } => "execute",
118 MessageKind::Dialplan { .. } => "dialplan",
119 MessageKind::ChannelData => "channel-data",
120 MessageKind::ChannelField { .. } => "channel-field",
121 MessageKind::Variable { .. } => "variable",
122 MessageKind::SdpMarker { .. } => "sdp-marker",
123 MessageKind::StateChange { .. } => "state-change",
124 MessageKind::CodecNegotiation => "codec-negotiation",
125 MessageKind::Media { .. } => "media",
126 MessageKind::ChannelLifecycle { .. } => "channel-lifecycle",
127 MessageKind::SipInvite { .. } => "sip-invite",
128 MessageKind::EventSocket { .. } => "event-socket",
129 MessageKind::General => "general",
130 MessageKind::FileChange => "file-change",
131 MessageKind::DateChange => "date-change",
132 }
133 }
134}
135
136impl fmt::Display for MessageKind {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match self {
139 MessageKind::Execute { application, .. } => write!(f, "execute({})", application),
140 MessageKind::Dialplan { .. } => f.pad("dialplan"),
141 MessageKind::ChannelData => f.pad("channel-data"),
142 MessageKind::ChannelField { name, .. } => write!(f, "field({})", name),
143 MessageKind::Variable { name, .. } => write!(f, "var({})", name),
144 MessageKind::SdpMarker { direction } => write!(f, "sdp({})", direction),
145 MessageKind::StateChange { .. } => f.pad("state-change"),
146 MessageKind::CodecNegotiation => f.pad("codec-negotiation"),
147 MessageKind::Media { .. } => f.pad("media"),
148 MessageKind::ChannelLifecycle { .. } => f.pad("channel-lifecycle"),
149 MessageKind::SipInvite { .. } => f.pad("sip-invite"),
150 MessageKind::EventSocket { .. } => f.pad("event-socket"),
151 MessageKind::General => f.pad("general"),
152 MessageKind::FileChange => f.pad("file-change"),
153 MessageKind::DateChange => f.pad("date-change"),
154 }
155 }
156}
157
158fn parse_execute(msg: &str) -> MessageKind {
159 let rest = &msg["EXECUTE ".len()..];
160
161 let depth = if rest.starts_with("[depth=") {
162 let end = rest.find(']').unwrap_or(0);
163 if end > 7 {
164 rest[7..end].parse::<u32>().unwrap_or(0)
165 } else {
166 0
167 }
168 } else {
169 return MessageKind::Execute {
170 depth: 0,
171 channel: String::new(),
172 application: String::new(),
173 arguments: rest.to_string(),
174 };
175 };
176
177 let after_bracket = rest.find("] ").map(|p| &rest[p + 2..]).unwrap_or("");
178
179 let (channel, app_part) = match after_bracket.find(' ') {
183 Some(p) => {
184 let first_token = &after_bracket[..p];
185 if first_token.contains('/') {
186 (first_token, &after_bracket[p + 1..])
187 } else {
188 ("", after_bracket)
189 }
190 }
191 None => ("", after_bracket),
192 };
193
194 let (application, arguments) = match app_part.find('(') {
195 Some(p) => {
196 let app = &app_part[..p];
197 let args = if app_part.ends_with(')') {
198 &app_part[p + 1..app_part.len() - 1]
199 } else {
200 &app_part[p + 1..]
201 };
202 (app, args)
203 }
204 None => (app_part, ""),
205 };
206
207 MessageKind::Execute {
208 depth,
209 channel: channel.to_string(),
210 application: application.to_string(),
211 arguments: arguments.to_string(),
212 }
213}
214
215fn parse_dialplan(msg: &str) -> MessageKind {
216 let prefix_len = if msg.starts_with("Chatplan: ") {
217 "Chatplan: ".len()
218 } else {
219 "Dialplan: ".len()
220 };
221 let rest = &msg[prefix_len..];
222 let (channel, detail) = match rest.find(' ') {
223 Some(p) => (&rest[..p], &rest[p + 1..]),
224 None => (rest, ""),
225 };
226 MessageKind::Dialplan {
227 channel: channel.to_string(),
228 detail: detail.to_string(),
229 }
230}
231
232fn parse_bracketed_value(s: &str, prefix_len: usize) -> Option<(&str, &str)> {
233 let after_prefix = &s[prefix_len..];
234 let colon = after_prefix.find(": ")?;
235 let name = &after_prefix[..colon];
236 let value_part = &after_prefix[colon + 2..];
237 if let Some(inner) = value_part.strip_prefix('[') {
238 if let Some(stripped) = inner.strip_suffix(']') {
239 Some((name, stripped))
240 } else {
241 Some((name, inner))
242 }
243 } else {
244 Some((name, value_part))
245 }
246}
247
248fn detect_sdp_direction(msg: &str) -> Option<SdpDirection> {
249 if msg.contains("Ring SDP") {
250 Some(SdpDirection::LocalRing)
251 } else if msg.contains("Local SDP") || msg.contains("local-sdp") {
252 Some(SdpDirection::Local)
253 } else if msg.contains("Remote SDP") || msg.contains("remote-sdp") {
254 Some(SdpDirection::Remote)
255 } else if msg.ends_with(" SDP:") || msg.ends_with(" SDP") {
256 Some(SdpDirection::Unknown)
257 } else {
258 None
259 }
260}
261
262pub fn classify_message(msg: &str) -> MessageKind {
267 if msg.starts_with("EXECUTE ") || msg.starts_with("Execute ") {
268 return parse_execute(msg);
269 }
270
271 if msg.starts_with("Dialplan: ") || msg.starts_with("Chatplan: ") {
272 return parse_dialplan(msg);
273 }
274
275 if msg.starts_with("Processing ")
276 && (msg.contains(" in context ") || msg.contains("recursive conditions"))
277 {
278 return parse_dialplan_processing(msg);
279 }
280
281 if msg.contains("CHANNEL_DATA") {
282 return MessageKind::ChannelData;
283 }
284
285 if msg.starts_with("variable_") {
286 if let Some((name, value)) = parse_bracketed_value(msg, 0) {
287 return MessageKind::Variable {
288 name: name.to_string(),
289 value: value.to_string(),
290 };
291 }
292 }
293
294 if let Some(direction) = detect_sdp_direction(msg) {
295 return MessageKind::SdpMarker { direction };
296 }
297
298 if msg.contains("State Change") || msg.contains("Callstate Change") {
299 return MessageKind::StateChange {
300 detail: msg.to_string(),
301 };
302 }
303
304 if msg.starts_with("SET ") || msg.starts_with("EXPORT ") {
305 if let Some(sv) = parse_set_or_export(msg) {
306 return sv;
307 }
308 }
309
310 if msg.starts_with("Audio Codec Compare ") {
311 return MessageKind::CodecNegotiation;
312 }
313
314 if msg.starts_with("CoreSession::setVariable(") {
315 return parse_core_session_set_variable(msg);
316 }
317
318 if msg.starts_with("UNSET ") {
319 return parse_unset(msg);
320 }
321
322 if let Some(rest) = msg.strip_prefix("set variable ") {
324 if let Some((name, value)) = rest.split_once('=') {
325 return MessageKind::Variable {
326 name: format!("variable_{name}"),
327 value: value.to_string(),
328 };
329 }
330 }
331
332 if msg.starts_with("Transfer ") {
333 return MessageKind::Dialplan {
334 channel: String::new(),
335 detail: msg.to_string(),
336 };
337 }
338
339 if msg.starts_with('(') {
341 if msg.contains(") State ") {
342 return MessageKind::StateChange {
343 detail: msg.to_string(),
344 };
345 }
346 return MessageKind::ChannelLifecycle {
347 detail: msg.to_string(),
348 };
349 }
350
351 if msg.starts_with("SOFIA ") {
353 return MessageKind::StateChange {
354 detail: msg.to_string(),
355 };
356 }
357
358 if msg.starts_with("checking condition") || msg.starts_with("action(") {
360 return MessageKind::ChannelLifecycle {
361 detail: msg.to_string(),
362 };
363 }
364
365 if msg.starts_with("Event Socket Command") {
366 return MessageKind::EventSocket {
367 detail: msg.to_string(),
368 };
369 }
370
371 if let Some(kind) = detect_media(msg) {
373 return kind;
374 }
375
376 if let Some(kind) = detect_channel_lifecycle(msg) {
378 return kind;
379 }
380
381 if let Some((channel_part, rest)) = strip_channel_prefix(msg) {
383 return classify_channel_prefixed(channel_part, rest);
384 }
385
386 if let Some((name, value)) = parse_bracketed_value(msg, 0) {
389 let name_bytes = name.as_bytes();
390 if !name_bytes.is_empty()
391 && !name.contains(' ')
392 && name_bytes[0].is_ascii_alphabetic()
393 && (name.contains('-') || name.starts_with("Channel-"))
394 {
395 return MessageKind::ChannelField {
396 name: name.to_string(),
397 value: value.to_string(),
398 };
399 }
400 }
401
402 MessageKind::General
403}
404
405fn strip_channel_prefix(msg: &str) -> Option<(&str, &str)> {
406 if !msg.starts_with("sofia/") && !msg.starts_with("loopback/") {
407 return None;
408 }
409 let bytes = msg.as_bytes();
410 let mut i = 0;
411 let mut bracket_depth: u32 = 0;
412 while i < bytes.len() {
413 match bytes[i] {
414 b'[' => bracket_depth += 1,
415 b']' => {
416 bracket_depth = bracket_depth.saturating_sub(1);
417 }
418 b' ' if bracket_depth == 0 => {
419 return Some((&msg[..i], &msg[i + 1..]));
420 }
421 _ => {}
422 }
423 i += 1;
424 }
425 None
426}
427
428fn classify_channel_prefixed(channel_part: &str, rest: &str) -> MessageKind {
429 if let Some(direction) = sip_invite_direction(rest) {
434 let profile = extract_sofia_profile(channel_part).unwrap_or_default();
435 let call_id = extract_call_id(rest);
436 return MessageKind::SipInvite {
437 direction,
438 profile,
439 call_id,
440 };
441 }
442
443 if rest.starts_with("SOFIA ") || rest.starts_with("Standard ") || rest.starts_with("RTC ") {
445 return MessageKind::StateChange {
446 detail: rest.to_string(),
447 };
448 }
449
450 if let Some(kind) = detect_media(rest) {
451 return kind;
452 }
453
454 MessageKind::ChannelLifecycle {
456 detail: rest.to_string(),
457 }
458}
459
460fn sip_invite_direction(rest: &str) -> Option<SipInviteDirection> {
461 if rest.starts_with("receiving invite") {
462 Some(SipInviteDirection::Receiving)
463 } else if rest.starts_with("sending invite") {
464 Some(SipInviteDirection::Sending)
465 } else {
466 None
467 }
468}
469
470fn extract_sofia_profile(channel_part: &str) -> Option<String> {
471 let after = channel_part.strip_prefix("sofia/")?;
472 let end = after.find('/').unwrap_or(after.len());
473 if end == 0 {
474 None
475 } else {
476 Some(after[..end].to_string())
477 }
478}
479
480fn extract_call_id(rest: &str) -> Option<String> {
481 let after = rest.split_once("call-id: ")?.1;
482 let token = after.split_whitespace().next()?;
483 if token == "(null)" {
484 None
485 } else {
486 Some(token.to_string())
487 }
488}
489
490fn detect_media(msg: &str) -> Option<MessageKind> {
491 let media_prefixes = [
492 "AUDIO RTP ",
493 "VIDEO RTP ",
494 "Activating ",
495 "RTCP ",
496 "Starting timer",
497 "Record session",
498 "Correct audio",
499 "No silence detection",
500 "Audio params",
501 "Codec ",
502 "Attaching BUG",
503 "Removing BUG",
504 "rtcp_stats_init",
505 "Send middle packet",
506 "Send end packet",
507 "Send first packet",
508 "START_RECORDING",
509 "Stop recording",
510 "Engaging Write Buffer",
511 "rtcp_stats:",
512 ];
513 for prefix in &media_prefixes {
514 if msg.starts_with(prefix) {
515 return Some(MessageKind::Media {
516 detail: msg.to_string(),
517 });
518 }
519 }
520
521 if msg.starts_with("Setting RTCP") || msg.starts_with("Setting BUG Codec") {
522 return Some(MessageKind::Media {
523 detail: msg.to_string(),
524 });
525 }
526
527 if msg.starts_with("Set ") {
528 return Some(MessageKind::Media {
529 detail: msg.to_string(),
530 });
531 }
532
533 if msg.starts_with("Original read codec set to")
534 || msg.starts_with("Forcing crypto_mode")
535 || msg.starts_with("Parsing global variables")
536 || msg.starts_with("Parsing session specific variables")
537 {
538 return Some(MessageKind::Media {
539 detail: msg.to_string(),
540 });
541 }
542
543 None
544}
545
546fn detect_channel_lifecycle(msg: &str) -> Option<MessageKind> {
547 let lifecycle_prefixes = [
548 "New Channel ",
549 "Close Channel ",
550 "Hangup ",
551 "Ring-Ready ",
552 "Ring Ready ",
553 "Pre-Answer ",
554 "Sending early media",
555 "Sending BYE",
556 "Sending CANCEL",
557 "Channel is hung up",
558 "Call appears",
559 "Found channel",
560 "3PCC ",
561 "Subscribed to 3PCC",
562 "New log started",
563 "Received a ",
564 "Session ",
565 "BRIDGE ",
566 "Originate ",
567 "USAGE:",
568 "Split into",
569 "Part ",
570 "Responding to INVITE",
571 "Redirecting to",
572 "subscribing to",
573 "Queue digit delay",
574 ];
575 for prefix in &lifecycle_prefixes {
576 if msg.starts_with(prefix) {
577 return Some(MessageKind::ChannelLifecycle {
578 detail: msg.to_string(),
579 });
580 }
581 }
582
583 if msg.starts_with("Channel ") {
584 return Some(MessageKind::ChannelLifecycle {
585 detail: msg.to_string(),
586 });
587 }
588
589 if msg.starts_with("Application ") && msg.contains("Requires media") {
590 return Some(MessageKind::ChannelLifecycle {
591 detail: msg.to_string(),
592 });
593 }
594
595 None
596}
597
598fn parse_core_session_set_variable(msg: &str) -> MessageKind {
599 let rest = &msg["CoreSession::setVariable(".len()..];
600 if let Some(end) = rest.strip_suffix(')') {
601 if let Some(comma) = end.find(", ") {
602 return MessageKind::Variable {
603 name: format!("variable_{}", &end[..comma]),
604 value: end[comma + 2..].to_string(),
605 };
606 }
607 }
608 MessageKind::Variable {
609 name: String::new(),
610 value: msg.to_string(),
611 }
612}
613
614fn parse_unset(msg: &str) -> MessageKind {
615 let rest = &msg["UNSET ".len()..];
616 let name = if let Some(inner) = rest.strip_prefix('[') {
617 inner.strip_suffix(']').unwrap_or(inner)
618 } else {
619 rest
620 };
621 MessageKind::Variable {
622 name: format!("variable_{name}"),
623 value: String::new(),
624 }
625}
626
627fn parse_dialplan_processing(msg: &str) -> MessageKind {
628 let rest = &msg["Processing ".len()..];
629 MessageKind::Dialplan {
630 channel: String::new(),
631 detail: rest.to_string(),
632 }
633}
634
635fn parse_set_or_export(msg: &str) -> Option<MessageKind> {
636 let sep = msg.find("]=[");
641 if let Some(sep_pos) = sep {
642 let name_start = msg[..sep_pos].rfind('[')?;
643 let name = &msg[name_start + 1..sep_pos];
644 let val_start = sep_pos + 3; let val_end = msg[val_start..]
646 .find(']')
647 .map(|p| val_start + p)
648 .unwrap_or(msg.len());
649 let value = &msg[val_start..val_end];
650 return Some(MessageKind::Variable {
651 name: format!("variable_{name}"),
652 value: value.to_string(),
653 });
654 }
655
656 None
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn execute_full() {
668 let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 db(insert/ng_a1b2c3d4/city/ST GEORGES)";
669 let kind = classify_message(msg);
670 assert_eq!(
671 kind,
672 MessageKind::Execute {
673 depth: 0,
674 channel: "sofia/internal/+15550001234@192.0.2.1".to_string(),
675 application: "db".to_string(),
676 arguments: "insert/ng_a1b2c3d4/city/ST GEORGES".to_string(),
677 }
678 );
679 }
680
681 #[test]
682 fn execute_nested_depth() {
683 let msg = "EXECUTE [depth=2] sofia/internal/+15550001234@192.0.2.1 set(x=y)";
684 match classify_message(msg) {
685 MessageKind::Execute {
686 depth,
687 application,
688 arguments,
689 ..
690 } => {
691 assert_eq!(depth, 2);
692 assert_eq!(application, "set");
693 assert_eq!(arguments, "x=y");
694 }
695 other => panic!("expected Execute, got {other:?}"),
696 }
697 }
698
699 #[test]
700 fn execute_no_arguments() {
701 let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 answer";
702 match classify_message(msg) {
703 MessageKind::Execute {
704 application,
705 arguments,
706 ..
707 } => {
708 assert_eq!(application, "answer");
709 assert_eq!(arguments, "");
710 }
711 other => panic!("expected Execute, got {other:?}"),
712 }
713 }
714
715 #[test]
716 fn execute_export_with_vars() {
717 let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 export(originate_timeout=3600)";
718 match classify_message(msg) {
719 MessageKind::Execute {
720 application,
721 arguments,
722 ..
723 } => {
724 assert_eq!(application, "export");
725 assert_eq!(arguments, "originate_timeout=3600");
726 }
727 other => panic!("expected Execute, got {other:?}"),
728 }
729 }
730
731 #[test]
732 fn dialplan_parsing() {
733 let msg = "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public->global] continue=true";
734 match classify_message(msg) {
735 MessageKind::Dialplan { channel, detail } => {
736 assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
737 assert_eq!(detail, "parsing [public->global] continue=true");
738 }
739 other => panic!("expected Dialplan, got {other:?}"),
740 }
741 }
742
743 #[test]
744 fn dialplan_regex() {
745 let msg = "Dialplan: sofia/internal/+15550001234@192.0.2.1 Regex (PASS) [global_routing] destination_number(18001234567) =~ /^1?(\\d{10})$/ break=on-false";
746 match classify_message(msg) {
747 MessageKind::Dialplan { channel, detail } => {
748 assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
749 assert!(detail.starts_with("Regex (PASS)"));
750 }
751 other => panic!("expected Dialplan, got {other:?}"),
752 }
753 }
754
755 #[test]
756 fn dialplan_action() {
757 let msg =
758 "Dialplan: sofia/internal/+15550001234@192.0.2.1 Action set(call_direction=inbound)";
759 match classify_message(msg) {
760 MessageKind::Dialplan { detail, .. } => {
761 assert!(detail.starts_with("Action "));
762 }
763 other => panic!("expected Dialplan, got {other:?}"),
764 }
765 }
766
767 #[test]
768 fn channel_data_marker() {
769 assert_eq!(classify_message("CHANNEL_DATA:"), MessageKind::ChannelData);
770 }
771
772 #[test]
773 fn channel_data_in_message() {
774 assert_eq!(
775 classify_message("New CHANNEL_DATA arrived"),
776 MessageKind::ChannelData,
777 );
778 }
779
780 #[test]
781 fn channel_field_with_brackets() {
782 let msg = "Channel-State: [CS_EXECUTE]";
783 match classify_message(msg) {
784 MessageKind::ChannelField { name, value } => {
785 assert_eq!(name, "Channel-State");
786 assert_eq!(value, "CS_EXECUTE");
787 }
788 other => panic!("expected ChannelField, got {other:?}"),
789 }
790 }
791
792 #[test]
793 fn channel_field_name() {
794 let msg = "Channel-Name: [sofia/internal/+15550001234@192.0.2.1]";
795 match classify_message(msg) {
796 MessageKind::ChannelField { name, value } => {
797 assert_eq!(name, "Channel-Name");
798 assert_eq!(value, "sofia/internal/+15550001234@192.0.2.1");
799 }
800 other => panic!("expected ChannelField, got {other:?}"),
801 }
802 }
803
804 #[test]
805 fn variable_single_line() {
806 let msg = "variable_sip_call_id: [test123@192.0.2.1]";
807 match classify_message(msg) {
808 MessageKind::Variable { name, value } => {
809 assert_eq!(name, "variable_sip_call_id");
810 assert_eq!(value, "test123@192.0.2.1");
811 }
812 other => panic!("expected Variable, got {other:?}"),
813 }
814 }
815
816 #[test]
817 fn variable_multi_line_start() {
818 let msg = "variable_switch_r_sdp: [v=0";
819 match classify_message(msg) {
820 MessageKind::Variable { name, value } => {
821 assert_eq!(name, "variable_switch_r_sdp");
822 assert_eq!(value, "v=0");
823 }
824 other => panic!("expected Variable, got {other:?}"),
825 }
826 }
827
828 #[test]
829 fn sdp_local() {
830 assert_eq!(
831 classify_message("Local SDP:"),
832 MessageKind::SdpMarker {
833 direction: SdpDirection::Local
834 },
835 );
836 }
837
838 #[test]
839 fn sdp_remote() {
840 assert_eq!(
841 classify_message("Remote SDP:"),
842 MessageKind::SdpMarker {
843 direction: SdpDirection::Remote
844 },
845 );
846 }
847
848 #[test]
849 fn sdp_in_longer_message() {
850 match classify_message("Setting Local SDP for call") {
851 MessageKind::SdpMarker { direction } => {
852 assert_eq!(direction, SdpDirection::Local);
853 }
854 other => panic!("expected SdpMarker, got {other:?}"),
855 }
856 }
857
858 #[test]
859 fn sdp_unknown_direction() {
860 assert_eq!(
861 classify_message("Patched SDP:"),
862 MessageKind::SdpMarker {
863 direction: SdpDirection::Unknown
864 },
865 );
866 }
867
868 #[test]
869 fn ring_sdp_is_local_ring() {
870 assert_eq!(
871 classify_message("Ring SDP:"),
872 MessageKind::SdpMarker {
873 direction: SdpDirection::LocalRing
874 },
875 );
876 }
877
878 #[test]
879 fn state_change() {
880 let msg = "State Change CS_INIT -> CS_ROUTING";
881 match classify_message(msg) {
882 MessageKind::StateChange { detail } => {
883 assert_eq!(detail, msg);
884 }
885 other => panic!("expected StateChange, got {other:?}"),
886 }
887 }
888
889 #[test]
890 fn core_session_set_variable() {
891 match classify_message("CoreSession::setVariable(X-City, ST GEORGES)") {
892 MessageKind::Variable { name, value } => {
893 assert_eq!(name, "variable_X-City");
894 assert_eq!(value, "ST GEORGES");
895 }
896 other => panic!("expected Variable, got {other:?}"),
897 }
898 }
899
900 #[test]
901 fn general_empty() {
902 assert_eq!(classify_message(""), MessageKind::General);
903 }
904
905 #[test]
906 fn hangup_is_channel_lifecycle() {
907 match classify_message(
908 "Hangup sofia/internal/+15550001234@192.0.2.1 [CS_CONSUME_MEDIA] [NORMAL_CLEARING]",
909 ) {
910 MessageKind::ChannelLifecycle { .. } => {}
911 other => panic!("expected ChannelLifecycle, got {other:?}"),
912 }
913 }
914
915 #[test]
916 fn channel_field_no_brackets() {
917 let msg = "Channel-Presence-ID: 1234@192.0.2.1";
918 match classify_message(msg) {
919 MessageKind::ChannelField { name, value } => {
920 assert_eq!(name, "Channel-Presence-ID");
921 assert_eq!(value, "1234@192.0.2.1");
922 }
923 other => panic!("expected ChannelField, got {other:?}"),
924 }
925 }
926
927 #[test]
928 fn variable_no_brackets() {
929 let msg = "variable_direction: inbound";
930 match classify_message(msg) {
931 MessageKind::Variable { name, value } => {
932 assert_eq!(name, "variable_direction");
933 assert_eq!(value, "inbound");
934 }
935 other => panic!("expected Variable, got {other:?}"),
936 }
937 }
938
939 #[test]
942 fn execute_lowercase() {
943 let msg = "Execute [depth=2] set(RECORD_STEREO=true)";
944 match classify_message(msg) {
945 MessageKind::Execute {
946 depth,
947 application,
948 arguments,
949 ..
950 } => {
951 assert_eq!(depth, 2);
952 assert_eq!(application, "set");
953 assert_eq!(arguments, "RECORD_STEREO=true");
954 }
955 other => panic!("expected Execute, got {other:?}"),
956 }
957 }
958
959 #[test]
960 fn execute_lowercase_db() {
961 let msg = "Execute [depth=1] db(insert/ng_${originating_leg_uuid}/record_leg/${uuid})";
962 match classify_message(msg) {
963 MessageKind::Execute { application, .. } => {
964 assert_eq!(application, "db");
965 }
966 other => panic!("expected Execute, got {other:?}"),
967 }
968 }
969
970 #[test]
971 fn set_variable_message() {
972 let msg = "SET sofia/internal-v6/1263@[fd51:2050:2220:198::10] [ngcs_bridge_sip_req_uri]=[conf-factory-app.qc.core.ng.911bell.ca]";
973 match classify_message(msg) {
974 MessageKind::Variable { name, value } => {
975 assert_eq!(name, "variable_ngcs_bridge_sip_req_uri");
976 assert_eq!(value, "conf-factory-app.qc.core.ng.911bell.ca");
977 }
978 other => panic!("expected Variable, got {other:?}"),
979 }
980 }
981
982 #[test]
983 fn export_variable_message() {
984 let msg =
985 "EXPORT (export_vars) (REMOTE ONLY) [sip_from_uri]=[sip:cauca1.qc.psap.ng.911bell.ca]";
986 match classify_message(msg) {
987 MessageKind::Variable { name, value } => {
988 assert_eq!(name, "variable_sip_from_uri");
989 assert_eq!(value, "sip:cauca1.qc.psap.ng.911bell.ca");
990 }
991 other => panic!("expected Variable, got {other:?}"),
992 }
993 }
994
995 #[test]
996 fn export_simple_variable() {
997 let msg = "EXPORT (export_vars) [originate_timeout]=[3600]";
998 match classify_message(msg) {
999 MessageKind::Variable { name, value } => {
1000 assert_eq!(name, "variable_originate_timeout");
1001 assert_eq!(value, "3600");
1002 }
1003 other => panic!("expected Variable, got {other:?}"),
1004 }
1005 }
1006
1007 #[test]
1008 fn processing_in_context() {
1009 let msg = "Processing Extension 1263 <1263>->start_recording in context recordings";
1010 match classify_message(msg) {
1011 MessageKind::Dialplan { detail, .. } => {
1012 assert!(detail.contains("start_recording"));
1013 assert!(detail.contains("recordings"));
1014 }
1015 other => panic!("expected Dialplan, got {other:?}"),
1016 }
1017 }
1018
1019 #[test]
1020 fn caller_field_as_channel_field() {
1021 let msg = "Caller-Username: [+15550001234]";
1022 match classify_message(msg) {
1023 MessageKind::ChannelField { name, value } => {
1024 assert_eq!(name, "Caller-Username");
1025 assert_eq!(value, "+15550001234");
1026 }
1027 other => panic!("expected ChannelField, got {other:?}"),
1028 }
1029 }
1030
1031 #[test]
1032 fn answer_state_as_channel_field() {
1033 let msg = "Answer-State: [ringing]";
1034 match classify_message(msg) {
1035 MessageKind::ChannelField { name, value } => {
1036 assert_eq!(name, "Answer-State");
1037 assert_eq!(value, "ringing");
1038 }
1039 other => panic!("expected ChannelField, got {other:?}"),
1040 }
1041 }
1042
1043 #[test]
1044 fn unique_id_as_channel_field() {
1045 let msg = "Unique-ID: [a1b2c3d4-e5f6-7890-abcd-ef1234567890]";
1046 match classify_message(msg) {
1047 MessageKind::ChannelField { name, value } => {
1048 assert_eq!(name, "Unique-ID");
1049 assert_eq!(value, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
1050 }
1051 other => panic!("expected ChannelField, got {other:?}"),
1052 }
1053 }
1054
1055 #[test]
1056 fn call_direction_as_channel_field() {
1057 let msg = "Call-Direction: [inbound]";
1058 match classify_message(msg) {
1059 MessageKind::ChannelField { name, value } => {
1060 assert_eq!(name, "Call-Direction");
1061 assert_eq!(value, "inbound");
1062 }
1063 other => panic!("expected ChannelField, got {other:?}"),
1064 }
1065 }
1066
1067 #[test]
1068 fn callstate_change() {
1069 let msg = "(sofia/internal-v4/sos) Callstate Change RINGING -> ACTIVE";
1070 match classify_message(msg) {
1071 MessageKind::StateChange { detail } => {
1072 assert!(detail.contains("RINGING -> ACTIVE"));
1073 }
1074 other => panic!("expected StateChange, got {other:?}"),
1075 }
1076 }
1077
1078 #[test]
1079 fn action_is_pre_dialplan_lifecycle() {
1080 match classify_message("action(1:3pcc_force_dialplan:1:set_tflag) success") {
1081 MessageKind::ChannelLifecycle { .. } => {}
1082 other => panic!("expected ChannelLifecycle, got {other:?}"),
1083 }
1084 }
1085
1086 #[test]
1087 fn channel_answered_is_lifecycle() {
1088 match classify_message("Channel [sofia/internal] has been answered") {
1089 MessageKind::ChannelLifecycle { .. } => {}
1090 other => panic!("expected ChannelLifecycle, got {other:?}"),
1091 }
1092 }
1093
1094 #[test]
1095 fn chatplan_regex() {
1096 let msg = "Chatplan: sofia/internal/+15550001234@192.0.2.1 Regex (PASS) [global_routing] destination_number(18001234567) =~ /^1?(\\d{10})$/ break=on-false";
1097 match classify_message(msg) {
1098 MessageKind::Dialplan { channel, detail } => {
1099 assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
1100 assert!(detail.starts_with("Regex (PASS)"));
1101 }
1102 other => panic!("expected Dialplan, got {other:?}"),
1103 }
1104 }
1105
1106 #[test]
1107 fn chatplan_action() {
1108 let msg =
1109 "Chatplan: sofia/internal/+15550001234@192.0.2.1 Action set(call_direction=inbound)";
1110 match classify_message(msg) {
1111 MessageKind::Dialplan { detail, .. } => {
1112 assert!(detail.starts_with("Action "));
1113 }
1114 other => panic!("expected Dialplan, got {other:?}"),
1115 }
1116 }
1117
1118 #[test]
1119 fn chatplan_anti_action() {
1120 let msg =
1121 "Chatplan: sofia/internal/+15550001234@192.0.2.1 ANTI-Action log(WARNING no match)";
1122 match classify_message(msg) {
1123 MessageKind::Dialplan { detail, .. } => {
1124 assert!(detail.starts_with("ANTI-Action "));
1125 }
1126 other => panic!("expected Dialplan, got {other:?}"),
1127 }
1128 }
1129
1130 #[test]
1131 fn standard_execute_is_state_change() {
1132 let msg = "sofia/internal/+15550001234@192.0.2.1 Standard EXECUTE";
1133 match classify_message(msg) {
1134 MessageKind::StateChange { detail } => {
1135 assert_eq!(detail, "Standard EXECUTE");
1136 }
1137 other => panic!("expected StateChange, got {other:?}"),
1138 }
1139 }
1140
1141 #[test]
1142 fn sofia_execute_is_state_change() {
1143 let msg = "sofia/internal/+15550001234@192.0.2.1 SOFIA EXECUTE";
1144 match classify_message(msg) {
1145 MessageKind::StateChange { detail } => {
1146 assert_eq!(detail, "SOFIA EXECUTE");
1147 }
1148 other => panic!("expected StateChange, got {other:?}"),
1149 }
1150 }
1151
1152 #[test]
1153 fn rtc_execute_is_state_change() {
1154 let msg = "sofia/internal/+15550001234@192.0.2.1 RTC EXECUTE";
1155 match classify_message(msg) {
1156 MessageKind::StateChange { detail } => {
1157 assert_eq!(detail, "RTC EXECUTE");
1158 }
1159 other => panic!("expected StateChange, got {other:?}"),
1160 }
1161 }
1162
1163 #[test]
1164 fn standard_soft_execute_is_state_change() {
1165 let msg = "sofia/internal/+15550001234@192.0.2.1 Standard SOFT_EXECUTE";
1166 match classify_message(msg) {
1167 MessageKind::StateChange { detail } => {
1168 assert_eq!(detail, "Standard SOFT_EXECUTE");
1169 }
1170 other => panic!("expected StateChange, got {other:?}"),
1171 }
1172 }
1173
1174 #[test]
1175 fn dialplan_recursive_conditions() {
1176 let msg = "Processing recursive conditions level:1 [default] require-nested=true";
1177 match classify_message(msg) {
1178 MessageKind::Dialplan { detail, .. } => {
1179 assert!(detail.contains("recursive conditions"));
1180 }
1181 other => panic!("expected Dialplan, got {other:?}"),
1182 }
1183 }
1184
1185 #[test]
1186 fn sdp_duplicate_marker() {
1187 let msg = "Duplicate SDP";
1188 match classify_message(msg) {
1189 MessageKind::SdpMarker { direction } => {
1190 assert_eq!(direction, SdpDirection::Unknown);
1191 }
1192 other => panic!("expected SdpMarker, got {other:?}"),
1193 }
1194 }
1195
1196 #[test]
1197 fn sdp_verto_update_media() {
1198 match classify_message("updateMedia: Local SDP") {
1199 MessageKind::SdpMarker { direction } => {
1200 assert_eq!(direction, SdpDirection::Local);
1201 }
1202 other => panic!("expected SdpMarker, got {other:?}"),
1203 }
1204 }
1205
1206 #[test]
1207 fn receiving_invite_routes_to_sip_invite_with_call_id() {
1208 let msg = "sofia/internal/1212@host.example:5062 receiving invite from 192.0.2.10:47215 version: 1.10.13-dev git abc 2026-01-01 00:00:00Z 64bit call-id: 00112233-4455-6677-8899-aabbccddeeff";
1209 match classify_message(msg) {
1210 MessageKind::SipInvite {
1211 direction,
1212 profile,
1213 call_id,
1214 } => {
1215 assert_eq!(direction, SipInviteDirection::Receiving);
1216 assert_eq!(profile, "internal");
1217 assert_eq!(
1218 call_id.as_deref(),
1219 Some("00112233-4455-6677-8899-aabbccddeeff")
1220 );
1221 }
1222 other => panic!("expected SipInvite, got {other:?}"),
1223 }
1224 }
1225
1226 #[test]
1227 fn sending_invite_routes_to_sip_invite() {
1228 let msg = "sofia/internalv6/ngcs_create_conference sending invite call-id: ffeeddcc-bbaa-9988-7766-554433221100";
1229 match classify_message(msg) {
1230 MessageKind::SipInvite {
1231 direction,
1232 profile,
1233 call_id,
1234 } => {
1235 assert_eq!(direction, SipInviteDirection::Sending);
1236 assert_eq!(profile, "internalv6");
1237 assert_eq!(
1238 call_id.as_deref(),
1239 Some("ffeeddcc-bbaa-9988-7766-554433221100")
1240 );
1241 }
1242 other => panic!("expected SipInvite, got {other:?}"),
1243 }
1244 }
1245
1246 #[test]
1247 fn sending_invite_null_call_id_yields_none() {
1248 let msg = "sofia/telus/15555550100 sending invite call-id: (null)";
1249 match classify_message(msg) {
1250 MessageKind::SipInvite {
1251 direction,
1252 profile,
1253 call_id,
1254 } => {
1255 assert_eq!(direction, SipInviteDirection::Sending);
1256 assert_eq!(profile, "telus");
1257 assert_eq!(call_id, None);
1258 }
1259 other => panic!("expected SipInvite, got {other:?}"),
1260 }
1261 }
1262
1263 #[test]
1264 fn sending_invite_version_only_yields_none() {
1265 let msg = "sofia/telus/15555550100 sending invite version: 1.10.13-dev git abc 2026-01-01 00:00:00Z 64bit";
1267 match classify_message(msg) {
1268 MessageKind::SipInvite {
1269 direction, call_id, ..
1270 } => {
1271 assert_eq!(direction, SipInviteDirection::Sending);
1272 assert_eq!(call_id, None);
1273 }
1274 other => panic!("expected SipInvite, got {other:?}"),
1275 }
1276 }
1277
1278 #[test]
1279 fn call_id_with_at_host_port_preserved() {
1280 let msg = "sofia/voipms/15555550101@198.51.100.52 receiving invite from 198.51.100.52:5060 version: 1.10.13-dev git abc 2026-01-01 00:00:00Z 64bit call-id: 00deadbeef00abc123def4567890abcd@198.51.100.52:5060";
1281 match classify_message(msg) {
1282 MessageKind::SipInvite { call_id, .. } => {
1283 assert_eq!(
1284 call_id.as_deref(),
1285 Some("00deadbeef00abc123def4567890abcd@198.51.100.52:5060")
1286 );
1287 }
1288 other => panic!("expected SipInvite, got {other:?}"),
1289 }
1290 }
1291
1292 #[test]
1293 fn non_invite_sofia_lifecycle_still_channel_lifecycle() {
1294 let msg = "sofia/internal/1212@host.example:5062 receiving refer";
1295 match classify_message(msg) {
1296 MessageKind::ChannelLifecycle { .. } => {}
1297 other => panic!("expected ChannelLifecycle, got {other:?}"),
1298 }
1299 }
1300}