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