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 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 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(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 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 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, true, 20, 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 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 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 =
258 headers.map(|h_map| crate::sip_util::sip_headers_from_map(&h_map));
259
260 if let Err(e) = dialog.bye_with_headers(sip_headers).await {
262 warn!(session_id, "Failed to send BYE: {}", e);
263 }
264 });
265
266 Ok(())
267 }
268 None => {
269 warn!(
270 dialog_id,
271 caller, callee, "no playbook matched for invite, rejecting"
272 );
273 Err(anyhow!(
274 "no matching playbook found for caller {} and callee {}",
275 caller,
276 callee
277 ))
278 }
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::config::PlaybookRule;
287
288 struct TestMatcher {
290 rules: Vec<(Option<Regex>, Option<Regex>, String)>,
291 default: Option<String>,
292 }
293
294 impl TestMatcher {
295 fn new(rules: Vec<PlaybookRule>, default: Option<String>) -> Result<Self> {
296 let mut compiled_rules = Vec::new();
297
298 for rule in rules {
299 let caller_regex = if let Some(pattern) = rule.caller {
300 Some(
301 Regex::new(&pattern)
302 .map_err(|e| anyhow!("invalid caller regex '{}': {}", pattern, e))?,
303 )
304 } else {
305 None
306 };
307
308 let callee_regex = if let Some(pattern) = rule.callee {
309 Some(
310 Regex::new(&pattern)
311 .map_err(|e| anyhow!("invalid callee regex '{}': {}", pattern, e))?,
312 )
313 } else {
314 None
315 };
316
317 compiled_rules.push((caller_regex, callee_regex, rule.playbook.clone()));
318 }
319
320 Ok(Self {
321 rules: compiled_rules,
322 default,
323 })
324 }
325
326 fn match_playbook(&self, caller: &str, callee: &str) -> Option<String> {
327 for (caller_re, callee_re, playbook) in &self.rules {
328 let caller_matches = caller_re
329 .as_ref()
330 .map(|r| r.is_match(caller))
331 .unwrap_or(true);
332
333 let callee_matches = callee_re
334 .as_ref()
335 .map(|r| r.is_match(callee))
336 .unwrap_or(true);
337
338 if caller_matches && callee_matches {
339 return Some(playbook.clone());
340 }
341 }
342
343 self.default.clone()
344 }
345 }
346
347 #[test]
348 fn test_playbook_rule_matching() {
349 let rules = vec![
350 PlaybookRule {
351 caller: Some(r"^\+1\d{10}$".to_string()),
352 callee: Some(r"^sip:support@.*".to_string()),
353 playbook: "support.md".to_string(),
354 },
355 PlaybookRule {
356 caller: Some(r"^\+86\d+$".to_string()),
357 callee: None,
358 playbook: "chinese.md".to_string(),
359 },
360 PlaybookRule {
361 caller: None,
362 callee: Some(r"^sip:sales@.*".to_string()),
363 playbook: "sales.md".to_string(),
364 },
365 ];
366
367 let matcher = TestMatcher::new(rules, Some("default.md".to_string())).unwrap();
368
369 assert_eq!(
371 matcher.match_playbook("+12125551234", "sip:support@example.com"),
372 Some("support.md".to_string())
373 );
374
375 assert_eq!(
377 matcher.match_playbook("+8613800138000", "sip:any@example.com"),
378 Some("chinese.md".to_string())
379 );
380
381 assert_eq!(
383 matcher.match_playbook("+44123456789", "sip:sales@example.com"),
384 Some("sales.md".to_string())
385 );
386
387 assert_eq!(
389 matcher.match_playbook("+44123456789", "sip:other@example.com"),
390 Some("default.md".to_string())
391 );
392 }
393
394 #[test]
395 fn test_playbook_rule_no_default() {
396 let rules = vec![PlaybookRule {
397 caller: Some(r"^\+1.*".to_string()),
398 callee: None,
399 playbook: "us.md".to_string(),
400 }];
401
402 let matcher = TestMatcher::new(rules, None).unwrap();
403
404 assert_eq!(
406 matcher.match_playbook("+12125551234", "sip:any@example.com"),
407 Some("us.md".to_string())
408 );
409
410 assert_eq!(
412 matcher.match_playbook("+44123456789", "sip:any@example.com"),
413 None
414 );
415 }
416
417 #[test]
418 fn test_invalid_regex() {
419 let rules = vec![PlaybookRule {
420 caller: Some(r"[invalid(".to_string()),
421 callee: None,
422 playbook: "test.md".to_string(),
423 }];
424
425 let result = TestMatcher::new(rules, None);
426 assert!(result.is_err());
427 let err_msg = result.err().unwrap().to_string();
428 assert!(err_msg.contains("invalid caller regex"));
429 }
430
431 #[test]
432 fn test_extract_custom_headers() {
433 use rsipstack::rsip::Header;
434
435 let mut headers = rsipstack::rsip::Headers::default();
436 headers.push(Header::ContentLength(10.into())); headers.push(Header::Other("X-Tenant-ID".into(), "123".into()));
438 headers.push(Header::Other("Custom-Header".into(), "xyz".into()));
439
440 let extras = PlaybookInvitationHandler::extract_custom_headers(&headers);
441
442 assert_eq!(extras.len(), 2);
443 assert_eq!(
444 extras.get("X-Tenant-ID").unwrap(),
445 &serde_json::Value::String("123".to_string())
446 );
447 assert_eq!(
448 extras.get("Custom-Header").unwrap(),
449 &serde_json::Value::String("xyz".to_string())
450 );
451 }
452}