use std::{env, net::IpAddr, path::PathBuf}; #[derive(Clone)] pub enum SpeechProviderConfig { Local { api_url: String }, Tencent(TencentSpeechConfig), } #[derive(Clone)] pub struct TencentSpeechConfig { pub app_id: u64, pub secret_id: String, pub secret_key: String, pub engine_type: String, } #[derive(Clone)] pub struct SpeechConfig { pub provider: SpeechProviderConfig, pub request_timeout_seconds: u64, pub worker_concurrency: usize, pub job_lease_seconds: i64, } #[derive(Clone)] pub struct SummaryConfig { pub api_url: String, pub api_key: Option, pub model: String, } #[derive(Clone)] pub struct WechatAuthConfig { pub app_id: String, pub app_secret: String, pub code2session_url: String, } #[derive(Clone)] pub struct AuthConfig { pub wechat: Option, pub session_ttl_days: i64, pub allow_development_user_header: bool, } #[derive(Clone)] pub struct Config { pub host: IpAddr, pub port: u16, pub database_url: Option, pub database_max_connections: u32, pub cors_allowed_origin: Option, pub audio_storage_dir: PathBuf, pub speech: Option, pub summary: Option, pub auth: AuthConfig, } impl Config { pub fn from_env() -> Result { let host = env::var("HOST") .unwrap_or_else(|_| "127.0.0.1".to_owned()) .parse() .map_err(|_| "HOST must be a valid IP address".to_owned())?; let port = env::var("PORT") .unwrap_or_else(|_| "8080".to_owned()) .parse() .map_err(|_| "PORT must be a valid u16 value".to_owned())?; let database_max_connections = env::var("DATABASE_MAX_CONNECTIONS") .unwrap_or_else(|_| "5".to_owned()) .parse() .map_err(|_| "DATABASE_MAX_CONNECTIONS must be a valid u32 value".to_owned())?; let speech = speech_config()?; let summary = summary_config()?; let auth = auth_config()?; Ok(Self { host, port, database_url: env::var("DATABASE_URL") .ok() .filter(|value| !value.trim().is_empty()), database_max_connections, cors_allowed_origin: env::var("CORS_ALLOWED_ORIGIN") .ok() .filter(|value| !value.trim().is_empty()), audio_storage_dir: env::var("AUDIO_STORAGE_DIR") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("./data/audio")), speech, summary, auth, }) } } fn auth_config() -> Result { let app_id = optional_env("WECHAT_APP_ID"); let app_secret = optional_env("WECHAT_APP_SECRET"); let wechat = match (app_id, app_secret) { (Some(app_id), Some(app_secret)) => Some(WechatAuthConfig { app_id, app_secret, code2session_url: optional_env("WECHAT_CODE2SESSION_URL") .unwrap_or_else(|| "https://api.weixin.qq.com/sns/jscode2session".to_owned()), }), (None, None) => None, _ => { return Err( "WECHAT_APP_ID and WECHAT_APP_SECRET must be configured together".to_owned(), ); } }; let session_ttl_days = parse_env("AUTH_SESSION_TTL_DAYS", 30_i64)?; if !(1..=365).contains(&session_ttl_days) { return Err("AUTH_SESSION_TTL_DAYS must be between 1 and 365".to_owned()); } let allow_development_user_header = parse_bool_env("ALLOW_DEVELOPMENT_USER_HEADER", false)?; Ok(AuthConfig { wechat, session_ttl_days, allow_development_user_header, }) } fn speech_config() -> Result, String> { let provider = optional_env("ASR_PROVIDER").unwrap_or_else(|| "local".to_owned()); if matches!(provider.as_str(), "disabled" | "none") { return Ok(None); } let provider = match provider.as_str() { "local" | "funasr" => SpeechProviderConfig::Local { api_url: optional_env("LOCAL_ASR_URL") .unwrap_or_else(|| "http://127.0.0.1:10095".to_owned()) .trim_end_matches('/') .to_owned(), }, "tencent" => { let app_id = required_env("TENCENT_CLOUD_ASR_APP_ID")? .parse() .map_err(|_| "TENCENT_CLOUD_ASR_APP_ID must be an integer".to_owned())?; SpeechProviderConfig::Tencent(TencentSpeechConfig { app_id, secret_id: required_env("TENCENT_CLOUD_ASR_SECRET_ID")?, secret_key: required_env("TENCENT_CLOUD_ASR_SECRET_KEY")?, engine_type: optional_env("TENCENT_CLOUD_ASR_ENGINE_TYPE") .unwrap_or_else(|| "16k_zh".to_owned()), }) } _ => { return Err( "ASR_PROVIDER must be local, funasr, tencent, disabled, or none".to_owned(), ); } }; let request_timeout_seconds = parse_env("ASR_REQUEST_TIMEOUT_SECONDS", 1_800_u64)?; let worker_concurrency = parse_env("ASR_WORKER_CONCURRENCY", 1_usize)?; let job_lease_seconds = parse_env("ASR_JOB_LEASE_SECONDS", 3_600_i64)?; if request_timeout_seconds == 0 { return Err("ASR_REQUEST_TIMEOUT_SECONDS must be greater than 0".to_owned()); } if worker_concurrency == 0 { return Err("ASR_WORKER_CONCURRENCY must be greater than 0".to_owned()); } if job_lease_seconds <= 0 { return Err("ASR_JOB_LEASE_SECONDS must be greater than 0".to_owned()); } Ok(Some(SpeechConfig { provider, request_timeout_seconds, worker_concurrency, job_lease_seconds, })) } fn summary_config() -> Result, String> { let Some(api_url) = optional_env("FEEDBACK_SUMMARY_API_URL") else { return Ok(None); }; let model = optional_env("FEEDBACK_SUMMARY_MODEL").ok_or_else(|| { "FEEDBACK_SUMMARY_MODEL is required when summary API is configured".to_owned() })?; Ok(Some(SummaryConfig { api_url: api_url.trim_end_matches('/').to_owned(), api_key: optional_env("FEEDBACK_SUMMARY_API_KEY"), model, })) } fn optional_env(name: &str) -> Option { env::var(name) .ok() .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()) } fn required_env(name: &str) -> Result { optional_env(name).ok_or_else(|| format!("{name} is required for the selected ASR provider")) } fn parse_env(name: &str, default: T) -> Result where T: std::str::FromStr, { optional_env(name).map_or_else( || Ok(default), |value| value.parse().map_err(|_| format!("{name} is invalid")), ) } fn parse_bool_env(name: &str, default: bool) -> Result { let Some(value) = optional_env(name) else { return Ok(default); }; match value.to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), _ => Err(format!("{name} must be true or false")), } }