201 lines
7.4 KiB
Rust
201 lines
7.4 KiB
Rust
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<dyn state::ReliableState> = 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<i64> =
|
|
config.telegram.chat_allowlist.iter().copied().collect();
|
|
let chat_id = config.telegram.chat_id;
|
|
let worker: Arc<dyn WorkerHandler> = 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<dyn WorkerHandler>,
|
|
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());
|
|
}
|
|
}
|