Skip to main content

active_call/useragent/
playbook_handler.rs

1use crate::{
2    app::AppState, call::RoutingState, config::PlaybookRule,
3    useragent::invitation::InvitationHandler,
4};
5use anyhow::{Result, anyhow};
6use async_trait::async_trait;
7use regex::Regex;
8use rsip::prelude::HeadersExt;
9use rsipstack::dialog::server_dialog::ServerInviteDialog;
10use std::sync::Arc;
11use tokio_util::sync::CancellationToken;
12use tracing::{info, warn};
13
14pub struct PlaybookInvitationHandler {
15    rules: Vec<CompiledPlaybookRule>,
16    default: Option<String>,
17    app_state: AppState,
18}
19
20struct CompiledPlaybookRule {
21    caller: Option<Regex>,
22    callee: Option<Regex>,
23    playbook: String,
24}
25
26impl PlaybookInvitationHandler {
27    pub fn new(
28        rules: Vec<PlaybookRule>,
29        default: Option<String>,
30        app_state: AppState,
31    ) -> Result<Self> {
32        let mut compiled_rules = Vec::new();
33
34        for rule in rules {
35            let caller_regex = if let Some(pattern) = rule.caller {
36                Some(
37                    Regex::new(&pattern)
38                        .map_err(|e| anyhow!("invalid caller regex '{}': {}", pattern, e))?,
39                )
40            } else {
41                None
42            };
43
44            let callee_regex = if let Some(pattern) = rule.callee {
45                Some(
46                    Regex::new(&pattern)
47                        .map_err(|e| anyhow!("invalid callee regex '{}': {}", pattern, e))?,
48                )
49            } else {
50                None
51            };
52
53            compiled_rules.push(CompiledPlaybookRule {
54                caller: caller_regex,
55                callee: callee_regex,
56                playbook: rule.playbook.clone(),
57            });
58        }
59
60        Ok(Self {
61            rules: compiled_rules,
62            default,
63            app_state,
64        })
65    }
66
67    pub fn match_playbook(&self, caller: &str, callee: &str) -> Option<String> {
68        for rule in &self.rules {
69            let caller_matches = rule
70                .caller
71                .as_ref()
72                .map(|r| r.is_match(caller))
73                .unwrap_or(true);
74
75            let callee_matches = rule
76                .callee
77                .as_ref()
78                .map(|r| r.is_match(callee))
79                .unwrap_or(true);
80
81            if caller_matches && callee_matches {
82                return Some(rule.playbook.clone());
83            }
84        }
85
86        self.default.clone()
87    }
88
89    fn extract_custom_headers(
90        headers: &rsip::Headers,
91    ) -> std::collections::HashMap<String, serde_json::Value> {
92        let mut extras = std::collections::HashMap::new();
93        for header in headers.iter() {
94            if let rsip::Header::Other(name, value) = header {
95                // Capture all custom headers, Playbook logic can filter them later using `sip.extract_headers` if needed
96                extras.insert(
97                    name.to_string(),
98                    serde_json::Value::String(value.to_string()),
99                );
100            }
101        }
102        extras
103    }
104}
105
106#[async_trait]
107impl InvitationHandler for PlaybookInvitationHandler {
108    async fn on_invite(
109        &self,
110        dialog_id: String,
111        cancel_token: CancellationToken,
112        dialog: ServerInviteDialog,
113        _routing_state: Arc<RoutingState>,
114    ) -> Result<()> {
115        let invite_request = dialog.initial_request();
116        let caller = invite_request.from_header()?.uri()?.to_string();
117        let callee = invite_request.to_header()?.uri()?.to_string();
118
119        match self.match_playbook(&caller, &callee) {
120            Some(playbook) => {
121                info!(
122                    dialog_id,
123                    caller, callee, playbook, "matched playbook for invite"
124                );
125
126                // Extract custom headers
127                let mut extras = Self::extract_custom_headers(&invite_request.headers);
128
129                // Inject built-in caller/callee variables
130                extras.insert(
131                    crate::playbook::BUILTIN_CALLER.to_string(),
132                    serde_json::Value::String(caller.clone()),
133                );
134                extras.insert(
135                    crate::playbook::BUILTIN_CALLEE.to_string(),
136                    serde_json::Value::String(callee.clone()),
137                );
138
139                if !extras.is_empty() {
140                    let mut params = self.app_state.pending_params.lock().await;
141                    params.insert(dialog_id.clone(), extras);
142                }
143
144                // Store the playbook name in pending_playbooks
145                {
146                    let mut pending = self.app_state.pending_playbooks.lock().await;
147                    pending.insert(dialog_id.clone(), playbook);
148                }
149
150                // Start call handler in background task
151                let app_state = self.app_state.clone();
152                let session_id = dialog_id.clone();
153                let cancel_token_clone = cancel_token.clone();
154
155                crate::spawn(async move {
156                    use crate::call::{ActiveCallType, Command};
157                    use bytes::Bytes;
158                    use std::path::PathBuf;
159
160                    // Pre-validate playbook file exists (for SIP calls)
161                    // Remove from pending to get the playbook name
162                    let playbook_name = {
163                        let pending = app_state.pending_playbooks.lock().await;
164                        pending.get(&session_id).cloned()
165                    };
166
167                    if let Some(name_or_content) = playbook_name {
168                        if !name_or_content.trim().starts_with("---") {
169                            // It's a file path, check if it exists
170                            let path = if name_or_content.starts_with("config/playbook/") {
171                                PathBuf::from(&name_or_content)
172                            } else {
173                                PathBuf::from("config/playbook").join(&name_or_content)
174                            };
175
176                            if !path.exists() {
177                                warn!(session_id, path=?path, "Playbook file not found, rejecting SIP call");
178                                // Reject the SIP dialog with 503
179                                if let Err(e) = dialog.reject(
180                                    Some(rsip::StatusCode::ServiceUnavailable),
181                                    Some("Playbook Not Found".to_string()),
182                                ) {
183                                    warn!(session_id, "Failed to reject SIP dialog: {}", e);
184                                }
185                                // Clean up pending playbook
186                                app_state.pending_playbooks.lock().await.remove(&session_id);
187                                return;
188                            }
189                        }
190                    }
191
192                    let (_audio_sender, audio_receiver) =
193                        tokio::sync::mpsc::unbounded_channel::<Bytes>();
194                    let (command_sender, command_receiver) =
195                        tokio::sync::mpsc::unbounded_channel::<Command>();
196                    let (event_sender, _event_receiver) =
197                        tokio::sync::mpsc::unbounded_channel::<crate::event::SessionEvent>();
198
199                    // Don't accept dialog here - let ActiveCall handle it after creating the track
200                    // This ensures proper SDP answer is generated
201
202                    // Send Accept command immediately to trigger SDP negotiation
203                    // This must be done before call_handler_core consumes the receiver
204                    if let Err(e) = command_sender.send(Command::Accept {
205                        option: Default::default(),
206                    }) {
207                        warn!(session_id, "Failed to send accept command: {}", e);
208                        return;
209                    }
210
211                    // Start call handler core
212                    let handler_task = crate::spawn(crate::handler::handler::call_handler_core(
213                        ActiveCallType::Sip,
214                        session_id.clone(),
215                        app_state.clone(),
216                        cancel_token_clone.clone(),
217                        audio_receiver,
218                        None, // server_side_track
219                        true, // dump_events
220                        20,   // ping_interval
221                        command_receiver,
222                        event_sender.clone(),
223                    ));
224
225                    // Wait for call to complete or cancellation
226                    tokio::select! {
227                        _ = handler_task => {
228                            info!(session_id, "SIP call handler completed");
229                        }
230                        _ = cancel_token_clone.cancelled() => {
231                            info!(session_id, "SIP call cancelled");
232                        }
233                    }
234
235                    // Attempt to retrieve custom headers for BYE
236                    let headers = {
237                        let mut params = app_state.pending_params.lock().await;
238                        // remove returns the map of extras for this session
239                        if let Some(extras) = params.remove(&session_id) {
240                            if let Some(h_val) = extras.get("_hangup_headers") {
241                                if let Ok(h_map) = serde_json::from_value::<
242                                    std::collections::HashMap<String, String>,
243                                >(h_val.clone())
244                                {
245                                    Some(h_map)
246                                } else if let serde_json::Value::String(s) = h_val {
247                                    // Handle case where set_var stores it as a string
248                                    serde_json::from_str::<std::collections::HashMap<String, String>>(s).ok()
249                                } else {
250                                    None
251                                }
252                            } else {
253                                None
254                            }
255                        } else {
256                            None
257                        }
258                    };
259
260                    let sip_headers = headers.map(|h_map| {
261                        h_map
262                            .into_iter()
263                            .map(|(k, v)| rsip::Header::Other(k.into(), v.into()))
264                            .collect::<Vec<_>>()
265                    });
266
267                    // Terminate the SIP dialog
268                    if let Err(e) = dialog.bye_with_headers(sip_headers).await {
269                        warn!(session_id, "Failed to send BYE: {}", e);
270                    }
271                });
272
273                Ok(())
274            }
275            None => {
276                warn!(
277                    dialog_id,
278                    caller, callee, "no playbook matched for invite, rejecting"
279                );
280                Err(anyhow!(
281                    "no matching playbook found for caller {} and callee {}",
282                    caller,
283                    callee
284                ))
285            }
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::config::PlaybookRule;
294
295    // Simpler helper that creates just the matching function for testing
296    struct TestMatcher {
297        rules: Vec<(Option<Regex>, Option<Regex>, String)>,
298        default: Option<String>,
299    }
300
301    impl TestMatcher {
302        fn new(rules: Vec<PlaybookRule>, default: Option<String>) -> Result<Self> {
303            let mut compiled_rules = Vec::new();
304
305            for rule in rules {
306                let caller_regex = if let Some(pattern) = rule.caller {
307                    Some(
308                        Regex::new(&pattern)
309                            .map_err(|e| anyhow!("invalid caller regex '{}': {}", pattern, e))?,
310                    )
311                } else {
312                    None
313                };
314
315                let callee_regex = if let Some(pattern) = rule.callee {
316                    Some(
317                        Regex::new(&pattern)
318                            .map_err(|e| anyhow!("invalid callee regex '{}': {}", pattern, e))?,
319                    )
320                } else {
321                    None
322                };
323
324                compiled_rules.push((caller_regex, callee_regex, rule.playbook.clone()));
325            }
326
327            Ok(Self {
328                rules: compiled_rules,
329                default,
330            })
331        }
332
333        fn match_playbook(&self, caller: &str, callee: &str) -> Option<String> {
334            for (caller_re, callee_re, playbook) in &self.rules {
335                let caller_matches = caller_re
336                    .as_ref()
337                    .map(|r| r.is_match(caller))
338                    .unwrap_or(true);
339
340                let callee_matches = callee_re
341                    .as_ref()
342                    .map(|r| r.is_match(callee))
343                    .unwrap_or(true);
344
345                if caller_matches && callee_matches {
346                    return Some(playbook.clone());
347                }
348            }
349
350            self.default.clone()
351        }
352    }
353
354    #[test]
355    fn test_playbook_rule_matching() {
356        let rules = vec![
357            PlaybookRule {
358                caller: Some(r"^\+1\d{10}$".to_string()),
359                callee: Some(r"^sip:support@.*".to_string()),
360                playbook: "support.md".to_string(),
361            },
362            PlaybookRule {
363                caller: Some(r"^\+86\d+$".to_string()),
364                callee: None,
365                playbook: "chinese.md".to_string(),
366            },
367            PlaybookRule {
368                caller: None,
369                callee: Some(r"^sip:sales@.*".to_string()),
370                playbook: "sales.md".to_string(),
371            },
372        ];
373
374        let matcher = TestMatcher::new(rules, Some("default.md".to_string())).unwrap();
375
376        // Test US number to support
377        assert_eq!(
378            matcher.match_playbook("+12125551234", "sip:support@example.com"),
379            Some("support.md".to_string())
380        );
381
382        // Test Chinese number (matches second rule)
383        assert_eq!(
384            matcher.match_playbook("+8613800138000", "sip:any@example.com"),
385            Some("chinese.md".to_string())
386        );
387
388        // Test sales callee (matches third rule)
389        assert_eq!(
390            matcher.match_playbook("+44123456789", "sip:sales@example.com"),
391            Some("sales.md".to_string())
392        );
393
394        // Test no match - should use default
395        assert_eq!(
396            matcher.match_playbook("+44123456789", "sip:other@example.com"),
397            Some("default.md".to_string())
398        );
399    }
400
401    #[test]
402    fn test_playbook_rule_no_default() {
403        let rules = vec![PlaybookRule {
404            caller: Some(r"^\+1.*".to_string()),
405            callee: None,
406            playbook: "us.md".to_string(),
407        }];
408
409        let matcher = TestMatcher::new(rules, None).unwrap();
410
411        // Matches
412        assert_eq!(
413            matcher.match_playbook("+12125551234", "sip:any@example.com"),
414            Some("us.md".to_string())
415        );
416
417        // No match and no default
418        assert_eq!(
419            matcher.match_playbook("+44123456789", "sip:any@example.com"),
420            None
421        );
422    }
423
424    #[test]
425    fn test_invalid_regex() {
426        let rules = vec![PlaybookRule {
427            caller: Some(r"[invalid(".to_string()),
428            callee: None,
429            playbook: "test.md".to_string(),
430        }];
431
432        let result = TestMatcher::new(rules, None);
433        assert!(result.is_err());
434        let err_msg = result.err().unwrap().to_string();
435        assert!(err_msg.contains("invalid caller regex"));
436    }
437
438    #[test]
439    fn test_extract_custom_headers() {
440        use rsip::Header;
441
442        let mut headers = rsip::Headers::default();
443        headers.push(Header::ContentLength(10.into())); // Standard header (ignored)
444        headers.push(Header::Other("X-Tenant-ID".into(), "123".into()));
445        headers.push(Header::Other("Custom-Header".into(), "xyz".into()));
446
447        let extras = PlaybookInvitationHandler::extract_custom_headers(&headers);
448
449        assert_eq!(extras.len(), 2);
450        assert_eq!(
451            extras.get("X-Tenant-ID").unwrap(),
452            &serde_json::Value::String("123".to_string())
453        );
454        assert_eq!(
455            extras.get("Custom-Header").unwrap(),
456            &serde_json::Value::String("xyz".to_string())
457        );
458    }
459}