use rsip::{Header, StatusCode};
use rsipstack::dialog::client_dialog::ClientInviteDialog;
use rsipstack::dialog::server_dialog::ServerInviteDialog;
use tracing::warn;
use crate::rtp::dtmf::DtmfDigit;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
pub const CONTENT_TYPE: &str = "application/dtmf-relay";
pub fn build_info_body(digit: DtmfDigit, duration_ms: u32) -> String {
format!("Signal={}\nDuration={duration_ms}", digit.as_char())
}
pub fn content_type_header() -> Header {
Header::ContentType(CONTENT_TYPE.into())
}
#[derive(Debug)]
pub enum InfoOutcome {
Accepted(StatusCode),
UnsupportedMedia,
OtherFailure(StatusCode),
DialogNotConfirmed,
}
impl InfoOutcome {
pub fn is_accepted(&self) -> bool {
matches!(self, Self::Accepted(_))
}
pub fn should_stop(&self) -> bool {
matches!(self, Self::UnsupportedMedia)
}
}
fn classify(response: Option<rsip::Response>) -> InfoOutcome {
let Some(r) = response else {
return InfoOutcome::DialogNotConfirmed;
};
let code = r.status_code.code();
if (200..300).contains(&code) {
InfoOutcome::Accepted(r.status_code)
} else if code == 415 {
warn!(
"remote rejected application/dtmf-relay (415); \
stop sending INFO DTMF on this dialog"
);
InfoOutcome::UnsupportedMedia
} else {
warn!(status = %r.status_code, "INFO DTMF failed");
InfoOutcome::OtherFailure(r.status_code)
}
}
pub async fn send_dtmf_info_client(
dialog: &ClientInviteDialog,
digit: DtmfDigit,
duration_ms: u32,
) -> Result<InfoOutcome, BoxError> {
let body = build_info_body(digit, duration_ms).into_bytes();
let headers = vec![content_type_header()];
let response = dialog.info(Some(headers), Some(body)).await?;
Ok(classify(response))
}
pub async fn send_dtmf_info_server(
dialog: &ServerInviteDialog,
digit: DtmfDigit,
duration_ms: u32,
) -> Result<InfoOutcome, BoxError> {
let body = build_info_body(digit, duration_ms).into_bytes();
let headers = vec![content_type_header()];
let response = dialog.info(Some(headers), Some(body)).await?;
Ok(classify(response))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn body_format_matches_application_dtmf_relay() {
let body = build_info_body(DtmfDigit::D5, 160);
assert_eq!(body, "Signal=5\nDuration=160");
}
#[test]
fn body_uses_digit_canonical_char_for_star_pound() {
assert_eq!(
build_info_body(DtmfDigit::Star, 100),
"Signal=*\nDuration=100"
);
assert_eq!(
build_info_body(DtmfDigit::Pound, 100),
"Signal=#\nDuration=100"
);
}
#[test]
fn body_uppercases_letter_digits() {
assert_eq!(build_info_body(DtmfDigit::A, 160), "Signal=A\nDuration=160");
assert_eq!(build_info_body(DtmfDigit::D, 160), "Signal=D\nDuration=160");
}
#[test]
fn content_type_header_is_application_dtmf_relay() {
let h = content_type_header();
let s = h.to_string();
assert!(
s.contains(CONTENT_TYPE),
"header should contain {CONTENT_TYPE}, got {s:?}"
);
}
#[test]
fn classify_2xx_response_is_accepted() {
let resp = make_response(200);
match classify(Some(resp)) {
InfoOutcome::Accepted(code) => assert_eq!(code.code(), 200),
other => panic!("expected Accepted, got {other:?}"),
}
}
#[test]
fn classify_415_response_is_unsupported_media() {
let resp = make_response(415);
assert!(matches!(
classify(Some(resp)),
InfoOutcome::UnsupportedMedia
));
}
#[test]
fn classify_500_response_is_other_failure() {
let resp = make_response(500);
match classify(Some(resp)) {
InfoOutcome::OtherFailure(code) => assert_eq!(code.code(), 500),
other => panic!("expected OtherFailure, got {other:?}"),
}
}
#[test]
fn classify_none_response_is_dialog_not_confirmed() {
assert!(matches!(classify(None), InfoOutcome::DialogNotConfirmed));
}
#[test]
fn outcome_helpers() {
let accepted = InfoOutcome::Accepted(StatusCode::from(200u16));
assert!(accepted.is_accepted());
assert!(!accepted.should_stop());
let unsupported = InfoOutcome::UnsupportedMedia;
assert!(!unsupported.is_accepted());
assert!(unsupported.should_stop());
let other = InfoOutcome::OtherFailure(StatusCode::from(500u16));
assert!(!other.is_accepted());
assert!(!other.should_stop());
}
fn make_response(code: u16) -> rsip::Response {
rsip::Response {
status_code: StatusCode::from(code),
version: rsip::Version::V2,
headers: rsip::Headers::default(),
body: Vec::new(),
}
}
}