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