1 from slack.errors import SlackApiError
2 from turbot.blocks import (
3 input_block, section_block, text_block, multi_select_block, checkbox_block
5 from turbot.hunt import (
8 hunt_puzzles_for_hunt_id
10 from turbot.puzzle import (
12 find_puzzle_for_sort_key,
13 puzzle_update_channel_and_sheet,
19 from turbot.round import round_quoted_puzzles_titles_answers
26 from botocore.exceptions import ClientError
27 from boto3.dynamodb.conditions import Key
28 from turbot.slack import slack_send_message
32 actions['button'] = {}
34 submission_handlers = {}
36 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
38 # Note: This restriction not only allows for hunt and puzzle ID values to
39 # be used as Slack channel names, but it also allows for '-' as a valid
40 # separator between a hunt and a puzzle ID (for example in the puzzle
41 # edit dialog where a single attribute must capture both values).
42 valid_id_re = r'^[_a-z0-9]+$'
44 lambda_ok = {'statusCode': 200}
46 def bot_reply(message):
47 """Construct a return value suitable for a bot reply
49 This is suitable as a way to give an error back to the user who
50 initiated a slash command, for example."""
57 def submission_error(field, error):
58 """Construct an error suitable for returning for an invalid submission.
60 Returning this value will prevent a submission and alert the user that
61 the given field is invalid because of the given error."""
63 print("Rejecting invalid modal submission: {}".format(error))
68 "Content-Type": "application/json"
71 "response_action": "errors",
78 def multi_static_select(turb, payload):
79 """Handler for the action of user entering a multi-select value"""
83 actions['multi_static_select'] = {"*": multi_static_select}
85 def edit(turb, body, args):
87 """Implementation of the `/edit` command
89 This can be used as `/edit` (with no arguments) in either a hunt
90 or a puzzle channel to edit that hunt or puzzle. It can also be
91 called explicitly as `/edit hunt` to edit a hunt even from a
94 In any case, the operation is identical to `/hunt edit` or
98 # If we have an explicit argument, do what it says to do
100 return edit_hunt_command(turb, body)
103 return edit_puzzle_command(turb, body)
105 # Any other argument string is an error
107 return bot_reply("Error: Unexpected argument: {}\n".format(args) +
108 "Usage: `/edit puzzle`, `/edit hunt`, or " +
109 "`/edit` (to choose based on channel)"
112 # No explicit argument, so select what to edit based on the current channel
113 channel_id = body['channel_id'][0]
114 trigger_id = body['trigger_id'][0]
116 puzzle = puzzle_for_channel(turb, channel_id)
118 return edit_puzzle(turb, puzzle, trigger_id)
120 hunt = hunt_for_channel(turb, channel_id)
122 return edit_hunt(turb, hunt, trigger_id)
124 return bot_reply("Sorry, `/edit` only works in a hunt or puzzle channel.")
126 commands["/edit"] = edit
129 def edit_puzzle_command(turb, body):
130 """Implementation of the `/puzzle edit` command
132 As dispatched from the puzzle() function.
135 channel_id = body['channel_id'][0]
136 trigger_id = body['trigger_id'][0]
138 puzzle = puzzle_for_channel(turb, channel_id)
141 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
143 return edit_puzzle(turb, puzzle, trigger_id)
145 def edit_puzzle_button(turb, payload):
146 """Handler for the action of user pressing an edit_puzzle button"""
148 action_id = payload['actions'][0]['action_id']
149 trigger_id = payload['trigger_id']
151 (hunt_id, sort_key) = action_id.split('-', 1)
153 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
156 return bot_reply("Error: Puzzle not found.")
158 return edit_puzzle(turb, puzzle, trigger_id)
160 actions['button']['edit_puzzle'] = edit_puzzle_button
162 def edit_puzzle(turb, puzzle, trigger_id):
163 """Common code for implementing an edit puzzle dialog
165 This implementation is common whether the edit operation was invoked
166 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
169 round_options = hunt_rounds(turb, puzzle['hunt_id'])
171 if len(round_options):
172 round_options_block = [
173 multi_select_block("Round(s)", "rounds",
174 "Existing round(s) this puzzle belongs to",
176 initial_options=puzzle.get("rounds", None)),
179 round_options_block = []
182 if puzzle.get("status", "unsolved") == solved:
186 solution_list = puzzle.get("solution", [])
188 solution_str = ", ".join(solution_list)
192 "private_metadata": json.dumps({
193 "hunt_id": puzzle['hunt_id'],
195 "puzzle_id": puzzle['puzzle_id'],
196 "channel_id": puzzle["channel_id"],
197 "channel_url": puzzle["channel_url"],
198 "sheet_url": puzzle["sheet_url"],
200 "title": {"type": "plain_text", "text": "Edit Puzzle"},
201 "submit": { "type": "plain_text", "text": "Save" },
203 input_block("Puzzle name", "name", "Name of the puzzle",
204 initial_value=puzzle["name"]),
205 input_block("Puzzle URL", "url", "External URL of puzzle",
206 initial_value=puzzle.get("url", None),
208 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
209 checked=(puzzle.get('type', 'plain') == 'meta')),
210 * round_options_block,
211 input_block("New round(s)", "new_rounds",
212 "New round(s) this puzzle belongs to " +
215 input_block("State", "state",
216 "State of this puzzle (partial progress, next steps)",
217 initial_value=puzzle.get("state", None),
220 "Puzzle status", "Solved", "solved",
221 checked=(puzzle.get('status', 'unsolved') == 'solved')),
222 input_block("Solution", "solution",
223 "Solution(s) (comma-separated if multiple)",
224 initial_value=solution_str,
229 result = turb.slack_client.views_open(trigger_id=trigger_id,
233 submission_handlers[result['view']['id']] = edit_puzzle_submission
237 def edit_puzzle_submission(turb, payload, metadata):
238 """Handler for the user submitting the edit puzzle modal
240 This is the modal view presented to the user by the edit_puzzle
246 # First, read all the various data from the request
247 meta = json.loads(metadata)
248 puzzle['hunt_id'] = meta['hunt_id']
249 puzzle['SK'] = meta['SK']
250 puzzle['puzzle_id'] = meta['puzzle_id']
251 puzzle['channel_id'] = meta['channel_id']
252 puzzle['channel_url'] = meta['channel_url']
253 puzzle['sheet_url'] = meta['sheet_url']
255 state = payload['view']['state']['values']
256 user_id = payload['user']['id']
258 puzzle['name'] = state['name']['name']['value']
259 url = state['url']['url']['value']
262 if state['meta']['meta']['selected_options']:
263 puzzle['type'] = 'meta'
265 puzzle['type'] = 'plain'
266 rounds = [option['value'] for option in
267 state['rounds']['rounds']['selected_options']]
269 puzzle['rounds'] = rounds
270 new_rounds = state['new_rounds']['new_rounds']['value']
271 puzzle_state = state['state']['state']['value']
273 puzzle['state'] = puzzle_state
274 if state['solved']['solved']['selected_options']:
275 puzzle['status'] = 'solved'
277 puzzle['status'] = 'unsolved'
278 puzzle['solution'] = []
279 solution = state['solution']['solution']['value']
281 puzzle['solution'] = [
282 sol.strip() for sol in solution.split(',')
285 # Verify that there's a solution if the puzzle is mark solved
286 if puzzle['status'] == 'solved' and not puzzle['solution']:
287 return submission_error("solution",
288 "A solved puzzle requires a solution.")
290 if puzzle['status'] == 'unsolved' and puzzle['solution']:
291 return submission_error("solution",
292 "An unsolved puzzle should have no solution.")
294 # Add any new rounds to the database
296 if 'rounds' not in puzzle:
297 puzzle['rounds'] = []
298 for round in new_rounds.split(','):
299 # Drop any leading/trailing spaces from the round name
300 round = round.strip()
301 # Ignore any empty string
304 puzzle['rounds'].append(round)
307 'hunt_id': puzzle['hunt_id'],
308 'SK': 'round-' + round
312 # Get old puzzle from the database (to determine what's changed)
313 old_puzzle = find_puzzle_for_sort_key(turb,
317 # If we are changing puzzle type (meta -> plain or plain -> meta)
318 # the the sort key has to change, so compute the new one and delete
319 # the old item from the database.
321 # XXX: We should really be using a transaction here to combine the
322 # delete_item and the put_item into a single transaction, but
323 # the boto interface is annoying in that transactions are only on
324 # the "Client" object which has a totally different interface than
325 # the "Table" object I've been using so I haven't figured out how
328 if puzzle['type'] != old_puzzle.get('type', 'plain'):
329 puzzle['SK'] = puzzle_sort_key(puzzle)
330 turb.table.delete_item(Key={
331 'hunt_id': old_puzzle['hunt_id'],
332 'SK': old_puzzle['SK']
335 # Update the puzzle in the database
336 turb.table.put_item(Item=puzzle)
338 # Inform the puzzle channel about the edit
339 edit_message = "Puzzle edited by <@{}>".format(user_id)
340 blocks = ([section_block(text_block(edit_message+":\n"))] +
341 puzzle_blocks(puzzle, include_rounds=True))
343 turb.slack_client, puzzle['channel_id'],
344 edit_message, blocks=blocks)
346 # Also inform the hunt if the puzzle's solved status changed
347 if puzzle['status'] != old_puzzle['status']:
348 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
349 if puzzle['status'] == 'solved':
350 message = "Puzzle <{}|{}> has been solved!".format(
351 puzzle['channel_url'],
354 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
355 puzzle['channel_url'],
357 slack_send_message(turb.slack_client, hunt['channel_id'], message)
359 # We need to set the channel topic if any of puzzle name, url,
360 # state, status, or solution, has changed. Let's just do that
361 # unconditionally here.
362 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
366 def edit_hunt_command(turb, body):
367 """Implementation of the `/hunt edit` command
369 As dispatched from the hunt() function.
372 channel_id = body['channel_id'][0]
373 trigger_id = body['trigger_id'][0]
375 hunt = hunt_for_channel(turb, channel_id)
378 return bot_reply("Sorry, this does not appear to be a hunt channel.")
380 return edit_hunt(turb, hunt, trigger_id)
382 def edit_hunt_button(turb, payload):
383 """Handler for the action of user pressing an edit_hunt button"""
385 hunt_id = payload['actions'][0]['action_id']
386 trigger_id = payload['trigger_id']
388 hunt = find_hunt_for_hunt_id(turb, hunt_id)
391 return bot_reply("Error: Hunt not found.")
393 return edit_hunt(turb, hunt, trigger_id)
395 actions['button']['edit_hunt'] = edit_hunt_button
397 def edit_hunt(turb, hunt, trigger_id):
398 """Common code for implementing an edit hunt dialog
400 This implementation is common whether the edit operation was invoked
401 by a button (edit_hunt_button) or a command (edit_hunt_command).
406 "private_metadata": json.dumps({
407 "hunt_id": hunt["hunt_id"],
409 "is_hunt": hunt["is_hunt"],
410 "channel_id": hunt["channel_id"],
411 "sheet_url": hunt["sheet_url"],
412 "folder_id": hunt["folder_id"],
414 "title": { "type": "plain_text", "text": "Edit Hunt" },
415 "submit": { "type": "plain_text", "text": "Save" },
417 input_block("Hunt name", "name", "Name of the hunt",
418 initial_value=hunt["name"]),
419 input_block("Hunt URL", "url", "External URL of hunt",
420 initial_value=hunt.get("url", None),
422 checkbox_block("Is this hunt active?", "Active", "active",
423 checked=(hunt.get('active', False)))
427 result = turb.slack_client.views_open(trigger_id=trigger_id,
431 submission_handlers[result['view']['id']] = edit_hunt_submission
435 def edit_hunt_submission(turb, payload, metadata):
436 """Handler for the user submitting the edit hunt modal
438 This is the modal view presented by the edit_hunt function above.
443 # First, read all the various data from the request
444 meta = json.loads(metadata)
445 hunt['hunt_id'] = meta['hunt_id']
446 hunt['SK'] = meta['SK']
447 hunt['is_hunt'] = meta['is_hunt']
448 hunt['channel_id'] = meta['channel_id']
449 hunt['sheet_url'] = meta['sheet_url']
450 hunt['folder_id'] = meta['folder_id']
452 state = payload['view']['state']['values']
453 user_id = payload['user']['id']
455 hunt['name'] = state['name']['name']['value']
456 url = state['url']['url']['value']
460 if state['active']['active']['selected_options']:
461 hunt['active'] = True
463 hunt['active'] = False
465 # Update the hunt in the database
466 turb.table.put_item(Item=hunt)
468 # Inform the hunt channel about the edit
469 edit_message = "Hunt edited by <@{}>".format(user_id)
471 section_block(text_block(edit_message)),
472 section_block(text_block("Hunt name: {}".format(hunt['name']))),
475 url = hunt.get('url', None)
478 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
482 turb.slack_client, hunt['channel_id'],
483 edit_message, blocks=blocks)
487 def new_hunt_command(turb, body):
488 """Implementation of the '/hunt new' command
490 As dispatched from the hunt() function.
493 trigger_id = body['trigger_id'][0]
495 return new_hunt(turb, trigger_id)
497 def new_hunt_button(turb, payload):
498 """Handler for the action of user pressing the new_hunt button"""
500 trigger_id = payload['trigger_id']
502 return new_hunt(turb, trigger_id)
504 def new_hunt(turb, trigger_id):
505 """Common code for implementing a new hunt dialog
507 This implementation is common whether the operations was invoked
508 by a button (new_hunt_button) or a command (new_hunt_command).
513 "private_metadata": json.dumps({}),
514 "title": { "type": "plain_text", "text": "New Hunt" },
515 "submit": { "type": "plain_text", "text": "Create" },
517 input_block("Hunt name", "name", "Name of the hunt"),
518 input_block("Hunt ID", "hunt_id",
519 "Used as puzzle channel prefix "
520 + "(no spaces nor punctuation)"),
521 input_block("Hunt URL", "url", "External URL of hunt",
526 result = turb.slack_client.views_open(trigger_id=trigger_id,
529 submission_handlers[result['view']['id']] = new_hunt_submission
533 actions['button']['new_hunt'] = new_hunt
535 def new_hunt_submission(turb, payload, metadata):
536 """Handler for the user submitting the new hunt modal
538 This is the modal view presented to the user by the new_hunt
541 state = payload['view']['state']['values']
542 user_id = payload['user']['id']
543 name = state['name']['name']['value']
544 hunt_id = state['hunt_id']['hunt_id']['value']
545 url = state['url']['url']['value']
547 # Validate that the hunt_id contains no invalid characters
548 if not re.match(valid_id_re, hunt_id):
549 return submission_error("hunt_id",
550 "Hunt ID can only contain lowercase letters, "
551 + "numbers, and underscores")
553 # Check to see if the turbot table exists
555 exists = turb.table.table_status in ("CREATING", "UPDATING",
560 # Create the turbot table if necessary.
562 turb.table = turb.db.create_table(
565 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
566 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
568 AttributeDefinitions=[
569 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
570 {'AttributeName': 'SK', 'AttributeType': 'S'},
571 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
572 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
573 {'AttributeName': 'url', 'AttributeType': 'S'}
575 ProvisionedThroughput={
576 'ReadCapacityUnits': 5,
577 'WriteCapacityUnits': 5
579 GlobalSecondaryIndexes=[
581 'IndexName': 'channel_id_index',
583 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
586 'ProjectionType': 'ALL'
588 'ProvisionedThroughput': {
589 'ReadCapacityUnits': 5,
590 'WriteCapacityUnits': 5
594 'IndexName': 'is_hunt_index',
596 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
599 'ProjectionType': 'ALL'
601 'ProvisionedThroughput': {
602 'ReadCapacityUnits': 5,
603 'WriteCapacityUnits': 5
607 LocalSecondaryIndexes = [
609 'IndexName': 'url_index',
611 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
612 {'AttributeName': 'url', 'KeyType': 'RANGE'},
615 'ProjectionType': 'ALL'
620 return submission_error(
622 "Still bootstrapping turbot table. Try again in a minute, please.")
624 # Create a channel for the hunt
626 response = turb.slack_client.conversations_create(name=hunt_id)
627 except SlackApiError as e:
628 return submission_error("hunt_id",
629 "Error creating Slack channel: {}"
630 .format(e.response['error']))
632 channel_id = response['channel']['id']
634 # Insert the newly-created hunt into the database
635 # (leaving it as non-active for now until the channel-created handler
636 # finishes fixing it up with a sheet and a companion table)
639 "SK": "hunt-{}".format(hunt_id),
641 "channel_id": channel_id,
647 turb.table.put_item(Item=item)
649 # Invite the initiating user to the channel
650 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
654 def view_submission(turb, payload):
655 """Handler for Slack interactive view submission
657 Specifically, those that have a payload type of 'view_submission'"""
659 view_id = payload['view']['id']
660 metadata = payload['view']['private_metadata']
662 if view_id in submission_handlers:
663 return submission_handlers[view_id](turb, payload, metadata)
665 print("Error: Unknown view ID: {}".format(view_id))
670 def rot(turb, body, args):
671 """Implementation of the /rot command
673 The args string should be as follows:
675 [count|*] String to be rotated
677 That is, the first word of the string is an optional number (or
678 the character '*'). If this is a number it indicates an amount to
679 rotate each character in the string. If the count is '*' or is not
680 present, then the string will be rotated through all possible 25
683 The result of the rotation is returned (with Slack formatting) in
684 the body of the response so that Slack will provide it as a reply
685 to the user who submitted the slash command."""
687 channel_name = body['channel_name'][0]
688 response_url = body['response_url'][0]
689 channel_id = body['channel_id'][0]
691 result = turbot.rot.rot(args)
693 if (channel_name == "directmessage"):
694 requests.post(response_url,
695 json = {"text": result},
696 headers = {"Content-type": "application/json"})
698 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
702 commands["/rot"] = rot
704 def get_table_item(turb, table_name, key, value):
705 """Get an item from the database 'table_name' with 'key' as 'value'
707 Returns a tuple of (item, table) if found and (None, None) otherwise."""
709 table = turb.db.Table(table_name)
711 response = table.get_item(Key={key: value})
713 if 'Item' in response:
714 return (response['Item'], table)
718 def db_entry_for_channel(turb, channel_id):
719 """Given a channel ID return the database item for this channel
721 If this channel is a registered hunt or puzzle channel, return the
722 corresponding row from the database for this channel. Otherwise,
725 Note: If you need to specifically ensure that the channel is a
726 puzzle or a hunt, please call puzzle_for_channel or
727 hunt_for_channel respectively.
730 response = turb.table.query(
731 IndexName = "channel_id_index",
732 KeyConditionExpression=Key("channel_id").eq(channel_id)
735 if response['Count'] == 0:
738 return response['Items'][0]
741 def puzzle_for_channel(turb, channel_id):
743 """Given a channel ID return the puzzle from the database for this channel
745 If the given channel_id is a puzzle's channel, this function
746 returns a dict filled with the attributes from the puzzle's entry
749 Otherwise, this function returns None.
752 entry = db_entry_for_channel(turb, channel_id)
754 if entry and entry['SK'].startswith('puzzle-'):
759 def hunt_for_channel(turb, channel_id):
761 """Given a channel ID return the hunt from the database for this channel
763 This works whether the original channel is a primary hunt channel,
764 or if it is one of the channels of a puzzle belonging to the hunt.
766 Returns None if channel does not belong to a hunt, otherwise a
767 dictionary with all fields from the hunt's row in the table,
768 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
771 entry = db_entry_for_channel(turb, channel_id)
773 # We're done if this channel doesn't exist in the database at all
777 # Also done if this channel is a hunt channel
778 if entry['SK'].startswith('hunt-'):
781 # Otherwise, (the channel is in the database, but is not a hunt),
782 # we expect this to be a puzzle channel instead
783 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
785 # python3.9 has a built-in removeprefix but AWS only has python3.8
786 def remove_prefix(text, prefix):
787 if text.startswith(prefix):
788 return text[len(prefix):]
791 def hunt_rounds(turb, hunt_id):
792 """Returns array of strings giving rounds that exist in the given hunt"""
794 response = turb.table.query(
795 KeyConditionExpression=(
796 Key('hunt_id').eq(hunt_id) &
797 Key('SK').begins_with('round-')
801 if response['Count'] == 0:
804 return [remove_prefix(option['SK'], 'round-')
805 for option in response['Items']]
807 def puzzle(turb, body, args):
808 """Implementation of the /puzzle command
810 The args string can be a sub-command:
812 /puzzle new: Bring up a dialog to create a new puzzle
814 /puzzle edit: Edit the puzzle for the current channel
816 Or with no argument at all:
818 /puzzle: Print details of the current puzzle (if in a puzzle channel)
822 return new_puzzle(turb, body)
825 return edit_puzzle_command(turb, body)
828 return bot_reply("Unknown syntax for `/puzzle` command. " +
829 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
830 "and `/puzzle new` to display, edit, or create " +
833 # For no arguments we print the current puzzle as a reply
834 channel_id = body['channel_id'][0]
835 response_url = body['response_url'][0]
837 puzzle = puzzle_for_channel(turb, channel_id)
840 hunt = hunt_for_channel(turb, channel_id)
843 "This is not a puzzle channel, but is a hunt channel. "
844 + "If you want to create a new puzzle for this hunt, use "
848 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
849 + "channel, so the `/puzzle` command cannot work here.")
851 blocks = puzzle_blocks(puzzle, include_rounds=True)
853 # For a meta puzzle, also display the titles and solutions for all
854 # puzzles in the same round.
855 if puzzle.get('type', 'plain') == 'meta':
856 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
858 # Drop this puzzle itself from the report
859 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
861 for round in puzzle.get('rounds', [None]):
862 answers = round_quoted_puzzles_titles_answers(round, puzzles)
864 section_block(text_block(
865 "*Feeder solutions from round {}*".format(
866 round if round else "<none>"
868 section_block(text_block(answers))
871 requests.post(response_url,
872 json = {'blocks': blocks},
873 headers = {'Content-type': 'application/json'}
878 commands["/puzzle"] = puzzle
880 def new(turb, body, args):
881 """Implementation of the `/new` command
883 This can be used to create a new hunt ("/new hunt") or a new
884 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
885 default behavior (as it is much more common).
887 This operations are identical to the existing "/hunt new" and
888 "/puzzle new". I don't know that that redundancy is actually
889 helpful in the interface. But at least having both allows us to
890 experiment and decide which is more natural and should be kept
895 return new_hunt_command(turb, body)
897 return new_puzzle(turb, body)
899 commands["/new"] = new
901 def new_puzzle(turb, body):
902 """Implementation of the "/puzzle new" command
904 This brings up a dialog box for creating a new puzzle.
907 channel_id = body['channel_id'][0]
908 trigger_id = body['trigger_id'][0]
910 hunt = hunt_for_channel(turb, channel_id)
913 return bot_reply("Sorry, this channel doesn't appear to "
914 + "be a hunt or puzzle channel")
916 round_options = hunt_rounds(turb, hunt['hunt_id'])
918 if len(round_options):
919 round_options_block = [
920 multi_select_block("Round(s)", "rounds",
921 "Existing round(s) this puzzle belongs to",
925 round_options_block = []
929 "private_metadata": json.dumps({
930 "hunt_id": hunt['hunt_id'],
932 "title": {"type": "plain_text", "text": "New Puzzle"},
933 "submit": { "type": "plain_text", "text": "Create" },
935 section_block(text_block("*For {}*".format(hunt['name']))),
936 input_block("Puzzle name", "name", "Name of the puzzle"),
937 input_block("Puzzle URL", "url", "External URL of puzzle",
939 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
940 * round_options_block,
941 input_block("New round(s)", "new_rounds",
942 "New round(s) this puzzle belongs to " +
948 result = turb.slack_client.views_open(trigger_id=trigger_id,
952 submission_handlers[result['view']['id']] = new_puzzle_submission
956 def new_puzzle_submission(turb, payload, metadata):
957 """Handler for the user submitting the new puzzle modal
959 This is the modal view presented to the user by the new_puzzle
963 # First, read all the various data from the request
964 meta = json.loads(metadata)
965 hunt_id = meta['hunt_id']
967 state = payload['view']['state']['values']
968 name = state['name']['name']['value']
969 url = state['url']['url']['value']
970 if state['meta']['meta']['selected_options']:
973 puzzle_type = 'plain'
974 if 'rounds' in state:
975 rounds = [option['value'] for option in
976 state['rounds']['rounds']['selected_options']]
979 new_rounds = state['new_rounds']['new_rounds']['value']
981 # Before doing anything, reject this puzzle if a puzzle already
982 # exists with the same URL.
984 existing = find_puzzle_for_url(turb, hunt_id, url)
986 return submission_error(
988 "Error: A puzzle with this URL already exists.")
990 # Create a Slack-channel-safe puzzle_id
991 puzzle_id = puzzle_id_from_name(name)
993 # Create a channel for the puzzle
994 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
997 response = turb.slack_client.conversations_create(
998 name=hunt_dash_channel)
999 except SlackApiError as e:
1000 return submission_error(
1002 "Error creating Slack channel {}: {}"
1003 .format(hunt_dash_channel, e.response['error']))
1005 channel_id = response['channel']['id']
1007 # Add any new rounds to the database
1009 for round in new_rounds.split(','):
1010 # Drop any leading/trailing spaces from the round name
1011 round = round.strip()
1012 # Ignore any empty string
1015 rounds.append(round)
1016 turb.table.put_item(
1019 'SK': 'round-' + round
1023 # Construct a puzzle dict
1026 "puzzle_id": puzzle_id,
1027 "channel_id": channel_id,
1029 "status": 'unsolved',
1036 puzzle['rounds'] = rounds
1038 # Finally, compute the appropriate sort key
1039 puzzle["SK"] = puzzle_sort_key(puzzle)
1041 # Insert the newly-created puzzle into the database
1042 turb.table.put_item(Item=puzzle)
1046 def state(turb, body, args):
1047 """Implementation of the /state command
1049 The args string should be a brief sentence describing where things
1050 stand or what's needed."""
1052 channel_id = body['channel_id'][0]
1054 old_puzzle = puzzle_for_channel(turb, channel_id)
1058 "Sorry, the /state command only works in a puzzle channel")
1060 # Make a deep copy of the puzzle object
1061 puzzle = puzzle_copy(old_puzzle)
1063 # Update the puzzle in the database
1064 puzzle['state'] = args
1065 turb.table.put_item(Item=puzzle)
1067 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1071 commands["/state"] = state
1073 def tag(turb, body, args):
1074 """Implementation of the `/tag` command.
1076 Arg is either a tag to add (optionally prefixed with '+'), or if
1077 prefixed with '-' is a tag to remove.
1081 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1082 + "or `/tag -TAG_TO_REMOVE`.")
1084 channel_id = body['channel_id'][0]
1086 old_puzzle = puzzle_for_channel(turb, channel_id)
1090 "Sorry, the /tag command only works in a puzzle channel")
1101 # Force tag to all uppercase
1104 # Reject a tag that is not alphabetic or underscore A-Z_
1105 if not re.match(r'^[A-Z0-9_]*$', tag):
1106 return bot_reply("Sorry, tags can only contain letters, numbers, "
1107 + "and the underscore character.")
1109 if action == 'remove':
1110 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1111 return bot_reply("Nothing to do. This puzzle is not tagged "
1112 + "with the tag: {}".format(tag))
1114 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1115 return bot_reply("Nothing to do. This puzzle is already tagged "
1116 + "with the tag: {}".format(tag))
1118 # OK. Error checking is done. Let's get to work
1120 # Make a deep copy of the puzzle object
1121 puzzle = puzzle_copy(old_puzzle)
1123 if action == 'remove':
1124 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1126 if 'tags' not in puzzle:
1127 puzzle['tags'] = [tag]
1129 puzzle['tags'].append(tag)
1131 turb.table.put_item(Item=puzzle)
1133 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1137 commands["/tag"] = tag
1139 def solved(turb, body, args):
1140 """Implementation of the /solved command
1142 The args string should be a confirmed solution."""
1144 channel_id = body['channel_id'][0]
1145 user_id = body['user_id'][0]
1147 old_puzzle = puzzle_for_channel(turb, channel_id)
1150 return bot_reply("Sorry, this is not a puzzle channel.")
1154 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1156 # Make a deep copy of the puzzle object
1157 puzzle = puzzle_copy(old_puzzle)
1159 # Set the status and solution fields in the database
1160 puzzle['status'] = 'solved'
1161 puzzle['solution'].append(args)
1162 if 'state' in puzzle:
1164 turb.table.put_item(Item=puzzle)
1166 # Report the solution to the puzzle's channel
1168 turb.slack_client, channel_id,
1169 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1171 # Also report the solution to the hunt channel
1172 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1174 turb.slack_client, hunt['channel_id'],
1175 "Puzzle <{}|{}> has been solved!".format(
1176 puzzle['channel_url'],
1180 # And update the puzzle's description
1181 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1185 commands["/solved"] = solved
1187 def hunt(turb, body, args):
1188 """Implementation of the /hunt command
1190 The (optional) args string can be used to filter which puzzles to
1191 display. The first word can be one of 'all', 'unsolved', or
1192 'solved' and can be used to display only puzzles with the given
1193 status. If this first word is missing, this command will display
1194 only unsolved puzzles by default.
1196 Any remaining text in the args string will be interpreted as
1197 search terms. These will be split into separate terms on space
1198 characters, (though quotation marks can be used to include a space
1199 character in a term). All terms must match on a puzzle in order
1200 for that puzzle to be included. But a puzzle will be considered to
1201 match if any of the puzzle title, round title, puzzle URL, puzzle
1202 state, puzzle type, tags, or puzzle solution match. Matching will
1203 be performed without regard to case sensitivity and the search
1204 terms can include regular expression syntax.
1208 channel_id = body['channel_id'][0]
1209 response_url = body['response_url'][0]
1211 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1213 return new_hunt_command(turb, body)
1216 return edit_hunt_command(turb, body)
1220 # The first word can be a puzzle status and all remaining word
1221 # (if any) are search terms. _But_, if the first word is not a
1222 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1223 # words are search terms and we default status to 'unsolved'.
1224 split_args = args.split(' ', 1)
1225 status = split_args[0]
1226 if (len(split_args) > 1):
1227 terms = split_args[1]
1228 if status not in ('unsolved', 'solved', 'all'):
1234 # Separate search terms on spaces (but allow for quotation marks
1235 # to capture spaces in a search term)
1237 terms = shlex.split(terms)
1239 hunt = hunt_for_channel(turb, channel_id)
1242 return bot_reply("Sorry, this channel doesn't appear to "
1243 + "be a hunt or puzzle channel")
1245 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1247 requests.post(response_url,
1248 json = { 'blocks': blocks },
1249 headers = {'Content-type': 'application/json'}
1254 commands["/hunt"] = hunt
1256 def round(turb, body, args):
1257 """Implementation of the /round command
1259 Displays puzzles in the same round(s) as the puzzle for the
1262 The (optional) args string can be used to filter which puzzles to
1263 display. The first word can be one of 'all', 'unsolved', or
1264 'solved' and can be used to display only puzzles with the given
1265 status. If this first word is missing, this command will display
1266 all puzzles in the round by default.
1268 Any remaining text in the args string will be interpreted as
1269 search terms. These will be split into separate terms on space
1270 characters, (though quotation marks can be used to include a space
1271 character in a term). All terms must match on a puzzle in order
1272 for that puzzle to be included. But a puzzle will be considered to
1273 match if any of the puzzle title, round title, puzzle URL, puzzle
1274 state, or puzzle solution match. Matching will be performed
1275 without regard to case sensitivity and the search terms can
1276 include regular expression syntax.
1279 channel_id = body['channel_id'][0]
1280 response_url = body['response_url'][0]
1282 puzzle = puzzle_for_channel(turb, channel_id)
1283 hunt = hunt_for_channel(turb, channel_id)
1288 "This is not a puzzle channel, but is a hunt channel. "
1289 + "Use /hunt if you want to see all rounds for this hunt.")
1292 "Sorry, this channel doesn't appear to be a puzzle channel "
1293 + "so the `/round` command cannot work here.")
1297 # The first word can be a puzzle status and all remaining word
1298 # (if any) are search terms. _But_, if the first word is not a
1299 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1300 # words are search terms and we default status to 'unsolved'.
1301 split_args = args.split(' ', 1)
1302 status = split_args[0]
1303 if (len(split_args) > 1):
1304 terms = split_args[1]
1305 if status not in ('unsolved', 'solved', 'all'):
1311 # Separate search terms on spaces (but allow for quotation marks
1312 # to capture spaces in a search term)
1314 terms = shlex.split(terms)
1316 blocks = hunt_blocks(turb, hunt,
1317 puzzle_status=status, search_terms=terms,
1318 limit_to_rounds=puzzle.get('rounds', [])
1321 requests.post(response_url,
1322 json = { 'blocks': blocks },
1323 headers = {'Content-type': 'application/json'}
1328 commands["/round"] = round