mod appointment; mod bot; mod db; mod error; mod schema; use std::env::args; 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, Duration, TimeZone, Utc}; use chrono_tz::Europe; use db::ChatInfo; use diesel::result::Error::{self, NotFound}; use diesel::{ BelongingToDsl, Connection, GroupedBy, RunQueryDsl, SelectableHelper, SqliteConnection, }; use diesel::{ExpressionMethods, QueryDsl}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use error::ConfigLoadError; use log::*; use serde::Deserialize; use teloxide::adaptors::Throttle; use teloxide::prelude::RequesterExt; use teloxide::requests::Requester; use teloxide::types::ChatId; use teloxide::{adaptors::throttle::Limits, Bot}; use tokio::time::sleep; use crate::bot::Command; use crate::db::{DbChat, DbReminder}; #[macro_use] extern crate rust_i18n; i18n!("locales"); #[derive(Deserialize, Debug)] pub struct Config { token: String, data_path: String, poll_interval: i64, } impl Config { fn load() -> Result { let env_var_name = "CALENDAR_BOT_CONFIG_FILE"; let default_filename = "./config.yaml"; let path = env::var(env_var_name).unwrap_or_else(|_| { warn!( "Cannot read env var '{}', assuming '{}'", env_var_name, default_filename ); default_filename.to_owned() }); info!("Reading configuration from {}", path); let file = File::open(path).map_err(ConfigLoadError::OpenFailed)?; let reader = BufReader::new(file); serde_yaml::from_reader(reader).map_err(ConfigLoadError::ReadError) } } const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); pub type Database = Arc>; #[tokio::main(flavor = "current_thread")] async fn main() { if let Some(arg1) = args().nth(1) { if arg1 == "commands" { Command::print_commands(); return; } } pretty_env_logger::init(); let config = Config::load().unwrap(); info!("Connecting to database {}", config.data_path); let mut db = SqliteConnection::establish(&config.data_path).unwrap(); db.run_pending_migrations(MIGRATIONS).unwrap(); let db = Arc::new(Mutex::new(db)); let bot = Bot::new(&config.token).throttle(Limits::default()); { let db = db.clone(); let bot = bot.clone(); let poll_duration = Duration::seconds(config.poll_interval); tokio::task::spawn(async move { loop { let now = Utc::now(); let next_appointment = db.lock().await.transaction(|db| { use schema::chats::dsl::*; chats .select(next_appointment_start) .filter(next_appointment_start.is_not_null()) .filter(next_appointment_start.gt(now.timestamp())) .order(next_appointment_start.asc()) .first::>(db) }); let next_appointment = match next_appointment { Err(NotFound) => None, Ok(appointment) => appointment, Err(e) => Err(e).unwrap(), }; let next_appointment = next_appointment.map(|timestamp| Utc.timestamp_opt(timestamp, 0).unwrap()); let sleep_duration = next_appointment .map(|next_appointment| next_appointment - now) .map(|duration| duration) .filter(|duration| *duration < poll_duration) .unwrap_or(poll_duration); sleep(sleep_duration.to_std().unwrap()).await; let result = check_task(&bot, &db).await; if let Err(e) = result { error!("{}\nBacktrace:\n{}", e, e.backtrace()); } } }); } tokio::time::sleep(std::time::Duration::from_secs(10)).await; bot::spawn(bot, db).await; } struct ReminderMessage { time: DateTime, 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, db: &Database) -> Result<()> { let chats = db.lock().await.transaction::<_, Error, _>(|db| { use schema::chats::dsl::*; let db_chats = chats.load::(db)?; let db_reminders: Vec = DbReminder::belonging_to(&db_chats) .select(DbReminder::as_select()) .load(db)?; let reminders_per_chat = db_reminders .grouped_by(&db_chats) .into_iter() .zip(db_chats) .collect::>(); Ok(reminders_per_chat) })?; let now = Utc::now().with_timezone(&Europe::Berlin); for (reminders, chat) in chats { 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 { Some(appointment) => appointment, None => continue, }; let appointment = appointment.with_timezone(&Europe::Berlin); let mut reminder = None; if now >= appointment.start { reminder = Some(ReminderMessage { time: appointment.start, text: t!("messages.starting_now", locale = &chat_info.locale), }); } else { let most_recent_active_reminder = chat_info .reminders .iter() .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 { appointment.start - reminder.delta } }) .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_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 => { 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", }; let reminder_text = t!( 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 .with_timezone(&Europe::London) .format("%H:%M") .to_string() ); reminder = Some(ReminderMessage { time: reminder_date_time, text: reminder_text, }); } } if let Some(reminder) = reminder { if chat_info.last_reminder.is_some() && chat_info.last_reminder.unwrap() >= reminder.time { continue; } bot.send_message(ChatId(chat_info.id), reminder.text) .await?; db.lock().await.transaction(|db| { use schema::chats::dsl::*; diesel::update(chats.filter(telegram_id.eq(chat_info.id))) .set((last_reminder.eq(now.timestamp()),)) .execute(db) })?; } } Ok(()) }