]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Create the hunts table if it doesn't already exist
[turbot] / turbot / interaction.py
1 from slack.errors import SlackApiError
2 from turbot.blocks import input_block, section_block, text_block
3 import turbot.rot
4 import turbot.sheets
5 import turbot.slack
6 import json
7 import re
8 import requests
9 from botocore.exceptions import ClientError
10
11 TURBOT_USER_ID = 'U01B9QM4P9R'
12
13 actions = {}
14 commands = {}
15 submission_handlers = {}
16
17 # Hunt and Puzzle IDs are restricted to letters, numbers, and underscores
18 valid_id_re = r'^[_a-zA-Z0-9]+$'
19
20 def bot_reply(message):
21     """Construct a return value suitable for a bot reply
22
23     This is suitable as a way to give an error back to the user who
24     initiated a slash command, for example."""
25
26     return {
27         'statusCode': 200,
28         'body': message
29     }
30
31 def submission_error(field, error):
32     """Construct an error suitable for returning for an invalid submission.
33
34     Returning this value will prevent a submission and alert the user that
35     the given field is invalid because of the given error."""
36
37     print("Rejecting invalid modal submission: {}".format(error))
38
39     return {
40         'statusCode': 200,
41         'headers': {
42             "Content-Type": "application/json"
43         },
44         'body': json.dumps({
45             "response_action": "errors",
46             "errors": {
47                 field: error
48             }
49         })
50     }
51
52 def new_hunt(turb, payload):
53     """Handler for the action of user pressing the new_hunt button"""
54
55     view = {
56         "type": "modal",
57         "private_metadata": json.dumps({}),
58         "title": { "type": "plain_text", "text": "New Hunt" },
59         "submit": { "type": "plain_text", "text": "Create" },
60         "blocks": [
61             input_block("Hunt name", "name", "Name of the hunt"),
62             input_block("Hunt ID", "hunt_id",
63                         "Used as puzzle channel prefix "
64                         + "(no spaces nor punctuation)"),
65             input_block("Hunt URL", "url", "External URL of hunt",
66                         optional=True)
67         ],
68     }
69
70     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
71                                           view=view)
72     if (result['ok']):
73         submission_handlers[result['view']['id']] = new_hunt_submission
74
75     return {
76         'statusCode': 200,
77         'body': 'OK'
78     }
79
80 actions['button'] = {"new_hunt": new_hunt}
81
82 def new_hunt_submission(turb, payload, metadata):
83     """Handler for the user submitting the new hunt modal
84
85     This is the modal view presented to the user by the new_hunt
86     function above."""
87
88     state = payload['view']['state']['values']
89     name = state['name']['name']['value']
90     hunt_id = state['hunt_id']['hunt_id']['value']
91     url = state['url']['url']['value']
92
93     # Validate that the hunt_id contains no invalid characters
94     if not re.match(valid_id_re, hunt_id):
95         return submission_error("hunt_id",
96                                 "Hunt ID can only contain letters, "
97                                 + "numbers, and underscores")
98
99     # Check to see if the hunts table exists
100     hunts_table = turb.db.Table("hunts")
101
102     try:
103         exists = hunts_table.table_status in ("CREATING", "UPDATING",
104                                               "ACTIVE")
105     except ClientError:
106         exists = False
107
108     # Create the hunts table if necessary.
109     if not exists:
110         hunts_table = turb.db.create_table(
111             TableName='hunts',
112             KeySchema=[
113                 {'AttributeName': 'channel_id', 'KeyType': 'HASH'},
114             ],
115             AttributeDefinitions=[
116                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
117             ],
118             ProvisionedThroughput={
119                 'ReadCapacityUnits': 5,
120                 'WriteCapacityUnits': 5
121             }
122         )
123         return submission_error("hunt_id",
124                                 "Still bootstrapping hunts table. Try again.")
125
126     # Create a channel for the hunt
127     try:
128         response = turb.slack_client.conversations_create(name=hunt_id)
129     except SlackApiError as e:
130         return submission_error("hunt_id",
131                                 "Error creating Slack channel: {}"
132                                 .format(e.response['error']))
133
134     if not response['ok']:
135         return submission_error("name",
136                                 "Error occurred creating Slack channel "
137                                 + "(see CloudWatch log")
138
139     user_id = payload['user']['id']
140     channel_id = response['channel']['id']
141
142     # Create a sheet for the channel
143     sheet = turbot.sheets.sheets_create(turb, hunt_id)
144
145     channel_id = response['channel']['id']
146
147     # Insert the newly-created hunt into the database
148     hunts_table.put_item(
149         Item={
150             'channel_id': channel_id,
151             "active": True,
152             "name": name,
153             "hunt_id": hunt_id,
154             "url": url,
155             "sheet_url": sheet['url']
156         }
157     )
158
159     # Invite the initiating user to the channel
160     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
161
162     # Message the channel with the URL of the sheet
163     turb.slack_client.chat_postMessage(channel=channel_id,
164                                        text="Sheet created for this hunt: {}"
165                                        .format(sheet['url']))
166
167     # Create a database table for this hunt's puzzles
168     table = turb.db.create_table(
169         TableName=hunt_id,
170         AttributeDefinitions=[
171             {'AttributeName': 'channel_id', 'AttributeType': 'S'}
172         ],
173         KeySchema=[
174             {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
175         ],
176         ProvisionedThroughput={
177             'ReadCapacityUnits': 5,
178             'WriteCapacityUnits': 4
179         }
180     )
181
182     # Message the hunt channel that the database is ready
183     turb.slack_client.chat_postMessage(
184         channel=channel_id,
185         text="Welcome to your new hunt! "
186         + "Use `/puzzle` to create puzzles for the hunt.")
187
188     return {
189         'statusCode': 200,
190     }
191
192 def view_submission(turb, payload):
193     """Handler for Slack interactive view submission
194
195     Specifically, those that have a payload type of 'view_submission'"""
196
197     view_id = payload['view']['id']
198     metadata = payload['view']['private_metadata']
199
200     if view_id in submission_handlers:
201         return submission_handlers[view_id](turb, payload, metadata)
202
203     print("Error: Unknown view ID: {}".format(view_id))
204     return {
205         'statusCode': 400
206     }
207
208 def rot(turb, body, args):
209     """Implementation of the /rot command
210
211     The args string should be as follows:
212
213         [count|*] String to be rotated
214
215     That is, the first word of the string is an optional number (or
216     the character '*'). If this is a number it indicates an amount to
217     rotate each character in the string. If the count is '*' or is not
218     present, then the string will be rotated through all possible 25
219     values.
220
221     The result of the rotation is returned (with Slack formatting) in
222     the body of the response so that Slack will provide it as a reply
223     to the user who submitted the slash command."""
224
225     channel_name = body['channel_name'][0]
226     response_url = body['response_url'][0]
227     channel_id = body['channel_id'][0]
228
229     result = turbot.rot.rot(args)
230
231     if (channel_name == "directmessage"):
232         requests.post(response_url,
233                       json = {"text": result},
234                       headers = {"Content-type": "application/json"})
235     else:
236         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
237
238     return {
239         'statusCode': 200,
240         'body': ""
241     }
242
243 commands["/rot"] = rot
244
245 def puzzle(turb, body, args):
246     """Implementation of the /puzzle command
247
248     The args string is currently ignored (this command will bring up
249     a modal dialog for user input instead)."""
250
251     channel_id = body['channel_id'][0]
252     trigger_id = body['trigger_id'][0]
253
254     hunts_table = turb.db.Table("hunts")
255     response = hunts_table.get_item(Key={'channel_id': channel_id})
256
257     if 'Item' in response:
258         hunt_name = response['Item']['name']
259         hunt_id = response['Item']['hunt_id']
260     else:
261         return bot_reply("Sorry, this channel doesn't appear to "
262                          + "be a hunt channel")
263
264     view = {
265         "type": "modal",
266         "private_metadata": json.dumps({
267             "hunt_id": hunt_id,
268             "hunt_channel_id": channel_id
269         }),
270         "title": {"type": "plain_text", "text": "New Puzzle"},
271         "submit": { "type": "plain_text", "text": "Create" },
272         "blocks": [
273             section_block(text_block("*For {}*".format(hunt_name))),
274             input_block("Puzzle name", "name", "Name of the puzzle"),
275             input_block("Puzzle ID", "puzzle_id",
276                         "Used as part of channel name "
277                         + "(no spaces nor punctuation)"),
278             input_block("Puzzle URL", "url", "External URL of puzzle",
279                         optional=True)
280         ]
281     }
282
283     result = turb.slack_client.views_open(trigger_id=trigger_id,
284                                           view=view)
285
286     if (result['ok']):
287         submission_handlers[result['view']['id']] = puzzle_submission
288
289     return {
290         'statusCode': 200
291     }
292
293 commands["/puzzle"] = puzzle
294
295 def puzzle_submission(turb, payload, metadata):
296     """Handler for the user submitting the new puzzle modal
297
298     This is the modal view presented to the user by the puzzle function
299     above."""
300
301     print("In puzzle_submission\npayload is: {}\nmetadata is {}"
302           .format(payload, metadata))
303
304     meta = json.loads(metadata)
305     hunt_id = meta['hunt_id']
306     hunt_channel_id = meta['hunt_channel_id']
307
308     state = payload['view']['state']['values']
309     name = state['name']['name']['value']
310     puzzle_id = state['puzzle_id']['puzzle_id']['value']
311     url = state['url']['url']['value']
312
313     # Validate that the puzzle_id contains no invalid characters
314     if not re.match(valid_id_re, puzzle_id):
315         return submission_error("puzzle_id",
316                                 "Puzzle ID can only contain letters, "
317                                 + "numbers, and underscores")
318
319     # Create a channel for the puzzle
320     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
321
322     try:
323         response = turb.slack_client.conversations_create(
324             name=hunt_dash_channel)
325     except SlackApiError as e:
326         return submission_error("puzzle_id",
327                                 "Error creating Slack channel: {}"
328                                 .format(e.response['error']))
329
330     puzzle_channel_id = response['channel']['id']
331
332     # Create a sheet for the puzzle
333     sheet = turbot.sheets.sheets_create_for_puzzle(turb, hunt_dash_channel)
334
335     # Insert the newly-created puzzle into the database
336     table = turb.db.Table(hunt_id)
337
338     table.put_item(
339         Item={
340             "channel_id": puzzle_channel_id,
341             "solution": [],
342             "status": 'unsolved',
343             "name": name,
344             "puzzle_id": puzzle_id,
345             "url": url,
346             "sheet_url": sheet['url']
347         }
348     )
349
350     # Find all members of the hunt channel
351     members = turbot.slack.slack_channel_members(turb.slack_client,
352                                                  hunt_channel_id)
353
354     # Filter out Turbot's own ID to avoid inviting itself
355     members = [m for m in members if m != TURBOT_USER_ID]
356
357     turb.slack_client.chat_postMessage(channel=puzzle_channel_id,
358                                        text="Inviting members: {}".format(str(members)))
359
360     # Invite those members to the puzzle channel (in chunks of 500)
361     cursor = 0
362     while cursor < len(members):
363         turb.slack_client.conversations_invite(
364             channel=puzzle_channel_id,
365             users=members[cursor:cursor + 500])
366         cursor += 500
367
368     # Message the channel with the URL of the puzzle's sheet
369     turb.slack_client.chat_postMessage(channel=puzzle_channel_id,
370                                        text="Sheet created for this puzzle: {}"
371                                        .format(sheet['url']))
372     return {
373         'statusCode': 200
374     }