|
| 1 | +import httplib2 |
| 2 | +import json |
| 3 | +import logging |
| 4 | +import os |
| 5 | +import sys |
| 6 | + |
| 7 | +import dateparser |
| 8 | +from pytz.exceptions import UnknownTimeZoneError |
| 9 | +from tzlocal import get_localzone |
| 10 | +from googleapiclient import discovery, errors |
| 11 | +from oauth2client.client import HttpAccessTokenRefreshError |
| 12 | +from oauth2client.file import Storage |
| 13 | + |
| 14 | +# Modify this to match where your credentials are stored |
| 15 | +# It should be the same path you used in the |
| 16 | +# '--credential_path' argument, when running the script |
| 17 | +# in "api/integrations/google/oauth.py". |
| 18 | +# |
| 19 | +# $ python api/integrations/google/oauth.py \ |
| 20 | +# --secret_path <your_client_secret_file_path> \ |
| 21 | +# --credential_path <your_credential_path> \ |
| 22 | +# -s https://www.googleapis.com/auth/calendar |
| 23 | +# |
| 24 | +# If you didn't specify any, it should be in the default |
| 25 | +# path (~/.google_credentials.json) |
| 26 | +CREDENTIAL_PATH = '~/.google_credentials.json' |
| 27 | + |
| 28 | +class MessageParseError(Exception): |
| 29 | + def __init__(self, msg): |
| 30 | + super().__init__(msg) |
| 31 | + |
| 32 | +def parse_message(message): |
| 33 | + """Identifies and parses the different parts of a message sent to the bot |
| 34 | +
|
| 35 | + Returns: |
| 36 | + Values, a tuple that contains the 2 (or 3) parameters of the message |
| 37 | + """ |
| 38 | + try: |
| 39 | + splits = message.split('|') |
| 40 | + title = splits[0].strip() |
| 41 | + |
| 42 | + settings = { |
| 43 | + 'RETURN_AS_TIMEZONE_AWARE': True |
| 44 | + } |
| 45 | + |
| 46 | + if len(splits) == 4: # Delimited event with timezone |
| 47 | + settings['TIMEZONE'] = splits[3].strip() |
| 48 | + |
| 49 | + start_date = dateparser.parse(splits[1].strip(), settings=settings) |
| 50 | + end_date = None |
| 51 | + |
| 52 | + if len(splits) >= 3: # Delimited event |
| 53 | + end_date = dateparser.parse(splits[2].strip(), settings=settings) |
| 54 | + |
| 55 | + if not start_date.tzinfo: |
| 56 | + start_date = start_date.replace(tzinfo=get_localzone()) |
| 57 | + if not end_date.tzinfo: |
| 58 | + end_date = end_date.replace(tzinfo=get_localzone()) |
| 59 | + |
| 60 | + # Unknown date format |
| 61 | + if not start_date or len(splits) >= 3 and not end_date: |
| 62 | + raise MessageParseError('Unknown date format') |
| 63 | + |
| 64 | + # Notice there isn't a "full day event with timezone", because the |
| 65 | + # timezone is irrelevant in that type of events |
| 66 | + except IndexError as e: |
| 67 | + raise MessageParseError('Unknown message format') |
| 68 | + except UnknownTimeZoneError as e: |
| 69 | + raise MessageParseError('The specified timezone doesn\'t exist') |
| 70 | + |
| 71 | + return title, start_date, end_date |
| 72 | + |
| 73 | +def help_message(): |
| 74 | + return ('This **Google Calendar bot** allows you to create events in your ' |
| 75 | + 'Google account\'s calendars.\n\n' |
| 76 | + 'For example, if you want to create a new event, use:\n\n' |
| 77 | + ' @gcalendar <event_title> | <start_date> | <end_date> | ' |
| 78 | + '<timezone> (optional)\n' |
| 79 | + 'And for full-day events:\n\n' |
| 80 | + ' @gcalendar <event_title> | <date>\n' |
| 81 | + 'Please notice that pipes (`|`) *cannot* be used in the input ' |
| 82 | + 'parameters.\n\n' |
| 83 | + 'Here are some usage examples:\n\n' |
| 84 | + ' @gcalendar Meeting with John | 2017/03/14 13:37 | 2017/03/14 ' |
| 85 | + '15:00:01 | EDT\n' |
| 86 | + ' @gcalendar Comida | en 10 minutos | en 2 horas\n' |
| 87 | + ' @gcalendar Trip to LA | tomorrow\n' |
| 88 | + '---\n' |
| 89 | + 'Here is some information about how the dates work:\n' |
| 90 | + '* If an ambiguous date format is used, **the American one will ' |
| 91 | + 'have preference** (`03/01/2016` will be read as `MM/DD/YYYY`). In ' |
| 92 | + 'case of doubt, [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) ' |
| 93 | + 'format is recommended (`YYYY/MM/DD`).\n' |
| 94 | + '* If a timezone is specified in both a date and in the optional ' |
| 95 | + '`timezone` field, **the one in the date will have preference**.\n' |
| 96 | + '* You can use **different languages and locales** for dates, such ' |
| 97 | + 'as `Martes 14 de enero de 2003 a las 13:37 CET`. Some (but not ' |
| 98 | + 'all) of them are: English, Spanish, Dutch and Russian. A full ' |
| 99 | + 'list can be found [here](https://dateparser.readthedocs.io/en/' |
| 100 | + 'latest/#supported-languages).\n' |
| 101 | + '* The default timezone is **the server\'s**. However, it can be' |
| 102 | + 'specified at the end of each date, in both numerical (`+01:00`) ' |
| 103 | + 'or abbreviated format (`CET`).') |
| 104 | + |
| 105 | +def parsing_error_message(err): |
| 106 | + e_msg = str(err) |
| 107 | + |
| 108 | + if e_msg == 'Unknown message format': |
| 109 | + return ('Unknown message format.\n\n' |
| 110 | + 'Usage examples:\n\n' |
| 111 | + ' @gcalendar Meeting with John | 2017/03/14 13:37 | ' |
| 112 | + '2017/03/14 15:00:01 | GMT\n' |
| 113 | + ' @gcalendar Trip to LA | tomorrow\n' |
| 114 | + 'Send `@gcalendar help` for detailed usage instructions.') |
| 115 | + elif e_msg == 'Unknown date format': |
| 116 | + return ('Unknown date format.\n\n' |
| 117 | + 'Send `@gcalendar help` for detailed usage instructions.') |
| 118 | + elif e_msg == 'The specified timezone doesn\'t exist': |
| 119 | + return ('Unknown timezone.\n\n' |
| 120 | + 'Please, use a numerical (`+01:00`) or abbreviated (`CET`) ' |
| 121 | + 'timezone.\n' |
| 122 | + 'Send `@gcalendar help` for detailed usage instructions.') |
| 123 | + |
| 124 | +class GCalendarHandler(object): |
| 125 | + """This plugin facilitates creating Google Calendar events. |
| 126 | +
|
| 127 | + Usage: |
| 128 | + For delimited events: |
| 129 | + @gcalendar <event_title> | <start_date> | <end_date> | <timezone> (optional) |
| 130 | + For full-day events: |
| 131 | + @gcalendar <event_title> | <start_date> |
| 132 | +
|
| 133 | + The "event-title" supports all characters but pipes (|) |
| 134 | +
|
| 135 | + Timezones can be specified in both numerical (+00:00) or abbreviated |
| 136 | + format (UTC). |
| 137 | + The timezone of the server will be used if none is specified. |
| 138 | +
|
| 139 | + Right now it only works for the calendar set as "primary" in your account. |
| 140 | +
|
| 141 | + Please, run the script "api/integrations/google/oauth.py" before using this |
| 142 | + bot in order to provide it the necessary access to your Google account. |
| 143 | + """ |
| 144 | + def __init__(self): |
| 145 | + # Attempt to gather the credentials |
| 146 | + store = Storage(os.path.expanduser(CREDENTIAL_PATH)) |
| 147 | + credentials = store.get() |
| 148 | + |
| 149 | + if credentials is None: |
| 150 | + logging.error('Couldn\'t find valid credentials.\n' |
| 151 | + 'Run the oauth.py script in this directory first.') |
| 152 | + sys.exit(1) |
| 153 | + |
| 154 | + # Create the Google Calendar service, once the bot is |
| 155 | + # successfully authorized |
| 156 | + http = credentials.authorize(httplib2.Http()) |
| 157 | + self.service = discovery.build('calendar', 'v3', http=http, cache_discovery=False) |
| 158 | + |
| 159 | + def usage(self): |
| 160 | + return """ |
| 161 | + This plugin will allow users to create events |
| 162 | + in their Google Calendar. Users should preface |
| 163 | + messages with "@gcalendar". |
| 164 | +
|
| 165 | + Before running this, make sure to register a new |
| 166 | + project in the Google Developers Console |
| 167 | + (https://console.developers.google.com/start/api?id=calendar), |
| 168 | + download the "client secret" as a JSON file, and run |
| 169 | + "oauth.py" for the Calendar read-write scope |
| 170 | + (i.e. "--scopes https://www.googleapis.com/auth/calendar"). |
| 171 | +
|
| 172 | + You can find more information on how how to install and use this |
| 173 | + bot in its documentation. |
| 174 | + """ |
| 175 | + |
| 176 | + def handle_message(self, message, bot_handler): |
| 177 | + content = message['content'].strip() |
| 178 | + if content == 'help': |
| 179 | + bot_handler.send_reply(message, help_message()) |
| 180 | + return |
| 181 | + |
| 182 | + try: |
| 183 | + title, start_date, end_date = parse_message(content) |
| 184 | + except MessageParseError as e: # Something went wrong during parsing |
| 185 | + bot_handler.send_reply(message, parsing_error_message(e)) |
| 186 | + return |
| 187 | + |
| 188 | + event = { |
| 189 | + 'summary': title |
| 190 | + } |
| 191 | + |
| 192 | + if not end_date: # Full-day event |
| 193 | + date = start_date.strftime('%Y-%m-%d') |
| 194 | + event.update({ |
| 195 | + 'start': {'date': date}, |
| 196 | + 'end': {'date': date} |
| 197 | + }) |
| 198 | + else: |
| 199 | + event.update({ |
| 200 | + 'start': {'dateTime': start_date.isoformat()}, |
| 201 | + 'end': {'dateTime': end_date.isoformat()} |
| 202 | + }) |
| 203 | + |
| 204 | + try: |
| 205 | + event['start'].update({'timeZone': start_date.tzinfo.zone}) |
| 206 | + event['end'].update({'timeZone': end_date.tzinfo.zone}) |
| 207 | + except AttributeError: |
| 208 | + pass |
| 209 | + |
| 210 | + try: |
| 211 | + # TODO: Choose calendar ID from somewhere |
| 212 | + event = self.service.events().insert(calendarId='primary', |
| 213 | + body=event).execute() |
| 214 | + except errors.HttpError as e: |
| 215 | + err = json.loads(e.content.decode('utf-8'))['error'] |
| 216 | + |
| 217 | + error = ':warning: **Error!**\n' |
| 218 | + |
| 219 | + if err['code'] == 400: # There's something wrong with the input |
| 220 | + error += '\n'.join('* ' + problem['message'] |
| 221 | + for problem in err['errors']) |
| 222 | + else: # Some other issue not related to the user |
| 223 | + logging.exception(e) |
| 224 | + |
| 225 | + error += ('Something went wrong.\n' |
| 226 | + 'Please, try again or check the logs if the issue ' |
| 227 | + 'persists.') |
| 228 | + |
| 229 | + bot_handler.send_reply(message, error) |
| 230 | + return |
| 231 | + except HttpAccessTokenRefreshError as e: |
| 232 | + logging.exception(e) |
| 233 | + |
| 234 | + error += ('The authorization token has expired.\n' |
| 235 | + 'The most probable cause for this is that the token has ' |
| 236 | + 'been revoked.\n' |
| 237 | + 'The bot will now stop. Please, run the oauth.py script ' |
| 238 | + 'again to go through the OAuth process and provide it ' |
| 239 | + 'with new credentials.') |
| 240 | + |
| 241 | + bot_handler.send_reply(message, error) |
| 242 | + sys.exit(1) |
| 243 | + |
| 244 | + if not title: |
| 245 | + title = '(Untitled)' |
| 246 | + |
| 247 | + date_format = '%c %Z' |
| 248 | + |
| 249 | + start_str = start_date.strftime(date_format) |
| 250 | + end_str = None |
| 251 | + |
| 252 | + if not end_date: |
| 253 | + reply = (':calendar: Full day event created!\n' |
| 254 | + '> **{title}**, on *{startDate}*') |
| 255 | + |
| 256 | + else: |
| 257 | + end_str = end_date.strftime(date_format) |
| 258 | + reply = (':calendar: Event created!\n' |
| 259 | + '> **{title}**, from *{startDate}* to *{endDate}*') |
| 260 | + |
| 261 | + bot_handler.send_reply(message, |
| 262 | + reply.format(title=title, |
| 263 | + startDate=start_str.strip(), |
| 264 | + endDate=end_str)) |
| 265 | + |
| 266 | +handler_class = GCalendarHandler |
| 267 | + |
| 268 | +def test(): |
| 269 | + # These tests only check that the message parser works properly, since |
| 270 | + # testing the other features in the bot require interacting with the Google |
| 271 | + # Calendar API. |
| 272 | + msg = 'Go to the bank | Monday, 21 Oct 2014' |
| 273 | + title, start_date, end_date = parse_message(msg) |
| 274 | + assert title == 'Go to the bank' |
| 275 | + assert start_date.strftime('%Y-%m-%d') == '2014-10-21' |
| 276 | + assert end_date is None |
| 277 | + |
| 278 | + msg = ('Meeting with John | ' |
| 279 | + 'Martes 14 de enero de 2003 a las 13:37:00 CET | ' |
| 280 | + 'Martes 14 de enero de 2003 a las 15:01:10 CET') |
| 281 | + title, start_date, end_date = parse_message(msg) |
| 282 | + assert title == 'Meeting with John' |
| 283 | + assert start_date.isoformat() == '2003-01-14T13:37:00+01:00' |
| 284 | + assert end_date.isoformat() == '2003-01-14T15:01:10+01:00' |
| 285 | + |
| 286 | + msg = 'Buy groceries | someday' |
| 287 | + ex_message = '' |
| 288 | + try: |
| 289 | + title, start_date, end_date = parse_message(msg) |
| 290 | + except MessageParseError as e: |
| 291 | + ex_message = e.message |
| 292 | + assert ex_message == 'Unknown date format' |
| 293 | + |
| 294 | +if __name__ == '__main__': |
| 295 | + test() |
0 commit comments