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