Skip to main content

videosdk/resources/sip/
calls.rs

1//! Places and manages SIP calls.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use futures_util::Stream;
7use reqwest::Method;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::client::{CallOptions, Client};
12use crate::error::{Error, Result};
13use crate::pagination::{auto_page, paginate, ItemMapper, ListParams, Page, PageFetcher};
14use crate::query::QueryBuilder;
15use crate::resources::escape;
16use crate::resources::sip::{SipCallStatus, SipDirection, SipIncludeHeaders, SipMediaEncryption};
17
18const PATH: &str = "/v2/sip/call";
19
20/// A SIP call.
21#[derive(Debug, Clone, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct SipCall {
24    /// The call id.
25    ///
26    /// The list endpoint keys this `callId`; the SDK surfaces it here either way.
27    #[serde(default, deserialize_with = "crate::common::null_to_default")]
28    pub id: String,
29    /// The call's direction.
30    #[serde(rename = "type")]
31    pub direction: Option<SipDirection>,
32    /// The SIP transport used.
33    pub transport: Option<String>,
34    /// The upstream provider.
35    pub provider: Option<String>,
36    /// The gateway the call was placed through.
37    pub gateway_id: Option<String>,
38    /// The routing rule that matched.
39    pub rule_id: Option<String>,
40    /// The room the call landed in.
41    pub room_id: Option<String>,
42    /// The destination number.
43    pub to: Option<String>,
44    /// The source number.
45    pub from: Option<String>,
46    /// Transfer details, when the call was transferred.
47    pub transfer: Option<Value>,
48    /// The session the call belongs to.
49    pub session_id: Option<String>,
50    /// The call's lifecycle status.
51    pub status: Option<SipCallStatus>,
52    /// Free-form metadata.
53    pub metadata: Option<Map<String, Value>>,
54    /// When the call started.
55    pub start: Option<String>,
56    /// When the call ended.
57    pub end: Option<String>,
58    /// The call's state transitions.
59    #[serde(default, deserialize_with = "crate::common::null_to_default")]
60    pub timelog: Vec<Value>,
61    /// The region the call ran in.
62    pub region: Option<String>,
63    /// Provider-specific details.
64    pub additional_details: Option<Value>,
65    /// Any fields the server returned that this SDK does not model yet.
66    #[serde(flatten)]
67    pub extra: Map<String, Value>,
68}
69
70/// The parameters for [`SipCallsResource::create`].
71///
72/// When the call originates from a provisioned phone number — `call_from` is a
73/// provisioned number, typically with `routing_rule_id` — then `record_audio`,
74/// `dtmf`, `hide_phone_number`, `headers`, `include_headers` and
75/// `media_encryption` are taken from the matching routing rule and gateway, so
76/// the per-call values here are ignored. When the call is placed directly
77/// through an outbound gateway (`gateway_id`), the per-call values apply.
78#[derive(Debug, Clone, Default)]
79pub struct CreateSipCallParams {
80    /// The destination number. Required.
81    pub call_to: String,
82    /// The source number. Required unless derivable from `gateway_id`.
83    pub call_from: Option<String>,
84    /// The destination room.
85    pub room_id: Option<String>,
86    /// The id to assign the call's participant in the destination room.
87    ///
88    /// Ignored when `participant` is set.
89    pub participant_id: Option<String>,
90    /// The outbound gateway to place the call through.
91    pub gateway_id: Option<String>,
92    /// The outbound routing rule to place the call through.
93    pub routing_rule_id: Option<String>,
94    /// The full participant configuration, overriding `participant_id`.
95    pub participant: Option<Map<String, Value>>,
96    /// Free-form metadata.
97    pub metadata: Option<Map<String, Value>>,
98    /// Whether to record the call's audio.
99    pub record_audio: Option<bool>,
100    /// Whether to block until the call is answered.
101    pub wait_until_answered: Option<bool>,
102    /// How long to ring before giving up, in seconds.
103    pub ringing_timeout: Option<u32>,
104    /// Whether to enable DTMF.
105    pub dtmf: Option<bool>,
106    /// The maximum call duration, in seconds.
107    pub max_call_duration: Option<u32>,
108    /// Whether to encrypt media.
109    pub media_encryption: Option<SipMediaEncryption>,
110    /// Whether to hide the caller's number from the room.
111    pub hide_phone_number: Option<bool>,
112    /// Headers to add to the outbound call.
113    pub headers: HashMap<String, String>,
114    /// Which SIP headers to forward.
115    pub include_headers: Option<SipIncludeHeaders>,
116}
117
118/// `call_to` becomes `sipCallTo`, `call_from` becomes `sipCallFrom`, and
119/// `room_id` becomes `destinationRoomId`.
120#[derive(Debug, Serialize)]
121#[serde(rename_all = "camelCase")]
122struct CreateSipCallWire {
123    sip_call_to: String,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    sip_call_from: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    destination_room_id: Option<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    participant: Option<Map<String, Value>>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    gateway_id: Option<String>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    routing_rule_id: Option<String>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    metadata: Option<Map<String, Value>>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    record_audio: Option<bool>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    wait_until_answered: Option<bool>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    ringing_timeout: Option<u32>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    dtmf: Option<bool>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    max_call_duration: Option<u32>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    media_encryption: Option<SipMediaEncryption>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    hide_phone_number: Option<bool>,
150    #[serde(skip_serializing_if = "HashMap::is_empty")]
151    headers: HashMap<String, String>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    include_headers: Option<SipIncludeHeaders>,
154}
155
156impl From<CreateSipCallParams> for CreateSipCallWire {
157    fn from(params: CreateSipCallParams) -> Self {
158        // `participant_id` is sugar for a participant object.
159        let participant = params.participant.or_else(|| {
160            params.participant_id.map(|id| {
161                let mut map = Map::new();
162                map.insert("id".to_string(), Value::String(id));
163                map
164            })
165        });
166
167        Self {
168            sip_call_to: params.call_to,
169            sip_call_from: params.call_from,
170            destination_room_id: params.room_id,
171            participant,
172            gateway_id: params.gateway_id,
173            routing_rule_id: params.routing_rule_id,
174            metadata: params.metadata,
175            record_audio: params.record_audio,
176            wait_until_answered: params.wait_until_answered,
177            ringing_timeout: params.ringing_timeout,
178            dtmf: params.dtmf,
179            max_call_duration: params.max_call_duration,
180            media_encryption: params.media_encryption,
181            hide_phone_number: params.hide_phone_number,
182            headers: params.headers,
183            include_headers: params.include_headers,
184        }
185    }
186}
187
188/// The parameters for [`SipCallsResource::transfer`].
189#[derive(Debug, Clone, Default)]
190pub struct TransferSipCallParams {
191    /// The destination to transfer to.
192    pub to: Option<String>,
193    /// The participant configuration for the transferred leg.
194    pub participant: Option<Map<String, Value>>,
195    /// Whether to play a dial tone during the transfer.
196    pub play_dialtone: Option<bool>,
197}
198
199/// `to` becomes `transferTo`.
200#[derive(Debug, Serialize)]
201#[serde(rename_all = "camelCase")]
202struct TransferWire<'a> {
203    call_id: &'a str,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    transfer_to: Option<&'a str>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    participant: Option<&'a Map<String, Value>>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    play_dialtone: Option<bool>,
210}
211
212/// The parameters for [`SipCallsResource::switch_room`].
213#[derive(Debug, Clone, Default)]
214pub struct SwitchRoomParams {
215    /// The destination room. Required.
216    pub room_id: String,
217    /// The id to assign the call's participant in the destination room.
218    pub participant_id: Option<String>,
219    /// A token authorizing the move.
220    pub token: Option<String>,
221}
222
223#[derive(Debug, Serialize)]
224#[serde(rename_all = "camelCase")]
225struct SwitchRoomWire<'a> {
226    call_id: &'a str,
227    room_id: &'a str,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    participant_id: Option<&'a str>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    token: Option<&'a str>,
232}
233
234/// The query parameters for [`SipCallsResource::list`].
235#[derive(Debug, Clone, Default)]
236pub struct SipCallListParams {
237    /// The 1-based page number.
238    pub page: Option<u32>,
239    /// Items per page.
240    pub per_page: Option<u32>,
241    /// An opaque cursor from a previous page.
242    pub cursor: Option<String>,
243    /// Filters by room id.
244    pub room_id: Option<String>,
245    /// Filters by session id.
246    pub session_id: Option<String>,
247    /// Filters by call id.
248    pub id: Option<String>,
249    /// Filters by gateway id.
250    pub gateway_id: Option<String>,
251    /// Filters by routing-rule id.
252    pub rule_id: Option<String>,
253    /// Filters by direction.
254    pub direction: Option<SipDirection>,
255    /// Matches on `to`, `from` or `room_id`.
256    pub search: Option<String>,
257    /// Filters by call start, in epoch milliseconds. Start of the range.
258    pub start_date: Option<i64>,
259    /// Filters by call start, in epoch milliseconds. End of the range.
260    pub end_date: Option<i64>,
261}
262
263impl SipCallListParams {
264    fn pagination(&self) -> ListParams {
265        ListParams {
266            page: self.page,
267            per_page: self.per_page,
268            cursor: self.cursor.clone(),
269        }
270    }
271}
272
273/// The list aggregation exposes the id as `callId`. Surface it as `id`, so a
274/// listed call has the same shape as a fetched one.
275fn call_item_mapper() -> ItemMapper<SipCall> {
276    Arc::new(|value: Value| {
277        let mut call: SipCall =
278            serde_json::from_value(value).map_err(|e| Error::decode("SIP call list item", e))?;
279        if call.id.is_empty() {
280            if let Some(call_id) = call.extra.get("callId").and_then(Value::as_str) {
281                call.id = call_id.to_string();
282            }
283        }
284        Ok(call)
285    })
286}
287
288/// SIP calls. Reached via [`SipResource::calls`](crate::SipResource::calls).
289#[derive(Debug, Clone, Copy)]
290pub struct SipCallsResource<'a> {
291    client: &'a Client,
292}
293
294impl<'a> SipCallsResource<'a> {
295    pub(crate) fn new(client: &'a Client) -> Self {
296        Self { client }
297    }
298
299    /// Places an outbound call.
300    pub async fn create(&self, params: CreateSipCallParams) -> Result<SipCall> {
301        if params.call_to.is_empty() {
302            return Err(Error::validation(
303                "sip.calls().create() requires call_to, the destination number",
304            ));
305        }
306        let body = CreateSipCallWire::from(params);
307        self.client
308            .data(Method::POST, PATH, CallOptions::json(&body)?)
309            .await
310    }
311
312    /// Lists calls, one page at a time.
313    pub async fn list(&self, params: SipCallListParams) -> Result<Page<SipCall>> {
314        paginate(
315            self.fetcher(&params),
316            &params.pagination(),
317            "data",
318            Some(call_item_mapper()),
319        )
320        .await
321    }
322
323    /// Lists calls, transparently fetching every page.
324    pub fn list_stream(
325        &self,
326        params: SipCallListParams,
327    ) -> impl Stream<Item = Result<SipCall>> + Send {
328        auto_page(
329            self.fetcher(&params),
330            params.pagination(),
331            "data",
332            Some(call_item_mapper()),
333        )
334    }
335
336    /// Fetches a call by id.
337    pub async fn get(&self, call_id: &str) -> Result<SipCall> {
338        let path = format!("{PATH}/{}", escape(call_id));
339        self.client
340            .data(Method::GET, &path, CallOptions::new())
341            .await
342    }
343
344    /// Cold-transfers an answered call.
345    pub async fn transfer(&self, call_id: &str, params: TransferSipCallParams) -> Result<SipCall> {
346        let body = TransferWire {
347            call_id,
348            transfer_to: params.to.as_deref(),
349            participant: params.participant.as_ref(),
350            play_dialtone: params.play_dialtone,
351        };
352        let path = format!("{PATH}/transfer");
353        self.client
354            .data(Method::POST, &path, CallOptions::json(&body)?)
355            .await
356    }
357
358    /// Hangs up a call.
359    pub async fn end(&self, call_id: &str) -> Result<SipCall> {
360        let body = serde_json::json!({ "callId": call_id });
361        let path = format!("{PATH}/end");
362        self.client
363            .data(Method::POST, &path, CallOptions::json(&body)?)
364            .await
365    }
366
367    /// Moves an active SIP leg to another room.
368    pub async fn switch_room(&self, call_id: &str, params: SwitchRoomParams) -> Result<SipCall> {
369        let body = SwitchRoomWire {
370            call_id,
371            room_id: &params.room_id,
372            participant_id: params.participant_id.as_deref(),
373            token: params.token.as_deref(),
374        };
375        let path = format!("{PATH}/switch-room");
376        self.client
377            .data(Method::POST, &path, CallOptions::json(&body)?)
378            .await
379    }
380
381    fn fetcher(&self, params: &SipCallListParams) -> PageFetcher {
382        let client = self.client.clone();
383        let params = params.clone();
384        Arc::new(move |page, per_page| {
385            let client = client.clone();
386            let params = params.clone();
387            Box::pin(async move {
388                let query = QueryBuilder::new()
389                    .opt("page", page)
390                    .opt("perPage", per_page)
391                    .opt_str("roomId", params.room_id.as_deref())
392                    .opt_str("sessionId", params.session_id.as_deref())
393                    .opt_str("id", params.id.as_deref())
394                    .opt_str("gatewayId", params.gateway_id.as_deref())
395                    .opt_str("ruleId", params.rule_id.as_deref())
396                    .opt_str("type", params.direction.as_ref().map(SipDirection::as_str))
397                    .opt_str("search", params.search.as_deref())
398                    .opt("startDate", params.start_date)
399                    .opt("endDate", params.end_date)
400                    .into_pairs();
401                client
402                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
403                    .await
404            })
405        })
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use serde_json::json;
413
414    fn wire(params: CreateSipCallParams) -> Value {
415        serde_json::to_value(CreateSipCallWire::from(params)).unwrap()
416    }
417
418    #[test]
419    fn create_renames_call_to_and_call_from() {
420        let body = wire(CreateSipCallParams {
421            call_to: "+15550001".into(),
422            call_from: Some("+15550002".into()),
423            room_id: Some("r-1".into()),
424            ..Default::default()
425        });
426        assert_eq!(
427            body,
428            json!({
429                "sipCallTo": "+15550001",
430                "sipCallFrom": "+15550002",
431                "destinationRoomId": "r-1",
432            })
433        );
434    }
435
436    #[test]
437    fn participant_id_is_sugar_for_a_participant_object() {
438        let body = wire(CreateSipCallParams {
439            call_to: "+1".into(),
440            participant_id: Some("p-1".into()),
441            ..Default::default()
442        });
443        assert_eq!(body["participant"], json!({"id": "p-1"}));
444    }
445
446    #[test]
447    fn an_explicit_participant_wins_over_participant_id() {
448        let mut participant = Map::new();
449        participant.insert("id".into(), json!("explicit"));
450        participant.insert("name".into(), json!("Ada"));
451
452        let body = wire(CreateSipCallParams {
453            call_to: "+1".into(),
454            participant_id: Some("sugar".into()),
455            participant: Some(participant),
456            ..Default::default()
457        });
458        assert_eq!(body["participant"]["id"], json!("explicit"));
459        assert_eq!(body["participant"]["name"], json!("Ada"));
460    }
461
462    #[test]
463    fn absent_options_are_omitted() {
464        let body = wire(CreateSipCallParams {
465            call_to: "+1".into(),
466            ..Default::default()
467        });
468        assert_eq!(body, json!({"sipCallTo": "+1"}));
469    }
470
471    #[test]
472    fn the_list_mapper_surfaces_call_id_as_id() {
473        let mapper = call_item_mapper();
474        let call = mapper(json!({"callId": "c-1", "to": "+1"})).unwrap();
475        assert_eq!(call.id, "c-1");
476
477        // A real `id` is never overwritten.
478        let call = mapper(json!({"id": "real", "callId": "c-1"})).unwrap();
479        assert_eq!(call.id, "real");
480    }
481
482    #[test]
483    fn transfer_renames_to_as_transfer_to() {
484        let body = serde_json::to_value(TransferWire {
485            call_id: "c-1",
486            transfer_to: Some("+15550003"),
487            participant: None,
488            play_dialtone: Some(true),
489        })
490        .unwrap();
491        assert_eq!(
492            body,
493            json!({"callId": "c-1", "transferTo": "+15550003", "playDialtone": true})
494        );
495    }
496}