diff --git a/.sqlx/query-2c2b2e7ab1850b7c4bf786be23c54398267a395e700f4af69d5f396e208f07d5.json b/.sqlx/query-2c2b2e7ab1850b7c4bf786be23c54398267a395e700f4af69d5f396e208f07d5.json new file mode 100644 index 0000000..20eeafa --- /dev/null +++ b/.sqlx/query-2c2b2e7ab1850b7c4bf786be23c54398267a395e700f4af69d5f396e208f07d5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO kv_store (id, content)\n VALUES ($1, $2)\n ON CONFLICT(id)\n DO UPDATE SET\n content = EXCLUDED.content;", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2c2b2e7ab1850b7c4bf786be23c54398267a395e700f4af69d5f396e208f07d5" +} diff --git a/.sqlx/query-f79d485f20e22f0b3abf69d0f8bdf6e566cd81c6270a1d51988c19a4b205bdc7.json b/.sqlx/query-f79d485f20e22f0b3abf69d0f8bdf6e566cd81c6270a1d51988c19a4b205bdc7.json deleted file mode 100644 index a605f79..0000000 --- a/.sqlx/query-f79d485f20e22f0b3abf69d0f8bdf6e566cd81c6270a1d51988c19a4b205bdc7.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO kv_store (id, content)\n VALUES ($1, $2)\n ON CONFLICT(id)\n DO UPDATE SET\n content = EXCLUDED.content;", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "f79d485f20e22f0b3abf69d0f8bdf6e566cd81c6270a1d51988c19a4b205bdc7" -} diff --git a/Cargo.lock b/Cargo.lock index 5be59cf..3990e03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bazooka-bot" -version = "0.6.3" +version = "0.6.4" dependencies = [ "anyhow", "human-time", diff --git a/Cargo.toml b/Cargo.toml index a408e7b..29c09b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bazooka-bot" -version = "0.6.3" +version = "0.6.4" edition = "2024" publish = false diff --git a/README.md b/README.md index 02f285a..5e50b65 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ This is the rust replacement for the bot [originally](https://github.com/fone-gi -Note: Some checked off items my not be complete only started but at the time of writing (2024-04-07) all are completed. +Note: Some checked off items my not be complete only started but at the time of writing (2025-08-13) all are completed. If they are not there will be TODO's in the code itself. The check off only denotes the start of the feature as then the TODOs are moved into the code as maintaining two places is not sustainable. @@ -53,12 +53,11 @@ It should be scheduled manually each season by an officer. # Nice to have -- [ ] Add ability for owner to download the data files to facilitate testing before uploading a version -- [ ] Setup deploy from CI - https://github.com/shuttle-hq/deploy-action -- [ ] Setup test deployment on shuttle (idle time of about 15 minutes should be good) - - [ ] [Naming](https://docs.shuttle.rs/configuration/project-name) - - [ ] From release notes from 0.40.0 - - Added a --secrets arg to the run and deploy commands that allows you to use a different secrets file than the default +- [x] Track how long the bot has been up or down +- [ ] Keep history of downtime +- [ ] Add ability for owner to download the data +- [ ] Add ability for owner to replace the data (intended to be from something downloaded previously) +- [x] Setup deploy from CI - https://github.com/shuttle-hq/deploy-action - [ ] Add message ID to the trace at ingress - [x] Sanitize input for markdown like `**` for example rn causes problems with bolding the ideas - [ ] Add web page (with info and to wake up bot, would help for test bot that would go to sleep) https://docs.shuttle.rs/templates/tutorials/custom-service diff --git a/src/commands.rs b/src/commands.rs index be38fad..186b16e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -43,13 +43,13 @@ async fn call_to_parent_command(ctx: Context<'_>) -> anyhow::Result<()> { pub fn commands_list() -> Vec> { vec![ - ping(), - help(), general::version(), - uptime(), - unranked(), - schedule(), + help(), + ping(), register(), + schedule(), + unranked(), + uptime(), ] } diff --git a/src/config.rs b/src/config.rs index 7858283..40155f5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,7 @@ use std::{collections::HashSet, fmt::Debug, time::Instant}; use anyhow::Context as _; use poise::serenity_prelude::{ChannelId, GuildId, RoleId, UserId}; use shuttle_runtime::{SecretStore, tokio}; -use tracing::{error, info}; +use tracing::error; use crate::secrets::KeyName; @@ -72,33 +72,7 @@ impl SharedConfig { let key = key.to_string(); let value = serde_json::to_string(value).context("failed to convert to json")?; tokio::spawn(async move { - let query = sqlx::query!( - "\ - INSERT INTO kv_store (id, content) - VALUES ($1, $2) - ON CONFLICT(id) - DO UPDATE SET - content = EXCLUDED.content;", - key, - value - ); - match query.execute(&pool).await { - Ok(query_result) => { - if query_result.rows_affected() == 1 { - info!("Save completed for key: {key}"); - } else { - error!( - ?key, - "Expected 1 row to be affected by save but got: {}", - query_result.rows_affected() - ) - } - } - Err(err_msg) => error!( - ?err_msg, - "Failed to save content for key: {key} to kv store" - ), - } + crate::db::save_kv(&pool, &key, value).await; }); Ok(()) } @@ -107,27 +81,17 @@ impl SharedConfig { &self, key: &str, ) -> T { - let record_opt = match sqlx::query!("SELECT content FROM kv_store where id = $1", key) - .fetch_optional(&self.db_pool) - .await - { - Ok(content) => content, - Err(err_msg) => { - error!(?err_msg, "Failed to get content for key: {key}"); - None - } + let Some(content) = crate::db::load_kv(&self.db_pool, key).await else { + return T::default(); }; - let record = match record_opt { - Some(record) => record, - None => { - info!("No content found in DB for key: {key}"); - return T::default(); - } - }; - match serde_json::from_str(&record.content) { + match serde_json::from_str(&content) { Ok(x) => x, Err(err_msg) => { - error!(?err_msg, ?record.content, "Failed to convert content extracted from the database"); + error!( + ?err_msg, + ?content, + "Failed to convert content extracted from the database" + ); T::default() } } diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..efa17f4 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,48 @@ +use tracing::{debug, error, info}; + +pub async fn save_kv(pool: &sqlx::PgPool, key: &str, value: String) { + let query = sqlx::query!( + "\ +INSERT INTO kv_store (id, content) + VALUES ($1, $2) + ON CONFLICT(id) + DO UPDATE SET + content = EXCLUDED.content;", + key, + value + ); + match query.execute(pool).await { + Ok(query_result) => { + if query_result.rows_affected() == 1 { + debug!("Save completed for key: {key}"); + } else { + error!( + ?key, + "Expected 1 row to be affected by save but got: {}", + query_result.rows_affected() + ) + } + } + Err(err_msg) => error!( + ?err_msg, + "Failed to save content for key: {key} to kv store" + ), + } +} + +pub async fn load_kv(pool: &sqlx::PgPool, key: &str) -> Option { + match sqlx::query!("SELECT content FROM kv_store where id = $1", key) + .fetch_optional(pool) + .await + { + Ok(Some(record)) => Some(record.content), + Ok(None) => { + info!("No content found in DB for key: {key}"); + None + } + Err(err_msg) => { + error!(?err_msg, "Failed to get content for key: {key}"); + None + } + } +} diff --git a/src/heartbeat.rs b/src/heartbeat.rs new file mode 100644 index 0000000..5796ec0 --- /dev/null +++ b/src/heartbeat.rs @@ -0,0 +1,64 @@ +use std::time::Duration; + +use crate::{ + db::{load_kv, save_kv}, + model::schedule::UnixTimestamp, +}; +use human_time::ToHumanTimeString; +use tracing::{error, info}; + +const KEY: &str = "HEARTBEAT"; + +pub fn start_heartbeat(db_pool: sqlx::PgPool) { + shuttle_runtime::tokio::spawn(async move { + info!("Heartbeat started"); + loop { + let timestamp = match UnixTimestamp::now() { + Ok(x) => x, + Err(err) => { + error!(?err, "failed to get timestamp heartbeat stopping"); + break; + } + }; + save_kv(&db_pool, KEY, timestamp.to_db_fmt()).await; + shuttle_runtime::tokio::time::sleep(std::time::Duration::from_secs(60)).await; + } + }); +} + +pub async fn last_heartbeat_info(db_pool: sqlx::PgPool) -> String { + match load_kv(&db_pool, KEY).await { + Some(db_value) => match UnixTimestamp::from_db_fmt(&db_value) { + Ok(last_heartbeat) => { + let Ok(now) = UnixTimestamp::now() else { + return format!( + "Last Heartbeat: {last_heartbeat} but Failed to get current timestamp" + ); + }; + let seconds_since_last_heartbeat = now.0 - last_heartbeat.0; + if seconds_since_last_heartbeat < 0 { + return format!( + "Last heartbeat in the future?! Last heartbeat: {last_heartbeat}, Now: {now}" + ); + } + let Ok(seconds_since_last_heartbeat) = seconds_since_last_heartbeat.try_into() + else { + // Invalid u64 + return format!( + "Invalid u64!!! Seconds since heartbeat: {seconds_since_last_heartbeat}, Last heartbeat: {last_heartbeat}, Now: {now}" + ); + }; + let downtime = Duration::from_secs(seconds_since_last_heartbeat); + format!( + "Downtime: {}\nLast Heartbeat: {last_heartbeat}\nNow: {now}", + downtime.to_human_time_string() + ) + } + Err(err) => { + error!(?err); + "Error Loading Last Heartbeat".to_string() + } + }, + None => "First run".to_string(), + } +} diff --git a/src/lib.rs b/src/lib.rs index 71f40ea..f7a2bf2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,8 @@ pub use self::{ mod commands; mod config; +mod db; +pub mod heartbeat; mod model; mod secrets; diff --git a/src/main.rs b/src/main.rs index f9df4ad..de3609a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use anyhow::Context as _; -use bazooka_bot::{Data, SharedConfig, StartupConfig, commands_list, get_secret_discord_token}; +use bazooka_bot::{ + Data, SharedConfig, StartupConfig, commands_list, get_secret_discord_token, heartbeat, +}; use poise::serenity_prelude::{ClientBuilder, GatewayIntents}; use secrecy::ExposeSecret; use shuttle_runtime::SecretStore; @@ -40,8 +42,8 @@ async fn main( let discord_token = get_secret_discord_token(&secret_store)?; let startup_config = StartupConfig::try_new(&secret_store).context("failed to create setup config")?; - let shared_config = - SharedConfig::try_new(&secret_store, db_pool).context("failed to created shared_config")?; + let shared_config = SharedConfig::try_new(&secret_store, db_pool.clone()) + .context("failed to created shared_config")?; let framework = poise::Framework::builder() .options(poise::FrameworkOptions { @@ -81,7 +83,11 @@ async fn main( } else { error!("Development run detected but no guild ID found so slash commands NOT registered"); } - let connect_msg = format!("{} is connected! Version: {}", ready.user.name, version!()); + let connect_msg = format!( + "{} is connected! Version: {}\n{}", + ready.user.name, version!(), + heartbeat::last_heartbeat_info(db_pool.clone()).await, + ); info!("{connect_msg}"); if let Some(channel) = shared_config.channel_bot_status{ channel.say(ctx, connect_msg).await?; @@ -89,6 +95,7 @@ async fn main( warn!("Not sending connection notification because channel_bot_status not set"); } let data = Data::new(shared_config, ctx.clone()).await; + heartbeat::start_heartbeat(db_pool); info!("END OF SETUP CLOSURE"); Ok(data) }) diff --git a/src/model/schedule.rs b/src/model/schedule.rs index b7d51fd..9bc164d 100644 --- a/src/model/schedule.rs +++ b/src/model/schedule.rs @@ -13,11 +13,33 @@ pub mod protected_ops; pub type ScheduledTaskId = OneBasedId; #[derive(Debug, serde::Serialize, serde::Deserialize, Default, Clone, Copy)] -pub struct UnixTimestamp(i32); +pub struct UnixTimestamp(pub i32); impl UnixTimestamp { pub fn new(value: i32) -> Self { Self(value) } + + pub fn now() -> anyhow::Result { + let seconds_since_epoch = UNIX_EPOCH + .elapsed() + .context("failed to get timestamp. System date before Unix Epoch?")? + .as_secs(); + let seconds_since_epoch: i32 = seconds_since_epoch + .try_into() + .context("failed to convert system time as seconds since epoch into i32")?; + Ok(Self(seconds_since_epoch)) + } + + pub fn to_db_fmt(self) -> String { + self.0.to_string() + } + + pub fn from_db_fmt(value: &str) -> anyhow::Result { + let value = value + .parse::() + .with_context(|| format!("failed to convert {value} into i32 for timestamp"))?; + Ok(Self(value)) + } } impl Display for UnixTimestamp { @@ -99,15 +121,7 @@ impl ScheduledTask { self.task.is_none(), "task should have been aborted already if it existed" ); - let seconds_since_epoch = UNIX_EPOCH - .elapsed() - .context("failed to get timestamp. System date before Unix Epoch?")? - .as_secs(); - let seconds_since_epoch: i32 = seconds_since_epoch - .try_into() - .context("failed to convert system time as seconds since epoch into i32")?; - info!(seconds_since_epoch); - let timestamp_now = UnixTimestamp::new(seconds_since_epoch); + let timestamp_now = UnixTimestamp::now()?; info!("timestamp_now={timestamp_now:?}"); let seconds_to_desired = self.desired_execution_timestamp.0 - timestamp_now.0; info!(seconds_to_desired);