diff --git a/Cargo.lock b/Cargo.lock index f19e85a..5cf29d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "actix-cors" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + [[package]] name = "actix-http" version = "3.11.0" @@ -31,12 +46,15 @@ dependencies = [ "actix-utils", "base64 0.22.1", "bitflags 2.9.1", + "brotli", "bytes", "bytestring", "derive_more", "encoding_rs", + "flate2", "foldhash", "futures-core", + "h2 0.3.26", "http 0.2.12", "httparse", "httpdate", @@ -52,6 +70,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "zstd", ] [[package]] @@ -73,6 +92,7 @@ dependencies = [ "bytestring", "cfg-if", "http 0.2.12", + "regex", "regex-lite", "serde", "tracing", @@ -143,6 +163,7 @@ dependencies = [ "bytes", "bytestring", "cfg-if", + "cookie", "derive_more", "encoding_rs", "foldhash", @@ -155,6 +176,7 @@ dependencies = [ "mime", "once_cell", "pin-project-lite", + "regex", "regex-lite", "serde", "serde_json", @@ -227,6 +249,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -248,6 +285,24 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "api" +version = "0.1.0" +dependencies = [ + "actix-cors", + "actix-web", + "clickhouse_pool", + "config", + "database", + "poise", + "tokio", + "tracing", + "tracing-actix-web", + "utoipa", + "utoipa-actix-web", + "utoipa-scalar", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -412,6 +467,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "brotli" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9991eea70ea4f293524138648e41ee89b0b2b12ddef3b255effa43c8056e0e0d" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.12.0" @@ -491,6 +567,8 @@ version = "1.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -525,11 +603,12 @@ checksum = "93a719913643003b84bd13022b4b7e703c09342cd03b679c4641c7d2e50dc34d" name = "cli" version = "0.1.0" dependencies = [ + "api", "bot", "config", + "cron_scheduler", "database", "tokio", - "tokio-cron-scheduler", "tool_tracing", "tracing", ] @@ -605,6 +684,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -639,6 +729,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cron_scheduler" +version = "0.1.0" +dependencies = [ + "chrono", + "config", + "database", + "poise", + "tokio", + "tokio-cron-scheduler", + "tracing", + "uuid", +] + [[package]] name = "croner" version = "2.1.0" @@ -1478,6 +1582,7 @@ checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", "hashbrown 0.15.3", + "serde", ] [[package]] @@ -1501,6 +1606,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1657,6 +1772,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "mutually_exclusive_features" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94e1e6445d314f972ff7395df2de295fe51b71821694f0b0e1e79c4f12c8577" + [[package]] name = "native-tls" version = "0.2.14" @@ -3212,6 +3333,21 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-actix-web" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2340b7722695166c7fc9b3e3cd1166e7c74fedb9075b8f0c74d3822d2e41caf5" +dependencies = [ + "actix-web", + "mutually_exclusive_features", + "opentelemetry", + "pin-project", + "tracing", + "tracing-opentelemetry", + "uuid", +] + [[package]] name = "tracing-attributes" version = "0.1.28" @@ -3444,6 +3580,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435c6f69ef38c9017b4b4eea965dfb91e71e53d869e896db40d1cf2441dd75c0" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-actix-web" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7eda9c23c05af0fb812f6a177514047331dac4851a2c8e9c4b895d6d826967f" +dependencies = [ + "actix-service", + "actix-web", + "utoipa", +] + +[[package]] +name = "utoipa-gen" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77d306bc75294fd52f3e99b13ece67c02c1a2789190a6f31d32f736624326f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "utoipa-scalar" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +dependencies = [ + "actix-web", + "serde", + "serde_json", + "utoipa", +] + [[package]] name = "uuid" version = "1.16.0" @@ -4129,3 +4311,31 @@ dependencies = [ "quote", "syn 2.0.101", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 425f66c..4b8344f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,8 @@ members = [ 'libs/database', 'libs/bot', 'libs/config', + 'libs/cron_scheduler', + 'libs/api', ] [workspace.dependencies] diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index d5f6453..39ed102 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -9,11 +9,9 @@ tokio = { workspace = true } tracing = { workspace = true } database = { path = "../../libs/database" } tool_tracing = { path = "../../libs/tool_tracing" } -tokio-cron-scheduler = { version = "0.14", features = [ - "tracing-subscriber", - "signal", -] } config = { path = "../../libs/config" } bot = { path = "../../libs/bot" } +cron_scheduler = { path = "../../libs/cron_scheduler" } +api = { path = "../../libs/api" } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index a177e72..727e6ac 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1,11 +1,16 @@ +use std::time::Duration; + +use api::init_api; use bot::start_bot; use config::parse_local_config; +use cron_scheduler::ScheduleJob; use database::{create_manager_and_init, create_pool_manager}; +use tokio::{sync::oneshot, time::sleep}; use tool_tracing::init::init_tracing; use tracing::{error, info}; #[tokio::main] -async fn main() { +async fn main() -> Result<(), ()> { println!(include_str!("banner.art")); let config = parse_local_config(); init_tracing(config.tracing.clone(), config.bot_name.clone()); @@ -19,10 +24,30 @@ async fn main() { } Err(e) => { error!("Failed to create database manager: {}", e); - return; + return Err(()); } }; info!("Database manager initialized successfully"); + let mut cron_scheduler = match ScheduleJob::start_cron_scheduler().await { + Ok(scheduler) => { + info!("Cron scheduler started successfully"); + scheduler + } + Err(_) => { + error!("Failed to start cron scheduler"); + return Err(()); + } + }; + let (tx_bot, rx_bot) = oneshot::channel(); + let http = start_bot(config.clone(), manager.clone(), rx_bot).await; - start_bot(config, manager).await; + let _ = init_api(config, manager, http).await; + + tx_bot.send(()).unwrap_or_else(|_| { + error!("Failed to send shutdown signal to bot"); + }); + cron_scheduler.stop_cron_scheduler().await; + sleep(Duration::from_secs(2)).await; + //process::exit(1); + Ok(()) } diff --git a/libs/api/Cargo.toml b/libs/api/Cargo.toml new file mode 100644 index 0000000..44b323f --- /dev/null +++ b/libs/api/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "api" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { workspace = true } +tracing = { workspace = true } +poise = { workspace = true } + +utoipa-actix-web = "0.1" +actix-web = "4" +actix-cors = "0.7" +utoipa-scalar = { version = "0.3", features = ["actix-web"] } +utoipa = "5" +tracing-actix-web = { version = "0.7", features = ["opentelemetry_0_29"] } + +config = { path = "../../libs/config" } +database = { path = "../../libs/database" } +clickhouse_pool = { path = "../../libs/clickhouse_pool" } + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/libs/api/project.json b/libs/api/project.json new file mode 100644 index 0000000..f21cc4c --- /dev/null +++ b/libs/api/project.json @@ -0,0 +1,43 @@ +{ + "name": "api", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/api/src", + "targets": { + "build": { + "executor": "@monodon/rust:check", + "outputs": [ + "{options.target-dir}" + ], + "options": { + "target-dir": "dist/target/api" + } + }, + "test": { + "cache": true, + "executor": "@monodon/rust:test", + "outputs": [ + "{options.target-dir}" + ], + "options": { + "target-dir": "dist/target/api" + }, + "configurations": { + "production": { + "release": true + } + } + }, + "lint": { + "cache": true, + "executor": "@monodon/rust:lint", + "outputs": [ + "{options.target-dir}" + ], + "options": { + "target-dir": "dist/target/api" + } + } + }, + "tags": [] +} diff --git a/libs/api/src/apidocs.rs b/libs/api/src/apidocs.rs new file mode 100644 index 0000000..e8ecbed --- /dev/null +++ b/libs/api/src/apidocs.rs @@ -0,0 +1,25 @@ +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + info( + title = "Bot API", + description = "API documentation for the Bot application", + version = "1.0.0" + ), + tags( + ( name = "Bot", + description = "Bot related endpoints" + ), + ( name = "User", + description = "User related endpoints" + ), + ( name = "Admin", + description = "Admin related endpoints" + ), + ( name = "Cron", + description = "Cron job related endpoints" + ), + ) +)] +pub struct ApiDocs; diff --git a/libs/api/src/lib.rs b/libs/api/src/lib.rs new file mode 100644 index 0000000..6687c33 --- /dev/null +++ b/libs/api/src/lib.rs @@ -0,0 +1,63 @@ +use actix_cors::Cors; +use actix_web::{dev::Service, http::header, App, HttpServer}; +use apidocs::ApiDocs; +use clickhouse_pool::pool_manager::PoolManager; +use config::Config; +use poise::serenity_prelude::Http; +use std::{net::Ipv4Addr, sync::Arc}; +use tracing::{error, info}; +use tracing_actix_web::{RequestId, TracingLogger}; +use utoipa::OpenApi; +use utoipa_actix_web::AppExt; +use utoipa_scalar::{Scalar, Servable as ScalarServable}; + +pub mod apidocs; + +pub async fn init_api(config: Config, pool: Arc, http: Arc) -> Result<(), ()> { + let port = config.port.clone(); + HttpServer::new(move || { + let cors = Cors::default() + .allow_any_origin() + .allow_any_method() + .allow_any_header() + .max_age(3600); + App::new() + .wrap(cors) + .wrap_fn(|mut req, srv| { + let request_id_asc = req.extract::(); + let fut = srv.call(req); + async move { + let mut res = fut.await?; + let request_id: RequestId = request_id_asc.await.unwrap(); + let request_id_str = format!("{}", request_id); + let headers = res.headers_mut(); + headers.insert( + header::HeaderName::from_static("x-request-id"), + header::HeaderValue::from_str(request_id_str.as_str()).unwrap(), + ); + Ok(res) + } + }) + .wrap(TracingLogger::default()) + .into_utoipa_app() + .openapi(ApiDocs::openapi()) + .app_data(actix_web::web::Data::new(pool.clone())) + .app_data(actix_web::web::Data::new(http.clone())) + .app_data(actix_web::web::Data::new(config.clone())) + .openapi_service(|api| Scalar::with_url("/api/docs", api)) + .into_app() + }) + .bind((Ipv4Addr::UNSPECIFIED, port)) + .map_err(|e| { + error!("Failed to bind API server: {}", e); + () + })? + .run() + .await + .map_err(|e| { + eprintln!("Failed to start API server: {}", e); + () + })?; + info!("Api server stopped"); + Ok(()) +} diff --git a/libs/bot/src/lib.rs b/libs/bot/src/lib.rs index d6415e8..d1d0368 100644 --- a/libs/bot/src/lib.rs +++ b/libs/bot/src/lib.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use clickhouse_pool::pool_manager::PoolManager; use event::event_handler; use poise::serenity_prelude::{GatewayIntents, Http}; +use tokio::sync::oneshot; use tracing::info; use trivia::trivia; use utility::{age::age, help::help, server::servers}; @@ -23,7 +24,11 @@ pub struct Data { pub type Error = Box; pub type Context<'a> = poise::Context<'a, Data, Error>; -pub async fn start_bot(config: Config, datalake_config: Arc) -> Arc { +pub async fn start_bot( + config: Config, + datalake_config: Arc, + rx: oneshot::Receiver<()>, +) -> Arc { let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::DIRECT_MESSAGES | GatewayIntents::MESSAGE_CONTENT @@ -69,6 +74,27 @@ pub async fn start_bot(config: Config, datalake_config: Arc) -> Arc } }; let http = client.http.clone(); - client.start().await.unwrap(); + let shard_manager = client.shard_manager.clone(); + tokio::spawn(async move { + match rx.await { + Ok(_) => { + tracing::info!("Received shutdown signal"); + shard_manager.shutdown_all().await; + tracing::info!("Shutting down bot"); + } + Err(_) => { + tracing::info!("Channel dropped signal"); + shard_manager.shutdown_all().await; + tracing::info!("Shutting down bot"); + } + } + }); + tokio::spawn(async move { + info!("Bot is running..."); + if let Err(why) = client.start_autosharded().await { + tracing::error!("Client error: {why:?}"); + } + info!("Bot is stopped..."); + }); http } diff --git a/libs/cron_scheduler/Cargo.toml b/libs/cron_scheduler/Cargo.toml new file mode 100644 index 0000000..eca852c --- /dev/null +++ b/libs/cron_scheduler/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cron_scheduler" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { workspace = true } +poise = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +database = { path = "../../libs/database" } +config = { path = "../../libs/config" } +tokio-cron-scheduler = { version = "0.14", features = [ + "tracing-subscriber", + "signal", +] } + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/libs/cron_scheduler/project.json b/libs/cron_scheduler/project.json new file mode 100644 index 0000000..e8b8fb6 --- /dev/null +++ b/libs/cron_scheduler/project.json @@ -0,0 +1,43 @@ +{ + "name": "cron_scheduler", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/cron_scheduler/src", + "targets": { + "build": { + "executor": "@monodon/rust:check", + "outputs": [ + "{options.target-dir}" + ], + "options": { + "target-dir": "dist/target/cron_scheduler" + } + }, + "test": { + "cache": true, + "executor": "@monodon/rust:test", + "outputs": [ + "{options.target-dir}" + ], + "options": { + "target-dir": "dist/target/cron_scheduler" + }, + "configurations": { + "production": { + "release": true + } + } + }, + "lint": { + "cache": true, + "executor": "@monodon/rust:lint", + "outputs": [ + "{options.target-dir}" + ], + "options": { + "target-dir": "dist/target/cron_scheduler" + } + } + }, + "tags": [] +} diff --git a/libs/cron_scheduler/src/lib.rs b/libs/cron_scheduler/src/lib.rs new file mode 100644 index 0000000..0fb25aa --- /dev/null +++ b/libs/cron_scheduler/src/lib.rs @@ -0,0 +1,147 @@ +use poise::serenity_prelude::Http; +use std::{ + collections::HashMap, + fmt::{self, Display}, + sync::Arc, +}; +use tokio::sync::RwLock; +use tokio_cron_scheduler::JobScheduler; +use tracing::{error, info, instrument}; +use uuid::Uuid; + +#[derive(Debug)] +pub enum StopScheduleJob { + JobNotFound, + RemoveFailed, +} + +impl Display for StopScheduleJob { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +pub struct ScheduleJob { + pub job_id: Arc>>, + pub scheduler: JobScheduler, +} + +impl Clone for ScheduleJob { + fn clone(&self) -> Self { + ScheduleJob { + job_id: self.job_id.clone(), + scheduler: self.scheduler.clone(), + } + } +} + +impl ScheduleJob { + #[instrument(level = "info")] + pub async fn start_cron_scheduler() -> Result { + let scheduler = JobScheduler::new().await; + let mut future_self = match scheduler { + Ok(scheduler) => ScheduleJob { + job_id: Default::default(), + scheduler, + }, + Err(e) => { + error!("Error starting cron scheduler: {:?}", e); + return Err(()); + } + }; + future_self.scheduler.set_shutdown_handler(Box::new(|| { + Box::pin(async { + info!("Cron scheduler stopped"); + }) + })); + future_self.scheduler.shutdown_on_ctrl_c(); + match future_self.scheduler.start().await { + Ok(_) => { + info!("Cron scheduler started"); + Ok(future_self) + } + Err(e) => { + error!("Error starting cron scheduler: {:?}", e); + Err(()) + } + } + } + + #[instrument(skip(self, _http), level = "info")] + pub async fn load_all_trivial_cron_job(&mut self, _http: &Http) -> Result<(), bool> { + // Load all trivial jobs + Ok(()) + } + + #[instrument(skip(self), level = "info")] + pub async fn stop_cron_scheduler(&mut self) -> &mut Self { + let job_id = self.job_id.write().await; + + for (server_id, channel_id) in job_id.keys() { + match self + .scheduler + .remove(job_id.get(&(*server_id, *channel_id)).unwrap()) + .await + { + Ok(_) => { + info!("Cron job removed"); + } + Err(e) => { + error!("Error removing cron job: {:?}", e); + } + } + } + + drop(job_id); + match self.scheduler.shutdown().await { + Ok(_) => { + info!("Cron scheduler stopped"); + self + } + Err(e) => { + error!("Error stopping cron scheduler: {:?}", e); + self + } + } + } + + #[instrument(skip(self, http), level = "info")] + pub async fn add_trivial_cron_job( + &mut self, + server_id: u64, + channel_id: u64, + cron_expression: String, + http: &Http, + ) -> Result { + let _http = Arc::new(Http::new(http.token())); + // Create a new job with the provided cron expression + Err(()) + } + #[instrument(skip(self), level = "info")] + pub async fn stop_scheduled_job( + &mut self, + server_id: u64, + channel_id: u64, + ) -> Result<(), StopScheduleJob> { + let remove_job = { + let mut job_id = self.job_id.write().await; + job_id.remove(&(server_id, channel_id)) + }; + match remove_job { + Some(job_uid) => match self.scheduler.remove(&job_uid).await { + Ok(_) => { + info!("Cron job removed"); + Ok(()) + } + Err(e) => { + error!("Error removing cron job: {:?}", e); + Err(StopScheduleJob::RemoveFailed) + } + }, + None => { + error!("Cron job not found"); + Err(StopScheduleJob::JobNotFound) + } + } + } +}