diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials deleted file mode 100644 index 8b7024822..000000000 --- a/zulip/integrations/google/get-google-credentials +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python -from __future__ import print_function -import datetime -import httplib2 -import os - -from oauth2client import client -from oauth2client import tools -from oauth2client.file import Storage - -from typing import Optional - -try: - import argparse - flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args() # type: Optional[argparse.Namespace] -except ImportError: - flags = None - -# If modifying these scopes, delete your previously saved credentials -# at zulip/bots/gcal/ -# NOTE: When adding more scopes, add them after the previous one in the same field, with a space -# seperating them. -SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' -# This file contains the information that google uses to figure out which application is requesting -# this client's data. -CLIENT_SECRET_FILE = 'client_secret.json' -APPLICATION_NAME = 'Zulip Calendar Bot' -HOME_DIR = os.path.expanduser('~') - -def get_credentials(): - # type: () -> client.Credentials - """Gets valid user credentials from storage. - - If nothing has been stored, or if the stored credentials are invalid, - the OAuth2 flow is completed to obtain the new credentials. - - Returns: - Credentials, the obtained credential. - """ - - credential_path = os.path.join(HOME_DIR, - 'google-credentials.json') - - store = Storage(credential_path) - credentials = store.get() - if not credentials or credentials.invalid: - flow = client.flow_from_clientsecrets(os.path.join(HOME_DIR, CLIENT_SECRET_FILE), SCOPES) - flow.user_agent = APPLICATION_NAME - if flags: - # This attempts to open an authorization page in the default web browser, and asks the user - # to grant the bot access to their data. If the user grants permission, the run_flow() - # function returns new credentials. - credentials = tools.run_flow(flow, store, flags) - else: # Needed only for compatibility with Python 2.6 - credentials = tools.run(flow, store) - print('Storing credentials to ' + credential_path) - -get_credentials() diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index eb0896f10..010cb6048 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -48,7 +48,7 @@ google-calendar --calendar calendarID@example.calendar.google.com Specify your Zulip API credentials and server in a ~/.zuliprc file or using the options. - Before running this integration make sure you run the get-google-credentials file to give Zulip + Before running this integration make sure you run the oauth.py file to give Zulip access to certain aspects of your Google Account. This integration should be run on your local machine. Your API key and other information are @@ -102,7 +102,7 @@ def get_credentials(): except client.Error: logging.exception('Error while trying to open the `google-credentials.json` file.') except IOError: - logging.error("Run the get-google-credentials script from this directory first.") + logging.error("Run the oauth.py script from this directory first.") def populate_events(): diff --git a/zulip_bots/zulip_bots/bots/google_calendar/__init__.py b/zulip_bots/zulip_bots/bots/google_calendar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/zulip_bots/zulip_bots/bots/google_calendar/doc.md b/zulip_bots/zulip_bots/bots/google_calendar/doc.md new file mode 100644 index 000000000..411a909f9 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/google_calendar/doc.md @@ -0,0 +1,73 @@ +# Google Calendar bot + +This bot facilitates creating Google Calendar events. + +## Setup + +1. Register a new project in the + [Google Developers Console](https://console.developers.google.com/start). +2. Enable the Google Calendar API in the Console. +3. Download the project's "client secret" JSON file to a path of your choosing. +4. Go to `/zulip/integrations/google/`. +5. Run the Google OAuth setup script, which will help you generate the + tokens required to operate with your Google Calendar: + + ```bash + $ python oauth.py \ + --secret_path \ + -s https://www.googleapis.com/auth/calendar + ``` + + The `--secret_path` must match wherever you stored the client secret + downloaded in step 3. + + You can also use the `--credential_path` argument, which is useful for + specifying where you want to store the generated tokens. Please note + that if you set a path different to `~/.google_credentials.json`, you + have to modify the `CREDENTIAL_PATH` constant in the bot's + `google_calendar.py` file. +6. Install the required dependencies: + + ```bash + $ sudo apt install python3-dev + $ pip install -r requirements.txt + ``` +7. Prepare your `.zuliprc` file and run the bot itself, as described in + ["Running bots"](https://chat.zulip.org/api/running-bots). + +## Usage + +For delimited events: + + @gcalendar | | | (optional) + +For full-day events: + + @gcalendar | + +For detailed help: + + @gcalendar help + +Here are some examples: + + @gcalendar Meeting with John | 2017/03/14 13:37 | 2017/03/14 15:00:01 | EDT + @gcalendar Comida | en 10 minutos | en 2 horas + @gcalendar Trip to LA | tomorrow + + +Some additional considerations: + +- If an ambiguous date format is used, **the American one will have preference** + (`03/01/2016` will be read as `MM/DD/YYYY`). In case of doubt, + [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format is recommended + (`YYYY/MM/DD`). +- If a timezone is specified in both a date and in the optional `timezone` + field, **the one in the date will have preference**. +- You can use **different languages and locales** for dates, such as + `Martes 14 de enero de 2003 a las 13:37 CET`. Some (but not all) of them are: + English, Spanish, Dutch and Russian. A full list can be found + [here](https://dateparser.readthedocs.io/en/latest/#supported-languages). +- The default timezone is **the server\'s**. However, it can be specified at + the end of each date, in both numerical (`+01:00`) or abbreviated format + (`CET`). diff --git a/zulip_bots/zulip_bots/bots/google_calendar/google_calendar.py b/zulip_bots/zulip_bots/bots/google_calendar/google_calendar.py new file mode 100644 index 000000000..4d784ea9d --- /dev/null +++ b/zulip_bots/zulip_bots/bots/google_calendar/google_calendar.py @@ -0,0 +1,295 @@ +import httplib2 +import json +import logging +import os +import sys + +import dateparser +from pytz.exceptions import UnknownTimeZoneError +from tzlocal import get_localzone +from googleapiclient import discovery, errors +from oauth2client.client import HttpAccessTokenRefreshError +from oauth2client.file import Storage + +# Modify this to match where your credentials are stored +# It should be the same path you used in the +# '--credential_path' argument, when running the script +# in "api/integrations/google/oauth.py". +# +# $ python api/integrations/google/oauth.py \ +# --secret_path \ +# --credential_path \ +# -s https://www.googleapis.com/auth/calendar +# +# If you didn't specify any, it should be in the default +# path (~/.google_credentials.json) +CREDENTIAL_PATH = '~/.google_credentials.json' + +class MessageParseError(Exception): + def __init__(self, msg): + super().__init__(msg) + +def parse_message(message): + """Identifies and parses the different parts of a message sent to the bot + + Returns: + Values, a tuple that contains the 2 (or 3) parameters of the message + """ + try: + splits = message.split('|') + title = splits[0].strip() + + settings = { + 'RETURN_AS_TIMEZONE_AWARE': True + } + + if len(splits) == 4: # Delimited event with timezone + settings['TIMEZONE'] = splits[3].strip() + + start_date = dateparser.parse(splits[1].strip(), settings=settings) + end_date = None + + if len(splits) >= 3: # Delimited event + end_date = dateparser.parse(splits[2].strip(), settings=settings) + + if not start_date.tzinfo: + start_date = start_date.replace(tzinfo=get_localzone()) + if not end_date.tzinfo: + end_date = end_date.replace(tzinfo=get_localzone()) + + # Unknown date format + if not start_date or len(splits) >= 3 and not end_date: + raise MessageParseError('Unknown date format') + + # Notice there isn't a "full day event with timezone", because the + # timezone is irrelevant in that type of events + except IndexError as e: + raise MessageParseError('Unknown message format') + except UnknownTimeZoneError as e: + raise MessageParseError('The specified timezone doesn\'t exist') + + return title, start_date, end_date + +def help_message(): + return ('This **Google Calendar bot** allows you to create events in your ' + 'Google account\'s calendars.\n\n' + 'For example, if you want to create a new event, use:\n\n' + ' @gcalendar | | | ' + ' (optional)\n' + 'And for full-day events:\n\n' + ' @gcalendar | \n' + 'Please notice that pipes (`|`) *cannot* be used in the input ' + 'parameters.\n\n' + 'Here are some usage examples:\n\n' + ' @gcalendar Meeting with John | 2017/03/14 13:37 | 2017/03/14 ' + '15:00:01 | EDT\n' + ' @gcalendar Comida | en 10 minutos | en 2 horas\n' + ' @gcalendar Trip to LA | tomorrow\n' + '---\n' + 'Here is some information about how the dates work:\n' + '* If an ambiguous date format is used, **the American one will ' + 'have preference** (`03/01/2016` will be read as `MM/DD/YYYY`). In ' + 'case of doubt, [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) ' + 'format is recommended (`YYYY/MM/DD`).\n' + '* If a timezone is specified in both a date and in the optional ' + '`timezone` field, **the one in the date will have preference**.\n' + '* You can use **different languages and locales** for dates, such ' + 'as `Martes 14 de enero de 2003 a las 13:37 CET`. Some (but not ' + 'all) of them are: English, Spanish, Dutch and Russian. A full ' + 'list can be found [here](https://dateparser.readthedocs.io/en/' + 'latest/#supported-languages).\n' + '* The default timezone is **the server\'s**. However, it can be' + 'specified at the end of each date, in both numerical (`+01:00`) ' + 'or abbreviated format (`CET`).') + +def parsing_error_message(err): + e_msg = str(err) + + if e_msg == 'Unknown message format': + return ('Unknown message format.\n\n' + 'Usage examples:\n\n' + ' @gcalendar Meeting with John | 2017/03/14 13:37 | ' + '2017/03/14 15:00:01 | GMT\n' + ' @gcalendar Trip to LA | tomorrow\n' + 'Send `@gcalendar help` for detailed usage instructions.') + elif e_msg == 'Unknown date format': + return ('Unknown date format.\n\n' + 'Send `@gcalendar help` for detailed usage instructions.') + elif e_msg == 'The specified timezone doesn\'t exist': + return ('Unknown timezone.\n\n' + 'Please, use a numerical (`+01:00`) or abbreviated (`CET`) ' + 'timezone.\n' + 'Send `@gcalendar help` for detailed usage instructions.') + +class GCalendarHandler(object): + """This plugin facilitates creating Google Calendar events. + + Usage: + For delimited events: + @gcalendar | | | (optional) + For full-day events: + @gcalendar | + + The "event-title" supports all characters but pipes (|) + + Timezones can be specified in both numerical (+00:00) or abbreviated + format (UTC). + The timezone of the server will be used if none is specified. + + Right now it only works for the calendar set as "primary" in your account. + + Please, run the script "api/integrations/google/oauth.py" before using this + bot in order to provide it the necessary access to your Google account. + """ + def __init__(self): + # Attempt to gather the credentials + store = Storage(os.path.expanduser(CREDENTIAL_PATH)) + credentials = store.get() + + if credentials is None: + logging.error('Couldn\'t find valid credentials.\n' + 'Run the oauth.py script in this directory first.') + sys.exit(1) + + # Create the Google Calendar service, once the bot is + # successfully authorized + http = credentials.authorize(httplib2.Http()) + self.service = discovery.build('calendar', 'v3', http=http, cache_discovery=False) + + def usage(self): + return """ + This plugin will allow users to create events + in their Google Calendar. Users should preface + messages with "@gcalendar". + + Before running this, make sure to register a new + project in the Google Developers Console + (https://console.developers.google.com/start/api?id=calendar), + download the "client secret" as a JSON file, and run + "oauth.py" for the Calendar read-write scope + (i.e. "--scopes https://www.googleapis.com/auth/calendar"). + + You can find more information on how how to install and use this + bot in its documentation. + """ + + def handle_message(self, message, bot_handler): + content = message['content'].strip() + if content == 'help': + bot_handler.send_reply(message, help_message()) + return + + try: + title, start_date, end_date = parse_message(content) + except MessageParseError as e: # Something went wrong during parsing + bot_handler.send_reply(message, parsing_error_message(e)) + return + + event = { + 'summary': title + } + + if not end_date: # Full-day event + date = start_date.strftime('%Y-%m-%d') + event.update({ + 'start': {'date': date}, + 'end': {'date': date} + }) + else: + event.update({ + 'start': {'dateTime': start_date.isoformat()}, + 'end': {'dateTime': end_date.isoformat()} + }) + + try: + event['start'].update({'timeZone': start_date.tzinfo.zone}) + event['end'].update({'timeZone': end_date.tzinfo.zone}) + except AttributeError: + pass + + try: + # TODO: Choose calendar ID from somewhere + event = self.service.events().insert(calendarId='primary', + body=event).execute() + except errors.HttpError as e: + err = json.loads(e.content.decode('utf-8'))['error'] + + error = ':warning: **Error!**\n' + + if err['code'] == 400: # There's something wrong with the input + error += '\n'.join('* ' + problem['message'] + for problem in err['errors']) + else: # Some other issue not related to the user + logging.exception(e) + + error += ('Something went wrong.\n' + 'Please, try again or check the logs if the issue ' + 'persists.') + + bot_handler.send_reply(message, error) + return + except HttpAccessTokenRefreshError as e: + logging.exception(e) + + error += ('The authorization token has expired.\n' + 'The most probable cause for this is that the token has ' + 'been revoked.\n' + 'The bot will now stop. Please, run the oauth.py script ' + 'again to go through the OAuth process and provide it ' + 'with new credentials.') + + bot_handler.send_reply(message, error) + sys.exit(1) + + if not title: + title = '(Untitled)' + + date_format = '%c %Z' + + start_str = start_date.strftime(date_format) + end_str = None + + if not end_date: + reply = (':calendar: Full day event created!\n' + '> **{title}**, on *{startDate}*') + + else: + end_str = end_date.strftime(date_format) + reply = (':calendar: Event created!\n' + '> **{title}**, from *{startDate}* to *{endDate}*') + + bot_handler.send_reply(message, + reply.format(title=title, + startDate=start_str.strip(), + endDate=end_str)) + +handler_class = GCalendarHandler + +def test(): + # These tests only check that the message parser works properly, since + # testing the other features in the bot require interacting with the Google + # Calendar API. + msg = 'Go to the bank | Monday, 21 Oct 2014' + title, start_date, end_date = parse_message(msg) + assert title == 'Go to the bank' + assert start_date.strftime('%Y-%m-%d') == '2014-10-21' + assert end_date is None + + msg = ('Meeting with John | ' + 'Martes 14 de enero de 2003 a las 13:37:00 CET | ' + 'Martes 14 de enero de 2003 a las 15:01:10 CET') + title, start_date, end_date = parse_message(msg) + assert title == 'Meeting with John' + assert start_date.isoformat() == '2003-01-14T13:37:00+01:00' + assert end_date.isoformat() == '2003-01-14T15:01:10+01:00' + + msg = 'Buy groceries | someday' + ex_message = '' + try: + title, start_date, end_date = parse_message(msg) + except MessageParseError as e: + ex_message = e.message + assert ex_message == 'Unknown date format' + +if __name__ == '__main__': + test() diff --git a/zulip_bots/zulip_bots/bots/google_calendar/oauth.py b/zulip_bots/zulip_bots/bots/google_calendar/oauth.py new file mode 100755 index 000000000..1055f1496 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/google_calendar/oauth.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +import argparse +import httplib2 +import os + +# Uses Google's Client Library +# pip install --upgrade google-api-python-client +from oauth2client import client, tools +from oauth2client.file import Storage + +# Before running this, make sure to register a new +# project in the Google Developers Console +# (https://console.developers.google.com/start) +# and download the JSON file with the "client secret". +# +# You'll have to specify its location with --secret-path. +# +# The "-s, --scopes" argument specifies the Google Accounts permissions your +# application needs to run (e.g. viewing your contacts). +# You can find a full list of the accepted scopes here: +# https://developers.google.com/identity/protocols/googlescopes +# +# If you're using this script to set a bot or integration up, look at its +# documentation. The developer probably specified the scopes required for +# that specific application. +# +# The script supports the addition of multiple scopes. Also, if you added +# some scopes in the past, you can add more without overwriting the already +# existing ones. + +# This parent argparser is used because it already contains +# the arguments that Google's Client Library method "tools.run_flow" +# supports. +parent = tools.argparser + +parent.add_argument('-p', '--secret_path', + default='~/.client_secret.json', + type=str, + help='File with the client secret from the Google ' + 'Developers Console can be found') +parent.add_argument('-c', '--credential_path', + default='~/.google_credentials.json', + type=str, + help='File where you wish to store the generated ' + 'authentication tokens') +parent.add_argument('-s', '--scopes', + nargs='+', + type=str, + required=True, + help='Scopes to use in the authentication, separated ' + 'with spaces') + +flags = argparse.ArgumentParser(formatter_class= + argparse.ArgumentDefaultsHelpFormatter, + parents=[tools.argparser] + ).parse_args() + +APPLICATION_NAME = 'Zulip' + +def get_credentials(): + # type: () -> client.Credentials + """Gets valid user credentials from storage. + + If nothing has been stored, or if the stored credentials are invalid, + the OAuth2 flow is completed to obtain the new credentials. + + Returns: + Credentials, the obtained credential. + """ + scopes = flags.scopes + + credential_path = os.path.expanduser(flags.credential_path) + secret_path = os.path.expanduser(flags.secret_path) + + # Try to read the previous credentials file (if any) + store = Storage(credential_path) + credentials = store.get() + + # There are no previous credentials, they aren't valid, or don't contain + # some of the requested scopes + if (not credentials or credentials.invalid or + not credentials.has_scopes(scopes)): + if credentials: + # Check which scopes already exist + http = credentials.authorize(httplib2.Http()) + old_scopes = list(credentials.retrieve_scopes(http)) + scopes += old_scopes + + # Prepare the OAuth flow with the specified configuration + flow = client.flow_from_clientsecrets(secret_path, scopes) + flow.user_agent = APPLICATION_NAME + + # Run the OAuth process + credentials = tools.run_flow(flow, store, flags) + else: + print('Credentials already exist!') +get_credentials() diff --git a/zulip_bots/zulip_bots/bots/google_calendar/requirements.txt b/zulip_bots/zulip_bots/bots/google_calendar/requirements.txt new file mode 100644 index 000000000..db75a6aad --- /dev/null +++ b/zulip_bots/zulip_bots/bots/google_calendar/requirements.txt @@ -0,0 +1,2 @@ +google-api-python-client==1.6.7 +dateparser==0.7.0