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