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