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
90#[async_trait]
91impl InvitationHandler for PlaybookInvitationHandler {
92    async fn on_invite(
93        &self,
94        dialog_id: String,
95        cancel_token: CancellationToken,
96        dialog: ServerInviteDialog,
97        _routing_state: Arc<RoutingState>,
98    ) -> Result<()> {
99        let invite_request = dialog.initial_request();
100        let caller = invite_request.from_header()?.uri()?.to_string();
101        let callee = invite_request.to_header()?.uri()?.to_string();
102
103        match self.match_playbook(&caller, &callee) {
104            Some(playbook) => {
105                info!(
106                    dialog_id,
107                    caller, callee, playbook, "matched playbook for invite"
108                );
109
110                // Store the playbook name in pending_playbooks
111                {
112                    let mut pending = self.app_state.pending_playbooks.lock().await;
113                    pending.insert(dialog_id.clone(), playbook);
114                }
115
116                // Start call handler in background task
117                let app_state = self.app_state.clone();
118                let session_id = dialog_id.clone();
119                let cancel_token_clone = cancel_token.clone();
120
121                crate::spawn(async move {
122                    use crate::call::{ActiveCallType, Command};
123                    use bytes::Bytes;
124                    use std::path::PathBuf;
125
126                    // Pre-validate playbook file exists (for SIP calls)
127                    // Remove from pending to get the playbook name
128                    let playbook_name = {
129                        let pending = app_state.pending_playbooks.lock().await;
130                        pending.get(&session_id).cloned()
131                    };
132
133                    if let Some(name_or_content) = playbook_name {
134                        if !name_or_content.trim().starts_with("---") {
135                            // It's a file path, check if it exists
136                            let path = if name_or_content.starts_with("config/playbook/") {
137                                PathBuf::from(&name_or_content)
138                            } else {
139                                PathBuf::from("config/playbook").join(&name_or_content)
140                            };
141
142                            if !path.exists() {
143                                warn!(session_id, path=?path, "Playbook file not found, rejecting SIP call");
144                                // Reject the SIP dialog with 503
145                                if let Err(e) = dialog.reject(
146                                    Some(rsip::StatusCode::ServiceUnavailable),
147                                    Some("Playbook Not Found".to_string()),
148                                ) {
149                                    warn!(session_id, "Failed to reject SIP dialog: {}", e);
150                                }
151                                // Clean up pending playbook
152                                app_state.pending_playbooks.lock().await.remove(&session_id);
153                                return;
154                            }
155                        }
156                    }
157
158                    let (_audio_sender, audio_receiver) =
159                        tokio::sync::mpsc::unbounded_channel::<Bytes>();
160                    let (command_sender, command_receiver) =
161                        tokio::sync::mpsc::unbounded_channel::<Command>();
162                    let (event_sender, _event_receiver) =
163                        tokio::sync::mpsc::unbounded_channel::<crate::event::SessionEvent>();
164
165                    // Don't accept dialog here - let ActiveCall handle it after creating the track
166                    // This ensures proper SDP answer is generated
167
168                    // Send Accept command immediately to trigger SDP negotiation
169                    // This must be done before call_handler_core consumes the receiver
170                    if let Err(e) = command_sender.send(Command::Accept {
171                        option: Default::default(),
172                    }) {
173                        warn!(session_id, "Failed to send accept command: {}", e);
174                        return;
175                    }
176
177                    // Start call handler core
178                    let handler_task = crate::spawn(crate::handler::handler::call_handler_core(
179                        ActiveCallType::Sip,
180                        session_id.clone(),
181                        app_state.clone(),
182                        cancel_token_clone.clone(),
183                        audio_receiver,
184                        None, // server_side_track
185                        true, // dump_events
186                        20,   // ping_interval
187                        command_receiver,
188                        event_sender.clone(),
189                    ));
190
191                    // Wait for call to complete or cancellation
192                    tokio::select! {
193                        _ = handler_task => {
194                            info!(session_id, "SIP call handler completed");
195                        }
196                        _ = cancel_token_clone.cancelled() => {
197                            info!(session_id, "SIP call cancelled");
198                        }
199                    }
200                });
201
202                Ok(())
203            }
204            None => {
205                warn!(
206                    dialog_id,
207                    caller, callee, "no playbook matched for invite, rejecting"
208                );
209                Err(anyhow!(
210                    "no matching playbook found for caller {} and callee {}",
211                    caller,
212                    callee
213                ))
214            }
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::config::PlaybookRule;
223
224    // Simpler helper that creates just the matching function for testing
225    struct TestMatcher {
226        rules: Vec<(Option<Regex>, Option<Regex>, String)>,
227        default: Option<String>,
228    }
229
230    impl TestMatcher {
231        fn new(rules: Vec<PlaybookRule>, default: Option<String>) -> Result<Self> {
232            let mut compiled_rules = Vec::new();
233
234            for rule in rules {
235                let caller_regex = if let Some(pattern) = rule.caller {
236                    Some(
237                        Regex::new(&pattern)
238                            .map_err(|e| anyhow!("invalid caller regex '{}': {}", pattern, e))?,
239                    )
240                } else {
241                    None
242                };
243
244                let callee_regex = if let Some(pattern) = rule.callee {
245                    Some(
246                        Regex::new(&pattern)
247                            .map_err(|e| anyhow!("invalid callee regex '{}': {}", pattern, e))?,
248                    )
249                } else {
250                    None
251                };
252
253                compiled_rules.push((caller_regex, callee_regex, rule.playbook.clone()));
254            }
255
256            Ok(Self {
257                rules: compiled_rules,
258                default,
259            })
260        }
261
262        fn match_playbook(&self, caller: &str, callee: &str) -> Option<String> {
263            for (caller_re, callee_re, playbook) in &self.rules {
264                let caller_matches = caller_re
265                    .as_ref()
266                    .map(|r| r.is_match(caller))
267                    .unwrap_or(true);
268
269                let callee_matches = callee_re
270                    .as_ref()
271                    .map(|r| r.is_match(callee))
272                    .unwrap_or(true);
273
274                if caller_matches && callee_matches {
275                    return Some(playbook.clone());
276                }
277            }
278
279            self.default.clone()
280        }
281    }
282
283    #[test]
284    fn test_playbook_rule_matching() {
285        let rules = vec![
286            PlaybookRule {
287                caller: Some(r"^\+1\d{10}$".to_string()),
288                callee: Some(r"^sip:support@.*".to_string()),
289                playbook: "support.md".to_string(),
290            },
291            PlaybookRule {
292                caller: Some(r"^\+86\d+$".to_string()),
293                callee: None,
294                playbook: "chinese.md".to_string(),
295            },
296            PlaybookRule {
297                caller: None,
298                callee: Some(r"^sip:sales@.*".to_string()),
299                playbook: "sales.md".to_string(),
300            },
301        ];
302
303        let matcher = TestMatcher::new(rules, Some("default.md".to_string())).unwrap();
304
305        // Test US number to support
306        assert_eq!(
307            matcher.match_playbook("+12125551234", "sip:support@example.com"),
308            Some("support.md".to_string())
309        );
310
311        // Test Chinese number (matches second rule)
312        assert_eq!(
313            matcher.match_playbook("+8613800138000", "sip:any@example.com"),
314            Some("chinese.md".to_string())
315        );
316
317        // Test sales callee (matches third rule)
318        assert_eq!(
319            matcher.match_playbook("+44123456789", "sip:sales@example.com"),
320            Some("sales.md".to_string())
321        );
322
323        // Test no match - should use default
324        assert_eq!(
325            matcher.match_playbook("+44123456789", "sip:other@example.com"),
326            Some("default.md".to_string())
327        );
328    }
329
330    #[test]
331    fn test_playbook_rule_no_default() {
332        let rules = vec![PlaybookRule {
333            caller: Some(r"^\+1.*".to_string()),
334            callee: None,
335            playbook: "us.md".to_string(),
336        }];
337
338        let matcher = TestMatcher::new(rules, None).unwrap();
339
340        // Matches
341        assert_eq!(
342            matcher.match_playbook("+12125551234", "sip:any@example.com"),
343            Some("us.md".to_string())
344        );
345
346        // No match and no default
347        assert_eq!(
348            matcher.match_playbook("+44123456789", "sip:any@example.com"),
349            None
350        );
351    }
352
353    #[test]
354    fn test_invalid_regex() {
355        let rules = vec![PlaybookRule {
356            caller: Some(r"[invalid(".to_string()),
357            callee: None,
358            playbook: "test.md".to_string(),
359        }];
360
361        let result = TestMatcher::new(rules, None);
362        assert!(result.is_err());
363        let err_msg = result.err().unwrap().to_string();
364        assert!(err_msg.contains("invalid caller regex"));
365    }
366}