feat(proxy-engine): add Rust-based outbound calling, WebRTC bridging, and voicemail handling

This commit is contained in:
2026-04-10 11:36:18 +00:00
parent ad253f823f
commit 239e2ac81d
42 changed files with 3360 additions and 6444 deletions

View File

@@ -241,15 +241,25 @@ impl CallManager {
.unwrap_or("")
.to_string();
// Resolve target device (first registered device for now).
// Resolve target device (first registered device).
let device_addr = match self.resolve_first_device(config, registrar) {
Some(addr) => addr,
None => {
// No device available — could route to voicemail
// For now, send 480 Temporarily Unavailable.
let resp = SipMessage::create_response(480, "Temporarily Unavailable", invite, None);
let _ = socket.send_to(&resp.serialize(), from_addr).await;
return None;
// No device registered — route to voicemail.
return self
.route_to_voicemail(
&call_id,
invite,
from_addr,
&caller_number,
provider_id,
provider_config,
config,
rtp_pool,
socket,
public_ip,
)
.await;
}
};
@@ -487,6 +497,225 @@ impl CallManager {
self.calls.contains_key(sip_call_id)
}
// --- Dashboard outbound call (B2BUA) ---
/// Initiate an outbound call from the dashboard.
/// Builds an INVITE from scratch and sends it to the provider.
/// The browser connects separately via WebRTC and gets linked to this call.
pub async fn make_outbound_call(
&mut self,
number: &str,
provider_config: &ProviderConfig,
config: &AppConfig,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
registered_aor: &str,
) -> Option<String> {
let call_id = self.next_call_id();
let lan_ip = &config.proxy.lan_ip;
let lan_port = config.proxy.lan_port;
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() {
Some(a) => a,
None => return None,
};
// Allocate RTP port for the provider leg.
let rtp_alloc = match rtp_pool.allocate().await {
Some(a) => a,
None => return None,
};
// Build the SIP Call-ID for this new dialog.
let sip_call_id = sip_proto::helpers::generate_call_id(None);
// Build SDP offer.
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
ip: pub_ip,
port: rtp_alloc.port,
payload_types: &provider_config.codecs,
..Default::default()
});
// Build INVITE.
let to_uri = format!("sip:{number}@{}", provider_config.domain);
let invite = SipMessage::create_request(
"INVITE",
&to_uri,
sip_proto::message::RequestOptions {
via_host: pub_ip.to_string(),
via_port: lan_port,
via_transport: None,
via_branch: Some(sip_proto::helpers::generate_branch()),
from_uri: registered_aor.to_string(),
from_display_name: None,
from_tag: Some(sip_proto::helpers::generate_tag()),
to_uri: to_uri.clone(),
to_display_name: None,
to_tag: None,
call_id: Some(sip_call_id.clone()),
cseq: Some(1),
contact: Some(format!("<sip:{pub_ip}:{lan_port}>")),
max_forwards: Some(70),
body: Some(sdp),
content_type: Some("application/sdp".to_string()),
extra_headers: Some(vec![
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
("Allow".to_string(), "INVITE, ACK, OPTIONS, CANCEL, BYE, INFO".to_string()),
]),
},
);
// Send INVITE to provider.
let _ = socket.send_to(&invite.serialize(), provider_dest).await;
// Create call entry — device_addr is a dummy (WebRTC will be linked later).
let dummy_addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
let call = PassthroughCall {
id: call_id.clone(),
sip_call_id: sip_call_id.clone(),
state: CallState::SettingUp,
direction: CallDirection::Outbound,
created_at: Instant::now(),
caller_number: Some(registered_aor.to_string()),
callee_number: Some(number.to_string()),
provider_id: provider_config.id.clone(),
provider_addr: provider_dest,
provider_media: None,
device_addr: dummy_addr,
device_media: None,
rtp_port: rtp_alloc.port,
rtp_socket: rtp_alloc.socket.clone(),
pkt_from_device: 0,
pkt_from_provider: 0,
};
self.calls.insert(sip_call_id, call);
Some(call_id)
}
// --- Voicemail ---
/// Route a call to voicemail: answer the INVITE, play greeting, record message.
async fn route_to_voicemail(
&mut self,
call_id: &str,
invite: &SipMessage,
from_addr: SocketAddr,
caller_number: &str,
provider_id: &str,
provider_config: &ProviderConfig,
config: &AppConfig,
rtp_pool: &mut RtpPortPool,
socket: &UdpSocket,
public_ip: Option<&str>,
) -> Option<String> {
let lan_ip = &config.proxy.lan_ip;
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
// Allocate RTP port for the voicemail session.
let rtp_alloc = match rtp_pool.allocate().await {
Some(a) => a,
None => {
let resp =
SipMessage::create_response(503, "Service Unavailable", invite, None);
let _ = socket.send_to(&resp.serialize(), from_addr).await;
return None;
}
};
// Determine provider's preferred codec.
let codec_pt = provider_config.codecs.first().copied().unwrap_or(9); // default G.722
// Build SDP with our RTP port.
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
ip: pub_ip,
port: rtp_alloc.port,
payload_types: &provider_config.codecs,
..Default::default()
});
// Answer the INVITE with 200 OK.
let response = SipMessage::create_response(
200,
"OK",
invite,
Some(sip_proto::message::ResponseOptions {
to_tag: Some(sip_proto::helpers::generate_tag()),
contact: Some(format!("<sip:{}:{}>", lan_ip, config.proxy.lan_port)),
body: Some(sdp),
content_type: Some("application/sdp".to_string()),
..Default::default()
}),
);
let _ = socket.send_to(&response.serialize(), from_addr).await;
// Extract provider media from original SDP.
let provider_media = if invite.has_sdp_body() {
sip_proto::helpers::parse_sdp_endpoint(&invite.body)
.and_then(|ep| format!("{}:{}", ep.address, ep.port).parse().ok())
} else {
Some(from_addr) // fallback to signaling address
};
let provider_media = provider_media.unwrap_or(from_addr);
// Create a voicemail call entry for BYE routing.
let call = PassthroughCall {
id: call_id.to_string(),
sip_call_id: invite.call_id().to_string(),
state: CallState::Voicemail,
direction: CallDirection::Inbound,
created_at: std::time::Instant::now(),
caller_number: Some(caller_number.to_string()),
callee_number: None,
provider_id: provider_id.to_string(),
provider_addr: from_addr,
provider_media: Some(provider_media),
device_addr: from_addr, // no device — just use provider addr as placeholder
device_media: None,
rtp_port: rtp_alloc.port,
rtp_socket: rtp_alloc.socket.clone(),
pkt_from_device: 0,
pkt_from_provider: 0,
};
self.calls.insert(invite.call_id().to_string(), call);
// Build recording file path.
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let recording_dir = format!(".nogit/voicemail/default");
let recording_path = format!("{recording_dir}/msg-{timestamp}.wav");
// Look for a greeting WAV file.
let greeting_wav = find_greeting_wav();
// Spawn the voicemail session.
let out_tx = self.out_tx.clone();
let call_id_owned = call_id.to_string();
let caller_owned = caller_number.to_string();
let rtp_socket = rtp_alloc.socket;
tokio::spawn(async move {
crate::voicemail::run_voicemail_session(
rtp_socket,
provider_media,
codec_pt,
greeting_wav,
recording_path,
120_000, // max 120 seconds
call_id_owned,
caller_owned,
out_tx,
)
.await;
});
Some(call_id.to_string())
}
// --- Internal helpers ---
fn resolve_first_device(&self, config: &AppConfig, registrar: &Registrar) -> Option<SocketAddr> {
@@ -495,10 +724,25 @@ impl CallManager {
return Some(addr);
}
}
None
None // No device registered — caller goes to voicemail.
}
}
/// Find a voicemail greeting WAV file.
fn find_greeting_wav() -> Option<String> {
// Check common locations for a pre-generated greeting.
let candidates = [
".nogit/voicemail/default/greeting.wav",
".nogit/voicemail/greeting.wav",
];
for path in &candidates {
if std::path::Path::new(path).exists() {
return Some(path.to_string());
}
}
None // No greeting found — voicemail will just play the beep.
}
/// Rewrite SDP for provider→device direction (use LAN IP).
fn rewrite_sdp_for_device(msg: &mut SipMessage, lan_ip: &str, rtp_port: u16) {
if msg.has_sdp_body() {