mod ai; mod channel; mod config; pub(crate) mod debug; pub mod domain; mod error; mod notify; pub mod state; mod web; mod worker; use secrecy::ExposeSecret; use std::net::SocketAddr; use crate::channel::telegram::TelegramClient; use crate::domain::jmap::{client::JmapClientBackend, JmapService}; use config::{encryption_key_from_env, Config}; use notify::{ router_configuration_setup, router_with_worker_state_runtime_bootstrap, MetadataWorker, WorkerHandle, WorkerHandler, }; use state::{runtime_provider, RedisState}; use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), error::BotError> { tracing_subscriber::fmt().with_env_filter("info").init(); // reqwest/JMAP and Redis can activate different rustls crypto backends. Install // ring explicitly before any client is constructed to avoid rustls provider panic. let _ = install_rustls_provider(); let port = std::env::var("PORT") .ok() .and_then(|value| value.parse().ok()) .unwrap_or(8080_u16); let addr: SocketAddr = format!("0.0.0.0:{port}") .parse() .map_err(|e| error::BotError::Config(format!("invalid PORT: {e}")))?; let listener = tokio::net::TcpListener::bind(addr).await?; let mut missing = Vec::new(); let redis_value = match std::env::var("REDIS_URL") { Ok(value) if !value.trim().is_empty() => value, _ => { missing.push("REDIS_URL".to_owned()); String::new() } }; // The SPA admin credential is this same Secret (see below): capture it once so the // validated value and the admin token can never diverge. let encryption_key_raw = std::env::var("CONFIG_ENCRYPTION_KEY").unwrap_or_default(); let encryption_key = match encryption_key_from_env() { Ok(key) => key, Err(_) => { missing.push("CONFIG_ENCRYPTION_KEY".to_owned()); [0_u8; 32] } }; if !missing.is_empty() { tracing::warn!( ?missing, "required bootstrap environment is incomplete; serving setup mode" ); let app = router_configuration_setup(missing).merge(web::router()); axum::serve(listener, app).await?; return Ok(()); } // REDIS_URL is the only bootstrap credential read from the environment; every // business credential comes from Redis business config instead. let redis_url = secrecy::SecretString::new(redis_value); let bootstrap_config = Config::redis_only(); tracing::info!(port, "starting webhook HTTP entrypoint"); let redis = RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) .await?; // SPA admin credential == CONFIG_ENCRYPTION_KEY (validated as a required // startup Secret above). It must not be derived from REDIS_URL: a Redis ACL // password only gates the database connection, and passwordless managed // Redis would leave the admin UI permanently locked out. let admin_token = secrecy::SecretString::new(encryption_key_raw); // SAF-DEBUG-GATE: dual factor — /debug/* is mounted only when the process is launched with // --debug AND a non-empty DEBUG_TOKEN Secret exists. Missing either factor keeps the // surface absolutely closed (no route registered, so requests get a generic 404). let debug_token = if std::env::args().any(|arg| arg == "--debug") { std::env::var("DEBUG_TOKEN") .ok() .filter(|value| !value.is_empty()) .map(secrecy::SecretString::new) } else { None }; if debug_token.is_some() { // SAF-LOG-PURITY: record that the surface is on, never the token value. tracing::warn!("debug endpoints enabled for remote 联调 (SAF-DEBUG-GATE)"); } let worker_state: std::sync::Arc = std::sync::Arc::new( RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key)) .await?, ); let config = match worker_state .get_business_config() .await .map_err(|_| error::BotError::Config("business config unavailable".into()))? { Some(value) => Config::from_business_json(value)?, None => bootstrap_config, }; let outbound = worker_state .get_outbound_config() .await .map_err(|_| error::BotError::Config("runtime outbound config unavailable".into()))?; let runtime = runtime_provider(outbound.clone()); let llm = if config.llm.enabled && config.llm.allow_net { Some(std::sync::Arc::new( ai::LlmClient::with_runtime( config.llm.base_url.clone().unwrap_or_default(), config .llm .api_key .clone() .unwrap_or_else(|| secrecy::SecretString::new("".into())), config.llm.model.clone().unwrap_or_default(), config.llm.summary_target_chars, runtime.clone(), ) .map_err(|_| error::BotError::Config("LLM endpoint must use HTTPS".into()))?, )) } else { None }; let allowlist: std::collections::HashSet = config.telegram.chat_allowlist.iter().copied().collect(); let chat_id = config.telegram.chat_id; let worker: Arc = if config.telegram.bot_token.expose_secret().is_empty() { Arc::new(notify::NoopWorker) } else { match JmapClientBackend::connect_with_runtime( &config.jmap.session_url, &config.jmap.username, config.jmap.app_password.expose_secret(), config.account_id.as_deref(), runtime.clone(), // All subsequent JMAP calls read this provider, so SPA updates apply // without restarting the process. ) .await { Ok(backend) => { let account = backend.account_id().to_owned(); match JmapService::new(backend, account) { Ok(jmap) => Arc::new(MetadataWorker::new( jmap, TelegramClient::with_runtime(config.telegram.bot_token, runtime.clone()), chat_id, worker_state, llm, )) as Arc, Err(_) => { tracing::warn!( "JMAP service unavailable; starting configuration-only mode" ); Arc::new(notify::NoopWorker) } } } Err(_) => { tracing::warn!("JMAP connection unavailable; starting configuration-only mode"); Arc::new(notify::NoopWorker) } } }; let app = router_with_worker_state_runtime_bootstrap( config.auth, config.worker_token, redis, allowlist, Arc::new(WorkerHandle::new(worker)), runtime, admin_token, debug_token, ) .merge(web::router()); axum::serve(listener, app).await?; Ok(()) } fn install_rustls_provider() -> bool { rustls::crypto::ring::default_provider() .install_default() .is_ok() || rustls::crypto::CryptoProvider::get_default().is_some() } #[cfg(test)] mod tests { use super::install_rustls_provider; #[test] fn rustls_provider_is_installable_without_network() { assert!(install_rustls_provider()); } }