]> git.cworth.org Git - turbot/blob - turbot/interaction.py
9e92242c0f64208b3a09a22772968f8ce045dbfd
[turbot] / turbot / interaction.py
1 from slack.errors import SlackApiError
2 from turbot.blocks import (
3     input_block, section_block, text_block, multi_select_block, checkbox_block
4 )
5 from turbot.hunt import find_hunt_for_hunt_id, hunt_blocks
6 from turbot.puzzle import (
7     find_puzzle_for_url,
8     find_puzzle_for_sort_key,
9     puzzle_update_channel_and_sheet,
10     puzzle_id_from_name,
11     puzzle_blocks,
12     puzzle_sort_key
13 )
14 import turbot.rot
15 import turbot.sheets
16 import turbot.slack
17 import json
18 import re
19 import requests
20 from botocore.exceptions import ClientError
21 from boto3.dynamodb.conditions import Key
22 from turbot.slack import slack_send_message
23 import shlex
24
25 actions = {}
26 actions['button'] = {}
27 commands = {}
28 submission_handlers = {}
29
30 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
31 #
32 # Note: This restriction not only allows for hunt and puzzle ID values to
33 # be used as Slack channel names, but it also allows for '-' as a valid
34 # separator between a hunt and a puzzle ID (for example in the puzzle
35 # edit dialog where a single attribute must capture both values).
36 valid_id_re = r'^[_a-z0-9]+$'
37
38 lambda_ok = {'statusCode': 200}
39
40 def bot_reply(message):
41     """Construct a return value suitable for a bot reply
42
43     This is suitable as a way to give an error back to the user who
44     initiated a slash command, for example."""
45
46     return {
47         'statusCode': 200,
48         'body': message
49     }
50
51 def submission_error(field, error):
52     """Construct an error suitable for returning for an invalid submission.
53
54     Returning this value will prevent a submission and alert the user that
55     the given field is invalid because of the given error."""
56
57     print("Rejecting invalid modal submission: {}".format(error))
58
59     return {
60         'statusCode': 200,
61         'headers': {
62             "Content-Type": "application/json"
63         },
64         'body': json.dumps({
65             "response_action": "errors",
66             "errors": {
67                 field: error
68             }
69         })
70     }
71
72 def multi_static_select(turb, payload):
73     """Handler for the action of user entering a multi-select value"""
74
75     return lambda_ok
76
77 actions['multi_static_select'] = {"*": multi_static_select}
78
79 def edit_puzzle_command(turb, body):
80     """Implementation of the `/puzzle edit` command
81
82     As dispatched from the puzzle() function.
83     """
84
85     channel_id = body['channel_id'][0]
86     trigger_id = body['trigger_id'][0]
87
88     puzzle = puzzle_for_channel(turb, channel_id)
89
90     if not puzzle:
91         return bot_reply("Sorry, this does not appear to be a puzzle channel.")
92
93     return edit_puzzle(turb, puzzle, trigger_id)
94
95     return lambda_ok
96
97 def edit_puzzle_button(turb, payload):
98     """Handler for the action of user pressing an edit_puzzle button"""
99
100     action_id = payload['actions'][0]['action_id']
101     response_url = payload['response_url']
102     trigger_id = payload['trigger_id']
103
104     (hunt_id, sort_key) = action_id.split('-', 1)
105
106     puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
107
108     if not puzzle:
109         requests.post(response_url,
110                       json = {"text": "Error: Puzzle not found!"},
111                       headers = {"Content-type": "application/json"})
112         return bot_reply("Error: Puzzle not found.")
113
114     return edit_puzzle(turb, puzzle, trigger_id)
115
116 actions['button']['edit_puzzle'] = edit_puzzle_button
117
118 def edit_puzzle(turb, puzzle, trigger_id):
119     """Common code for implementing an edit puzzle dialog
120
121     This implementation is common whether the edit operation was invoked
122     by a button (edit_puzzle_button) or a command (edit_puzzle_command).
123     """
124
125     round_options = hunt_rounds(turb, puzzle['hunt_id'])
126
127     if len(round_options):
128         round_options_block = [
129             multi_select_block("Round(s)", "rounds",
130                                "Existing round(s) this puzzle belongs to",
131                                round_options,
132                                initial_options=puzzle.get("rounds", None)),
133         ]
134     else:
135         round_options_block = []
136
137     solved = False
138     if puzzle.get("status", "unsolved") == solved:
139         solved = True
140
141     solution_str = None
142     solution_list = puzzle.get("solution", [])
143     if solution_list:
144         solution_str = ", ".join(solution_list)
145
146     view = {
147         "type": "modal",
148         "private_metadata": json.dumps({
149             "hunt_id": puzzle['hunt_id'],
150             "SK": puzzle["SK"],
151             "puzzle_id": puzzle['puzzle_id'],
152             "channel_id": puzzle["channel_id"],
153             "channel_url": puzzle["channel_url"],
154             "sheet_url": puzzle["sheet_url"],
155         }),
156         "title": {"type": "plain_text", "text": "Edit Puzzle"},
157         "submit": { "type": "plain_text", "text": "Save" },
158         "blocks": [
159             input_block("Puzzle name", "name", "Name of the puzzle",
160                         initial_value=puzzle["name"]),
161             input_block("Puzzle URL", "url", "External URL of puzzle",
162                         initial_value=puzzle.get("url", None),
163                         optional=True),
164             checkbox_block("Is this a meta puzzle?", "Meta", "meta",
165                            checked=(puzzle.get('type', 'plain') == 'meta')),
166             * round_options_block,
167             input_block("New round(s)", "new_rounds",
168                         "New round(s) this puzzle belongs to " +
169                         "(comma separated)",
170                         optional=True),
171             input_block("State", "state",
172                         "State of this puzzle (partial progress, next steps)",
173                         initial_value=puzzle.get("state", None),
174                         optional=True),
175             checkbox_block(
176                 "Puzzle status", "Solved", "solved",
177                 checked=(puzzle.get('status', 'unsolved') == 'solved')),
178             input_block("Solution", "solution",
179                         "Solution(s) (comma-separated if multiple)",
180                         initial_value=solution_str,
181                         optional=True),
182         ]
183     }
184
185     result = turb.slack_client.views_open(trigger_id=trigger_id,
186                                           view=view)
187
188     if (result['ok']):
189         submission_handlers[result['view']['id']] = edit_puzzle_submission
190
191     return lambda_ok
192
193 def edit_puzzle_submission(turb, payload, metadata):
194     """Handler for the user submitting the edit puzzle modal
195
196     This is the modal view presented to the user by the edit_puzzle
197     function above.
198     """
199
200     puzzle={}
201
202     # First, read all the various data from the request
203     meta = json.loads(metadata)
204     puzzle['hunt_id'] = meta['hunt_id']
205     puzzle['SK'] = meta['SK']
206     puzzle['puzzle_id'] = meta['puzzle_id']
207     puzzle['channel_id'] = meta['channel_id']
208     puzzle['channel_url'] = meta['channel_url']
209     puzzle['sheet_url'] = meta['sheet_url']
210
211     state = payload['view']['state']['values']
212     user_id = payload['user']['id']
213
214     puzzle['name'] = state['name']['name']['value']
215     url = state['url']['url']['value']
216     if url:
217         puzzle['url'] = url
218     if state['meta']['meta']['selected_options']:
219         puzzle['type'] = 'meta'
220     else:
221         puzzle['type'] = 'plain'
222     rounds = [option['value'] for option in
223               state['rounds']['rounds']['selected_options']]
224     if rounds:
225         puzzle['rounds'] = rounds
226     new_rounds = state['new_rounds']['new_rounds']['value']
227     puzzle_state = state['state']['state']['value']
228     if puzzle_state:
229         puzzle['state'] = puzzle_state
230     if state['solved']['solved']['selected_options']:
231         puzzle['status'] = 'solved'
232     else:
233         puzzle['status'] = 'unsolved'
234     puzzle['solution'] = []
235     solution = state['solution']['solution']['value']
236     if solution:
237         puzzle['solution'] = [
238             sol.strip() for sol in solution.split(',')
239         ]
240
241     # Verify that there's a solution if the puzzle is mark solved
242     if puzzle['status'] == 'solved' and not puzzle['solution']:
243         return submission_error("solution",
244                                 "A solved puzzle requires a solution.")
245
246     if puzzle['status'] == 'unsolved' and puzzle['solution']:
247         return submission_error("solution",
248                                 "An unsolved puzzle should have no solution.")
249
250     # Add any new rounds to the database
251     if new_rounds:
252         if 'rounds' not in puzzle:
253             puzzle['rounds'] = []
254         for round in new_rounds.split(','):
255             # Drop any leading/trailing spaces from the round name
256             round = round.strip()
257             # Ignore any empty string
258             if not len(round):
259                 continue
260             puzzle['rounds'].append(round)
261             turb.table.put_item(
262                 Item={
263                     'hunt_id': puzzle['hunt_id'],
264                     'SK': 'round-' + round
265                 }
266             )
267
268     # Get old puzzle from the database (to determine what's changed)
269     old_puzzle = find_puzzle_for_sort_key(turb,
270                                           puzzle['hunt_id'],
271                                           puzzle['SK'])
272
273     # If we are changing puzzle type (meta -> plain or plain -> meta)
274     # the the sort key has to change, so compute the new one and delete
275     # the old item from the database.
276     #
277     # XXX: We should really be using a transaction here to combine the
278     # delete_item and the put_item into a single transaction, but
279     # the boto interface is annoying in that transactions are only on
280     # the "Client" object which has a totally different interface than
281     # the "Table" object I've been using so I haven't figured out how
282     # to do that yet.
283
284     if puzzle['type'] != old_puzzle.get('type', 'plain'):
285         puzzle['SK'] = puzzle_sort_key(puzzle)
286         turb.table.delete_item(Key={
287             'hunt_id': old_puzzle['hunt_id'],
288             'SK': old_puzzle['SK']
289         })
290
291     # Update the puzzle in the database
292     turb.table.put_item(Item=puzzle)
293
294     # Inform the puzzle channel about the edit
295     edit_message = "Puzzle edited by <@{}>".format(user_id)
296     blocks = ([section_block(text_block(edit_message+":\n"))] +
297               puzzle_blocks(puzzle, include_rounds=True))
298     slack_send_message(
299         turb.slack_client, puzzle['channel_id'],
300         edit_message, blocks=blocks)
301
302     # Also inform the hunt if the puzzle's solved status changed
303     if puzzle['status'] != old_puzzle['status']:
304         hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
305         if puzzle['status'] == 'solved':
306             message = "Puzzle <{}|{}> has been solved!".format(
307                 puzzle['channel_url'],
308                 puzzle['name'])
309         else:
310             message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
311                 puzzle['channel_url'],
312                 puzzle['name'])
313         slack_send_message(turb.slack_client, hunt['channel_id'], message)
314
315     # We need to set the channel topic if any of puzzle name, url,
316     # state, status, or solution, has changed. Let's just do that
317     # unconditionally here.
318     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
319
320     return lambda_ok
321
322 def new_hunt(turb, payload):
323     """Handler for the action of user pressing the new_hunt button"""
324
325     view = {
326         "type": "modal",
327         "private_metadata": json.dumps({}),
328         "title": { "type": "plain_text", "text": "New Hunt" },
329         "submit": { "type": "plain_text", "text": "Create" },
330         "blocks": [
331             input_block("Hunt name", "name", "Name of the hunt"),
332             input_block("Hunt ID", "hunt_id",
333                         "Used as puzzle channel prefix "
334                         + "(no spaces nor punctuation)"),
335             input_block("Hunt URL", "url", "External URL of hunt",
336                         optional=True)
337         ],
338     }
339
340     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
341                                           view=view)
342     if (result['ok']):
343         submission_handlers[result['view']['id']] = new_hunt_submission
344
345     return lambda_ok
346
347 actions['button']['new_hunt'] = new_hunt
348
349 def new_hunt_submission(turb, payload, metadata):
350     """Handler for the user submitting the new hunt modal
351
352     This is the modal view presented to the user by the new_hunt
353     function above."""
354
355     state = payload['view']['state']['values']
356     user_id = payload['user']['id']
357     name = state['name']['name']['value']
358     hunt_id = state['hunt_id']['hunt_id']['value']
359     url = state['url']['url']['value']
360
361     # Validate that the hunt_id contains no invalid characters
362     if not re.match(valid_id_re, hunt_id):
363         return submission_error("hunt_id",
364                                 "Hunt ID can only contain lowercase letters, "
365                                 + "numbers, and underscores")
366
367     # Check to see if the turbot table exists
368     try:
369         exists = turb.table.table_status in ("CREATING", "UPDATING",
370                                              "ACTIVE")
371     except ClientError:
372         exists = False
373
374     # Create the turbot table if necessary.
375     if not exists:
376         turb.table = turb.db.create_table(
377             TableName='turbot',
378             KeySchema=[
379                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
380                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
381             ],
382             AttributeDefinitions=[
383                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
384                 {'AttributeName': 'SK', 'AttributeType': 'S'},
385                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
386                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
387                 {'AttributeName': 'url', 'AttributeType': 'S'}
388             ],
389             ProvisionedThroughput={
390                 'ReadCapacityUnits': 5,
391                 'WriteCapacityUnits': 5
392             },
393             GlobalSecondaryIndexes=[
394                 {
395                     'IndexName': 'channel_id_index',
396                     'KeySchema': [
397                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
398                     ],
399                     'Projection': {
400                         'ProjectionType': 'ALL'
401                     },
402                     'ProvisionedThroughput': {
403                         'ReadCapacityUnits': 5,
404                         'WriteCapacityUnits': 5
405                     }
406                 },
407                 {
408                     'IndexName': 'is_hunt_index',
409                     'KeySchema': [
410                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
411                     ],
412                     'Projection': {
413                         'ProjectionType': 'ALL'
414                     },
415                     'ProvisionedThroughput': {
416                         'ReadCapacityUnits': 5,
417                         'WriteCapacityUnits': 5
418                     }
419                 }
420             ],
421             LocalSecondaryIndexes = [
422                 {
423                     'IndexName': 'url_index',
424                     'KeySchema': [
425                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
426                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
427                     ],
428                     'Projection': {
429                         'ProjectionType': 'ALL'
430                     }
431                 }
432             ]
433         )
434         return submission_error(
435             "hunt_id",
436             "Still bootstrapping turbot table. Try again in a minute, please.")
437
438     # Create a channel for the hunt
439     try:
440         response = turb.slack_client.conversations_create(name=hunt_id)
441     except SlackApiError as e:
442         return submission_error("hunt_id",
443                                 "Error creating Slack channel: {}"
444                                 .format(e.response['error']))
445
446     channel_id = response['channel']['id']
447
448     # Insert the newly-created hunt into the database
449     # (leaving it as non-active for now until the channel-created handler
450     #  finishes fixing it up with a sheet and a companion table)
451     item={
452         "hunt_id": hunt_id,
453         "SK": "hunt-{}".format(hunt_id),
454         "is_hunt": hunt_id,
455         "channel_id": channel_id,
456         "active": False,
457         "name": name,
458     }
459     if url:
460         item['url'] = url
461     turb.table.put_item(Item=item)
462
463     # Invite the initiating user to the channel
464     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
465
466     return lambda_ok
467
468 def view_submission(turb, payload):
469     """Handler for Slack interactive view submission
470
471     Specifically, those that have a payload type of 'view_submission'"""
472
473     view_id = payload['view']['id']
474     metadata = payload['view']['private_metadata']
475
476     if view_id in submission_handlers:
477         return submission_handlers[view_id](turb, payload, metadata)
478
479     print("Error: Unknown view ID: {}".format(view_id))
480     return {
481         'statusCode': 400
482     }
483
484 def rot(turb, body, args):
485     """Implementation of the /rot command
486
487     The args string should be as follows:
488
489         [count|*] String to be rotated
490
491     That is, the first word of the string is an optional number (or
492     the character '*'). If this is a number it indicates an amount to
493     rotate each character in the string. If the count is '*' or is not
494     present, then the string will be rotated through all possible 25
495     values.
496
497     The result of the rotation is returned (with Slack formatting) in
498     the body of the response so that Slack will provide it as a reply
499     to the user who submitted the slash command."""
500
501     channel_name = body['channel_name'][0]
502     response_url = body['response_url'][0]
503     channel_id = body['channel_id'][0]
504
505     result = turbot.rot.rot(args)
506
507     if (channel_name == "directmessage"):
508         requests.post(response_url,
509                       json = {"text": result},
510                       headers = {"Content-type": "application/json"})
511     else:
512         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
513
514     return lambda_ok
515
516 commands["/rot"] = rot
517
518 def get_table_item(turb, table_name, key, value):
519     """Get an item from the database 'table_name' with 'key' as 'value'
520
521     Returns a tuple of (item, table) if found and (None, None) otherwise."""
522
523     table = turb.db.Table(table_name)
524
525     response = table.get_item(Key={key: value})
526
527     if 'Item' in response:
528         return (response['Item'], table)
529     else:
530         return (None, None)
531
532 def db_entry_for_channel(turb, channel_id):
533     """Given a channel ID return the database item for this channel
534
535     If this channel is a registered hunt or puzzle channel, return the
536     corresponding row from the database for this channel. Otherwise,
537     return None.
538
539     Note: If you need to specifically ensure that the channel is a
540     puzzle or a hunt, please call puzzle_for_channel or
541     hunt_for_channel respectively.
542     """
543
544     response = turb.table.query(
545         IndexName = "channel_id_index",
546         KeyConditionExpression=Key("channel_id").eq(channel_id)
547     )
548
549     if response['Count'] == 0:
550         return None
551
552     return response['Items'][0]
553
554
555 def puzzle_for_channel(turb, channel_id):
556
557     """Given a channel ID return the puzzle from the database for this channel
558
559     If the given channel_id is a puzzle's channel, this function
560     returns a dict filled with the attributes from the puzzle's entry
561     in the database.
562
563     Otherwise, this function returns None.
564     """
565
566     entry = db_entry_for_channel(turb, channel_id)
567
568     if entry and entry['SK'].startswith('puzzle-'):
569         return entry
570     else:
571         return None
572
573 def hunt_for_channel(turb, channel_id):
574
575     """Given a channel ID return the hunt from the database for this channel
576
577     This works whether the original channel is a primary hunt channel,
578     or if it is one of the channels of a puzzle belonging to the hunt.
579
580     Returns None if channel does not belong to a hunt, otherwise a
581     dictionary with all fields from the hunt's row in the table,
582     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
583     """
584
585     entry = db_entry_for_channel(turb, channel_id)
586
587     # We're done if this channel doesn't exist in the database at all
588     if not entry:
589         return None
590
591     # Also done if this channel is a hunt channel
592     if entry['SK'].startswith('hunt-'):
593         return entry
594
595     # Otherwise, (the channel is in the database, but is not a hunt),
596     # we expect this to be a puzzle channel instead
597     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
598
599 # python3.9 has a built-in removeprefix but AWS only has python3.8
600 def remove_prefix(text, prefix):
601     if text.startswith(prefix):
602         return text[len(prefix):]
603     return text
604
605 def hunt_rounds(turb, hunt_id):
606     """Returns array of strings giving rounds that exist in the given hunt"""
607
608     response = turb.table.query(
609         KeyConditionExpression=(
610             Key('hunt_id').eq(hunt_id) &
611             Key('SK').begins_with('round-')
612         )
613     )
614
615     if response['Count'] == 0:
616         return []
617
618     return [remove_prefix(option['SK'], 'round-')
619             for option in response['Items']]
620
621 def puzzle(turb, body, args):
622     """Implementation of the /puzzle command
623
624     The args string can be a sub-command:
625
626         /puzzle new: Bring up a dialog to create a new puzzle
627
628         /puzzle edit: Edit the puzzle for the current channel
629
630     Or with no argument at all:
631
632         /puzzle: Print details of the current puzzle (if in a puzzle channel)
633     """
634
635     if args == 'new':
636         return new_puzzle(turb, body)
637
638     if args == 'edit':
639         return edit_puzzle_command(turb, body)
640
641     if len(args):
642         return bot_reply("Unknown syntax for `/puzzle` command. " +
643                          "Valid commands are: `/puzzle`, `/puzzle edit`, " +
644                          "and `/puzzle new` to display, edit, or create " +
645                          "a puzzle.")
646
647     # For no arguments we print the current puzzle as a reply
648     channel_id = body['channel_id'][0]
649     response_url = body['response_url'][0]
650
651     puzzle = puzzle_for_channel(turb, channel_id)
652
653     if not puzzle:
654         hunt = hunt_for_channel(turb, channel_id)
655         if hunt:
656             return bot_reply(
657                 "This is not a puzzle channel, but is a hunt channel. "
658                 + "If you want to create a new puzzle for this hunt, use "
659                 + "`/puzzle new`.")
660         else:
661             return bot_reply(
662                 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
663                 + "channel, so the `/puzzle` command cannot work here.")
664
665     blocks = puzzle_blocks(puzzle, include_rounds=True)
666
667     requests.post(response_url,
668                   json = {'blocks': blocks},
669                   headers = {'Content-type': 'application/json'}
670                   )
671
672     return lambda_ok
673
674 commands["/puzzle"] = puzzle
675
676 def new_puzzle(turb, body):
677     """Implementation of the "/puzzle new" command
678
679     This brings up a dialog box for creating a new puzzle.
680     """
681
682     channel_id = body['channel_id'][0]
683     trigger_id = body['trigger_id'][0]
684
685     hunt = hunt_for_channel(turb, channel_id)
686
687     if not hunt:
688         return bot_reply("Sorry, this channel doesn't appear to "
689                          + "be a hunt or puzzle channel")
690
691     round_options = hunt_rounds(turb, hunt['hunt_id'])
692
693     if len(round_options):
694         round_options_block = [
695             multi_select_block("Round(s)", "rounds",
696                                "Existing round(s) this puzzle belongs to",
697                                round_options)
698         ]
699     else:
700         round_options_block = []
701
702     view = {
703         "type": "modal",
704         "private_metadata": json.dumps({
705             "hunt_id": hunt['hunt_id'],
706         }),
707         "title": {"type": "plain_text", "text": "New Puzzle"},
708         "submit": { "type": "plain_text", "text": "Create" },
709         "blocks": [
710             section_block(text_block("*For {}*".format(hunt['name']))),
711             input_block("Puzzle name", "name", "Name of the puzzle"),
712             input_block("Puzzle URL", "url", "External URL of puzzle",
713                         optional=True),
714             checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
715             * round_options_block,
716             input_block("New round(s)", "new_rounds",
717                         "New round(s) this puzzle belongs to " +
718                         "(comma separated)",
719                         optional=True)
720         ]
721     }
722
723     result = turb.slack_client.views_open(trigger_id=trigger_id,
724                                           view=view)
725
726     if (result['ok']):
727         submission_handlers[result['view']['id']] = new_puzzle_submission
728
729     return lambda_ok
730
731 def new_puzzle_submission(turb, payload, metadata):
732     """Handler for the user submitting the new puzzle modal
733
734     This is the modal view presented to the user by the new_puzzle
735     function above.
736     """
737
738     # First, read all the various data from the request
739     meta = json.loads(metadata)
740     hunt_id = meta['hunt_id']
741
742     state = payload['view']['state']['values']
743     name = state['name']['name']['value']
744     url = state['url']['url']['value']
745     if state['meta']['meta']['selected_options']:
746         puzzle_type = 'meta'
747     else:
748         puzzle_type = 'plain'
749     if 'rounds' in state:
750         rounds = [option['value'] for option in
751                   state['rounds']['rounds']['selected_options']]
752     else:
753         rounds = []
754     new_rounds = state['new_rounds']['new_rounds']['value']
755
756     # Before doing anything, reject this puzzle if a puzzle already
757     # exists with the same URL.
758     if url:
759         existing = find_puzzle_for_url(turb, hunt_id, url)
760         if existing:
761             return submission_error(
762                 "url",
763                 "Error: A puzzle with this URL already exists.")
764
765     # Create a Slack-channel-safe puzzle_id
766     puzzle_id = puzzle_id_from_name(name)
767
768     # Create a channel for the puzzle
769     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
770
771     try:
772         response = turb.slack_client.conversations_create(
773             name=hunt_dash_channel)
774     except SlackApiError as e:
775         return submission_error(
776             "name",
777             "Error creating Slack channel {}: {}"
778             .format(hunt_dash_channel, e.response['error']))
779
780     channel_id = response['channel']['id']
781
782     # Add any new rounds to the database
783     if new_rounds:
784         for round in new_rounds.split(','):
785             # Drop any leading/trailing spaces from the round name
786             round = round.strip()
787             # Ignore any empty string
788             if not len(round):
789                 continue
790             rounds.append(round)
791             turb.table.put_item(
792                 Item={
793                     'hunt_id': hunt_id,
794                     'SK': 'round-' + round
795                 }
796             )
797
798     # Construct a puzzle dict
799     puzzle = {
800         "hunt_id": hunt_id,
801         "puzzle_id": puzzle_id,
802         "channel_id": channel_id,
803         "solution": [],
804         "status": 'unsolved',
805         "name": name,
806         "type": puzzle_type
807     }
808     if url:
809         puzzle['url'] = url
810     if rounds:
811         puzzle['rounds'] = rounds
812
813     # Finally, compute the appropriate sort key
814     puzzle["SK"] = puzzle_sort_key(puzzle)
815
816     # Insert the newly-created puzzle into the database
817     turb.table.put_item(Item=puzzle)
818
819     return lambda_ok
820
821 def state(turb, body, args):
822     """Implementation of the /state command
823
824     The args string should be a brief sentence describing where things
825     stand or what's needed."""
826
827     channel_id = body['channel_id'][0]
828
829     old_puzzle = puzzle_for_channel(turb, channel_id)
830
831     if not old_puzzle:
832         return bot_reply(
833             "Sorry, the /state command only works in a puzzle channel")
834
835     # Make a copy of the puzzle object
836     puzzle = old_puzzle.copy()
837
838     # Update the puzzle in the database
839     puzzle['state'] = args
840     turb.table.put_item(Item=puzzle)
841
842     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
843
844     return lambda_ok
845
846 commands["/state"] = state
847
848 def solved(turb, body, args):
849     """Implementation of the /solved command
850
851     The args string should be a confirmed solution."""
852
853     channel_id = body['channel_id'][0]
854     user_name = body['user_name'][0]
855
856     old_puzzle = puzzle_for_channel(turb, channel_id)
857
858     if not old_puzzle:
859         return bot_reply("Sorry, this is not a puzzle channel.")
860
861     if not args:
862         return bot_reply(
863             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
864
865     # Make a copy of the puzzle object
866     puzzle = old_puzzle.copy()
867
868     # Set the status and solution fields in the database
869     puzzle['status'] = 'solved'
870     puzzle['solution'].append(args)
871     if 'state' in puzzle:
872         del puzzle['state']
873     turb.table.put_item(Item=puzzle)
874
875     # Report the solution to the puzzle's channel
876     slack_send_message(
877         turb.slack_client, channel_id,
878         "Puzzle mark solved by {}: `{}`".format(user_name, args))
879
880     # Also report the solution to the hunt channel
881     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
882     slack_send_message(
883         turb.slack_client, hunt['channel_id'],
884         "Puzzle <{}|{}> has been solved!".format(
885             puzzle['channel_url'],
886             puzzle['name'])
887     )
888
889     # And update the puzzle's description
890     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
891
892     return lambda_ok
893
894 commands["/solved"] = solved
895
896 def hunt(turb, body, args):
897     """Implementation of the /hunt command
898
899     The (optional) args string can be used to filter which puzzles to
900     display. The first word can be one of 'all', 'unsolved', or
901     'solved' and can be used to display only puzzles with the given
902     status. If this first word is missing, this command will display
903     only unsolved puzzles by default.
904
905     Any remaining text in the args string will be interpreted as
906     search terms. These will be split into separate terms on space
907     characters, (though quotation marks can be used to include a space
908     character in a term). All terms must match on a puzzle in order
909     for that puzzle to be included. But a puzzle will be considered to
910     match if any of the puzzle title, round title, puzzle URL, puzzle
911     state, or puzzle solution match. Matching will be performed
912     without regard to case sensitivity and the search terms can
913     include regular expression syntax.
914     """
915
916     channel_id = body['channel_id'][0]
917     response_url = body['response_url'][0]
918
919     terms = None
920     if args:
921         # The first word can be a puzzle status and all remaining word
922         # (if any) are search terms. _But_, if the first word is not a
923         # valid puzzle status ('all', 'unsolved', 'solved'), then all
924         # words are search terms and we default status to 'unsolved'.
925         split_args = args.split(' ', 1)
926         status = split_args[0]
927         if (len(split_args) > 1):
928             terms = split_args[1]
929         if status not in ('unsolved', 'solved', 'all'):
930             terms = args
931             status = 'unsolved'
932     else:
933         status = 'unsolved'
934
935     # Separate search terms on spaces (but allow for quotation marks
936     # to capture spaces in a search term)
937     if terms:
938         terms = shlex.split(terms)
939
940     hunt = hunt_for_channel(turb, channel_id)
941
942     if not hunt:
943         return bot_reply("Sorry, this channel doesn't appear to "
944                          + "be a hunt or puzzle channel")
945
946     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
947
948     requests.post(response_url,
949                   json = { 'blocks': blocks },
950                   headers = {'Content-type': 'application/json'}
951                   )
952
953     return lambda_ok
954
955 commands["/hunt"] = hunt
956
957 def round(turb, body, args):
958     """Implementation of the /round command
959
960     Displays puzzles in the same round(s) as the puzzle for the
961     current channel.
962
963     The (optional) args string can be used to filter which puzzles to
964     display. The first word can be one of 'all', 'unsolved', or
965     'solved' and can be used to display only puzzles with the given
966     status. If this first word is missing, this command will display
967     all puzzles in the round by default.
968
969     Any remaining text in the args string will be interpreted as
970     search terms. These will be split into separate terms on space
971     characters, (though quotation marks can be used to include a space
972     character in a term). All terms must match on a puzzle in order
973     for that puzzle to be included. But a puzzle will be considered to
974     match if any of the puzzle title, round title, puzzle URL, puzzle
975     state, or puzzle solution match. Matching will be performed
976     without regard to case sensitivity and the search terms can
977     include regular expression syntax.
978     """
979
980     channel_id = body['channel_id'][0]
981     response_url = body['response_url'][0]
982
983     puzzle = puzzle_for_channel(turb, channel_id)
984     hunt = hunt_for_channel(turb, channel_id)
985
986     if not puzzle:
987         if hunt:
988             return bot_reply(
989                 "This is not a puzzle channel, but is a hunt channel. "
990                 + "Use /hunt if you want to see all rounds for this hunt.")
991         else:
992             return bot_reply(
993                 "Sorry, this channel doesn't appear to be a puzzle channel "
994                 + "so the `/round` command cannot work here.")
995
996     terms = None
997     if args:
998         # The first word can be a puzzle status and all remaining word
999         # (if any) are search terms. _But_, if the first word is not a
1000         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1001         # words are search terms and we default status to 'unsolved'.
1002         split_args = args.split(' ', 1)
1003         status = split_args[0]
1004         if (len(split_args) > 1):
1005             terms = split_args[1]
1006         if status not in ('unsolved', 'solved', 'all'):
1007             terms = args
1008             status = 'all'
1009     else:
1010         status = 'all'
1011
1012     # Separate search terms on spaces (but allow for quotation marks
1013     # to capture spaces in a search term)
1014     if terms:
1015         terms = shlex.split(terms)
1016
1017     blocks = hunt_blocks(turb, hunt,
1018                          puzzle_status=status, search_terms=terms,
1019                          limit_to_rounds=puzzle.get('rounds', [])
1020                          )
1021
1022     requests.post(response_url,
1023                   json = { 'blocks': blocks },
1024                   headers = {'Content-type': 'application/json'}
1025                   )
1026
1027     return lambda_ok
1028
1029 commands["/round"] = round