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 rsipstack::dialog::invite_dialog::InviteDialog;
9use rsipstack::rsip::prelude::HeadersExt;
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: &rsipstack::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 rsipstack::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: InviteDialog,
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                let extras_opt = if extras.is_empty() {
140                    None
141                } else {
142                    Some(extras)
143                };
144
145                // Start call handler in background task
146                let app_state = self.app_state.clone();
147                let session_id = dialog_id.clone();
148                let cancel_token_clone = cancel_token.clone();
149
150                crate::spawn(async move {
151                    use crate::call::{ActiveCallType, Command};
152                    use bytes::Bytes;
153                    use std::path::PathBuf;
154
155                    // Pre-validate playbook file exists (for SIP calls)
156                    if !playbook.trim().starts_with("---") {
157                        // It's a file path, check if it exists
158                        let path = if playbook.starts_with("config/playbook/") {
159                            PathBuf::from(&playbook)
160                        } else {
161                            PathBuf::from("config/playbook").join(&playbook)
162                        };
163
164                        if !path.exists() {
165                            warn!(session_id, path=?path, "Playbook file not found, rejecting SIP call");
166                            if let Err(e) = dialog.reject(
167                                Some(rsipstack::rsip::StatusCode::ServiceUnavailable),
168                                Some("Playbook Not Found".to_string()),
169                            ) {
170                                warn!(session_id, "Failed to reject SIP dialog: {}", e);
171                            }
172                            return;
173                        }
174                    }
175
176                    let (_audio_sender, audio_receiver) =
177                        tokio::sync::mpsc::unbounded_channel::<Bytes>();
178                    let (command_sender, command_receiver) =
179                        tokio::sync::mpsc::unbounded_channel::<Command>();
180                    let (event_sender, _event_receiver) =
181                        tokio::sync::mpsc::unbounded_channel::<crate::event::SessionEvent>();
182
183                    // Send Accept command immediately to trigger SDP negotiation
184                    let accept_option = crate::CallOption {
185                        caller: Some(caller.clone()),
186                        callee: Some(callee.clone()),
187                        ..Default::default()
188                    };
189                    if let Err(e) = command_sender.send(Command::Accept {
190                        option: accept_option,
191                    }) {
192                        warn!(session_id, "Failed to send accept command: {}", e);
193                        return;
194                    }
195
196                    // Use a oneshot channel to receive final call extras
197                    // (including _hangup_headers) from call_handler_core
198                    let (extras_tx, extras_rx) = tokio::sync::oneshot::channel();
199                    let extras_for_call = extras_opt;
200                    let playbook_for_call = playbook;
201                    crate::spawn({
202                        let session_id = session_id.clone();
203                        let app_state = app_state.clone();
204                        let cancel_token = cancel_token_clone.clone();
205                        async move {
206                            let result = crate::handler::handler::call_handler_core(
207                                ActiveCallType::Sip,
208                                session_id,
209                                app_state,
210                                cancel_token,
211                                audio_receiver,
212                                None, // server_side_track
213                                true, // dump_events
214                                20,   // ping_interval
215                                command_receiver,
216                                event_sender,
217                                extras_for_call,
218                                Some(playbook_for_call),
219                            )
220                            .await;
221                            let _ = extras_tx.send(result);
222                        }
223                    });
224
225                    // Wait for call to complete or cancellation
226                    let final_extras = tokio::select! {
227                        result = extras_rx => {
228                            info!(session_id, "SIP call handler completed");
229                            result.ok().flatten()
230                        }
231                        _ = cancel_token_clone.cancelled() => {
232                            info!(session_id, "SIP call cancelled");
233                            None
234                        }
235                    };
236
237                    // Extract hangup headers from final call extras
238                    let headers = final_extras.and_then(|extras| {
239                        extras.get("_hangup_headers").and_then(|h_val| {
240                            serde_json::from_value::<std::collections::HashMap<String, String>>(
241                                h_val.clone(),
242                            )
243                            .ok()
244                            .or_else(|| {
245                                if let serde_json::Value::String(s) = h_val {
246                                    serde_json::from_str::<
247                                        std::collections::HashMap<String, String>,
248                                    >(s.as_str())
249                                    .ok()
250                                } else {
251                                    None
252                                }
253                            })
254                        })
255                    });
256
257                    let sip_headers = headers.map(|h_map| {
258                        h_map
259                            .into_iter()
260                            .map(|(k, v)| rsipstack::rsip::Header::Other(k.into(), v.into()))
261                            .collect::<Vec<_>>()
262                    });
263
264                    // Terminate the SIP dialog
265                    if let Err(e) = dialog.bye_with_headers(sip_headers).await {
266                        warn!(session_id, "Failed to send BYE: {}", e);
267                    }
268                });
269
270                Ok(())
271            }
272            None => {
273                warn!(
274                    dialog_id,
275                    caller, callee, "no playbook matched for invite, rejecting"
276                );
277                Err(anyhow!(
278                    "no matching playbook found for caller {} and callee {}",
279                    caller,
280                    callee
281                ))
282            }
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::config::PlaybookRule;
291
292    // Simpler helper that creates just the matching function for testing
293    struct TestMatcher {
294        rules: Vec<(Option<Regex>, Option<Regex>, String)>,
295        default: Option<String>,
296    }
297
298    impl TestMatcher {
299        fn new(rules: Vec<PlaybookRule>, default: Option<String>) -> Result<Self> {
300            let mut compiled_rules = Vec::new();
301
302            for rule in rules {
303                let caller_regex = if let Some(pattern) = rule.caller {
304                    Some(
305                        Regex::new(&pattern)
306                            .map_err(|e| anyhow!("invalid caller regex '{}': {}", pattern, e))?,
307                    )
308                } else {
309                    None
310                };
311
312                let callee_regex = if let Some(pattern) = rule.callee {
313                    Some(
314                        Regex::new(&pattern)
315                            .map_err(|e| anyhow!("invalid callee regex '{}': {}", pattern, e))?,
316                    )
317                } else {
318                    None
319                };
320
321                compiled_rules.push((caller_regex, callee_regex, rule.playbook.clone()));
322            }
323
324            Ok(Self {
325                rules: compiled_rules,
326                default,
327            })
328        }
329
330        fn match_playbook(&self, caller: &str, callee: &str) -> Option<String> {
331            for (caller_re, callee_re, playbook) in &self.rules {
332                let caller_matches = caller_re
333                    .as_ref()
334                    .map(|r| r.is_match(caller))
335                    .unwrap_or(true);
336
337                let callee_matches = callee_re
338                    .as_ref()
339                    .map(|r| r.is_match(callee))
340                    .unwrap_or(true);
341
342                if caller_matches && callee_matches {
343                    return Some(playbook.clone());
344                }
345            }
346
347            self.default.clone()
348        }
349    }
350
351    #[test]
352    fn test_playbook_rule_matching() {
353        let rules = vec![
354            PlaybookRule {
355                caller: Some(r"^\+1\d{10}$".to_string()),
356                callee: Some(r"^sip:support@.*".to_string()),
357                playbook: "support.md".to_string(),
358            },
359            PlaybookRule {
360                caller: Some(r"^\+86\d+$".to_string()),
361                callee: None,
362                playbook: "chinese.md".to_string(),
363            },
364            PlaybookRule {
365                caller: None,
366                callee: Some(r"^sip:sales@.*".to_string()),
367                playbook: "sales.md".to_string(),
368            },
369        ];
370
371        let matcher = TestMatcher::new(rules, Some("default.md".to_string())).unwrap();
372
373        // Test US number to support
374        assert_eq!(
375            matcher.match_playbook("+12125551234", "sip:support@example.com"),
376            Some("support.md".to_string())
377        );
378
379        // Test Chinese number (matches second rule)
380        assert_eq!(
381            matcher.match_playbook("+8613800138000", "sip:any@example.com"),
382            Some("chinese.md".to_string())
383        );
384
385        // Test sales callee (matches third rule)
386        assert_eq!(
387            matcher.match_playbook("+44123456789", "sip:sales@example.com"),
388            Some("sales.md".to_string())
389        );
390
391        // Test no match - should use default
392        assert_eq!(
393            matcher.match_playbook("+44123456789", "sip:other@example.com"),
394            Some("default.md".to_string())
395        );
396    }
397
398    #[test]
399    fn test_playbook_rule_no_default() {
400        let rules = vec![PlaybookRule {
401            caller: Some(r"^\+1.*".to_string()),
402            callee: None,
403            playbook: "us.md".to_string(),
404        }];
405
406        let matcher = TestMatcher::new(rules, None).unwrap();
407
408        // Matches
409        assert_eq!(
410            matcher.match_playbook("+12125551234", "sip:any@example.com"),
411            Some("us.md".to_string())
412        );
413
414        // No match and no default
415        assert_eq!(
416            matcher.match_playbook("+44123456789", "sip:any@example.com"),
417            None
418        );
419    }
420
421    #[test]
422    fn test_invalid_regex() {
423        let rules = vec![PlaybookRule {
424            caller: Some(r"[invalid(".to_string()),
425            callee: None,
426            playbook: "test.md".to_string(),
427        }];
428
429        let result = TestMatcher::new(rules, None);
430        assert!(result.is_err());
431        let err_msg = result.err().unwrap().to_string();
432        assert!(err_msg.contains("invalid caller regex"));
433    }
434
435    #[test]
436    fn test_extract_custom_headers() {
437        use rsipstack::rsip::Header;
438
439        let mut headers = rsipstack::rsip::Headers::default();
440        headers.push(Header::ContentLength(10.into())); // Standard header (ignored)
441        headers.push(Header::Other("X-Tenant-ID".into(), "123".into()));
442        headers.push(Header::Other("Custom-Header".into(), "xyz".into()));
443
444        let extras = PlaybookInvitationHandler::extract_custom_headers(&headers);
445
446        assert_eq!(extras.len(), 2);
447        assert_eq!(
448            extras.get("X-Tenant-ID").unwrap(),
449            &serde_json::Value::String("123".to_string())
450        );
451        assert_eq!(
452            extras.get("Custom-Header").unwrap(),
453            &serde_json::Value::String("xyz".to_string())
454        );
455    }
456}