]> git.cworth.org Git - turbot/blob - turbot/interaction.py
8f94a064071674d75dc1a039ac2d273b2a72e33b
[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': 'hunt_id', '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': 'hunt_id_index',
140                     'KeySchema': [
141                         {'AttributeName': 'hunt_id', '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     turb.table.put_item(
171         Item={
172             "PK": "hunt-{}".format(hunt_id),
173             "SK": "hunt-{}".format(hunt_id),
174             "hunt_id": hunt_id,
175             "channel_id": channel_id,
176             "active": False,
177             "name": name,
178             "url": url
179         }
180     )
181
182     # Invite the initiating user to the channel
183     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
184
185     return lambda_ok
186
187 def view_submission(turb, payload):
188     """Handler for Slack interactive view submission
189
190     Specifically, those that have a payload type of 'view_submission'"""
191
192     view_id = payload['view']['id']
193     metadata = payload['view']['private_metadata']
194
195     if view_id in submission_handlers:
196         return submission_handlers[view_id](turb, payload, metadata)
197
198     print("Error: Unknown view ID: {}".format(view_id))
199     return {
200         'statusCode': 400
201     }
202
203 def rot(turb, body, args):
204     """Implementation of the /rot command
205
206     The args string should be as follows:
207
208         [count|*] String to be rotated
209
210     That is, the first word of the string is an optional number (or
211     the character '*'). If this is a number it indicates an amount to
212     rotate each character in the string. If the count is '*' or is not
213     present, then the string will be rotated through all possible 25
214     values.
215
216     The result of the rotation is returned (with Slack formatting) in
217     the body of the response so that Slack will provide it as a reply
218     to the user who submitted the slash command."""
219
220     channel_name = body['channel_name'][0]
221     response_url = body['response_url'][0]
222     channel_id = body['channel_id'][0]
223
224     result = turbot.rot.rot(args)
225
226     if (channel_name == "directmessage"):
227         requests.post(response_url,
228                       json = {"text": result},
229                       headers = {"Content-type": "application/json"})
230     else:
231         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
232
233     return lambda_ok
234
235 commands["/rot"] = rot
236
237 def get_table_item(turb, table_name, key, value):
238     """Get an item from the database 'table_name' with 'key' as 'value'
239
240     Returns a tuple of (item, table) if found and (None, None) otherwise."""
241
242     table = turb.db.Table(table_name)
243
244     response = table.get_item(Key={key: value})
245
246     if 'Item' in response:
247         return (response['Item'], table)
248     else:
249         return (None, None)
250
251 def channel_is_puzzle(turb, channel_id, channel_name):
252     """Given a channel ID/name return the database item for the puzzle
253
254     If this channel is a puzzle, this function returns a tuple:
255
256         (puzzle, table)
257
258     Where puzzle is dict filled with database entries, and table is a
259     database table that can be used to update the puzzle in the
260     database.
261
262     Otherwise, this function returns (None, None)."""
263
264     hunt_id = channel_name.split('-')[0]
265
266     # Not a puzzle channel if there is no hyphen in the name
267     if hunt_id == channel_name:
268         return (None, None)
269
270     return get_table_item(turb, hunt_id, 'channel_id', channel_id)
271
272 def channel_is_hunt(turb, channel_id):
273
274     """Given a channel ID/name return the database item for the hunt
275
276     Returns a dict (filled with database entries) if there is a hunt
277     for this channel, otherwise returns None."""
278
279     response = turb.table.query(
280         IndexName = "channel_id_index",
281         KeyConditionExpression=Key("channel_id").eq(channel_id)
282     )
283
284     if 'Items' not in response:
285         return None
286
287     return response['Items'][0]
288
289 def find_hunt_for_hunt_id(turb, hunt_id):
290     """Given a hunt ID find the database for for that hunt
291
292     Returns None if hunt ID is not found, otherwise a
293     dictionary with all fields from the hunt's row in the table,
294     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
295
296     """
297     turbot_table = turb.db.Table("turbot")
298
299     response = turbot_table.get_item(Key={'PK': 'hunt-{}'.format(hunt_id)})
300
301     if 'Item' in response:
302         return response['Item']
303     else:
304         return None
305
306 def find_hunt_for_channel(turb, channel_id, channel_name):
307     """Given a channel ID/name find the id/name of the hunt for this channel
308
309     This works whether the original channel is a primary hunt channel,
310     or if it is one of the channels of a puzzle belonging to the hunt.
311
312     Returns None if channel does not belong to a hunt, otherwise a
313     dictionary with all fields from the hunt's row in the table,
314     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
315
316     """
317
318     hunt = channel_is_hunt(turb, channel_id)
319
320     if hunt:
321         return hunt
322
323     # So we're not a hunt channel, let's look to see if we are a
324     # puzzle channel with a hunt-id prefix.
325     hunt_id = channel_name.split('-')[0]
326
327     return find_hunt_for_hunt_id(turb, hunt_id)
328
329 def puzzle(turb, body, args):
330     """Implementation of the /puzzle command
331
332     The args string is currently ignored (this command will bring up
333     a modal dialog for user input instead)."""
334
335     channel_id = body['channel_id'][0]
336     channel_name = body['channel_name'][0]
337     trigger_id = body['trigger_id'][0]
338
339     hunt = find_hunt_for_channel(turb,
340                                  channel_id,
341                                  channel_name)
342
343     if not hunt:
344         return bot_reply("Sorry, this channel doesn't appear to "
345                          + "be a hunt or puzzle channel")
346
347     view = {
348         "type": "modal",
349         "private_metadata": json.dumps({
350             "hunt_id": hunt['hunt_id'],
351         }),
352         "title": {"type": "plain_text", "text": "New Puzzle"},
353         "submit": { "type": "plain_text", "text": "Create" },
354         "blocks": [
355             section_block(text_block("*For {}*".format(hunt['name']))),
356             input_block("Puzzle name", "name", "Name of the puzzle"),
357             input_block("Puzzle URL", "url", "External URL of puzzle",
358                         optional=True)
359         ]
360     }
361
362     result = turb.slack_client.views_open(trigger_id=trigger_id,
363                                           view=view)
364
365     if (result['ok']):
366         submission_handlers[result['view']['id']] = puzzle_submission
367
368     return lambda_ok
369
370 commands["/puzzle"] = puzzle
371
372 def puzzle_submission(turb, payload, metadata):
373     """Handler for the user submitting the new puzzle modal
374
375     This is the modal view presented to the user by the puzzle function
376     above."""
377
378     meta = json.loads(metadata)
379     hunt_id = meta['hunt_id']
380
381     state = payload['view']['state']['values']
382     name = state['name']['name']['value']
383     url = state['url']['url']['value']
384
385     # Create a Slack-channel-safe puzzle_id
386     puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
387
388     # Create a channel for the puzzle
389     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
390
391     try:
392         response = turb.slack_client.conversations_create(
393             name=hunt_dash_channel)
394     except SlackApiError as e:
395         return submission_error(
396             "name",
397             "Error creating Slack channel {}: {}"
398             .format(hunt_dash_channel, e.response['error']))
399
400     channel_id = response['channel']['id']
401
402     # Insert the newly-created puzzle into the database
403     turb.table.put_item(
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             "url": url,
413         }
414     )
415
416     return lambda_ok
417
418 # XXX: This duplicates functionality eith events.py:set_channel_description
419 def set_channel_topic(turb, puzzle):
420     channel_id = puzzle['channel_id']
421     name = puzzle['name']
422     url = puzzle.get('url', None)
423     sheet_url = puzzle.get('sheet_url', None)
424     state = puzzle.get('state', None)
425     status = puzzle['status']
426
427     description = ''
428
429     if status == 'solved':
430         description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
431
432     description += name
433
434     links = []
435     if url:
436         links.append("<{}|Puzzle>".format(url))
437     if sheet_url:
438         links.append("<{}|Sheet>".format(sheet_url))
439
440     if len(links):
441         description += "({})".format(', '.join(links))
442
443     if state:
444         description += " {}".format(state)
445
446     turb.slack_client.conversations_setTopic(channel=channel_id,
447                                              topic=description)
448
449 def state(turb, body, args):
450     """Implementation of the /state command
451
452     The args string should be a brief sentence describing where things
453     stand or what's needed."""
454
455     channel_id = body['channel_id'][0]
456     channel_name = body['channel_name'][0]
457
458     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
459
460     if not puzzle:
461         return bot_reply("Sorry, this is not a puzzle channel.")
462
463     # Set the state field in the database
464     puzzle['state'] = args
465     table.put_item(Item=puzzle)
466
467     set_channel_topic(turb, puzzle)
468
469     return lambda_ok
470
471 commands["/state"] = state
472
473 def solved(turb, body, args):
474     """Implementation of the /solved command
475
476     The args string should be a confirmed solution."""
477
478     channel_id = body['channel_id'][0]
479     channel_name = body['channel_name'][0]
480     user_name = body['user_name'][0]
481
482     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
483
484     if not puzzle:
485         return bot_reply("Sorry, this is not a puzzle channel.")
486
487     # Set the status and solution fields in the database
488     puzzle['status'] = 'solved'
489     puzzle['solution'].append(args)
490     table.put_item(Item=puzzle)
491
492     # Report the solution to the puzzle's channel
493     slack_send_message(
494         turb.slack_client, channel_id,
495         "Puzzle mark solved by {}: `{}`".format(user_name, args))
496
497     # Also report the solution to the hunt channel
498     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
499     slack_send_message(
500         turb.slack_client, hunt['channel_id'],
501         "Puzzle <{}|{}> has been solved!".format(
502             puzzle['channel_url'],
503             puzzle['name'])
504     )
505
506     # And update the puzzle's description
507     set_channel_topic(turb, puzzle)
508
509     # And rename the sheet to prefix with "SOLVED: "
510     turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
511                               'SOLVED: ' + puzzle['name'])
512
513     # Finally, rename the Slack channel to add the suffix '-solved'
514     channel_name = "{}-{}-solved".format(
515         puzzle['hunt_id'],
516         puzzle['puzzle_id'])
517     turb.slack_client.conversations_rename(
518         channel=puzzle['channel_id'],
519         name=channel_name)
520
521     return lambda_ok
522
523 commands["/solved"] = solved