├── needed_files ├── BotToken.txt └── database.db ├── .gitignore ├── src ├── david.ttf ├── main.py ├── scheduler.py ├── database.py ├── time_table_to_pdf.py ├── telegram_bot.py └── internet.py ├── requirements.txt ├── pull_script.sh ├── README.md └── LICENSE /needed_files/BotToken.txt: -------------------------------------------------------------------------------- 1 | enter your telegram bot token here 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv 3 | __pycache__ 4 | src/BotToken.txt 5 | src/database.db -------------------------------------------------------------------------------- /src/david.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomerMellick/Project_Moodle_Bot/HEAD/src/david.ttf -------------------------------------------------------------------------------- /needed_files/database.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomerMellick/Project_Moodle_Bot/HEAD/needed_files/database.db -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests~=2.28.1 2 | python-telegram-bot~=20.0a2 3 | schedule~=1.1.0 4 | Pillow~=9.3.0 5 | fpdf2~=2.5.7 -------------------------------------------------------------------------------- /pull_script.sh: -------------------------------------------------------------------------------- 1 | cd ~/bot_develop/Project_Moodle_Bot/src 2 | 3 | pkill -f bot_develop 4 | 5 | git checkout master 6 | 7 | git pull 8 | 9 | bash -c "exec -a bot_develop python main.py &" 10 | 11 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import telegram_bot 2 | import multiprocessing 3 | import scheduler 4 | 5 | 6 | def main(): 7 | token = open('BotToken.txt').readline().strip() 8 | scheduler_task = multiprocessing.Process(target=scheduler.schedule_messages, args=(token,)) 9 | scheduler_task.start() 10 | telegram_bot.start_telegram_bot(token) 11 | scheduler_task.close() 12 | 13 | 14 | if __name__ == '__main__': 15 | main() 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Orbit Moodle Bot # 2 | This bot is an easy interface to Orbit's and Moodle's websites. 3 | 4 | ![GitHub release (latest by date including pre-releases)](https://img.shields.io/github/v/release/TomerMellick/Project_Moodle_Bot?include_prereleases) 5 | ![GitHub tag (latest SemVer pre-release)](https://img.shields.io/github/v/tag/TomerMellick/Project_Moodle_Bot?include_prereleases) 6 | ![GitHub](https://img.shields.io/github/license/TomerMellick/Project_Moodle_Bot) 7 | 8 | ## How to Use ## 9 | 1. join the main bot via [this link](https://t.me/moodle_hadassah_bot) 10 | or the develop bot via [this link](https://t.me/hadassah_develop_bot). 11 | 2. send to him your Orbit's username and password. (username is your id number and password is not necessarily your Hadassa's Google password) 12 | 3. use any of its commands 13 | 14 | ## How to Open Your Own Bot ## 15 | 1. Create bot via [BotFather](https://t.me/BotFather) 16 | 2. Download the code 17 | 3. Install the requirements packages with `pip install requirements.txt` 18 | 4. Copy the files in the `needed_files` dir to the `src` dir 19 | 5. Change the `BotToken.txt` to your token 20 | 21 | 22 | ## Requirements ## 23 | 1. python 3.10 24 | 2. all packages from `requirements.txt` 25 | 26 | ## Commands ## 27 | start - start the telegram bot, wait for username and password 28 | set_year - set the year of the user 29 | update_user - update the user's info (ask again for username and password) 30 | get_grades - get all the grades of the student (include average grade) 31 | get_unfinished_events - get all unfinished events (from moodle) 32 | get_document - get document from the moodle 33 | update_schedule - update the schedule 34 | get_notebook - get notebook file 35 | get_upcoming_exams - get all upcoming exams 36 | get_grade_distribution - get distribution for specific grade 37 | change_password - change password in the orbit website 38 | get_time_table - get the timetable as a pdf file 39 | register_period - register to a period 40 | -------------------------------------------------------------------------------- /src/scheduler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import schedule 4 | import telegram 5 | import database 6 | import internet 7 | import time 8 | import telegram_bot 9 | 10 | 11 | async def async_send_messages(token, all_users, time_scope): 12 | await asyncio.gather(*[send_scheduled_event(token, user, time_scope) for user in all_users]) 13 | 14 | 15 | def once_a_day(token: str): 16 | all_users = database.get_users_by_schedule(1) 17 | time_scope = datetime.datetime.now() + datetime.timedelta(days=1) 18 | asyncio.run(async_send_messages(token, all_users, time_scope)) 19 | 20 | 21 | def once_a_week(token: str): 22 | all_users = database.get_users_by_schedule(2) 23 | time_scope = datetime.datetime.now() + datetime.timedelta(days=7) 24 | asyncio.run(async_send_messages(token, all_users, time_scope)) 25 | 26 | 27 | async def send_scheduled_event(token: str, user: database.User, time_scope: datetime.datetime): 28 | """ 29 | send the unfinished events by the given time scope 30 | :param token: 31 | :param user: 32 | :param time_scope: 33 | :return: 34 | """ 35 | bot = telegram.Bot(token) 36 | unfinished_events = internet.Internet(user.user_name, user.password).get_unfinished_events(time_scope) 37 | if not unfinished_events: 38 | return 39 | if unfinished_events.warnings: 40 | await telegram_bot.handle_warnings(unfinished_events.warnings, bot, user.user_id) 41 | if unfinished_events.error: 42 | await telegram_bot.handle_error(unfinished_events.error, bot, user.user_id) 43 | return 44 | events = unfinished_events.result 45 | events_text = "no events" 46 | if events: 47 | events_text = '\n---------------------------------------------\n'.join(f'{event.name}\n' 48 | f'{event.course_name}\n' 49 | f'{event.end_time}\n' 50 | f'{event.url}' 51 | for event in events) 52 | 53 | await bot.send_message(chat_id=user.user_id, text=events_text) 54 | 55 | 56 | def schedule_messages(token: str): 57 | """ 58 | schedule sending the messages and call the scheduled jobs 59 | :param token: 60 | :return: 61 | """ 62 | schedule.every().day.at("06:00").do(once_a_day, token) 63 | schedule.every().sunday.at("06:00").do(once_a_week, token) 64 | 65 | while True: 66 | schedule.run_pending() 67 | time.sleep(1) 68 | -------------------------------------------------------------------------------- /src/database.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from collections import namedtuple 3 | from typing import Iterator 4 | 5 | DATABASE = 'database.db' 6 | TABLE = 'users' 7 | 8 | User = namedtuple('User', 'user_id user_name password schedule_code, year') 9 | 10 | 11 | def add_user(user_id: int, user_name: str, password: str): 12 | """ 13 | adds a row to TABLE with the parameters 14 | if exist user_id, remove that row 15 | :param user_id: 16 | :param user_name: 17 | :param password: 18 | :return: 19 | """ 20 | # delete if exist 21 | delete_user(user_id) 22 | 23 | with sqlite3.connect(DATABASE) as con: 24 | curses = con.cursor() 25 | curses.execute(f'INSERT INTO {TABLE} VALUES(?,?,?,0,0)', (user_id, user_name, password)) 26 | 27 | 28 | def delete_user(user_id: int): 29 | """ 30 | delete a user by its id 31 | :param user_id: 32 | :return: 33 | """ 34 | with sqlite3.connect(DATABASE) as con: 35 | handle = con.cursor() 36 | handle.execute(f'DELETE FROM {TABLE} WHERE user_id = ?', (user_id,)) 37 | 38 | 39 | def get_user_by_id(user_id: int) -> User: 40 | """ 41 | gets the row of a user by id 42 | :param user_id: 43 | :return: the row as a tuple 44 | """ 45 | with sqlite3.connect(DATABASE) as con: 46 | handle = con.cursor() 47 | user_row = handle.execute(f'SELECT * FROM {TABLE} WHERE user_id=?', (user_id,)) 48 | user = user_row.fetchone() 49 | if not user: 50 | return user 51 | 52 | return User(*user) 53 | 54 | 55 | def get_all_users() -> Iterator[User]: 56 | """ 57 | :return: all the TABLE as sqlite3 object 58 | """ 59 | with sqlite3.connect(DATABASE) as con: 60 | handle = con.cursor() 61 | return (User(*user) for user in handle.execute(f'SELECT * FROM {TABLE}')) 62 | 63 | 64 | def update_schedule(user_id: int, schedule_code: int): 65 | """ 66 | updates the schedule_code field in a given user 67 | :param user_id: 68 | :param schedule_code: 69 | :return: 70 | """ 71 | with sqlite3.connect(DATABASE) as con: 72 | curses = con.cursor() 73 | curses.execute(f'UPDATE {TABLE} SET schedule_code =? WHERE user_id=? ', (schedule_code, user_id)) 74 | 75 | 76 | def update_year(user_id: int, year: int): 77 | """ 78 | updates the year field in a given user 79 | :param user_id: 80 | :param year: 81 | :return: 82 | """ 83 | with sqlite3.connect(DATABASE) as con: 84 | curses = con.cursor() 85 | curses.execute(f'UPDATE {TABLE} SET year =? WHERE user_id=? ', (year, user_id)) 86 | 87 | 88 | def get_users_by_schedule(schedule_code: int) -> Iterator[User]: 89 | """ 90 | 91 | :param schedule_code: 92 | :return: 93 | """ 94 | with sqlite3.connect(DATABASE) as con: 95 | handle = con.cursor() 96 | 97 | return (User(*user) for user in 98 | handle.execute(f'SELECT * FROM {TABLE} WHERE schedule_code=?', (schedule_code,))) 99 | -------------------------------------------------------------------------------- /src/time_table_to_pdf.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | from PIL import ImageFont 4 | from fpdf import FPDF 5 | from fpdf.enums import Align 6 | 7 | 8 | class HebrewTimeTablePDF(FPDF): 9 | __hebrew_chars = '()אבגדהוזחטיכלמנסעפצקרשתםךףץן ' 10 | __font = ImageFont.truetype("david.ttf", 10) 11 | 12 | def __init__(self, time_table_data, *args, **kwargs): 13 | super().__init__(*args, orientation='L', **kwargs) 14 | self.add_page() 15 | self.add_font("David", "", "david.ttf") 16 | self.set_font("David", size=10) 17 | self.set_title("time table") 18 | self.start_x, self.start_y = self.get_x(), self.get_y() 19 | self.draw_time_table(time_table_data) 20 | 21 | @staticmethod 22 | def __hebrew_fixer(text: str, max_width) -> str: 23 | new_text = [] 24 | for line in text.split('\n'): 25 | new_text.append('') 26 | for word in line.split(' '): 27 | if not new_text[-1]: 28 | new_text[-1] = word 29 | elif HebrewTimeTablePDF.get_width(new_text[-1] + ' ' + word) < max_width: 30 | new_text[-1] += ' ' + word 31 | else: 32 | new_text.append(word) 33 | 34 | new_text = [text for text in new_text][::-1] 35 | text = '\n'.join(new_text) 36 | if not text: 37 | return '' 38 | text = text[::-1] 39 | text = ''.join([chr(ord('(') + ord(')') - ord(char)) if char in '()' else char for char in text]) 40 | start = 0 41 | is_hebrew = text[0] in HebrewTimeTablePDF.__hebrew_chars 42 | end_text = '' 43 | for index, char in enumerate(text): 44 | if (char in HebrewTimeTablePDF.__hebrew_chars) != is_hebrew or char == '\n': 45 | if is_hebrew: 46 | end_text += text[start:index] 47 | else: 48 | if start > 0: 49 | end_text += text[index - 1:start - 1:-1] 50 | else: 51 | end_text += text[index - 1::-1] 52 | if char == '\n': 53 | is_hebrew = '\n' 54 | else: 55 | is_hebrew = char in HebrewTimeTablePDF.__hebrew_chars 56 | start = index 57 | if is_hebrew: 58 | end_text += text[start:] 59 | else: 60 | if start == 0: 61 | end_text += text[::-1] 62 | else: 63 | end_text += text[:start - 1:-1] 64 | return end_text 65 | 66 | @staticmethod 67 | def get_width(text: str) -> float: 68 | return HebrewTimeTablePDF.__font.getlength(text) / 2.69 69 | 70 | @staticmethod 71 | def get_days(time_table_data): 72 | days = [ 73 | "יום ראשון", 74 | "יום שני", 75 | "יום שלישי", 76 | "יום רביעי", 77 | "יום חמישי", 78 | "יום שישי" 79 | ] 80 | day_indexer = list(range(1, 7)) 81 | new_days = [] 82 | for day_index in range(len(days)): 83 | if any(time[1] == day_index for time in time_table_data): 84 | new_days.append(days[day_index]) 85 | else: 86 | for i in range(day_index, 6): 87 | day_indexer[i] -= 1 88 | return new_days, day_indexer 89 | 90 | def new_cell(self, x, y, w, h, txt='', fill=False, fill_color=0xffffff, heb=True, **kwargs): 91 | self.set_fill_color((fill_color >> (2 * 8)) & 0xff, 92 | (fill_color >> (1 * 8)) & 0xff, 93 | (fill_color >> (0 * 8)) & 0xff) 94 | self.set_xy(x, y) 95 | if heb: 96 | txt = self.__hebrew_fixer(txt, w) 97 | self.cell(w=w, h=h, txt=txt, fill=fill, align=Align.R, **kwargs) 98 | 99 | def new_multi_cell(self, x, y, w, h, txt='', fill=False, fill_color=0xffffff, heb=True, border=1): 100 | self.set_fill_color((fill_color >> (2 * 8)) & 0xff, 101 | (fill_color >> (1 * 8)) & 0xff, 102 | (fill_color >> (0 * 8)) & 0xff) 103 | self.new_cell(x=x, y=y, w=w, h=h, fill=fill, fill_color=fill_color, border=border) 104 | self.set_xy(x, y) 105 | if heb: 106 | txt = self.__hebrew_fixer(txt, w) 107 | self.multi_cell(w=w, txt=txt, align=Align.R) 108 | 109 | def draw_time_table(self, time_table_data): 110 | days, day_indexer = self.get_days(time_table_data) 111 | colum_width = self.epw / (len(days) + 1) 112 | line_height = self.font_size * 2.5 113 | 114 | # draw columns 115 | for index, colum in enumerate(["שעות"] + days): 116 | self.new_cell(x=self.start_x + colum_width * (len(days) - index), 117 | y=self.start_y, 118 | w=colum_width, 119 | h=self.eph, 120 | border=1, 121 | fill=True, 122 | fill_color=0xAAAAAA) 123 | self.new_cell(x=self.start_x + colum_width * (len(days) - index), 124 | y=self.start_y, 125 | w=colum_width, 126 | h=line_height, 127 | txt=colum, 128 | border=1, 129 | fill=True) 130 | 131 | self.start_y += line_height 132 | # draw hours 133 | min_hour = min(int(data[2]) for data in time_table_data) 134 | max_hour = max(math.ceil(data[3]) for data in time_table_data) 135 | hour_high = (self.eph - line_height) / (max_hour - min_hour) 136 | 137 | for i in range(max_hour - min_hour): 138 | self.new_cell(x=self.start_x + len(days) * colum_width, 139 | y=self.start_y + i * hour_high, 140 | w=colum_width, 141 | h=hour_high, 142 | fill=True, 143 | border=True, 144 | heb=False, 145 | txt=f'{str(min_hour + i).zfill(2)}:00 - {str(min_hour + i + 1).zfill(2)}:00') 146 | 147 | # draw data 148 | for my_class in time_table_data: 149 | self.new_multi_cell( 150 | x=self.start_x + (len(days) - day_indexer[my_class[1]]) * colum_width, 151 | y=self.start_y + (my_class[2] - min_hour) * hour_high, 152 | w=colum_width, 153 | h=(my_class[3] - my_class[2]) * hour_high, 154 | border=True, 155 | fill=True, 156 | txt=my_class[0] + '\n\n' + my_class[4] + '\n\n' + my_class[5] 157 | ) 158 | 159 | def get_output(self): 160 | return bytes(super().output()) 161 | 162 | -------------------------------------------------------------------------------- /src/telegram_bot.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import List 3 | from enum import Enum, auto 4 | 5 | 6 | import telegram 7 | from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton 8 | from telegram.ext import ApplicationBuilder, ContextTypes, CommandHandler, ConversationHandler, MessageHandler, \ 9 | filters, CallbackQueryHandler 10 | 11 | from internet import Internet, Document, documents_heb_name, documents_file_name 12 | import database 13 | 14 | users = {} 15 | 16 | 17 | def get_user(f): 18 | async def function(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): 19 | user = database.get_user_by_id(update.effective_chat.id) 20 | if not user: 21 | await enter_data(context.bot, update.effective_chat.id) 22 | return 23 | return await f(user, update, context, *args, **kwargs) 24 | 25 | return function 26 | 27 | 28 | def internet_func(internet_function, value_on_error=None, btn_name=None, btn_value_func=None, get_message=False): 29 | def decorator(function): 30 | @get_user 31 | async def actual_function(user: database.User, update: Update, context: ContextTypes.DEFAULT_TYPE): 32 | 33 | if btn_name: 34 | btn_value = update.callback_query.data[len(btn_name):] 35 | if btn_value_func: 36 | btn_value = btn_value_func(btn_value) 37 | res = internet_function(Internet(user), btn_value) 38 | elif get_message: 39 | res = internet_function(Internet(user), update.message.text) 40 | else: 41 | res = internet_function(Internet(user)) 42 | 43 | if res.warnings: 44 | await handle_warnings(res.warnings, context.bot, update.effective_chat.id) 45 | if res.error: 46 | await handle_error(res.error, context.bot, update.effective_chat.id) 47 | return value_on_error 48 | return await function(user, res.result, update, context) 49 | 50 | return actual_function 51 | 52 | return decorator 53 | 54 | 55 | class GetUser(Enum): 56 | GET_USERNAME = auto() 57 | GET_PASSWORD = auto() 58 | 59 | 60 | async def enter_data(bot: telegram.Bot, chat_id: int): 61 | await bot.send_message(chat_id=chat_id, 62 | text="must enter username and password before using this command\n" 63 | "/update_user") 64 | 65 | 66 | async def handle_warnings(warning: List[Internet.Warning], bot: telegram.Bot, chat_id: int): 67 | if Internet.Warning.CHANGE_PASSWORD in warning: 68 | await bot.send_message(chat_id=chat_id, 69 | text="warning: please change your username and password at the orbit website") 70 | 71 | 72 | async def handle_error(error: Internet.Error, bot: telegram.Bot, chat_id: int): 73 | if error is Internet.Error.ORBIT_DOWN: 74 | await bot.send_message(chat_id=chat_id, 75 | text="error: orbit website is down") 76 | elif error is Internet.Error.MOODLE_DOWN: 77 | await bot.send_message(chat_id=chat_id, 78 | text="error: moodle website is down") 79 | elif error is Internet.Error.WRONG_PASSWORD: 80 | await bot.send_message(chat_id=chat_id, 81 | text="error: username or password is incorrect use /update_user to update them") 82 | elif error is Internet.Error.BOT_ERROR: 83 | await bot.send_message(chat_id=chat_id, 84 | text="error: the bot did something stupid, please try again later") 85 | elif error is Internet.Error.CHANGE_PASSWORD: 86 | await bot.send_message(chat_id=chat_id, 87 | text="error: you need to change the password in the orbit site " 88 | "or by using /change_password command") 89 | elif error is Internet.Error.OLD_EQUAL_NEW_PASSWORD: 90 | await bot.send_message(chat_id=chat_id, 91 | text="error: Please enter password that was never in use or /cancel to cancel") 92 | 93 | 94 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): 95 | await context.bot.send_message(chat_id=update.effective_chat.id, 96 | text="Hello and welcome to our bot.\nPlease enter your orbit username") 97 | return GetUser.GET_USERNAME 98 | 99 | 100 | async def update_user(update: Update, context: ContextTypes.DEFAULT_TYPE): 101 | await context.bot.send_message(chat_id=update.effective_chat.id, text="Please enter your orbit username") 102 | return GetUser.GET_USERNAME 103 | 104 | 105 | async def change_password(update: Update, context: ContextTypes.DEFAULT_TYPE): 106 | await context.bot.send_message(chat_id=update.effective_chat.id, text="Please enter your new orbit password " 107 | "or /cancel to cancel") 108 | return GetUser.GET_PASSWORD 109 | 110 | 111 | @internet_func(Internet.change_password, value_on_error=GetUser.GET_PASSWORD, get_message=True) 112 | async def get_new_password(user, _, update: Update, context: ContextTypes.DEFAULT_TYPE): 113 | database.add_user(update.effective_chat.id, user.user_name, update.message.text) 114 | await context.bot.send_message(update.effective_chat.id, text="password changed successfully") 115 | return ConversationHandler.END 116 | 117 | 118 | async def get_username(update: Update, context: ContextTypes.DEFAULT_TYPE): 119 | users[update.effective_chat.id] = update.message.text 120 | await context.bot.send_message(chat_id=update.effective_chat.id, text="Please enter your orbit password") 121 | return GetUser.GET_PASSWORD 122 | 123 | 124 | async def get_password(update: Update, context: ContextTypes.DEFAULT_TYPE): 125 | username = users.pop(update.effective_chat.id) 126 | password = update.message.text 127 | database.add_user(update.effective_chat.id, username, password) 128 | await context.bot.deleteMessage(update.effective_chat.id, update.message.id) 129 | if Internet(database.User(update.effective_chat.id, username, password, 0, 0)).connect_orbit().result: 130 | await context.bot.send_message(chat_id=update.effective_chat.id, text="Thanks") 131 | return ConversationHandler.END 132 | else: 133 | await context.bot.send_message(chat_id=update.effective_chat.id, 134 | text="username or password are incorrect\n" 135 | "Please enter your username again") 136 | return GetUser.GET_USERNAME 137 | 138 | 139 | async def get_time_table(update: Update, context: ContextTypes.DEFAULT_TYPE): 140 | keyword = InlineKeyboardMarkup( 141 | [ 142 | [ 143 | InlineKeyboardButton(f'סמסטר א', callback_data=f'time_table_1'), 144 | InlineKeyboardButton(f'סמסטר ב', callback_data=f'time_table_2'), 145 | InlineKeyboardButton(f'סמסטר קיץ', callback_data=f'time_table_3') 146 | ] 147 | ] 148 | ) 149 | await context.bot.send_message(chat_id=update.effective_chat.id, text='select semester', reply_markup=keyword) 150 | 151 | 152 | @internet_func(Internet.get_time_table, btn_name='time_table_', btn_value_func=int) 153 | async def call_back_time_table_button(_, res, update: Update, context: ContextTypes.DEFAULT_TYPE): 154 | if res: 155 | await context.bot.send_document(update.effective_chat.id, res[1], filename=res[0]) 156 | else: 157 | await context.bot.send_message(update.effective_chat.id, "can't find the time table") 158 | 159 | 160 | @internet_func(Internet.get_grades) 161 | async def get_grades(_, grades, update: Update, context: ContextTypes.DEFAULT_TYPE): 162 | sum_grades = 0 163 | num_of_units = 0 164 | for grade in grades: 165 | if grade.grade.isdigit(): 166 | sum_grades += int(grade.grade) * grade.units 167 | num_of_units += grade.units 168 | avg = None 169 | if num_of_units > 0: 170 | avg = round(sum_grades / num_of_units, 2) 171 | 172 | grades_text = '\n'.join(f'{grade.name} - {grade.units} - {grade.grade}' for grade in grades if grade.grade != '') 173 | await context.bot.send_message(chat_id=update.effective_chat.id, text=grades_text + f'\n\n ממוצע: {avg}') 174 | 175 | 176 | @internet_func(Internet.get_years) 177 | async def set_year(_, years, update: Update, context: ContextTypes.DEFAULT_TYPE): 178 | keyword = InlineKeyboardMarkup( 179 | [[InlineKeyboardButton(f'default', callback_data=f'set_year_0')]] + 180 | [ 181 | [InlineKeyboardButton(f'{year}', callback_data=f'set_year_{year}')] 182 | for year in years 183 | ] 184 | ) 185 | await context.bot.send_message(chat_id=update.effective_chat.id, text='select year', reply_markup=keyword) 186 | 187 | 188 | @internet_func(Internet.get_grades) 189 | async def get_grade_distribution(_, grades, update: Update, context: ContextTypes.DEFAULT_TYPE): 190 | keyword = InlineKeyboardMarkup( 191 | [ 192 | [InlineKeyboardButton(f'{grade.name}', callback_data=f'grade_distribution_{grade.grade_distribution}')] 193 | for grade in grades if grade.grade_distribution 194 | ] 195 | ) 196 | await context.bot.send_message(chat_id=update.effective_chat.id, text='select subject', reply_markup=keyword) 197 | 198 | def format_delta_time(dtime: datetime.timedelta): 199 | return f'{dtime.days} days, {dtime.seconds // 3600} hours, {(dtime.seconds // 60) % 60} minutes' 200 | 201 | 202 | @internet_func(Internet.get_unfinished_events) 203 | async def get_unfinished_events(_, events, update: Update, context: ContextTypes.DEFAULT_TYPE): 204 | now = datetime.datetime.now() 205 | events_text = '\n---------------------------------------------\n'.join(f'{event.name}\n' 206 | f'{event.course_short_name}\n' 207 | f'{event.end_time}\n' 208 | f'{format_delta_time(event.end_time - now)}\n' 209 | f'{event.url}' 210 | for event in events) 211 | 212 | await context.bot.send_message(chat_id=update.effective_chat.id, text=events_text) 213 | 214 | 215 | async def update_schedule(update: Update, context: ContextTypes.DEFAULT_TYPE): 216 | keyboard = InlineKeyboardMarkup([[InlineKeyboardButton("once a day", callback_data='schedule_1'), 217 | InlineKeyboardButton("once a week", callback_data='schedule_2'), 218 | InlineKeyboardButton("never", callback_data='schedule_0')]]) 219 | 220 | await context.bot.send_message(chat_id=update.effective_chat.id, 221 | text="on what schedule would you like to get you unfinished events?", 222 | reply_markup=keyboard) 223 | 224 | 225 | async def get_document_buttons(update: Update, context: ContextTypes.DEFAULT_TYPE): 226 | keyboard = InlineKeyboardMarkup( 227 | [[InlineKeyboardButton(name, callback_data=f'document_{doc_num.value}')] for doc_num, name in 228 | documents_heb_name.items()]) 229 | 230 | await context.bot.send_message(chat_id=update.effective_chat.id, 231 | text="choose file to download", 232 | reply_markup=keyboard) 233 | 234 | 235 | 236 | # async def get_notebook(update: Update, context: ContextTypes.DEFAULT_TYPE): 237 | # data = database.get_user_by_id(update.effective_chat.id) 238 | # if not data: 239 | # await enter_data(context.bot, update.effective_chat.id) 240 | # return 241 | # exams = Internet(data.user_name, data.password).get_all_exams() 242 | # if exams.warnings: 243 | # await handle_warnings(exams.warnings, context.bot, update.effective_chat.id) 244 | # if exams.error: 245 | # await handle_error(exams.error, context.bot, update.effective_chat.id) 246 | # return 247 | # exams = exams.result 248 | # exams = [exam for exam in exams if exam.notebook] 249 | 250 | @internet_func(Internet.get_grade_distribution, btn_name='grade_distribution_') 251 | async def call_back_get_grade_distribution_button(_, 252 | grade_distribution, 253 | update: Update, 254 | context: ContextTypes.DEFAULT_TYPE): 255 | text = f'ציונך: {grade_distribution.grade}\n' \ 256 | f'ממוצע: {grade_distribution.average}\n' \ 257 | f'ס.ת: {grade_distribution.standard_deviation}\n' \ 258 | f'דירוג: {grade_distribution.position}\n' 259 | await context.bot.send_message(update.effective_chat.id, text=text) 260 | await context.bot.send_photo(update.effective_chat.id, grade_distribution.image) 261 | 262 | 263 | @internet_func(Internet.get_all_exams) 264 | async def get_notebook(_, exams, update: Update, context: ContextTypes.DEFAULT_TYPE): 265 | exams = [exam for exam in exams if exam.notebook_url] 266 | exams.sort(key=lambda a: a.time_start, reverse=True) 267 | keyboard = InlineKeyboardMarkup( 268 | [[InlineKeyboardButton(f'{exam.name} {exam.period}', callback_data=f'notebook_{exam.number}')] 269 | for exam in exams]) 270 | 271 | await context.bot.send_message(chat_id=update.effective_chat.id, 272 | text="choose notebook to download", 273 | reply_markup=keyboard) 274 | 275 | @internet_func(Internet.get_all_exams) 276 | async def register_period(_, exams, update: Update, context: ContextTypes.DEFAULT_TYPE): 277 | exams.sort(key=lambda a: a.time_start, reverse=False) 278 | buttons = [] 279 | for exam in exams: 280 | if exam.register: 281 | buttons.append([InlineKeyboardButton(f'רישום {exam.name} {exam.period}', 282 | callback_data=f'register_period_1_{exam.number}')]) 283 | elif exam.cancel_register: 284 | buttons.append([InlineKeyboardButton(f'ביטול רישום {exam.name} {exam.period}', 285 | callback_data=f'register_period_0_{exam.number}')]) 286 | keyboard = InlineKeyboardMarkup(buttons) 287 | 288 | await context.bot.send_message(chat_id=update.effective_chat.id, 289 | text="register or cancel register", 290 | reply_markup=keyboard) 291 | 292 | 293 | 294 | # @internet_func(Internet.register_exam, btn_name='register_period_', btn_value_func=lambda x: Document(int(x))) 295 | # todo: add this decorator (some variables in the function) 296 | async def call_back_register_button(update: Update, context: ContextTypes.DEFAULT_TYPE): 297 | data = database.get_user_by_id(update.effective_chat.id) 298 | if not data: 299 | await enter_data(context.bot, update.effective_chat.id) 300 | return 301 | register_data = update.callback_query.data[len('register_period_'):].split('_') 302 | register_data[0] = register_data[0] == '1' 303 | res = Internet(data).register_exam(register_data[1], register_data[0]) 304 | if res.error: 305 | await handle_error(res.error, context.bot, update.effective_chat.id) 306 | return 307 | exams = Internet(data).get_all_exams() 308 | if exams.error: 309 | await handle_error(exams.error, context.bot, update.effective_chat.id) 310 | return 311 | exams = exams.result 312 | buttons = [] 313 | for exam in exams: 314 | if exam.register: 315 | buttons.append([InlineKeyboardButton(f'רישום {exam.name} {exam.period}', 316 | callback_data=f'register_period_1_{exam.number}')]) 317 | elif exam.cancel_register: 318 | buttons.append([InlineKeyboardButton(f'ביטול רישום {exam.name} {exam.period}', 319 | callback_data=f'register_period_0_{exam.number}')]) 320 | keyboard = InlineKeyboardMarkup(buttons) 321 | 322 | await context.bot.edit_message_reply_markup(update.effective_chat.id, update.effective_message.id, reply_markup=keyboard) 323 | 324 | 325 | @internet_func(Internet.get_all_exams) 326 | async def get_upcoming_exams(_, exams, update: Update, context: ContextTypes.DEFAULT_TYPE): 327 | now = datetime.datetime.now() 328 | exams = [exam for exam in exams if exam.time_start > now] 329 | exams.sort(key=lambda a: a.time_start) 330 | text = '\n--------\n\n'.join(f'{exam.name} {exam.period}\n{exam.room}\n{exam.time_start}\n{exam.time_end}' 331 | for exam in exams) 332 | 333 | await context.bot.send_message(chat_id=update.effective_chat.id, 334 | text=text) 335 | 336 | 337 | @internet_func(Internet.get_classes) 338 | async def register_class(_, classes, update: Update, context: ContextTypes.DEFAULT_TYPE): 339 | keyboard = InlineKeyboardMarkup( 340 | [[InlineKeyboardButton(my_class, callback_data=f'register_class_{my_class}')] for my_class in classes]) 341 | await context.bot.send_message(chat_id=update.effective_chat.id, text="select class", reply_markup=keyboard) 342 | 343 | 344 | @internet_func(Internet.register_for_class, btn_name='register_class_') 345 | async def call_back_register_class_button(_, res, update: Update, context: ContextTypes.DEFAULT_TYPE): 346 | register_message = '\n-------\n'.join(f'{lesson[1]}-{lesson[2]}' for lesson in res[0]) 347 | if register_message: 348 | register_message = "register to:\n" + register_message 349 | unregister_message = '\n-------\n'.join(f'{lesson[1]}-{lesson[2]}' for lesson in res[1]) 350 | if unregister_message: 351 | unregister_message = "\nproblem with:\n" + unregister_message 352 | message = register_message + unregister_message 353 | await context.bot.send_message(chat_id=update.effective_chat.id, text=message) 354 | 355 | 356 | @internet_func(Internet.get_exam_notebook, btn_name='notebook_', btn_value_func=int) 357 | async def call_back_notebook_button(_, notebook, update: Update, context: ContextTypes.DEFAULT_TYPE): 358 | await context.bot.send_document(update.effective_chat.id, notebook, filename=f'notebook.pdf') 359 | 360 | 361 | @internet_func(Internet.get_document, btn_name='document_', btn_value_func=lambda x: Document(int(x))) 362 | async def call_back_document_button(_, doc_value, update: Update, context: ContextTypes.DEFAULT_TYPE): 363 | doc = Document(int(update.callback_query.data[len('document_'):])) 364 | await context.bot.send_document(update.effective_chat.id, doc_value, filename=documents_file_name[doc]) 365 | 366 | 367 | async def call_back_schedule_button(update: Update, context: ContextTypes.DEFAULT_TYPE): 368 | value_int = int(update.callback_query.data[len('schedule_'):]) 369 | value_text = ['never', 'once a day', 'once a week'][value_int] 370 | database.update_schedule(update.effective_chat.id, value_int) 371 | 372 | await context.bot.send_message(chat_id=update.effective_chat.id, 373 | text=f"schedule message to unfinished events set to `{value_text}`") 374 | 375 | async def call_back_set_year_button(update: Update, context: ContextTypes.DEFAULT_TYPE): 376 | value_int = int(update.callback_query.data[len('set_year_'):]) 377 | database.update_year(update.effective_chat.id, value_int) 378 | 379 | await context.bot.send_message(chat_id=update.effective_chat.id, 380 | text=f"year set to `{value_int if value_int else 'default'}`") 381 | 382 | 383 | async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE): 384 | await context.bot.send_message(update.effective_chat.id, text="operation canceled") 385 | return ConversationHandler.END 386 | 387 | 388 | 389 | login_info_handler = ConversationHandler( 390 | entry_points=[CommandHandler("start", start), CommandHandler('update_user', update_user)], 391 | states={ 392 | GetUser.GET_USERNAME: [MessageHandler(filters.TEXT, get_username)], 393 | GetUser.GET_PASSWORD: [MessageHandler(filters.TEXT | filters.COMMAND, get_password)], 394 | }, 395 | fallbacks=[], 396 | ) 397 | change_password_handler = ConversationHandler( 398 | entry_points=[CommandHandler("change_password", change_password)], 399 | states={ 400 | GetUser.GET_PASSWORD: [MessageHandler(filters.TEXT & (~ filters.COMMAND), get_new_password)] 401 | }, 402 | fallbacks=[CommandHandler("cancel", cancel)], 403 | ) 404 | 405 | 406 | def start_telegram_bot(token: str): 407 | application = ApplicationBuilder().token(token).build() 408 | application.add_handler(CommandHandler('get_grades', get_grades)) 409 | application.add_handler(CommandHandler('get_unfinished_events', get_unfinished_events)) 410 | application.add_handler(CommandHandler('get_document', get_document_buttons)) 411 | application.add_handler(CommandHandler('update_schedule', update_schedule)) 412 | application.add_handler(CommandHandler('get_notebook', get_notebook)) 413 | application.add_handler(CommandHandler('get_upcoming_exams', get_upcoming_exams)) 414 | application.add_handler(CommandHandler('register_period', register_period)) 415 | application.add_handler(CommandHandler('get_grade_distribution', get_grade_distribution)) 416 | application.add_handler(CommandHandler('register_class', register_class)) 417 | application.add_handler(CommandHandler('set_year', set_year)) 418 | application.add_handler(CommandHandler('get_time_table', get_time_table)) 419 | 420 | application.add_handler(login_info_handler) 421 | application.add_handler(change_password_handler) 422 | 423 | application.add_handler(CallbackQueryHandler(call_back_document_button, pattern=r'^document_')) 424 | application.add_handler(CallbackQueryHandler(call_back_schedule_button, pattern=r'^schedule_')) 425 | application.add_handler(CallbackQueryHandler(call_back_notebook_button, pattern=r'^notebook_')) 426 | application.add_handler(CallbackQueryHandler(call_back_register_button, pattern=r'^register_period_')) 427 | application.add_handler(CallbackQueryHandler(call_back_set_year_button, pattern=r'^set_year_')) 428 | application.add_handler(CallbackQueryHandler(call_back_register_class_button, pattern=r'^register_class_')) 429 | application.add_handler(CallbackQueryHandler(call_back_time_table_button, pattern=r'^time_table_')) 430 | application.add_handler(CallbackQueryHandler(call_back_get_grade_distribution_button, 431 | pattern=r'^grade_distribution_')) 432 | 433 | application.run_polling() 434 | 435 | 436 | if __name__ == '__main__': 437 | start_telegram_bot(open('BotToken.txt').readline()) 438 | -------------------------------------------------------------------------------- /src/internet.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from enum import Enum 3 | from urllib.parse import urlencode, quote 4 | from collections import namedtuple 5 | from datetime import datetime 6 | from typing import Union, List, Optional 7 | import requests 8 | import html 9 | import json 10 | import re 11 | import database 12 | from time_table_to_pdf import HebrewTimeTablePDF 13 | 14 | Grade = namedtuple('Grade', 'name units grade grade_distribution') 15 | Exam = namedtuple('Exam', 'name period time_start time_end mark room notebook_url register cancel_register number') 16 | Event = namedtuple('event', 'course_short_name name course_name course_id end_time url') 17 | Res = namedtuple('Result', 'result warnings error') 18 | GradesDistribution = namedtuple('GradesDistribution', 'grade average standard_deviation position image') 19 | 20 | 21 | class Document(Enum): 22 | STUDENT_PERMIT_E = 0 23 | STUDENT_PERMIT = 1 24 | TUITION_FEE_APPROVAL = 2 25 | REGISTRATION_CONFIRMATION = 3 26 | GRADES_SHEET_E = 4 27 | GRADES_SHEET = 5 28 | ENGLISH_LEVEL = 13 29 | 30 | 31 | documents_heb_name = { 32 | Document.STUDENT_PERMIT_E: 'v12 אישור לימודים באנגלית לסטודנט', 33 | Document.STUDENT_PERMIT: 'V45- אישור לימודים מפורט', 34 | Document.TUITION_FEE_APPROVAL: 'אישור גובה שכר לימוד', 35 | Document.REGISTRATION_CONFIRMATION: 'אישור הרשמה', 36 | Document.GRADES_SHEET_E: 'גליון ציונים באנגלית', 37 | Document.GRADES_SHEET: 'גליון ציונים', 38 | Document.ENGLISH_LEVEL: 'רמת אנגלית' 39 | } 40 | documents_file_name = { 41 | Document.STUDENT_PERMIT_E: 'student_permit_english.pdf', 42 | Document.STUDENT_PERMIT: 'student_permit.pdf', 43 | Document.TUITION_FEE_APPROVAL: 'tuition_fee_approval.pdf', 44 | Document.REGISTRATION_CONFIRMATION: 'registration_confirmation.pdf', 45 | Document.GRADES_SHEET_E: 'english_grades_sheet.pdf', 46 | Document.GRADES_SHEET: 'grades_sheet.pdf', 47 | Document.ENGLISH_LEVEL: 'english_level.pdf' 48 | } 49 | 50 | 51 | def required_decorator(required_function): 52 | """ 53 | decorator for needed function to works 54 | :param required_function: 55 | :return: 56 | """ 57 | 58 | def actual_decorator(function): 59 | f""" 60 | decorator for function that need `{required_function}` function to work 61 | :param function: the warped function 62 | :return: the final function 63 | """ 64 | 65 | @functools.wraps(function) 66 | def actual_function(self, *args, **kwargs): 67 | res, warnings, error = required_function(self) 68 | if error: 69 | return Res(False, warnings[:], error) 70 | return function(self, res, warnings[:], *args, **kwargs) 71 | 72 | return actual_function 73 | 74 | return actual_decorator 75 | 76 | 77 | class Internet: 78 | """ 79 | This class communicate with orbit and moodle. 80 | """ 81 | __ORBIT_URL = 'https://live.or-bit.net/hadassah' 82 | __MAIN_URL = f'{__ORBIT_URL}/Main.aspx' 83 | __LOGIN_URL = f'{__ORBIT_URL}/Login.aspx' 84 | __CHANGE_PASSWORD_URL = f'{__ORBIT_URL}/ChangePassword.aspx' 85 | __CONNECT_MOODLE_URL = f'{__ORBIT_URL}/Handlers/Moodle.ashx' 86 | __GET_DOCUMENT_URL = f'{__ORBIT_URL}/DocumentGenerationPage.aspx' 87 | __GRADE_LIST_URL = f'{__ORBIT_URL}//StudentGradesList.aspx' 88 | __EXAMS_URL = f'{__ORBIT_URL}/StudentAssignmentTermList.aspx' 89 | __SET_SCHEDULE_URL = f'{__ORBIT_URL}/CreateStudentWeeklySchedule.aspx' 90 | __TIME_TABLE_URL = f'{__ORBIT_URL}/StudentPeriodSchedule.aspx' 91 | 92 | __MOODLE_URL = 'https://mowgli.hac.ac.il' 93 | __MY_MOODLE = f'{__MOODLE_URL}/my/' 94 | __MOODLE_SERVICE_URL = f'{__MOODLE_URL}/lib/ajax/service.php' 95 | 96 | def __init__(self, user: database.User): 97 | self.session = requests.session() 98 | self.moodle_res = Res(False, [], None) 99 | self.orbit_res = Res(False, [], None) 100 | self.user = user 101 | 102 | class Error(Enum): 103 | ORBIT_DOWN = 0 104 | MOODLE_DOWN = 1 105 | WRONG_PASSWORD = 2 106 | BOT_ERROR = 3 107 | WEBSITE_DOWN = 4 108 | CHANGE_PASSWORD = 5 109 | OLD_EQUAL_NEW_PASSWORD = 6 110 | 111 | class Warning(Enum): 112 | CHANGE_PASSWORD = 0 113 | 114 | def connect_orbit(self) -> Res: 115 | """ 116 | connect to robit website using the username and password 117 | if this object already connected the method do nothing (and return the res of the first time tried to connect) 118 | :return: is the method successfully connect to orbit 119 | """ 120 | if self.orbit_res.result: 121 | return self.orbit_res 122 | 123 | orbit_login_website = self.__get(Internet.__LOGIN_URL) 124 | 125 | if orbit_login_website.status_code != 200: 126 | self.orbit_res = Res(False, [], Internet.Error.ORBIT_DOWN) 127 | return self.orbit_res 128 | 129 | login_data = self.__get_hidden_inputs(orbit_login_website.text) 130 | login_data.update( 131 | { 132 | 'edtUsername': self.user.user_name, 133 | 'edtPassword': self.user.password, 134 | '__LASTFOCUS': '', 135 | '__EVENTTARGET': '', 136 | '__EVENTARGUMENT': '', 137 | 'btnLogin': 'כניסה' 138 | } 139 | ) 140 | orbit_website = self.__post(Internet.__LOGIN_URL, payload_data=login_data) 141 | 142 | if orbit_website.status_code != 200 or orbit_website.url == Internet.__LOGIN_URL: 143 | self.orbit_res = Res(False, [], Internet.Error.WRONG_PASSWORD) 144 | return self.orbit_res 145 | 146 | if orbit_website.url == Internet.__CHANGE_PASSWORD_URL: 147 | if self.__get(Internet.__MAIN_URL).url == Internet.__CHANGE_PASSWORD_URL: 148 | self.orbit_res = Res(False, [], Internet.Error.CHANGE_PASSWORD) 149 | return self.orbit_res 150 | self.orbit_res.warnings.append(Internet.Warning.CHANGE_PASSWORD) 151 | 152 | if self.user.year: 153 | inputs = self.__get_hidden_inputs(orbit_website.text) 154 | self.__post(Internet.__MAIN_URL, payload_data=inputs) 155 | 156 | self.orbit_res = Res(True, self.orbit_res.warnings, None) 157 | return self.orbit_res 158 | 159 | @required_decorator(connect_orbit) 160 | def connect_moodle(self, _, warnings) -> Res: 161 | """ 162 | connect to moodle website 163 | :return: is the method successfully connect to moodle 164 | """ 165 | if self.moodle_res.result: 166 | return self.moodle_res 167 | 168 | moodle_session = self.__get(Internet.__CONNECT_MOODLE_URL) 169 | if moodle_session.status_code != 200: 170 | self.moodle_res = Res(False, warnings, Internet.Error.MOODLE_DOWN) 171 | return self.moodle_res 172 | 173 | reg = re.search("URL='(.*?)'", moodle_session.text) 174 | if not reg: 175 | self.moodle_res = Res(False, warnings, Internet.Error.BOT_ERROR) 176 | 177 | redirect_url = reg[1] 178 | moodle_website = self.__get(redirect_url) 179 | if moodle_website.status_code != 200 or moodle_website.url != Internet.__MY_MOODLE: 180 | self.moodle_res = Res(False, warnings, Internet.Error.MOODLE_DOWN) 181 | return self.moodle_res 182 | self.moodle_res = Res(True, warnings, None) 183 | return self.moodle_res 184 | 185 | @required_decorator(connect_orbit) 186 | def get_years(self, _, warnings) -> Res: 187 | """ 188 | get all the years from that can be picked 189 | """ 190 | website = self.__get(Internet.__MAIN_URL) 191 | years_regex = '" 644 | hidden_inputs = re.findall(hidden_input_regex, text, re.DOTALL) 645 | if self.user.year: 646 | hidden_inputs.append(('ctl00$cmbActiveYear', self.user.year)) 647 | else: 648 | year_regex = '