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
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 rtp_bind_ip: Option<String>,
203
204    pub callrecord: Option<CallRecordConfig>,
205    #[serde(default = "default_config_media_cache_path")]
206    pub media_cache_path: String,
207    pub ambiance: Option<AmbianceOption>,
208    pub ice_servers: Option<Vec<IceServer>>,
209    #[serde(default)]
210    pub recording: Option<RecordingPolicy>,
211    pub rewrites: Option<Vec<RewriteRule>>,
212}
213
214#[derive(Debug, Deserialize, Clone, Serialize)]
215#[serde(rename_all = "snake_case")]
216#[serde(tag = "type")]
217pub enum InviteHandlerConfig {
218    Webhook {
219        url: Option<String>,
220        urls: Option<Vec<String>>,
221        method: Option<String>,
222        headers: Option<Vec<(String, String)>>,
223    },
224    Playbook {
225        rules: Option<Vec<PlaybookRule>>,
226        default: Option<String>,
227    },
228}
229
230#[derive(Debug, Deserialize, Clone, Serialize)]
231#[serde(rename_all = "snake_case")]
232pub struct PlaybookRule {
233    pub caller: Option<String>,
234    pub callee: Option<String>,
235    pub playbook: String,
236}
237
238#[derive(Debug, Deserialize, Clone, Serialize)]
239#[serde(rename_all = "snake_case")]
240pub enum S3Vendor {
241    Aliyun,
242    Tencent,
243    Minio,
244    AWS,
245    GCP,
246    Azure,
247    DigitalOcean,
248}
249
250#[derive(Debug, Deserialize, Clone, Serialize)]
251#[serde(tag = "type")]
252#[serde(rename_all = "snake_case")]
253pub enum CallRecordConfig {
254    Local {
255        root: String,
256    },
257    S3 {
258        vendor: S3Vendor,
259        bucket: String,
260        region: String,
261        access_key: String,
262        secret_key: String,
263        endpoint: String,
264        root: String,
265        with_media: Option<bool>,
266        keep_media_copy: Option<bool>,
267    },
268    Http {
269        url: String,
270        headers: Option<HashMap<String, String>>,
271        with_media: Option<bool>,
272        keep_media_copy: Option<bool>,
273    },
274}
275
276impl Default for CallRecordConfig {
277    fn default() -> Self {
278        Self::Local {
279            #[cfg(target_os = "windows")]
280            root: "./config/cdr".to_string(),
281            #[cfg(not(target_os = "windows"))]
282            root: "./config/cdr".to_string(),
283        }
284    }
285}
286
287impl Default for Config {
288    fn default() -> Self {
289        Self {
290            http_addr: default_config_http_addr(),
291            log_level: None,
292            log_file: None,
293            http_access_skip_paths: Vec::new(),
294            addr: default_sip_addr(),
295            udp_port: default_sip_port(),
296            useragent: None,
297            register_users: None,
298            graceful_shutdown: Some(true),
299            handler: None,
300            accept_timeout: Some("50s".to_string()),
301            media_cache_path: default_config_media_cache_path(),
302            ambiance: None,
303            callrecord: None,
304            ice_servers: None,
305            codecs: None,
306            external_ip: None,
307            rtp_start_port: default_config_rtp_start_port(),
308            rtp_end_port: default_config_rtp_end_port(),
309            enable_rtp_latching: Some(true),
310            rtp_bind_ip: None,
311            recording: None,
312            rewrites: None,
313        }
314    }
315}
316
317impl Clone for Config {
318    fn clone(&self) -> Self {
319        // This is a bit expensive but Config is not cloned often in hot paths
320        // and implementing Clone manually for all nested structs is tedious
321        let s = toml::to_string(self).unwrap();
322        toml::from_str(&s).unwrap()
323    }
324}
325
326impl Config {
327    pub fn load(path: &str) -> Result<Self, Error> {
328        let config: Self = toml::from_str(
329            &std::fs::read_to_string(path).map_err(|e| anyhow::anyhow!("{}: {}", e, path))?,
330        )?;
331        Ok(config)
332    }
333
334    pub fn recorder_path(&self) -> String {
335        self.recording
336            .as_ref()
337            .map(|policy| policy.recorder_path())
338            .unwrap_or_else(default_config_recorder_path)
339    }
340
341    pub fn recorder_format(&self) -> RecorderFormat {
342        self.recording
343            .as_ref()
344            .map(|policy| policy.recorder_format())
345            .unwrap_or_default()
346    }
347
348    pub fn ensure_recording_defaults(&mut self) -> bool {
349        let mut fallback = false;
350
351        if let Some(policy) = self.recording.as_mut() {
352            fallback |= policy.ensure_defaults();
353        }
354
355        fallback
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_playbook_handler_config_parsing() {
365        let toml_config = r#"
366http_addr = "0.0.0.0:8080"
367addr = "0.0.0.0"
368udp_port = 25060
369
370[handler]
371type = "playbook"
372default = "default.md"
373
374[[handler.rules]]
375caller = "^\\+1\\d{10}$"
376callee = "^sip:support@.*"
377playbook = "support.md"
378
379[[handler.rules]]
380caller = "^\\+86\\d+"
381playbook = "chinese.md"
382
383[[handler.rules]]
384callee = "^sip:sales@.*"
385playbook = "sales.md"
386"#;
387
388        let config: Config = toml::from_str(toml_config).unwrap();
389
390        assert!(config.handler.is_some());
391        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
392            assert_eq!(default, Some("default.md".to_string()));
393            let rules = rules.unwrap();
394            assert_eq!(rules.len(), 3);
395
396            assert_eq!(rules[0].caller, Some(r"^\+1\d{10}$".to_string()));
397            assert_eq!(rules[0].callee, Some("^sip:support@.*".to_string()));
398            assert_eq!(rules[0].playbook, "support.md");
399
400            assert_eq!(rules[1].caller, Some(r"^\+86\d+".to_string()));
401            assert_eq!(rules[1].callee, None);
402            assert_eq!(rules[1].playbook, "chinese.md");
403
404            assert_eq!(rules[2].caller, None);
405            assert_eq!(rules[2].callee, Some("^sip:sales@.*".to_string()));
406            assert_eq!(rules[2].playbook, "sales.md");
407        } else {
408            panic!("Expected Playbook handler config");
409        }
410    }
411
412    #[test]
413    fn test_playbook_handler_config_without_default() {
414        let toml_config = r#"
415http_addr = "0.0.0.0:8080"
416addr = "0.0.0.0"
417udp_port = 25060
418
419[handler]
420type = "playbook"
421
422[[handler.rules]]
423caller = "^\\+1.*"
424playbook = "us.md"
425"#;
426
427        let config: Config = toml::from_str(toml_config).unwrap();
428
429        if let Some(InviteHandlerConfig::Playbook { rules, default }) = config.handler {
430            assert_eq!(default, None);
431            let rules = rules.unwrap();
432            assert_eq!(rules.len(), 1);
433        } else {
434            panic!("Expected Playbook handler config");
435        }
436    }
437
438    #[test]
439    fn test_webhook_handler_config_still_works() {
440        let toml_config = r#"
441http_addr = "0.0.0.0:8080"
442addr = "0.0.0.0"
443udp_port = 25060
444
445[handler]
446type = "webhook"
447url = "http://example.com/webhook"
448"#;
449
450        let config: Config = toml::from_str(toml_config).unwrap();
451
452        if let Some(InviteHandlerConfig::Webhook { url, .. }) = config.handler {
453            assert_eq!(url, Some("http://example.com/webhook".to_string()));
454        } else {
455            panic!("Expected Webhook handler config");
456        }
457    }
458}