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

12
Cargo.lock generated
View File

@@ -53,7 +53,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a941c39708478e8eea39243b5983f1c42d2717b3620ee91f4a52115fd02ac43f" checksum = "a941c39708478e8eea39243b5983f1c42d2717b3620ee91f4a52115fd02ac43f"
dependencies = [ dependencies = [
"itertools", "itertools 0.9.0",
"proc-macro-error", "proc-macro-error",
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -195,6 +195,7 @@ dependencies = [
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"ical", "ical",
"itertools 0.12.0",
"log", "log",
"once_cell", "once_cell",
"pretty_env_logger", "pretty_env_logger",
@@ -920,6 +921,15 @@ dependencies = [
"either", "either",
] ]
[[package]]
name = "itertools"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25db6b064527c5d482d0423354fcd07a89a2dfe07b67892e62411946db7f07b0"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.10" version = "1.0.10"

View File

@@ -15,6 +15,7 @@ chrono-tz = "0.8.5"
diesel = { version = "2.0.2", features = ["chrono", "sqlite"] } diesel = { version = "2.0.2", features = ["chrono", "sqlite"] }
diesel_migrations = "2.0.0" diesel_migrations = "2.0.0"
ical = { version = "0.10.0", features = ["ical"], default-features = false } ical = { version = "0.10.0", features = ["ical"], default-features = false }
itertools = "0.12.0"
log = "0.4.17" log = "0.4.17"
once_cell = "1.17.0" once_cell = "1.17.0"
pretty_env_logger = "0.5.0" pretty_env_logger = "0.5.0"
@@ -26,7 +27,7 @@ serde = { version = "1.0.145", features = ["derive"] }
serde_yaml = "0.9.13" serde_yaml = "0.9.13"
strum = { version = "0.25.0", features = ["derive"] } strum = { version = "0.25.0", features = ["derive"] }
teloxide = { version = "0.12.2", features = ["macros", "throttle"] } teloxide = { version = "0.12.2", features = ["macros", "throttle"] }
thiserror = "1.0.37" thiserror = "1.0.56"
tokio = { version = "1.21.2", features = ["macros"] } tokio = { version = "1.21.2", features = ["macros"] }
[package.metadata.i18n] [package.metadata.i18n]

View File

@@ -1,10 +1,31 @@
messages: messages:
next_appointment: "Nächster Termin: %{weekday}, %{date}" next_appointment: "Nächster Termin: %{weekday}, %{date}"
appointment_hours:
one: "In 1 Stunde geht's weiter"
other: "In %{no_hours} Stunden geht's weiter"
appointment_today: "Heute um %{start_time} Uhr geht's weiter" appointment_today: "Heute um %{start_time} Uhr geht's weiter"
appointment_tomorrow: "Morgen um %{start_time} Uhr geht's weiter" appointment_tomorrow: "Morgen um %{start_time} Uhr geht's weiter"
appointment_soon: "In %{no_days} Tagen um %{start_time} Uhr geht's weiter" appointment_soon: "In %{no_days} Tagen um %{start_time} Uhr geht's weiter"
starting_now: "Jetzt geht's weiter" starting_now: "Jetzt geht's weiter"
reminders_deleted: "Die Erinnerungen wurden gelöscht"
reminders_set: "Die folgenden Erinnerungen wurden gesetzt:\n\n"
reminders:
delta: "%{delta_text} bevor der Termin beginnt"
thisday_delta: "Am Tag des Termins"
time: " um %{time} Uhr"
days:
one: "1 Tag"
other: "%{delta} Tage"
hours:
one: "1 Stunde"
other: "%{delta} Stunden"
errors: errors:
invalid_number: "Ungültige Zahl: %{number}" cannot_mix_delete: "Der Parameter <code>delete</code> kann nicht mit anderen parameter vermischt werden"
param_no_days_nonnegative: "Die Anzahl der Tage darf nicht negativ sein" delta_zero: "Die Anzahl Tage/Stunden darf nur null sein, wenn eine Uhrzeit angegeben wird, zu der die Erinnerung erfolgen soll"
invalid_reminder_format: "Die Erinnerungen haben ein ungültiges Format"
invalid_time: "Die angegebene Uhrzeit ist ungültig"
help:
setreminders: "Mit setreminders können Erinnerungen vor beginn des nächsten Termins festgelegt werden. Beim Ausführen des Kommandos werden alle bestehenden Erinnerungen gelöscht und die als Parameter übergebenen Erinnerungen gesetzt. Es es können mehrere Erinnerungen mit einem Leerzeichen getrennt übergeben werden. Um alle Erinnerungen zu löschen, sende <code>delete</code> als Parameter.\n\nBeispiele für Erinnerungen:\n\n<code>0d@11:30</code> Es erfolgt eine Erinnerung am Tag des Termins um 11:30 Uhr.\n\n<code>2d@10</code> Es erfolgt eine Erinnerung zwei Tage vor dem Termin um 10 Uhr.\n\n<code>1d</code> Es erfolgt eine Erinnerung 1 Tag (24 Stunden) bevor der Termin beginnt.\n\n<code>1h</code> Es erfolgt eine Erinnerung 1 Stunde bevor der Termin beginnt.\n\n<code>4d@12:00 2h</code>Es erfolgt eine Erinnerung 4 Tage vor Beginn des Termins um 12:00 Uhr. Zusätzlich erfolgt eine weitere Erinnerung 4 Stunden vor Terminstart."

View File

@@ -1,10 +1,31 @@
messages: messages:
next_appointment: "Next meeting: %{weekday}, %{date} (%{uk_time} UK time)" next_appointment: "Next meeting: %{weekday}, %{date} (%{uk_time} UK time)"
appointment_hours:
one: "Next meeting starts in 1 hour"
other: "Next meeting starts in %{no_hours} hours"
appointment_today: "Next meeting is today at %{start_time} (%{uk_time} UK time)" appointment_today: "Next meeting is today at %{start_time} (%{uk_time} UK time)"
appointment_tomorrow: "Next meeting is tomorrow at %{start_time} (%{uk_time} UK time)" appointment_tomorrow: "Next meeting is tomorrow at %{start_time} (%{uk_time} UK time)"
appointment_soon: "Next meeting is in %{no_days} days at %{start_time} (%{uk_time} UK time)" appointment_soon: "Next meeting is in %{no_days} days at %{start_time} (%{uk_time} UK time)"
starting_now: "The meeting starts now" starting_now: "The meeting starts now"
reminders_deleted: "The reminders have been deleted"
reminders_set: "The following reminders have been set:\n\n"
reminders:
delta: "%{delta_text} before the meeting starts"
thisday_delta: "At the day of the event"
time: " at %{time}"
days:
one: "1 day"
other: "%{delta} days"
hours:
one: "1 hour"
other: "%{delta} hours"
errors: errors:
invalid_number: "Invalid number: %{number}" cannot_mix_delete: "The parameter <code>delete</code> cannot be mixed with other parameters"
param_no_days_nonnegative: "The number of days may not be negative" delta_zero: "The number of days/hours is only allowed to be zero if a reminder time is specified"
invalid_reminder_format: "The reminder format is invalid"
invalid_time: "The specified time is invalid"
help:
setreminders: "setreminders allows you to configure reminders that will be sent before the start of the next appointment. When you use this command, all configured commands will be deleted and the reminders passed as parameters will be set. You can configure multiple reminders by sending them as a list separate by a space. To delete all reminders, send <code>delete</code> als Parameter.\n\nExample reminders:\n\n<code>0d@11:30</code> Sends a reminder at 11:30 on the day of the appointment.\n\n<code>2d@10</code> Sends a reminder two days before the appointment at 10:00.\n\n<code>1d</code> Sends a reminder one day (24 hours) before the apopintment starts.\n\n<code>1h</code> Sends a reminder one hour before the apopintment starts.\n\n<code>4d@12:00 2h</code>Sends a reminder 4 days bere the meeintg starts at 12:00. Addintally sends a reminder 2 hours before the meeting starts."

View File

@@ -0,0 +1,7 @@
DELETE FROM reminders WHERE reminder_time == NULL;
ALTER TABLE reminders DROP reminder_time;
ALTER TABLE reminders RENAME reminder_delta_hours TO days_ahead;
-- Convert days_ahead from hours to days
UPDATE reminders SET days_ahead = days_ahead / 24;

View File

@@ -0,0 +1,7 @@
ALTER TABLE reminders RENAME days_ahead TO reminder_delta_hours;
ALTER TABLE reminders ADD reminder_time TEXT;
-- Convert reminder_delta_hours from days to hours
UPDATE reminders SET reminder_delta_hours = reminder_delta_hours * 24;
UPDATE reminders SET reminder_time='10:00';

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use anyhow::anyhow; use anyhow::anyhow;
use anyhow::{Error, Result}; use anyhow::{Error, Result};
use chrono::Datelike; use chrono::Datelike;
@@ -7,6 +9,7 @@ use diesel::Connection;
use diesel::ExpressionMethods; use diesel::ExpressionMethods;
use diesel::QueryDsl; use diesel::QueryDsl;
use diesel::RunQueryDsl; use diesel::RunQueryDsl;
use itertools::Itertools;
use strum::Display; use strum::Display;
use strum::EnumIter; use strum::EnumIter;
use strum::IntoEnumIterator; use strum::IntoEnumIterator;
@@ -28,8 +31,8 @@ use teloxide::utils::command::BotCommands;
use teloxide::Bot; use teloxide::Bot;
use crate::appointment::fetch_next_appointment; use crate::appointment::fetch_next_appointment;
use crate::db::ChatInfo; use crate::db::{ChatInfo, Reminder};
use crate::db::DbChat; use crate::db::{DbChat, ParseReminderError};
use crate::{schema, Database}; use crate::{schema, Database};
#[derive(BotCommands, Clone, EnumIter, Display)] #[derive(BotCommands, Clone, EnumIter, Display)]
@@ -38,7 +41,7 @@ pub enum Command {
#[command()] #[command()]
SetCalendar, SetCalendar,
SetLocale, SetLocale,
RemindDaysAhead, SetReminders,
} }
impl Command { impl Command {
@@ -49,8 +52,8 @@ impl Command {
"Specify an URL to an ical file from which this bot will poll for events" "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::SetLocale => "Choose between the languages \"de\" and \"en\"",
Command::RemindDaysAhead => { Command::SetReminders => {
"Choose how many days ahead of the event you'd like to be reminded" "Configure reminders ahead of the event. Use 'help' as parameter for more information"
} }
}; };
println!("{} - {}", command.to_string().to_lowercase(), description); println!("{} - {}", command.to_string().to_lowercase(), description);
@@ -70,7 +73,7 @@ fn build_handler_chain() -> UpdateHandler<Error> {
let command_handler = teloxide::filter_command::<Command, _>() let command_handler = teloxide::filter_command::<Command, _>()
.branch(case![Command::SetCalendar].endpoint(set_calendar)) .branch(case![Command::SetCalendar].endpoint(set_calendar))
.branch(case![Command::SetLocale].endpoint(set_locale)) .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); 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, last_reminder: None,
pinned_message_id: None, pinned_message_id: None,
locale: "de".into(), locale: "de".into(),
remind_days_ahead: vec![], reminders: vec![],
}; };
fetch_and_announce_appointment(&bot, &mut chat_info, &db).await?; 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(()) Ok(())
} }
async fn set_remind_days_ahead( async fn set_reminders(bot: Throttle<Bot>, msg: Message, db: Database) -> Result<(), Error> {
bot: Throttle<Bot>,
msg: Message,
db: Database,
) -> Result<(), Error> {
let chat = db.lock().await.transaction(|db| { let chat = db.lock().await.transaction(|db| {
use schema::chats::dsl::*; use schema::chats::dsl::*;
chats chats
@@ -162,31 +161,69 @@ async fn set_remind_days_ahead(
.first::<DbChat>(db) .first::<DbChat>(db)
})?; })?;
let chat = ChatInfo::<Utc>::from_db(chat, vec![]); let chat = ChatInfo::<Utc>::from_db(chat, vec![])?;
let days = let params = msg
match msg
.text() .text()
.ok_or_else(|| { .ok_or_else(|| {
anyhow!("Set remind days ahead command didn't receive any text (this should never happen)") anyhow!("Set reminders command didn't receive any text (this should never happen)")
})? })?
.split(" ") .split(" ")
.skip(1) .skip(1)
.map(|day| day.trim()) .map(|day| day.trim())
.filter(|day| !day.is_empty()) .filter(|day| !day.is_empty())
.map(|day| day.parse().map_err(|err| (day, err))) .collect::<Vec<_>>();
.collect::<Result<Vec<i64>, _>>()
{ if params.len() == 0 || params.contains(&"help") {
Ok(days_ahead) => days_ahead, bot.send_message(msg.chat.id, t!("help.setreminders", locale = &chat.locale))
Err((invalid_str, _)) => { .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( bot.send_message(
msg.chat.id, msg.chat.id,
t!( t!("errors.cannot_mix_delete", locale = &chat.locale),
"errors.invalid_number",
locale = &chat.locale,
number = invalid_str
),
) )
.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()
.await?;
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) .reply_to_message_id(msg.id)
.send() .send()
.await?; .await?;
@@ -194,28 +231,35 @@ async fn set_remind_days_ahead(
} }
}; };
if days.iter().any(|day| *day < 0) { let db_reminders = reminders
bot.send_message( .iter()
msg.chat.id, .map(|reminder| reminder.to_db(chat.db_id))
t!("errors.param_no_days_nonnegative", locale = &chat.locale), .collect::<Vec<_>>();
)
db.lock().await.transaction(|db| {
use schema::reminders::dsl::*;
diesel::delete(reminders.filter(chat_id.eq(chat.db_id))).execute(db)?;
diesel::insert_into(reminders)
.values(db_reminders)
.execute(db)
})?;
#[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) .reply_to_message_id(msg.id)
.send() .send()
.await?; .await?;
return Ok(()); return Ok(());
}
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)
})?;
Ok(())
} }
pub async fn fetch_and_announce_appointment( pub async fn fetch_and_announce_appointment(
@@ -236,7 +280,7 @@ pub async fn fetch_and_announce_appointment(
.first::<DbChat>(db) .first::<DbChat>(db)
})?; })?;
let entry = ChatInfo::from_db(entry, vec![]); let entry = ChatInfo::from_db(entry, vec![])?;
let is_new_appointment = entry let is_new_appointment = entry
.next_appointment .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::{ use diesel::{
associations::{Associations, Identifiable}, associations::{Associations, Identifiable},
prelude::Insertable,
Queryable, Selectable, Queryable, Selectable,
}; };
use once_cell::sync::Lazy;
use regex::Regex;
use thiserror::Error;
use crate::appointment::Appointment; use crate::appointment::Appointment;
use crate::schema::{chats, reminders}; use crate::schema::{chats, reminders};
const TIME_FORMAT: &str = "%H:%M";
#[derive(Queryable, Selectable, Identifiable)] #[derive(Queryable, Selectable, Identifiable)]
#[diesel(table_name = chats)] #[diesel(table_name = chats)]
pub struct DbChat { pub struct DbChat {
@@ -26,7 +34,135 @@ pub struct DbChat {
pub struct DbReminder { pub struct DbReminder {
id: i32, id: i32,
chat_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> { pub struct ChatInfo<Tz: TimeZone> {
@@ -37,11 +173,14 @@ pub struct ChatInfo<Tz: TimeZone> {
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: Vec<u64>, pub reminders: Vec<Reminder>,
} }
impl ChatInfo<Utc> { 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 let next_appointment = db_chat
.next_appointment_start .next_appointment_start
// Join appointments into single option // Join appointments into single option
@@ -57,12 +196,12 @@ impl 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_reminders let reminders = db_reminders
.into_iter() .into_iter()
.map(|reminder| reminder.days_ahead.try_into().unwrap_or(0)) .map(Reminder::try_from)
.collect(); .collect::<Result<_, _>>()?;
ChatInfo { Ok(ChatInfo {
db_id: db_chat.id, db_id: db_chat.id,
id: db_chat.telegram_id, id: db_chat.telegram_id,
calendar: db_chat.calendar, calendar: db_chat.calendar,
@@ -70,7 +209,7 @@ impl ChatInfo<Utc> {
last_reminder, last_reminder,
pinned_message_id: db_chat.pinned_message_id, pinned_message_id: db_chat.pinned_message_id,
locale, locale,
remind_days_ahead, reminders,
} })
} }
} }

View File

@@ -5,13 +5,12 @@ mod error;
mod schema; mod schema;
use std::env::args; use std::env::args;
use std::time::Duration;
use std::{env, fs::File, io::BufReader, sync::Arc}; use std::{env, fs::File, io::BufReader, sync::Arc};
use anyhow::Result; use anyhow::Result;
use async_mutex::Mutex; use async_mutex::Mutex;
use bot::fetch_and_announce_appointment; use bot::fetch_and_announce_appointment;
use chrono::{DateTime, Days, NaiveTime, TimeZone, Utc}; use chrono::{DateTime, Duration, TimeZone, Utc};
use chrono_tz::Europe; use chrono_tz::Europe;
use db::ChatInfo; use db::ChatInfo;
use diesel::result::Error::{self, NotFound}; use diesel::result::Error::{self, NotFound};
@@ -22,7 +21,7 @@ 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::{Deserialize, Deserializer}; use serde::Deserialize;
use teloxide::adaptors::Throttle; use teloxide::adaptors::Throttle;
use teloxide::prelude::RequesterExt; use teloxide::prelude::RequesterExt;
use teloxide::requests::Requester; use teloxide::requests::Requester;
@@ -42,16 +41,7 @@ i18n!("locales");
pub struct Config { pub struct Config {
token: String, token: String,
data_path: String, data_path: String,
poll_interval: u64, poll_interval: i64,
#[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)
} }
impl Config { impl Config {
@@ -96,7 +86,7 @@ async fn main() {
{ {
let db = db.clone(); let db = db.clone();
let bot = bot.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 { tokio::task::spawn(async move {
loop { loop {
let now = Utc::now(); let now = Utc::now();
@@ -122,13 +112,13 @@ async fn main() {
let sleep_duration = next_appointment let sleep_duration = next_appointment
.map(|next_appointment| next_appointment - now) .map(|next_appointment| next_appointment - now)
.map(|duration| duration.to_std().unwrap()) .map(|duration| duration)
.filter(|duration| *duration < poll_duration) .filter(|duration| *duration < poll_duration)
.unwrap_or(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 { if let Err(e) = result {
error!("{}\nBacktrace:\n{}", e, e.backtrace()); 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; bot::spawn(bot, db).await;
} }
struct Reminder<Tz: TimeZone> { struct ReminderMessage<Tz: TimeZone> {
time: DateTime<Tz>, time: DateTime<Tz>,
text: String, text: String,
} }
// 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>, db: &Database) -> Result<()> {
let chats = db.lock().await.transaction::<_, Error, _>(|db| { let chats = db.lock().await.transaction::<_, Error, _>(|db| {
use schema::chats::dsl::*; use schema::chats::dsl::*;
use schema::reminders::dsl::*;
let db_chats = chats.load::<DbChat>(db)?; let db_chats = chats.load::<DbChat>(db)?;
let db_reminders: Vec<DbReminder> = DbReminder::belonging_to(&db_chats) let db_reminders: Vec<DbReminder> = DbReminder::belonging_to(&db_chats)
.select(DbReminder::as_select()) .select(DbReminder::as_select())
.order(days_ahead.asc())
.load(db)?; .load(db)?;
let reminders_per_chat = db_reminders 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); let now = Utc::now().with_timezone(&Europe::Berlin);
for (reminders, chat) in chats { 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?; fetch_and_announce_appointment(bot, &mut chat_info, db).await?;
let appointment = match chat_info.next_appointment { 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; let mut reminder = None;
if now >= appointment.start { if now >= appointment.start {
reminder = Some(Reminder { reminder = Some(ReminderMessage {
time: appointment.start, time: appointment.start,
text: t!("messages.starting_now", locale = &chat_info.locale), text: t!("messages.starting_now", locale = &chat_info.locale),
}); });
} else { } else {
// This assumes that remind_days_ahead is sorted in ascending order
let most_recent_active_reminder = chat_info let most_recent_active_reminder = chat_info
.remind_days_ahead .reminders
.iter() .iter()
.map(|days_ahead| { .map(|reminder| {
let reminder_day = appointment.start.date_naive() - Days::new(*days_ahead); if let Some(reminder_time) = reminder.time {
let reminder_date_time = if *days_ahead == 0 { let reminder_day = appointment.start.date_naive() - reminder.delta;
reminder_day.and_time(config.reminder_time) let reminder_date_time = reminder_day.and_time(reminder_time);
} else {
reminder_day.and_time(config.preceeding_day_reminder_time)
};
reminder_date_time reminder_date_time
.and_local_timezone(now.timezone()) .and_local_timezone(now.timezone())
.unwrap() .unwrap()
} else {
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 { 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_days = appointment.start.date_naive() - now.date_naive();
let remaining_days = remaining_time.num_days(); 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 { 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", 1 => "messages.appointment_tomorrow",
_ => "messages.appointment_soon", _ => "messages.appointment_soon",
}; };
@@ -217,6 +216,7 @@ async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Resu
message_id, message_id,
locale = &chat_info.locale, locale = &chat_info.locale,
no_days = remaining_days, no_days = remaining_days,
no_hours = remaining_hours,
start_time = &appointment.start.format("%H:%M").to_string(), start_time = &appointment.start.format("%H:%M").to_string(),
uk_time = &appointment uk_time = &appointment
.start .start
@@ -224,7 +224,7 @@ async fn check_task(bot: &Throttle<Bot>, config: &Config, db: &Database) -> Resu
.format("%H:%M") .format("%H:%M")
.to_string() .to_string()
); );
reminder = Some(Reminder { reminder = Some(ReminderMessage {
time: reminder_date_time, time: reminder_date_time,
text: reminder_text, text: reminder_text,
}); });

View File

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