active_call/
config.rs

1use crate::media::{ambiance::AmbianceOption, recorder::RecorderFormat};
2use crate::useragent::RegisterOption;
3use anyhow::{Error, Result};
4use clap::Parser;
5use rustrtc::IceServer;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Parser, Debug)]
10#[command(version)]
11pub struct Cli {
12    #[clap(long)]
13    pub conf: Option<String>,
14
15    #[clap(long)]
16    pub http: Option<String>,
17
18    #[clap(long)]
19    pub sip: Option<String>,
20
21    /// SIP invitation handler: URL for webhook (http://...) or playbook file (.md)
22    #[clap(long)]
23    pub handler: Option<String>,
24
25    /// Download models (sensevoice, supertonic, or all)
26    #[cfg(feature = "offline")]
27    #[clap(long)]
28    pub download_models: Option<String>,
29
30    /// Models directory for offline inference
31    #[cfg(feature = "offline")]
32    #[clap(long, default_value = "./models")]
33    pub models_dir: String,
34
35    /// Exit after downloading models
36    #[cfg(feature = "offline")]
37    #[clap(long)]
38    pub exit_after_download: bool,
39}
40
41pub(crate) fn default_config_recorder_path() -> String {
42    #[cfg(target_os = "windows")]
43    return "./config/recorders".to_string();
44    #[cfg(not(target_os = "windows"))]
45    return "./config/recorders".to_string();
46}
47
48fn default_config_media_cache_path() -> String {
49    #[cfg(target_os = "windows")]
50    return "./config/mediacache".to_string();
51    #[cfg(not(target_os = "windows"))]
52    return "./config/mediacache".to_string();
53}
54
55fn default_config_http_addr() -> String {
56    "0.0.0.0:8080".to_string()
57}
58
59fn default_sip_addr() -> String {
60    "0.0.0.0".to_string()
61}
62
63fn default_sip_port() -> u16 {
64    25060
65}
66
67fn default_config_rtp_start_port() -> Option<u16> {
68    Some(12000)
69}
70
71fn default_config_rtp_end_port() -> Option<u16> {
72    Some(42000)
73}
74
75fn default_codecs() -> Option<Vec<String>> {
76    let mut codecs = vec![
77        "pcmu".to_string(),
78        "pcma".to_string(),
79        "g722".to_string(),
80        "g729".to_string(),
81        "telephone_event".to_string(),
82    ];
83
84    #[cfg(feature = "opus")]
85    {
86        codecs.push("opus".to_string());
87    }
88
89    Some(codecs)
90}
91
92#[derive(Debug, Clone, Deserialize, Serialize, Default)]
93#[serde(rename_all = "snake_case")]
94pub struct RecordingPolicy {
95    #[serde(default)]
96    pub enabled: bool,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub auto_start: Option<bool>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub filename_pattern: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub samplerate: Option<u32>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub ptime: Option<u32>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub path: Option<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub format: Option<RecorderFormat>,
109}
110
111impl RecordingPolicy {
112    pub fn recorder_path(&self) -> String {
113        self.path
114            .as_ref()
115            .map(|p| p.trim())
116            .filter(|p| !p.is_empty())
117            .map(|p| p.to_string())
118            .unwrap_or_else(default_config_recorder_path)
119    }
120
121    pub fn recorder_format(&self) -> RecorderFormat {
122        self.format.unwrap_or_default()
123    }
124
125    pub fn ensure_defaults(&mut self) -> bool {
126        if self
127            .path
128            .as_ref()
129            .map(|p| p.trim().is_empty())
130            .unwrap_or(true)
131        {
132            self.path = Some(default_config_recorder_path());
133        }
134
135        false
136    }
137}
138
139#[derive(Debug, Clone, Deserialize, Serialize)]
140pub struct RewriteRule {
141    pub r#match: String,
142    pub rewrite: String,
143}
144
145#[derive(Debug, Deserialize, Serialize)]
146pub struct Config {
147    #[serde(default = "default_config_http_addr")]
148    pub http_addr: String,
149    pub addr: String,
150    pub udp_port: u16,
151
152    pub log_level: Option<String>,
153    pub log_file: Option<String>,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub http_access_skip_paths: Vec<String>,
156
157    pub useragent: Option<String>,
158    pub register_users: Option<Vec<RegisterOption>>,
159    pub graceful_shutdown: Option<bool>,
160    pub handler: Option<InviteHandlerConfig>,
161    pub accept_timeout: Option<String>,
162    #[serde(default = "default_codecs")]
163    pub codecs: Option<Vec<String>>,
164    pub external_ip: Option<String>,
165    #[serde(default = "default_config_rtp_start_port")]
166    pub rtp_start_port: Option<u16>,
167    #[serde(default = "default_config_rtp_end_port")]
168    pub rtp_end_port: Option<u16>,
169
170    pub callrecord: Option<CallRecordConfig>,
171    #[serde(default = "default_config_media_cache_path")]
172    pub media_cache_path: String,
173    pub ambiance: Option<AmbianceOption>,
174    pub ice_servers: Option<Vec<IceServer>>,
175    #[serde(default)]
176    pub recording: Option<RecordingPolicy>,
177    pub rewrites: Option<Vec<RewriteRule>>,
178}
179
180#[derive(Debug, Deserialize, Clone, Serialize)]
181#[serde(rename_all = "snake_case")]
182#[serde(tag = "type")]
183pub enum InviteHandlerConfig {
184    Webhook {
185        url: Option<String>,
186        urls: Option<Vec<String>>,
187        method: Option<String>,
188        headers: Option<Vec<(String, String)>>,
189    },
190    Playbook {
191        rules: Option<Vec<PlaybookRule>>,
192        default: Option<String>,
193    },
194}
195
196#[derive(Debug, Deserialize, Clone, Serialize)]
197#[serde(rename_all = "snake_case")]
198pub struct PlaybookRule {
199    pub caller: Option<String>,
200    pub callee: Option<String>,
201    pub playbook: String,
202}
203
204#[derive(Debug, Deserialize, Clone, Serialize)]
205#[serde(rename_all = "snake_case")]
206pub enum S3Vendor {
207    Aliyun,
208    Tencent,
209    Minio,
210    AWS,
211    GCP,
212    Azure,
213    DigitalOcean,
214}
215
216#[derive(Debug, Deserialize, Clone, Serialize)]
217#[serde(tag = "type")]
218#[serde(rename_all = "snake_case")]
219pub enum CallRecordConfig {
220    Local {
221        root: String,
222    },
223    S3 {
224        vendor: S3Vendor,
225        bucket: String,
226        region: String,
227        access_key: String,
228        secret_key: String,
229        endpoint: String,
230        root: String,
231        with_media: Option<bool>,
232        keep_media_copy: Option<bool>,
233    },
234    Http {
235        url: String,
236        headers: Option<HashMap<String, String>>,
237        with_media: Option<bool>,
238        keep_media_copy: Option<bool>,
239    },
240}
241
242impl Default for CallRecordConfig {
243    fn default() -> Self {
244        Self::Local {
245            #[cfg(target_os = "windows")]
246            root: "./config/cdr".to_string(),
247            #[cfg(not(target_os = "windows"))]
248            root: "./config/cdr".to_string(),
249        }
250    }
251}
252
253impl Default for Config {
254    fn default() -> Self {
255        Self {
256            http_addr: default_config_http_addr(),
257            log_level: None,
258            log_file: None,
259            http_access_skip_paths: Vec::new(),
260            addr: default_sip_addr(),
261            udp_port: default_sip_port(),
262            useragent: None,
263            register_users: None,
264            graceful_shutdown: Some(true),
265            handler: None,
266            accept_timeout: Some("50s".to_string()),
267            media_cache_path: default_config_media_cache_path(),
268            ambiance: None,
269            callrecord: None,
270            ice_servers: None,
271            codecs: None,
272            external_ip: None,
273            rtp_start_port: default_config_rtp_start_port(),
274            rtp_end_port: default_config_rtp_end_port(),
275            recording: None,
276            rewrites: None,
277        }
278    }
279}
280
281impl Clone for Config {
282    fn clone(&self) -> Self {
283        // This is a bit expensive but Config is not cloned often in hot paths
284        // and implementing Clone manually for all nested structs is tedious
285        let s = toml::to_string(self).unwrap();
286        toml::from_str(&s).unwrap()
287    }
288}
289
290impl Config {
291    pub fn load(path: &str) -> Result<Self, Error> {
292        let config: Self = toml::from_str(
293            &std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("{}: {}", e, path))?,
294        )?;
295        Ok(config)
296    }
297
298    pub fn recorder_path(&self) -> String {
299        self.recording
300            .as_ref()
301            .map(|policy| policy.recorder_path())
302            .unwrap_or_else(default_config_recorder_path)
303    }
304
305    pub fn recorder_format(&self) -> RecorderFormat {
306        self.recording
307            .as_ref()
308            .map(|policy| policy.recorder_format())
309            .unwrap_or_default()
310    }
311
312    pub fn ensure_recording_defaults(&mut self) -> bool {
313        let mut fallback = false;
314
315        if let Some(policy) = self.recording.as_mut() {
316            fallback |= policy.ensure_defaults();
317        }
318
319        fallback
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_playbook_handler_config_parsing() {
329        let toml_config = r#"
330http_addr = "0.0.0.0:8080"
331addr = "0.0.0.0"
332udp_port = 25060
333
334[handler]
335type = "playbook"
336default = "default.md"
337
338[[handler.rules]]
339caller = "^\\+1\\d{10}$"
340callee = "^sip:support@.*"
341playbook = "support.md"
342
343[[handler.rules]]
344caller = "^\\+86\\d+"
345playbook = "chinese.md"
346
347[[handler.rules]]
348callee = "^sip:sales@.*"
349playbook = "sales.md"
350"#;
351
352        let config: Config = toml::from_str(toml_config).unwrap();
353
354        assert!(config.handler.is_some());
355        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
356            assert_eq!(default, Some("default.md".to_string()));
357            let rules = rules.unwrap();
358            assert_eq!(rules.len(), 3);
359
360            assert_eq!(rules[0].caller, Some(r"^\+1\d{10}$".to_string()));
361            assert_eq!(rules[0].callee, Some("^sip:support@.*".to_string()));
362            assert_eq!(rules[0].playbook, "support.md");
363
364            assert_eq!(rules[1].caller, Some(r"^\+86\d+".to_string()));
365            assert_eq!(rules[1].callee, None);
366            assert_eq!(rules[1].playbook, "chinese.md");
367
368            assert_eq!(rules[2].caller, None);
369            assert_eq!(rules[2].callee, Some("^sip:sales@.*".to_string()));
370            assert_eq!(rules[2].playbook, "sales.md");
371        } else {
372            panic!("Expected Playbook handler config");
373        }
374    }
375
376    #[test]
377    fn test_playbook_handler_config_without_default() {
378        let toml_config = r#"
379http_addr = "0.0.0.0:8080"
380addr = "0.0.0.0"
381udp_port = 25060
382
383[handler]
384type = "playbook"
385
386[[handler.rules]]
387caller = "^\\+1.*"
388playbook = "us.md"
389"#;
390
391        let config: Config = toml::from_str(toml_config).unwrap();
392
393        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
394            assert_eq!(default, None);
395            let rules = rules.unwrap();
396            assert_eq!(rules.len(), 1);
397        } else {
398            panic!("Expected Playbook handler config");
399        }
400    }
401
402    #[test]
403    fn test_webhook_handler_config_still_works() {
404        let toml_config = r#"
405http_addr = "0.0.0.0:8080"
406addr = "0.0.0.0"
407udp_port = 25060
408
409[handler]
410type = "webhook"
411url = "http://example.com/webhook"
412"#;
413
414        let config: Config = toml::from_str(toml_config).unwrap();
415
416        if let Some(InviteHandlerConfig::Webhook { url, .. }) = config.handler {
417            assert_eq!(url, Some("http://example.com/webhook".to_string()));
418        } else {
419            panic!("Expected Webhook handler config");
420        }
421    }
422}