Skip to main content

asterisk_rs/
pbx.rs

1//! high-level PBX abstraction for call management over AMI
2//!
3//! wraps [`AmiClient`] with call lifecycle
4//! tracking and convenience methods for common telephony operations
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use asterisk_rs_ami::action::{HangupAction, OriginateAction};
10use asterisk_rs_ami::event::AmiEvent;
11use asterisk_rs_ami::tracker::{CallTracker, CompletedCall};
12use asterisk_rs_ami::AmiClient;
13use asterisk_rs_core::event::EventSubscription;
14use tokio::sync::{mpsc, Mutex};
15
16/// a live call being tracked by the PBX
17///
18/// wraps a channel name and unique_id with the AMI client for
19/// issuing commands and tracking events
20#[derive(Debug, Clone)]
21pub struct Call {
22    /// channel name (e.g. "PJSIP/100-00000001")
23    pub channel: String,
24    /// per-channel unique identifier
25    pub unique_id: String,
26    client: AmiClient,
27    // pre-started subscription created before originate so that any Newstate/Hangup
28    // events arriving before wait_for_answer is called are buffered and not lost;
29    // Arc+Mutex keeps Call Clone and lets wait_for_answer take &self
30    answer_sub: Arc<Mutex<EventSubscription<AmiEvent>>>,
31}
32
33impl Call {
34    /// hang up this call
35    pub async fn hangup(
36        &self,
37    ) -> asterisk_rs_ami::error::Result<asterisk_rs_ami::response::AmiResponse> {
38        self.client.hangup(HangupAction::new(&self.channel)).await
39    }
40
41    /// wait for this channel to reach "Up" state (answered)
42    ///
43    /// listens for Newstate events with channel_state_desc "Up".
44    /// returns Err if the channel hangs up before answering.
45    ///
46    /// the inner subscription is protected by a tokio Mutex so that
47    /// [`Call`] can remain `Clone`. if multiple clones call this
48    /// concurrently, only one acquires the lock at a time — the
49    /// winner consumes events while the others block on the mutex.
50    /// callers that need concurrent waiting should create separate
51    /// subscriptions via [`Pbx::client`].
52    pub async fn wait_for_answer(&self, timeout: Duration) -> Result<(), PbxError> {
53        let uid = self.unique_id.clone();
54
55        let result = tokio::time::timeout(timeout, async {
56            let mut sub = self.answer_sub.lock().await;
57            loop {
58                let Some(event) = sub.recv().await else {
59                    return Err(PbxError::Disconnected);
60                };
61                match event {
62                    AmiEvent::Newstate {
63                        unique_id,
64                        channel_state_desc,
65                        ..
66                    } if unique_id == uid => {
67                        if channel_state_desc == "Up" {
68                            return Ok(());
69                        }
70                    }
71                    AmiEvent::Hangup {
72                        unique_id,
73                        cause,
74                        cause_txt,
75                        ..
76                    } if unique_id == uid => {
77                        return Err(PbxError::CallFailed { cause, cause_txt });
78                    }
79                    _ => {}
80                }
81            }
82        })
83        .await;
84
85        match result {
86            Ok(inner) => inner,
87            Err(_) => Err(PbxError::Timeout),
88        }
89    }
90}
91
92/// options for originating a call
93#[derive(Debug, Clone, Default)]
94#[must_use]
95pub struct DialOptions {
96    /// caller ID to present
97    pub caller_id: Option<String>,
98    /// maximum time to wait for answer in milliseconds
99    pub timeout_ms: Option<u64>,
100    /// channel variables to set
101    pub variables: Option<std::collections::HashMap<String, String>>,
102}
103
104impl DialOptions {
105    /// create default dial options
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    /// set the caller id to present
111    pub fn caller_id(mut self, cid: impl Into<String>) -> Self {
112        self.caller_id = Some(cid.into());
113        self
114    }
115
116    /// set max wait time in milliseconds (matches Asterisk Originate timeout)
117    pub fn timeout_ms(mut self, ms: u64) -> Self {
118        self.timeout_ms = Some(ms);
119        self
120    }
121}
122
123/// errors from PBX operations
124#[derive(Debug, thiserror::Error)]
125#[non_exhaustive]
126pub enum PbxError {
127    #[error("AMI error: {0}")]
128    Ami(#[from] asterisk_rs_ami::AmiError),
129
130    #[error("call failed: {cause} ({cause_txt})")]
131    CallFailed { cause: u32, cause_txt: String },
132
133    #[error("operation timed out")]
134    Timeout,
135
136    #[error("client disconnected")]
137    Disconnected,
138}
139
140/// high-level PBX abstraction wrapping an AMI client
141///
142/// provides convenient methods for common telephony operations
143/// with built-in call tracking via [`CallTracker`]
144#[derive(Debug)]
145pub struct Pbx {
146    client: AmiClient,
147    tracker: CallTracker,
148    completed_rx: mpsc::Receiver<CompletedCall>,
149}
150
151impl Pbx {
152    /// create a new PBX abstraction wrapping an AMI client
153    pub fn new(client: AmiClient) -> Self {
154        let (tracker, completed_rx) = client.call_tracker();
155        Self {
156            client,
157            tracker,
158            completed_rx,
159        }
160    }
161
162    /// originate a call from one endpoint to another
163    ///
164    /// uses async originate so the call is queued immediately.
165    /// waits for the OriginateResponse event to get the actual
166    /// channel name and unique_id.
167    pub async fn dial(
168        &self,
169        from: impl Into<String>,
170        to: impl Into<String>,
171        options: Option<DialOptions>,
172    ) -> Result<Call, PbxError> {
173        let from = from.into();
174        let to = to.into();
175        let opts = options.unwrap_or_default();
176
177        let mut action = OriginateAction::new(&from)
178            .extension(&to)
179            .context("default")
180            .priority(1)
181            .async_originate(true);
182
183        if let Some(ref cid) = opts.caller_id {
184            action = action.caller_id(cid);
185        }
186        if let Some(ms) = opts.timeout_ms {
187            action = action.timeout_ms(ms);
188        }
189        if let Some(ref vars) = opts.variables {
190            for (k, v) in vars {
191                action = action.variable(k, v);
192            }
193        }
194
195        // subscribe to answer-state events BEFORE sending the originate action;
196        // events arriving between originate and wait_for_answer are buffered
197        // in the broadcast channel and will not be missed
198        let answer_sub = Arc::new(Mutex::new(self.client.subscribe()));
199
200        // subscribe to OriginateResponse before sending so we don't miss a fast
201        // response; we don't know action_id yet, so filter by type here and match
202        // by action_id in the loop below
203        let mut orig_sub = self
204            .client
205            .subscribe_filtered(move |e| matches!(e, AmiEvent::OriginateResponse { .. }));
206
207        let orig_response = self.client.originate(action).await?;
208        let expected_action_id = orig_response.action_id;
209
210        // wait for the OriginateResponse event with a timeout
211        let originate_timeout =
212            Duration::from_secs(opts.timeout_ms.map(|ms| ms / 1000 + 5).unwrap_or(35));
213
214        let event = tokio::time::timeout(originate_timeout, async {
215            loop {
216                let Some(event) = orig_sub.recv().await else {
217                    return Err(PbxError::Disconnected);
218                };
219                if let AmiEvent::OriginateResponse {
220                    action_id,
221                    channel,
222                    unique_id,
223                    response,
224                    ..
225                } = event
226                {
227                    if action_id == expected_action_id {
228                        return Ok((channel, unique_id, response));
229                    }
230                }
231            }
232        })
233        .await
234        .map_err(|_| PbxError::Timeout)??;
235
236        let (channel, unique_id, response) = event;
237
238        if response.eq_ignore_ascii_case("failure") {
239            return Err(PbxError::CallFailed {
240                cause: 0,
241                cause_txt: "originate failed".to_owned(),
242            });
243        }
244
245        Ok(Call {
246            channel,
247            unique_id,
248            client: self.client.clone(),
249            answer_sub,
250        })
251    }
252
253    /// receive the next completed call record
254    pub async fn next_completed_call(&mut self) -> Option<CompletedCall> {
255        self.completed_rx.recv().await
256    }
257
258    /// access the underlying AMI client
259    pub fn client(&self) -> &AmiClient {
260        &self.client
261    }
262
263    /// shut down the call tracker
264    pub fn shutdown(self) {
265        self.tracker.shutdown();
266    }
267}