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