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 mut extras = Self::extract_custom_headers(&invite_request.headers);
128
129 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 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 if !playbook.trim().starts_with("---") {
157 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(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 if let Err(e) = command_sender.send(Command::Accept {
185 option: Default::default(),
186 }) {
187 warn!(session_id, "Failed to send accept command: {}", e);
188 return;
189 }
190
191 let (extras_tx, extras_rx) = tokio::sync::oneshot::channel();
194 let extras_for_call = extras_opt;
195 let playbook_for_call = playbook;
196 crate::spawn({
197 let session_id = session_id.clone();
198 let app_state = app_state.clone();
199 let cancel_token = cancel_token_clone.clone();
200 async move {
201 let result = crate::handler::handler::call_handler_core(
202 ActiveCallType::Sip,
203 session_id,
204 app_state,
205 cancel_token,
206 audio_receiver,
207 None, true, 20, command_receiver,
211 event_sender,
212 extras_for_call,
213 Some(playbook_for_call),
214 )
215 .await;
216 let _ = extras_tx.send(result);
217 }
218 });
219
220 let final_extras = tokio::select! {
222 result = extras_rx => {
223 info!(session_id, "SIP call handler completed");
224 result.ok().flatten()
225 }
226 _ = cancel_token_clone.cancelled() => {
227 info!(session_id, "SIP call cancelled");
228 None
229 }
230 };
231
232 let headers = final_extras.and_then(|extras| {
234 extras.get("_hangup_headers").and_then(|h_val| {
235 serde_json::from_value::<std::collections::HashMap<String, String>>(
236 h_val.clone(),
237 )
238 .ok()
239 .or_else(|| {
240 if let serde_json::Value::String(s) = h_val {
241 serde_json::from_str::<
242 std::collections::HashMap<String, String>,
243 >(s.as_str())
244 .ok()
245 } else {
246 None
247 }
248 })
249 })
250 });
251
252 let sip_headers = headers.map(|h_map| {
253 h_map
254 .into_iter()
255 .map(|(k, v)| rsip::Header::Other(k.into(), v.into()))
256 .collect::<Vec<_>>()
257 });
258
259 if let Err(e) = dialog.bye_with_headers(sip_headers).await {
261 warn!(session_id, "Failed to send BYE: {}", e);
262 }
263 });
264
265 Ok(())
266 }
267 None => {
268 warn!(
269 dialog_id,
270 caller, callee, "no playbook matched for invite, rejecting"
271 );
272 Err(anyhow!(
273 "no matching playbook found for caller {} and callee {}",
274 caller,
275 callee
276 ))
277 }
278 }
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::config::PlaybookRule;
286
287 struct TestMatcher {
289 rules: Vec<(Option<Regex>, Option<Regex>, String)>,
290 default: Option<String>,
291 }
292
293 impl TestMatcher {
294 fn new(rules: Vec<PlaybookRule>, default: Option<String>) -> Result<Self> {
295 let mut compiled_rules = Vec::new();
296
297 for rule in rules {
298 let caller_regex = if let Some(pattern) = rule.caller {
299 Some(
300 Regex::new(&pattern)
301 .map_err(|e| anyhow!("invalid caller regex '{}': {}", pattern, e))?,
302 )
303 } else {
304 None
305 };
306
307 let callee_regex = if let Some(pattern) = rule.callee {
308 Some(
309 Regex::new(&pattern)
310 .map_err(|e| anyhow!("invalid callee regex '{}': {}", pattern, e))?,
311 )
312 } else {
313 None
314 };
315
316 compiled_rules.push((caller_regex, callee_regex, rule.playbook.clone()));
317 }
318
319 Ok(Self {
320 rules: compiled_rules,
321 default,
322 })
323 }
324
325 fn match_playbook(&self, caller: &str, callee: &str) -> Option<String> {
326 for (caller_re, callee_re, playbook) in &self.rules {
327 let caller_matches = caller_re
328 .as_ref()
329 .map(|r| r.is_match(caller))
330 .unwrap_or(true);
331
332 let callee_matches = callee_re
333 .as_ref()
334 .map(|r| r.is_match(callee))
335 .unwrap_or(true);
336
337 if caller_matches && callee_matches {
338 return Some(playbook.clone());
339 }
340 }
341
342 self.default.clone()
343 }
344 }
345
346 #[test]
347 fn test_playbook_rule_matching() {
348 let rules = vec![
349 PlaybookRule {
350 caller: Some(r"^\+1\d{10}$".to_string()),
351 callee: Some(r"^sip:support@.*".to_string()),
352 playbook: "support.md".to_string(),
353 },
354 PlaybookRule {
355 caller: Some(r"^\+86\d+$".to_string()),
356 callee: None,
357 playbook: "chinese.md".to_string(),
358 },
359 PlaybookRule {
360 caller: None,
361 callee: Some(r"^sip:sales@.*".to_string()),
362 playbook: "sales.md".to_string(),
363 },
364 ];
365
366 let matcher = TestMatcher::new(rules, Some("default.md".to_string())).unwrap();
367
368 assert_eq!(
370 matcher.match_playbook("+12125551234", "sip:support@example.com"),
371 Some("support.md".to_string())
372 );
373
374 assert_eq!(
376 matcher.match_playbook("+8613800138000", "sip:any@example.com"),
377 Some("chinese.md".to_string())
378 );
379
380 assert_eq!(
382 matcher.match_playbook("+44123456789", "sip:sales@example.com"),
383 Some("sales.md".to_string())
384 );
385
386 assert_eq!(
388 matcher.match_playbook("+44123456789", "sip:other@example.com"),
389 Some("default.md".to_string())
390 );
391 }
392
393 #[test]
394 fn test_playbook_rule_no_default() {
395 let rules = vec![PlaybookRule {
396 caller: Some(r"^\+1.*".to_string()),
397 callee: None,
398 playbook: "us.md".to_string(),
399 }];
400
401 let matcher = TestMatcher::new(rules, None).unwrap();
402
403 assert_eq!(
405 matcher.match_playbook("+12125551234", "sip:any@example.com"),
406 Some("us.md".to_string())
407 );
408
409 assert_eq!(
411 matcher.match_playbook("+44123456789", "sip:any@example.com"),
412 None
413 );
414 }
415
416 #[test]
417 fn test_invalid_regex() {
418 let rules = vec![PlaybookRule {
419 caller: Some(r"[invalid(".to_string()),
420 callee: None,
421 playbook: "test.md".to_string(),
422 }];
423
424 let result = TestMatcher::new(rules, None);
425 assert!(result.is_err());
426 let err_msg = result.err().unwrap().to_string();
427 assert!(err_msg.contains("invalid caller regex"));
428 }
429
430 #[test]
431 fn test_extract_custom_headers() {
432 use rsip::Header;
433
434 let mut headers = rsip::Headers::default();
435 headers.push(Header::ContentLength(10.into())); headers.push(Header::Other("X-Tenant-ID".into(), "123".into()));
437 headers.push(Header::Other("Custom-Header".into(), "xyz".into()));
438
439 let extras = PlaybookInvitationHandler::extract_custom_headers(&headers);
440
441 assert_eq!(extras.len(), 2);
442 assert_eq!(
443 extras.get("X-Tenant-ID").unwrap(),
444 &serde_json::Value::String("123".to_string())
445 );
446 assert_eq!(
447 extras.get("Custom-Header").unwrap(),
448 &serde_json::Value::String("xyz".to_string())
449 );
450 }
451}