import base64 import json import os import re import threading import time import traceback from dataclasses import dataclass from pathlib import Path import json5 import requests import schedule as schedule from bs4 import BeautifulSoup from telegram import Update from telegram.ext import Updater, CallbackContext, Dispatcher, CommandHandler, MessageHandler, \ Filters @dataclass class MenuItem: name: str serving_size: str price: float calories: int # menu_id_map[menu name] = menu id MenuIdMap = dict[str, str] # dining_halls[hall name][menu name] = menu id DiningHalls = dict[str, MenuIdMap] RawMenu = list[list[str]] Menu = dict[str, list[MenuItem]] def report(msg: str) -> None: print(msg) def get_dining_halls() -> DiningHalls: r: str = requests.get('https://fso.ueat.utoronto.ca/FSO/ServiceMenuReport/Today').text m: list[str] = re.findall(r"ASPx\.createControl\(MVCxClientMenu,'mnuUnits','',.*", r) if len(m) == 0: raise AssertionError('Failed to get menu units. Maybe API changed?') data = m[0] data = data.replace("ASPx.createControl(MVCxClientMenu,'mnuUnits','',", '')[:-2] data = re.sub(r"{'ItemClick':function.*?;}},", '', data) b64 = re.findall(r"(?<={'itemsInfo':')[A-Za-z0-9=]*(?='})", data)[0] b64 = base64.b64decode(b64).decode('utf-8') items_json = re.findall(r"(?<={'items':).*(?=})", data)[0] items = json5.loads(items_json)[0]['items'] dining_halls: DiningHalls = {} # Get dining hall names for i in items: first_id = i['items'][0]['name'] name = re.findall(f"(?<=.)[A-Za-z0-9() ]+(?=.*\\${first_id})", b64)[-1] dining_halls[name] = {} # Get menu names for menu in i['items']: mn = re.findall(f"(?<=\\${menu['name']}).*?[A-Za-z0-9() ]+(?=.)", b64)[0] mn = re.findall(r"[A-Za-z0-9() ]+", mn)[0] dining_halls[name][mn] = menu['name'] return dining_halls def get_menu(id: str) -> tuple[RawMenu, Menu]: r = requests.post(f" https://fso.ueat.utoronto.ca/FSO/ServiceMenuReport/GetReport/{id}").text s = BeautifulSoup(r, 'html.parser') # Parse table t = s.find('table') d = [[i.text.strip() for i in r.find_all('td')] for r in t.find_all('tr')] data = {} current: list[MenuItem] = [] for i in d[1:]: # Title if len(i) == 1: current = [] data[i[0]] = current # Item else: current.append(MenuItem(i[0], i[1], float(i[2] or '-1'), int(i[3] or '-1'))) return d, data def filter_menu(menu: Menu) -> Menu: menu: Menu = \ {m: [i for i in menu[m] if i.price != -1] for m in menu} for m in menu: for i in menu[m]: i.name = i.name.replace(' - Small', '').replace(' - Large', '') names = {n.name for n in menu[m]} menu[m] = [[i for i in menu[m] if i.name == n][0] for n in names] return menu if __name__ == '__main__': # Find telegram token path = Path(os.path.abspath(__file__)).parent db_path = path.joinpath('menu_bot_database.json') if 'tg_token' in os.environ: tg_token = os.environ['tg_token'] else: with open(path.joinpath('menu_bot_token.txt'), 'r', encoding='utf-8') as f: tg_token = f.read().strip() # Telegram login updater = Updater(token=tg_token, use_context=True) dispatcher: Dispatcher = updater.dispatcher # Database def save_db(): with open(db_path, 'w', encoding='utf-8') as f: json.dump(database, f) schedule.clear() for d in database: def func(): menu_helper(d[0], d[1]) schedule.every().day.at('07:00').do(func) database: list[tuple[int, list[str]]] if os.path.isfile(db_path): with open(db_path, 'r', encoding='utf-8') as f: database = json.load(f) save_db() else: database = [] def r(u: Update, msg: str, md=True): updater.bot.sendMessage(chat_id=u.effective_chat.id, text=msg, parse_mode='Markdown' if md else None) def start(u: Update, c: CallbackContext): r(u, 'Hi, start with /halls') def error(u: Update, c: CallbackContext): traceback.print_exc() r(u, str(c.error), False) def dining_halls(u: Update, c: CallbackContext): halls = get_dining_halls() r(u, '*Available Dining Halls:* \n' + '\n'.join(halls.keys()) + '\n\nNext: /menus ') def get_hall_with_name(hall: str) -> tuple[str, MenuIdMap]: hall = hall.lower() halls = get_dining_halls() h = [h for h in halls if h.lower().startswith(hall)] if len(h) == 0: raise AssertionError(f'No dining hall {hall} found.') return h[0], halls[h[0]] def get_menu_with_name(hall: str, menu: str) -> tuple[str, str, Menu]: menu = menu.lower() hall, h = get_hall_with_name(hall) m = [m for m in h if m.lower().startswith(menu)] if len(m) == 0: raise AssertionError(f'No menu {menu} found in {hall}.') return hall, m[0], get_menu(h[m[0]])[1] def get_menu_cats(hall: str, menu: str, cats: list[str]) -> tuple[str, str, Menu]: hall, menu, m = get_menu_with_name(hall, menu) m = filter_menu(m) copy_cats = cats.copy() cats = [c.lower() for c in cats] cats = [([n for n in m if n.lower().startswith(c)] or [''])[0] for c in cats] cats = [c for c in cats if c != ''] if len(cats) == 0: raise AssertionError(f'No categories in {copy_cats} are valid.') m = {c: m[c] for c in cats} return hall, menu, m def menus(u: Update, c: CallbackContext): if len(c.args) != 1: r(u, 'Usage: /menus ') return hall = c.args[0] hall, h = get_hall_with_name(hall) r(u, '*Available Menus:* \n' + '\n'.join(h.keys()) + '\n\nNext: /cats ') def categories(u: Update, c: CallbackContext): if len(c.args) < 2: r(u, 'Usage: /categories ') return hall, menu = c.args hall, menu, m = get_menu_with_name(hall, menu) r(u, '*Available Menus:* \n' + '\n'.join(m.keys()) + '\n\nNext: /menu ') def menu_helper(chat_id: int, args: list[str]): print('Menu helper called.', chat_id, args) hall, menu = args[:2] cats = args[2:] hall, menu, m = get_menu_cats(hall, menu, cats) for n in m: m[n].sort(key=lambda x: -x.price) msg = f"*Today's Menu for {menu}:* \n" + \ '\n'.join(f"\n*{n}:* \n" + '\n'.join( f"{i + 1}. {m[n][i].name} - ${m[n][i].price}" for i in range(len(m[n]))) for n in m) updater.bot.sendMessage(chat_id=chat_id, text=msg, parse_mode='Markdown') def menu(u: Update, c: CallbackContext): if len(c.args) < 2: r(u, 'Usage: /menu ') return menu_helper(u.effective_chat.id, c.args) def channel_update(u: Update, c: CallbackContext): if u.channel_post is None or u.channel_post.text is None: return args = u.channel_post.text.split() cmd = args[0] args = args[1:] id = u.effective_chat.id if cmd == '/config': database.append((id, args)) save_db() menu_helper(id, args) r(u, 'Scheduled every day at 7:00.') elif cmd == '/clear': to_remove = [d for d in database if d[0] == id] [database.remove(d) for d in to_remove] save_db() elif cmd == '/debug': r(u, json.dumps(database)) # Scheduler thread def thread_func(): while True: schedule.run_pending() time.sleep(2) threading.Thread(target=thread_func).start() # Commands dispatcher.add_error_handler(error) dispatcher.add_handler(CommandHandler('start', start)) dispatcher.add_handler(CommandHandler('halls', dining_halls)) dispatcher.add_handler(CommandHandler('menus', menus)) dispatcher.add_handler(CommandHandler('categories', categories)) dispatcher.add_handler(CommandHandler('cats', categories)) dispatcher.add_handler(CommandHandler('menu', menu)) dispatcher.add_handler(MessageHandler(Filters.update, channel_update)) updater.start_polling() # # menu = filter_menu(get_menu('f1343803-84f6-4b4f-bbfd-a374ed6bd00e')[1]) # menu = {m: menu[m] for m in ['Soup', 'Lunch Entree', 'Pan Station']}