1use std::fmt;
2
3use crate::codec::CodecMedia;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum SdpDirection {
8 Local,
9 LocalRing,
11 Remote,
12 Unknown,
14}
15
16#[non_exhaustive]
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SipInviteDirection {
20 Receiving,
22 Sending,
24}
25
26#[non_exhaustive]
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum DtmfSource {
30 Rtp,
32 Channel,
34 SipInfo,
36}
37
38impl fmt::Display for DtmfSource {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 DtmfSource::Rtp => f.pad("rtp"),
42 DtmfSource::Channel => f.pad("channel"),
43 DtmfSource::SipInfo => f.pad("sip-info"),
44 }
45 }
46}
47
48impl fmt::Display for SdpDirection {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 SdpDirection::Local => f.pad("local"),
52 SdpDirection::LocalRing => f.pad("local-ring"),
53 SdpDirection::Remote => f.pad("remote"),
54 SdpDirection::Unknown => f.pad("unknown"),
55 }
56 }
57}
58
59#[non_exhaustive]
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum MessageKind {
66 Execute {
68 depth: u32,
69 channel: String,
70 application: String,
71 arguments: String,
72 },
73 Dialplan { channel: String, detail: String },
75 ChannelData,
77 ChannelField { name: String, value: String },
79 Variable { name: String, value: String },
81 SdpMarker { direction: SdpDirection },
83 StateChange { detail: String },
85 CodecNegotiation { media: CodecMedia },
87 Media { detail: String },
89 ChannelLifecycle { detail: String },
91 SipInvite {
99 direction: SipInviteDirection,
100 profile: String,
102 call_id: Option<String>,
107 },
108 EventSocket { detail: String },
110 Dtmf {
113 source: DtmfSource,
115 digit: char,
117 duration_ms: Option<u32>,
119 },
120 General,
122 FileChange,
124 DateChange,
126}
127
128impl MessageKind {
129 pub const ALL_LABELS: &[&str] = &[
131 "execute",
132 "dialplan",
133 "channel-data",
134 "channel-field",
135 "variable",
136 "sdp-marker",
137 "state-change",
138 "codec-negotiation",
139 "media",
140 "channel-lifecycle",
141 "sip-invite",
142 "event-socket",
143 "dtmf",
144 "general",
145 "file-change",
146 "date-change",
147 ];
148
149 pub fn label(&self) -> &'static str {
151 match self {
152 MessageKind::Execute { .. } => "execute",
153 MessageKind::Dialplan { .. } => "dialplan",
154 MessageKind::ChannelData => "channel-data",
155 MessageKind::ChannelField { .. } => "channel-field",
156 MessageKind::Variable { .. } => "variable",
157 MessageKind::SdpMarker { .. } => "sdp-marker",
158 MessageKind::StateChange { .. } => "state-change",
159 MessageKind::CodecNegotiation { .. } => "codec-negotiation",
160 MessageKind::Media { .. } => "media",
161 MessageKind::ChannelLifecycle { .. } => "channel-lifecycle",
162 MessageKind::SipInvite { .. } => "sip-invite",
163 MessageKind::EventSocket { .. } => "event-socket",
164 MessageKind::Dtmf { .. } => "dtmf",
165 MessageKind::General => "general",
166 MessageKind::FileChange => "file-change",
167 MessageKind::DateChange => "date-change",
168 }
169 }
170}
171
172impl fmt::Display for MessageKind {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 MessageKind::Execute { application, .. } => write!(f, "execute({})", application),
176 MessageKind::Dialplan { .. } => f.pad("dialplan"),
177 MessageKind::ChannelData => f.pad("channel-data"),
178 MessageKind::ChannelField { name, .. } => write!(f, "field({})", name),
179 MessageKind::Variable { name, .. } => write!(f, "var({})", name),
180 MessageKind::SdpMarker { direction } => write!(f, "sdp({})", direction),
181 MessageKind::StateChange { .. } => f.pad("state-change"),
182 MessageKind::CodecNegotiation { media } => {
183 f.pad(&format!("codec-negotiation({media})"))
184 }
185 MessageKind::Media { .. } => f.pad("media"),
186 MessageKind::ChannelLifecycle { .. } => f.pad("channel-lifecycle"),
187 MessageKind::SipInvite { .. } => f.pad("sip-invite"),
188 MessageKind::EventSocket { .. } => f.pad("event-socket"),
189 MessageKind::Dtmf {
190 source,
191 digit,
192 duration_ms,
193 } => match duration_ms {
194 Some(ms) => write!(f, "dtmf({source}:{digit}:{ms}ms)"),
195 None => write!(f, "dtmf({source}:{digit})"),
196 },
197 MessageKind::General => f.pad("general"),
198 MessageKind::FileChange => f.pad("file-change"),
199 MessageKind::DateChange => f.pad("date-change"),
200 }
201 }
202}
203
204fn parse_execute(msg: &str) -> MessageKind {
205 let rest = &msg["EXECUTE ".len()..];
206
207 let depth = if rest.starts_with("[depth=") {
208 let end = rest.find(']').unwrap_or(0);
209 if end > 7 {
210 rest[7..end].parse::<u32>().unwrap_or(0)
211 } else {
212 0
213 }
214 } else {
215 return MessageKind::Execute {
216 depth: 0,
217 channel: String::new(),
218 application: String::new(),
219 arguments: rest.to_string(),
220 };
221 };
222
223 let after_bracket = rest.find("] ").map(|p| &rest[p + 2..]).unwrap_or("");
224
225 let (channel, app_part) = match after_bracket.find(' ') {
229 Some(p) => {
230 let first_token = &after_bracket[..p];
231 if first_token.contains('/') {
232 (first_token, &after_bracket[p + 1..])
233 } else {
234 ("", after_bracket)
235 }
236 }
237 None => ("", after_bracket),
238 };
239
240 let (application, arguments) = match app_part.find('(') {
241 Some(p) => {
242 let app = &app_part[..p];
243 let args = if app_part.ends_with(')') {
244 &app_part[p + 1..app_part.len() - 1]
245 } else {
246 &app_part[p + 1..]
247 };
248 (app, args)
249 }
250 None => (app_part, ""),
251 };
252
253 MessageKind::Execute {
254 depth,
255 channel: channel.to_string(),
256 application: application.to_string(),
257 arguments: arguments.to_string(),
258 }
259}
260
261fn parse_dialplan(msg: &str) -> MessageKind {
262 let prefix_len = if msg.starts_with("Chatplan: ") {
263 "Chatplan: ".len()
264 } else {
265 "Dialplan: ".len()
266 };
267 let rest = &msg[prefix_len..];
268 let (channel, detail) = match rest.find(' ') {
269 Some(p) => (&rest[..p], &rest[p + 1..]),
270 None => (rest, ""),
271 };
272 MessageKind::Dialplan {
273 channel: channel.to_string(),
274 detail: detail.to_string(),
275 }
276}
277
278fn parse_bracketed_value(s: &str, prefix_len: usize) -> Option<(&str, &str)> {
279 let after_prefix = &s[prefix_len..];
280 let colon = after_prefix.find(": ")?;
281 let name = &after_prefix[..colon];
282 let value_part = &after_prefix[colon + 2..];
283 if let Some(inner) = value_part.strip_prefix('[') {
284 if let Some(stripped) = inner.strip_suffix(']') {
285 Some((name, stripped))
286 } else {
287 Some((name, inner))
288 }
289 } else {
290 Some((name, value_part))
291 }
292}
293
294fn detect_sdp_direction(msg: &str) -> Option<SdpDirection> {
295 if msg.contains("Ring SDP") {
296 Some(SdpDirection::LocalRing)
297 } else if msg.contains("Local SDP") || msg.contains("local-sdp") {
298 Some(SdpDirection::Local)
299 } else if msg.contains("Remote SDP") || msg.contains("remote-sdp") {
300 Some(SdpDirection::Remote)
301 } else if msg.ends_with(" SDP:") || msg.ends_with(" SDP") {
302 Some(SdpDirection::Unknown)
303 } else {
304 None
305 }
306}
307
308fn is_valid_dtmf_digit(c: char) -> bool {
309 matches!(c, '0'..='9' | '*' | '#' | 'A'..='D' | 'F')
310}
311
312fn parse_dtmf(msg: &str) -> Option<MessageKind> {
313 if let Some(rest) = msg.strip_prefix("RTP RECV DTMF ") {
315 let colon = rest.find(':')?;
316 if colon != 1 {
317 return None;
318 }
319 let digit = rest.chars().next()?;
320 if !is_valid_dtmf_digit(digit) {
321 return None;
322 }
323 let duration_ms = rest[colon + 1..].parse::<u32>().ok()?;
324 return Some(MessageKind::Dtmf {
325 source: DtmfSource::Rtp,
326 digit,
327 duration_ms: Some(duration_ms),
328 });
329 }
330
331 if let Some(rest) = msg.strip_prefix("RECV DTMF ") {
333 let colon = rest.find(':')?;
334 if colon != 1 {
335 return None;
336 }
337 let digit = rest.chars().next()?;
338 if !is_valid_dtmf_digit(digit) {
339 return None;
340 }
341 let duration_ms = rest[colon + 1..].parse::<u32>().ok()?;
342 return Some(MessageKind::Dtmf {
343 source: DtmfSource::Channel,
344 digit,
345 duration_ms: Some(duration_ms),
346 });
347 }
348
349 if let Some(rest) = msg.strip_prefix("INFO DTMF(") {
351 let close = rest.find(')')?;
352 if close != 1 {
353 return None;
354 }
355 let digit = rest.chars().next()?;
356 if !is_valid_dtmf_digit(digit) {
357 return None;
358 }
359 return Some(MessageKind::Dtmf {
360 source: DtmfSource::SipInfo,
361 digit,
362 duration_ms: None,
363 });
364 }
365
366 None
367}
368
369pub fn classify_message(msg: &str) -> MessageKind {
374 if msg.starts_with("EXECUTE ") || msg.starts_with("Execute ") {
375 return parse_execute(msg);
376 }
377
378 if msg.starts_with("RECV DTMF ")
379 || msg.starts_with("RTP RECV DTMF ")
380 || msg.starts_with("INFO DTMF(")
381 {
382 if let Some(dtmf) = parse_dtmf(msg) {
383 return dtmf;
384 }
385 }
386
387 if msg.starts_with("Dialplan: ") || msg.starts_with("Chatplan: ") {
388 return parse_dialplan(msg);
389 }
390
391 if msg.starts_with("Processing ")
392 && (msg.contains(" in context ") || msg.contains("recursive conditions"))
393 {
394 return parse_dialplan_processing(msg);
395 }
396
397 if msg.contains("CHANNEL_DATA") {
398 return MessageKind::ChannelData;
399 }
400
401 if msg.starts_with("variable_") {
402 if let Some((name, value)) = parse_bracketed_value(msg, 0) {
403 return MessageKind::Variable {
404 name: name.to_string(),
405 value: value.to_string(),
406 };
407 }
408 }
409
410 if let Some(direction) = detect_sdp_direction(msg) {
411 return MessageKind::SdpMarker { direction };
412 }
413
414 if msg.contains("State Change") || msg.contains("Callstate Change") {
415 return MessageKind::StateChange {
416 detail: msg.to_string(),
417 };
418 }
419
420 if msg.starts_with("SET ") || msg.starts_with("EXPORT ") {
421 if let Some(sv) = parse_set_or_export(msg) {
422 return sv;
423 }
424 }
425
426 if msg.starts_with("Audio Codec Compare ") {
427 return MessageKind::CodecNegotiation {
428 media: CodecMedia::Audio,
429 };
430 }
431
432 if msg.starts_with("Video Codec Compare ") {
433 return MessageKind::CodecNegotiation {
434 media: CodecMedia::Video,
435 };
436 }
437
438 if msg.starts_with("CoreSession::setVariable(") {
439 return parse_core_session_set_variable(msg);
440 }
441
442 if msg.starts_with("UNSET ") {
443 return parse_unset(msg);
444 }
445
446 if let Some(rest) = msg.strip_prefix("set variable ") {
448 if let Some((name, value)) = rest.split_once('=') {
449 return MessageKind::Variable {
450 name: format!("variable_{name}"),
451 value: value.to_string(),
452 };
453 }
454 }
455
456 if msg.starts_with("Transfer ") {
457 return MessageKind::Dialplan {
458 channel: String::new(),
459 detail: msg.to_string(),
460 };
461 }
462
463 if msg.starts_with('(') {
465 if msg.contains(") State ") {
466 return MessageKind::StateChange {
467 detail: msg.to_string(),
468 };
469 }
470 return MessageKind::ChannelLifecycle {
471 detail: msg.to_string(),
472 };
473 }
474
475 if msg.starts_with("SOFIA ") {
477 return MessageKind::StateChange {
478 detail: msg.to_string(),
479 };
480 }
481
482 if msg.starts_with("checking condition") || msg.starts_with("action(") {
484 return MessageKind::ChannelLifecycle {
485 detail: msg.to_string(),
486 };
487 }
488
489 if msg.starts_with("Event Socket Command") {
490 return MessageKind::EventSocket {
491 detail: msg.to_string(),
492 };
493 }
494
495 if let Some(kind) = detect_media(msg) {
497 return kind;
498 }
499
500 if let Some(kind) = detect_channel_lifecycle(msg) {
502 return kind;
503 }
504
505 if let Some((channel_part, rest)) = strip_channel_prefix(msg) {
507 return classify_channel_prefixed(channel_part, rest);
508 }
509
510 if let Some((name, value)) = parse_bracketed_value(msg, 0) {
513 let name_bytes = name.as_bytes();
514 if !name_bytes.is_empty()
515 && !name.contains(' ')
516 && name_bytes[0].is_ascii_alphabetic()
517 && (name.contains('-') || name.starts_with("Channel-"))
518 {
519 return MessageKind::ChannelField {
520 name: name.to_string(),
521 value: value.to_string(),
522 };
523 }
524 }
525
526 MessageKind::General
527}
528
529fn strip_channel_prefix(msg: &str) -> Option<(&str, &str)> {
530 if !msg.starts_with("sofia/") && !msg.starts_with("loopback/") {
531 return None;
532 }
533 let bytes = msg.as_bytes();
534 let mut i = 0;
535 let mut bracket_depth: u32 = 0;
536 while i < bytes.len() {
537 match bytes[i] {
538 b'[' => bracket_depth += 1,
539 b']' => {
540 bracket_depth = bracket_depth.saturating_sub(1);
541 }
542 b' ' if bracket_depth == 0 => {
543 return Some((&msg[..i], &msg[i + 1..]));
544 }
545 _ => {}
546 }
547 i += 1;
548 }
549 None
550}
551
552fn classify_channel_prefixed(channel_part: &str, rest: &str) -> MessageKind {
553 if let Some(direction) = sip_invite_direction(rest) {
558 let profile = extract_sofia_profile(channel_part).unwrap_or_default();
559 let call_id = extract_call_id(rest);
560 return MessageKind::SipInvite {
561 direction,
562 profile,
563 call_id,
564 };
565 }
566
567 if rest.starts_with("SOFIA ") || rest.starts_with("Standard ") || rest.starts_with("RTC ") {
569 return MessageKind::StateChange {
570 detail: rest.to_string(),
571 };
572 }
573
574 if let Some(kind) = detect_media(rest) {
575 return kind;
576 }
577
578 MessageKind::ChannelLifecycle {
580 detail: rest.to_string(),
581 }
582}
583
584fn sip_invite_direction(rest: &str) -> Option<SipInviteDirection> {
585 if rest.starts_with("receiving invite") {
586 Some(SipInviteDirection::Receiving)
587 } else if rest.starts_with("sending invite") {
588 Some(SipInviteDirection::Sending)
589 } else {
590 None
591 }
592}
593
594fn extract_sofia_profile(channel_part: &str) -> Option<String> {
595 let after = channel_part.strip_prefix("sofia/")?;
596 let end = after.find('/').unwrap_or(after.len());
597 if end == 0 {
598 None
599 } else {
600 Some(after[..end].to_string())
601 }
602}
603
604fn extract_call_id(rest: &str) -> Option<String> {
605 let after = rest.split_once("call-id: ")?.1;
606 let token = after.split_whitespace().next()?;
607 if token == "(null)" {
608 None
609 } else {
610 Some(token.to_string())
611 }
612}
613
614fn detect_media(msg: &str) -> Option<MessageKind> {
615 let media_prefixes = [
616 "AUDIO RTP ",
617 "VIDEO RTP ",
618 "Activating ",
619 "RTCP ",
620 "Starting timer",
621 "Record session",
622 "Correct audio",
623 "No silence detection",
624 "Audio params",
625 "Codec ",
626 "Attaching BUG",
627 "Removing BUG",
628 "rtcp_stats_init",
629 "Send middle packet",
630 "Send end packet",
631 "Send first packet",
632 "START_RECORDING",
633 "Stop recording",
634 "Engaging Write Buffer",
635 "rtcp_stats:",
636 ];
637 for prefix in &media_prefixes {
638 if msg.starts_with(prefix) {
639 return Some(MessageKind::Media {
640 detail: msg.to_string(),
641 });
642 }
643 }
644
645 if msg.starts_with("Setting RTCP") || msg.starts_with("Setting BUG Codec") {
646 return Some(MessageKind::Media {
647 detail: msg.to_string(),
648 });
649 }
650
651 if msg.starts_with("Set ") {
652 return Some(MessageKind::Media {
653 detail: msg.to_string(),
654 });
655 }
656
657 if msg.starts_with("Original read codec set to")
658 || msg.starts_with("Forcing crypto_mode")
659 || msg.starts_with("Parsing global variables")
660 || msg.starts_with("Parsing session specific variables")
661 {
662 return Some(MessageKind::Media {
663 detail: msg.to_string(),
664 });
665 }
666
667 None
668}
669
670fn detect_channel_lifecycle(msg: &str) -> Option<MessageKind> {
671 let lifecycle_prefixes = [
672 "New Channel ",
673 "Close Channel ",
674 "Hangup ",
675 "Ring-Ready ",
676 "Ring Ready ",
677 "Pre-Answer ",
678 "Sending early media",
679 "Sending BYE",
680 "Sending CANCEL",
681 "Channel is hung up",
682 "Call appears",
683 "Found channel",
684 "3PCC ",
685 "Subscribed to 3PCC",
686 "New log started",
687 "Received a ",
688 "Session ",
689 "BRIDGE ",
690 "Originate ",
691 "USAGE:",
692 "Split into",
693 "Part ",
694 "Responding to INVITE",
695 "Redirecting to",
696 "subscribing to",
697 "Queue digit delay",
698 ];
699 for prefix in &lifecycle_prefixes {
700 if msg.starts_with(prefix) {
701 return Some(MessageKind::ChannelLifecycle {
702 detail: msg.to_string(),
703 });
704 }
705 }
706
707 if msg.starts_with("Channel ") {
708 return Some(MessageKind::ChannelLifecycle {
709 detail: msg.to_string(),
710 });
711 }
712
713 if msg.starts_with("Application ") && msg.contains("Requires media") {
714 return Some(MessageKind::ChannelLifecycle {
715 detail: msg.to_string(),
716 });
717 }
718
719 None
720}
721
722fn parse_core_session_set_variable(msg: &str) -> MessageKind {
723 let rest = &msg["CoreSession::setVariable(".len()..];
724 if let Some(end) = rest.strip_suffix(')') {
725 if let Some(comma) = end.find(", ") {
726 return MessageKind::Variable {
727 name: format!("variable_{}", &end[..comma]),
728 value: end[comma + 2..].to_string(),
729 };
730 }
731 }
732 MessageKind::Variable {
733 name: String::new(),
734 value: msg.to_string(),
735 }
736}
737
738fn parse_unset(msg: &str) -> MessageKind {
739 let rest = &msg["UNSET ".len()..];
740 let name = if let Some(inner) = rest.strip_prefix('[') {
741 inner.strip_suffix(']').unwrap_or(inner)
742 } else {
743 rest
744 };
745 MessageKind::Variable {
746 name: format!("variable_{name}"),
747 value: String::new(),
748 }
749}
750
751fn parse_dialplan_processing(msg: &str) -> MessageKind {
752 let rest = &msg["Processing ".len()..];
753 MessageKind::Dialplan {
754 channel: String::new(),
755 detail: rest.to_string(),
756 }
757}
758
759fn parse_set_or_export(msg: &str) -> Option<MessageKind> {
760 let sep = msg.find("]=[");
765 if let Some(sep_pos) = sep {
766 let name_start = msg[..sep_pos].rfind('[')?;
767 let name = &msg[name_start + 1..sep_pos];
768 let val_start = sep_pos + 3; let val_end = msg[val_start..]
770 .find(']')
771 .map(|p| val_start + p)
772 .unwrap_or(msg.len());
773 let value = &msg[val_start..val_end];
774 return Some(MessageKind::Variable {
775 name: format!("variable_{name}"),
776 value: value.to_string(),
777 });
778 }
779
780 None
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786
787 #[test]
788 fn execute_full() {
789 let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 db(insert/ng_a1b2c3d4/city/ST GEORGES)";
790 let kind = classify_message(msg);
791 assert_eq!(
792 kind,
793 MessageKind::Execute {
794 depth: 0,
795 channel: "sofia/internal/+15550001234@192.0.2.1".to_string(),
796 application: "db".to_string(),
797 arguments: "insert/ng_a1b2c3d4/city/ST GEORGES".to_string(),
798 }
799 );
800 }
801
802 #[test]
803 fn execute_nested_depth() {
804 let msg = "EXECUTE [depth=2] sofia/internal/+15550001234@192.0.2.1 set(x=y)";
805 match classify_message(msg) {
806 MessageKind::Execute {
807 depth,
808 application,
809 arguments,
810 ..
811 } => {
812 assert_eq!(depth, 2);
813 assert_eq!(application, "set");
814 assert_eq!(arguments, "x=y");
815 }
816 other => panic!("expected Execute, got {other:?}"),
817 }
818 }
819
820 #[test]
821 fn execute_no_arguments() {
822 let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 answer";
823 match classify_message(msg) {
824 MessageKind::Execute {
825 application,
826 arguments,
827 ..
828 } => {
829 assert_eq!(application, "answer");
830 assert_eq!(arguments, "");
831 }
832 other => panic!("expected Execute, got {other:?}"),
833 }
834 }
835
836 #[test]
837 fn execute_export_with_vars() {
838 let msg = "EXECUTE [depth=0] sofia/internal/+15550001234@192.0.2.1 export(originate_timeout=3600)";
839 match classify_message(msg) {
840 MessageKind::Execute {
841 application,
842 arguments,
843 ..
844 } => {
845 assert_eq!(application, "export");
846 assert_eq!(arguments, "originate_timeout=3600");
847 }
848 other => panic!("expected Execute, got {other:?}"),
849 }
850 }
851
852 #[test]
853 fn dialplan_parsing() {
854 let msg = "Dialplan: sofia/internal/+15550001234@192.0.2.1 parsing [public->global] continue=true";
855 match classify_message(msg) {
856 MessageKind::Dialplan { channel, detail } => {
857 assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
858 assert_eq!(detail, "parsing [public->global] continue=true");
859 }
860 other => panic!("expected Dialplan, got {other:?}"),
861 }
862 }
863
864 #[test]
865 fn dialplan_regex() {
866 let msg = "Dialplan: sofia/internal/+15550001234@192.0.2.1 Regex (PASS) [global_routing] destination_number(18001234567) =~ /^1?(\\d{10})$/ break=on-false";
867 match classify_message(msg) {
868 MessageKind::Dialplan { channel, detail } => {
869 assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
870 assert!(detail.starts_with("Regex (PASS)"));
871 }
872 other => panic!("expected Dialplan, got {other:?}"),
873 }
874 }
875
876 #[test]
877 fn dialplan_action() {
878 let msg =
879 "Dialplan: sofia/internal/+15550001234@192.0.2.1 Action set(call_direction=inbound)";
880 match classify_message(msg) {
881 MessageKind::Dialplan { detail, .. } => {
882 assert!(detail.starts_with("Action "));
883 }
884 other => panic!("expected Dialplan, got {other:?}"),
885 }
886 }
887
888 #[test]
889 fn channel_data_marker() {
890 assert_eq!(classify_message("CHANNEL_DATA:"), MessageKind::ChannelData);
891 }
892
893 #[test]
894 fn channel_data_in_message() {
895 assert_eq!(
896 classify_message("New CHANNEL_DATA arrived"),
897 MessageKind::ChannelData,
898 );
899 }
900
901 #[test]
902 fn channel_field_with_brackets() {
903 let msg = "Channel-State: [CS_EXECUTE]";
904 match classify_message(msg) {
905 MessageKind::ChannelField { name, value } => {
906 assert_eq!(name, "Channel-State");
907 assert_eq!(value, "CS_EXECUTE");
908 }
909 other => panic!("expected ChannelField, got {other:?}"),
910 }
911 }
912
913 #[test]
914 fn channel_field_name() {
915 let msg = "Channel-Name: [sofia/internal/+15550001234@192.0.2.1]";
916 match classify_message(msg) {
917 MessageKind::ChannelField { name, value } => {
918 assert_eq!(name, "Channel-Name");
919 assert_eq!(value, "sofia/internal/+15550001234@192.0.2.1");
920 }
921 other => panic!("expected ChannelField, got {other:?}"),
922 }
923 }
924
925 #[test]
926 fn variable_single_line() {
927 let msg = "variable_sip_call_id: [test123@192.0.2.1]";
928 match classify_message(msg) {
929 MessageKind::Variable { name, value } => {
930 assert_eq!(name, "variable_sip_call_id");
931 assert_eq!(value, "test123@192.0.2.1");
932 }
933 other => panic!("expected Variable, got {other:?}"),
934 }
935 }
936
937 #[test]
938 fn variable_multi_line_start() {
939 let msg = "variable_switch_r_sdp: [v=0";
940 match classify_message(msg) {
941 MessageKind::Variable { name, value } => {
942 assert_eq!(name, "variable_switch_r_sdp");
943 assert_eq!(value, "v=0");
944 }
945 other => panic!("expected Variable, got {other:?}"),
946 }
947 }
948
949 #[test]
950 fn sdp_local() {
951 assert_eq!(
952 classify_message("Local SDP:"),
953 MessageKind::SdpMarker {
954 direction: SdpDirection::Local
955 },
956 );
957 }
958
959 #[test]
960 fn sdp_remote() {
961 assert_eq!(
962 classify_message("Remote SDP:"),
963 MessageKind::SdpMarker {
964 direction: SdpDirection::Remote
965 },
966 );
967 }
968
969 #[test]
970 fn sdp_in_longer_message() {
971 match classify_message("Setting Local SDP for call") {
972 MessageKind::SdpMarker { direction } => {
973 assert_eq!(direction, SdpDirection::Local);
974 }
975 other => panic!("expected SdpMarker, got {other:?}"),
976 }
977 }
978
979 #[test]
980 fn sdp_unknown_direction() {
981 assert_eq!(
982 classify_message("Patched SDP:"),
983 MessageKind::SdpMarker {
984 direction: SdpDirection::Unknown
985 },
986 );
987 }
988
989 #[test]
990 fn ring_sdp_is_local_ring() {
991 assert_eq!(
992 classify_message("Ring SDP:"),
993 MessageKind::SdpMarker {
994 direction: SdpDirection::LocalRing
995 },
996 );
997 }
998
999 #[test]
1000 fn state_change() {
1001 let msg = "State Change CS_INIT -> CS_ROUTING";
1002 match classify_message(msg) {
1003 MessageKind::StateChange { detail } => {
1004 assert_eq!(detail, msg);
1005 }
1006 other => panic!("expected StateChange, got {other:?}"),
1007 }
1008 }
1009
1010 #[test]
1011 fn core_session_set_variable() {
1012 match classify_message("CoreSession::setVariable(X-City, ST GEORGES)") {
1013 MessageKind::Variable { name, value } => {
1014 assert_eq!(name, "variable_X-City");
1015 assert_eq!(value, "ST GEORGES");
1016 }
1017 other => panic!("expected Variable, got {other:?}"),
1018 }
1019 }
1020
1021 #[test]
1022 fn general_empty() {
1023 assert_eq!(classify_message(""), MessageKind::General);
1024 }
1025
1026 #[test]
1027 fn hangup_is_channel_lifecycle() {
1028 match classify_message(
1029 "Hangup sofia/internal/+15550001234@192.0.2.1 [CS_CONSUME_MEDIA] [NORMAL_CLEARING]",
1030 ) {
1031 MessageKind::ChannelLifecycle { .. } => {}
1032 other => panic!("expected ChannelLifecycle, got {other:?}"),
1033 }
1034 }
1035
1036 #[test]
1037 fn channel_field_no_brackets() {
1038 let msg = "Channel-Presence-ID: 1234@192.0.2.1";
1039 match classify_message(msg) {
1040 MessageKind::ChannelField { name, value } => {
1041 assert_eq!(name, "Channel-Presence-ID");
1042 assert_eq!(value, "1234@192.0.2.1");
1043 }
1044 other => panic!("expected ChannelField, got {other:?}"),
1045 }
1046 }
1047
1048 #[test]
1049 fn variable_no_brackets() {
1050 let msg = "variable_direction: inbound";
1051 match classify_message(msg) {
1052 MessageKind::Variable { name, value } => {
1053 assert_eq!(name, "variable_direction");
1054 assert_eq!(value, "inbound");
1055 }
1056 other => panic!("expected Variable, got {other:?}"),
1057 }
1058 }
1059
1060 #[test]
1063 fn execute_lowercase() {
1064 let msg = "Execute [depth=2] set(RECORD_STEREO=true)";
1065 match classify_message(msg) {
1066 MessageKind::Execute {
1067 depth,
1068 application,
1069 arguments,
1070 ..
1071 } => {
1072 assert_eq!(depth, 2);
1073 assert_eq!(application, "set");
1074 assert_eq!(arguments, "RECORD_STEREO=true");
1075 }
1076 other => panic!("expected Execute, got {other:?}"),
1077 }
1078 }
1079
1080 #[test]
1081 fn execute_lowercase_db() {
1082 let msg = "Execute [depth=1] db(insert/ng_${originating_leg_uuid}/record_leg/${uuid})";
1083 match classify_message(msg) {
1084 MessageKind::Execute { application, .. } => {
1085 assert_eq!(application, "db");
1086 }
1087 other => panic!("expected Execute, got {other:?}"),
1088 }
1089 }
1090
1091 #[test]
1092 fn set_variable_message() {
1093 let msg = "SET sofia/internal-v6/1263@[2001:db8:2220:198::10] [ngcs_bridge_sip_req_uri]=[conf-factory-app.qc.core.ng.example.test]";
1094 match classify_message(msg) {
1095 MessageKind::Variable { name, value } => {
1096 assert_eq!(name, "variable_ngcs_bridge_sip_req_uri");
1097 assert_eq!(value, "conf-factory-app.qc.core.ng.example.test");
1098 }
1099 other => panic!("expected Variable, got {other:?}"),
1100 }
1101 }
1102
1103 #[test]
1104 fn export_variable_message() {
1105 let msg =
1106 "EXPORT (export_vars) (REMOTE ONLY) [sip_from_uri]=[sip:psap1.qc.psap.ng.example.test]";
1107 match classify_message(msg) {
1108 MessageKind::Variable { name, value } => {
1109 assert_eq!(name, "variable_sip_from_uri");
1110 assert_eq!(value, "sip:psap1.qc.psap.ng.example.test");
1111 }
1112 other => panic!("expected Variable, got {other:?}"),
1113 }
1114 }
1115
1116 #[test]
1117 fn export_simple_variable() {
1118 let msg = "EXPORT (export_vars) [originate_timeout]=[3600]";
1119 match classify_message(msg) {
1120 MessageKind::Variable { name, value } => {
1121 assert_eq!(name, "variable_originate_timeout");
1122 assert_eq!(value, "3600");
1123 }
1124 other => panic!("expected Variable, got {other:?}"),
1125 }
1126 }
1127
1128 #[test]
1129 fn processing_in_context() {
1130 let msg = "Processing Extension 1263 <1263>->start_recording in context recordings";
1131 match classify_message(msg) {
1132 MessageKind::Dialplan { detail, .. } => {
1133 assert!(detail.contains("start_recording"));
1134 assert!(detail.contains("recordings"));
1135 }
1136 other => panic!("expected Dialplan, got {other:?}"),
1137 }
1138 }
1139
1140 #[test]
1141 fn caller_field_as_channel_field() {
1142 let msg = "Caller-Username: [+15550001234]";
1143 match classify_message(msg) {
1144 MessageKind::ChannelField { name, value } => {
1145 assert_eq!(name, "Caller-Username");
1146 assert_eq!(value, "+15550001234");
1147 }
1148 other => panic!("expected ChannelField, got {other:?}"),
1149 }
1150 }
1151
1152 #[test]
1153 fn answer_state_as_channel_field() {
1154 let msg = "Answer-State: [ringing]";
1155 match classify_message(msg) {
1156 MessageKind::ChannelField { name, value } => {
1157 assert_eq!(name, "Answer-State");
1158 assert_eq!(value, "ringing");
1159 }
1160 other => panic!("expected ChannelField, got {other:?}"),
1161 }
1162 }
1163
1164 #[test]
1165 fn unique_id_as_channel_field() {
1166 let msg = "Unique-ID: [a1b2c3d4-e5f6-7890-abcd-ef1234567890]";
1167 match classify_message(msg) {
1168 MessageKind::ChannelField { name, value } => {
1169 assert_eq!(name, "Unique-ID");
1170 assert_eq!(value, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
1171 }
1172 other => panic!("expected ChannelField, got {other:?}"),
1173 }
1174 }
1175
1176 #[test]
1177 fn call_direction_as_channel_field() {
1178 let msg = "Call-Direction: [inbound]";
1179 match classify_message(msg) {
1180 MessageKind::ChannelField { name, value } => {
1181 assert_eq!(name, "Call-Direction");
1182 assert_eq!(value, "inbound");
1183 }
1184 other => panic!("expected ChannelField, got {other:?}"),
1185 }
1186 }
1187
1188 #[test]
1189 fn callstate_change() {
1190 let msg = "(sofia/internal-v4/sos) Callstate Change RINGING -> ACTIVE";
1191 match classify_message(msg) {
1192 MessageKind::StateChange { detail } => {
1193 assert!(detail.contains("RINGING -> ACTIVE"));
1194 }
1195 other => panic!("expected StateChange, got {other:?}"),
1196 }
1197 }
1198
1199 #[test]
1200 fn action_is_pre_dialplan_lifecycle() {
1201 match classify_message("action(1:3pcc_force_dialplan:1:set_tflag) success") {
1202 MessageKind::ChannelLifecycle { .. } => {}
1203 other => panic!("expected ChannelLifecycle, got {other:?}"),
1204 }
1205 }
1206
1207 #[test]
1208 fn channel_answered_is_lifecycle() {
1209 match classify_message("Channel [sofia/internal] has been answered") {
1210 MessageKind::ChannelLifecycle { .. } => {}
1211 other => panic!("expected ChannelLifecycle, got {other:?}"),
1212 }
1213 }
1214
1215 #[test]
1216 fn chatplan_regex() {
1217 let msg = "Chatplan: sofia/internal/+15550001234@192.0.2.1 Regex (PASS) [global_routing] destination_number(18001234567) =~ /^1?(\\d{10})$/ break=on-false";
1218 match classify_message(msg) {
1219 MessageKind::Dialplan { channel, detail } => {
1220 assert_eq!(channel, "sofia/internal/+15550001234@192.0.2.1");
1221 assert!(detail.starts_with("Regex (PASS)"));
1222 }
1223 other => panic!("expected Dialplan, got {other:?}"),
1224 }
1225 }
1226
1227 #[test]
1228 fn chatplan_action() {
1229 let msg =
1230 "Chatplan: sofia/internal/+15550001234@192.0.2.1 Action set(call_direction=inbound)";
1231 match classify_message(msg) {
1232 MessageKind::Dialplan { detail, .. } => {
1233 assert!(detail.starts_with("Action "));
1234 }
1235 other => panic!("expected Dialplan, got {other:?}"),
1236 }
1237 }
1238
1239 #[test]
1240 fn chatplan_anti_action() {
1241 let msg =
1242 "Chatplan: sofia/internal/+15550001234@192.0.2.1 ANTI-Action log(WARNING no match)";
1243 match classify_message(msg) {
1244 MessageKind::Dialplan { detail, .. } => {
1245 assert!(detail.starts_with("ANTI-Action "));
1246 }
1247 other => panic!("expected Dialplan, got {other:?}"),
1248 }
1249 }
1250
1251 #[test]
1252 fn standard_execute_is_state_change() {
1253 let msg = "sofia/internal/+15550001234@192.0.2.1 Standard EXECUTE";
1254 match classify_message(msg) {
1255 MessageKind::StateChange { detail } => {
1256 assert_eq!(detail, "Standard EXECUTE");
1257 }
1258 other => panic!("expected StateChange, got {other:?}"),
1259 }
1260 }
1261
1262 #[test]
1263 fn sofia_execute_is_state_change() {
1264 let msg = "sofia/internal/+15550001234@192.0.2.1 SOFIA EXECUTE";
1265 match classify_message(msg) {
1266 MessageKind::StateChange { detail } => {
1267 assert_eq!(detail, "SOFIA EXECUTE");
1268 }
1269 other => panic!("expected StateChange, got {other:?}"),
1270 }
1271 }
1272
1273 #[test]
1274 fn rtc_execute_is_state_change() {
1275 let msg = "sofia/internal/+15550001234@192.0.2.1 RTC EXECUTE";
1276 match classify_message(msg) {
1277 MessageKind::StateChange { detail } => {
1278 assert_eq!(detail, "RTC EXECUTE");
1279 }
1280 other => panic!("expected StateChange, got {other:?}"),
1281 }
1282 }
1283
1284 #[test]
1285 fn standard_soft_execute_is_state_change() {
1286 let msg = "sofia/internal/+15550001234@192.0.2.1 Standard SOFT_EXECUTE";
1287 match classify_message(msg) {
1288 MessageKind::StateChange { detail } => {
1289 assert_eq!(detail, "Standard SOFT_EXECUTE");
1290 }
1291 other => panic!("expected StateChange, got {other:?}"),
1292 }
1293 }
1294
1295 #[test]
1296 fn dialplan_recursive_conditions() {
1297 let msg = "Processing recursive conditions level:1 [default] require-nested=true";
1298 match classify_message(msg) {
1299 MessageKind::Dialplan { detail, .. } => {
1300 assert!(detail.contains("recursive conditions"));
1301 }
1302 other => panic!("expected Dialplan, got {other:?}"),
1303 }
1304 }
1305
1306 #[test]
1307 fn sdp_duplicate_marker() {
1308 let msg = "Duplicate SDP";
1309 match classify_message(msg) {
1310 MessageKind::SdpMarker { direction } => {
1311 assert_eq!(direction, SdpDirection::Unknown);
1312 }
1313 other => panic!("expected SdpMarker, got {other:?}"),
1314 }
1315 }
1316
1317 #[test]
1318 fn sdp_verto_update_media() {
1319 match classify_message("updateMedia: Local SDP") {
1320 MessageKind::SdpMarker { direction } => {
1321 assert_eq!(direction, SdpDirection::Local);
1322 }
1323 other => panic!("expected SdpMarker, got {other:?}"),
1324 }
1325 }
1326
1327 #[test]
1328 fn receiving_invite_routes_to_sip_invite_with_call_id() {
1329 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";
1330 match classify_message(msg) {
1331 MessageKind::SipInvite {
1332 direction,
1333 profile,
1334 call_id,
1335 } => {
1336 assert_eq!(direction, SipInviteDirection::Receiving);
1337 assert_eq!(profile, "internal");
1338 assert_eq!(
1339 call_id.as_deref(),
1340 Some("00112233-4455-6677-8899-aabbccddeeff")
1341 );
1342 }
1343 other => panic!("expected SipInvite, got {other:?}"),
1344 }
1345 }
1346
1347 #[test]
1348 fn sending_invite_routes_to_sip_invite() {
1349 let msg = "sofia/internalv6/ngcs_create_conference sending invite call-id: ffeeddcc-bbaa-9988-7766-554433221100";
1350 match classify_message(msg) {
1351 MessageKind::SipInvite {
1352 direction,
1353 profile,
1354 call_id,
1355 } => {
1356 assert_eq!(direction, SipInviteDirection::Sending);
1357 assert_eq!(profile, "internalv6");
1358 assert_eq!(
1359 call_id.as_deref(),
1360 Some("ffeeddcc-bbaa-9988-7766-554433221100")
1361 );
1362 }
1363 other => panic!("expected SipInvite, got {other:?}"),
1364 }
1365 }
1366
1367 #[test]
1368 fn sending_invite_null_call_id_yields_none() {
1369 let msg = "sofia/telus/15555550100 sending invite call-id: (null)";
1370 match classify_message(msg) {
1371 MessageKind::SipInvite {
1372 direction,
1373 profile,
1374 call_id,
1375 } => {
1376 assert_eq!(direction, SipInviteDirection::Sending);
1377 assert_eq!(profile, "telus");
1378 assert_eq!(call_id, None);
1379 }
1380 other => panic!("expected SipInvite, got {other:?}"),
1381 }
1382 }
1383
1384 #[test]
1385 fn sending_invite_version_only_yields_none() {
1386 let msg = "sofia/telus/15555550100 sending invite version: 1.10.13-dev git abc 2026-01-01 00:00:00Z 64bit";
1388 match classify_message(msg) {
1389 MessageKind::SipInvite {
1390 direction, call_id, ..
1391 } => {
1392 assert_eq!(direction, SipInviteDirection::Sending);
1393 assert_eq!(call_id, None);
1394 }
1395 other => panic!("expected SipInvite, got {other:?}"),
1396 }
1397 }
1398
1399 #[test]
1400 fn call_id_with_at_host_port_preserved() {
1401 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";
1402 match classify_message(msg) {
1403 MessageKind::SipInvite { call_id, .. } => {
1404 assert_eq!(
1405 call_id.as_deref(),
1406 Some("00deadbeef00abc123def4567890abcd@198.51.100.52:5060")
1407 );
1408 }
1409 other => panic!("expected SipInvite, got {other:?}"),
1410 }
1411 }
1412
1413 #[test]
1414 fn non_invite_sofia_lifecycle_still_channel_lifecycle() {
1415 let msg = "sofia/internal/1212@host.example:5062 receiving refer";
1416 match classify_message(msg) {
1417 MessageKind::ChannelLifecycle { .. } => {}
1418 other => panic!("expected ChannelLifecycle, got {other:?}"),
1419 }
1420 }
1421
1422 #[test]
1425 fn dtmf_channel_digit() {
1426 let msg = "RECV DTMF 1:2080";
1427 match classify_message(msg) {
1428 MessageKind::Dtmf {
1429 source,
1430 digit,
1431 duration_ms,
1432 } => {
1433 assert_eq!(source, DtmfSource::Channel);
1434 assert_eq!(digit, '1');
1435 assert_eq!(duration_ms, Some(2080));
1436 }
1437 other => panic!("expected Dtmf, got {other:?}"),
1438 }
1439 }
1440
1441 #[test]
1442 fn dtmf_rtp_digit() {
1443 let msg = "RTP RECV DTMF 5:1440";
1444 match classify_message(msg) {
1445 MessageKind::Dtmf {
1446 source,
1447 digit,
1448 duration_ms,
1449 } => {
1450 assert_eq!(source, DtmfSource::Rtp);
1451 assert_eq!(digit, '5');
1452 assert_eq!(duration_ms, Some(1440));
1453 }
1454 other => panic!("expected Dtmf, got {other:?}"),
1455 }
1456 }
1457
1458 #[test]
1459 fn dtmf_sip_info() {
1460 let msg = "INFO DTMF(7)";
1461 match classify_message(msg) {
1462 MessageKind::Dtmf {
1463 source,
1464 digit,
1465 duration_ms,
1466 } => {
1467 assert_eq!(source, DtmfSource::SipInfo);
1468 assert_eq!(digit, '7');
1469 assert_eq!(duration_ms, None);
1470 }
1471 other => panic!("expected Dtmf, got {other:?}"),
1472 }
1473 }
1474
1475 #[test]
1476 fn dtmf_star() {
1477 let msg = "RECV DTMF *:2080";
1478 match classify_message(msg) {
1479 MessageKind::Dtmf { digit, .. } => {
1480 assert_eq!(digit, '*');
1481 }
1482 other => panic!("expected Dtmf, got {other:?}"),
1483 }
1484 }
1485
1486 #[test]
1487 fn dtmf_hash() {
1488 let msg = "RECV DTMF #:560";
1489 match classify_message(msg) {
1490 MessageKind::Dtmf { digit, .. } => {
1491 assert_eq!(digit, '#');
1492 }
1493 other => panic!("expected Dtmf, got {other:?}"),
1494 }
1495 }
1496
1497 #[test]
1498 fn dtmf_flash() {
1499 let msg = "RECV DTMF F:2080";
1500 match classify_message(msg) {
1501 MessageKind::Dtmf { digit, .. } => {
1502 assert_eq!(digit, 'F');
1503 }
1504 other => panic!("expected Dtmf, got {other:?}"),
1505 }
1506 }
1507
1508 #[test]
1509 fn dtmf_letter_a() {
1510 let msg = "RTP RECV DTMF A:1360";
1511 match classify_message(msg) {
1512 MessageKind::Dtmf { digit, .. } => {
1513 assert_eq!(digit, 'A');
1514 }
1515 other => panic!("expected Dtmf, got {other:?}"),
1516 }
1517 }
1518
1519 #[test]
1520 fn dtmf_invalid_digit_falls_through() {
1521 let msg = "RECV DTMF X:1000";
1522 assert_eq!(classify_message(msg), MessageKind::General);
1523 }
1524
1525 #[test]
1526 fn dtmf_malformed_no_colon_falls_through() {
1527 let msg = "RECV DTMF 1";
1528 assert_eq!(classify_message(msg), MessageKind::General);
1529 }
1530
1531 #[test]
1532 fn dtmf_malformed_no_duration_falls_through() {
1533 let msg = "RECV DTMF 1:";
1534 assert_eq!(classify_message(msg), MessageKind::General);
1535 }
1536
1537 #[test]
1538 fn dtmf_display_with_duration() {
1539 let kind = MessageKind::Dtmf {
1540 source: DtmfSource::Channel,
1541 digit: '5',
1542 duration_ms: Some(1440),
1543 };
1544 assert_eq!(format!("{kind}"), "dtmf(channel:5:1440ms)");
1545 }
1546
1547 #[test]
1548 fn dtmf_display_without_duration() {
1549 let kind = MessageKind::Dtmf {
1550 source: DtmfSource::SipInfo,
1551 digit: '9',
1552 duration_ms: None,
1553 };
1554 assert_eq!(format!("{kind}"), "dtmf(sip-info:9)");
1555 }
1556
1557 #[test]
1558 fn dtmf_label() {
1559 let kind = MessageKind::Dtmf {
1560 source: DtmfSource::Rtp,
1561 digit: '0',
1562 duration_ms: Some(2000),
1563 };
1564 assert_eq!(kind.label(), "dtmf");
1565 }
1566}