diff --git a/api/integrations/google/get-google-credentials b/api/integrations/google/get-google-credentials new file mode 100644 index 0000000000..e91938859e --- /dev/null +++ b/api/integrations/google/get-google-credentials @@ -0,0 +1,56 @@ +#!/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 + +try: + import argparse + flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args() +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/api/integrations/google/google-calendar b/api/integrations/google/google-calendar index 1064c3db6e..014b7e48e6 100755 --- a/api/integrations/google/google-calendar +++ b/api/integrations/google/google-calendar @@ -1,92 +1,131 @@ #!/usr/bin/env python from __future__ import print_function +import datetime +import httplib2 +import itertools +import logging +import optparse +import os +from six.moves import urllib import sys import time -import datetime -import optparse -from six.moves import urllib -import itertools import traceback -import os - -sys.path.append(os.path.join(os.path.dirname(__file__), '../api')) -import zulip from typing import List, Set, Tuple, Iterable, Optional +from oauth2client import client, tools +from oauth2client.file import Storage +try: + from googleapiclient import discovery +except ImportError: + logging.exception('Install google-api-python-client') + +sys.path.append(os.path.join(os.path.dirname(__file__), '../../')) +import zulip + +SCOPES = 'https://www.googleapis.com/auth/calendar.readonly' +CLIENT_SECRET_FILE = 'client_secret.json' +APPLICATION_NAME = 'Zulip' +HOME_DIR = os.path.expanduser('~') + +# Our cached view of the calendar, updated periodically. +events = [] # type: List[Tuple[int, datetime.datetime, str]] + +# Unique keys for events we've already sent, so we don't remind twice. +sent = set() # type: Set[Tuple[int, datetime.datetime]] + +sys.path.append(os.path.dirname(__file__)) + parser = optparse.OptionParser(r""" %prog \ --user foo@zulip.com \ - --calendar http://www.google.com/calendar/feeds/foo%40zulip.com/private-fedcba9876543210fedcba9876543210/basic + --calendar calendarID@example.calendar.google.com - Send yourself reminders on Zulip of Google Calendar events. + This integration can be used to send yourself reminders, on Zulip, of Google Calendar Events. - To get the calendar URL: - - Load Google Calendar in a web browser - - Find your calendar in the "My calendars" list on the left - - Click the down-wedge icon that appears on mouseover, and select "Calendar settings" - - Copy the link address for the "XML" button under "Private Address" + Before running this integration make sure you run the get-google-credentials file to give Zulip + access to certain aspects of your Google Account. - Run this on your personal machine. Your API key and calendar URL are revealed to local - users through the command line. + This integration should be run on your local machine. Your API key and other information are + revealed to local users through the command line. - Depends on: python-gdata + Depends on: google-api-python-client """) -parser.add_option('--calendar', - dest='calendar', - action='store', - help='Google Calendar XML "Private Address"', - metavar='URL') + parser.add_option('--interval', dest='interval', - default=10, + default=30, type=int, action='store', - help='Minutes before event for reminder [default: 10]', + help='Minutes before event for reminder [default: 30]', metavar='MINUTES') + +parser.add_option('--calendar', + dest = 'calendarID', + default = 'primary', + type = str, + action = 'store', + help = 'Calendar ID for the calendar you want to receive reminders from.') + parser.add_option_group(zulip.generate_option_group(parser)) (options, args) = parser.parse_args() -if not (options.zulip_email and options.calendar): - parser.error('You must specify --user and --calendar') +if not (options.zulip_email): + parser.error('You must specify --user') -try: - from gdata.calendar.client import CalendarClient -except ImportError: - parser.error('Install python-gdata') +zulip_client = zulip.init_from_options(options) -def get_calendar_url(): - # type: () -> str - parts = urllib.parse.urlparse(options.calendar) - pat = os.path.split(parts.path) - if pat[1] != 'basic': - parser.error('The --calendar URL should be the XML "Private Address" ' + - 'from your calendar settings') - return urllib.parse.urlunparse((parts.scheme, parts.netloc, pat[0] + '/full', - '', 'futureevents=true&orderby=startdate', '')) +def get_credentials(): + # type: () -> client.Credentials + """Gets valid user credentials from storage. -calendar_url = get_calendar_url() + If nothing has been stored, or if the stored credentials are invalid, + an exception is thrown and the user is informed to run the script in this directory to get + credentials. + + Returns: + Credentials, the obtained credential. + """ + try: + credential_path = os.path.join(HOME_DIR, + 'google-credentials.json') + + store = Storage(credential_path) + credentials = store.get() + + return 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.") -client = zulip.init_from_options(options) def get_events(): # type: () -> Iterable[Tuple[int, datetime.datetime, str]] - feed = CalendarClient().GetCalendarEventFeed(uri=calendar_url) + credentials = get_credentials() + creds = credentials.authorize(httplib2.Http()) + service = discovery.build('calendar', 'v3', http=creds) - for event in feed.entry: - start = event.when[0].start.split('.')[0] + now = datetime.datetime.utcnow().isoformat() + 'Z' # 'Z' indicates UTC time + feed = service.events().list(calendarId=options.calendarID, timeMin=now, maxResults=5, + singleEvents=True, orderBy='startTime').execute() + + for event in feed["items"]: + try: + start = event["start"]["dateTime"] + except KeyError: + start = event["start"]["date"] + start = start[:19] # All-day events can have only a date fmt = '%Y-%m-%dT%H:%M:%S' if 'T' in start else '%Y-%m-%d' start = datetime.datetime.strptime(start, fmt) - yield (event.uid.value, start, event.title.text) + try: + yield (event["id"], start, event["summary"]) + except KeyError: + yield (event["id"], start, "(No Title)") -# Our cached view of the calendar, updated periodically. -events = [] # type: List[Tuple[int, datetime.datetime, str]] - -# Unique keys for events we've already sent, so we don't remind twice. -sent = set() # type: Set[Tuple[int, datetime.datetime]] def send_reminders(): # type: () -> Optional[None] @@ -96,14 +135,17 @@ def send_reminders(): keys = set() now = datetime.datetime.now() - for uid, start, title in events: + for id, start, summary in events: dt = start - now - if dt.days == 0 and dt.seconds < 60*options.interval: + if dt.days == 0 and dt.seconds < 60 * options.interval: # The unique key includes the start time, because of # repeating events. - key = (uid, start) + key = (id, start) if key not in sent: - line = '%s starts at %s' % (title, start.strftime('%H:%M')) + if start.hour == 0 and start.minute == 0: + line = '%s is today.' % (summary) + else: + line = '%s starts at %s' % (summary, start.strftime('%H:%M')) print('Sending reminder:', line) messages.append(line) keys.add(key) @@ -116,12 +158,13 @@ def send_reminders(): else: message = 'Reminder:\n\n' + '\n'.join('* ' + m for m in messages) - client.send_message(dict( + zulip_client.send_message(dict( type = 'private', - to = options.user, + to = options.zulip_email, + sender = options.zulip_email, content = message)) - sent |= keys + sent.update(keys) # Loop forever for i in itertools.count(): @@ -132,5 +175,5 @@ for i in itertools.count(): events = list(get_events()) send_reminders() except: - traceback.print_exc() + logging.exception("Couldn't download Google calendar and/or couldn't post to Zulip.") time.sleep(60) diff --git a/static/images/integrations/google/calendar/001.png b/static/images/integrations/google/calendar/001.png new file mode 100644 index 0000000000..18953f0986 Binary files /dev/null and b/static/images/integrations/google/calendar/001.png differ diff --git a/static/images/integrations/google/calendar/002.png b/static/images/integrations/google/calendar/002.png new file mode 100644 index 0000000000..420a40fcce Binary files /dev/null and b/static/images/integrations/google/calendar/002.png differ diff --git a/static/images/integrations/google/calendar/003.png b/static/images/integrations/google/calendar/003.png new file mode 100644 index 0000000000..abf2760e94 Binary files /dev/null and b/static/images/integrations/google/calendar/003.png differ diff --git a/static/images/integrations/logos/google-calendar.png b/static/images/integrations/logos/google-calendar.png new file mode 100644 index 0000000000..dc82095046 Binary files /dev/null and b/static/images/integrations/logos/google-calendar.png differ diff --git a/templates/zerver/integrations.html b/templates/zerver/integrations.html index ff9aa6b429..c8cb2c2c8a 100644 --- a/templates/zerver/integrations.html +++ b/templates/zerver/integrations.html @@ -813,7 +813,6 @@ -

First, download and install our Python @@ -1009,6 +1008,95 @@

+
+ +

+ Get Google Calendar reminders in Zulip! This is a great way to see your reminders directly in + your Zulip feed. +

+ +

+ First download and install our Python Bindings and example scripts. + This bot should be set up on a trusted machine, because your API key is visible to local users + through the command line or config file. +

+ +

+ Next, follow the instructions for Step 1 at this link to get a client_secret + file. Save this file to your /~ directory instead of your working directory. +

+ +

+ Next, install the latest Google API Client for Python by following the instructions on + the + Google Website. +

+ +

+ Then go to your Zulip Settings by clicking on the cog in the top right corner, + and then clicking on Settings. +

+ +

+ Click on the tab that's labeled Your Bots and click on Show/change your API + key. Enter your password if prompted, and download the .zuliprc file. Save + this file to your ~/ folder. +

+ +

+ +

+ Run the get-google-credentials with this command: +

python /usr/local/share/zulip/integrations/google/get-google-credentials
+ It should open up a browser and ask you for certain permissions. Give Zulip access, and move + on to the next step. If it doesn't open a browser, follow the instructions in the terminal + window. +

+ +

+ Now, all that's left to do is to run the gcal-bot script, in the same + directory as the get-google-credentials script, with the necessary paramaters: +

python /usr/local/share/zulip/integrations/google/gcal-bot --user foo@zulip.com
+

+ +

+ The --user flag specifies the user to send the reminder to.
There are + two optional flags that you can specify when running this script: +

+
    +
  1. + --calendar: This flag specifies the calendar to watch from the user's + Google Account. By default, this flag is set to a user's primary or default calendar. + To specify a calendar, you need the calendar ID which can be obtained by going to Google + Calendar and clicking on the wedge next to the calendar's name. Click on settings in + Calendar settings in the drop down, and look for the Calendar Address + section. Copy the Calendar ID from the right side of the page and use that as the + value for this flag. + +

    +
  2. + +
  3. + --interval: This flag specifies the interval of time - in minutes - between + receiving the reminder, and the actual event. For example, an interval of 30 minutes + would mean that you would recieve a reminder for an event 30 minutes before it is + scheduled to occur. +
  4. +
+ +

+ Don't close the terminal window with the bot running. You will only get reminders if the + bot is still running.

+ +

+ Congratulations! You're done!
You will get a Zulip private message, whenever you + have a calendar event scheduled, that looks like this: + +

+ +
+

Learn how Zulip integrations work with this simple Hello World example!

diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py index 76f6204821..9f893ebda3 100644 --- a/zerver/lib/integrations.py +++ b/zerver/lib/integrations.py @@ -163,6 +163,7 @@ INTEGRATIONS = { 'codebase': Integration('codebase', 'codebase'), 'email': Integration('email', 'email'), 'git': Integration('git', 'git'), + 'google-calendar': Integration('google-calendar', 'google-calendar', display_name='Google Calendar'), 'hubot': Integration('hubot', 'hubot'), 'jenkins': Integration('jenkins', 'jenkins', secondary_line_text='(or Hudson)'), 'jira-plugin': Integration(