Skip to content

Commit fff5106

Browse files
committed
zulip_bots: Create Google Calendar bot.
1 parent 4737e81 commit fff5106

File tree

5 files changed

+389
-14
lines changed

5 files changed

+389
-14
lines changed

zulip_bots/zulip_bots/bots/google_calendar/__init__.py

Whitespace-only changes.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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. Enable the Google Calendar API in the Console.
10+
3. Download the project's "client secret" JSON file to a path of your choosing.
11+
4. Go to `<python_zulip_api_root>/zulip/integrations/google/`.
12+
5. Run the Google OAuth setup script, which will help you generate the
13+
tokens required to operate with your Google Calendar:
14+
15+
```bash
16+
$ python oauth.py \
17+
--secret_path <your_client_secret_file_path> \
18+
-s https://www.googleapis.com/auth/calendar
19+
```
20+
21+
The `--secret_path` must match wherever you stored the client secret
22+
downloaded in step 3.
23+
24+
You can also use the `--credential_path` argument, which is useful for
25+
specifying where you want to store the generated tokens. Please note
26+
that if you set a path different to `~/.google_credentials.json`, you
27+
have to modify the `CREDENTIAL_PATH` constant in the bot's
28+
`google_calendar.py` file.
29+
6. Install the required dependencies:
30+
31+
```bash
32+
$ sudo apt install python3-dev
33+
$ pip install -r requirements.txt
34+
```
35+
7. Prepare your `.zuliprc` file and run the bot itself, as described in
36+
["Running bots"](https://chat.zulip.org/api/running-bots).
37+
38+
## Usage
39+
40+
For delimited events:
41+
42+
@gcalendar <event_title> | <start_date> | <end_date> | <timezone> (optional)
43+
44+
For full-day events:
45+
46+
@gcalendar <event_title> | <start_date>
47+
48+
For detailed help:
49+
50+
@gcalendar help
51+
52+
Here are some examples:
53+
54+
@gcalendar Meeting with John | 2017/03/14 13:37 | 2017/03/14 15:00:01 | EDT
55+
@gcalendar Comida | en 10 minutos | en 2 horas
56+
@gcalendar Trip to LA | tomorrow
57+
58+
59+
Some additional considerations:
60+
61+
- If an ambiguous date format is used, **the American one will have preference**
62+
(`03/01/2016` will be read as `MM/DD/YYYY`). In case of doubt,
63+
[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format is recommended
64+
(`YYYY/MM/DD`).
65+
- If a timezone is specified in both a date and in the optional `timezone`
66+
field, **the one in the date will have preference**.
67+
- You can use **different languages and locales** for dates, such as
68+
`Martes 14 de enero de 2003 a las 13:37 CET`. Some (but not all) of them are:
69+
English, Spanish, Dutch and Russian. A full list can be found
70+
[here](https://dateparser.readthedocs.io/en/latest/#supported-languages).
71+
- The default timezone is **the server\'s**. However, it can be specified at
72+
the end of each date, in both numerical (`+01:00`) or abbreviated format
73+
(`CET`).
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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

Comments
 (0)