feat(fax): add fax routing, job tracking, inbox management, and T.38/UDPTL media support
This commit is contained in:
@@ -19,6 +19,8 @@ regex-lite = "0.1"
|
||||
webrtc = "0.8"
|
||||
rand = "0.8"
|
||||
hound = "3.5"
|
||||
spandsp = "0.1.5"
|
||||
udptl = "0.1.0"
|
||||
kokoro-tts = { version = "0.3", default-features = false, features = ["use-cmudict"] }
|
||||
ort = { version = "=2.0.0-rc.11", default-features = false, features = [
|
||||
"std", "download-binaries", "copy-dylibs", "ndarray",
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub type LegId = String;
|
||||
@@ -114,6 +114,13 @@ pub struct LegInfo {
|
||||
pub kind: LegKind,
|
||||
pub state: LegState,
|
||||
pub codec_pt: u8,
|
||||
/// Media transport currently negotiated for this leg.
|
||||
///
|
||||
/// `rtp` covers classic SIP audio media, `t38-udptl` covers T.38 fax,
|
||||
/// `webrtc` is used for browser legs, and `internal` for proxy-local media/tool paths.
|
||||
pub media_protocol: &'static str,
|
||||
/// Whether this leg is currently wired into an active media bridge.
|
||||
pub media_io_active: bool,
|
||||
|
||||
/// For SIP legs: the SIP dialog manager (handles 407 auth, BYE, etc).
|
||||
pub sip_leg: Option<SipLeg>,
|
||||
@@ -146,6 +153,15 @@ pub struct LegInfo {
|
||||
pub metadata: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PendingDialogBridge {
|
||||
pub source_leg_id: LegId,
|
||||
pub target_leg_id: LegId,
|
||||
pub source_request: SipMessage,
|
||||
pub target_request: SipMessage,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// A multiparty call with N legs and a central mixer.
|
||||
pub struct Call {
|
||||
// Duplicated from the HashMap key in CallManager. Kept for future
|
||||
@@ -169,12 +185,21 @@ pub struct Call {
|
||||
/// Used to construct proper 180/200/error responses back to the device.
|
||||
pub device_invite: Option<SipMessage>,
|
||||
|
||||
/// Pending in-dialog B2BUA transaction bridged across two different SIP dialogs.
|
||||
pub pending_dialog_bridge: Option<PendingDialogBridge>,
|
||||
|
||||
/// All legs in this call, keyed by leg ID.
|
||||
pub legs: HashMap<LegId, LegInfo>,
|
||||
|
||||
/// Channel to send commands to the mixer task.
|
||||
pub mixer_cmd_tx: mpsc::Sender<MixerCommand>,
|
||||
|
||||
/// Active passthrough media bridge mode, if any.
|
||||
pub media_bridge_mode: Option<String>,
|
||||
|
||||
/// Cancellation handles for non-mixer passthrough media tasks.
|
||||
media_bridge_cancel_txs: Vec<watch::Sender<bool>>,
|
||||
|
||||
/// Handle to the mixer task (aborted on call teardown).
|
||||
mixer_task: Option<JoinHandle<()>>,
|
||||
}
|
||||
@@ -196,8 +221,11 @@ impl Call {
|
||||
callee_number: None,
|
||||
provider_id,
|
||||
device_invite: None,
|
||||
pending_dialog_bridge: None,
|
||||
legs: HashMap::new(),
|
||||
mixer_cmd_tx,
|
||||
media_bridge_mode: None,
|
||||
media_bridge_cancel_txs: Vec::new(),
|
||||
mixer_task: Some(mixer_task),
|
||||
}
|
||||
}
|
||||
@@ -235,8 +263,31 @@ impl Call {
|
||||
self.created_at.elapsed().as_secs()
|
||||
}
|
||||
|
||||
pub fn clear_media_bridge(&mut self) {
|
||||
for cancel_tx in self.media_bridge_cancel_txs.drain(..) {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
self.media_bridge_mode = None;
|
||||
}
|
||||
|
||||
pub fn install_media_bridge(
|
||||
&mut self,
|
||||
mode: &str,
|
||||
cancel_txs: Vec<watch::Sender<bool>>,
|
||||
) {
|
||||
self.clear_media_bridge();
|
||||
self.media_bridge_mode = Some(mode.to_string());
|
||||
self.media_bridge_cancel_txs = cancel_txs;
|
||||
}
|
||||
|
||||
pub fn note_mixer_bridge(&mut self, mode: &str) {
|
||||
self.clear_media_bridge();
|
||||
self.media_bridge_mode = Some(mode.to_string());
|
||||
}
|
||||
|
||||
/// Shut down the mixer and abort its task.
|
||||
pub async fn shutdown_mixer(&mut self) {
|
||||
self.clear_media_bridge();
|
||||
let _ = self.mixer_cmd_tx.send(MixerCommand::Shutdown).await;
|
||||
if let Some(handle) = self.mixer_task.take() {
|
||||
handle.abort();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,8 @@ pub struct RouteAction {
|
||||
pub ring_browsers: Option<bool>,
|
||||
#[serde(rename = "voicemailBox")]
|
||||
pub voicemail_box: Option<String>,
|
||||
#[serde(rename = "faxBox")]
|
||||
pub fax_box: Option<String>,
|
||||
#[serde(rename = "ivrMenuId")]
|
||||
pub ivr_menu_id: Option<String>,
|
||||
#[serde(rename = "noAnswerTimeout")]
|
||||
@@ -161,6 +163,8 @@ pub struct AppConfig {
|
||||
pub devices: Vec<DeviceConfig>,
|
||||
pub routing: RoutingConfig,
|
||||
#[serde(default)]
|
||||
pub faxboxes: Vec<FaxBoxConfig>,
|
||||
#[serde(default)]
|
||||
pub voiceboxes: Vec<VoiceboxConfig>,
|
||||
#[serde(default)]
|
||||
pub ivr: Option<IvrConfig>,
|
||||
@@ -191,6 +195,16 @@ pub struct VoiceboxConfig {
|
||||
pub max_recording_sec: Option<u32>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FaxBoxConfig {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(rename = "maxMessages")]
|
||||
pub max_messages: Option<u32>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IVR config
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -415,6 +429,7 @@ pub struct InboundRouteResult {
|
||||
pub ring_all_devices: bool,
|
||||
pub ring_browsers: bool,
|
||||
pub voicemail_box: Option<String>,
|
||||
pub fax_box: Option<String>,
|
||||
pub ivr_menu_id: Option<String>,
|
||||
pub no_answer_timeout: Option<u32>,
|
||||
}
|
||||
@@ -525,6 +540,7 @@ impl AppConfig {
|
||||
ring_all_devices: explicit_targets.is_none(),
|
||||
ring_browsers: route.action.ring_browsers.unwrap_or(false),
|
||||
voicemail_box: route.action.voicemail_box.clone(),
|
||||
fax_box: route.action.fax_box.clone(),
|
||||
ivr_menu_id: route.action.ivr_menu_id.clone(),
|
||||
no_answer_timeout: route.action.no_answer_timeout,
|
||||
});
|
||||
@@ -574,6 +590,7 @@ mod tests {
|
||||
extension: "100".to_string(),
|
||||
}],
|
||||
routing: RoutingConfig { routes },
|
||||
faxboxes: vec![],
|
||||
voiceboxes: vec![],
|
||||
ivr: None,
|
||||
}
|
||||
@@ -620,6 +637,7 @@ mod tests {
|
||||
targets: Some(vec!["desk".to_string()]),
|
||||
ring_browsers: Some(true),
|
||||
voicemail_box: None,
|
||||
fax_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: None,
|
||||
provider: None,
|
||||
@@ -644,6 +662,7 @@ mod tests {
|
||||
targets: None,
|
||||
ring_browsers: Some(false),
|
||||
voicemail_box: Some("support-box".to_string()),
|
||||
fax_box: None,
|
||||
ivr_menu_id: None,
|
||||
no_answer_timeout: Some(20),
|
||||
provider: None,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ use crate::mixer::RtpPacket;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
/// Channel pair for connecting a leg to the mixer.
|
||||
pub struct LegChannels {
|
||||
@@ -109,3 +109,56 @@ pub fn spawn_sip_outbound(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a raw UDP inbound task for non-RTP passthrough media such as T.38 UDPTL.
|
||||
pub fn spawn_raw_udp_inbound(
|
||||
media_socket: Arc<UdpSocket>,
|
||||
inbound_tx: mpsc::Sender<Vec<u8>>,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => break,
|
||||
recv = media_socket.recv_from(&mut buf) => {
|
||||
match recv {
|
||||
Ok((n, _from)) => {
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
if inbound_tx.send(buf[..n].to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a raw UDP outbound task for non-RTP passthrough media such as T.38 UDPTL.
|
||||
pub fn spawn_raw_udp_outbound(
|
||||
media_socket: Arc<UdpSocket>,
|
||||
remote_media: SocketAddr,
|
||||
mut outbound_rx: mpsc::Receiver<Vec<u8>>,
|
||||
mut cancel_rx: watch::Receiver<bool>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel_rx.changed() => break,
|
||||
pkt = outbound_rx.recv() => {
|
||||
match pkt {
|
||||
Some(packet) => {
|
||||
let _ = media_socket.send_to(&packet, remote_media).await;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ mod audio_player;
|
||||
mod call;
|
||||
mod call_manager;
|
||||
mod config;
|
||||
#[allow(dead_code)]
|
||||
mod fax_engine;
|
||||
mod ipc;
|
||||
mod jitter_buffer;
|
||||
mod leg_io;
|
||||
@@ -139,6 +141,7 @@ async fn handle_command(
|
||||
"configure" => handle_configure(engine, out_tx, &cmd).await,
|
||||
"hangup" => handle_hangup(engine, out_tx, &cmd).await,
|
||||
"make_call" => handle_make_call(engine, out_tx, &cmd).await,
|
||||
"send_fax" => handle_send_fax(engine, out_tx, &cmd).await,
|
||||
"add_leg" => handle_add_leg(engine, out_tx, &cmd).await,
|
||||
"remove_leg" => handle_remove_leg(engine, out_tx, &cmd).await,
|
||||
// WebRTC commands — lock webrtc only (no engine contention).
|
||||
@@ -576,6 +579,162 @@ async fn handle_make_call(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd:
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle `send_fax` — place an outbound server-side fax call via SpanDSP over G.711 audio.
|
||||
async fn handle_send_fax(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let number = match cmd.params.get("number").and_then(|v| v.as_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "missing number");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let file_path = match cmd.params.get("file_path").and_then(|v| v.as_str()) {
|
||||
Some(path) if std::path::Path::new(path).exists() => path.to_string(),
|
||||
Some(_) => {
|
||||
respond_err(out_tx, &cmd.id, "fax file does not exist");
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "missing file_path");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let provider_id = cmd.params.get("provider_id").and_then(|v| v.as_str());
|
||||
|
||||
let mut eng = engine.lock().await;
|
||||
let config_ref = match &eng.config {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "not configured");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let provider_config = if let Some(pid) = provider_id {
|
||||
config_ref.providers.iter().find(|p| p.id == pid).cloned()
|
||||
} else {
|
||||
let route = config_ref.resolve_outbound_route(&number, None, &|_| true);
|
||||
route.map(|r| r.provider)
|
||||
};
|
||||
|
||||
let mut provider_config = match provider_config {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "no provider available");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let fax_codec = if provider_config.codecs.contains(&codec_lib::PT_PCMU) {
|
||||
codec_lib::PT_PCMU
|
||||
} else if provider_config.codecs.contains(&codec_lib::PT_PCMA) {
|
||||
codec_lib::PT_PCMA
|
||||
} else {
|
||||
respond_err(
|
||||
out_tx,
|
||||
&cmd.id,
|
||||
&format!(
|
||||
"provider {} does not advertise PCMU/PCMA, which outbound fax currently requires",
|
||||
provider_config.id
|
||||
),
|
||||
);
|
||||
return;
|
||||
};
|
||||
provider_config.codecs = vec![fax_codec];
|
||||
|
||||
let (public_ip, registered_aor) = if let Some(ps_arc) = eng
|
||||
.provider_mgr
|
||||
.find_by_address(
|
||||
&provider_config
|
||||
.outbound_proxy
|
||||
.to_socket_addr()
|
||||
.unwrap_or_else(|| "0.0.0.0:0".parse().unwrap()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let ps = ps_arc.lock().await;
|
||||
(ps.public_ip.clone(), ps.registered_aor.clone())
|
||||
} else {
|
||||
(
|
||||
None,
|
||||
format!(
|
||||
"sip:{}@{}",
|
||||
provider_config.username, provider_config.domain
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let socket = match &eng.transport {
|
||||
Some(t) => t.socket(),
|
||||
None => {
|
||||
respond_err(out_tx, &cmd.id, "not initialized");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let ProxyEngine {
|
||||
ref mut call_mgr,
|
||||
ref mut rtp_pool,
|
||||
..
|
||||
} = *eng;
|
||||
let rtp_pool = rtp_pool.as_mut().unwrap();
|
||||
|
||||
let call_id = call_mgr
|
||||
.make_outbound_call(
|
||||
&number,
|
||||
&provider_config,
|
||||
&config_ref,
|
||||
rtp_pool,
|
||||
&socket,
|
||||
public_ip.as_deref(),
|
||||
®istered_aor,
|
||||
)
|
||||
.await;
|
||||
|
||||
let call_id = match call_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
respond_err(
|
||||
out_tx,
|
||||
&cmd.id,
|
||||
"fax origination failed — provider not registered or no ports available",
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(call) = call_mgr.calls.get_mut(&call_id) {
|
||||
let provider_leg_id = format!("{call_id}-prov");
|
||||
if let Some(leg) = call.legs.get_mut(&provider_leg_id) {
|
||||
leg.codec_pt = fax_codec;
|
||||
leg.metadata
|
||||
.insert("fax_mode".to_string(), serde_json::json!("outbound-audio"));
|
||||
leg.metadata
|
||||
.insert("fax_file_path".to_string(), serde_json::json!(file_path));
|
||||
}
|
||||
}
|
||||
|
||||
emit_event(
|
||||
out_tx,
|
||||
"outbound_call_started",
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"number": number,
|
||||
"provider_id": provider_config.id,
|
||||
"ring_browsers": false,
|
||||
}),
|
||||
);
|
||||
|
||||
respond_ok(
|
||||
out_tx,
|
||||
&cmd.id,
|
||||
serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"codec": if fax_codec == codec_lib::PT_PCMU { "PCMU" } else { "PCMA" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle the `hangup` command.
|
||||
async fn handle_hangup(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cmd: &Command) {
|
||||
let call_id = match cmd.params.get("call_id").and_then(|v| v.as_str()) {
|
||||
@@ -738,6 +897,8 @@ async fn handle_webrtc_link(
|
||||
kind: crate::call::LegKind::WebRtc,
|
||||
state: crate::call::LegState::Connected,
|
||||
codec_pt: codec_lib::PT_OPUS,
|
||||
media_protocol: "webrtc",
|
||||
media_io_active: true,
|
||||
sip_leg: None,
|
||||
sip_call_id: None,
|
||||
webrtc_session_id: Some(session_id.clone()),
|
||||
@@ -762,6 +923,7 @@ async fn handle_webrtc_link(
|
||||
"state": "connected",
|
||||
"codec": "Opus",
|
||||
"rtpPort": 0,
|
||||
"mediaProtocol": "webrtc",
|
||||
"remoteMedia": null,
|
||||
"metadata": {},
|
||||
}),
|
||||
@@ -1462,6 +1624,8 @@ async fn handle_add_tool_leg(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cm
|
||||
kind: crate::call::LegKind::Tool,
|
||||
state: crate::call::LegState::Connected,
|
||||
codec_pt: 0,
|
||||
media_protocol: "internal",
|
||||
media_io_active: true,
|
||||
sip_leg: None,
|
||||
sip_call_id: None,
|
||||
webrtc_session_id: None,
|
||||
@@ -1485,6 +1649,7 @@ async fn handle_add_tool_leg(engine: Arc<Mutex<ProxyEngine>>, out_tx: &OutTx, cm
|
||||
"state": "connected",
|
||||
"codec": null,
|
||||
"rtpPort": 0,
|
||||
"mediaProtocol": "internal",
|
||||
"remoteMedia": null,
|
||||
"metadata": { "tool_type": tool_type_str },
|
||||
}),
|
||||
|
||||
@@ -108,6 +108,24 @@ impl SipLeg {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
self.send_invite_with_sdp(from_uri, to_uri, sip_call_id, socket, sdp)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn send_invite_with_sdp(
|
||||
&mut self,
|
||||
from_uri: &str,
|
||||
to_uri: &str,
|
||||
sip_call_id: &str,
|
||||
socket: &UdpSocket,
|
||||
sdp: String,
|
||||
) {
|
||||
let ip = self
|
||||
.config
|
||||
.public_ip
|
||||
.as_deref()
|
||||
.unwrap_or(&self.config.lan_ip);
|
||||
|
||||
let invite = SipMessage::create_request(
|
||||
"INVITE",
|
||||
to_uri,
|
||||
@@ -401,6 +419,10 @@ impl SipLeg {
|
||||
return SipLegAction::Send(ok.serialize());
|
||||
}
|
||||
|
||||
if method == "INVITE" || method == "UPDATE" {
|
||||
return SipLegAction::InDialogRequest(method.to_string());
|
||||
}
|
||||
|
||||
SipLegAction::None
|
||||
}
|
||||
|
||||
@@ -436,6 +458,9 @@ pub enum SipLegAction {
|
||||
StateChange(LegState),
|
||||
/// Connected — send this ACK.
|
||||
ConnectedWithAck(Vec<u8>),
|
||||
/// Provider sent an in-dialog request (re-INVITE / UPDATE) that needs
|
||||
/// call-manager-specific handling.
|
||||
InDialogRequest(String),
|
||||
/// Terminated with a reason.
|
||||
Terminated(String),
|
||||
/// Send 200 OK and terminate.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -16,4 +16,47 @@ pub struct Endpoint {
|
||||
pub port: u16,
|
||||
/// First payload type from the SDP `m=audio` line (the preferred codec).
|
||||
pub codec_pt: Option<u8>,
|
||||
/// SDP media kind from the `m=` line.
|
||||
pub media_kind: SdpMediaKind,
|
||||
/// SDP transport token from the `m=` line (e.g. `RTP/AVP`, `udptl`).
|
||||
pub transport: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SdpMediaKind {
|
||||
Audio,
|
||||
Image,
|
||||
Application,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl SdpMediaKind {
|
||||
pub fn as_sdp_token(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Audio => "audio",
|
||||
Self::Image => "image",
|
||||
Self::Application => "application",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_sdp_token(token: &str) -> Self {
|
||||
match token.to_ascii_lowercase().as_str() {
|
||||
"audio" => Self::Audio,
|
||||
"image" => Self::Image,
|
||||
"application" => Self::Application,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Endpoint {
|
||||
pub fn is_audio_rtp(&self) -> bool {
|
||||
self.media_kind == SdpMediaKind::Audio
|
||||
&& self.transport.to_ascii_uppercase().starts_with("RTP/")
|
||||
}
|
||||
|
||||
pub fn is_t38_udptl(&self) -> bool {
|
||||
self.media_kind == SdpMediaKind::Image && self.transport.eq_ignore_ascii_case("udptl")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Ported from ts/sip/rewrite.ts.
|
||||
|
||||
use crate::Endpoint;
|
||||
use crate::{Endpoint, SdpMediaKind};
|
||||
|
||||
/// Replaces the host:port in every `sip:` / `sips:` URI found in `value`.
|
||||
pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
|
||||
@@ -57,12 +57,12 @@ pub fn rewrite_sip_uri(value: &str, host: &str, port: u16) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// Rewrites the connection address (`c=`) and audio media port (`m=audio`)
|
||||
/// in an SDP body. Returns the rewritten body together with the original
|
||||
/// endpoint that was replaced (if any).
|
||||
/// Rewrites the connection address (`c=`) and first supported media port
|
||||
/// (`m=audio`, `m=image`, `m=application`) in an SDP body. Returns the
|
||||
/// rewritten body together with the original endpoint that was replaced (if any).
|
||||
pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>) {
|
||||
let mut orig_addr: Option<String> = None;
|
||||
let mut orig_port: Option<u16> = None;
|
||||
let mut orig_media: Option<(SdpMediaKind, u16, String)> = None;
|
||||
|
||||
let lines: Vec<String> = body
|
||||
.replace("\r\n", "\n")
|
||||
@@ -71,10 +71,25 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
|
||||
if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
|
||||
orig_addr = Some(rest.trim().to_string());
|
||||
format!("c=IN IP4 {ip}")
|
||||
} else if line.starts_with("m=audio ") {
|
||||
} else if line.starts_with("m=audio ")
|
||||
|| line.starts_with("m=image ")
|
||||
|| line.starts_with("m=application ")
|
||||
{
|
||||
let parts: Vec<&str> = line.split(' ').collect();
|
||||
if parts.len() >= 2 {
|
||||
orig_port = parts[1].parse().ok();
|
||||
let media_kind = parts[0]
|
||||
.strip_prefix("m=")
|
||||
.map(SdpMediaKind::from_sdp_token)
|
||||
.unwrap_or(SdpMediaKind::Unknown);
|
||||
if orig_media.is_none() {
|
||||
orig_media = parts[1].parse().ok().map(|orig_port| {
|
||||
(
|
||||
media_kind,
|
||||
orig_port,
|
||||
parts.get(2).copied().unwrap_or("").to_string(),
|
||||
)
|
||||
});
|
||||
}
|
||||
let mut rebuilt = parts[0].to_string();
|
||||
rebuilt.push(' ');
|
||||
rebuilt.push_str(&port.to_string());
|
||||
@@ -91,11 +106,13 @@ pub fn rewrite_sdp(body: &str, ip: &str, port: u16) -> (String, Option<Endpoint>
|
||||
})
|
||||
.collect();
|
||||
|
||||
let original = match (orig_addr, orig_port) {
|
||||
(Some(a), Some(p)) => Some(Endpoint {
|
||||
let original = match (orig_addr, orig_media) {
|
||||
(Some(a), Some((media_kind, p, transport))) => Some(Endpoint {
|
||||
address: a,
|
||||
port: p,
|
||||
codec_pt: None,
|
||||
media_kind,
|
||||
transport,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
@@ -130,5 +147,19 @@ mod tests {
|
||||
let ep = orig.unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
assert_eq!(ep.port, 5060);
|
||||
assert_eq!(ep.transport, "RTP/AVP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rewrite_t38_sdp() {
|
||||
let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=image 5060 udptl t38\r\na=T38FaxVersion:0\r\n";
|
||||
let (rewritten, orig) = rewrite_sdp(sdp, "192.168.1.1", 4000);
|
||||
assert!(rewritten.contains("c=IN IP4 192.168.1.1"));
|
||||
assert!(rewritten.contains("m=image 4000 udptl t38"));
|
||||
let ep = orig.unwrap();
|
||||
assert_eq!(ep.address, "10.0.0.1");
|
||||
assert_eq!(ep.port, 5060);
|
||||
assert_eq!(ep.media_kind, SdpMediaKind::Image);
|
||||
assert_eq!(ep.transport, "udptl");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user