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