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 #[clap(long)]
14 pub conf: Option<String>,
15 #[clap(long)]
17 pub http: Option<String>,
18
19 #[clap(long)]
21 pub sip: Option<String>,
22
23 #[clap(long)]
25 pub handler: Option<String>,
26
27 #[clap(long)]
29 pub call: Option<String>,
30
31 #[clap(long)]
33 pub external_ip: Option<String>,
34
35 #[clap(long, value_delimiter = ',')]
37 pub codecs: Option<Vec<String>>,
38
39 #[cfg(feature = "offline")]
41 #[clap(long)]
42 pub download_models: Option<String>,
43
44 #[cfg(feature = "offline")]
46 #[clap(long, default_value = "./models")]
47 pub models_dir: String,
48
49 #[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 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}