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