Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

This file was deleted.

2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bazooka-bot"
version = "0.6.3"
version = "0.6.4"
edition = "2024"
publish = false

Expand Down
13 changes: 6 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This is the rust replacement for the bot [originally](https://github.com/fone-gi

<!-- Leave completed items as a feature list / what is being considered for implementation -->

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.

Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ async fn call_to_parent_command(ctx: Context<'_>) -> anyhow::Result<()> {

pub fn commands_list() -> Vec<poise::Command<Data, anyhow::Error>> {
vec![
ping(),
help(),
general::version(),
uptime(),
unranked(),
schedule(),
help(),
ping(),
register(),
schedule(),
unranked(),
uptime(),
]
}

Expand Down
56 changes: 10 additions & 46 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(())
}
Expand All @@ -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()
}
}
Expand Down
48 changes: 48 additions & 0 deletions src/db.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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
}
}
}
64 changes: 64 additions & 0 deletions src/heartbeat.rs
Original file line number Diff line number Diff line change
@@ -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(),
}
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ pub use self::{

mod commands;
mod config;
mod db;
pub mod heartbeat;
mod model;
mod secrets;

Expand Down
15 changes: 11 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -81,14 +83,19 @@ 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?;
} else{
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)
})
Expand Down
34 changes: 24 additions & 10 deletions src/model/schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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<Self> {
let value = value
.parse::<i32>()
.with_context(|| format!("failed to convert {value} into i32 for timestamp"))?;
Ok(Self(value))
}
}

impl Display for UnixTimestamp {
Expand Down Expand Up @@ -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);
Expand Down
Loading