From 86a49d13d069425a9e0f3a9f4405289fd9716bbe Mon Sep 17 00:00:00 2001 From: fmk17 <fmk17@inf.ufpr.br> Date: Wed, 9 Feb 2022 01:38:08 -0300 Subject: [PATCH] Add scheduling for notifications --- .gitignore | 1 + crawler.py | 17 ++-- database.py | 73 +++++++++++++++ main.py | 256 ++++++++++++++++++++++++++++++++++++++++++++++------ 4 files changed, 314 insertions(+), 33 deletions(-) create mode 100644 database.py diff --git a/.gitignore b/.gitignore index d441426..0212e0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env __pycache__ *.log +db diff --git a/crawler.py b/crawler.py index 7f52615..0cb26de 100644 --- a/crawler.py +++ b/crawler.py @@ -6,8 +6,8 @@ import requests import re import itertools -Day = namedtuple('Day', 'date, date_raw, meals') -Meal = namedtuple('Meal', 'name, items') +Day = namedtuple('Day', 'date, date_raw, menus') +Menu = namedtuple('Menu', 'meal, items') class Location(Enum): CENTRAL = 'https://pra.ufpr.br/ru/ru-central/' @@ -15,10 +15,15 @@ class Location(Enum): BOTANICO = 'https://pra.ufpr.br/ru/cardapio-ru-jardim-botanico/' AGRARIAS = 'https://pra.ufpr.br/ru/cardapio-ru-agrarias/' +class Meal(Enum): + BREAKFAST = "CAFÉ DA MANHÃ" + LUNCH = "ALMOÇO" + DINNER = "JANTAR" + cached_update_times = dict() cached_responses = dict() -def get_meals_by_days(location: Location): +def get_menus_by_days(location: Location): global cached_responses global cached_update_times @@ -40,11 +45,11 @@ def get_meals_by_days(location: Location): if date_re is None: break d, m, y = map(int, date_re.groups()) table_children = iter(table.select('td')) - meals = [] + menus = [] for title_node, items in zip(table_children, table_children): - meals.append(Meal(name=title_node.text, items=list(items.stripped_strings))) + menus.append(Menu(meal=Meal(title_node.text), items=list(items.stripped_strings))) days.append( - Day(date=date(y, m, d), date_raw=date_text, meals=meals) + Day(date=date(y, m, d), date_raw=date_text, menus=menus) ) cached_responses[location] = days diff --git a/database.py b/database.py new file mode 100644 index 0000000..20872c5 --- /dev/null +++ b/database.py @@ -0,0 +1,73 @@ +from crawler import Location, Meal +from collections import namedtuple +import sqlite3 + +Schedule = namedtuple('Schedule', 'time, day_week, location, meal, user_id, created_at') + +connection = sqlite3.connect("db", isolation_level=None, check_same_thread=False) + +# Create tables +with connection: + connection.execute(''' + CREATE TABLE IF NOT EXISTS schedule ( + time TEXT NOT NULL, + day_week INT NOT NULL, + location TEXT NOT NULL, + meal TEXT NOT NULL, + user_id INT NOT NULL, + created_at DATETIME NOT NULL + ) + ''') + +def get_schedules_for_user(user_id): + cur = connection.execute(''' + SELECT time, day_week, location, meal, user_id, created_at + FROM schedule + WHERE user_id = ? + ''', (user_id,)) + rows = cur.fetchall() + return [Schedule( + row[0], + row[1], + Location[row[2]], + Meal[row[3]], + row[4], + row[5] + ) for row in rows] + +def insert_schedule(schedule): + connection.execute(''' + INSERT INTO schedule + (time, day_week, location, meal, user_id, created_at) + VALUES + (?, ?, ?, ?, ?, ?) + ''', ( + schedule.time, + schedule.day_week, + schedule.location.name, + schedule.meal.name, + schedule.user_id, + schedule.created_at + )) + +def get_schedules_matching_time(time): + cur = connection.execute(''' + SELECT time, day_week, location, meal, user_id, created_at + FROM schedule + WHERE time = ? and day_week = ? + ''', (time.strftime('%H:%M'), time.weekday())) + rows = cur.fetchall() + return [Schedule( + row[0], + row[1], + Location[row[2]], + Meal[row[3]], + row[4], + row[5] + ) for row in rows] + +def delete_all_schedules_from_user(user_id): + connection.execute(''' + DELETE FROM schedule + WHERE user_id = ? + ''', (user_id,)) diff --git a/main.py b/main.py index 44e14fa..fc79fbf 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,16 @@ #!/usr/bin/env python3 from dotenv import load_dotenv -from telegram import Update, ReplyKeyboardMarkup -from telegram.ext import Updater, CommandHandler, CallbackContext -from crawler import get_meals_by_days, Location +from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton +from telegram.constants import PARSEMODE_HTML +from telegram.ext import Updater, CommandHandler, CallbackContext, CallbackQueryHandler +from crawler import get_menus_by_days, Location, Meal +from database import get_schedules_matching_time, insert_schedule, get_schedules_for_user, Schedule, delete_all_schedules_from_user +from datetime import datetime, timedelta, date +from collections import defaultdict +from copy import deepcopy +from pprint import pformat +from dataclasses import dataclass, field +import json import logging import os @@ -10,35 +18,28 @@ load_dotenv() file_handler = logging.FileHandler('bot.log') stream_handler = logging.StreamHandler() -stream_handler.setLevel(logging.DEBUG) +stream_handler.setLevel(logging.INFO) logging.basicConfig( level=logging.DEBUG, format='%(asctime)s %(levelname)s %(name)s (%(module)s:%(funcName)s:%(lineno)d) %(message)s', - datefmt='%Y-%m-%mT%H:%M:%S', + datefmt='%Y-%m-%dT%H:%M:%S', handlers=[file_handler, stream_handler]) logger = logging.getLogger("bot") def start(update: Update, context: CallbackContext) -> None: - """Sends explanation on how to use the bot.""" - update.message.reply_html(''' - Olá, eu sou o RU UFPR Bot, o robô de <a href="https://gitlab.c3sl.ufpr.br/caad/ru-bot-telegram">código aberto</a> mantido pelo <a href="https://caad.inf.ufpr.br/">CAAD (Centro Acadêmico Alexandre Direne)</a> que te mostra o cardápio do Restaurante Universitário da UFPR! - ''', reply_markup=ReplyKeyboardMarkup( - [ - [ - "/agendar · Agendar envio de cardápio" - ], - [ - "/cardapio_central · Ver o cardápio do RU Central", - "/cardapio_poli · Ver o cardápio do RU Centro Politécnico", - ], - [ - "/cardapio_botanico · Ver o cardápio do RU Jardim Botânico", - "/cardapio_agrarias · Ver o cardápio do RU Agrárias", - ], - ] - )) + update.message.reply_text(''' + Olá, eu sou o RU UFPR Bot, o robô de <a href="https://gitlab.c3sl.ufpr.br/caad/ru-bot-telegram">código aberto</a> mantido pelo <a href="https://caad.inf.ufpr.br/">CAAD (Centro Acadêmico Alexandre Direne)</a> que te mostra o cardápio do Restaurante Universitário da UFPR! Aqui vão os meus comandos: + + - /agendar · Configura notificações de cardápio + - /cardapio_central · Mostra o cardápio do RU Central + - /cardapio_poli · Mostra o cardápio do RU Centro Politécnico + - /cardapio_botanico · Mostra o cardápio do RU Jardim Botânico + - /cardapio_agrarias · Mostra o cardápio do RU Agrárias + ''', parse_mode=PARSEMODE_HTML) + + logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} used /start") COMMAND_TO_LOCATION = { 'cardapio_central': Location.CENTRAL, @@ -57,28 +58,229 @@ LOCATION_TO_HEADER = { def cardapio(update: Update, context: CallbackContext) -> None: command = next(iter(update.message.parse_entities(types=["bot_command"]).values()))[1:] location = COMMAND_TO_LOCATION[command] - days, update_time = get_meals_by_days(location) + days, update_time = get_menus_by_days(location) header = f'<b>{LOCATION_TO_HEADER[location]}</b>' if len(days): body = '\n\n'.join( f'<b>{day.date_raw}</b>\n' + '\n'.join( - f' <b>{meal.name}</b>\n' + '\n'.join(f' {item}' for item in meal.items) for meal in day.meals + f' <b>{menu.meal.value}</b>\n' + '\n'.join(f' {item}' for item in menu.items) for menu in day.menus ) for day in days ) else: body = '<b>Cardápio indisponível</b>' updated_on = f'<i>Atualizado às ' + update_time.strftime('%H:%M:%S') + ' de hoje</i>' - update.message.reply_html(header + '\n\n' + body + '\n\n' + updated_on) + update.message.reply_text(header + '\n\n' + body + '\n\n' + updated_on, parse_mode=PARSEMODE_HTML) logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} used /{command}") +DAY_WEEK_NUMBER_TO_STRING = { + 0: "SEG", 1: "TER", 2: "QUA", 3: "QUI", 4: "SEX", 5: "SÁB", 6: "DOM" +} + +DAY_WEEK_NUMBER_TO_LONG_STRING = { + 0: "Segunda-feira", 1: "Terça-feira", 2: "Quarta-feira", 3: "Quinta-feira", 4: "Sexta-feira", 5: "Sábado", 6: "Domingo" +} + +@dataclass +class AgendarAntesAbertura: + step: int + location: Location = None + days_week_by_meal: dict[Meal, set] = field(default_factory=dict) + +class RemoveTodasNotificacoes: + pass + +def agendar(user_id): + schedules = get_schedules_for_user(user_id) + keyboard = [ + [InlineKeyboardButton('Agendar notificações para antes da abertura...', callback_data=AgendarAntesAbertura(-1))], + #[InlineKeyboardButton('Agendar notificações diárias...', callback_data="agendar_diario")], + #[InlineKeyboardButton('Agendar uma notificação individual...', callback_data="agendar_individual")], + ] + if len(schedules): + keyboard += [ + #[InlineKeyboardButton('Remover uma notificação individual...', callback_data="remover_agendamento_individual")], + [InlineKeyboardButton('Remover todas as notificações...', callback_data=RemoveTodasNotificacoes())], + ] + + keyboard = InlineKeyboardMarkup(keyboard) + if len(schedules): + schedules_by_location = defaultdict(list) + for schedule in schedules: + schedules_by_location[schedule.location].append(schedule) + day_week_by_time_meal_by_location = dict() + for key, schedules in schedules_by_location.items(): + by_time_meal = defaultdict(set) + for schedule in schedules: + by_time_meal[(schedule.time, schedule.meal)].add(schedule.day_week) + day_week_by_time_meal_by_location[key] = by_time_meal + return ( + f'<b>Notificações</b>\n\n' + + '\n\n'.join( + f'<b>{LOCATION_TO_HEADER[location]}</b>\n' + + '\n'.join( + f' {time} · {meal.value} · {", ".join(DAY_WEEK_NUMBER_TO_STRING[day_week] for day_week in day_weeks)}' + for (time, meal), day_weeks in day_week_by_time_meal.items() + ) + for location, day_week_by_time_meal + in day_week_by_time_meal_by_location.items() + ), + keyboard + ) + else: + return ( + f'<b>Notificações</b>\n\nAtualmente você não tem nenhuma notificação agendada.', + keyboard + ) + +def agendar_reply(update: Update, context: CallbackContext) -> None: + body, keyboard = agendar(update.effective_user.id) + update.message.reply_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML) + +def agendar_callback(update: Update, context: CallbackContext) -> None: + body, keyboard = agendar(update.effective_user.id) + update.callback_query.message.edit_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML) + +VALID_WEEK_DAYS_BY_LOCATION = { + Location.CENTRAL: {0, 1, 2, 3, 4, 5, 6}, + Location.POLITECNICO: {0, 1, 2, 3, 4}, +} + +TIME_BEFORE_OPENING_BY_MEAL = { + Meal.BREAKFAST: '06:15', + Meal.LUNCH: '10:30', + Meal.DINNER: '17:00', +} + +def agendar_antes_abertura(update: Update, context: CallbackContext) -> None: + data = update.callback_query.data + if data.step == -1: + buttons = [] + for location, valid_days_week in VALID_WEEK_DAYS_BY_LOCATION.items(): + new_data = deepcopy(data) + new_data.location = location + new_data.step += 1 + for meal, old_days_week in new_data.days_week_by_meal.items(): + new_data.days_week_by_meal[meal] = old_days_week.intersection(valid_days_week) + buttons.append([ + InlineKeyboardButton(LOCATION_TO_HEADER[location], callback_data=new_data) + ]) + + update.callback_query.message.edit_text( + '<b>Notificações</b>\n\nDe qual local você quer receber notificações?', + reply_markup=InlineKeyboardMarkup(buttons), + parse_mode=PARSEMODE_HTML + ) + return + + if data.step == 3: + for meal, days_week in data.days_week_by_meal.items(): + for day_week in days_week: + time = TIME_BEFORE_OPENING_BY_MEAL[meal] + schedule = Schedule(time=time, day_week=day_week, location=data.location, meal=meal, user_id=update.effective_user.id, created_at=datetime.now()) + logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} added schedule {pformat(schedule)}") + insert_schedule(schedule) + body, keyboard = agendar(update.effective_user.id) + update.callback_query.message.edit_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML) + return + + meal_step = list(Meal.__members__.values())[data.step] + if meal_step not in data.days_week_by_meal: + data.days_week_by_meal[meal_step] = { 0, 1, 2, 3, 4 } + days_week_step = data.days_week_by_meal[meal_step] + buttons = [] + for day_week in VALID_WEEK_DAYS_BY_LOCATION[data.location]: + if day_week in days_week_step: + new_data = deepcopy(data) + new_data.days_week_by_meal[meal_step].remove(day_week) + buttons.append([InlineKeyboardButton(f'Remover {DAY_WEEK_NUMBER_TO_LONG_STRING[day_week]}', callback_data=new_data)]) + else: + new_data = deepcopy(data) + new_data.days_week_by_meal[meal_step].add(day_week) + buttons.append([InlineKeyboardButton(f'Adicionar {DAY_WEEK_NUMBER_TO_LONG_STRING[day_week]}', callback_data=new_data)]) + + next_data = deepcopy(data) + next_data.step += 1 + prev_data = deepcopy(data) + prev_data.step -= 1 + update.callback_query.message.edit_text( + f'<b>Notificações</b>\n\n<b>{LOCATION_TO_HEADER[data.location]}</b>\n' + + '\n'.join(f' {meal.value} · {", ".join(DAY_WEEK_NUMBER_TO_STRING[day_week] for day_week in day_weeks)}' + for meal, day_weeks in data.days_week_by_meal.items()) + '\n\n' + + f'Adicione ou remova dias de semana para as notificações do {meal_step.value}, vá para o próximo quando terminar.', + reply_markup=InlineKeyboardMarkup([ + *buttons, + [ + InlineKeyboardButton('« Voltar', callback_data=prev_data), + InlineKeyboardButton('Próximo »', callback_data=next_data) + ], + ]), + parse_mode=PARSEMODE_HTML + ) + +class RemoveTodasNotificacoesOk: + pass + +class Agendar: + pass + +def remover_todas_notificacoes(update: Update, context: CallbackContext) -> None: + update.callback_query.message.edit_text( + '<b>Notificações</b>\n\nVocê tem certeza que quer remover todas as notificações?', + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton('Sim, remover todas as notificações', callback_data=RemoveTodasNotificacoesOk())], + [InlineKeyboardButton('Não, mantenha minhas notificações', callback_data=Agendar())] + ]), + parse_mode=PARSEMODE_HTML + ) + +def remover_todas_notificacoes_ok(update: Update, context: CallbackContext) -> None: + delete_all_schedules_from_user(update.effective_user.id) + body, keyboard = agendar(update.effective_user.id) + update.callback_query.message.edit_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML) + logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} removed all notifications") + +def send_scheduled(context: CallbackContext) -> None: + time = context.job.next_t - timedelta(minutes=EVERY_X_MINUTES) + schedules = get_schedules_matching_time(time) + for schedule in schedules: + days, update_time = get_menus_by_days(schedule.location) + header = f'<b>{LOCATION_TO_HEADER[schedule.location]}</b>' + days = [day for day in days if date.today() == day.date] + if len(days): + day = days[0] + body = next( + f'<b>{day.date_raw}</b>\n<b> {menu.meal.value}</b>\n' + '\n'.join(f' {item}' for item in menu.items) + for menu in day.menus if menu.meal == schedule.meal + ) + else: + body = '<b>Cardápio indisponível</b>' + updated_on = f'<i>Atualizado às ' + update_time.strftime('%H:%M:%S') + ' de hoje</i>' + context.dispatcher.bot.send_message( + schedule.user_id, + header + '\n\n' + body + '\n\n' + updated_on, + parse_mode=PARSEMODE_HTML + ) + logger.info(f"User {update.effective_user.id} {update.effective_user.first_name} {update.effective_user.last_name} {update.effective_user.username} received schedule {pformat(schedule)}") + +EVERY_X_MINUTES = 15 + def main() -> None: logger.info("Starting bot...") - updater = Updater(os.environ['TELEGRAM_BOT_TOKEN']) + updater = Updater(os.environ['TELEGRAM_BOT_TOKEN'], arbitrary_callback_data=True) for command in COMMAND_TO_LOCATION.keys(): updater.dispatcher.add_handler(CommandHandler(command, cardapio)) updater.dispatcher.add_handler(CommandHandler('start', start)) + updater.dispatcher.add_handler(CommandHandler('agendar', agendar_reply)) + updater.dispatcher.add_handler(CallbackQueryHandler(remover_todas_notificacoes, pattern=RemoveTodasNotificacoes)) + updater.dispatcher.add_handler(CallbackQueryHandler(remover_todas_notificacoes_ok, pattern=RemoveTodasNotificacoesOk)) + updater.dispatcher.add_handler(CallbackQueryHandler(agendar_callback, pattern=Agendar)) + updater.dispatcher.add_handler(CallbackQueryHandler(agendar_antes_abertura, pattern=AgendarAntesAbertura)) + tm = datetime.now() + tm = tm - timedelta(minutes=tm.minute % EVERY_X_MINUTES, seconds=tm.second, microseconds=tm.microsecond) + tm = tm + timedelta(minutes=EVERY_X_MINUTES) + updater.dispatcher.job_queue.run_repeating(send_scheduled, timedelta(minutes=EVERY_X_MINUTES), first=tm) updater.start_polling() logger.info("Connected") updater.idle() -- GitLab