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_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 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}