Skip to content

Commit 4220de1

Browse files
committed
zulip_bots: Create Google Calendar bot.
1 parent 080d363 commit 4220de1

File tree

3 files changed

+398
-0
lines changed

3 files changed

+398
-0
lines changed

zulip_bots/zulip_bots/bots/gcalendar/__init__.py

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

0 commit comments

Comments
 (0)