diff --git a/basic_sitter_bot_with_pickle.py b/basic_sitter_bot_with_pickle.py index 6564945..9e62c62 100644 --- a/basic_sitter_bot_with_pickle.py +++ b/basic_sitter_bot_with_pickle.py @@ -34,6 +34,15 @@ def bot(): response = f'Okay, I added {sitter_name.title()} to sitters, with phone # {sitter_num}. ' print(sitters) + elif any(remove_word in body for remove_word in ['remove', 'delete']): + + try: + sitter_name = remove_sitter(body) + except KeyError: + response = 'No such sitter. Please write "delete [sitter\'s first name]."' + else: + response = f'Okay, I removed {sitter_name.title()} from the sitters.' + resp = MessagingResponse() resp.message(response) return str(resp) @@ -55,10 +64,22 @@ def add_sitter(body: str) -> Tuple[str, str]: assert len(num_only) == 10 - sitters[lowercase_name] = f'+1{num_only}' + phone_number = f'+1{num_only}' + sitters[lowercase_name] = {'num': phone_number, + 'name': lowercase_name} persist_sitters() - return name, sitters[lowercase_name] + return name, phone_number + + +def remove_sitter(body: str) -> str: + sitter_first_name = body.split(' ')[1] + sitter = sitters.get(sitter_first_name) + if sitter is None: + raise KeyError + del sitters[sitter_first_name] + persist_sitters() + return sitter_first_name def persist_sitters(): diff --git a/requirements.txt b/requirements.txt index d578e30..20f2de4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ click==6.7 decorator==4.0.11 falcon==1.2.0 Flask==0.12.2 +future==0.16.0 google-api-python-client==1.6.2 httplib2==0.10.3 hug==2.3.0 @@ -16,6 +17,7 @@ jedi==0.10.2 Jinja2==2.9.6 MarkupSafe==1.0 oauth2client==4.1.1 +parsedatetime==2.4 parso==0.1.0 pexpect==4.2.1 pickleshare==0.7.4 diff --git a/sitter_bot.py b/sitter_bot.py index 1b30ef1..74709f1 100644 --- a/sitter_bot.py +++ b/sitter_bot.py @@ -1,8 +1,12 @@ import datetime +from multiprocessing import Process import os -from typing import Optional, Tuple +import time +from typing import Tuple, Dict +import parsedatetime as pdt import pickle + from flask import request, Flask from twilio.rest import Client as TwilioClient from twilio.twiml.messaging_response import MessagingResponse @@ -14,31 +18,26 @@ BOOKER_NUM = os.getenv('MY_TWILIO_NUM') COUNTRY_CODE = f'+{os.getenv("TWILIO_COUNTRY_CODE")}' TIMEOUT_MINUTES = 120 -sitters = {} -if os.path.exists('sitters.p'): - sitters = pickle.load(open('sitters.p', 'rb')) - -bookings = {} -if os.path.exists('bookings.p'): - bookings = pickle.load(open('bookings.p', 'rb')) - help_add = 'You can add a sitter by giving me their first name and 10-digit phone number' help_text = help_add + ', or book a sitter by ' \ - 'specifying a date and time. You can also remove a sitter from the list ' \ - 'with "delete" or "remove" and then their first name.' + 'specifying a date and time. You can also remove a sitter from the list ' \ + 'with "delete" or "remove" and then their first name.' app = Flask(__name__) app.config.from_object(__name__) - -class NoneAvailable(Exception): - pass +cal = pdt.Calendar() -def say_hi_ask_for_sitters(): - message = "Hey, can you tell me your sitters' info one at a time? " \ - "First name then phone num. By the way, if you need help, type 'halp' at any time" - twilio_client.api.account.messages.create(to=MY_CELL, from_=BOOKER_NUM, body=message) +def load_from_pickle(var_name: str) -> dict: + payload = {} + if os.path.exists(f'{var_name}.p'): + payload = pickle.load(open(f'{var_name}.p', 'rb')) + return payload + + +sitters, bookings = load_from_pickle('sitters'), load_from_pickle('bookings') +sitters_num_name_lookup = {v['num']: k for k, v in sitters.items()} @app.route('/bot', methods=['POST']) @@ -47,66 +46,222 @@ def bot() -> str: body = request.values.get('Body').lower() resp = MessagingResponse() - response = None + response = '' - if not from_ == MY_CELL: - return str(resp.message('')) + if from_ == MY_CELL: + + if has_phone_num(body): + + try: + sitter_name, sitter_num = add_sitter(body) + except (AssertionError, ValueError): + response = 'Sorry, did you mean to add a sitter? Please try again.' + else: + response = f'Okay, I added {sitter_name.title()} to sitters, with phone # {sitter_num}. ' + + elif any(remove_word in body for remove_word in ['remove', 'delete']): + + try: + sitter_name = remove_sitter(body) + except KeyError: + response = 'No such sitter. Please write "delete [sitter\'s first name]."' + else: + response = f'Okay, I removed {sitter_name.title()} from the sitters.' - if 'halp' in body: - if not sitters: - response = f'You don\'t have any sitters yet. {help_add}.' else: - sitter_list = 'Your sitters are ' + ' and '.join( - f'{sitter_name.title()}' for sitter_name in sitters) + '.' - response = f'{sitter_list} {help_text}' + try: + start_time, end_time = request_booking(body) + except ValueError: + response = 'Please specify an end time (e.g. "tomorrow 5pm to 10pm").' + else: + booking_string = make_booking_string(start_time, end_time) + response = f'Okay, I will reach out to the sitters about sitting on {booking_string}.' - elif has_phone_num(body): - - try: - sitter_name, sitter_num = add_sitter(body) - except (AssertionError, ValueError): - response = 'Sorry, did you mean to add a sitter? Please try again.' - else: - response = f'Okay, I added {sitter_name.title()} to sitters, with phone # {sitter_num}. ' - - elif any(remove_word in body for remove_word in ['remove', 'delete']): - - try: - sitter_name = remove_sitter(body) - except KeyError: - response = 'No such sitter. Please write "delete [sitter\'s first name]."' - else: - response = f'Okay, I removed {sitter_name.title()} from the sitters.' + if response is None: + response = 'I wasn\'t sure what to do with your input. ' + help_text else: - try: - book_sitter(body) - except (AssertionError, ValueError): - response = 'Sorry, did you mean to book a sitter? Please try again.' - if response is None: - response = 'I wasn\'t sure what to do with your input. ' + help_text + sitter_name = sitters_num_name_lookup.get(from_) + if sitter_name is not None: + response = accept_or_decline(sitter_name, body) resp.message(response) return str(resp) +def accept_or_decline(sitter_name: str, body: str) -> str: + body = body.strip() + + global bookings + global sitters + bookings, sitters = load_from_pickle('bookings'), load_from_pickle('sitters') + sitter = sitters[sitter_name] + + sitter_offers = [k for k, v in bookings.items() + if sitter_name in v['offered'] + if v['offered'][sitter_name] not in ['yes', 'no']] + + if body not in ['yes', 'no', 'n', 'y'] and not body.isnumeric(): + return f'Hm, I\'m not sure what you meant, {sitter_name.title()}. Please write "yes", "no", ' \ + f'or a number (if there are any pending bookings).' + + action = None + + if not body.isnumeric(): + action = 'accept' if body in ['yes', 'y'] else 'decline' + + if len(sitter_offers) == 0: + return f'Sorry, {sitter_name.title()}, it looks like either that gig ' \ + f'is already booked or there aren\'t any pending gigs.' + + elif len(sitter_offers) == 1: + offer = sitter_offers[0] + + else: + sitter_offers_string = ", ".join([f'{idx + 1}) {make_booking_string(*sitter_offer)}' + for idx, sitter_offer in enumerate(sitter_offers)]) + + if body.isnumeric(): + try: + offer = sitter_offers[int(body) - 1] + except IndexError: + action = sitter['next action'] + return f'Sorry, which booking did you want to {action}? {sitter_offers_string}' + else: + sitter['next action'] = action + persist_sitters() + return f'Sorry, which booking did you want to {action}? {sitter_offers_string}' + + try: + action = action or sitter.pop('next action') + except KeyError: + raise KeyError(f'no next action, and sitter_offers is {sitter_offers}, so offer is {offer}.') + booking_string = make_booking_string(*offer) + + if action == 'accept': + + if not bookings.get(offer): + return f'Sorry, {sitter_name.title()}, it looks like {booking_string} is already booked.' + + if any(bookings[offer]['offered'][sitter_] == 'yes' + for sitter_ in bookings[offer]['offered'].keys()): + if bookings[offer]['offered'][sitter_name] == 'yes': + return f'You already accepted {booking_string}, {sitter_name.title()}!' + return f'Sorry, {sitter_name.title()}, it looks like {booking_string} is already booked.' + + bookings[offer]['offered'][sitter_name] = 'yes' + persist_bookings() + update_client(f'{sitter_name.title()} agreed to babysit on {booking_string}!') + return f'Awesome, {sitter_name.title()}! See you on {booking_string}.' + + else: + if bookings[offer]['offered'][sitter_name] == 'yes': + return f'You already accepted {booking_string}, {sitter_name.title()}!' + + bookings[offer]['offered'][sitter_name] = 'no' + persist_bookings() + return f'Okay, no problem, {sitter_name.title()}! Next time.' + + +def make_booking_string(start_time: datetime.datetime, end_time: datetime.time) -> str: + start_time_and_date_string = start_time.strftime('%-m/%-d from %-I:%M%p') + end_time_string = end_time.strftime('%-I:%M%p') + return f'{start_time_and_date_string} to {end_time_string}' + + +def book_forever(): + while True: + + sitters_, bookings_ = load_from_pickle('sitters'), load_from_pickle('bookings') + + if bookings_: + + bookings_keys_to_delete = [] + + for booking_start_and_end, offered_dict in bookings_.items(): + + booking = bookings_[booking_start_and_end] + offers = booking['offered'] + + if any(v == 'yes' for k, v in offers.items()): + continue + + sitter_to_offer_name = None + booking_string = make_booking_string(*booking_start_and_end) + + if len(offers) == 0: + first_sitter_name = list(sitters_)[0] + sitter_to_offer_name = first_sitter_name + else: + last_offer_was_minutes_ago = 0 + offers_without_a_no = {k: v for k, v in offers.items() if v != 'no'} + if len(offers_without_a_no) > 0: + last_offer: datetime.datetime = max(offers_without_a_no.values()) + last_offer_was_minutes_ago \ + = (datetime.datetime.now() - last_offer).total_seconds() / 60 + + if len(offers_without_a_no) == 0 or last_offer_was_minutes_ago > 1: + # if len(offers_without_a_no) == 0 or last_offer_was_minutes_ago > 60: + + # if len(sitters_) == len(offers): + # bookings_keys_to_delete.append(booking_start_and_end) + # update_client( + # f'No babysitters are available for {booking_string}! Deleting request.') + + # else: + for sitter in sitters_: + if sitter not in offers: + sitter_to_offer_name = sitter + break + + if sitter_to_offer_name is not None: + sitter_to_offer = sitters_[sitter_to_offer_name] + offer_booking(sitter_to_offer, booking_string) + offers[sitter_to_offer_name] = datetime.datetime.now() + update_client( + f'Okay, I offered {booking_string} to {sitter_to_offer_name.title()}.') + + if len(bookings_keys_to_delete) > 0: + for k in bookings_keys_to_delete: + del bookings_[k] + + persist_bookings(bookings_) + + # time.sleep(60) + time.sleep(5) + + +def offer_booking(sitter_dict: dict, booking_string: str) -> None: + message = f'{sitter_dict["name"].title()}, are you available to babysit on {booking_string}?' + twilio_client.api.account.messages.create(to=sitter_dict['num'], + from_=BOOKER_NUM, + body=message) + + +def update_client(string: str) -> None: + twilio_client.api.account.messages.create(to=MY_CELL, from_=BOOKER_NUM, body=string) + + def has_phone_num(string): return len([char for char in string if char.isnumeric()]) == 10 -def syndicate_and_book(session_start: datetime.datetime, session_end: datetime.datetime) -> Optional[str]: - # blast out to all sitters - # give it to the first 'yes' - # handle late 'yeses' and all 'noes' - # wait until everyone says no or timeout_minutes runs out - pass +def request_booking(body: str) -> Tuple[datetime.datetime, datetime.time]: + session_start, session_end = parse_booking_request(body) + global bookings + bookings = load_from_pickle('bookings') + bookings[(session_start, session_end)] = {'offered': dict()} + persist_bookings() + return session_start, session_end -def book_sitter(body: str) -> Optional[str]: - session_start, session_end = parse_sitter_request(body) - syndicate_and_book(session_start, session_end) +def parse_booking_request(body: str) -> Tuple[datetime.datetime, datetime.time]: + start_string, end_string = body.split(' to ') + session_start = cal.parseDT(start_string)[0] + session_end_time = datetime.time(cal.parse(end_string)[0].tm_hour) + return session_start, session_end_time def add_sitter(body: str) -> Tuple[str, str]: @@ -120,9 +275,12 @@ def add_sitter(body: str) -> Tuple[str, str]: assert len(num_only) == 10 - sitters[lowercase_name] = f'{COUNTRY_CODE}num_only' + phone_number = f'{COUNTRY_CODE}{num_only}' + sitters[lowercase_name] = {'num': phone_number, + 'name': lowercase_name} + persist_sitters() - return name, sitters[lowercase_name] + return name, phone_number def remove_sitter(body: str) -> str: @@ -139,7 +297,14 @@ def persist_sitters(): pickle.dump(sitters, open('sitters.p', 'wb')) +def persist_bookings(bookings_: dict = None): + pickle.dump(bookings_ if bookings_ is not None else bookings, open('bookings.p', 'wb')) + + if __name__ == '__main__': - if not sitters: - say_hi_ask_for_sitters() - app.run(debug=True, port=4567) + update_client('Hi, this is Babysitter Bot, on the job! Send me a date with time range and ' + 'I\'ll try to book one of our sitters!') + p = Process(target=book_forever) + p.start() + app.run(debug=True, port=8000) + p.join()