diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..63bbefa --- /dev/null +++ b/.flake8 @@ -0,0 +1,11 @@ +[flake8] +exclude = + .git, + __pycache__, + docs/source/conf.py, + old, + build, + dist, + venv/*, + +max-complexity = 10 \ No newline at end of file diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..91f332c --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,33 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics \ No newline at end of file diff --git a/.gitignore b/.gitignore index fabc081..89ba654 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,12 @@ +# native +token.pickle +reminder.csv +credentials.json + +# virtual env +bin/ +pyvenv.cfg + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -115,4 +124,4 @@ venv.bak/ dmypy.json # Pyre type checker -.pyre/ \ No newline at end of file +.pyre/ diff --git a/Procfile b/Procfile index bb83ddf..9aab65d 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -bot: python laundrybot.py \ No newline at end of file +bot: python main/laundrybot.py diff --git a/README.md b/README.md index 4867ba5..7ce7cc3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # NUSCollegeLaundryBot Telegram BOT interface for the Laundry Machine Bot Project; made with python-telegram-bot wrapper; - made for RC4 by RC4Space + made for RC4 by Orcatech(RC4Space) # The Bot ## Interface diff --git a/laundrybot.py b/laundrybot.py deleted file mode 100644 index e0c8328..0000000 --- a/laundrybot.py +++ /dev/null @@ -1,192 +0,0 @@ -# Laundry Bot for RC4, current telegram handle: @RC4LaundryBot - -import os -import re -import logging -import requests -from datetime import datetime -import time -from emoji import emojize -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ParseMode -from telegram.ext import Updater, CommandHandler, CallbackQueryHandler - -# Set up logging -logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO) -logger = logging.getLogger(__name__) - -# Initialize global variables -RC_URL = "https://us-central1-rc4laundrybot.cloudfunctions.net/readData/RC4-" -LAUNDRY_LEVELS = [5, 8, 11, 14, 17] -MACHINES_INFO = { - 'washer1': 'Washer 1', - 'washer2': 'Washer 2', - 'dryer1': 'Dryer 1', - 'dryer2': 'Dryer 2' -} - -# Building menu for every occasion -def build_menu(buttons, n_cols, header_buttons=None, footer_buttons=None): - menu = [buttons[i:i + n_cols] for i in range(0, len(buttons), n_cols)] - if header_buttons: - menu.insert(0, header_buttons) - if footer_buttons: - menu.append(footer_buttons) - return InlineKeyboardMarkup(menu) - -# Building emojis for every occasion -ebluediamond = emojize(":small_blue_diamond: ", use_aliases=True) -etick = emojize(":white_check_mark: ", use_aliases=True) -ecross = emojize(":x: ", use_aliases=True) -esoon = emojize(":soon: ", use_aliases=True) - -# start command initializes: -def check_handler(bot, update, user_data): - user = update.message.from_user - if 'pinned_level' in user_data: - level_status(bot, update, user_data, - from_pinned_level=True, new_message=True) - else: - ask_level(bot, update) - -def ask_level(bot, update): - level_text = "Heyyo! I am RC4's Laundry Bot. As I am currently in [BETA] mode, I can only show details for Ursa floor.\n\nWhich laundry level do you wish to check?" - level_buttons = [] - for level in LAUNDRY_LEVELS: - label = 'Level {}'.format(level) - data = 'set_L{}'.format(level) - buttons = InlineKeyboardButton(text=label, callback_data=data) # data callback to set_pinned_level - level_buttons.append(buttons) - update.message.reply_text(text=level_text, - reply_markup=build_menu(level_buttons, 1), - parse_mode=ParseMode.HTML) - -def set_pinned_level(bot, update, user_data): - query = update.callback_query - level = int(re.match('^set_L(5|8|11|14|17)$', query.data).group(1)) - user_data['pinned_level'] = level - - level_status(bot, update, user_data, from_pinned_level=True) - -# Carves the status text for each level -def make_status_text(level_number): - laundry_data = '' - floor_url = RC_URL + str(level_number) - - # Get Request to the database backend - machine_status = requests.get(floor_url).json() - - for machine_id in MACHINES_INFO: - if machine_status[machine_id] == 0: - status_emoji = etick - elif machine_status[machine_id] == 1: - status_emoji = ecross - else: - status_emoji = esoon - machine_name = MACHINES_INFO[machine_id] - laundry_data += '{} {}\n'.format(status_emoji, machine_name) - - # TODO: This should be the backend server time instead - current_time = datetime.fromtimestamp(time.time() + 8*3600).strftime('%d %B %Y %H:%M:%S') - - return "Showing statuses for Level {}:\n\n" \ - "{}\n" \ - "Last updated: {}\n".format(level_number, laundry_data, current_time) - -# Create the status menu which contains the help command, a pinned level number, and refresh button -def make_status_menu(level_number): - level_buttons = [] - - for level in LAUNDRY_LEVELS: - label = 'L{}'.format(level) - data = 'check_L{}'.format(level) - if level == level_number: - # label = u'\u2022 ' + label + u' \u2022' - label = ebluediamond + label - - button = InlineKeyboardButton(text=label, callback_data=data) - level_buttons.append(button) - - refresh_button = [InlineKeyboardButton( - text='Refresh', - callback_data='check_L{}'.format(level_number) - )] - - help_button = [InlineKeyboardButton( - text='Help', - callback_data='Help' - )] - - return build_menu(level_buttons, 5, footer_buttons=refresh_button, header_buttons=help_button) - -def level_status(bot, update, user_data, from_pinned_level=False, new_message=False): - query = update.callback_query - if from_pinned_level: - level = user_data['pinned_level'] - else: - level = int(re.match('^check_L(5|8|11|14|17)$', query.data).group(1)) - - user_data['check_level'] = level - - if new_message: - update.message.reply_text(text=make_status_text(level), - reply_markup=make_status_menu(level), - parse_mode=ParseMode.HTML) - else: - bot.edit_message_text(text=make_status_text(level), - chat_id=query.message.chat_id, - message_id=query.message.message_id, - reply_markup=make_status_menu(level), - parse_mode=ParseMode.HTML) - - -def help_menu(bot, update, user_data, from_pinned_level=False, new_message=False): - query = update.callback_query - help_text = "Help\n\n" + "Washer 1 and Dryer 2 accept coins\n" + etick + "= Available / Job done\n" + esoon + "= Job finishing soon\n" + ecross + "= In use\n" - help_text += "\nInformation not accurate or faced a problem? Please message @PakornUe or @Cpf05, thank you!" - help_text += "\n\nThis is a project by RC4Space's Laundry Bot Team. We appreciate your feedback as we are currently still beta-testing in Ursa before launching the college-wide implementation! :)" - - level = user_data['check_level'] - - help_menu_button = [InlineKeyboardButton( - text='Back', - callback_data='check_L{}'.format(level) - )] - - bot.edit_message_text(text=help_text, - chat_id=query.message.chat_id, - message_id=query.message.message_id, - reply_markup=build_menu(help_menu_button, 1), - parse_mode=ParseMode.HTML) - - -def error(bot, update, error): - logger.warning('Update "%s" caused error "%s"', update, error) - -def main(): - TOKEN = os.environ['RC4LAUNDRYBOT_TOKEN'] - #NAME = 'nuscollegelaundrybot' - #PORT = os.environ.get('PORT') - - updater = Updater(TOKEN) - dp = updater.dispatcher - - dp.add_handler(CommandHandler('start', check_handler, pass_user_data=True)) - dp.add_handler(CallbackQueryHandler(set_pinned_level, - pattern='^set_L(5|8|11|14|17)$', - pass_user_data=True)) - dp.add_handler(CallbackQueryHandler(level_status, - pattern='^check_L(5|8|11|14|17)$', - pass_user_data=True)) - dp.add_handler(CallbackQueryHandler(help_menu, - pattern='Help', - pass_user_data=True)) - dp.add_error_handler(error) - - #updater.start_webhook(listen="0.0.0.0", port=int(PORT), url_path=TOKEN) - #updater.bot.setWebhook("https://{}.herokuapp.com/{}".format(NAME, TOKEN)) - updater.start_polling() - updater.idle() - -if __name__ == '__main__': - main() diff --git a/__init__.py b/main/__init__.py similarity index 100% rename from __init__.py rename to main/__init__.py diff --git a/main/data.py b/main/data.py new file mode 100644 index 0000000..f96d666 --- /dev/null +++ b/main/data.py @@ -0,0 +1,45 @@ +from datetime import datetime, timedelta + +class MockData(): + def getLevels(self): + return [5, 8, 11, 14, 17] + + def getStatuses(self, level): + count = 4 + + machine_types = [ + "washer-coin", + "washer-ezlink", + "dryer-ezlink", + "dryer-coin" + ] + start_times = [ + datetime.now() - timedelta(seconds=100), + datetime.now() - timedelta(seconds=200), + datetime.now() - timedelta(seconds=300), + datetime.now() - timedelta(seconds=400) + ] + statuses = [0, 2, 1, 2] + machine_durations = [30, 30, 40, 40] + + return [ + { + "level": level, + "type": machine_types[i], + "start-time": start_times[i], + "status": statuses[i], + "time": datetime.now(), + "machine-duration": machine_durations[i] + } for i in range(0, count) + ] + + def charts(self): + chart = {"Mon": [], "Tue": [], "Wed": [], "Thu": [], "Fri": []} + n = 0 + + for day in chart: + chart[day] = [x**2 + n for x in range(0, 72)] + n += 1 + + return chart + \ No newline at end of file diff --git a/main/laundrybot.py b/main/laundrybot.py new file mode 100644 index 0000000..7088322 --- /dev/null +++ b/main/laundrybot.py @@ -0,0 +1,518 @@ +# Laundry Bot for RC4, current telegram handle: @RC4LaundryBot + +import os +import csv +import re +import logging +import requests +import threading +from datetime import datetime +import time +from emoji import emojize +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ParseMode, ReplyKeyboardMarkup, KeyboardButton +from telegram.ext import Updater, CommandHandler, CallbackQueryHandler, MessageHandler, ConversationHandler, Filters +from sheets import add_response + +from reminders import ReminderList +from data import MockData +from string import Template +import pandas as pd + +# Set up logging +logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO) +logger = logging.getLogger(__name__) +logging.getLogger('googleapiclient.discovery_cache').setLevel(logging.ERROR) # Silence oauth2client ImportError + +# Initialize global variables +RC_URL = "https://us-central1-rc4laundrybot.cloudfunctions.net/readData/RC4-" +LAUNDRY_LEVELS = [5, 8, 11, 14, 17] +MACHINES_INFO = { + 'washer-coin': 'Washer 1', + 'washer-ezlink': 'Washer 2', + 'dryer-ezlink': 'Dryer 1', + 'dryer-coin': 'Dryer 2' +} +user_response = '' +DATA = MockData() +REMINDERS = ReminderList() + + +# Building menu for every occasion +def build_menu(buttons, n_cols, header_buttons=None, row_buttons=None, + footer_buttons=None): + menu = [buttons[i:i + n_cols] for i in range(0, len(buttons), n_cols)] + if header_buttons: + menu.insert(0, header_buttons) + if row_buttons: + for b in row_buttons: + menu.append(b) + if footer_buttons: + menu.append(footer_buttons) + return InlineKeyboardMarkup(menu) + + +# Checks whether 'pinned_level' is null +def pinned_level_exists(bot, update, user_data, chat_id=None): + if 'pinned_level' in user_data: + return True + + text = "The message you clicked is outdated. Please enter /start first before proceeding." + if chat_id: + bot.send_message( + text=text, + chat_id=chat_id + ) + else: + bot.send_message( + text=text, + chat_id=update.callback_query.message.chat_id + ) + + return False + + +# Building emojis for every occasion +ebluediamond = emojize(":small_blue_diamond: ", use_aliases=True) +etick = emojize(":white_check_mark: ", use_aliases=True) +ecross = emojize(":x: ", use_aliases=True) +esoon = emojize(":soon: ", use_aliases=True) +ehourglass = emojize(":hourglass:", use_aliases=True) +ebarchart = emojize(':bar_chart:', use_aliases=True) +ealarm = emojize(':alarm_clock:', use_aliases=True) +erefresh = emojize(':arrows_counterclockwise:', use_aliases=True) +equestion = emojize(':question:', use_aliases=True) + + +# start command initializes: +def check_handler(bot, update, user_data): + + logging.info("Checking handler of chat id {}".format(update.message.chat_id)) + + # user = update.message.from_user + if 'pinned_level' in user_data: + level_status(bot, update, user_data, + from_pinned_level=True, new_message=True) + else: + ask_level(bot, update) + + +def ask_level(bot, update): + + logging.info("Ask level of chat id {}".format(update.message.chat_id)) + + level_text = "Heyyo! I am RC4's Laundry Bot. As I am currently in [BETA] mode, "\ + "I can only show details for Ursa floor.\n\nWhich laundry level do you wish to check?" + level_buttons = [] + for level in LAUNDRY_LEVELS: + label = 'Level {}'.format(level) + data = 'set_L{}'.format(level) + buttons = InlineKeyboardButton(text=label, callback_data=data) # data callback to set_pinned_level + level_buttons.append(buttons) + update.message.reply_text(text=level_text, + reply_markup=build_menu(level_buttons, 1), + parse_mode=ParseMode.HTML) + + +def set_pinned_level(bot, update, user_data): + + query = update.callback_query + level = int(re.match('^set_L(5|8|11|14|17)$', query.data).group(1)) + user_data['pinned_level'] = level + + logging.info("Set chat id {} pinned level as {}".format(query.message.chat_id, level)) + + level_status(bot, update, user_data, from_pinned_level=True) + + +# Generate a Hour Minute Second template +class DeltaTemplate(Template): + ''' Set a template for input data ''' + delimiter = "%" + +# Change from timedelta to H M S format without unecessary microsecond +def strfdelta(tdelta, fmt): + ''' Format timedelta object to the format of string fmt + + Parameter + ---------- + tdelta: datetime.timedelta + The datetime.timedelta object that needs to be formatted + fmt: str + The format string (eg. %H:%M:%S) + + Returns + ------- + str + a formmated time string + ''' + + d = {"D": tdelta.days} + hours, rem = divmod(tdelta.seconds, 3600) + minutes, seconds = divmod(rem, 60) + d["H"] = '{:02d}'.format(hours) + d["M"] = '{:02d}'.format(minutes) + d["S"] = '{:02d}'.format(seconds) + t = DeltaTemplate(fmt) + return t.substitute(**d) + + +# Carves the status text for each level +def make_status_text(level_number): + laundry_data = '' + floor_url = RC_URL + str(level_number) + # TODO: This should be the backend server time instead + current_time = datetime.fromtimestamp(time.time() + 8*3600).strftime('%d %B %Y %H:%M:%S') + + # Get Request to the database backend + # machine_status = requests.get(floor_url).json() + + # Use mock data + machine_data = DATA.getStatuses(level_number) + + for machine in machine_data: + # Get data from back end - time since request/refresh + remaining_time = datetime.today() - machine["start-time"] + remaining_time = strfdelta(remaining_time, '%H:%M:%S') + + if machine["status"] == 0: + status_emoji = etick + else: + status_emoji = f'{ehourglass} {remaining_time} |' + + machine_name = machine["type"] + laundry_data += '{} {}\n'.format(status_emoji, machine_name) + + return "Showing statuses for Level {}:\n\n" \ + "{}\n" \ + "Last updated: {}\n".format(level_number, laundry_data, current_time) + + +# Create the status menu which contains the help command, a pinned level number, and refresh button +def make_status_menu(level_number): + level_buttons = [] + + for level in LAUNDRY_LEVELS: + label = 'L{}'.format(level) + data = 'check_L{}'.format(level) + if level == level_number: + # label = u'\u2022 ' + label + u' \u2022' + label = ebluediamond + label + + button = InlineKeyboardButton(text=label, callback_data=data) + level_buttons.append(button) + + refresh_button = [InlineKeyboardButton( + text='Refresh {}'.format(erefresh), + callback_data='check_L{}'.format(level_number) + )] + + help_button = [InlineKeyboardButton( + text='Help', + callback_data='Help' + )] + + report_button = [InlineKeyboardButton( + text='Something wrong?', + callback_data='Report' + )] + + reminder_button = [InlineKeyboardButton( + text="Set a reminder {}".format(ealarm), + callback_data="remind" + )] + + insights_button = [InlineKeyboardButton( + text="Get Chart Insights {}".format(ebarchart), + callback_data="insight" + )] + + row_buttons = [reminder_button, insights_button] + + header_buttons = help_button + report_button + + logging.info("Returning menu") + + return build_menu(level_buttons, 5, footer_buttons=refresh_button, + header_buttons=header_buttons, row_buttons=row_buttons) + + +def level_status(bot, update, user_data, from_pinned_level=False, new_message=False): + + if not pinned_level_exists(bot, update, user_data): + return + + logging.info("Chat id {} checks level {} status".format( + update.callback_query.message.chat_id, + user_data['pinned_level'] + )) + + query = update.callback_query + if from_pinned_level: + level = user_data['pinned_level'] + else: + level = int(re.match('^check_L(5|8|11|14|17)$', query.data).group(1)) + + if new_message: + logging.info("Chat id {} sent first status message".format( + update.callback_query.message.chat_id + )) + update.message.reply_text(text=make_status_text(level), + reply_markup=make_status_menu(level), + parse_mode=ParseMode.HTML) + else: + logging.info("Chat id {} edited status message".format( + update.callback_query.message.chat_id + )) + bot.edit_message_text( + text=make_status_text(level), + chat_id=query.message.chat_id, + message_id=query.message.message_id, + reply_markup=make_status_menu(level), + parse_mode=ParseMode.HTML + ) + + +def help_menu(bot, update, user_data, from_pinned_level=False, new_message=False): + + if not pinned_level_exists(bot, update, user_data): + return + + query = update.callback_query + help_text = "Help\n\n" + "Washer 1 and Dryer 2 accept coins\n" \ + + etick + "= Available / Job done\n" + esoon + "= Job finishing soon\n" + ecross + "= In use\n" + + help_text += "\nInformation not accurate or faced a problem? "\ + "Please message @PakornUe or @Cpf05, thank you!" + help_text += "\n\nThis is a project by RC4Space's Laundry Bot Team. "\ + "We appreciate your feedback as we are currently still beta-testing "\ + "in Ursa before launching the college-wide implementation! :)" + + level = user_data['pinned_level'] + + help_menu_button = [InlineKeyboardButton( + text='Back', + callback_data='check_L{}'.format(level) + )] + + bot.edit_message_text( + text=help_text, + chat_id=query.message.chat_id, + message_id=query.message.message_id, + reply_markup=build_menu(help_menu_button, 1), + parse_mode=ParseMode.HTML + ) + + +def report(bot, update, user_data, from_pinned_level=False, new_message=False): + + if not pinned_level_exists(bot, update, user_data): + return + + query = update.callback_query + + text = 'Is there anything wrong? Tell me in a couple of sentences.\n' + text += 'Elaborate on any incorrect information!' + bot.send_message( + text = text, + chat_id = query.message.chat_id + ) + return 1 + + +def get_response_ask_consent(bot, update, user_data): + global user_response + + user_input = update.message.text + user_response = user_input + query = update.callback_query + + yes_button = KeyboardButton(text='Yes', callback_data='Yes') + no_button = KeyboardButton(text='No', call_back='No') + consent_button = [yes_button, no_button] + + text = 'Thank you for reporting!\n' + text += 'Do you consent to give this information along with your telegram use ID to the developer team?' + + bot.send_message( + text = text, + chat_id=update.message.chat_id, + reply_markup=ReplyKeyboardMarkup(keyboard=[consent_button], one_time_keyboard=True) + ) + + return 2 + + +def get_consent_end(bot, update, user_data): + user_input = update.message.text + query = update.callback_query + + if user_input == 'Yes': + text = 'Huge thanks!\n' + username = update.message.chat.username + level = user_data['pinned_level'] + add_response(username, level, user_response) + else: + text = 'Okay, your response was not recorded.\n' + text += 'Enter /start to restart the bot.' + + bot.send_message( + text = text, + chat_id = update.message.chat_id + ) + + return ConversationHandler.END + +def error(bot, update, error): + logger.warning('Update "%s" caused error "%s"', update, error) + +def remind(bot, update, user_data): + ''' Set up reminder function interface + + User will be bring to a prompt which show machines that are in used + and can be set a reminder for (machines that are not in used cannot + be set a reminder function). + + When machine buttons is clicked: + data will be sent to add_reminder() + When back button is clicked: + user will be sent back to origianl prompt + ''' + + if not pinned_level_exists(bot, update, user_data): + return + + query = update.callback_query + level = user_data['pinned_level'] + selection = [] + + #Mock test + machine_data = DATA.getStatuses(level) + + question = "Which machine on Level {} do you like to set a reminder for?\n".format(level) \ + + "You can only set reminders for busy machines." + + # Put in-use machines in selection list + for machine in machine_data: + if machine["status"] != 0: + label = machine['type'] + data = machine['type'] + button = InlineKeyboardButton(text=label, callback_data=data) + selection.append(button) + + back_button = [InlineKeyboardButton( + text='Back', + callback_data='check_L{}'.format(level) + )] + + bot.edit_message_text( + text=question, + chat_id=query.message.chat_id, + message_id=query.message.message_id, + reply_markup=build_menu(selection, len(selection), footer_buttons=back_button), + parse_mode=ParseMode.HTML + ) + +def add_reminder(bot, update, user_data): + ''' Append current time, username, level and machine to Google sheet + + Append current date, current time, username, level and machine data to Google sheet + to save reminders. The data is saved in Laundrybot sheet under Reminder tab. + ''' + query = update.callback_query + + # User inputs + input_data = { + 'current_date': datetime.fromtimestamp(time.time() + 8*3600).strftime('%d %B %Y'), + 'current_time': datetime.fromtimestamp(time.time() + 8*3600).strftime('%H:%M:%S'), + 'username': query['from_user']['username'], + 'level': user_data['pinned_level'], + 'machine-type': query['data'], + } + + # Mock data for temporary use + machine_data = list( + filter( + lambda x: x['type'] == input_data['machine-type'], + DATA.getStatuses(input_data['level']) + ) + ).pop() + + notice = 'A reminder has been set for Level {} {}'.format(input_data['level'],input_data['machine-type']) + + # The following is the format of reminders logging + REMINDERS.append({ + 'username': query['from_user']['username'], + 'chat_id': query.message.chat_id, + 'input_data': input_data, + 'machine_data': machine_data + }) + + # with open('../reminder.csv', "a",newline='') as file: + # file_reader = csv.reader(file,delimiter=',') + # fieldsnames = ['current_date','current_time','username','level','machine-type'] + # writer = csv.DictWriter(file,fieldnames=fieldsnames) + # writer.writerow(input_data) + + back_button = [InlineKeyboardButton( + text='Back', + callback_data='check_L{}'.format(input_data['level']) + )] + + bot.edit_message_text( + text=notice, + chat_id=query.message.chat_id, + message_id=query.message.message_id, + reply_markup=build_menu(back_button, 1), + parse_mode=ParseMode.HTML, + ) + +def run_bot(updater): + + dp = updater.dispatcher + + # dp.add_handler used to receive back querry + # to call a function using a button, passed in pattern= callback_data + dp.add_handler(CommandHandler('start', check_handler, pass_user_data=True)) + dp.add_handler(CallbackQueryHandler(set_pinned_level, + pattern='^set_L(5|8|11|14|17)$', + pass_user_data=True)) + dp.add_handler(CallbackQueryHandler(level_status, + pattern='^check_L(5|8|11|14|17)$', + pass_user_data=True)) + dp.add_handler(CallbackQueryHandler(help_menu, + pattern='Help', + pass_user_data=True)) + dp.add_handler(CallbackQueryHandler(remind, + pattern='remind', + pass_user_data=True)) + dp.add_handler(CallbackQueryHandler(add_reminder, + pattern='^(washer-coin|washer-ezlink|dryer-ezlink|dryer-coin)$', + pass_user_data=True)) + dp.add_handler(ConversationHandler( + [CallbackQueryHandler(report, pattern="Report", pass_user_data=True)], + { + 1: [MessageHandler(Filters.text, get_response_ask_consent, pass_user_data=True)], + 2: [MessageHandler(Filters.text, get_consent_end, pass_user_data=True)] + }, + [])) + dp.add_error_handler(error) + + updater.start_polling() + updater.idle() + +def main(): + + TOKEN = os.environ['RC4LAUNDRYBOT_TOKEN'] + + updater = Updater(TOKEN) + + t = threading.Thread(target=REMINDERS.poll, args=(updater.bot,)) + t.start() + + run_bot(updater) + + + +if __name__ == '__main__': + main() diff --git a/main/reminders.py b/main/reminders.py new file mode 100644 index 0000000..926d711 --- /dev/null +++ b/main/reminders.py @@ -0,0 +1,56 @@ +import datetime +import time + +from emoji import emojize +from telegram import ParseMode + +REMINDER_FILE_PATH = '../reminder.csv' + +class ReminderList(list): + + """ + Format for each reminder is + { + 'username': query['from_user']['username'], + 'chat_id': query.message.chat_id, + 'input_data': input_data, + 'machine_data': machine_data + } + """ + + def poll(self, bot): + while(True): + for reminder in self: + + remaining_time = ( + reminder['machine_data']['start-time'] + + datetime.timedelta(minutes=reminder['machine_data']['machine-duration']) + ) - datetime.datetime.fromtimestamp(time.time() + 8*3600) + + print( + "Reminder remaining time for user {}: ".format(reminder['username']) + + str(remaining_time.seconds) + "\n" + ) + + if remaining_time < datetime.timedelta(minutes=5): + x = self.pop(0) + + remaining_time_text = " ".join([ + str(remaining_time.seconds // 60), "minutes", + str(remaining_time.seconds % 60), "seconds" + ]) + + text = "The {} machine in level {} is done in {}. ".format( + x['machine_data']['type'], + x['machine_data']['level'], + remaining_time_text + ) + "Get your clothes ready!" + emojize(":basket:", use_aliases=True) + + print(x) + + bot.send_message( + text=text, + chat_id=x['chat_id'], + parse_mode=ParseMode.HTML + ) + time.sleep(60) diff --git a/main/sheets.py b/main/sheets.py new file mode 100644 index 0000000..11b2848 --- /dev/null +++ b/main/sheets.py @@ -0,0 +1,67 @@ +import json +import pickle +import os.path +from googleapiclient.discovery import build +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request +from datetime import datetime + +FILE_PATH = os.path.dirname(__file__) +SCOPES = ['https://www.googleapis.com/auth/spreadsheets'] +SPREADSHEET_ID = '1Wu2fL9DMmroz4PM7iNE-wf7IfqEAho4ArJ9L-zzxrxo' +RANGE_NAME = 'Error!A2:D' + + +def authorize_creds(): + + creds = None + + if os.path.exists(os.path.join(FILE_PATH, '../token.pickle')): + with open(os.path.join(FILE_PATH, '../token.pickle'), 'rb') as token: + creds = pickle.load(token) + + # If there are no (valid) credentials available, let the user log in. + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + elif not os.path.exists(os.path.join(FILE_PATH, '../credentials.json')): + json_str = os.environ.get('CREDENTIALS_JSON') + print(json.dumps(json.loads(json_str), indent=4, sort_keys=True)) + with open(os.path.join(FILE_PATH, '../credentials.json'), 'w') as f: + f.write(json_str) + f.close() + + flow = InstalledAppFlow.from_client_secrets_file( + os.path.join(FILE_PATH, '../credentials.json'), SCOPES) + creds = flow.run_console() + + # Save the credentials for the next run + with open(os.path.join(FILE_PATH, '../token.pickle'), 'wb') as token: + pickle.dump(creds, token) + + return creds + + +def add_response(username, level, response): + + # Authorize requests to read/write + creds = authorize_creds() + + service = build('sheets', 'v4', credentials=creds) + + # Call the Sheets API + sheet = service.spreadsheets() + + response_time = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + values = [[response_time, username, level, response]] + body = {'values': values} + + # Append the data on the last row + result = sheet.values().append( + spreadsheetId=SPREADSHEET_ID, range=RANGE_NAME, + valueInputOption='RAW', body=body).execute() + + +if __name__ == '__main__': + print("Testing credentials...") + print(authorize_creds()) diff --git a/reminder.csv b/reminder.csv new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index e28231c..ef05431 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ -python_telegram_bot==10.0.1 -telegram==0.0.1 +python_telegram_bot==12.8 emoji -requests \ No newline at end of file +requests +google-api-python-client +google-auth-httplib2 +google-auth-oauthlib +pandas diff --git a/runtime.txt b/runtime.txt new file mode 100644 index 0000000..0fd6938 --- /dev/null +++ b/runtime.txt @@ -0,0 +1 @@ +python-3.8.6