Skip to main content

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_config_rtp_latching() -> Option<bool> {
90    Some(true)
91}
92
93fn default_graceful_shutdown() -> Option<bool> {
94    Some(true)
95}
96
97fn default_config_useragent() -> Option<String> {
98    Some(format!(
99        "active-call({} miuda.ai)",
100        env!("CARGO_PKG_VERSION")
101    ))
102}
103
104fn default_codecs() -> Option<Vec<String>> {
105    let mut codecs = vec![
106        "pcmu".to_string(),
107        "pcma".to_string(),
108        "g722".to_string(),
109        "g729".to_string(),
110        "telephone_event".to_string(),
111    ];
112
113    #[cfg(feature = "opus")]
114    {
115        codecs.push("opus".to_string());
116    }
117
118    Some(codecs)
119}
120
121#[derive(Debug, Clone, Deserialize, Serialize, Default)]
122#[serde(rename_all = "snake_case")]
123pub struct RecordingPolicy {
124    #[serde(default)]
125    pub enabled: bool,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub auto_start: Option<bool>,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub filename_pattern: Option<String>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub samplerate: Option<u32>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub ptime: Option<u32>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub path: Option<String>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub format: Option<RecorderFormat>,
138}
139
140impl RecordingPolicy {
141    pub fn recorder_path(&self) -> String {
142        self.path
143            .as_ref()
144            .map(|p| p.trim())
145            .filter(|p| !p.is_empty())
146            .map(|p| p.to_string())
147            .unwrap_or_else(default_config_recorder_path)
148    }
149
150    pub fn recorder_format(&self) -> RecorderFormat {
151        self.format.unwrap_or_default()
152    }
153
154    pub fn ensure_defaults(&mut self) -> bool {
155        if self
156            .path
157            .as_ref()
158            .map(|p| p.trim().is_empty())
159            .unwrap_or(true)
160        {
161            self.path = Some(default_config_recorder_path());
162        }
163
164        false
165    }
166}
167
168#[derive(Debug, Clone, Deserialize, Serialize)]
169pub struct RewriteRule {
170    pub r#match: String,
171    pub rewrite: String,
172}
173
174#[derive(Debug, Deserialize, Serialize)]
175pub struct Config {
176    #[serde(default = "default_config_http_addr")]
177    pub http_addr: String,
178    pub addr: String,
179    pub udp_port: u16,
180    pub auto_learn_public_address: Option<bool>,
181
182    pub log_level: Option<String>,
183    pub log_file: Option<String>,
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub http_access_skip_paths: Vec<String>,
186
187    #[serde(default = "default_config_useragent")]
188    pub useragent: Option<String>,
189    pub register_users: Option<Vec<RegisterOption>>,
190    #[serde(default = "default_graceful_shutdown")]
191    pub graceful_shutdown: Option<bool>,
192    pub handler: Option<InviteHandlerConfig>,
193    pub accept_timeout: Option<String>,
194    #[serde(default = "default_codecs")]
195    pub codecs: Option<Vec<String>>,
196    pub external_ip: Option<String>,
197    #[serde(default = "default_config_rtp_start_port")]
198    pub rtp_start_port: Option<u16>,
199    #[serde(default = "default_config_rtp_end_port")]
200    pub rtp_end_port: Option<u16>,
201    #[serde(default = "default_config_rtp_latching")]
202    pub enable_rtp_latching: Option<bool>,
203    pub enable_ice_lite: Option<bool>,
204    pub rtp_bind_ip: Option<String>,
205    pub tls_port: Option<u16>,
206    pub tls_cert_file: Option<String>,
207    pub tls_key_file: Option<String>,
208
209    pub enable_srtp: Option<bool>,
210
211    pub callrecord: Option<CallRecordConfig>,
212    #[serde(default = "default_config_media_cache_path")]
213    pub media_cache_path: String,
214    pub ambiance: Option<AmbianceOption>,
215    pub ice_servers: Option<Vec<IceServer>>,
216    #[serde(default)]
217    pub recording: Option<RecordingPolicy>,
218    pub rewrites: Option<Vec<RewriteRule>>,
219}
220
221#[derive(Debug, Deserialize, Clone, Serialize)]
222#[serde(rename_all = "snake_case")]
223#[serde(tag = "type")]
224pub enum InviteHandlerConfig {
225    Webhook {
226        url: Option<String>,
227        urls: Option<Vec<String>>,
228        method: Option<String>,
229        headers: Option<Vec<(String, String)>>,
230    },
231    Playbook {
232        rules: Option<Vec<PlaybookRule>>,
233        default: Option<String>,
234    },
235}
236
237#[derive(Debug, Deserialize, Clone, Serialize)]
238#[serde(rename_all = "snake_case")]
239pub struct PlaybookRule {
240    pub caller: Option<String>,
241    pub callee: Option<String>,
242    pub playbook: String,
243}
244
245#[derive(Debug, Deserialize, Clone, Serialize)]
246#[serde(rename_all = "snake_case")]
247pub enum S3Vendor {
248    Aliyun,
249    Tencent,
250    Minio,
251    AWS,
252    GCP,
253    Azure,
254    DigitalOcean,
255}
256
257#[derive(Debug, Deserialize, Clone, Serialize)]
258#[serde(tag = "type")]
259#[serde(rename_all = "snake_case")]
260pub enum CallRecordConfig {
261    Local {
262        root: String,
263    },
264    S3 {
265        vendor: S3Vendor,
266        bucket: String,
267        region: String,
268        access_key: String,
269        secret_key: String,
270        endpoint: String,
271        root: String,
272        with_media: Option<bool>,
273        keep_media_copy: Option<bool>,
274    },
275    Http {
276        url: String,
277        headers: Option<HashMap<String, String>>,
278        with_media: Option<bool>,
279        keep_media_copy: Option<bool>,
280    },
281}
282
283impl Default for CallRecordConfig {
284    fn default() -> Self {
285        Self::Local {
286            #[cfg(target_os = "windows")]
287            root: "./config/cdr".to_string(),
288            #[cfg(not(target_os = "windows"))]
289            root: "./config/cdr".to_string(),
290        }
291    }
292}
293
294impl Default for Config {
295    fn default() -> Self {
296        Self {
297            http_addr: default_config_http_addr(),
298            log_level: None,
299            log_file: None,
300            http_access_skip_paths: Vec::new(),
301            addr: default_sip_addr(),
302            udp_port: default_sip_port(),
303            auto_learn_public_address: None,
304            useragent: None,
305            register_users: None,
306            graceful_shutdown: Some(true),
307            handler: None,
308            accept_timeout: Some("50s".to_string()),
309            media_cache_path: default_config_media_cache_path(),
310            ambiance: None,
311            callrecord: None,
312            ice_servers: None,
313            codecs: None,
314            external_ip: None,
315            rtp_start_port: default_config_rtp_start_port(),
316            rtp_end_port: default_config_rtp_end_port(),
317            enable_rtp_latching: Some(true),
318            rtp_bind_ip: None,
319            enable_ice_lite: None,
320            tls_port: None,
321            tls_cert_file: None,
322            tls_key_file: None,
323            enable_srtp: None,
324            recording: None,
325            rewrites: None,
326        }
327    }
328}
329
330impl Clone for Config {
331    fn clone(&self) -> Self {
332        // This is a bit expensive but Config is not cloned often in hot paths
333        // and implementing Clone manually for all nested structs is tedious
334        let s = toml::to_string(self).unwrap();
335        toml::from_str(&s).unwrap()
336    }
337}
338
339impl Config {
340    pub fn load(path: &str) -> Result<Self, Error> {
341        let config: Self = toml::from_str(
342            &std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("{}: {}", e, path))?,
343        )?;
344        Ok(config)
345    }
346
347    pub fn recorder_path(&self) -> String {
348        self.recording
349            .as_ref()
350            .map(|policy| policy.recorder_path())
351            .unwrap_or_else(default_config_recorder_path)
352    }
353
354    pub fn recorder_format(&self) -> RecorderFormat {
355        self.recording
356            .as_ref()
357            .map(|policy| policy.recorder_format())
358            .unwrap_or_default()
359    }
360
361    pub fn ensure_recording_defaults(&mut self) -> bool {
362        let mut fallback = false;
363
364        if let Some(policy) = self.recording.as_mut() {
365            fallback |= policy.ensure_defaults();
366        }
367
368        fallback
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_playbook_handler_config_parsing() {
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"
385default = "default.md"
386
387[[handler.rules]]
388caller = "^\\+1\\d{10}$"
389callee = "^sip:support@.*"
390playbook = "support.md"
391
392[[handler.rules]]
393caller = "^\\+86\\d+"
394playbook = "chinese.md"
395
396[[handler.rules]]
397callee = "^sip:sales@.*"
398playbook = "sales.md"
399"#;
400
401        let config: Config = toml::from_str(toml_config).unwrap();
402
403        assert!(config.handler.is_some());
404        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
405            assert_eq!(default, Some("default.md".to_string()));
406            let rules = rules.unwrap();
407            assert_eq!(rules.len(), 3);
408
409            assert_eq!(rules[0].caller, Some(r"^\+1\d{10}$".to_string()));
410            assert_eq!(rules[0].callee, Some("^sip:support@.*".to_string()));
411            assert_eq!(rules[0].playbook, "support.md");
412
413            assert_eq!(rules[1].caller, Some(r"^\+86\d+".to_string()));
414            assert_eq!(rules[1].callee, None);
415            assert_eq!(rules[1].playbook, "chinese.md");
416
417            assert_eq!(rules[2].caller, None);
418            assert_eq!(rules[2].callee, Some("^sip:sales@.*".to_string()));
419            assert_eq!(rules[2].playbook, "sales.md");
420        } else {
421            panic!("Expected Playbook handler config");
422        }
423    }
424
425    #[test]
426    fn test_playbook_handler_config_without_default() {
427        let toml_config = r#"
428http_addr = "0.0.0.0:8080"
429addr = "0.0.0.0"
430udp_port = 25060
431
432[handler]
433type = "playbook"
434
435[[handler.rules]]
436caller = "^\\+1.*"
437playbook = "us.md"
438"#;
439
440        let config: Config = toml::from_str(toml_config).unwrap();
441
442        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
443            assert_eq!(default, None);
444            let rules = rules.unwrap();
445            assert_eq!(rules.len(), 1);
446        } else {
447            panic!("Expected Playbook handler config");
448        }
449    }
450
451    #[test]
452    fn test_webhook_handler_config_still_works() {
453        let toml_config = r#"
454http_addr = "0.0.0.0:8080"
455addr = "0.0.0.0"
456udp_port = 25060
457
458[handler]
459type = "webhook"
460url = "http://example.com/webhook"
461"#;
462
463        let config: Config = toml::from_str(toml_config).unwrap();
464
465        if let Some(InviteHandlerConfig::Webhook { url, .. }) = config.handler {
466            assert_eq!(url, Some("http://example.com/webhook".to_string()));
467        } else {
468            panic!("Expected Webhook handler config");
469        }
470    }
471}