From 5790d86e631821f4d4a047ef46752b273284d508 Mon Sep 17 00:00:00 2001
From: fmk17 <fmk17@inf.ufpr.br>
Date: Sun, 13 Feb 2022 18:31:25 -0300
Subject: [PATCH] Refactor code

---
 .pre-commit-config.yaml |  29 ++
 crawler.py              | 105 ++++---
 database.py             | 405 +++++++++++++++++++-----
 main.py                 | 673 ++++++++++++++++++++++++++++------------
 model.py                |  95 ++++++
 pyproject.toml          |   5 +
 seed.py                 | 130 ++++++++
 setup.cfg               |   2 +
 8 files changed, 1135 insertions(+), 309 deletions(-)
 create mode 100644 .pre-commit-config.yaml
 create mode 100644 model.py
 create mode 100644 pyproject.toml
 create mode 100644 seed.py
 create mode 100644 setup.cfg

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..f79bcf3
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,29 @@
+repos:
+  # Fixes the spaces
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v4.1.0
+    hooks:
+      - id: end-of-file-fixer
+      - id: trailing-whitespace
+  # Black formats the Python code
+  - repo: https://github.com/psf/black
+    rev: 22.1.0
+    hooks:
+      - id: black
+  # Flake8 lints the Python code
+  - repo: https://github.com/pycqa/flake8
+    rev: 4.0.1
+    hooks:
+      - id: flake8
+  # isort sorts the imports
+  - repo: https://github.com/PyCQA/isort
+    rev: 5.10.1
+    hooks:
+    - id: isort
+      name: isort (python)
+  # mypy checks types
+  - repo: https://github.com/pre-commit/mirrors-mypy
+    rev: 'v0.931'
+    hooks:
+    - id: mypy
+      additional_dependencies: [types-requests==2.27.9]
diff --git a/crawler.py b/crawler.py
index 9a74b31..1c45f67 100644
--- a/crawler.py
+++ b/crawler.py
@@ -1,57 +1,92 @@
+import re
 from collections import namedtuple
 from datetime import date, datetime, timedelta
-from bs4 import BeautifulSoup
-from enum import Enum
+from typing import Dict, Set
+
 import requests
-import re
-import itertools
+from bs4 import BeautifulSoup
 
-Day = namedtuple('Day', 'date, date_raw, menus')
-Menu = namedtuple('Menu', 'meal, items')
+from model import Location, MenuItem, MenuItemIndicator
 
-class Location(Enum):
-    CENTRAL = 'https://pra.ufpr.br/ru/ru-central/'
-    POLITECNICO = 'https://pra.ufpr.br/ru/ru-centro-politecnico/'
-    BOTANICO = 'https://pra.ufpr.br/ru/cardapio-ru-jardim-botanico/'
-    AGRARIAS = 'https://pra.ufpr.br/ru/cardapio-ru-agrarias/'
+Menu = namedtuple("Menu", "meal_name, items")
+Day = namedtuple("Day", "date, date_raw, menus")
+LocationDays = namedtuple("LocationDays", "days, location, update_datetime")
 
-class Meal(Enum):
-    BREAKFAST = "CAFÉ DA MANHÃ"
-    LUNCH = "ALMOÇO"
-    DINNER = "JANTAR"
+cached_update_times: Dict[Location, datetime] = dict()
+cached_responses: Dict[Location, LocationDays] = dict()
 
-cached_update_times = dict()
-cached_responses = dict()
 
-def get_menus_by_days(location: Location):
+def get_location_days(location: Location) -> LocationDays:
     global cached_responses
     global cached_update_times
 
-    if location in cached_update_times and cached_update_times[location] + timedelta(minutes=5) > datetime.now():
-        return (cached_responses[location], cached_update_times[location])
+    if (
+        location in cached_update_times
+        and cached_update_times[location] + timedelta(minutes=5)
+        > datetime.now()
+    ):
+        return cached_responses[location]
 
-    response = requests.get(location.value)
-    soup = BeautifulSoup(response.text, 'lxml')
+    response = requests.get(location.url)
+    soup = BeautifulSoup(response.text, "lxml")
 
-    post = soup.select_one('#post div:nth-child(3)')
+    post = soup.select_one("#post div:nth-child(3)")
     post_children = iter(post.children)
-    next(post_children)
+    post_children = (node for node in post_children if node.text.strip() != "")
 
     days = []
 
-    for date_node, _, table, _ in zip(post_children, post_children, post_children, post_children):
+    for date_node, table_node in zip(post_children, post_children):
         date_text = date_node.text
-        date_re = re.search(r'(\d{1,2})\/(\d{1,2})\/(\d{4})', date_text)
-        if date_re is None: break
+        date_re = re.search(r"(\d{1,2})\/(\d{1,2})\/(\d{4})", date_text)
+        if date_re is None:
+            break
         d, m, y = map(int, date_re.groups())
-        table_children = iter(table.select('td'))
+        table_children = iter(table_node.select("td"))
         menus = []
-        for title_node, items in zip(table_children, table_children):
-            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, menus=menus)
-        )
+        for title_node, item_nodes in zip(table_children, table_children):
+            items = []
+            item_name = None
+            item_indicators: Set[MenuItemIndicator] = set()
+            for child in item_nodes:
+                if child.name == "br" and item_name is not None:
+                    items.append(
+                        MenuItem(name=item_name, indicators=item_indicators)
+                    )
+                    item_name = None
+                    item_indicators = set()
+                elif child.name == "a":
+                    if not child.select_one("img"):
+                        continue
+                    href = child["href"]
+                    if "Vegano" in href:
+                        item_indicators.add(MenuItemIndicator.VEGAN)
+                    elif "Gluten" in href:
+                        item_indicators.add(MenuItemIndicator.GLUTEN)
+                    elif "Leite" in href:
+                        item_indicators.add(MenuItemIndicator.LACTOSE)
+                    elif "animal" in href:
+                        item_indicators.add(MenuItemIndicator.ANIMAL)
+                    elif "Ovo" in href:
+                        item_indicators.add(MenuItemIndicator.EGG)
+                    elif "Mel" in href:
+                        item_indicators.add(MenuItemIndicator.HONEY)
+                    elif "Alergenicos" in href:
+                        item_indicators.add(MenuItemIndicator.ALERGIC)
+                elif child.text.strip() != "":
+                    item_name = child.text.strip()
+            if item_name is not None:
+                items.append(
+                    MenuItem(name=item_name, indicators=item_indicators)
+                )
+            menus.append(Menu(meal_name=title_node.text, items=items))
+        days.append(Day(date=date(y, m, d), date_raw=date_text, menus=menus))
 
-    cached_responses[location] = days
     cached_update_times[location] = datetime.now()
-    return (days, cached_update_times[location])
+    location_days = LocationDays(
+        days=days,
+        location=location,
+        update_datetime=cached_update_times[location],
+    )
+    cached_responses[location] = location_days
+    return location_days
diff --git a/database.py b/database.py
index 789be68..cf81613 100644
--- a/database.py
+++ b/database.py
@@ -1,96 +1,349 @@
-from crawler import Location, Meal
-from collections import namedtuple
-from pprint import pformat
-import sqlite3
 import logging
+import sqlite3
+from datetime import datetime, time
+from pprint import pformat
+from textwrap import dedent
+from zoneinfo import ZoneInfo
+
+from model import Location, Meal, Schedule, WeekDay
+from seed import get_seed_locations, get_seed_meals
+
+CURITIBA_TZ = ZoneInfo("America/Sao_Paulo")
 
-logger = logging.getLogger("database")
 
-Schedule = namedtuple('Schedule', 'time, day_week, location, meal, user_id, created_at')
+def parse_curitiba_time(formatted_time):
+    hour, minute = map(int, formatted_time.split(":"))
+    return time(hour, minute, tzinfo=CURITIBA_TZ)
 
-connection = sqlite3.connect("db", isolation_level=None, check_same_thread=False)
+
+logger = logging.getLogger("database")
+
+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
+    connection.execute(
+        dedent(
+            """\
+    CREATE TABLE IF NOT EXISTS location (
+            name TEXT NOT NULL PRIMARY KEY,
+            command TEXT NOT NULL,
+            ordering INT NOT NULL,
+            url TEXT NOT NULL
+        )
+    """
+        )
+    )
+    connection.execute(
+        dedent(
+            """\
+        CREATE TABLE IF NOT EXISTS meal (
+            location_name TEXT NOT NULL
+                REFERENCES location (name)
+                DEFERRABLE INITIALLY DEFERRED,
+            name TEXT NOT NULL,
+            week_day INT NOT NULL,
+            ordering INT NOT NULL,
+            start_time TEXT NOT NULL,
+            end_time TEXT NOT NULL,
+            PRIMARY KEY (location_name, name, week_day)
+        )
+    """
+        )
     )
-    ''')
+    connection.execute(
+        dedent(
+            """\
+        CREATE TABLE IF NOT EXISTS schedule (
+            time TEXT NOT NULL,
+            location_name TEXT NOT NULL REFERENCES location (name),
+            meal_name TEXT NOT NULL,
+            week_day INT NOT NULL,
+            user_id INT NOT NULL,
+            created_at DATETIME NOT NULL,
+            PRIMARY KEY (time, location_name, meal_name, week_day, user_id),
+            FOREIGN KEY (location_name, meal_name, week_day)
+                REFERENCES meal(location_name, name, week_day)
+                DEFERRABLE INITIALLY DEFERRED
+        )
+    """
+        )
+    )
+    cur = connection.cursor()
+    cur.execute("BEGIN")
+    cur.execute("DELETE FROM location")
+    seed_locations = get_seed_locations()
+    for location in seed_locations:
+        cur.execute(
+            dedent(
+                """\
+            INSERT INTO location (name, command, ordering, url)
+            VALUES (?, ?, ?, ?)
+        """
+            ),
+            location,
+        )
+    cur.execute("DELETE FROM meal")
+    seed_meals = get_seed_meals()
+    for meal in seed_meals:
+        cur.execute(
+            dedent(
+                """\
+            INSERT INTO meal
+            (location_name, name, week_day, ordering, start_time, end_time)
+            VALUES
+            (?, ?, ?, ?, ?, ?)
+        """
+            ),
+            meal,
+        )
+    cur.execute("COMMIT")
 
-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,))
+
+def get_schedules_query(end_query, end_params):
+    cur = connection.execute(
+        dedent(
+            f"""\
+        SELECT
+            schedule.time,
+            schedule.week_day,
+            schedule.location_name,
+            location.command as location_command,
+            location.ordering as location_ordering,
+            location.url AS location_url,
+            schedule.meal_name,
+            meal.ordering as meal_ordering,
+            meal.start_time as meal_start_time,
+            meal.end_time as meal_end_time,
+            schedule.user_id,
+            schedule.created_at
+        FROM schedule
+        INNER JOIN location
+        ON location.name = schedule.location_name
+        INNER JOIN meal
+        ON meal.location_name = schedule.location_name
+            AND meal.name = schedule.meal_name
+            AND meal.week_day = schedule.week_day
+        {end_query}
+    """
+        ),
+        end_params,
+    )
     rows = cur.fetchall()
-    return [Schedule(
-        row[0],
-        row[1],
-        Location[row[2]],
-        Meal[row[3]],
-        row[4],
-        row[5]
-    ) for row in rows]
+    return [
+        Schedule(
+            time=time,
+            week_day=WeekDay(week_day),
+            meal=Meal(
+                location=Location(
+                    name=location_name,
+                    command=location_command,
+                    ordering=location_ordering,
+                    url=location_url,
+                ),
+                name=meal_name,
+                week_day=WeekDay(week_day),
+                ordering=meal_ordering,
+                start_time=meal_start_time,
+                end_time=meal_end_time,
+            ),
+            user_id=user_id,
+            created_at=created_at,
+        )
+        for (
+            time,
+            week_day,
+            location_name,
+            location_command,
+            location_ordering,
+            location_url,
+            meal_name,
+            meal_ordering,
+            meal_start_time,
+            meal_end_time,
+            user_id,
+            created_at,
+        ) in rows
+    ]
+
+
+def get_schedules_for_user(user_id):
+    return get_schedules_query(
+        dedent(
+            """\
+        WHERE user_id = ?
+        ORDER BY location.ordering, meal.ordering, meal.week_day
+    """
+        ),
+        (user_id,),
+    )
+
+
+def get_schedules_matching_datetime(dt: datetime):
+    time = dt.strftime("%H:%M")
+    week_day = dt.weekday()
+    logging.info(
+        f"Getting schedules matching time {time} AND week_day {week_day}"
+    )
+    return get_schedules_query(
+        dedent(
+            """\
+        WHERE schedule.time = ? AND schedule.week_day = ?
+        ORDER BY schedule.user_id, location.ordering, meal.ordering
+    """
+        ),
+        (time, week_day),
+    )
+
 
 def upsert_schedule(schedule):
-    cur = connection.execute('''
-    SELECT created_at
-    FROM schedule
-    WHERE time = ? and day_week = ? and location = ? and meal = ? and user_id = ?
-    ''', (
-        schedule.time,
-        schedule.day_week,
-        schedule.location.name,
-        schedule.meal.name,
-        schedule.user_id
-    ))
+    cur = connection.execute(
+        dedent(
+            """\
+        SELECT created_at
+        FROM schedule
+        WHERE week_day = ? AND location_name = ?
+            AND meal_name = ? AND user_id = ?
+    """
+        ),
+        (
+            schedule.week_day.value,
+            schedule.meal.location.name,
+            schedule.meal.name,
+            schedule.user_id,
+        ),
+    )
     row = cur.fetchone()
     if not row:
         logging.info(f"Inserting {pformat(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
-        ))
+        connection.execute(
+            dedent(
+                """\
+            INSERT INTO schedule
+            (time, week_day, location_name, meal_name, user_id, created_at)
+            VALUES
+            (?, ?, ?, ?, ?, ?)
+        """
+            ),
+            (
+                schedule.time.strftime("%H:%M"),
+                schedule.week_day.value,
+                schedule.meal.location.name,
+                schedule.meal.name,
+                schedule.user_id,
+                schedule.created_at,
+            ),
+        )
     else:
         logging.info(f"Already inserted {pformat(schedule)}")
 
-def get_schedules_matching_time(datetime):
-    time = datetime.strftime('%H:%M')
-    day_week = datetime.weekday()
-    logging.info(f"Getting schedules matching time {time} and day_week {day_week}")
-    cur = connection.execute('''
-    SELECT time, day_week, location, meal, user_id, created_at
-    FROM schedule
-    WHERE time = ? and day_week = ?
-    ''', (time, day_week))
-    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,))
+    connection.execute(
+        dedent(
+            """
+        DELETE FROM schedule
+        WHERE user_id = ?
+    """
+        ),
+        (user_id,),
+    )
+
+
+def get_locations():
+    cur = connection.execute(
+        dedent(
+            """\
+        SELECT name, command, ordering, url FROM location
+    """
+        )
+    )
+    rows = cur.fetchall()
+    return [Location(*row) for row in rows]
+
+
+def get_location_by_command(command):
+    cur = connection.execute(
+        dedent(
+            """\
+        SELECT name, command, ordering, url FROM location
+        WHERE command = ?
+    """
+        ),
+        (command,),
+    )
+    return Location(*row) if (row := cur.fetchone()) is not None else None
+
+
+def get_meals_query(end_query, end_params=()):
+    cur = connection.execute(
+        dedent(
+            f"""\
+        SELECT
+            meal.location_name,
+            location.command as location_command,
+            location.ordering as location_ordering,
+            location.url as location_url,
+            meal.name,
+            meal.week_day,
+            meal.ordering,
+            meal.start_time,
+            meal.end_time
+        FROM meal
+        INNER JOIN location ON location.name = meal.location_name
+        {end_query}
+    """
+        ),
+        end_params,
+    )
+    rows = cur.fetchall()
+    return [
+        Meal(
+            location=Location(
+                name=location_name,
+                command=location_command,
+                ordering=location_ordering,
+                url=location_url,
+            ),
+            name=name,
+            week_day=WeekDay(week_day),
+            ordering=ordering,
+            start_time=parse_curitiba_time(start_time),
+            end_time=parse_curitiba_time(end_time),
+        )
+        for (
+            location_name,
+            location_command,
+            location_ordering,
+            location_url,
+            name,
+            week_day,
+            ordering,
+            start_time,
+            end_time,
+        ) in rows
+    ]
+
+
+def get_meals():
+    return get_meals_query(
+        dedent(
+            """\
+        ORDER BY location.name, meal.ordering, meal.week_day
+    """
+        )
+    )
+
+
+def get_meals_by_location_name(location_name):
+    return get_meals_query(
+        dedent(
+            """\
+        WHERE meal.location_name = ?
+        ORDER BY meal.ordering, meal.week_day
+    """
+        ),
+        (location_name,),
+    )
diff --git a/main.py b/main.py
index 758e2b8..79fc1a2 100644
--- a/main.py
+++ b/main.py
@@ -1,301 +1,578 @@
 #!/usr/bin/env python3
-from dotenv import load_dotenv
-from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
-from telegram.constants import PARSEMODE_HTML
-from telegram.ext import Updater, CommandHandler, CallbackContext, CallbackQueryHandler, InvalidCallbackData
-from crawler import get_menus_by_days, Location, Meal
-from database import get_schedules_matching_time, upsert_schedule, get_schedules_for_user, Schedule, delete_all_schedules_from_user
-from datetime import datetime, timedelta, date
+import logging
+import os
 from collections import defaultdict
 from copy import deepcopy
-from pprint import pformat
 from dataclasses import dataclass, field
-import typing
-import json
-import logging
-import os
+from datetime import date, datetime, timedelta
+from itertools import groupby
+from pprint import pformat
+from textwrap import dedent
+from typing import Dict, Optional, Set
+
+from dotenv import load_dotenv
+from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
+from telegram.constants import PARSEMODE_HTML
+from telegram.ext import (
+    CallbackContext,
+    CallbackQueryHandler,
+    CommandHandler,
+    InvalidCallbackData,
+    Updater,
+)
+
+from crawler import Day, Location, LocationDays, get_location_days
+from database import (
+    delete_all_schedules_from_user,
+    get_location_by_command,
+    get_locations,
+    get_meals,
+    get_meals_by_location_name,
+    get_schedules_for_user,
+    get_schedules_matching_datetime,
+    upsert_schedule,
+)
+from model import Meal, Schedule
+
+
+def add_time_and_timedelta(time, timedelta):
+    return (datetime.combine(date.today(), time) + timedelta).time()
+
 
 load_dotenv()
 
-file_handler = logging.FileHandler('bot.log')
+file_handler = logging.FileHandler("bot.log")
 stream_handler = logging.StreamHandler()
 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-%dT%H:%M:%S',
-    handlers=[file_handler, stream_handler])
+    format="%(asctime)s %(levelname)s %(name)s"
+    " (%(module)s:%(funcName)s:%(lineno)d) %(message)s",
+    datefmt="%Y-%m-%dT%H:%M:%S",
+    handlers=[file_handler, stream_handler],
+)
 
 logger = logging.getLogger("bot")
 
-def start(update: Update, context: CallbackContext) -> None:
-    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 licenciado sob AGPL</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,
-    'cardapio_poli': Location.POLITECNICO,
-    'cardapio_botanico': Location.BOTANICO,
-    'cardapio_agrarias': Location.AGRARIAS,
-}
-
-LOCATION_TO_HEADER = {
-    Location.CENTRAL: 'RU Central',
-    Location.POLITECNICO: 'RU Centro Politécnico',
-    Location.BOTANICO: 'RU Jardim Botânico',
-    Location.AGRARIAS: 'RU Agrárias',
-}
-
-def cardapio(update: Update, context: CallbackContext) -> None:
-    command = next(iter(update.message.parse_entities(types=["bot_command"]).values()))[1:]
-    if '@' in command:
-        command = command[:command.index('@')]
-
-    location = COMMAND_TO_LOCATION[command]
-    days, update_time = get_menus_by_days(location)
-
-    header = f'<b>{LOCATION_TO_HEADER[location]}</b>'
+
+def format_user(user):
+    user_id = user.id
+    name = user.first_name + (
+        f" {user.last_name}" if user.last_name is not None else ""
+    )
+    username = f"@{user.username}" if user.username is not None else ""
+    return f'{user_id}{username}:"{name}"'
+
+
+def format_location_days(location_days):
+    days, location, update_datetime = location_days
+    header = f"<b>RU {location.name}</b>"
     if len(days):
-        body = '\n\n'.join(
-            f'<b>{day.date_raw}</b>\n' + '\n'.join(
-                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
+        body = "\n\n".join(
+            f"<b>{day.date_raw}</b>\n"
+            + "\n".join(
+                f"  <b>{menu.meal_name}</b>\n"
+                + "\n".join(
+                    f"    • {item.name} "
+                    + "".join(indicator.emoji for indicator in item.indicators)
+                    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_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"
-}
+        body = "<i>Cardápio indisponível</i>"
+    updated_on = (
+        "<i>Atualizado às "
+        + update_datetime.strftime("%H:%M:%S")
+        + " de hoje</i>"
+    )
+    return header + "\n\n" + body + "\n\n" + updated_on
 
-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:
+class ScheduleBeforeOpeningCallbackData:
     step: int
-    location: Location = None
-    days_week_by_meal: typing.Dict[Meal, set] = field(default_factory=dict)
+    location: Optional[Location] = None
+    timedelta_before_opening: Optional[timedelta] = None
+    meals_by_name: Dict[str, Set[Meal]] = field(default_factory=dict)
+
+
+class AskDeleteAllNotificationsCallbackData:
+    pass
 
-class RemoveTodasNotificacoes:
+
+class ReadyCallbackData:
+    pass
+
+
+class DeleteAllNotificationsCallbackData:
     pass
 
-def agendar(user_id):
+
+class ScheduleCallbackData:
+    pass
+
+
+def answer_start_command(update: Update, context: CallbackContext) -> None:
+    locations = get_locations()
+    update.message.reply_text(
+        f"Olá, sou o @{context.bot.username}, o robô de"
+        """ <a href="https://gitlab.c3sl.ufpr.br/caad/ru-bot-telegram">"""
+        """código aberto licenciado sob AGPL</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!"""
+        + dedent(
+            """
+            <b>Estes são meus comandos:</b>
+                • /agendar · Configura notificações de cardápio
+        """
+        )
+        + "\n".join(
+            f"    • /cardapio_{location.command} · "
+            "Mostra o cardápio do RU {location.name}"
+            for location in locations
+        ),
+        parse_mode=PARSEMODE_HTML,
+    )
+
+    logger.info(f"User {format_user(update.effective_user)} used /start")
+
+
+def answer_cardapio_command(update: Update, context: CallbackContext) -> None:
+    command = next(
+        iter(update.message.parse_entities(types=["bot_command"]).values())
+    )[1:]
+    if "@" in command:
+        command = command[: command.index("@")]
+
+    location = get_location_by_command(command[len("cardapio_") :])
+    location_days = get_location_days(location)
+    update.message.reply_text(
+        format_location_days(location_days), parse_mode=PARSEMODE_HTML
+    )
+    logger.info(f"User {format_user(update.effective_user)} used /{command}")
+
+
+def answer_schedule(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")],
+        [
+            InlineKeyboardButton(
+                "Agendar notificações para antes da abertura...",
+                callback_data=ScheduleBeforeOpeningCallbackData(-1),
+            )
+        ],
+        # [
+        #     InlineKeyboardButton(
+        #         "Agendar notificações diárias...",
+        #         callback_data=ScheduleDigestCallbackData(-1)
+        #     )
+        # ],
+        # [
+        #     InlineKeyboardButton(
+        #         "Agendar uma notificação individual...",
+        #         callback_data=ScheduleOnceCallbackData(-1)
+        #     )
+        # ],
     ]
     if len(schedules):
         keyboard += [
-            #[InlineKeyboardButton('Remover uma notificação individual...', callback_data="remover_agendamento_individual")],
-            [InlineKeyboardButton('Remover todas as notificações...', callback_data=RemoveTodasNotificacoes())],
+            # [
+            #     InlineKeyboardButton(
+            #         "Remover uma notificação individual...",
+            #         callback_data=DeleteOnceCallbackData()
+            #     )
+            # ],
+            [
+                InlineKeyboardButton(
+                    "Remover todas as notificações...",
+                    callback_data=AskDeleteAllNotificationsCallbackData(),
+                )
+            ],
         ]
+    keyboard.append(
+        [InlineKeyboardButton("Pronto", callback_data=ReadyCallbackData())]
+    )
 
     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()
+            schedules_by_location[schedule.meal.location].append(schedule)
+        week_day_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
+                by_time_meal[(schedule.time, schedule.meal.name)].add(
+                    schedule.week_day
+                )
+            week_day_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()
+            "<b>Notificações</b>\n\n"
+            + "\n\n".join(
+                f"<b>RU {location.name}</b>\n"
+                + "\n".join(
+                    f"  • {time} · {meal_name} · "
+                    + ", ".join(
+                        week_day.short for week_day in sorted(week_days)
+                    )
+                    for (
+                        time,
+                        meal_name,
+                    ), week_days in week_day_by_time_meal.items()
                 )
-                for location, day_week_by_time_meal
-                in day_week_by_time_meal_by_location.items()
+                for (
+                    location,
+                    week_day_by_time_meal,
+                ) in week_day_by_time_meal_by_location.items()
             ),
-            keyboard
+            keyboard,
         )
     else:
         return (
-            f'<b>Notificações</b>\n\nAtualmente você não tem nenhuma notificação agendada.',
-            keyboard
+            "<b>Notificações</b>\n\n"
+            "<i>Atualmente você não tem nenhuma notificação agendada.</i>",
+            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)
+def answer_ready_callback(update: Update, context: CallbackContext) -> None:
+    update.callback_query.message.edit_reply_markup(None)
+
 
-VALID_WEEK_DAYS_BY_LOCATION = {
-    Location.CENTRAL: {0, 1, 2, 3, 4, 5, 6},
-    Location.POLITECNICO: {0, 1, 2, 3, 4},
-}
+def answer_schedule_command(update: Update, context: CallbackContext) -> None:
+    body, keyboard = answer_schedule(update.effective_user.id)
+    update.message.reply_text(
+        body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML
+    )
+
+
+def answer_schedule_callback(update: Update, context: CallbackContext) -> None:
+    body, keyboard = answer_schedule(update.effective_user.id)
+    update.callback_query.message.edit_text(
+        body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML
+    )
 
-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:
+def answer_schedule_before_opening_callback(
+    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():
+        meals = get_meals()
+        for location, location_meals in groupby(
+            meals, key=lambda lwd: lwd.location
+        ):
+            new_data = ScheduleBeforeOpeningCallbackData(
+                step=0, location=location
+            )
+            buttons.append(
+                [
+                    InlineKeyboardButton(
+                        f"RU {location.name}", callback_data=new_data
+                    )
+                ]
+            )
+        buttons.append(
+            [
+                InlineKeyboardButton(
+                    "« Voltar", callback_data=ScheduleCallbackData()
+                ),
+            ]
+        )
+
+        update.callback_query.message.edit_text(
+            "<b>Notificações</b>\n\n"
+            "De qual local você quer receber notificações?",
+            reply_markup=InlineKeyboardMarkup(buttons),
+            parse_mode=PARSEMODE_HTML,
+        )
+        return
+
+    meals = get_meals_by_location_name(data.location.name)
+    meals_by_name_tuples = [
+        (key, list(values))
+        for key, values in groupby(meals, key=lambda m: m.name)
+    ]
+
+    if data.step == len(meals_by_name_tuples):
+        buttons = []
+
+        for text, delta in [
+            ("15m", timedelta(hours=0, minutes=15)),
+            ("30m", timedelta(hours=0, minutes=30)),
+            ("45m", timedelta(hours=0, minutes=45)),
+            ("1h", timedelta(hours=1, minutes=0)),
+            ("1h15m", timedelta(hours=1, minutes=15)),
+            ("1h30m", timedelta(hours=1, minutes=30)),
+            ("1h45m", timedelta(hours=1, minutes=45)),
+            ("2h", timedelta(hours=2, minutes=0)),
+        ]:
             new_data = deepcopy(data)
-            new_data.location = location
+            new_data.timedelta_before_opening = delta
             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)
-            ])
+            buttons.append(InlineKeyboardButton(text, callback_data=new_data))
+
+        buttons = [buttons[i : i + 4] for i in range(0, len(buttons), 4)]
+
+        prev_data = deepcopy(data)
+        prev_data.step -= 1
+        buttons.append(
+            [InlineKeyboardButton("« Voltar", callback_data=prev_data)]
+        )
 
         update.callback_query.message.edit_text(
-            '<b>Notificações</b>\n\nDe qual local você quer receber notificações?',
+            "<b>Notificações</b>\n\n"
+            f"Quanto tempo antes da abertura do RU {data.location.name}"
+            " você quer receber a notificação?",
             reply_markup=InlineKeyboardMarkup(buttons),
-            parse_mode=PARSEMODE_HTML
+            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)}")
+    if data.step == len(meals_by_name_tuples) + 1:
+        for meals in data.meals_by_name.values():
+            for meal in meals:
+                time = add_time_and_timedelta(
+                    meal.start_time, -data.timedelta_before_opening
+                )
+                schedule = Schedule(
+                    time=time,
+                    week_day=meal.week_day,
+                    meal=meal,
+                    user_id=update.effective_user.id,
+                    created_at=datetime.now(),
+                )
+                logger.info(
+                    f"User {format_user(update.effective_user)}"
+                    " added schedule {pformat(schedule)}"
+                )
                 upsert_schedule(schedule)
-        body, keyboard = agendar(update.effective_user.id)
-        update.callback_query.message.edit_text(body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML)
+        body, keyboard = answer_schedule(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]
+    meal_step_name, meals_step = meals_by_name_tuples[data.step]
+    if meal_step_name not in data.meals_by_name:
+        data.meals_by_name[meal_step_name] = set(meals_step)
+    week_days_step = data.meals_by_name[meal_step_name]
     buttons = []
-    for day_week in VALID_WEEK_DAYS_BY_LOCATION[data.location]:
-        if day_week in days_week_step:
+    for meal in meals_step:
+        if meal in week_days_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)])
+            new_data.meals_by_name[meal_step_name].remove(meal)
+            buttons.append(
+                InlineKeyboardButton(
+                    f"Remover {meal.week_day.long}", 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)])
+            new_data.meals_by_name[meal_step_name].add(meal)
+            buttons.append(
+                InlineKeyboardButton(
+                    f"Adicionar {meal.week_day.long}", callback_data=new_data
+                )
+            )
+
+    buttons = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
 
     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,
+        f"<b>Notificações</b>\n\n<b>RU {data.location.name}</b>\n"
+        + "\n".join(
+            ("<b>" if i == data.step else "")
+            + f"  • {meal_name} · "
+            + ", ".join(
+                meal.week_day.short
+                for meal in sorted(meals, key=lambda m: m.week_day)
+            )
+            + ("</b>" if i == data.step else "")
+            for i, (meal_name, meals) in enumerate(data.meals_by_name.items())
+        )
+        + "\n\n"
+        "Adicione ou remova dias de semana para as notificações"
+        f" do <b>{meal_step_name}</b>, vá para o próximo quando terminar.",
+        reply_markup=InlineKeyboardMarkup(
             [
-                InlineKeyboardButton('« Voltar', callback_data=prev_data),
-                InlineKeyboardButton('Próximo »', callback_data=next_data)
-            ],
-        ]),
-        parse_mode=PARSEMODE_HTML
+                *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:
+def answer_ask_delete_all_notifications_callback(
+    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
+        "<b>Notificações</b>\n\n"
+        + "Você tem certeza que quer remover todas as notificações?",
+        reply_markup=InlineKeyboardMarkup(
+            [
+                [
+                    InlineKeyboardButton(
+                        "Sim, remover todas as notificações",
+                        callback_data=DeleteAllNotificationsCallbackData(),
+                    )
+                ],
+                [
+                    InlineKeyboardButton(
+                        "Não, mantenha minhas notificações",
+                        callback_data=ScheduleCallbackData(),
+                    )
+                ],
+            ]
+        ),
+        parse_mode=PARSEMODE_HTML,
     )
 
-def remover_todas_notificacoes_ok(update: Update, context: CallbackContext) -> None:
+
+def answer_delete_all_notifications(
+    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")
+    body, keyboard = answer_schedule(update.effective_user.id)
+    update.callback_query.message.edit_text(
+        body, reply_markup=keyboard, parse_mode=PARSEMODE_HTML
+    )
+    logger.info(
+        f"User {format_user(update.effective_user)} removed all notifications"
+    )
+
+
+def answer_invalid_button_callback(
+    update: Update, context: CallbackContext
+) -> None:
+    update.callback_query.answer(
+        text="Essa mensagem não é mais válida", show_alert=True
+    )
+    update.callback_query.message.edit_reply_markup()
+
+
+def try_send_schedule_again(context: CallbackContext) -> None:
+    pass
 
-def invalid_button(update: Update, context: CallbackContext) -> None:
-    update.callback_query.answer(text="Essa mensagem não é mais válida", show_alert=True)
 
 def send_scheduled(context: CallbackContext) -> None:
-    time = datetime.now()
-    time += datetime.timedelta(minutes=EVERY_X_MINUTES)
-    time -= datetime.timedelta(minutes=time.minute % EVERY_X_MINUTES, seconds=time.second, microseconds=time.microsecond)
-    logging.info(f"Getting schedules matching {time}")
-    schedules = get_schedules_matching_time(time)
+    dt = datetime.now()
+    dt += timedelta(minutes=EVERY_X_MINUTES)
+    dt -= timedelta(
+        minutes=dt.minute % EVERY_X_MINUTES,
+        seconds=dt.second,
+        microseconds=dt.microsecond,
+    )
+    logging.info(f"Getting schedules matching {dt}")
+    schedules = get_schedules_matching_datetime(dt)
+
     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>'
+        days, location, update_datetime = get_location_days(schedule.location)
+        location_days = LocationDays(
+            days=[
+                Day(
+                    date=date,
+                    date_raw=date_raw,
+                    menus=[
+                        menu for menu in menus if menu.meal == schedule.meal
+                    ],
+                )
+                for date, date_raw, menus in days
+                if date.today() == schedule.day.date
+            ],
+            location=location,
+            update_datetime=update_datetime,
+        )
         context.dispatcher.bot.send_message(
             schedule.user_id,
-            header + '\n\n' + body + '\n\n' + updated_on,
-            parse_mode=PARSEMODE_HTML
+            format_location_days(location_days),
+            parse_mode=PARSEMODE_HTML,
+        )
+        logger.info(
+            f"User {schedule.user_id} received schedule {pformat(schedule)}"
         )
-        logger.info(f"User {schedule.user_id} received schedule {pformat(schedule)}")
+
 
 EVERY_X_MINUTES = 15
 
+
 def main() -> None:
     logger.info("Starting bot...")
-    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))
-    updater.dispatcher.add_handler(CallbackQueryHandler(invalid_button, pattern=InvalidCallbackData))
+    updater = Updater(
+        os.environ["TELEGRAM_BOT_TOKEN"], arbitrary_callback_data=True
+    )
+    updater.dispatcher.add_handler(
+        CommandHandler("start", answer_start_command)
+    )
+    locations = get_locations()
+    for location in locations:
+        updater.dispatcher.add_handler(
+            CommandHandler(
+                f"cardapio_{location.command}", answer_cardapio_command
+            )
+        )
+    updater.dispatcher.add_handler(
+        CommandHandler("agendar", answer_schedule_command)
+    )
+    updater.dispatcher.add_handler(
+        CallbackQueryHandler(
+            answer_ask_delete_all_notifications_callback,
+            pattern=AskDeleteAllNotificationsCallbackData,
+        )
+    )
+    updater.dispatcher.add_handler(
+        CallbackQueryHandler(
+            answer_delete_all_notifications,
+            pattern=DeleteAllNotificationsCallbackData,
+        )
+    )
+    updater.dispatcher.add_handler(
+        CallbackQueryHandler(
+            answer_schedule_callback, pattern=ScheduleCallbackData
+        )
+    )
+    updater.dispatcher.add_handler(
+        CallbackQueryHandler(answer_ready_callback, pattern=ReadyCallbackData)
+    )
+    updater.dispatcher.add_handler(
+        CallbackQueryHandler(
+            answer_schedule_before_opening_callback,
+            pattern=ScheduleBeforeOpeningCallbackData,
+        )
+    )
+    updater.dispatcher.add_handler(
+        CallbackQueryHandler(
+            answer_invalid_button_callback, pattern=InvalidCallbackData
+        )
+    )
     time = datetime.now()
-    time -= timedelta(minutes=time.minute % EVERY_X_MINUTES, seconds=time.second, microseconds=time.microsecond)
+    time -= timedelta(
+        minutes=time.minute % EVERY_X_MINUTES,
+        seconds=time.second,
+        microseconds=time.microsecond,
+    )
     time += timedelta(minutes=EVERY_X_MINUTES)
-    updater.dispatcher.job_queue.run_repeating(send_scheduled, timedelta(minutes=EVERY_X_MINUTES), first=time)
+    updater.dispatcher.job_queue.run_repeating(
+        send_scheduled, timedelta(minutes=EVERY_X_MINUTES), first=time
+    )
     updater.start_polling()
     logger.info("Connected")
     updater.idle()
     logger.info("Done")
 
-if __name__ == '__main__':
+
+if __name__ == "__main__":
     main()
diff --git a/model.py b/model.py
new file mode 100644
index 0000000..d1d5746
--- /dev/null
+++ b/model.py
@@ -0,0 +1,95 @@
+from dataclasses import dataclass
+from datetime import datetime, time
+from enum import Enum
+from typing import Set
+
+
+class WeekDay(Enum):
+    MONDAY = (0, "SEG", "Segunda-feira")
+    TUESDAY = (1, "TER", "Terça-feira")
+    WEDNESDAY = (2, "QUA", "Quarta-feira")
+    THURSDAY = (3, "QUI", "Quinta-feira")
+    FRIDAY = (4, "SEX", "Sexta-feira")
+    SATURDAY = (5, "SAB", "Sábado")
+    SUNDAY = (6, "DOM", "Domingo")
+    HOLIDAYS = (7, "FER", "Feriado")
+
+    def __new__(cls, value, short, long):
+        obj = object.__new__(cls)
+        obj._value_ = value
+        obj.short = short
+        obj.long = long
+        return obj
+
+    def __lt__(self, other):
+        if self.__class__ is other.__class__:
+            return self.value < other.value
+        return NotImplemented
+
+
+WorkingWeekDay = [
+    WeekDay.MONDAY,
+    WeekDay.TUESDAY,
+    WeekDay.WEDNESDAY,
+    WeekDay.THURSDAY,
+    WeekDay.FRIDAY,
+]
+
+
+class MenuItemIndicator(Enum):
+    VEGAN = ("🌱", "Indicado para veganos")
+    GLUTEN = ("🌾", "Não indicado para celíacos por conter glúten")
+    LACTOSE = (
+        "🥛",
+        "Não indicado para intolerantes à lactose por conter lactose",
+    )
+    ANIMAL = ("🥩", "Contém produtos de origem animal")
+    EGG = ("🥚", "Contém ovo")
+    HONEY = ("🍯", "Contém mel")
+    ALERGIC = ("⚠️", "Contém produto(s) alergênico(s)")
+
+    def __new__(cls, emoji, description):
+        obj = object.__new__(cls)
+        obj._value_ = (emoji, description)
+        obj.emoji = emoji
+        obj.description = description
+        return obj
+
+
+@dataclass(frozen=True)
+class MenuItem:
+    name: str
+    indicators: Set[MenuItemIndicator]
+
+
+@dataclass(frozen=True)
+class Location:
+    name: str
+    command: str
+    ordering: int
+    url: str
+
+
+@dataclass(frozen=True)
+class LocationWeekDays:
+    location: Location
+    week_day: WeekDay
+
+
+@dataclass(frozen=True)
+class Meal:
+    location: Location
+    name: str
+    week_day: WeekDay
+    ordering: int
+    start_time: time
+    end_time: time
+
+
+@dataclass(frozen=True)
+class Schedule:
+    time: str  # 18:00
+    week_day: WeekDay
+    meal: Meal
+    user_id: int  # Telegram user id
+    created_at: datetime
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..1e75c2f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,5 @@
+[tool.black]
+line-length = 79
+
+[tool.isort]
+profile = "black"
diff --git a/seed.py b/seed.py
new file mode 100644
index 0000000..732445b
--- /dev/null
+++ b/seed.py
@@ -0,0 +1,130 @@
+# Este arquivo tem configurações iniciais.
+# Os horários de funcionamento podem sofrer alterações.
+# Atualizado em 10 de fevereiro de 2022.
+
+from model import WeekDay, WorkingWeekDay
+
+
+def get_seed_locations():
+    return [
+        (
+            "Centro Politécnico",
+            "poli",
+            1,
+            "https://pra.ufpr.br/ru/ru-centro-politecnico/",
+        ),
+        ("Central", "central", 2, "https://pra.ufpr.br/ru/ru-central/"),
+        (
+            "Jardim Botânico",
+            "botanico",
+            3,
+            "https://pra.ufpr.br/ru/cardapio-ru-jardim-botanico/",
+        ),
+        (
+            "Agrárias",
+            "agrarias",
+            4,
+            "https://pra.ufpr.br/ru/cardapio-ru-agrarias/",
+        ),
+    ]
+
+
+def get_seed_meals():
+    return (
+        [
+            ("Central", "CAFÉ DA MANHÃ", week_day.value, 1, "06:45", "08:00")
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            ("Central", "ALMOÇO", week_day.value, 2, "11:00", "13:30")
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            ("Central", "JANTAR", week_day.value, 3, "17:30", "19:30")
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            (
+                "Central",
+                "CAFÉ DA MANHÃ",
+                WeekDay.SATURDAY.value,
+                1,
+                "08:15",
+                "09:15",
+            )
+        ]
+        + [("Central", "ALMOÇO", WeekDay.SATURDAY.value, 2, "11:30", "13:30")]
+        + [("Central", "JANTAR", WeekDay.SATURDAY.value, 3, "17:45", "19:30")]
+        + [
+            ("Central", "CAFÉ DA MANHÃ", week_day.value, 1, "08:30", "09:30")
+            for week_day in (WeekDay.SUNDAY, WeekDay.HOLIDAYS)
+        ]
+        + [
+            ("Central", "ALMOÇO", week_day.value, 2, "11:30", "13:30")
+            for week_day in (WeekDay.SUNDAY, WeekDay.HOLIDAYS)
+        ]
+        + [
+            ("Central", "JANTAR", week_day.value, 3, "17:45", "19:00")
+            for week_day in (WeekDay.SUNDAY, WeekDay.HOLIDAYS)
+        ]
+        + [
+            (
+                "Centro Politécnico",
+                "CAFÉ DA MANHÃ",
+                week_day.value,
+                1,
+                "06:45",
+                "08:00",
+            )
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            (
+                "Centro Politécnico",
+                "ALMOÇO",
+                week_day.value,
+                2,
+                "11:00",
+                "13:30",
+            )
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            (
+                "Centro Politécnico",
+                "JANTAR",
+                week_day.value,
+                3,
+                "17:30",
+                "19:30",
+            )
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            ("Agrárias", "CAFÉ DA MANHÃ", week_day.value, 1, "06:45", "08:00")
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            ("Agrárias", "ALMOÇO", week_day.value, 2, "11:00", "13:30")
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            (
+                "Jardim Botânico",
+                "CAFÉ DA MANHÃ",
+                week_day.value,
+                1,
+                "06:45",
+                "08:00",
+            )
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            ("Jardim Botânico", "ALMOÇO", week_day.value, 2, "11:00", "13:30")
+            for week_day in WorkingWeekDay
+        ]
+        + [
+            ("Jardim Botânico", "JANTAR", week_day.value, 3, "17:30", "19:30")
+            for week_day in WorkingWeekDay
+        ]
+    )
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..8434b4b
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,2 @@
+[flake8]
+ignore = E203, W503
-- 
GitLab