Skip to main content

wavekat_sip/
caller.rs

1//! Outbound INVITE handling — symmetric to [`crate::callee`].
2//!
3//! [`Caller::dial`] sends an INVITE in the background and returns a
4//! [`PendingDial`] whose `state_rx` exposes the early dialog states
5//! (`Calling`, `Early`, `Confirmed`, `Terminated`). The consumer pumps
6//! that channel while the UI shows a "Dialing…" / "Ringing…" surface,
7//! then calls [`PendingDial::on_confirmed`] once a 2xx arrives to
8//! collect the negotiated SDP answer plus the bound local RTP socket.
9//!
10//! The local RTP socket is bound **at `dial` time**, not at
11//! `on_confirmed` time, because the SDP offer in the INVITE must carry
12//! a concrete local port. Cancelling the dial or dropping the
13//! [`PendingDial`] frees the socket.
14//!
15//! ## Hanging up a connected call
16//!
17//! [`AcceptedDial::dialog`] is a
18//! [`ClientInviteDialog`]. To hang up locally (user hit "End call"):
19//!
20//! ```ignore
21//! accepted.dialog.bye().await?;
22//! ```
23//!
24//! The dialog state machine then transitions to
25//! `Terminated(_, TerminatedReason::UacBye)` on `state_rx`, so a single
26//! watcher pumping `state_rx` handles both local and remote hangup
27//! through the same code path.
28//!
29//! Audio device I/O, codecs, recording, AI taps — all of those are the
30//! consumer's problem. The `rtp_socket` + `remote_media` +
31//! `local_rtp_addr` triple is the raw plumbing; route frames anywhere.
32
33use std::net::SocketAddr;
34use std::sync::Arc;
35
36use rsipstack::dialog::authenticate::Credential;
37use rsipstack::dialog::client_dialog::ClientInviteDialog;
38use rsipstack::dialog::dialog::{DialogState, DialogStateReceiver};
39use rsipstack::dialog::invitation::{InviteAsyncResult, InviteOption};
40use rsipstack::transport::SipAddr;
41use tokio::net::{lookup_host, UdpSocket};
42use tokio::task::JoinHandle;
43use tracing::{debug, info};
44
45use crate::account::SipAccount;
46use crate::endpoint::SipEndpoint;
47use crate::sdp::{build_sdp, parse_sdp, RemoteMedia};
48
49type BoxError = Box<dyn std::error::Error + Send + Sync>;
50
51/// SIP-only handles for an outbound call that the remote answered.
52///
53/// Symmetric to [`crate::callee::AcceptedCall`] but with a
54/// [`ClientInviteDialog`] (outbound) instead of `ServerInviteDialog`.
55/// The audio plumbing fields are deliberately raw so the consumer can
56/// drop a mic / recorder / AI pipeline onto them without satisfying a
57/// `wavekat-sip` trait.
58pub struct AcceptedDial {
59    /// Client-side dialog. Call `.bye().await` to hang up locally.
60    pub dialog: ClientInviteDialog,
61    /// Where the remote endpoint expects RTP (from the SDP answer).
62    pub remote_media: RemoteMedia,
63    /// Local RTP socket; share via `Arc` to send and receive concurrently.
64    pub rtp_socket: Arc<UdpSocket>,
65    /// Local RTP address advertised in the SDP offer.
66    pub local_rtp_addr: SocketAddr,
67    /// Dialog state updates — re-INVITE acks, BYE, termination reasons.
68    /// Pump this to detect remote hangup.
69    pub state_rx: DialogStateReceiver,
70}
71
72/// An outbound INVITE on the wire whose final response has not arrived.
73///
74/// Pump `state_rx` while the UI shows "Dialing…" / "Ringing…":
75///
76/// - `Calling(_)` — early dialog created; provisional or no response yet.
77/// - `Early(_, _)` — provisional 1xx (180/183) received.
78/// - `Confirmed(_)` — remote picked up; call [`on_confirmed`] to get
79///   the [`AcceptedDial`] (parsed SDP answer + bound RTP socket).
80/// - `Terminated(_, reason)` — call ended; see `TerminatedReason`
81///   (`UasBusy`, `UasDecline`, `Timeout`, …).
82///
83/// If the user hits "End" on the dialing screen, call [`cancel`].
84///
85/// [`cancel`]: PendingDial::cancel
86/// [`on_confirmed`]: PendingDial::on_confirmed
87pub struct PendingDial {
88    /// Client-side dialog. Use this for [`cancel`](Self::cancel) and,
89    /// after promotion to [`AcceptedDial`], for `.bye()`.
90    pub dialog: ClientInviteDialog,
91    /// Dialog state updates from the moment the INVITE goes on the wire.
92    pub state_rx: DialogStateReceiver,
93    rtp_socket: Arc<UdpSocket>,
94    local_rtp_addr: SocketAddr,
95    invite_task: JoinHandle<InviteAsyncResult>,
96}
97
98impl PendingDial {
99    /// Send `CANCEL` for the pending INVITE. Idempotent: returns
100    /// `Ok(())` without re-sending if the dialog has already
101    /// confirmed or terminated. Maps to the user hitting "End" on the
102    /// dialing screen.
103    pub async fn cancel(&self) -> Result<(), BoxError> {
104        match self.dialog.state() {
105            DialogState::Confirmed(_, _) | DialogState::Terminated(_, _) => {
106                debug!("cancel on settled dialog; no-op");
107                Ok(())
108            }
109            _ => {
110                self.dialog.cancel().await?;
111                info!("sent CANCEL on outbound INVITE");
112                Ok(())
113            }
114        }
115    }
116
117    /// Wait for the INVITE transaction to complete and assemble the
118    /// [`AcceptedDial`] from the negotiated SDP answer plus the
119    /// already-bound local RTP socket.
120    ///
121    /// Returns an error if the call did not confirm with a 2xx (final
122    /// non-2xx, timeout, transport error). On error the local RTP
123    /// socket is dropped.
124    pub async fn on_confirmed(self) -> Result<AcceptedDial, BoxError> {
125        let (_dialog_id, resp) = self.invite_task.await??;
126        let resp = resp.ok_or_else::<BoxError, _>(|| "INVITE produced no final response".into())?;
127        if resp.status_code.kind() != rsip::StatusCodeKind::Successful {
128            return Err(format!("INVITE did not confirm: status {}", resp.status_code).into());
129        }
130        let remote_media = parse_sdp(&resp.body)?;
131        info!(
132            remote_addr = %remote_media.addr,
133            remote_port = remote_media.port,
134            payload_type = remote_media.payload_type,
135            "parsed SDP answer",
136        );
137        Ok(AcceptedDial {
138            dialog: self.dialog,
139            remote_media,
140            rtp_socket: self.rtp_socket,
141            local_rtp_addr: self.local_rtp_addr,
142            state_rx: self.state_rx,
143        })
144    }
145}
146
147/// Stateless helper bound to an account + endpoint.
148pub struct Caller {
149    account: SipAccount,
150    endpoint: Arc<SipEndpoint>,
151}
152
153impl Caller {
154    /// Construct a `Caller` for the given account and shared endpoint.
155    pub fn new(account: SipAccount, endpoint: Arc<SipEndpoint>) -> Self {
156        Self { account, endpoint }
157    }
158
159    /// Place an outbound INVITE to `target`. Binds a local RTP socket,
160    /// builds the SDP offer, fires the INVITE in the background, and
161    /// returns a [`PendingDial`] the consumer pumps for state updates.
162    ///
163    /// The destination is resolved from the account's
164    /// `server`/`port` (typical: a SIP proxy). Use
165    /// [`dial_with_destination`](Self::dial_with_destination) to route
166    /// directly to an explicit address (UA-to-UA, tests).
167    pub async fn dial(&self, target: rsip::Uri) -> Result<PendingDial, BoxError> {
168        let destination = resolve_server(&self.account).await?;
169        self.dial_with_destination(target, destination).await
170    }
171
172    /// Like [`dial`](Self::dial) but with an explicit network
173    /// destination override (useful for direct UA-to-UA flows and
174    /// tests where no proxy resolves the target URI).
175    pub async fn dial_with_destination(
176        &self,
177        target: rsip::Uri,
178        destination: Option<SipAddr>,
179    ) -> Result<PendingDial, BoxError> {
180        let rtp_socket = UdpSocket::bind("0.0.0.0:0").await?;
181        let local_rtp_addr = rtp_socket.local_addr()?;
182        let rtp_port = local_rtp_addr.port();
183        let local_ip = self.endpoint.local_ip();
184        info!(local_ip = %local_ip, rtp_port, "bound RTP socket for outbound dial");
185
186        let offer = build_sdp(local_ip, rtp_port);
187        debug!("SDP offer:\n{}", String::from_utf8_lossy(&offer));
188
189        let opt = build_invite_option(
190            &self.account,
191            &self.endpoint.sip_addr.addr.to_string(),
192            target,
193            offer,
194            destination,
195        )?;
196        let (state_sender, state_rx) = self.endpoint.dialog_layer.new_dialog_state_channel();
197        let (dialog, invite_task) = self
198            .endpoint
199            .dialog_layer
200            .do_invite_async(opt, state_sender)?;
201        info!("INVITE on the wire");
202
203        Ok(PendingDial {
204            dialog,
205            state_rx,
206            rtp_socket: Arc::new(rtp_socket),
207            local_rtp_addr,
208            invite_task,
209        })
210    }
211}
212
213/// Resolve the account's configured SIP server to a single
214/// [`SipAddr`]. Returns `None` if the host has no A/AAAA records.
215async fn resolve_server(account: &SipAccount) -> Result<Option<SipAddr>, BoxError> {
216    let host_port = format!("{}:{}", account.server(), account.port());
217    let mut addrs = lookup_host(&host_port).await?;
218    Ok(addrs.next().map(SipAddr::from))
219}
220
221/// Compose an [`InviteOption`] from `account` + target. `contact_host`
222/// is the endpoint's bound `host:port` (used for the `Contact` URI).
223fn build_invite_option(
224    account: &SipAccount,
225    contact_host: &str,
226    target: rsip::Uri,
227    offer: Vec<u8>,
228    destination: Option<SipAddr>,
229) -> Result<InviteOption, BoxError> {
230    let caller_uri: rsip::Uri =
231        format!("sip:{}@{}", account.username, account.domain).try_into()?;
232    let contact_uri: rsip::Uri = format!("sip:{}@{}", account.username, contact_host).try_into()?;
233    let credential = Credential {
234        username: account.auth_username().to_string(),
235        password: account.password.clone(),
236        realm: None,
237    };
238    let display_name = if account.display_name.is_empty() {
239        None
240    } else {
241        Some(account.display_name.clone())
242    };
243    Ok(InviteOption {
244        caller_display_name: display_name,
245        caller: caller_uri,
246        callee: target,
247        destination,
248        content_type: Some("application/sdp".into()),
249        offer: Some(offer),
250        contact: contact_uri,
251        credential: Some(credential),
252        ..Default::default()
253    })
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::account::Transport;
260
261    fn test_account() -> SipAccount {
262        SipAccount {
263            display_name: "Office".to_string(),
264            username: "1001".to_string(),
265            password: "secret".to_string(),
266            domain: "sip.example.com".to_string(),
267            auth_username: None,
268            server: Some("pbx.example.com".to_string()),
269            port: Some(5080),
270            transport: Transport::Udp,
271        }
272    }
273
274    #[test]
275    fn build_invite_option_composes_from_account_and_target() {
276        let acct = test_account();
277        let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap();
278        let opt = build_invite_option(
279            &acct,
280            "192.168.1.50:5060",
281            target.clone(),
282            b"v=0\r\n".to_vec(),
283            None,
284        )
285        .expect("build_invite_option");
286
287        assert_eq!(opt.caller.to_string(), "sip:1001@sip.example.com");
288        assert_eq!(opt.callee, target);
289        assert_eq!(opt.contact.to_string(), "sip:1001@192.168.1.50:5060");
290        assert_eq!(opt.content_type.as_deref(), Some("application/sdp"));
291        assert_eq!(opt.caller_display_name.as_deref(), Some("Office"));
292
293        let cred = opt.credential.expect("credential should be set");
294        assert_eq!(cred.username, "1001");
295        assert_eq!(cred.password, "secret");
296    }
297
298    #[test]
299    fn build_invite_option_uses_auth_username_when_set() {
300        let mut acct = test_account();
301        acct.auth_username = Some("admin".to_string());
302        let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap();
303        let opt = build_invite_option(&acct, "10.0.0.1:5060", target, vec![], None).unwrap();
304        let cred = opt.credential.unwrap();
305        assert_eq!(
306            cred.username, "admin",
307            "credential.username should follow auth_username, not the AOR user"
308        );
309    }
310
311    #[test]
312    fn build_invite_option_omits_display_name_when_empty() {
313        let mut acct = test_account();
314        acct.display_name = String::new();
315        let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap();
316        let opt = build_invite_option(&acct, "10.0.0.1:5060", target, vec![], None).unwrap();
317        assert!(opt.caller_display_name.is_none());
318    }
319
320    #[test]
321    fn build_invite_option_carries_offer_body() {
322        let acct = test_account();
323        let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap();
324        let offer = b"v=0\r\nm=audio 30000 RTP/AVP 0\r\n".to_vec();
325        let opt = build_invite_option(&acct, "10.0.0.1:5060", target, offer.clone(), None).unwrap();
326        assert_eq!(opt.offer.as_deref(), Some(offer.as_slice()));
327    }
328
329    #[test]
330    fn build_invite_option_threads_destination() {
331        let acct = test_account();
332        let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap();
333        let dest: SipAddr = "127.0.0.1:5060".parse::<SocketAddr>().unwrap().into();
334        let opt = build_invite_option(&acct, "10.0.0.1:5060", target, vec![], Some(dest.clone()))
335            .unwrap();
336        assert_eq!(opt.destination, Some(dest));
337    }
338}