feat(proxy-engine): add Rust-based outbound calling, WebRTC bridging, and voicemail handling
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user