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