Allow multiple reminder dates for a single chat group

This commit is contained in:
Manuel Vögele
2024-01-17 17:02:20 +01:00
parent bc2f647243
commit a4479e6a9d
8 changed files with 160 additions and 73 deletions

View File

@@ -6,5 +6,5 @@ messages:
starting_now: "Jetzt geht's weiter" starting_now: "Jetzt geht's weiter"
errors: errors:
param_no_days: "Bitte gib die Anzahl Tage an, die du vorab erinnert werden möchtest, als Parameter an" invalid_number: "Ungültige Zahl: %{number}"
param_no_days_nonnegative: "Die Anzahl der Tage darf nicht negativ sein" param_no_days_nonnegative: "Die Anzahl der Tage darf nicht negativ sein"

View File

@@ -6,5 +6,5 @@ messages:
starting_now: "The meeting starts now" starting_now: "The meeting starts now"
errors: errors:
param_no_days: "Please specify the number of days that you want to be reminded in advance as parameter" invalid_number: "Invalid number: %{number}"
param_no_days_nonnegative: "The number of days may not be negative" param_no_days_nonnegative: "The number of days may not be negative"

View File

@@ -0,0 +1,5 @@
ALTER TABLE chat ADD remind_days_ahead BIGINT NOT NULL DEFAULT 0;
UPDATE chat SET remind_days_ahead = (SELECT days_ahead FROM reminder WHERE chat_id = chat.id ORDER BY days_ahead ASC LIMIT 1);
DROP TABLE reminder;

View File

@@ -0,0 +1,13 @@
-- This migration extracts "remind days ahead" into a separate table so that a chat can configure mutiple reminders
CREATE TABLE reminder (
id INTEGER PRIMARY KEY NOT NULL,
chat_id INTEGER NOT NULL REFERENCES chat (id) ON DELETE CASCADE,
days_ahead BIGINT NOT NULL
);
CREATE INDEX reminders_chat_id ON reminder (chat_id, days_ahead); -- Having days_ahead as index saves us from taking extra effort for sorted data
INSERT INTO reminder (chat_id, days_ahead) SELECT id, remind_days_ahead FROM chat;
ALTER TABLE chat DROP remind_days_ahead;

View File

@@ -1,3 +1,4 @@
use anyhow::anyhow;
use anyhow::{Error, Result}; use anyhow::{Error, Result};
use chrono::Datelike; use chrono::Datelike;
use chrono::Utc; use chrono::Utc;
@@ -114,13 +115,14 @@ async fn set_calendar(bot: Throttle<Bot>, msg: Message, db: Database) -> Result<
})?; })?;
let mut chat_info = ChatInfo::<Utc> { let mut chat_info = ChatInfo::<Utc> {
db_id: -1,
id: msg.chat.id.0, id: msg.chat.id.0,
calendar: url.to_owned(), calendar: url.to_owned(),
next_appointment: None, next_appointment: None,
last_reminder: None, last_reminder: None,
pinned_message_id: None, pinned_message_id: None,
locale: "de".into(), locale: "de".into(),
remind_days_ahead: 0, remind_days_ahead: vec![],
}; };
fetch_and_announce_appointment(&bot, &mut chat_info, &db).await?; fetch_and_announce_appointment(&bot, &mut chat_info, &db).await?;
@@ -159,28 +161,39 @@ async fn set_remind_days_ahead(
.first::<DbChat>(db) .first::<DbChat>(db)
})?; })?;
let chat = ChatInfo::<Utc>::from(chat); let chat = ChatInfo::<Utc>::from_db(chat, vec![]);
let days_ahead = msg let days =
match msg
.text() .text()
.map(|text| text.splitn(2, " ").nth(1).map(|param| param.parse().ok())) .ok_or_else(|| {
.flatten() anyhow!("Set remind days ahead command didn't receive any text (this should never happen)")
.flatten(); })?
.split(" ")
if days_ahead.is_none() { .skip(1)
.map(|day| day.trim())
.filter(|day| !day.is_empty())
.map(|day| day.parse().map_err(|err| (day, err)))
.collect::<Result<Vec<i64>, _>>()
{
Ok(days_ahead) => days_ahead,
Err((invalid_str, _)) => {
bot.send_message( bot.send_message(
msg.chat.id, msg.chat.id,
t!("errors.param_no_days", locale = &chat.locale), t!(
"errors.invalid_number",
locale = &chat.locale,
number = invalid_str
),
) )
.reply_to_message_id(msg.id) .reply_to_message_id(msg.id)
.send() .send()
.await?; .await?;
return Ok(()); return Ok(());
} }
};
let days_ahead: i64 = days_ahead.unwrap(); if days.iter().any(|day| *day < 0) {
if days_ahead < 0 {
bot.send_message( bot.send_message(
msg.chat.id, msg.chat.id,
t!("errors.param_no_days_nonnegative", locale = &chat.locale), t!("errors.param_no_days_nonnegative", locale = &chat.locale),
@@ -191,13 +204,14 @@ async fn set_remind_days_ahead(
return Ok(()); return Ok(());
} }
db.lock().await.transaction::<_, Error, _>(|db| { db.lock().await.transaction(|db| {
use schema::chat::dsl::*; use schema::reminder::dsl::*;
diesel::update(chat) diesel::delete(reminder.filter(chat_id.eq(chat.db_id))).execute(db)?;
.filter(telegram_id.eq(msg.chat.id.0)) let values = days
.set(remind_days_ahead.eq(days_ahead)) .iter()
.execute(db)?; .map(|days| (chat_id.eq(chat.db_id), days_ahead.eq(days)))
Ok(()) .collect::<Vec<_>>();
diesel::insert_into(reminder).values(&values).execute(db)
})?; })?;
Ok(()) Ok(())
@@ -220,7 +234,7 @@ pub async fn fetch_and_announce_appointment(
.first::<DbChat>(db) .first::<DbChat>(db)
})?; })?;
let entry = ChatInfo::from(entry); let entry = ChatInfo::from_db(entry, vec![]);
let is_new_appointment = entry let is_new_appointment = entry
.next_appointment .next_appointment

View File

@@ -1,33 +1,47 @@
use chrono::{DateTime, TimeZone, Utc}; use chrono::{DateTime, TimeZone, Utc};
use diesel::Queryable; use diesel::{
associations::{Associations, Identifiable},
Queryable, Selectable,
};
use crate::appointment::Appointment; use crate::appointment::Appointment;
use crate::schema::{chat, reminder};
#[derive(Queryable)] #[derive(Queryable, Selectable, Identifiable)]
#[diesel(table_name = chat)]
pub struct DbChat { pub struct DbChat {
_id: i32, id: i32,
telegram_id: i64, telegram_id: i64,
calendar: String, calendar: String,
next_appointment_start: Option<i64>, next_appointment_start: Option<i64>,
next_appointment_end: Option<i64>, next_appointment_end: Option<i64>,
last_reminder: Option<i64>, last_reminder: Option<i64>,
pinned_message: Option<i32>, pinned_message_id: Option<i32>,
locale: Option<String>, locale: Option<String>,
remind_days_ahead: i64, }
#[derive(Queryable, Selectable, Identifiable, Associations)]
#[diesel(belongs_to(DbChat, foreign_key=chat_id))]
#[diesel(table_name = reminder)]
pub struct DbReminder {
id: i32,
chat_id: i32,
days_ahead: i64,
} }
pub struct ChatInfo<Tz: TimeZone> { pub struct ChatInfo<Tz: TimeZone> {
pub db_id: i32,
pub id: i64, pub id: i64,
pub calendar: String, pub calendar: String,
pub next_appointment: Option<Appointment<Tz>>, pub next_appointment: Option<Appointment<Tz>>,
pub last_reminder: Option<DateTime<Tz>>, pub last_reminder: Option<DateTime<Tz>>,
pub pinned_message_id: Option<i32>, pub pinned_message_id: Option<i32>,
pub locale: String, pub locale: String,
pub remind_days_ahead: u64, pub remind_days_ahead: Vec<u64>,
} }
impl From<DbChat> for ChatInfo<Utc> { impl ChatInfo<Utc> {
fn from(db_chat: DbChat) -> Self { pub fn from_db(db_chat: DbChat, db_reminders: Vec<DbReminder>) -> Self {
let next_appointment = db_chat let next_appointment = db_chat
.next_appointment_start .next_appointment_start
// Join appointments into single option // Join appointments into single option
@@ -43,14 +57,18 @@ impl From<DbChat> for ChatInfo<Utc> {
let locale = db_chat.locale.unwrap_or("de".into()); let locale = db_chat.locale.unwrap_or("de".into());
let remind_days_ahead = db_chat.remind_days_ahead.try_into().unwrap_or(0); let remind_days_ahead = db_reminders
.into_iter()
.map(|reminder| reminder.days_ahead.try_into().unwrap_or(0))
.collect();
ChatInfo { ChatInfo {
db_id: db_chat.id,
id: db_chat.telegram_id, id: db_chat.telegram_id,
calendar: db_chat.calendar, calendar: db_chat.calendar,
next_appointment: next_appointment, next_appointment: next_appointment,
last_reminder, last_reminder,
pinned_message_id: db_chat.pinned_message, pinned_message_id: db_chat.pinned_message_id,
locale, locale,
remind_days_ahead, remind_days_ahead,
} }

View File

@@ -14,13 +14,15 @@ use bot::fetch_and_announce_appointment;
use chrono::{DateTime, Days, NaiveTime, TimeZone, Utc}; use chrono::{DateTime, Days, NaiveTime, TimeZone, Utc};
use chrono_tz::Europe; use chrono_tz::Europe;
use db::ChatInfo; use db::ChatInfo;
use diesel::result::Error::NotFound; use diesel::result::Error::{self, NotFound};
use diesel::{Connection, RunQueryDsl, SqliteConnection}; use diesel::{
BelongingToDsl, Connection, GroupedBy, RunQueryDsl, SelectableHelper, SqliteConnection,
};
use diesel::{ExpressionMethods, QueryDsl}; use diesel::{ExpressionMethods, QueryDsl};
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use error::ConfigLoadError; use error::ConfigLoadError;
use log::*; use log::*;
use serde::{de::Error, Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
use teloxide::adaptors::Throttle; use teloxide::adaptors::Throttle;
use teloxide::prelude::RequesterExt; use teloxide::prelude::RequesterExt;
use teloxide::requests::Requester; use teloxide::requests::Requester;
@@ -29,7 +31,7 @@ use teloxide::{adaptors::throttle::Limits, Bot};
use tokio::time::sleep; use tokio::time::sleep;
use crate::bot::Command; use crate::bot::Command;
use crate::db::DbChat; use crate::db::{DbChat, DbReminder};
#[macro_use] #[macro_use]
extern crate rust_i18n; extern crate rust_i18n;
@@ -49,7 +51,7 @@ pub struct Config {
fn deserialize_time<'de, D: Deserializer<'de>>(deserializer: D) -> Result<NaiveTime, D::Error> { fn deserialize_time<'de, D: Deserializer<'de>>(deserializer: D) -> Result<NaiveTime, D::Error> {
let s: String = Deserialize::deserialize(deserializer)?; let s: String = Deserialize::deserialize(deserializer)?;
NaiveTime::parse_from_str(&s, "%H:%M").map_err(D::Error::custom) NaiveTime::parse_from_str(&s, "%H:%M").map_err(serde::de::Error::custom)
} }
impl Config { impl Config {
@@ -146,15 +148,29 @@ struct Reminder<Tz: TimeZone> {
// Checks if the date of the next appointment has changed (and announces if so) // Checks if the date of the next appointment has changed (and announces if so)
// Additionally, checks if it is time for a reminder and sends that reminder if necessary // Additionally, checks if it is time for a reminder and sends that reminder if necessary
async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Result<()> { async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Result<()> {
let chats = db.lock().await.transaction(|db| { let chats = db.lock().await.transaction::<_, Error, _>(|db| {
use schema::chat::dsl::*; use schema::chat::dsl::*;
chat.load::<DbChat>(db) use schema::reminder::dsl::*;
let chats = chat.load::<DbChat>(db)?;
let reminders: Vec<DbReminder> = DbReminder::belonging_to(&chats)
.select(DbReminder::as_select())
.order(days_ahead.asc())
.load(db)?;
let reminders_per_chat = reminders
.grouped_by(&chats)
.into_iter()
.zip(chats)
.collect::<Vec<_>>();
Ok(reminders_per_chat)
})?; })?;
let now = Utc::now().with_timezone(&Europe::Berlin); let now = Utc::now().with_timezone(&Europe::Berlin);
for chat in chats { for (reminders, chat) in chats {
let mut chat_info = ChatInfo::from(chat); let mut chat_info = ChatInfo::from_db(chat, reminders);
fetch_and_announce_appointment(bot, &mut chat_info, db).await?; fetch_and_announce_appointment(bot, &mut chat_info, db).await?;
let appointment = match chat_info.next_appointment { let appointment = match chat_info.next_appointment {
@@ -170,17 +186,24 @@ async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Resu
text: t!("messages.starting_now", locale = &chat_info.locale), text: t!("messages.starting_now", locale = &chat_info.locale),
}); });
} else { } else {
let reminder_day = // This assumes that remind_days_ahead is sorted in ascending order
appointment.start.date_naive() - Days::new(chat_info.remind_days_ahead); let most_recent_active_reminder = chat_info
let reminder_date_time = if chat_info.remind_days_ahead == 0 { .remind_days_ahead
.iter()
.map(|days_ahead| {
let reminder_day = appointment.start.date_naive() - Days::new(*days_ahead);
let reminder_date_time = if *days_ahead == 0 {
reminder_day.and_time(config.reminder_time) reminder_day.and_time(config.reminder_time)
} else { } else {
reminder_day.and_time(config.preceeding_day_reminder_time) reminder_day.and_time(config.preceeding_day_reminder_time)
}; };
let reminder_date_time = reminder_date_time reminder_date_time
.and_local_timezone(now.timezone()) .and_local_timezone(now.timezone())
.unwrap(); .unwrap()
if now >= reminder_date_time { })
.find(|reminder_datetime| now >= *reminder_datetime);
if let Some(reminder_date_time) = most_recent_active_reminder {
// TODO This can have weird effects if it's happenig around midnight, since it's not timezone aware (and may even mix multiple timezones) // TODO This can have weird effects if it's happenig around midnight, since it's not timezone aware (and may even mix multiple timezones)
let remaining_time = appointment.start.date_naive() - now.date_naive(); let remaining_time = appointment.start.date_naive() - now.date_naive();
let remaining_days = remaining_time.num_days(); let remaining_days = remaining_time.num_days();
@@ -203,7 +226,7 @@ async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Resu
reminder = Some(Reminder { reminder = Some(Reminder {
time: reminder_date_time, time: reminder_date_time,
text: reminder_text, text: reminder_text,
}) });
} }
} }

View File

@@ -10,6 +10,20 @@ diesel::table! {
last_reminder -> Nullable<BigInt>, last_reminder -> Nullable<BigInt>,
pinned_message_id -> Nullable<Integer>, pinned_message_id -> Nullable<Integer>,
locale -> Nullable<Text>, locale -> Nullable<Text>,
remind_days_ahead -> BigInt,
} }
} }
diesel::table! {
reminder (id) {
id -> Integer,
chat_id -> Integer,
days_ahead -> BigInt,
}
}
diesel::joinable!(reminder -> chat (chat_id));
diesel::allow_tables_to_appear_in_same_query!(
chat,
reminder,
);