From d9ae6527a1db09ceba178a1d6b3b022a2b45add6 Mon Sep 17 00:00:00 2001 From: LordBaryhobal Date: Sat, 14 Sep 2024 02:06:51 +0200 Subject: [PATCH] initial commit --- .idea/.gitignore | 8 + .idea/beebot-docker.iml | 8 + .idea/icon.svg | 189 +++++++++++++ .idea/inspectionProfiles/Project_Default.xml | 25 ++ .../inspectionProfiles/profiles_settings.xml | 7 + .idea/misc.xml | 7 + .idea/modules.xml | 8 + .idea/sqldialects.xml | 7 + .idea/vcs.xml | 6 + Dockerfile | 18 ++ src/beebot.py | 263 ++++++++++++++++++ src/menu.typ | 154 ++++++++++ src/requirements.txt | 4 + 13 files changed, 704 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/beebot-docker.iml create mode 100644 .idea/icon.svg create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/sqldialects.xml create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile create mode 100644 src/beebot.py create mode 100644 src/menu.typ create mode 100644 src/requirements.txt diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/beebot-docker.iml b/.idea/beebot-docker.iml new file mode 100644 index 0000000..d0876a7 --- /dev/null +++ b/.idea/beebot-docker.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 0000000..f52d748 --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..27a252c --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..dd4c951 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..9de2865 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e0e64c8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..9bd6ab9 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d2ceca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.10 +LABEL authors="Lord Baryhobal" +LABEL maintainer="Lord Baryhobal " + +RUN echo "Installing Typst" \ + && wget -q -O /tmp/typst.tar.xz https://github.com/typst/typst/releases/download/v0.11.1/typst-x86_64-unknown-linux-musl.tar.xz \ + && tar -x /tmp/typst.tar.xz -C /tmp/ \ + && mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/bin/typst \ + && chmod +x /usr/bin/typst \ + && rm -r /tmp/typst-x86_64-unknown-linux-musl \ + && rm /tmp/typst.tar.xz + +WORKDIR /app +COPY src /app + +RUN pip install -r requirements.txt + +ENTRYPOINT ["python", "beebot.py"] \ No newline at end of file diff --git a/src/beebot.py b/src/beebot.py new file mode 100644 index 0000000..300142d --- /dev/null +++ b/src/beebot.py @@ -0,0 +1,263 @@ +import asyncio +import datetime +import json +import logging +import os +import sqlite3 +import subprocess +from sqlite3 import Connection +from typing import Optional + +import requests +import telegram.constants +from bs4 import BeautifulSoup +from telegram import Update +from telegram.ext import ApplicationBuilder, CommandHandler, ContextTypes + +logging.basicConfig( + format="[%(asctime)s] %(name)s/%(levelname)s - %(message)s", + level=logging.INFO +) +logging.getLogger("httpx").setLevel(logging.WARNING) +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +class BeeBot: + MENU_URL = "https://www.epfl.ch/campus/restaurants-shops-hotels/fr/industrie21-epfl-valais-wallis/?date={date}" + RESTO_ID = 545 + BASE_DIR = os.path.dirname(__file__) + CACHE_PATH = os.path.join(BASE_DIR, "cache.json") + DB_PATH = os.path.join(BASE_DIR, "database.db") + TEMPLATE_PATH = os.path.join(BASE_DIR, "menu.typ") + IMG_DIR = "/tmp/menu_images" + + def __init__(self, token: str): + self.tg_token: str = token + self.tg_app = None + self.cache: dict[str, str] = {} + self.db_con: Optional[Connection] = None + self.locks: dict[str, bool] = {} + self.fetch_lock = asyncio.Lock() + self.gen_lock = asyncio.Lock() + + def mainloop(self): + logger.info("Initialization") + + self.db_con = sqlite3.connect(self.DB_PATH) + self.check_database() + self.load_cache() + + app = ApplicationBuilder().token(self.tg_token).build() + app.add_handler(CommandHandler("week", self.cmd_week)) + app.add_handler(CommandHandler("today", self.cmd_today)) + + logger.info("Starting bot") + app.run_polling() + + async def cmd_week(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + logger.debug("Received /week") + await self.request_menu(update, context, False) + + async def cmd_today(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + logger.debug("Received /today") + await self.request_menu(update, context, True) + + async def request_menu(self, update: Update, context: ContextTypes.DEFAULT_TYPE, today_only: bool) -> None: + chat_id = update.effective_chat.id + await context.bot.send_chat_action(chat_id=chat_id, action=telegram.constants.ChatAction.TYPING) + prefs = self.get_user_pref(update) + logger.debug(f"User prefs: {prefs}") + categories = prefs["categories"] + + img_id = self.get_img_id(today_only, categories) + filename = img_id + ".png" + img_path = os.path.join(self.IMG_DIR, filename) + + menu_id = "today_menu" if today_only else "week_menu" + menu_path = os.path.join(self.BASE_DIR, "menus_today.json" if today_only else "menus_week.json") + + # If menu needs to be fetched + if not os.path.exists(menu_path) or self.is_outdated(menu_id, today_only): + # Notify user + msg = await update.message.reply_text("The menu is being updated, please wait...") + async with self.fetch_lock: + if today_only: + await self.fetch_today_menu() + else: + await self.fetch_week_menu() + await msg.delete() + + # If image needs to be (re)generated + if not os.path.exists(img_path) or self.is_outdated(img_id, today_only): + await self.gen_image(today_only, categories, img_path, img_id) + + await context.bot.send_photo(chat_id=chat_id, photo=img_path) + + def check_database(self): + cur = self.db_con.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS "user" ( + "id" INTEGER, + "telegram_id" INTEGER NOT NULL UNIQUE, + "categories" TEXT, + "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY("id" AUTOINCREMENT) + ); + """) + self.db_con.commit() + cur.close() + + def get_user_pref(self, update: Update) -> dict: + user_id = update.effective_user.id + cur = self.db_con.cursor() + res = cur.execute("SELECT categories FROM user WHERE telegram_id=?", (user_id,)) + user = res.fetchone() + cur.close() + + if user is None: + self.create_user(user_id) + return { + "categories": {"E", "D", "C", "V"} + } + + categories = set(user[0].split(",")) + return { + "categories": categories + } + + def create_user(self, telegram_id: int) -> None: + logger.debug(f"New user with id {telegram_id}") + cur = self.db_con.cursor() + cur.execute( + "INSERT INTO user (telegram_id, categories) VALUES (?, ?)", + (telegram_id, "E,D,C,V") + ) + self.db_con.commit() + cur.close() + + def load_cache(self): + if os.path.exists(self.CACHE_PATH): + with open(self.CACHE_PATH, "r") as f: + self.cache = json.load(f) + + def save_cache(self): + with open(self.CACHE_PATH, "w") as f: + json.dump(self.cache, f) + + @staticmethod + def get_img_id(today_only: bool, categories: set[str]) -> str: + categs = "".join(sorted(categories)) + layout = "today" if today_only else "week" + return f"{layout}-{categs}" + + def is_outdated(self, obj_id: str, today_only: bool) -> bool: + if obj_id not in self.cache: + return True + gen_date = datetime.datetime.strptime(self.cache[obj_id], "%Y-%m-%d") + today = datetime.datetime.today() + if today_only: + if (today - gen_date).days > 0: + return True + else: + delta1 = datetime.timedelta(days=gen_date.weekday()) + delta2 = datetime.timedelta(days=today.weekday()) + gen_monday = gen_date - delta1 + cur_monday = today - delta2 + if (cur_monday - gen_monday).days > 0: + return True + return False + + async def gen_image(self, today_only: bool, categories: set[str], outpath: str, img_id: str) -> None: + if not os.path.exists(self.IMG_DIR): + os.mkdir(self.IMG_DIR) + + logger.info(f"Generating {'today' if today_only else 'week'} menu image for categories {categories}") + menu_path = "menus_today.json" if today_only else "menus_week.json" + subprocess.call([ + "typst", + "compile", + "--root", self.BASE_DIR, + "--font-path", os.path.join(self.BASE_DIR, "fonts"), + "--input", f"path={menu_path}", + "--input", f"categories={','.join(categories)}", + self.TEMPLATE_PATH, + outpath + ]) + + self.cache[img_id] = datetime.datetime.today().strftime("%Y-%m-%d") + self.save_cache() + + def save_menu(self, days: list, menu_id: str, filename: str) -> None: + with open(os.path.join(self.BASE_DIR, filename), "w") as f: + json.dump(days, f) + + self.cache[menu_id] = datetime.datetime.today().strftime("%Y-%m-%d") + self.save_cache() + + async def fetch_week_menu(self) -> None: + logger.info("Fetching week menu") + today = datetime.datetime.today() + delta = datetime.timedelta(days=today.weekday()) + monday = today - delta + days = [] + for i in range(5): + dt = datetime.timedelta(days=i) + date = monday + dt + menus = await self.fetch_menu(date) + days.append({ + "date": date.strftime("%Y-%m-%d"), + "menus": menus + }) + + self.save_menu(days, "week_menu", "menus_week.json") + + async def fetch_today_menu(self) -> None: + logger.info("Fetching today menu") + today = datetime.datetime.today() + menus = await self.fetch_menu(today) + days = [{ + "date": today.strftime("%Y-%m-%d"), + "menus": menus + }] + + self.save_menu(days, "today_menu", "menus_today.json") + + async def fetch_menu(self, date: datetime.date) -> list: + url = self.MENU_URL.format(date=date.strftime("%Y-%m-%d")) + headers = { + "User-Agent": "BeeBot 1.0" + } + res = requests.get(url, headers=headers) + + if res.status_code != 200: + return [] + + bs = BeautifulSoup(res.content, features="lxml") + table = bs.find("table", {"id": "menuTable"}) + lines = table.find("tbody").findAll("tr", {"data-restoid": str(self.RESTO_ID)}) + + menus = [] + + for line in lines: + menu = line.find("td", class_="menu").find("div", class_="descr").contents[0].text + menu_lines = menu.split("\n") + + price_cells = line.find("td", class_="prices").findAll("span", class_="price") + prices = {} + for cell in price_cells: + category = cell.find("abbr").text + price = cell.contents[-1].replace("CHF", "").strip() + prices[category] = float(price) + + menus.append({ + "name": menu_lines[0], + "extra": menu_lines[1:], + "prices": prices + }) + + return menus + +if __name__ == '__main__': + logger.info("Welcome to BeeBot !") + bot = BeeBot(os.getenv("TELEGRAM_TOKEN")) + bot.mainloop() diff --git a/src/menu.typ b/src/menu.typ new file mode 100644 index 0000000..3f86813 --- /dev/null +++ b/src/menu.typ @@ -0,0 +1,154 @@ +#let path = sys.inputs.at("path", default: "menus.json") +#let categories = sys.inputs.at("categories", default: "E,D,C,V").split(",") +#let days = json(path) +#let nbsp = sym.space.nobreak + +#let width = if days.len() > 1 {20cm} else {10cm} + +#set page(margin: 0cm, height: auto, width: width) + +#let parse-date(date-txt) = { + let (year, month, day) = date-txt.split("-") + return datetime(year: int(year), month: int(month), day: int(day)) +} + +#let fmt-number( + number, + decimals: 2, + decimal-sep: ".", + thousands-sep: "'" +) = { + let integer = calc.trunc(number) + let decimal = calc.fract(number) + let res = str(integer).clusters() + .rev() + .chunks(3) + .map(c => c.join("")) + .join(thousands-sep) + .rev() + + if decimals != 0 { + res += decimal-sep + decimal = decimal * calc.pow(10, decimals) + decimal = calc.round(decimal) + decimal = str(decimal) + ("0" * decimals) + res += decimal.slice(0, decimals) + } + + return res +} + +#let day-names = ( + "Lundi", + "Mardi", + "Mercredi", + "Jeudi", + "Vendredi", + "Samedi", + "Dimanche" +) + +#let display-menus( + days, + categories, + day-sep-stroke: rgb("#82BC00") + 1pt, + title-underline-stroke: rgb("#e34243") + 2pt, + menu-sep-stroke: rgb("#ababab") + 1pt, + price-categ-color: rgb("#577F25") +) = { + set text(font: "Liberation Sans", hyphenate: true) + let cols = calc.min(2, days.len()) + + let cells = () + + for day in days { + let weekday = parse-date(day.date).weekday() - 1 + let day-name = day-names.at(weekday) + let menus = () + for menu in day.menus { + let price-cell = grid.cell(rowspan: 2)[] + let prices = menu.at( + "prices", + default: (:) + ) + if menu.keys().contains("price") { + prices = ("": menu.price) + } + + let pairs = prices.pairs() + .filter(p => p.first() in categories) + .sorted(key: p => p.last()) + + let prices = pairs.map(p => { + let categ = emph( + text( + fill: price-categ-color, + p.first() + ) + ) + let price = fmt-number(p.last()) + categ + ":" + nbsp + "CHF" + nbsp + price + }) + price-cell = grid.cell( + rowspan: menu.extra.len() + 1, + stack(dir: ttb, spacing: .4em, ..prices) + ) + //let price = "CHF" + sym.space.nobreak + str(menu.price) + menus.push( + grid( + columns: (1fr, auto), + column-gutter: .4em, + row-gutter: .4em, + [*#menu.name*], + price-cell, + ..menu.extra.map(l => text(10pt, l)) + ) + ) + } + + let title = align( + center, + text(size: 14pt, weight: "bold", day-name) + ) + let title-box = box( + width: 50%, + inset: .6em, + stroke: (bottom: title-underline-stroke), + title + ) + let title-cell = grid.cell(align: center, title-box) + + cells.push(box(width: 100%, grid( + columns: (100%), + inset: (x, y) => ( + top: if y == 0 {0pt} else {.8em}, + bottom: if y == 0 {0pt} else {2em} + ), + stroke: (_, y) => (top: if y < 2 {none} else {menu-sep-stroke}), + title-cell, + ..menus + ))) + } + + if calc.rem(cells.len(), 2) == 1 { + cells.last() = grid.cell(colspan: calc.min(2, cols), cells.last()) + } + + grid( + columns: (100% / cols,) * cols, + inset: (x: 1.5em, y: .8em), + stroke: (x, y) => { + let borders = (:) + if x != 0 { + borders.insert("left", day-sep-stroke) + } + if y != 0 { + borders.insert("top", day-sep-stroke) + } + return borders + }, + ..cells + ) +} + +#display-menus(days, categories) diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..225b5d4 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,4 @@ +requests==2.25.1 +beautifulsoup4==4.10.0 +lxml==4.8.0 +python-telegram-bot==21.5 \ No newline at end of file