MessageWeave/src/main.rs

178 lines
6.4 KiB
Rust

mod ai;
mod channel;
mod config;
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();
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()
}
};
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(());
}
// The Redis URL is the only bootstrap credential in the migration path. Until
// the bootstrap route is wired, retain the legacy parser solely to obtain it.
let bootstrap_config = match Config::from_env() {
Ok(config) => config,
Err(_) => Config::redis_only(secrecy::SecretString::new(redis_value.clone())),
};
let redis_url = bootstrap_config.redis_url.clone();
// RUN_MODE is intentionally explicit even while both modes share the stage-0
// no-op router; later stages attach webhook/reconcile side effects here.
match bootstrap_config.run_mode.as_str() {
"webhook" => tracing::info!("RUN_MODE=webhook"),
"reconcile" => tracing::info!("RUN_MODE=reconcile"),
mode => {
return Err(error::BotError::Config(format!(
"RUN_MODE must be webhook or reconcile, got {mode}"
)))
}
}
tracing::info!(
port = bootstrap_config.port,
"starting webhook HTTP entrypoint"
);
let redis =
RedisState::connect_with_encryption(redis_url.expose_secret(), Some(encryption_key))
.await?;
let bootstrap_token = url::Url::parse(redis_url.expose_secret())
.ok()
.and_then(|value| value.password().map(ToOwned::to_owned))
.map(secrecy::SecretString::new)
.unwrap_or_else(|| secrecy::SecretString::new(String::new()));
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(redis_url.clone(), 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,
bootstrap_token,
)
.merge(web::router());
axum::serve(listener, app).await?;
Ok(())
}