]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add a hunt_id attribute and index to the database for hunts
[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 from turbot.slack import slack_send_message
11
12 actions = {}
13 commands = {}
14 submission_handlers = {}
15
16 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
17 valid_id_re = r'^[_a-z0-9]+$'
18
19 lambda_ok = {'statusCode': 200}
20
21 def bot_reply(message):
22     """Construct a return value suitable for a bot reply
23
24     This is suitable as a way to give an error back to the user who
25     initiated a slash command, for example."""
26
27     return {
28         'statusCode': 200,
29         'body': message
30     }
31
32 def submission_error(field, error):
33     """Construct an error suitable for returning for an invalid submission.
34
35     Returning this value will prevent a submission and alert the user that
36     the given field is invalid because of the given error."""
37
38     print("Rejecting invalid modal submission: {}".format(error))
39
40     return {
41         'statusCode': 200,
42         'headers': {
43             "Content-Type": "application/json"
44         },
45         'body': json.dumps({
46             "response_action": "errors",
47             "errors": {
48                 field: error
49             }
50         })
51     }
52
53 def new_hunt(turb, payload):
54     """Handler for the action of user pressing the new_hunt button"""
55
56     view = {
57         "type": "modal",
58         "private_metadata": json.dumps({}),
59         "title": { "type": "plain_text", "text": "New Hunt" },
60         "submit": { "type": "plain_text", "text": "Create" },
61         "blocks": [
62             input_block("Hunt name", "name", "Name of the hunt"),
63             input_block("Hunt ID", "hunt_id",
64                         "Used as puzzle channel prefix "
65                         + "(no spaces nor punctuation)"),
66             input_block("Hunt URL", "url", "External URL of hunt",
67                         optional=True)
68         ],
69     }
70
71     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
72                                           view=view)
73     if (result['ok']):
74         submission_handlers[result['view']['id']] = new_hunt_submission
75
76     return lambda_ok
77
78 actions['button'] = {"new_hunt": new_hunt}
79
80 def new_hunt_submission(turb, payload, metadata):
81     """Handler for the user submitting the new hunt modal
82
83     This is the modal view presented to the user by the new_hunt
84     function above."""
85
86     state = payload['view']['state']['values']
87     user_id = payload['user']['id']
88     name = state['name']['name']['value']
89     hunt_id = state['hunt_id']['hunt_id']['value']
90     url = state['url']['url']['value']
91
92     # Validate that the hunt_id contains no invalid characters
93     if not re.match(valid_id_re, hunt_id):
94         return submission_error("hunt_id",
95                                 "Hunt ID can only contain lowercase letters, "
96                                 + "numbers, and underscores")
97
98     # Check to see if the turbot table exists
99     try:
100         exists = turb.table.table_status in ("CREATING", "UPDATING",
101                                              "ACTIVE")
102     except ClientError:
103         exists = False
104
105     # Create the turbot table if necessary.
106     if not exists:
107         turb.table = turb.db.create_table(
108             TableName='turbot',
109             KeySchema=[
110                 {'AttributeName': 'PK', 'KeyType': 'HASH'},
111                 {'AttributeName': 'SK', 'KeyType': 'RANGE'},
112             ],
113             AttributeDefinitions=[
114                 {'AttributeName': 'PK', 'AttributeType': 'S'},
115                 {'AttributeName': 'SK', 'AttributeType': 'S'},
116                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
117                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
118             ],
119             ProvisionedThroughput={
120                 'ReadCapacityUnits': 5,
121                 'WriteCapacityUnits': 5
122             },
123             GlobalSecondaryIndexes=[
124                 {
125                     'IndexName': 'channel_id_index',
126                     'KeySchema': [
127                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
128                     ],
129                     'Projection': {
130                         'ProjectionType': 'ALL'
131                     },
132                     'ProvisionedThroughput': {
133                         'ReadCapacityUnits': 5,
134                         'WriteCapacityUnits': 5
135                     }
136                 },
137                 {
138                     'IndexName': 'hunt_id_index',
139                     'KeySchema': [
140                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'}
141                     ],
142                     'Projection': {
143                         'ProjectionType': 'ALL'
144                     },
145                     'ProvisionedThroughput': {
146                         'ReadCapacityUnits': 5,
147                         'WriteCapacityUnits': 5
148                     }
149                 }
150             ]
151         )
152         return submission_error("hunt_id",
153                                 "Still bootstrapping turbot table. Try again.")
154
155     # Create a channel for the hunt
156     try:
157         response = turb.slack_client.conversations_create(name=hunt_id)
158     except SlackApiError as e:
159         return submission_error("hunt_id",
160                                 "Error creating Slack channel: {}"
161                                 .format(e.response['error']))
162
163     channel_id = response['channel']['id']
164
165     # Insert the newly-created hunt into the database
166     # (leaving it as non-active for now until the channel-created handler
167     #  finishes fixing it up with a sheet and a companion table)
168     turb.table.put_item(
169         Item={
170             "PK": "hunt-{}".format(hunt_id),
171             "SK": "hunt-{}".format(hunt_id),
172             "hunt_id": hunt_id,
173             "channel_id": channel_id,
174             "active": False,
175             "name": name,
176             "url": url
177         }
178     )
179
180     # Invite the initiating user to the channel
181     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
182
183     return lambda_ok
184
185 def view_submission(turb, payload):
186     """Handler for Slack interactive view submission
187
188     Specifically, those that have a payload type of 'view_submission'"""
189
190     view_id = payload['view']['id']
191     metadata = payload['view']['private_metadata']
192
193     if view_id in submission_handlers:
194         return submission_handlers[view_id](turb, payload, metadata)
195
196     print("Error: Unknown view ID: {}".format(view_id))
197     return {
198         'statusCode': 400
199     }
200
201 def rot(turb, body, args):
202     """Implementation of the /rot command
203
204     The args string should be as follows:
205
206         [count|*] String to be rotated
207
208     That is, the first word of the string is an optional number (or
209     the character '*'). If this is a number it indicates an amount to
210     rotate each character in the string. If the count is '*' or is not
211     present, then the string will be rotated through all possible 25
212     values.
213
214     The result of the rotation is returned (with Slack formatting) in
215     the body of the response so that Slack will provide it as a reply
216     to the user who submitted the slash command."""
217
218     channel_name = body['channel_name'][0]
219     response_url = body['response_url'][0]
220     channel_id = body['channel_id'][0]
221
222     result = turbot.rot.rot(args)
223
224     if (channel_name == "directmessage"):
225         requests.post(response_url,
226                       json = {"text": result},
227                       headers = {"Content-type": "application/json"})
228     else:
229         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
230
231     return lambda_ok
232
233 commands["/rot"] = rot
234
235 def get_table_item(turb, table_name, key, value):
236     """Get an item from the database 'table_name' with 'key' as 'value'
237
238     Returns a tuple of (item, table) if found and (None, None) otherwise."""
239
240     table = turb.db.Table(table_name)
241
242     response = table.get_item(Key={key: value})
243
244     if 'Item' in response:
245         return (response['Item'], table)
246     else:
247         return (None, None)
248
249 def channel_is_puzzle(turb, channel_id, channel_name):
250     """Given a channel ID/name return the database item for the puzzle
251
252     If this channel is a puzzle, this function returns a tuple:
253
254         (puzzle, table)
255
256     Where puzzle is dict filled with database entries, and table is a
257     database table that can be used to update the puzzle in the
258     database.
259
260     Otherwise, this function returns (None, None)."""
261
262     hunt_id = channel_name.split('-')[0]
263
264     # Not a puzzle channel if there is no hyphen in the name
265     if hunt_id == channel_name:
266         return (None, None)
267
268     return get_table_item(turb, hunt_id, 'channel_id', channel_id)
269
270 def channel_is_hunt(turb, channel_id):
271
272     """Given a channel ID/name return the database item for the hunt
273
274     Returns a dict (filled with database entries) if there is a hunt
275     for this channel, otherwise returns None."""
276
277     return get_table_item(turb, "channel_id_index", 'channel_id', channel_id)
278
279 def find_hunt_for_hunt_id(turb, hunt_id):
280     """Given a hunt ID find the database for for that hunt
281
282     Returns None if hunt ID is not found, otherwise a
283     dictionary with all fields from the hunt's row in the table,
284     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
285
286     """
287     turbot_table = turb.db.Table("turbot")
288
289     response = turbot_table.get_item(Key={'PK': 'hunt-{}'.format(hunt_id)})
290
291     if 'Item' in response:
292         return response['Item']
293     else:
294         return None
295
296 def find_hunt_for_channel(turb, channel_id, channel_name):
297     """Given a channel ID/name find the id/name of the hunt for this channel
298
299     This works whether the original channel is a primary hunt channel,
300     or if it is one of the channels of a puzzle belonging to the hunt.
301
302     Returns None if channel does not belong to a hunt, otherwise a
303     dictionary with all fields from the hunt's row in the table,
304     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
305
306     """
307
308     (hunt, _) = channel_is_hunt(turb, channel_id)
309
310     if hunt:
311         return hunt
312
313     # So we're not a hunt channel, let's look to see if we are a
314     # puzzle channel with a hunt-id prefix.
315     hunt_id = channel_name.split('-')[0]
316
317     return find_hunt_for_hunt_id(turb, hunt_id)
318
319 def puzzle(turb, body, args):
320     """Implementation of the /puzzle command
321
322     The args string is currently ignored (this command will bring up
323     a modal dialog for user input instead)."""
324
325     channel_id = body['channel_id'][0]
326     channel_name = body['channel_name'][0]
327     trigger_id = body['trigger_id'][0]
328
329     hunt = find_hunt_for_channel(turb,
330                                  channel_id,
331                                  channel_name)
332
333     if not hunt:
334         return bot_reply("Sorry, this channel doesn't appear to "
335                          + "be a hunt or puzzle channel")
336
337     view = {
338         "type": "modal",
339         "private_metadata": json.dumps({
340             "hunt_id": hunt['hunt_id'],
341         }),
342         "title": {"type": "plain_text", "text": "New Puzzle"},
343         "submit": { "type": "plain_text", "text": "Create" },
344         "blocks": [
345             section_block(text_block("*For {}*".format(hunt['name']))),
346             input_block("Puzzle name", "name", "Name of the puzzle"),
347             input_block("Puzzle URL", "url", "External URL of puzzle",
348                         optional=True)
349         ]
350     }
351
352     result = turb.slack_client.views_open(trigger_id=trigger_id,
353                                           view=view)
354
355     if (result['ok']):
356         submission_handlers[result['view']['id']] = puzzle_submission
357
358     return lambda_ok
359
360 commands["/puzzle"] = puzzle
361
362 def puzzle_submission(turb, payload, metadata):
363     """Handler for the user submitting the new puzzle modal
364
365     This is the modal view presented to the user by the puzzle function
366     above."""
367
368     meta = json.loads(metadata)
369     hunt_id = meta['hunt_id']
370
371     state = payload['view']['state']['values']
372     name = state['name']['name']['value']
373     url = state['url']['url']['value']
374
375     # Create a Slack-channel-safe puzzle_id
376     puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
377
378     # Create a channel for the puzzle
379     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
380
381     try:
382         response = turb.slack_client.conversations_create(
383             name=hunt_dash_channel)
384     except SlackApiError as e:
385         return submission_error(
386             "name",
387             "Error creating Slack channel {}: {}"
388             .format(hunt_dash_channel, e.response['error']))
389
390     puzzle_channel_id = response['channel']['id']
391
392     # Insert the newly-created puzzle into the database
393     table = turb.db.Table(hunt_id)
394     table.put_item(
395         Item={
396             "channel_id": puzzle_channel_id,
397             "solution": [],
398             "status": 'unsolved',
399             "hunt_id": hunt_id,
400             "name": name,
401             "puzzle_id": puzzle_id,
402             "url": url,
403         }
404     )
405
406     return lambda_ok
407
408 # XXX: This duplicates functionality eith events.py:set_channel_description
409 def set_channel_topic(turb, puzzle):
410     channel_id = puzzle['channel_id']
411     name = puzzle['name']
412     url = puzzle.get('url', None)
413     sheet_url = puzzle.get('sheet_url', None)
414     state = puzzle.get('state', None)
415     status = puzzle['status']
416
417     description = ''
418
419     if status == 'solved':
420         description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
421
422     description += name
423
424     links = []
425     if url:
426         links.append("<{}|Puzzle>".format(url))
427     if sheet_url:
428         links.append("<{}|Sheet>".format(sheet_url))
429
430     if len(links):
431         description += "({})".format(', '.join(links))
432
433     if state:
434         description += " {}".format(state)
435
436     turb.slack_client.conversations_setTopic(channel=channel_id,
437                                              topic=description)
438
439 def state(turb, body, args):
440     """Implementation of the /state command
441
442     The args string should be a brief sentence describing where things
443     stand or what's needed."""
444
445     channel_id = body['channel_id'][0]
446     channel_name = body['channel_name'][0]
447
448     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
449
450     if not puzzle:
451         return bot_reply("Sorry, this is not a puzzle channel.")
452
453     # Set the state field in the database
454     puzzle['state'] = args
455     table.put_item(Item=puzzle)
456
457     set_channel_topic(turb, puzzle)
458
459     return lambda_ok
460
461 commands["/state"] = state
462
463 def solved(turb, body, args):
464     """Implementation of the /solved command
465
466     The args string should be a confirmed solution."""
467
468     channel_id = body['channel_id'][0]
469     channel_name = body['channel_name'][0]
470     user_name = body['user_name'][0]
471
472     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
473
474     if not puzzle:
475         return bot_reply("Sorry, this is not a puzzle channel.")
476
477     # Set the status and solution fields in the database
478     puzzle['status'] = 'solved'
479     puzzle['solution'].append(args)
480     table.put_item(Item=puzzle)
481
482     # Report the solution to the puzzle's channel
483     slack_send_message(
484         turb.slack_client, channel_id,
485         "Puzzle mark solved by {}: `{}`".format(user_name, args))
486
487     # Also report the solution to the hunt channel
488     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
489     slack_send_message(
490         turb.slack_client, hunt['channel_id'],
491         "Puzzle <{}|{}> has been solved!".format(
492             puzzle['channel_url'],
493             puzzle['name'])
494     )
495
496     # And update the puzzle's description
497     set_channel_topic(turb, puzzle)
498
499     # And rename the sheet to prefix with "SOLVED: "
500     turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
501                               'SOLVED: ' + puzzle['name'])
502
503     # Finally, rename the Slack channel to add the suffix '-solved'
504     channel_name = "{}-{}-solved".format(
505         puzzle['hunt_id'],
506         puzzle['puzzle_id'])
507     turb.slack_client.conversations_rename(
508         channel=puzzle['channel_id'],
509         name=channel_name)
510
511     return lambda_ok
512
513 commands["/solved"] = solved