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