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