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