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