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