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