use std::sync::Arc; use ::agent::chat::ChatService; use ::agent::task::service::TaskService; use ::agent::tool::ToolRegistry; use async_openai::config::OpenAIConfig; use avatar::AppAvatar; use config::AppConfig; use db::cache::AppCache; use db::database::AppDatabase; use email::AppEmail; use queue::{ start_email_worker, EmailEnvelope, EmailSendFn, EmailSendFut, GetRedis, MessageProducer, RedisFuture, RedisPubSub, }; use room::metrics::RoomMetrics; use room::RoomService; use serde::{Deserialize, Serialize}; use observability::build_logger; use utoipa::ToSchema; use ws_token::WsTokenService; pub mod storage; pub use storage::AppStorage; pub mod push; pub use push::{WebPushService, PushPayload}; #[derive(Clone)] pub struct AppService { pub db: AppDatabase, pub config: AppConfig, pub cache: AppCache, pub email: AppEmail, pub logs: slog::Logger, pub avatar: AppAvatar, pub room: RoomService, pub ws_token: Arc, pub queue_producer: MessageProducer, pub storage: Option, pub push: Option, } impl AppService { /// Send a Web Push notification to a specific user. /// Reads the user's push subscription from `user_notification` table. /// Non-blocking: failures are logged but don't affect the caller. pub fn send_push_to_user(&self, user_id: uuid::Uuid, payload: PushPayload) { let push = self.push.clone(); let db = self.db.clone(); let log = self.logs.clone(); tokio::spawn(async move { if let Some(push) = push { use models::users::user_notification; use sea_orm::EntityTrait; let prefs = user_notification::Entity::find_by_id(user_id) .one(&db) .await; if let Ok(Some(prefs)) = prefs { if prefs.push_enabled && prefs.push_subscription_endpoint.is_some() && prefs.push_subscription_keys_p256dh.is_some() && prefs.push_subscription_keys_auth.is_some() { let endpoint = prefs.push_subscription_endpoint.unwrap(); let p256dh = prefs.push_subscription_keys_p256dh.unwrap(); let auth = prefs.push_subscription_keys_auth.unwrap(); if let Err(e) = push.send(&endpoint, &p256dh, &auth, &payload).await { slog::warn!(log, "WebPush send failed"; "user_id" => %user_id, "error" => %e); } } } else if let Err(e) = prefs { slog::warn!(log, "Failed to read push subscription"; "user_id" => %user_id, "error" => %e); } } }); } } impl AppService { pub async fn start_room_workers( &self, shutdown_rx: tokio::sync::broadcast::Receiver<()>, log: slog::Logger, ) -> anyhow::Result<()> { self.room.start_workers(shutdown_rx, log).await } pub async fn new(config: AppConfig) -> anyhow::Result { let db = AppDatabase::init(&config).await?; let cache = AppCache::init(&config).await?; let log_level = config.log_level().unwrap_or_else(|_| "info".to_string()); let logs = build_logger(&log_level); let email = AppEmail::init(&config, logs.clone()).await?; let avatar = AppAvatar::init(&config).await?; let storage = match AppStorage::new(&config) { Ok(s) => { slog::info!(logs, "Storage initialized at {}", s.base_path.display()); Some(s) } Err(e) => { slog::warn!(logs, "Storage not available: {}", e); None } }; let push = match ( config.vapid_public_key(), config.vapid_private_key(), ) { (Some(public_key), Some(private_key)) => { match WebPushService::new( public_key, private_key, config.vapid_sender_email(), ) { Ok(s) => { slog::info!(logs, "WebPush initialized"); Some(s) } Err(e) => { slog::warn!(logs, "WebPush not available: {}", e); None } } } _ => { slog::warn!(logs, "WebPush disabled — VAPID keys not configured"); None } }; // Build get_redis closure for MessageProducer let get_redis: Arc< dyn Fn() -> tokio::task::JoinHandle> + Send + Sync, > = Arc::new({ let pool = cache.redis_pool().clone(); move || { let pool = pool.clone(); tokio::spawn(async move { pool.get().await.map_err(|e| anyhow::anyhow!("{}", e)) }) } }); let redis_pubsub = Some(RedisPubSub { get_redis: get_redis.clone(), log: logs.clone(), }); let message_producer = MessageProducer::new(get_redis.clone(), redis_pubsub.clone(), 10000, logs.clone()); // Build RoomService let task_service = Arc::new(TaskService::new(db.clone())); let room_metrics = Arc::new(RoomMetrics::default()); let room_manager = Arc::new(room::connection::RoomConnectionManager::new( room_metrics.clone(), )); let redis_url = config .redis_urls() .ok() .and_then(|urls| urls.first().cloned()) .unwrap_or_else(|| "redis://127.0.0.1:6379".to_string()); // Build ChatService if AI is configured; otherwise AI chat is disabled (graceful degradation) let chat_service: Option> = match (config.ai_api_key(), config.ai_basic_url()) { (Ok(api_key), Ok(base_url)) => { slog::info!(logs, "AI chat enabled — connecting to {}", base_url); let cfg = OpenAIConfig::new() .with_api_key(&api_key) .with_api_base(&base_url); let client = async_openai::Client::with_config(cfg); let mut registry = ToolRegistry::new(); git_tools::register_all(&mut registry); file_tools::register_all(&mut registry); project_tools::register_all(&mut registry); Some(Arc::new( ChatService::new(client).with_tool_registry(registry), )) } (Err(e), _) => { slog::warn!(logs, "AI chat disabled — {}", e); None } (_, Err(e)) => { slog::warn!(logs, "AI chat disabled — {}", e); None } }; // Build push notification callback for RoomService let push_fn: Option = push.clone().map(|push_svc| { let db_clone = db.clone(); let log_clone = logs.clone(); Arc::new(move |user_id: uuid::Uuid, title: String, body: Option, url: Option| { let push = push_svc.clone(); let db = db_clone.clone(); let log = log_clone.clone(); let payload = PushPayload { title, body: body.unwrap_or_default(), url, icon: None, }; tokio::spawn(async move { use models::users::user_notification; use sea_orm::EntityTrait; let prefs = user_notification::Entity::find_by_id(user_id) .one(&db) .await; if let Ok(Some(prefs)) = prefs { if prefs.push_enabled && prefs.push_subscription_endpoint.is_some() && prefs.push_subscription_keys_p256dh.is_some() && prefs.push_subscription_keys_auth.is_some() { let endpoint = prefs.push_subscription_endpoint.unwrap(); let p256dh = prefs.push_subscription_keys_p256dh.unwrap(); let auth = prefs.push_subscription_keys_auth.unwrap(); if let Err(e) = push.send(&endpoint, &p256dh, &auth, &payload).await { slog::warn!(log, "WebPush send failed"; "user_id" => %user_id, "error" => %e); } } } }); }) as room::PushNotificationFn }); let room = RoomService::new( db.clone(), cache.clone(), config.clone(), message_producer.clone(), room_manager, redis_url, chat_service, Some(task_service.clone()), logs.clone(), None, push_fn, ); // Build WsTokenService let ws_token = Arc::new(WsTokenService::new(get_redis.clone())); Ok(Self { db, config, cache, email, logs, avatar, room, ws_token, queue_producer: message_producer, storage, push, }) } pub async fn start_email_workers( &self, shutdown_rx: tokio::sync::broadcast::Receiver<()>, ) -> anyhow::Result<()> { let get_redis_fn = self.queue_producer.get_redis.clone(); let get_redis: GetRedis = Arc::new(move || -> RedisFuture { let get_redis_fn = get_redis_fn.clone(); Box::pin(async move { get_redis_fn().await? }) }); let email = self.email.clone(); let logs = self.logs.clone(); let send_fn: EmailSendFn = Arc::new(move |envelopes: Vec| -> EmailSendFut { let email = email.clone(); let logs = logs.clone(); Box::pin(async move { for envelope in envelopes { let to = envelope.to.clone(); let msg = email::EmailMessage { to: envelope.to, subject: envelope.subject, body: envelope.body, }; if let Err(e) = email.send(msg).await { let err = format!("email send failed to:{} error: {}", to, e); slog::error!(logs, "{}", err); } } Ok(()) }) }); start_email_worker(get_redis, send_fn, shutdown_rx, self.logs.clone()).await; Ok(()) } } pub mod agent; pub mod auth; pub mod error; pub mod file_tools; pub mod git; pub mod git_tools; pub mod issue; pub mod project; pub mod project_tools; pub mod pull_request; pub mod search; pub mod skill; pub mod user; pub mod utils; pub mod workspace; pub mod ws_token; #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] pub struct Pager { pub page: i64, pub par_page: i64, }