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 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 let extras = Self::extract_custom_headers(&invite_request.headers);
128
129 if !extras.is_empty() {
130 let mut params = self.app_state.pending_params.lock().await;
131 params.insert(dialog_id.clone(), extras);
132 }
133
134 {
136 let mut pending = self.app_state.pending_playbooks.lock().await;
137 pending.insert(dialog_id.clone(), playbook);
138 }
139
140 let app_state = self.app_state.clone();
142 let session_id = dialog_id.clone();
143 let cancel_token_clone = cancel_token.clone();
144
145 crate::spawn(async move {
146 use crate::call::{ActiveCallType, Command};
147 use bytes::Bytes;
148 use std::path::PathBuf;
149
150 let playbook_name = {
153 let pending = app_state.pending_playbooks.lock().await;
154 pending.get(&session_id).cloned()
155 };
156
157 if let Some(name_or_content) = playbook_name {
158 if !name_or_content.trim().starts_with("---") {
159 let path = if name_or_content.starts_with("config/playbook/") {
161 PathBuf::from(&name_or_content)
162 } else {
163 PathBuf::from("config/playbook").join(&name_or_content)
164 };
165
166 if !path.exists() {
167 warn!(session_id, path=?path, "Playbook file not found, rejecting SIP call");
168 if let Err(e) = dialog.reject(
170 Some(rsip::StatusCode::ServiceUnavailable),
171 Some("Playbook Not Found".to_string()),
172 ) {
173 warn!(session_id, "Failed to reject SIP dialog: {}", e);
174 }
175 app_state.pending_playbooks.lock().await.remove(&session_id);
177 return;
178 }
179 }
180 }
181
182 let (_audio_sender, audio_receiver) =
183 tokio::sync::mpsc::unbounded_channel::<Bytes>();
184 let (command_sender, command_receiver) =
185 tokio::sync::mpsc::unbounded_channel::<Command>();
186 let (event_sender, _event_receiver) =
187 tokio::sync::mpsc::unbounded_channel::<crate::event::SessionEvent>();
188
189 if let Err(e) = command_sender.send(Command::Accept {
195 option: Default::default(),
196 }) {
197 warn!(session_id, "Failed to send accept command: {}", e);
198 return;
199 }
200
201 let handler_task = crate::spawn(crate::handler::handler::call_handler_core(
203 ActiveCallType::Sip,
204 session_id.clone(),
205 app_state.clone(),
206 cancel_token_clone.clone(),
207 audio_receiver,
208 None, true, 20, command_receiver,
212 event_sender.clone(),
213 ));
214
215 tokio::select! {
217 _ = handler_task => {
218 info!(session_id, "SIP call handler completed");
219 }
220 _ = cancel_token_clone.cancelled() => {
221 info!(session_id, "SIP call cancelled");
222 }
223 }
224
225 let headers = {
227 let mut params = app_state.pending_params.lock().await;
228 if let Some(extras) = params.remove(&session_id) {
230 if let Some(h_val) = extras.get("_sip_headers") {
231 if let Ok(h_map) = serde_json::from_value::<
232 std::collections::HashMap<String, String>,
233 >(h_val.clone())
234 {
235 Some(h_map)
236 } else if let serde_json::Value::String(s) = h_val {
237 serde_json::from_str::<std::collections::HashMap<String, String>>(s).ok()
239 } else {
240 None
241 }
242 } else {
243 None
244 }
245 } else {
246 None
247 }
248 };
249
250 let sip_headers = headers.map(|h_map| {
251 h_map
252 .into_iter()
253 .map(|(k, v)| rsip::Header::Other(k.into(), v.into()))
254 .collect::<Vec<_>>()
255 });
256
257 if let Err(e) = dialog.bye_with_headers(sip_headers).await {
259 warn!(session_id, "Failed to send BYE: {}", e);
260 }
261 });
262
263 Ok(())
264 }
265 None => {
266 warn!(
267 dialog_id,
268 caller, callee, "no playbook matched for invite, rejecting"
269 );
270 Err(anyhow!(
271 "no matching playbook found for caller {} and callee {}",
272 caller,
273 callee
274 ))
275 }
276 }
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::config::PlaybookRule;
284
285 struct TestMatcher {
287 rules: Vec<(Option<Regex>, Option<Regex>, String)>,
288 default: Option<String>,
289 }
290
291 impl TestMatcher {
292 fn new(rules: Vec<PlaybookRule>, default: Option<String>) -> Result<Self> {
293 let mut compiled_rules = Vec::new();
294
295 for rule in rules {
296 let caller_regex = if let Some(pattern) = rule.caller {
297 Some(
298 Regex::new(&pattern)
299 .map_err(|e| anyhow!("invalid caller regex '{}': {}", pattern, e))?,
300 )
301 } else {
302 None
303 };
304
305 let callee_regex = if let Some(pattern) = rule.callee {
306 Some(
307 Regex::new(&pattern)
308 .map_err(|e| anyhow!("invalid callee regex '{}': {}", pattern, e))?,
309 )
310 } else {
311 None
312 };
313
314 compiled_rules.push((caller_regex, callee_regex, rule.playbook.clone()));
315 }
316
317 Ok(Self {
318 rules: compiled_rules,
319 default,
320 })
321 }
322
323 fn match_playbook(&self, caller: &str, callee: &str) -> Option<String> {
324 for (caller_re, callee_re, playbook) in &self.rules {
325 let caller_matches = caller_re
326 .as_ref()
327 .map(|r| r.is_match(caller))
328 .unwrap_or(true);
329
330 let callee_matches = callee_re
331 .as_ref()
332 .map(|r| r.is_match(callee))
333 .unwrap_or(true);
334
335 if caller_matches && callee_matches {
336 return Some(playbook.clone());
337 }
338 }
339
340 self.default.clone()
341 }
342 }
343
344 #[test]
345 fn test_playbook_rule_matching() {
346 let rules = vec![
347 PlaybookRule {
348 caller: Some(r"^\+1\d{10}$".to_string()),
349 callee: Some(r"^sip:support@.*".to_string()),
350 playbook: "support.md".to_string(),
351 },
352 PlaybookRule {
353 caller: Some(r"^\+86\d+$".to_string()),
354 callee: None,
355 playbook: "chinese.md".to_string(),
356 },
357 PlaybookRule {
358 caller: None,
359 callee: Some(r"^sip:sales@.*".to_string()),
360 playbook: "sales.md".to_string(),
361 },
362 ];
363
364 let matcher = TestMatcher::new(rules, Some("default.md".to_string())).unwrap();
365
366 assert_eq!(
368 matcher.match_playbook("+12125551234", "sip:support@example.com"),
369 Some("support.md".to_string())
370 );
371
372 assert_eq!(
374 matcher.match_playbook("+8613800138000", "sip:any@example.com"),
375 Some("chinese.md".to_string())
376 );
377
378 assert_eq!(
380 matcher.match_playbook("+44123456789", "sip:sales@example.com"),
381 Some("sales.md".to_string())
382 );
383
384 assert_eq!(
386 matcher.match_playbook("+44123456789", "sip:other@example.com"),
387 Some("default.md".to_string())
388 );
389 }
390
391 #[test]
392 fn test_playbook_rule_no_default() {
393 let rules = vec![PlaybookRule {
394 caller: Some(r"^\+1.*".to_string()),
395 callee: None,
396 playbook: "us.md".to_string(),
397 }];
398
399 let matcher = TestMatcher::new(rules, None).unwrap();
400
401 assert_eq!(
403 matcher.match_playbook("+12125551234", "sip:any@example.com"),
404 Some("us.md".to_string())
405 );
406
407 assert_eq!(
409 matcher.match_playbook("+44123456789", "sip:any@example.com"),
410 None
411 );
412 }
413
414 #[test]
415 fn test_invalid_regex() {
416 let rules = vec![PlaybookRule {
417 caller: Some(r"[invalid(".to_string()),
418 callee: None,
419 playbook: "test.md".to_string(),
420 }];
421
422 let result = TestMatcher::new(rules, None);
423 assert!(result.is_err());
424 let err_msg = result.err().unwrap().to_string();
425 assert!(err_msg.contains("invalid caller regex"));
426 }
427
428 #[test]
429 fn test_extract_custom_headers() {
430 use rsip::Header;
431
432 let mut headers = rsip::Headers::default();
433 headers.push(Header::ContentLength(10.into())); headers.push(Header::Other("X-Tenant-ID".into(), "123".into()));
435 headers.push(Header::Other("Custom-Header".into(), "xyz".into()));
436
437 let extras = PlaybookInvitationHandler::extract_custom_headers(&headers);
438
439 assert_eq!(extras.len(), 2);
440 assert_eq!(
441 extras.get("X-Tenant-ID").unwrap(),
442 &serde_json::Value::String("123".to_string())
443 );
444 assert_eq!(
445 extras.get("Custom-Header").unwrap(),
446 &serde_json::Value::String("xyz".to_string())
447 );
448 }
449}