feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
use md5::{Digest, Md5};
|
||||
use rand::Rng;
|
||||
|
||||
use crate::{Endpoint, SdpMediaKind};
|
||||
|
||||
// ---- ID generators ---------------------------------------------------------
|
||||
|
||||
/// Generate a random SIP Call-ID (32 hex chars).
|
||||
@@ -55,6 +57,9 @@ pub struct SdpOptions<'a> {
|
||||
pub ip: &'a str,
|
||||
pub port: u16,
|
||||
pub payload_types: &'a [u8],
|
||||
pub media_kind: SdpMediaKind,
|
||||
pub transport: &'a str,
|
||||
pub media_formats: &'a [&'a str],
|
||||
pub session_id: Option<&'a str>,
|
||||
pub session_name: Option<&'a str>,
|
||||
pub direction: Option<&'a str>,
|
||||
@@ -67,6 +72,9 @@ impl<'a> Default for SdpOptions<'a> {
|
||||
ip: "0.0.0.0",
|
||||
port: 0,
|
||||
payload_types: &[9, 0, 8, 101],
|
||||
media_kind: SdpMediaKind::Audio,
|
||||
transport: "RTP/AVP",
|
||||
media_formats: &[],
|
||||
session_id: None,
|
||||
session_name: None,
|
||||
direction: None,
|
||||
@@ -83,7 +91,14 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
|
||||
.unwrap_or_else(|| format!("{}", rand::thread_rng().gen_range(0..1_000_000_000u64)));
|
||||
let session_name = opts.session_name.unwrap_or("-");
|
||||
let direction = opts.direction.unwrap_or("sendrecv");
|
||||
let pts: Vec<String> = opts.payload_types.iter().map(|pt| pt.to_string()).collect();
|
||||
let media_formats: Vec<String> = if !opts.media_formats.is_empty() {
|
||||
opts.media_formats
|
||||
.iter()
|
||||
.map(|fmt| fmt.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
opts.payload_types.iter().map(|pt| pt.to_string()).collect()
|
||||
};
|
||||
|
||||
let mut lines = vec![
|
||||
"v=0".to_string(),
|
||||
@@ -91,16 +106,24 @@ pub fn build_sdp(opts: &SdpOptions) -> String {
|
||||
format!("s={session_name}"),
|
||||
format!("c=IN IP4 {}", opts.ip),
|
||||
"t=0 0".to_string(),
|
||||
format!("m=audio {} RTP/AVP {}", opts.port, pts.join(" ")),
|
||||
format!(
|
||||
"m={} {} {} {}",
|
||||
opts.media_kind.as_sdp_token(),
|
||||
opts.port,
|
||||
opts.transport,
|
||||
media_formats.join(" ")
|
||||
),
|
||||
];
|
||||
|
||||
for &pt in opts.payload_types {
|
||||
let name = codec_name(pt);
|
||||
if name != "unknown" {
|
||||
lines.push(format!("a=rtpmap:{pt} {name}"));
|
||||
}
|
||||
if pt == 101 {
|
||||
lines.push("a=fmtp:101 0-16".to_string());
|
||||
if opts.media_kind == SdpMediaKind::Audio {
|
||||
for &pt in opts.payload_types {
|
||||
let name = codec_name(pt);
|
||||
if name != "unknown" {
|
||||
lines.push(format!("a=rtpmap:{pt} {name}"));
|
||||
}
|
||||
if pt == 101 {
|
||||
lines.push("a=fmtp:101 0-16".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,38 +222,62 @@ pub fn compute_digest_auth(
|
||||
|
||||
// ---- SDP parser ------------------------------------------------------------
|
||||
|
||||
use crate::Endpoint;
|
||||
|
||||
/// Parse the audio media port, connection address, and preferred codec from an SDP body.
|
||||
/// Parse the preferred media endpoint from an SDP body.
|
||||
///
|
||||
/// Audio `m=` lines are preferred when present so existing RTP call flows keep
|
||||
/// their current behavior. If no audio section exists, the first media section
|
||||
/// is returned, which allows T.38-only SDP offers/answers to be represented.
|
||||
pub fn parse_sdp_endpoint(sdp: &str) -> Option<Endpoint> {
|
||||
let mut addr: Option<&str> = None;
|
||||
let mut port: Option<u16> = None;
|
||||
let mut codec_pt: Option<u8> = None;
|
||||
let mut preferred: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
||||
let mut fallback: Option<(SdpMediaKind, u16, Option<u8>, String)> = None;
|
||||
|
||||
let normalized = sdp.replace("\r\n", "\n");
|
||||
for raw in normalized.split('\n') {
|
||||
let line = raw.trim();
|
||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||
addr = Some(rest.trim());
|
||||
} else if let Some(rest) = line.strip_prefix("m=audio ") {
|
||||
// m=audio <port> RTP/AVP <pt1> [<pt2> ...]
|
||||
let parts: Vec<&str> = rest.split_whitespace().collect();
|
||||
if !parts.is_empty() {
|
||||
port = parts[0].parse().ok();
|
||||
} else if let Some(rest) = line.strip_prefix("m=") {
|
||||
// m=<media> <port> <transport> <fmt1> [<fmt2> ...]
|
||||
let mut media_and_rest = rest.splitn(2, ' ');
|
||||
let media = media_and_rest.next().unwrap_or("");
|
||||
let remainder = media_and_rest.next().unwrap_or("");
|
||||
let media_kind = SdpMediaKind::from_sdp_token(media);
|
||||
if media_kind == SdpMediaKind::Unknown {
|
||||
continue;
|
||||
}
|
||||
// parts[1] is "RTP/AVP" or similar, parts[2..] are payload types.
|
||||
// The first PT is the preferred codec.
|
||||
if parts.len() > 2 {
|
||||
codec_pt = parts[2].parse::<u8>().ok();
|
||||
|
||||
let parts: Vec<&str> = remainder.split_whitespace().collect();
|
||||
if !parts.is_empty() {
|
||||
if let Ok(port) = parts[0].parse() {
|
||||
let transport = parts.get(1).copied().unwrap_or("").to_string();
|
||||
let codec_pt = if media_kind == SdpMediaKind::Audio && parts.len() > 2 {
|
||||
parts[2].parse::<u8>().ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let candidate = (media_kind, port, codec_pt, transport);
|
||||
if fallback.is_none() {
|
||||
fallback = Some(candidate.clone());
|
||||
}
|
||||
if media_kind == SdpMediaKind::Audio {
|
||||
preferred = Some(candidate);
|
||||
} else if preferred.is_none() {
|
||||
preferred = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (addr, port) {
|
||||
(Some(a), Some(p)) => Some(Endpoint {
|
||||
match (addr, preferred.or(fallback)) {
|
||||
(Some(a), Some((media_kind, port, codec_pt, transport))) => Some(Endpoint {
|
||||
address: a.to_string(),
|
||||
port: p,
|
||||
port,
|
||||
codec_pt,
|
||||
media_kind,
|
||||
transport,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
@@ -327,6 +374,40 @@ mod tests {
|
||||
let ep = parse_sdp_endpoint(sdp).unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
assert_eq!(ep.port, 5060);
|
||||
assert_eq!(ep.media_kind, SdpMediaKind::Audio);
|
||||
assert_eq!(ep.transport, "RTP/AVP");
|
||||
assert!(ep.is_audio_rtp());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_t38_sdp_endpoint() {
|
||||
let sdp = concat!(
|
||||
"v=0\r\n",
|
||||
"c=IN IP4 203.0.113.9\r\n",
|
||||
"m=image 4000 udptl t38\r\n",
|
||||
"a=T38FaxVersion:0\r\n",
|
||||
);
|
||||
let ep = parse_sdp_endpoint(sdp).unwrap();
|
||||
assert_eq!(ep.address, "203.0.113.9");
|
||||
assert_eq!(ep.port, 4000);
|
||||
assert_eq!(ep.media_kind, SdpMediaKind::Image);
|
||||
assert_eq!(ep.transport, "udptl");
|
||||
assert!(ep.is_t38_udptl());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_t38_sdp() {
|
||||
let sdp = build_sdp(&SdpOptions {
|
||||
ip: "192.168.1.1",
|
||||
port: 4000,
|
||||
media_kind: SdpMediaKind::Image,
|
||||
transport: "udptl",
|
||||
media_formats: &["t38"],
|
||||
attributes: &["T38FaxVersion:0"],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(sdp.contains("m=image 4000 udptl t38"));
|
||||
assert!(sdp.contains("a=T38FaxVersion:0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user