Add reminder functionality
Hal Deployment / build (push) Successful in 5m53s Details
Hal Deployment / deploy (push) Successful in 7s Details

This commit is contained in:
Lucy Bladen 2025-05-04 16:47:43 +01:00
parent e20a327db1
commit ce8836ed9c
13 changed files with 295 additions and 23 deletions

18
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="hal_development@localhost" uuid="e615e80e-8d72-443f-bb35-e5745367f643">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/hal_development</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.resource.type" value="Deployment" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

7
.idea/sqldialects.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/migrations/2025-05-04-131216_create reminder table/up.sql" dialect="GenericSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>

6
Cargo.lock generated
View File

@ -922,10 +922,11 @@ dependencies = [
[[package]]
name = "hal"
version = "0.1.0"
version = "1.0.0"
dependencies = [
"built",
"clap",
"diesel",
"diesel_migrations",
"env_logger",
"log",
@ -1405,8 +1406,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "manifold"
version = "7.0.0"
source = "git+https://code.orbiter-radio.uk/discord/manifold.git#8602c883d3d3146b0ec8258f4986d33203e5e92a"
version = "8.0.0"
dependencies = [
"built",
"chrono",

View File

@ -9,6 +9,7 @@ built = { version = "0.8.0", features = ["git2", "semver", "chrono"] }
[dependencies]
built = { version = "0.8.0", features = ["git2", "semver", "chrono"] }
clap = { version = "4.3.23", features = ["cargo"] }
diesel = { version = "2.1.0", features = ["postgres", "r2d2", "chrono"] }
diesel_migrations = "2.1.0"
env_logger = "0.10.0"
log = "0.4.20"
@ -17,4 +18,3 @@ manifold = { git = "https://code.orbiter-radio.uk/discord/manifold.git" }
poise = { version = "0.5.*", features = [ "cache" ] }
rand = { version = "0.9.1", features = ["std_rng"] }
tokio = { version = "1.16.1", features = ["sync", "macros", "rt-multi-thread"] }

9
diesel.toml Normal file
View File

@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/hal/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId"]
[migrations_directory]
dir = "migrations"

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE IF EXISTS "reminders";

View File

@ -0,0 +1,10 @@
-- Your SQL goes here
CREATE TABLE IF NOT EXISTS "reminders" (
id SERIAL PRIMARY KEY,
cadence TEXT NOT NULL,
target_role BIGINT NOT NULL,
target_channel BIGINT NOT NULL,
time_of_day INTEGER NOT NULL,
message_content TEXT NOT NULL,
next_alert_time BIGINT
);

View File

@ -1,21 +1,62 @@
use manifold::error::{ManifoldError, ManifoldResult};
use manifold::{ManifoldContext, ManifoldData};
use poise::serenity_prelude::RoleId;
use poise::ChoiceParameter;
use poise::serenity_prelude::{ChannelId, RoleId};
use models::reminder::ReminderCadence;
use crate::hal::models;
use crate::hal::models::reminder::Reminder;
#[derive(ChoiceParameter)]
enum ReminderCadence {
Daily,
Weekly,
Monthly,
Yearly,
Weekdaily,
Fortnightly,
FourWeekly,
#[poise::command(slash_command, subcommands("add", "list", "delete"))]
async fn reminder(_ctx: ManifoldContext<'_>) -> ManifoldResult<()> {
Ok(())
}
#[poise::command(slash_command, prefix_command, aliases("r"))]
async fn reminder(_ctx: ManifoldContext<'_>, _target: RoleId, _cadence: ReminderCadence, _tod: u8) -> ManifoldResult<()> {
#[poise::command(slash_command)]
async fn add(ctx: ManifoldContext<'_>, target_role: RoleId, tgt_channel: ChannelId, cadence: ReminderCadence, time_of_day: i32, message_content: String) -> ManifoldResult<()> {
let db = &ctx.data().database;
let nat = Reminder::get_next_alert_time(&cadence, &time_of_day)?;
Reminder {
id: 0,
cadence: cadence.to_string(),
target_role: target_role.into(),
target_channel: tgt_channel.into(),
time_of_day,
message_content: message_content.clone(),
next_alert_time: Some(nat),
}.insert(&db)?;
ctx.reply(format!("Okay, I have set up a {cadence} reminder at {time_of_day} in <#{tgt_channel}> for <@{target_role}> with the message: {message_content}. It will first trigger at <t:{next_alert_time}>.", cadence = cadence, time_of_day = time_of_day, tgt_channel = tgt_channel, target_role = target_role, message_content = &message_content, next_alert_time = nat)).await?;
Ok(())
}
#[poise::command(slash_command)]
async fn delete(ctx: ManifoldContext<'_>, target: i32) -> ManifoldResult<()> {
let db = &ctx.data().database;
Reminder::delete_by_id(&db, target)?;
ctx.reply("Okay. I removed that reminder. I promise.").await?;
Ok(())
}
#[poise::command(slash_command)]
async fn list(ctx: ManifoldContext<'_>) -> ManifoldResult<()> {
let db = &ctx.data().database;
let reminders = Reminder::get_all(&db)?;
ctx.reply(format!("I currently have {count} reminder(s) stored.", count = reminders.len())).await?;
for reminder in reminders {
ctx.reply(format!("Reminder ID: {id} notifies role {target_role} in channel <#{target_channel}> at {time_of_day}:00 (ish) every {cadence}. It will next fire <t:{next_alert_time}>", id = reminder.id, target_role = reminder.target_role, target_channel = reminder.target_channel, time_of_day = reminder.time_of_day, cadence = reminder.cadence, next_alert_time = reminder.next_alert_time.unwrap_or(0))).await?;
}
ctx.reply("That's all the reminders I know about.").await?;
Ok(())
}

View File

@ -1,20 +1,30 @@
use crate::hal::models::reminder::Reminder;
use built::chrono::Utc;
use manifold::error::{ManifoldError, ManifoldResult};
use manifold::events::EventHandler;
use manifold::ManifoldData;
use poise::serenity_prelude::{Context, Ready};
use manifold::{Db, ManifoldData};
use poise::serenity_prelude::{ChannelId, Context, TypeMapKey};
use poise::{async_trait, Event, FrameworkContext};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
pub struct HalHandler {
}
struct HalTimer;
impl TypeMapKey for HalTimer {
type Value = AtomicBool;
}
#[async_trait]
impl EventHandler for HalHandler {
async fn listen(ctx: &Context, framework_ctx: FrameworkContext<'_, ManifoldData, ManifoldError>, event: &Event<'_>) -> ManifoldResult<bool> {
async fn listen(ctx: Context, framework_ctx: FrameworkContext<'_, ManifoldData, ManifoldError>, event: &Event<'_>) -> ManifoldResult<bool> {
match event {
Event::CacheReady { guilds: _guilds } => HalHandler::standard_startup(ctx, framework_ctx).await,
Event::MessageDelete { channel_id: _channel_id, deleted_message_id: _deleted_message_id, guild_id: _guild_id } => HalHandler::message_deleted().await,
Event::MessageUpdate { old_if_available: _old_if_available, new: _new, event: _event } => HalHandler::message_edited().await,
Event::Ready { data_about_bot } => HalHandler::standard_startup(&ctx, &framework_ctx, data_about_bot).await,
_ => Ok(false)
}
}
@ -22,7 +32,35 @@ impl EventHandler for HalHandler {
impl HalHandler {
async fn standard_startup(_ctx: &Context, _framework_ctx: &FrameworkContext<'_, ManifoldData, ManifoldError>, _data_about_bot: &Ready) -> ManifoldResult<bool> {
async fn standard_startup(ctx: Context, framework_ctx: FrameworkContext<'_, ManifoldData, ManifoldError>) -> ManifoldResult<bool> {
let arc_db: Arc<Db> = Arc::new(Db { pool: framework_ctx.user_data().await.database.clone() });
let ctx_clone = Arc::clone(&Arc::new(ctx.clone()));
let mut data = ctx.data.write().await;
let timer_running = data.get_mut::<HalTimer>();
if timer_running.is_none_or(|x| !x.load(Ordering::Relaxed)) {
info!("Timer init.");
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
info!("Waking to check for scheduled actions");
let db: Arc<Db> = Arc::clone(&arc_db);
let curr_time = Utc::now().timestamp();
for mut reminder in Reminder::get_by_next_alert(&db, &curr_time).unwrap() {
_ = ChannelId(reminder.target_channel as u64).say(&ctx_clone, format!("Scheduled notifier for <@&{target_role}>: {message_content}", target_role = &reminder.target_role, message_content = &reminder.message_content)).await;
reminder.next_alert_time = Some(Reminder::get_next_alert_time(&reminder.cadence.clone().into(), &reminder.time_of_day).unwrap());
// Store new alert time
_ = reminder.insert(&db);
}
}
});
} else {
info!("Not initialising timer due to pre-check");
}
Ok(false)
}

View File

@ -1,5 +1,7 @@
mod events;
mod commands;
mod models;
mod schema;
use clap::ArgMatches;
use poise::serenity_prelude::GatewayIntents;

1
src/hal/models/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod reminder;

112
src/hal/models/reminder.rs Normal file
View File

@ -0,0 +1,112 @@
use built::chrono::{Datelike, Weekday};
use built::chrono;
use built::chrono::{Duration, Timelike};
use diesel::dsl::delete;
use diesel::prelude::*;
use diesel::insert_into;
use manifold::Db;
use manifold::error::{ManifoldError, ManifoldResult};
use poise::ChoiceParameter;
use crate::hal::schema::*;
#[derive(ChoiceParameter)]
pub enum ReminderCadence {
Daily,
Weekly,
Weekdaily,
Fortnightly,
FourWeekly,
}
impl From<String> for ReminderCadence {
fn from(s: String) -> Self {
match s.to_lowercase().as_str() {
"daily" => ReminderCadence::Daily,
"weekly" => ReminderCadence::Weekly,
"weekdaily" => ReminderCadence::Weekdaily,
"fortnightly" => ReminderCadence::Fortnightly,
"fourweekly" => ReminderCadence::FourWeekly,
_ => ReminderCadence::Daily,
}
}
}
#[derive(Queryable, Selectable, Identifiable, Insertable, AsChangeset, Debug, PartialEq, Clone)]
#[diesel(table_name = reminders)]
pub struct Reminder {
pub id: i32,
pub cadence: String,
pub target_role: i64,
pub target_channel: i64,
pub time_of_day: i32,
pub message_content: String,
pub next_alert_time: Option<i64>,
}
impl Reminder {
pub fn get_next_alert_time(cadence: &ReminderCadence, time_of_day: &i32) -> ManifoldResult<i64> {
let current_time = chrono::Utc::now();
let delta: i64 = match cadence {
ReminderCadence::Daily => {
let tt = (current_time + Duration::days(1)).with_hour(time_of_day.to_owned() as u32).unwrap().with_minute(0).unwrap().with_second(0).unwrap();
tt.signed_duration_since(current_time).to_std().unwrap().as_secs() as i64
}
ReminderCadence::Weekly => {
let tt = (current_time + Duration::days(7)).with_hour(time_of_day.to_owned() as u32).unwrap().with_minute(0).unwrap().with_second(0).unwrap();
tt.signed_duration_since(current_time).to_std().unwrap().as_secs() as i64
},
ReminderCadence::Weekdaily => {
// if today is friday or saturday bump the delta across the weekend
// this ignores bank holidays and so should you
let days_to_add = match current_time.weekday() {
Weekday::Fri => 3,
Weekday::Sat => 2,
_ => 1,
};
let tt = (current_time + Duration::days(days_to_add)).with_hour(time_of_day.to_owned() as u32).unwrap().with_minute(0).unwrap().with_second(0).unwrap();
tt.signed_duration_since(current_time).to_std().unwrap().as_secs() as i64
}
ReminderCadence::Fortnightly => 1209600,
ReminderCadence::FourWeekly => 2419200,
};
Ok(chrono::Utc::now().timestamp() + delta)
}
pub fn get_by_id(conn: &Db, needle: &i32) -> ManifoldResult<Self> {
Ok(reminders::table
.filter(reminders::id.eq(needle))
.select(Reminder::as_select())
.get_result(&mut conn.get()?)?)
}
pub fn get_all(conn: &Db) -> ManifoldResult<Vec<Self>> {
Ok(reminders::table.load::<Reminder>(&mut conn.get()?)?)
}
pub fn get_by_next_alert(conn: &Db, needle: &i64) -> ManifoldResult<Vec<Self>> {
Ok(reminders::table
.filter(reminders::next_alert_time.le(needle))
.load::<Reminder>(&mut conn.get()?)?)
}
pub fn insert(&self, conn: &Db) -> ManifoldResult<usize> {
insert_into(reminders::table)
.values(self)
.on_conflict(reminders::id)
.do_update()
.set(self)
.execute(&mut conn.get()?)
.map_err(|e| ManifoldError::from(e))
}
pub fn delete_by_id(conn: &Db, needle: i32) -> ManifoldResult<usize> {
Ok(delete(reminders::table)
.filter(reminders::id.eq(needle))
.execute(&mut conn.get()?)?)
}
}

32
src/hal/schema.rs Normal file
View File

@ -0,0 +1,32 @@
// @generated automatically by Diesel CLI.
diesel::table! {
reminders (id) {
id -> Int4,
cadence -> Text,
target_role -> Int8,
target_channel -> Int8,
time_of_day -> Int4,
message_content -> Text,
next_alert_time -> Nullable<Int8>,
}
}
diesel::table! {
userinfo (user_id) {
user_id -> Int8,
username -> Text,
#[max_length = 36]
weather_location -> Nullable<Varchar>,
#[max_length = 1]
weather_units -> Nullable<Varchar>,
#[max_length = 8]
timezone -> Nullable<Varchar>,
last_seen -> Nullable<Int8>,
}
}
diesel::allow_tables_to_appear_in_same_query!(
reminders,
userinfo,
);