Skip to main content

wavekat_sip/
caller.rs

1//! Outbound calls and the established-call handle.
2//!
3//! [`Caller::dial`] binds a local RTP socket, builds the SDP offer, places the
4//! INVITE through the engine (answering a digest challenge if the server
5//! demands one), and on a 2xx returns a [`Call`] — the negotiated remote media
6//! plus the bound RTP socket. Audio device I/O, codecs and recording stay with
7//! the consumer; the `rtp_socket` + `remote_media` + `local_rtp_addr` triple is
8//! the raw plumbing.
9
10use std::net::SocketAddr;
11use std::sync::Arc;
12
13use rsip::{Header, Uri};
14use tokio::net::UdpSocket;
15use tokio::sync::{mpsc, Mutex};
16use tokio_util::sync::CancellationToken;
17use tracing::{debug, info};
18
19use crate::account::SipAccount;
20use crate::dtmf_info::{build_info_body, classify, content_type_header, InfoOutcome};
21use crate::endpoint::SipEndpoint;
22use crate::inbound::InboundRequest;
23use crate::rtp::dtmf::DtmfDigit;
24use crate::sdp::{
25    build_sdp_offer, build_sdp_with, parse_sdp, CodecMenu, MediaDirection, RemoteMedia,
26};
27use crate::session_timer::{
28    negotiate_uac, supported_timer_header, SessionDialogOps, SessionExpires, SessionTimer,
29    DEFAULT_SESSION_EXPIRES_SECS,
30};
31use crate::stack::call::{CallConfig, CallOutcome};
32use crate::stack::dialog::{Dialog, DialogId};
33use crate::stack::transaction::gen_tag;
34
35type BoxError = Box<dyn std::error::Error + Send + Sync>;
36
37/// An established call: negotiated remote media plus the local RTP socket.
38///
39/// The same handle is produced by [`Caller::dial`] (outbound) and
40/// [`crate::IncomingCall::accept`] (inbound), so call control is uniform.
41pub struct Call {
42    endpoint: Arc<SipEndpoint>,
43    /// Shared so a background session-timer loop ([`Call::session_handle`]) can
44    /// send refresh re-INVITEs / BYE while the call owner drives audio. The
45    /// mutex serializes the dialog's CSeq across both.
46    dialog: Arc<Mutex<Dialog>>,
47    /// This dialog's identity, used to register for inbound in-dialog requests
48    /// and termination.
49    dialog_id: DialogId,
50    /// The peer's remote target (its `Contact`), captured at establishment. The
51    /// URI a third party should `INVITE` to reach this exact peer — used as the
52    /// `Refer-To` target of an attended transfer (RFC 5589 §7), paired with the
53    /// dialog's `Replaces`.
54    peer_uri: Uri,
55    /// Fired when the peer ends the call (an in-dialog `BYE`); surfaced via
56    /// [`Call::terminated`]. Registered with the endpoint at construction.
57    terminated: CancellationToken,
58    peer: SocketAddr,
59    /// `true` once we have put the peer on hold via a `sendonly` re-INVITE.
60    held: bool,
61    /// SDP `o=` version; bumped on every re-offer (RFC 3264 §5).
62    sdp_version: u32,
63    /// The RFC 4028 session timer negotiated at call setup, if any.
64    session_timer: Option<SessionTimer>,
65    /// Where the remote endpoint expects RTP (from the negotiated SDP).
66    pub remote_media: RemoteMedia,
67    /// Local RTP socket; share via `Arc` to send and receive concurrently.
68    pub rtp_socket: Arc<UdpSocket>,
69    /// Local RTP address advertised in our SDP.
70    pub local_rtp_addr: SocketAddr,
71}
72
73impl Call {
74    pub(crate) fn new(
75        endpoint: Arc<SipEndpoint>,
76        dialog: Dialog,
77        peer: SocketAddr,
78        session_timer: Option<SessionTimer>,
79        remote_media: RemoteMedia,
80        rtp_socket: Arc<UdpSocket>,
81        local_rtp_addr: SocketAddr,
82    ) -> Self {
83        let dialog_id = dialog.id();
84        let peer_uri = dialog.remote_target().clone();
85        // Register for the peer-BYE termination signal up front, so a remote
86        // hangup is observable via `Call::terminated` whether or not the call
87        // ever opts into `inbound_requests`.
88        let terminated = endpoint.register_termination(dialog_id.clone());
89        Self {
90            endpoint,
91            dialog: Arc::new(Mutex::new(dialog)),
92            dialog_id,
93            peer_uri,
94            terminated,
95            peer,
96            held: false,
97            // The initial offer/answer was o= version 0.
98            sdp_version: 0,
99            session_timer,
100            remote_media,
101            rtp_socket,
102            local_rtp_addr,
103        }
104    }
105
106    /// Put the peer on hold (`on = true`, `a=sendonly`) or resume the call
107    /// (`on = false`, `a=sendrecv`) by sending an in-dialog re-INVITE with a
108    /// fresh SDP re-offer (RFC 3264 §8.4).
109    ///
110    /// The local hold state only flips once the peer accepts the re-INVITE with
111    /// a 2xx; a non-2xx final surfaces the server's reason and leaves the call
112    /// unchanged. The `o=` version is bumped for each re-offer regardless, as
113    /// RFC 3264 requires.
114    ///
115    /// The re-offer is **pinned to the negotiated codec** — re-offering the
116    /// full menu would invite the peer to switch codecs mid-call, which the
117    /// consumer's audio path (encoder state, sample rates, recording) is not
118    /// prepared for.
119    pub async fn set_hold(&mut self, on: bool) -> Result<(), BoxError> {
120        let direction = if on {
121            MediaDirection::SendOnly
122        } else {
123            MediaDirection::SendRecv
124        };
125        self.sdp_version += 1;
126        let menu = match self.remote_media.codec {
127            Some(codec) => CodecMenu::Pinned {
128                codec,
129                dtmf: self.remote_media.dtmf(),
130            },
131            // Unreachable on a healthy call (no resolvable codec means the
132            // consumer never got audio flowing); fall back to the symmetric
133            // full menu rather than inventing a codec to pin.
134            None => CodecMenu::Full,
135        };
136        let offer = build_sdp_with(
137            self.endpoint.local_ip(),
138            self.local_rtp_addr.port(),
139            menu,
140            direction,
141            self.sdp_version,
142        );
143        let headers = vec![Header::ContentType("application/sdp".into())];
144        let response = {
145            let mut dialog = self.dialog.lock().await;
146            self.endpoint
147                .ua()
148                .reinvite(self.peer, &mut dialog, headers, offer)
149                .await
150        };
151        match response {
152            Some(r) if (200..300).contains(&r.status_code.code()) => {
153                self.held = on;
154                info!(on, "hold state updated via re-INVITE");
155                Ok(())
156            }
157            Some(r) => Err(format!("re-INVITE rejected: {}", r.status_code).into()),
158            None => Err("re-INVITE timed out with no final response".into()),
159        }
160    }
161
162    /// `true` if the call is currently on hold (we sent a `sendonly` re-INVITE
163    /// the peer accepted).
164    pub fn is_held(&self) -> bool {
165        self.held
166    }
167
168    /// The RFC 4028 session timer negotiated when the call was set up, or
169    /// `None` if neither side asked for one. Drive it with
170    /// [`crate::session_timer_loop`] against [`Call::session_handle`].
171    pub fn session_timer(&self) -> Option<SessionTimer> {
172        self.session_timer
173    }
174
175    /// A cloneable handle that sends refresh re-INVITEs / BYE on this call's
176    /// dialog, for running [`crate::session_timer_loop`] in a background task
177    /// alongside the audio path. Shares the dialog with the `Call`, so their
178    /// in-dialog requests serialize correctly.
179    pub fn session_handle(&self) -> CallSession {
180        CallSession {
181            endpoint: self.endpoint.clone(),
182            dialog: self.dialog.clone(),
183            peer: self.peer,
184        }
185    }
186
187    /// Opt in to handle this call's inbound in-dialog requests — the peer's
188    /// re-`INVITE`s (e.g. an RFC 4028 session refresh, or a peer-initiated
189    /// hold) and `INFO`s (e.g. SIP-INFO DTMF) — instead of having the endpoint
190    /// auto-answer them `200 OK`.
191    ///
192    /// Returns a stream; each [`InboundRequest`] must be answered (with
193    /// [`InboundRequest::respond`] / [`InboundRequest::ok`]). While the returned
194    /// [`InboundRequests`] is alive, those requests route here; drop it to
195    /// revert to auto-answering. `BYE` / `OPTIONS` are always auto-answered.
196    /// Call this once per [`Call`].
197    pub fn inbound_requests(&self) -> InboundRequests {
198        let rx = self.endpoint.register_dialog(self.dialog_id.clone());
199        InboundRequests {
200            endpoint: self.endpoint.clone(),
201            dialog_id: self.dialog_id.clone(),
202            rx,
203        }
204    }
205
206    /// A token that fires when the peer ends the call by sending an in-dialog
207    /// `BYE`. The endpoint auto-answers the BYE `200 OK`; this is purely the
208    /// notification. Clone it and `await` [`CancellationToken::cancelled`] in a
209    /// task to drive call teardown (stop audio, finalize a recording). It does
210    /// **not** fire for a local [`Call::hangup`] — the caller already knows.
211    pub fn terminated(&self) -> CancellationToken {
212        self.terminated.clone()
213    }
214
215    /// The peer's remote-target URI (its `Contact`) — the address a third party
216    /// should `INVITE` to reach this exact peer. Used as the `Refer-To` target
217    /// of an attended transfer (read off the *consultation* call), paired with
218    /// [`Call::dialog_triplet`] for the `Replaces`.
219    pub fn peer_uri(&self) -> &Uri {
220        &self.peer_uri
221    }
222
223    /// This call's dialog identity (Call-ID + our/remote tags), for naming it in
224    /// an attended transfer's `Replaces` (RFC 3891). Read it off the
225    /// *consultation* call (the leg we built to the transfer target) and pass it
226    /// to [`Call::attended_transfer`] on the call being transferred.
227    pub fn dialog_triplet(&self) -> crate::refer::DialogTriplet {
228        crate::refer::DialogTriplet {
229            call_id: self.dialog_id.call_id.clone(),
230            local_tag: self.dialog_id.local_tag.clone(),
231            remote_tag: self.dialog_id.remote_tag.clone(),
232        }
233    }
234
235    /// Send one DTMF press via SIP `INFO` (`application/dtmf-relay`).
236    ///
237    /// Use this only when the remote did not negotiate RFC 4733 — i.e.
238    /// [`RemoteMedia::dtmf_payload_type`] is `None`. When telephone-event is
239    /// available, prefer [`crate::send_dtmf_burst`] over RTP. A
240    /// [`InfoOutcome::UnsupportedMedia`] result means the remote rejects this
241    /// transport too; stop sending further presses on this dialog.
242    pub async fn send_dtmf_info(&mut self, digit: DtmfDigit, duration_ms: u32) -> InfoOutcome {
243        let body = build_info_body(digit, duration_ms).into_bytes();
244        let response = {
245            let mut dialog = self.dialog.lock().await;
246            self.endpoint
247                .ua()
248                .info(self.peer, &mut dialog, vec![content_type_header()], body)
249                .await
250        };
251        classify(response)
252    }
253
254    /// Blind-transfer the call: ask the peer to place a fresh call to `target`
255    /// by sending an in-dialog `REFER` with a `Refer-To` (RFC 3515).
256    ///
257    /// Returns `Ok(())` once the peer accepts the `REFER` with a 2xx
258    /// (`202 Accepted`) — at which point the transfer is *in progress*, not yet
259    /// complete. The peer then reports the outcome as a series of in-dialog
260    /// `NOTIFY`s (a `message/sipfrag` status line) that arrive on
261    /// [`Call::inbound_requests`]; the consumer watches those (parsing each with
262    /// [`crate::parse_sipfrag_status`]) and tears its own leg down once the
263    /// target answers. A non-2xx final to the `REFER` surfaces the peer's reason
264    /// and leaves the call unchanged — the peer won't honour the transfer, so
265    /// the consumer should keep the call up.
266    ///
267    /// This is *blind* (unattended) transfer: we do not first call `target`
268    /// ourselves. Attended transfer (consult `target`, then `REFER` with
269    /// `Replaces`) is a separate method, not yet implemented.
270    pub async fn blind_transfer(&mut self, target: Uri) -> Result<(), BoxError> {
271        let headers = vec![crate::refer::refer_to_header(&target)];
272        let response = {
273            let mut dialog = self.dialog.lock().await;
274            self.endpoint
275                .ua()
276                .refer(self.peer, &mut dialog, headers)
277                .await
278        };
279        match response {
280            Some(r) if (200..300).contains(&r.status_code.code()) => {
281                info!(%target, "blind transfer accepted (REFER 2xx); awaiting NOTIFY");
282                Ok(())
283            }
284            Some(r) => Err(format!("REFER rejected: {}", r.status_code).into()),
285            None => Err("REFER timed out with no final response".into()),
286        }
287    }
288
289    /// Attended-transfer the call: ask the peer (the party we hold) to take over
290    /// the consultation dialog named by `replaces` by sending an in-dialog
291    /// `REFER` whose `Refer-To` carries `target` plus a `Replaces` header
292    /// (RFC 3515 + RFC 3891).
293    ///
294    /// `replaces` is the dialog identity of the *consultation* call — the leg we
295    /// already established to `target` ourselves — read via
296    /// [`Call::dialog_triplet`]. When the peer accepts (`202`), it `INVITE`s
297    /// `target` with that `Replaces`, so `target` replaces the consultation leg
298    /// rather than ringing afresh. The outcome arrives exactly as for a blind
299    /// transfer — `NOTIFY`/sipfrag on [`Call::inbound_requests`] — so the
300    /// consumer drives both the same way. A non-2xx final to the `REFER`
301    /// surfaces the peer's reason and leaves the call unchanged.
302    pub async fn attended_transfer(
303        &mut self,
304        target: Uri,
305        replaces: &crate::refer::DialogTriplet,
306    ) -> Result<(), BoxError> {
307        let headers = vec![crate::refer::refer_to_with_replaces(&target, replaces)];
308        let response = {
309            let mut dialog = self.dialog.lock().await;
310            self.endpoint
311                .ua()
312                .refer(self.peer, &mut dialog, headers)
313                .await
314        };
315        match response {
316            Some(r) if (200..300).contains(&r.status_code.code()) => {
317                info!(%target, "attended transfer accepted (REFER 2xx); awaiting NOTIFY");
318                Ok(())
319            }
320            Some(r) => Err(format!("REFER rejected: {}", r.status_code).into()),
321            None => Err("REFER timed out with no final response".into()),
322        }
323    }
324
325    /// Hang up by sending an in-dialog `BYE`. Returns once the peer 2xxs it
326    /// (or the transaction gives up).
327    pub async fn hangup(&mut self) -> Result<(), BoxError> {
328        let acked = {
329            let mut dialog = self.dialog.lock().await;
330            self.endpoint.ua().hangup(self.peer, &mut dialog).await
331        };
332        if acked {
333            info!("call hung up (BYE acknowledged)");
334            Ok(())
335        } else {
336            Err("BYE was not acknowledged".into())
337        }
338    }
339}
340
341impl Drop for Call {
342    fn drop(&mut self) {
343        // Release the termination registration so the endpoint's table doesn't
344        // grow for the life of the process. (`InboundRequests` similarly
345        // unregisters the dialog on its own drop.)
346        self.endpoint.unregister_termination(&self.dialog_id);
347    }
348}
349
350/// A stream of a [`Call`]'s inbound in-dialog requests (peer re-`INVITE` /
351/// `INFO`), produced by [`Call::inbound_requests`].
352///
353/// Dropping it unregisters the dialog, so its inbound requests revert to being
354/// auto-answered `200 OK` by the endpoint.
355pub struct InboundRequests {
356    endpoint: Arc<SipEndpoint>,
357    dialog_id: DialogId,
358    rx: mpsc::Receiver<InboundRequest>,
359}
360
361impl InboundRequests {
362    /// Await the next inbound request, or `None` once the call's endpoint shuts
363    /// down or this stream is being torn down.
364    pub async fn recv(&mut self) -> Option<InboundRequest> {
365        self.rx.recv().await
366    }
367}
368
369impl Drop for InboundRequests {
370    fn drop(&mut self) {
371        self.endpoint.unregister_dialog(&self.dialog_id);
372    }
373}
374
375/// A cloneable session-control handle over a [`Call`]'s dialog.
376///
377/// Produced by [`Call::session_handle`] and consumed by
378/// [`crate::session_timer_loop`]: it implements [`SessionDialogOps`] so the
379/// loop can send refresh re-INVITEs and the tear-down BYE on the shared dialog.
380#[derive(Clone)]
381pub struct CallSession {
382    endpoint: Arc<SipEndpoint>,
383    dialog: Arc<Mutex<Dialog>>,
384    peer: SocketAddr,
385}
386
387impl SessionDialogOps for CallSession {
388    async fn refresh(
389        &self,
390        mut headers: Vec<Header>,
391        body: Option<Vec<u8>>,
392    ) -> Result<Option<rsip::Response>, BoxError> {
393        let body = body.unwrap_or_default();
394        if !body.is_empty() {
395            headers.push(Header::ContentType("application/sdp".into()));
396        }
397        let mut dialog = self.dialog.lock().await;
398        Ok(self
399            .endpoint
400            .ua()
401            .reinvite(self.peer, &mut dialog, headers, body)
402            .await)
403    }
404
405    async fn send_bye(&self) -> Result<(), BoxError> {
406        let mut dialog = self.dialog.lock().await;
407        if self.endpoint.ua().hangup(self.peer, &mut dialog).await {
408            Ok(())
409        } else {
410            Err("BYE was not acknowledged".into())
411        }
412    }
413}
414
415/// Stateless helper bound to an account + endpoint.
416pub struct Caller {
417    account: SipAccount,
418    endpoint: Arc<SipEndpoint>,
419}
420
421impl Caller {
422    /// Construct a `Caller` for the given account and shared endpoint.
423    pub fn new(account: SipAccount, endpoint: Arc<SipEndpoint>) -> Self {
424        Self { account, endpoint }
425    }
426
427    /// Place an outbound call to `target` and wait for it to be answered.
428    ///
429    /// Binds a local RTP socket, offers the full codec menu (Opus preferred,
430    /// G.711 fallback — see [`crate::sdp::CodecMenu::Full`]), sends the INVITE
431    /// to the account's resolved server, follows provisional responses, and
432    /// answers a single `401`/`407` challenge. Returns the [`Call`] on a 2xx,
433    /// or an error if the call was rejected, timed out, or had no usable SDP
434    /// answer. The negotiated codec is [`RemoteMedia::codec`] on the returned
435    /// call.
436    pub async fn dial(&self, target: Uri) -> Result<Call, BoxError> {
437        self.dial_inner(target, &CancellationToken::new(), None)
438            .await
439    }
440
441    /// Like [`Caller::dial`], but `cancel` aborts a still-ringing call with a
442    /// `CANCEL` (RFC 3261 §9). Firing the token once a provisional has arrived
443    /// tears the pending INVITE down; the returned error then reflects the
444    /// `487 Request Terminated`. Use `cancel.is_cancelled()` to tell a
445    /// cancellation apart from a callee rejection.
446    pub async fn dial_cancellable(
447        &self,
448        target: Uri,
449        cancel: &CancellationToken,
450    ) -> Result<Call, BoxError> {
451        self.dial_inner(target, cancel, None).await
452    }
453
454    /// Like [`Caller::dial_cancellable`], and additionally forwards each
455    /// provisional response status (e.g. [`rsip::StatusCode::Ringing`]) to
456    /// `progress` as it arrives — for a "ringing" UI. The channel closes when
457    /// the call reaches a final response.
458    pub async fn dial_with_progress(
459        &self,
460        target: Uri,
461        cancel: &CancellationToken,
462        progress: mpsc::Sender<rsip::StatusCode>,
463    ) -> Result<Call, BoxError> {
464        self.dial_inner(target, cancel, Some(progress)).await
465    }
466
467    async fn dial_inner(
468        &self,
469        target: Uri,
470        cancel: &CancellationToken,
471        progress: Option<mpsc::Sender<rsip::StatusCode>>,
472    ) -> Result<Call, BoxError> {
473        let rtp_socket = UdpSocket::bind("0.0.0.0:0").await?;
474        let local_rtp_addr = rtp_socket.local_addr()?;
475        let local_ip = self.endpoint.local_ip();
476        info!(%local_ip, rtp_port = local_rtp_addr.port(), "bound RTP socket for outbound dial");
477
478        let offer = build_sdp_offer(local_ip, local_rtp_addr.port());
479        debug!("SDP offer:\n{}", String::from_utf8_lossy(&offer));
480
481        let from: Uri =
482            format!("sip:{}@{}", self.account.username, self.account.domain).try_into()?;
483        let contact: Uri = format!(
484            "sip:{}@{}",
485            self.account.username,
486            self.endpoint.local_addr()
487        )
488        .try_into()?;
489
490        // Advertise RFC 4028 session-timer support so the answerer can pin a
491        // refresh interval in its 2xx (negotiated below).
492        let cfg = CallConfig {
493            target,
494            from,
495            contact,
496            from_tag: gen_tag(),
497            call_id: format!("{}@wavekat.com", gen_tag()),
498            sdp: offer,
499            extra_headers: vec![
500                supported_timer_header(),
501                SessionExpires {
502                    interval_secs: DEFAULT_SESSION_EXPIRES_SECS,
503                    refresher: None,
504                }
505                .header(),
506            ],
507            username: self.account.auth_username().to_string(),
508            password: self.account.password.clone(),
509        };
510
511        match self
512            .endpoint
513            .ua()
514            .call_cancellable(&cfg, self.endpoint.server(), 1, cancel, progress.as_ref())
515            .await
516        {
517            CallOutcome::Answered { dialog, response } => {
518                let remote_media = parse_sdp(&response.body)?;
519                let session_timer = negotiate_uac(&response.headers);
520                info!(
521                    remote_addr = %remote_media.addr,
522                    remote_port = remote_media.port,
523                    payload_type = remote_media.payload_type,
524                    codec = ?remote_media.codec,
525                    ?session_timer,
526                    "call answered; parsed SDP answer",
527                );
528                Ok(Call::new(
529                    self.endpoint.clone(),
530                    *dialog,
531                    self.endpoint.server(),
532                    session_timer,
533                    remote_media,
534                    Arc::new(rtp_socket),
535                    local_rtp_addr,
536                ))
537            }
538            CallOutcome::Rejected(status) => Err(format!("call rejected: {status}").into()),
539            CallOutcome::Unauthorized => Err("call rejected: authentication failed".into()),
540            CallOutcome::TimedOut => Err("call timed out with no final response".into()),
541            CallOutcome::EngineStopped => Err("engine stopped".into()),
542        }
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::account::Transport;
550
551    fn test_account() -> SipAccount {
552        SipAccount {
553            display_name: "Office".to_string(),
554            username: "1001".to_string(),
555            password: "secret".to_string(),
556            domain: "sip.example.com".to_string(),
557            auth_username: None,
558            server: Some("pbx.example.com".to_string()),
559            port: Some(5080),
560            transport: Transport::Udp,
561        }
562    }
563
564    #[test]
565    fn caller_holds_account_and_endpoint_inputs() {
566        // Construction is pure; the call path is covered by the stack's
567        // loopback tests (`stack::ua`). Here we just check `new` wiring.
568        let acct = test_account();
569        assert_eq!(acct.auth_username(), "1001");
570    }
571}