Allow granular per-chat reminder configuration

This commit is contained in:
2024-02-11 10:30:20 +01:00
parent d338f89a15
commit 454bcf5307
10 changed files with 359 additions and 108 deletions

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use anyhow::anyhow;
use anyhow::{Error, Result};
use chrono::Datelike;
@@ -7,6 +9,7 @@ use diesel::Connection;
use diesel::ExpressionMethods;
use diesel::QueryDsl;
use diesel::RunQueryDsl;
use itertools::Itertools;
use strum::Display;
use strum::EnumIter;
use strum::IntoEnumIterator;
@@ -28,8 +31,8 @@ use teloxide::utils::command::BotCommands;
use teloxide::Bot;
use crate::appointment::fetch_next_appointment;
use crate::db::ChatInfo;
use crate::db::DbChat;
use crate::db::{ChatInfo, Reminder};
use crate::db::{DbChat, ParseReminderError};
use crate::{schema, Database};
#[derive(BotCommands, Clone, EnumIter, Display)]
@@ -38,7 +41,7 @@ pub enum Command {
#[command()]
SetCalendar,
SetLocale,
RemindDaysAhead,
SetReminders,
}
impl Command {
@@ -49,8 +52,8 @@ impl Command {
"Specify an URL to an ical file from which this bot will poll for events"
}
Command::SetLocale => "Choose between the languages \"de\" and \"en\"",
Command::RemindDaysAhead => {
"Choose how many days ahead of the event you'd like to be reminded"
Command::SetReminders => {
"Configure reminders ahead of the event. Use 'help' as parameter for more information"
}
};
println!("{} - {}", command.to_string().to_lowercase(), description);
@@ -70,7 +73,7 @@ fn build_handler_chain() -> UpdateHandler<Error> {
let command_handler = teloxide::filter_command::<Command, _>()
.branch(case![Command::SetCalendar].endpoint(set_calendar))
.branch(case![Command::SetLocale].endpoint(set_locale))
.branch(case![Command::RemindDaysAhead].endpoint(set_remind_days_ahead));
.branch(case![Command::SetReminders].endpoint(set_reminders));
let my_chat_member_handler = Update::filter_my_chat_member().endpoint(handle_my_chat_member);
@@ -122,7 +125,7 @@ async fn set_calendar(bot: Throttle<Bot>, msg: Message, db: Database) -> Result<
last_reminder: None,
pinned_message_id: None,
locale: "de".into(),
remind_days_ahead: vec![],
reminders: vec![],
};
fetch_and_announce_appointment(&bot, &mut chat_info, &db).await?;
@@ -150,11 +153,7 @@ async fn set_locale(_bot: Throttle<Bot>, msg: Message, db: Database) -> Result<(
Ok(())
}
async fn set_remind_days_ahead(
bot: Throttle<Bot>,
msg: Message,
db: Database,
) -> Result<(), Error> {
async fn set_reminders(bot: Throttle<Bot>, msg: Message, db: Database) -> Result<(), Error> {
let chat = db.lock().await.transaction(|db| {
use schema::chats::dsl::*;
chats
@@ -162,42 +161,48 @@ async fn set_remind_days_ahead(
.first::<DbChat>(db)
})?;
let chat = ChatInfo::<Utc>::from_db(chat, vec![]);
let chat = ChatInfo::<Utc>::from_db(chat, vec![])?;
let days =
match msg
.text()
.ok_or_else(|| {
anyhow!("Set remind days ahead command didn't receive any text (this should never happen)")
})?
.split(" ")
.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(
msg.chat.id,
t!(
"errors.invalid_number",
locale = &chat.locale,
number = invalid_str
),
)
.reply_to_message_id(msg.id)
.send()
.await?;
return Ok(());
}
};
let params = msg
.text()
.ok_or_else(|| {
anyhow!("Set reminders command didn't receive any text (this should never happen)")
})?
.split(" ")
.skip(1)
.map(|day| day.trim())
.filter(|day| !day.is_empty())
.collect::<Vec<_>>();
if days.iter().any(|day| *day < 0) {
if params.len() == 0 || params.contains(&"help") {
bot.send_message(msg.chat.id, t!("help.setreminders", locale = &chat.locale))
.parse_mode(teloxide::types::ParseMode::Html)
.reply_to_message_id(msg.id)
.send()
.await?;
return Ok(());
}
if params.len() > 1 && params.contains(&"delete") {
bot.send_message(
msg.chat.id,
t!("errors.param_no_days_nonnegative", locale = &chat.locale),
t!("errors.cannot_mix_delete", locale = &chat.locale),
)
.parse_mode(teloxide::types::ParseMode::Html)
.reply_to_message_id(msg.id)
.send()
.await?;
return Ok(());
}
if params.len() == 1 && params[0] == "delete" {
db.lock().await.transaction(|db| {
use schema::reminders::dsl::*;
diesel::delete(reminders.filter(chat_id.eq(chat.db_id))).execute(db)
})?;
bot.send_message(
msg.chat.id,
t!("messages.reminders_deleted", locale = &chat.locale),
)
.reply_to_message_id(msg.id)
.send()
@@ -205,17 +210,56 @@ async fn set_remind_days_ahead(
return Ok(());
}
let reminders = params
.into_iter()
.map(Reminder::from_str)
.collect::<Result<Vec<_>, _>>();
let reminders = match reminders {
Ok(reminders) => reminders,
Err(e) => {
let reminder_text = match e {
ParseReminderError::InvalidReminderFormat => "errors.invalid_reminder_format",
ParseReminderError::InvalidTime => "errors.invalid_time",
ParseReminderError::DeltaZero => "errors.delta_zero",
};
bot.send_message(msg.chat.id, t!(reminder_text, locale = &chat.locale))
.reply_to_message_id(msg.id)
.send()
.await?;
return Ok(());
}
};
let db_reminders = reminders
.iter()
.map(|reminder| reminder.to_db(chat.db_id))
.collect::<Vec<_>>();
db.lock().await.transaction(|db| {
use schema::reminders::dsl::*;
diesel::delete(reminders.filter(chat_id.eq(chat.db_id))).execute(db)?;
let values = days
.iter()
.map(|days| (chat_id.eq(chat.db_id), days_ahead.eq(days)))
.collect::<Vec<_>>();
diesel::insert_into(reminders).values(&values).execute(db)
diesel::insert_into(reminders)
.values(db_reminders)
.execute(db)
})?;
Ok(())
#[allow(unstable_name_collisions)]
let reminders_text = reminders
.iter()
.map(|reminder| String::from("- ") + &reminder.to_localized_str(&chat.locale))
.intersperse("\n".to_string())
.collect::<String>();
let text = t!("messages.reminders_set", locale = &chat.locale) + &reminders_text;
bot.send_message(msg.chat.id, text)
.reply_to_message_id(msg.id)
.send()
.await?;
return Ok(());
}
pub async fn fetch_and_announce_appointment(
@@ -236,7 +280,7 @@ pub async fn fetch_and_announce_appointment(
.first::<DbChat>(db)
})?;
let entry = ChatInfo::from_db(entry, vec![]);
let entry = ChatInfo::from_db(entry, vec![])?;
let is_new_appointment = entry
.next_appointment

159
src/db.rs
View File

@@ -1,12 +1,20 @@
use chrono::{DateTime, TimeZone, Utc};
use std::str::FromStr;
use chrono::{DateTime, Duration, NaiveTime, TimeZone, Utc};
use diesel::{
associations::{Associations, Identifiable},
prelude::Insertable,
Queryable, Selectable,
};
use once_cell::sync::Lazy;
use regex::Regex;
use thiserror::Error;
use crate::appointment::Appointment;
use crate::schema::{chats, reminders};
const TIME_FORMAT: &str = "%H:%M";
#[derive(Queryable, Selectable, Identifiable)]
#[diesel(table_name = chats)]
pub struct DbChat {
@@ -26,7 +34,135 @@ pub struct DbChat {
pub struct DbReminder {
id: i32,
chat_id: i32,
days_ahead: i64,
reminder_delta_hours: i64,
reminder_time: Option<String>,
}
#[derive(Insertable)]
#[diesel(table_name = reminders)]
pub struct NewDbReminder {
chat_id: i32,
reminder_delta_hours: i64,
reminder_time: Option<String>,
}
pub struct Reminder {
pub delta: Duration,
pub time: Option<NaiveTime>,
}
impl Reminder {
pub fn to_localized_str<S: AsRef<str>>(&self, locale: S) -> String {
let delta_mode;
let delta;
if self.delta.num_hours() % 24 == 0 {
delta_mode = "days";
delta = self.delta.num_days();
} else {
delta_mode = "hours";
delta = self.delta.num_hours();
};
let pluralization = if delta == 1 { "one" } else { "other" };
let delta_string = t!(
&format!("reminders.{}.{}", delta_mode, pluralization),
locale = locale.as_ref(),
delta = delta,
);
let message_key = if self.delta.num_hours() > 0 {
"reminders.delta"
} else {
"reminders.thisday_delta"
};
let mut result = t!(
message_key,
locale = locale.as_ref(),
delta_text = &delta_string,
);
if let Some(time) = self.time {
result += &t!(
"reminders.time",
locale = locale.as_ref(),
time = time.format(TIME_FORMAT),
);
}
result
}
pub fn to_db(&self, chat_id: i32) -> NewDbReminder {
let reminder_time = self.time.map(|time| time.format(TIME_FORMAT).to_string());
let reminder_delta_hours = self.delta.num_hours();
NewDbReminder {
chat_id,
reminder_delta_hours,
reminder_time,
}
}
}
static RE_REMINDER: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(\d+)([hHdD])(?:@(\d{1,2})(?:[:](\d{2}))?)?$").unwrap());
#[derive(Error, Debug)]
pub enum ParseReminderError {
#[error("The reminder format is invalid")]
InvalidReminderFormat,
#[error("The time is invalid")]
InvalidTime,
#[error("The delta is not allowed to be zero")]
DeltaZero,
}
impl FromStr for Reminder {
type Err = ParseReminderError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// TODO Better error messages
let captures = RE_REMINDER
.captures(s)
.ok_or(ParseReminderError::InvalidReminderFormat)?;
let mut delta = captures.get(1).unwrap().as_str().parse().unwrap();
if captures.get(2).unwrap().as_str().to_lowercase() == "d" {
delta *= 24;
} else {
// Setting a fixed time is not allowed in hour-mode
if captures.get(3).is_some() {
return Err(ParseReminderError::InvalidReminderFormat);
}
}
let time = captures
.get(3)
.map(|capture| {
let hours = capture.as_str().parse().unwrap();
let minutes = captures
.get(4)
.map(|minutes| minutes.as_str().parse().unwrap())
.unwrap_or(0);
NaiveTime::from_hms_opt(hours, minutes, 0).ok_or(ParseReminderError::InvalidTime)
})
.transpose()?;
if delta == 0 && time.is_none() {
return Err(ParseReminderError::DeltaZero);
}
let delta = Duration::hours(delta);
Ok(Self { delta, time })
}
}
impl TryFrom<DbReminder> for Reminder {
type Error = chrono::ParseError;
fn try_from(db_reminder: DbReminder) -> Result<Self, Self::Error> {
let time = db_reminder
.reminder_time
.as_ref()
.map(|s| NaiveTime::parse_from_str(s, TIME_FORMAT))
.transpose()?;
let delta = Duration::hours(db_reminder.reminder_delta_hours);
Ok(Self { delta, time })
}
}
pub struct ChatInfo<Tz: TimeZone> {
@@ -37,11 +173,14 @@ pub struct ChatInfo<Tz: TimeZone> {
pub last_reminder: Option<DateTime<Tz>>,
pub pinned_message_id: Option<i32>,
pub locale: String,
pub remind_days_ahead: Vec<u64>,
pub reminders: Vec<Reminder>,
}
impl ChatInfo<Utc> {
pub fn from_db(db_chat: DbChat, db_reminders: Vec<DbReminder>) -> Self {
pub fn from_db(
db_chat: DbChat,
db_reminders: Vec<DbReminder>,
) -> Result<Self, chrono::ParseError> {
let next_appointment = db_chat
.next_appointment_start
// Join appointments into single option
@@ -57,12 +196,12 @@ impl ChatInfo<Utc> {
let locale = db_chat.locale.unwrap_or("de".into());
let remind_days_ahead = db_reminders
let reminders = db_reminders
.into_iter()
.map(|reminder| reminder.days_ahead.try_into().unwrap_or(0))
.collect();
.map(Reminder::try_from)
.collect::<Result<_, _>>()?;
ChatInfo {
Ok(ChatInfo {
db_id: db_chat.id,
id: db_chat.telegram_id,
calendar: db_chat.calendar,
@@ -70,7 +209,7 @@ impl ChatInfo<Utc> {
last_reminder,
pinned_message_id: db_chat.pinned_message_id,
locale,
remind_days_ahead,
}
reminders,
})
}
}

View File

@@ -5,13 +5,12 @@ mod error;
mod schema;
use std::env::args;
use std::time::Duration;
use std::{env, fs::File, io::BufReader, sync::Arc};
use anyhow::Result;
use async_mutex::Mutex;
use bot::fetch_and_announce_appointment;
use chrono::{DateTime, Days, NaiveTime, TimeZone, Utc};
use chrono::{DateTime, Duration, TimeZone, Utc};
use chrono_tz::Europe;
use db::ChatInfo;
use diesel::result::Error::{self, NotFound};
@@ -22,7 +21,7 @@ use diesel::{ExpressionMethods, QueryDsl};
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use error::ConfigLoadError;
use log::*;
use serde::{Deserialize, Deserializer};
use serde::Deserialize;
use teloxide::adaptors::Throttle;
use teloxide::prelude::RequesterExt;
use teloxide::requests::Requester;
@@ -42,16 +41,7 @@ i18n!("locales");
pub struct Config {
token: String,
data_path: String,
poll_interval: u64,
#[serde(deserialize_with = "deserialize_time")]
reminder_time: NaiveTime,
#[serde(deserialize_with = "deserialize_time")]
preceeding_day_reminder_time: NaiveTime,
}
fn deserialize_time<'de, D: Deserializer<'de>>(deserializer: D) -> Result<NaiveTime, D::Error> {
let s: String = Deserialize::deserialize(deserializer)?;
NaiveTime::parse_from_str(&s, "%H:%M").map_err(serde::de::Error::custom)
poll_interval: i64,
}
impl Config {
@@ -96,7 +86,7 @@ async fn main() {
{
let db = db.clone();
let bot = bot.clone();
let poll_duration = Duration::from_secs(config.poll_interval);
let poll_duration = Duration::seconds(config.poll_interval);
tokio::task::spawn(async move {
loop {
let now = Utc::now();
@@ -122,13 +112,13 @@ async fn main() {
let sleep_duration = next_appointment
.map(|next_appointment| next_appointment - now)
.map(|duration| duration.to_std().unwrap())
.map(|duration| duration)
.filter(|duration| *duration < poll_duration)
.unwrap_or(poll_duration);
sleep(sleep_duration).await;
sleep(sleep_duration.to_std().unwrap()).await;
let result = check_task(&bot, &config, &db).await;
let result = check_task(&bot, &db).await;
if let Err(e) = result {
error!("{}\nBacktrace:\n{}", e, e.backtrace());
}
@@ -136,27 +126,25 @@ async fn main() {
});
}
tokio::time::sleep(Duration::from_secs(10)).await;
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
bot::spawn(bot, db).await;
}
struct Reminder<Tz: TimeZone> {
struct ReminderMessage<Tz: TimeZone> {
time: DateTime<Tz>,
text: String,
}
// 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
async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Result<()> {
async fn check_task(bot: &Throttle<Bot>, db: &Database) -> Result<()> {
let chats = db.lock().await.transaction::<_, Error, _>(|db| {
use schema::chats::dsl::*;
use schema::reminders::dsl::*;
let db_chats = chats.load::<DbChat>(db)?;
let db_reminders: Vec<DbReminder> = DbReminder::belonging_to(&db_chats)
.select(DbReminder::as_select())
.order(days_ahead.asc())
.load(db)?;
let reminders_per_chat = db_reminders
@@ -171,7 +159,7 @@ async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Resu
let now = Utc::now().with_timezone(&Europe::Berlin);
for (reminders, chat) in chats {
let mut chat_info = ChatInfo::from_db(chat, reminders);
let mut chat_info = ChatInfo::from_db(chat, reminders)?;
fetch_and_announce_appointment(bot, &mut chat_info, db).await?;
let appointment = match chat_info.next_appointment {
@@ -182,34 +170,45 @@ async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Resu
let mut reminder = None;
if now >= appointment.start {
reminder = Some(Reminder {
reminder = Some(ReminderMessage {
time: appointment.start,
text: t!("messages.starting_now", locale = &chat_info.locale),
});
} else {
// This assumes that remind_days_ahead is sorted in ascending order
let most_recent_active_reminder = chat_info
.remind_days_ahead
.reminders
.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)
.map(|reminder| {
if let Some(reminder_time) = reminder.time {
let reminder_day = appointment.start.date_naive() - reminder.delta;
let reminder_date_time = reminder_day.and_time(reminder_time);
reminder_date_time
.and_local_timezone(now.timezone())
.unwrap()
} else {
reminder_day.and_time(config.preceeding_day_reminder_time)
};
reminder_date_time
.and_local_timezone(now.timezone())
.unwrap()
appointment.start - reminder.delta
}
})
.find(|reminder_datetime| now >= *reminder_datetime);
.filter(|reminder_datetime| now >= *reminder_datetime)
.max();
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)
let remaining_time = appointment.start.date_naive() - now.date_naive();
let remaining_days = remaining_time.num_days();
let remaining_days = appointment.start.date_naive() - now.date_naive();
let remaining_days = remaining_days.num_days();
let remaining_hours = appointment.start - now;
// Add 15 Minutes to ensure we aren't reporting too few hours because we're missing a few minutes (or even seconds) due to a delay by the bot
let remaining_hours = (remaining_hours + Duration::minutes(15)).num_hours();
let message_id = match remaining_days {
0 => "messages.appointment_today",
0 => {
if remaining_hours > 6 {
"messages.appointment_today"
} else if remaining_hours == 1 {
"messages.appointment_hours.one"
} else {
"messages.appointment_hours.other"
}
}
1 => "messages.appointment_tomorrow",
_ => "messages.appointment_soon",
};
@@ -217,6 +216,7 @@ async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Resu
message_id,
locale = &chat_info.locale,
no_days = remaining_days,
no_hours = remaining_hours,
start_time = &appointment.start.format("%H:%M").to_string(),
uk_time = &appointment
.start
@@ -224,7 +224,7 @@ async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Resu
.format("%H:%M")
.to_string()
);
reminder = Some(Reminder {
reminder = Some(ReminderMessage {
time: reminder_date_time,
text: reminder_text,
});

View File

@@ -17,7 +17,8 @@ diesel::table! {
reminders (id) {
id -> Integer,
chat_id -> Integer,
days_ahead -> BigInt,
reminder_delta_hours -> BigInt,
reminder_time -> Nullable<Text>,
}
}