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 {
112 let mut pending = self.app_state.pending_playbooks.lock().await;
113 pending.insert(dialog_id.clone(), playbook);
114 }
115
116 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 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 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 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 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 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 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, true, 20, command_receiver,
188 event_sender.clone(),
189 ));
190
191 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 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 assert_eq!(
307 matcher.match_playbook("+12125551234", "sip:support@example.com"),
308 Some("support.md".to_string())
309 );
310
311 assert_eq!(
313 matcher.match_playbook("+8613800138000", "sip:any@example.com"),
314 Some("chinese.md".to_string())
315 );
316
317 assert_eq!(
319 matcher.match_playbook("+44123456789", "sip:sales@example.com"),
320 Some("sales.md".to_string())
321 );
322
323 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 assert_eq!(
342 matcher.match_playbook("+12125551234", "sip:any@example.com"),
343 Some("us.md".to_string())
344 );
345
346 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}