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 response_url = payload['response_url']
150 trigger_id = payload['trigger_id']
152 (hunt_id, sort_key) = action_id.split('-', 1)
154 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
157 requests.post(response_url,
158 json = {"text": "Error: Puzzle not found!"},
159 headers = {"Content-type": "application/json"})
160 return bot_reply("Error: Puzzle not found.")
162 return edit_puzzle(turb, puzzle, trigger_id)
164 actions['button']['edit_puzzle'] = edit_puzzle_button
166 def edit_puzzle(turb, puzzle, trigger_id):
167 """Common code for implementing an edit puzzle dialog
169 This implementation is common whether the edit operation was invoked
170 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
173 round_options = hunt_rounds(turb, puzzle['hunt_id'])
175 if len(round_options):
176 round_options_block = [
177 multi_select_block("Round(s)", "rounds",
178 "Existing round(s) this puzzle belongs to",
180 initial_options=puzzle.get("rounds", None)),
183 round_options_block = []
186 if puzzle.get("status", "unsolved") == solved:
190 solution_list = puzzle.get("solution", [])
192 solution_str = ", ".join(solution_list)
196 "private_metadata": json.dumps({
197 "hunt_id": puzzle['hunt_id'],
199 "puzzle_id": puzzle['puzzle_id'],
200 "channel_id": puzzle["channel_id"],
201 "channel_url": puzzle["channel_url"],
202 "sheet_url": puzzle["sheet_url"],
204 "title": {"type": "plain_text", "text": "Edit Puzzle"},
205 "submit": { "type": "plain_text", "text": "Save" },
207 input_block("Puzzle name", "name", "Name of the puzzle",
208 initial_value=puzzle["name"]),
209 input_block("Puzzle URL", "url", "External URL of puzzle",
210 initial_value=puzzle.get("url", None),
212 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
213 checked=(puzzle.get('type', 'plain') == 'meta')),
214 * round_options_block,
215 input_block("New round(s)", "new_rounds",
216 "New round(s) this puzzle belongs to " +
219 input_block("State", "state",
220 "State of this puzzle (partial progress, next steps)",
221 initial_value=puzzle.get("state", None),
224 "Puzzle status", "Solved", "solved",
225 checked=(puzzle.get('status', 'unsolved') == 'solved')),
226 input_block("Solution", "solution",
227 "Solution(s) (comma-separated if multiple)",
228 initial_value=solution_str,
233 result = turb.slack_client.views_open(trigger_id=trigger_id,
237 submission_handlers[result['view']['id']] = edit_puzzle_submission
241 def edit_puzzle_submission(turb, payload, metadata):
242 """Handler for the user submitting the edit puzzle modal
244 This is the modal view presented to the user by the edit_puzzle
250 # First, read all the various data from the request
251 meta = json.loads(metadata)
252 puzzle['hunt_id'] = meta['hunt_id']
253 puzzle['SK'] = meta['SK']
254 puzzle['puzzle_id'] = meta['puzzle_id']
255 puzzle['channel_id'] = meta['channel_id']
256 puzzle['channel_url'] = meta['channel_url']
257 puzzle['sheet_url'] = meta['sheet_url']
259 state = payload['view']['state']['values']
260 user_id = payload['user']['id']
262 puzzle['name'] = state['name']['name']['value']
263 url = state['url']['url']['value']
266 if state['meta']['meta']['selected_options']:
267 puzzle['type'] = 'meta'
269 puzzle['type'] = 'plain'
270 rounds = [option['value'] for option in
271 state['rounds']['rounds']['selected_options']]
273 puzzle['rounds'] = rounds
274 new_rounds = state['new_rounds']['new_rounds']['value']
275 puzzle_state = state['state']['state']['value']
277 puzzle['state'] = puzzle_state
278 if state['solved']['solved']['selected_options']:
279 puzzle['status'] = 'solved'
281 puzzle['status'] = 'unsolved'
282 puzzle['solution'] = []
283 solution = state['solution']['solution']['value']
285 puzzle['solution'] = [
286 sol.strip() for sol in solution.split(',')
289 # Verify that there's a solution if the puzzle is mark solved
290 if puzzle['status'] == 'solved' and not puzzle['solution']:
291 return submission_error("solution",
292 "A solved puzzle requires a solution.")
294 if puzzle['status'] == 'unsolved' and puzzle['solution']:
295 return submission_error("solution",
296 "An unsolved puzzle should have no solution.")
298 # Add any new rounds to the database
300 if 'rounds' not in puzzle:
301 puzzle['rounds'] = []
302 for round in new_rounds.split(','):
303 # Drop any leading/trailing spaces from the round name
304 round = round.strip()
305 # Ignore any empty string
308 puzzle['rounds'].append(round)
311 'hunt_id': puzzle['hunt_id'],
312 'SK': 'round-' + round
316 # Get old puzzle from the database (to determine what's changed)
317 old_puzzle = find_puzzle_for_sort_key(turb,
321 # If we are changing puzzle type (meta -> plain or plain -> meta)
322 # the the sort key has to change, so compute the new one and delete
323 # the old item from the database.
325 # XXX: We should really be using a transaction here to combine the
326 # delete_item and the put_item into a single transaction, but
327 # the boto interface is annoying in that transactions are only on
328 # the "Client" object which has a totally different interface than
329 # the "Table" object I've been using so I haven't figured out how
332 if puzzle['type'] != old_puzzle.get('type', 'plain'):
333 puzzle['SK'] = puzzle_sort_key(puzzle)
334 turb.table.delete_item(Key={
335 'hunt_id': old_puzzle['hunt_id'],
336 'SK': old_puzzle['SK']
339 # Update the puzzle in the database
340 turb.table.put_item(Item=puzzle)
342 # Inform the puzzle channel about the edit
343 edit_message = "Puzzle edited by <@{}>".format(user_id)
344 blocks = ([section_block(text_block(edit_message+":\n"))] +
345 puzzle_blocks(puzzle, include_rounds=True))
347 turb.slack_client, puzzle['channel_id'],
348 edit_message, blocks=blocks)
350 # Also inform the hunt if the puzzle's solved status changed
351 if puzzle['status'] != old_puzzle['status']:
352 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
353 if puzzle['status'] == 'solved':
354 message = "Puzzle <{}|{}> has been solved!".format(
355 puzzle['channel_url'],
358 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
359 puzzle['channel_url'],
361 slack_send_message(turb.slack_client, hunt['channel_id'], message)
363 # We need to set the channel topic if any of puzzle name, url,
364 # state, status, or solution, has changed. Let's just do that
365 # unconditionally here.
366 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
370 def edit_hunt_command(turb, body):
371 """Implementation of the `/hunt edit` command
373 As dispatched from the hunt() function.
376 channel_id = body['channel_id'][0]
377 trigger_id = body['trigger_id'][0]
379 hunt = hunt_for_channel(turb, channel_id)
382 return bot_reply("Sorry, this does not appear to be a hunt channel.")
384 return edit_hunt(turb, hunt, trigger_id)
386 def edit_hunt_button(turb, payload):
387 """Handler for the action of user pressing an edit_hunt button"""
389 hunt_id = payload['actions'][0]['action_id']
390 response_url = payload['response_url']
391 trigger_id = payload['trigger_id']
393 hunt = find_hunt_for_hunt_id(hunt_id)
396 requests.post(response_url,
397 json = {"text": "Error: Hunt not found!"},
398 headers = {"Content-type": "application/json"})
399 return bot_reply("Error: Hunt not found.")
401 return edit_hunt(turb, hunt, trigger_id)
403 actions['button']['edit_hunt'] = edit_hunt_button
405 def edit_hunt(turb, hunt, trigger_id):
406 """Common code for implementing an edit hunt dialog
408 This implementation is common whether the edit operation was invoked
409 by a button (edit_hunt_button) or a command (edit_hunt_command).
414 "private_metadata": json.dumps({
415 "hunt_id": hunt["hunt_id"],
417 "is_hunt": hunt["is_hunt"],
418 "channel_id": hunt["channel_id"],
419 "sheet_url": hunt["sheet_url"],
420 "folder_id": hunt["folder_id"],
422 "title": { "type": "plain_text", "text": "Edit Hunt" },
423 "submit": { "type": "plain_text", "text": "Save" },
425 input_block("Hunt name", "name", "Name of the hunt",
426 initial_value=hunt["name"]),
427 input_block("Hunt URL", "url", "External URL of hunt",
428 initial_value=hunt.get("url", None),
430 checkbox_block("Is this hunt active?", "Active", "active",
431 checked=(hunt.get('active', False)))
435 result = turb.slack_client.views_open(trigger_id=trigger_id,
439 submission_handlers[result['view']['id']] = edit_hunt_submission
443 def edit_hunt_submission(turb, payload, metadata):
444 """Handler for the user submitting the edit hunt modal
446 This is the modal view presented by the edit_hunt function above.
451 # First, read all the various data from the request
452 meta = json.loads(metadata)
453 hunt['hunt_id'] = meta['hunt_id']
454 hunt['SK'] = meta['SK']
455 hunt['is_hunt'] = meta['is_hunt']
456 hunt['channel_id'] = meta['channel_id']
457 hunt['sheet_url'] = meta['sheet_url']
458 hunt['folder_id'] = meta['folder_id']
460 state = payload['view']['state']['values']
461 user_id = payload['user']['id']
463 hunt['name'] = state['name']['name']['value']
464 url = state['url']['url']['value']
468 if state['active']['active']['selected_options']:
469 hunt['active'] = True
471 hunt['active'] = False
473 # Update the hunt in the database
474 turb.table.put_item(Item=hunt)
476 # Inform the hunt channel about the edit
477 edit_message = "Hunt edited by <@{}>".format(user_id)
479 section_block(text_block(edit_message)),
480 section_block(text_block("Hunt name: {}".format(hunt['name']))),
483 url = hunt.get('url', None)
486 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
490 turb.slack_client, hunt['channel_id'],
491 edit_message, blocks=blocks)
495 def new_hunt_command(turb, body):
496 """Implementation of the '/hunt new' command
498 As dispatched from the hunt() function.
501 trigger_id = body['trigger_id'][0]
503 return new_hunt(turb, trigger_id)
505 def new_hunt_button(turb, payload):
506 """Handler for the action of user pressing the new_hunt button"""
508 trigger_id = payload['trigger_id']
510 return new_hunt(turb, trigger_id)
512 def new_hunt(turb, trigger_id):
513 """Common code for implementing a new hunt dialog
515 This implementation is common whether the operations was invoked
516 by a button (new_hunt_button) or a command (new_hunt_command).
521 "private_metadata": json.dumps({}),
522 "title": { "type": "plain_text", "text": "New Hunt" },
523 "submit": { "type": "plain_text", "text": "Create" },
525 input_block("Hunt name", "name", "Name of the hunt"),
526 input_block("Hunt ID", "hunt_id",
527 "Used as puzzle channel prefix "
528 + "(no spaces nor punctuation)"),
529 input_block("Hunt URL", "url", "External URL of hunt",
534 result = turb.slack_client.views_open(trigger_id=trigger_id,
537 submission_handlers[result['view']['id']] = new_hunt_submission
541 actions['button']['new_hunt'] = new_hunt
543 def new_hunt_submission(turb, payload, metadata):
544 """Handler for the user submitting the new hunt modal
546 This is the modal view presented to the user by the new_hunt
549 state = payload['view']['state']['values']
550 user_id = payload['user']['id']
551 name = state['name']['name']['value']
552 hunt_id = state['hunt_id']['hunt_id']['value']
553 url = state['url']['url']['value']
555 # Validate that the hunt_id contains no invalid characters
556 if not re.match(valid_id_re, hunt_id):
557 return submission_error("hunt_id",
558 "Hunt ID can only contain lowercase letters, "
559 + "numbers, and underscores")
561 # Check to see if the turbot table exists
563 exists = turb.table.table_status in ("CREATING", "UPDATING",
568 # Create the turbot table if necessary.
570 turb.table = turb.db.create_table(
573 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
574 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
576 AttributeDefinitions=[
577 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
578 {'AttributeName': 'SK', 'AttributeType': 'S'},
579 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
580 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
581 {'AttributeName': 'url', 'AttributeType': 'S'}
583 ProvisionedThroughput={
584 'ReadCapacityUnits': 5,
585 'WriteCapacityUnits': 5
587 GlobalSecondaryIndexes=[
589 'IndexName': 'channel_id_index',
591 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
594 'ProjectionType': 'ALL'
596 'ProvisionedThroughput': {
597 'ReadCapacityUnits': 5,
598 'WriteCapacityUnits': 5
602 'IndexName': 'is_hunt_index',
604 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
607 'ProjectionType': 'ALL'
609 'ProvisionedThroughput': {
610 'ReadCapacityUnits': 5,
611 'WriteCapacityUnits': 5
615 LocalSecondaryIndexes = [
617 'IndexName': 'url_index',
619 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
620 {'AttributeName': 'url', 'KeyType': 'RANGE'},
623 'ProjectionType': 'ALL'
628 return submission_error(
630 "Still bootstrapping turbot table. Try again in a minute, please.")
632 # Create a channel for the hunt
634 response = turb.slack_client.conversations_create(name=hunt_id)
635 except SlackApiError as e:
636 return submission_error("hunt_id",
637 "Error creating Slack channel: {}"
638 .format(e.response['error']))
640 channel_id = response['channel']['id']
642 # Insert the newly-created hunt into the database
643 # (leaving it as non-active for now until the channel-created handler
644 # finishes fixing it up with a sheet and a companion table)
647 "SK": "hunt-{}".format(hunt_id),
649 "channel_id": channel_id,
655 turb.table.put_item(Item=item)
657 # Invite the initiating user to the channel
658 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
662 def view_submission(turb, payload):
663 """Handler for Slack interactive view submission
665 Specifically, those that have a payload type of 'view_submission'"""
667 view_id = payload['view']['id']
668 metadata = payload['view']['private_metadata']
670 if view_id in submission_handlers:
671 return submission_handlers[view_id](turb, payload, metadata)
673 print("Error: Unknown view ID: {}".format(view_id))
678 def rot(turb, body, args):
679 """Implementation of the /rot command
681 The args string should be as follows:
683 [count|*] String to be rotated
685 That is, the first word of the string is an optional number (or
686 the character '*'). If this is a number it indicates an amount to
687 rotate each character in the string. If the count is '*' or is not
688 present, then the string will be rotated through all possible 25
691 The result of the rotation is returned (with Slack formatting) in
692 the body of the response so that Slack will provide it as a reply
693 to the user who submitted the slash command."""
695 channel_name = body['channel_name'][0]
696 response_url = body['response_url'][0]
697 channel_id = body['channel_id'][0]
699 result = turbot.rot.rot(args)
701 if (channel_name == "directmessage"):
702 requests.post(response_url,
703 json = {"text": result},
704 headers = {"Content-type": "application/json"})
706 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
710 commands["/rot"] = rot
712 def get_table_item(turb, table_name, key, value):
713 """Get an item from the database 'table_name' with 'key' as 'value'
715 Returns a tuple of (item, table) if found and (None, None) otherwise."""
717 table = turb.db.Table(table_name)
719 response = table.get_item(Key={key: value})
721 if 'Item' in response:
722 return (response['Item'], table)
726 def db_entry_for_channel(turb, channel_id):
727 """Given a channel ID return the database item for this channel
729 If this channel is a registered hunt or puzzle channel, return the
730 corresponding row from the database for this channel. Otherwise,
733 Note: If you need to specifically ensure that the channel is a
734 puzzle or a hunt, please call puzzle_for_channel or
735 hunt_for_channel respectively.
738 response = turb.table.query(
739 IndexName = "channel_id_index",
740 KeyConditionExpression=Key("channel_id").eq(channel_id)
743 if response['Count'] == 0:
746 return response['Items'][0]
749 def puzzle_for_channel(turb, channel_id):
751 """Given a channel ID return the puzzle from the database for this channel
753 If the given channel_id is a puzzle's channel, this function
754 returns a dict filled with the attributes from the puzzle's entry
757 Otherwise, this function returns None.
760 entry = db_entry_for_channel(turb, channel_id)
762 if entry and entry['SK'].startswith('puzzle-'):
767 def hunt_for_channel(turb, channel_id):
769 """Given a channel ID return the hunt from the database for this channel
771 This works whether the original channel is a primary hunt channel,
772 or if it is one of the channels of a puzzle belonging to the hunt.
774 Returns None if channel does not belong to a hunt, otherwise a
775 dictionary with all fields from the hunt's row in the table,
776 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
779 entry = db_entry_for_channel(turb, channel_id)
781 # We're done if this channel doesn't exist in the database at all
785 # Also done if this channel is a hunt channel
786 if entry['SK'].startswith('hunt-'):
789 # Otherwise, (the channel is in the database, but is not a hunt),
790 # we expect this to be a puzzle channel instead
791 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
793 # python3.9 has a built-in removeprefix but AWS only has python3.8
794 def remove_prefix(text, prefix):
795 if text.startswith(prefix):
796 return text[len(prefix):]
799 def hunt_rounds(turb, hunt_id):
800 """Returns array of strings giving rounds that exist in the given hunt"""
802 response = turb.table.query(
803 KeyConditionExpression=(
804 Key('hunt_id').eq(hunt_id) &
805 Key('SK').begins_with('round-')
809 if response['Count'] == 0:
812 return [remove_prefix(option['SK'], 'round-')
813 for option in response['Items']]
815 def puzzle(turb, body, args):
816 """Implementation of the /puzzle command
818 The args string can be a sub-command:
820 /puzzle new: Bring up a dialog to create a new puzzle
822 /puzzle edit: Edit the puzzle for the current channel
824 Or with no argument at all:
826 /puzzle: Print details of the current puzzle (if in a puzzle channel)
830 return new_puzzle(turb, body)
833 return edit_puzzle_command(turb, body)
836 return bot_reply("Unknown syntax for `/puzzle` command. " +
837 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
838 "and `/puzzle new` to display, edit, or create " +
841 # For no arguments we print the current puzzle as a reply
842 channel_id = body['channel_id'][0]
843 response_url = body['response_url'][0]
845 puzzle = puzzle_for_channel(turb, channel_id)
848 hunt = hunt_for_channel(turb, channel_id)
851 "This is not a puzzle channel, but is a hunt channel. "
852 + "If you want to create a new puzzle for this hunt, use "
856 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
857 + "channel, so the `/puzzle` command cannot work here.")
859 blocks = puzzle_blocks(puzzle, include_rounds=True)
861 # For a meta puzzle, also display the titles and solutions for all
862 # puzzles in the same round.
863 if puzzle.get('type', 'plain') == 'meta':
864 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
866 # Drop this puzzle itself from the report
867 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
869 for round in puzzle.get('rounds', [None]):
870 answers = round_quoted_puzzles_titles_answers(round, puzzles)
872 section_block(text_block(
873 "*Feeder solutions from round {}*".format(
874 round if round else "<none>"
876 section_block(text_block(answers))
879 requests.post(response_url,
880 json = {'blocks': blocks},
881 headers = {'Content-type': 'application/json'}
886 commands["/puzzle"] = puzzle
888 def new(turb, body, args):
889 """Implementation of the `/new` command
891 This can be used to create a new hunt ("/new hunt") or a new
892 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
893 default behavior (as it is much more common).
895 This operations are identical to the existing "/hunt new" and
896 "/puzzle new". I don't know that that redundancy is actually
897 helpful in the interface. But at least having both allows us to
898 experiment and decide which is more natural and should be kept
903 return new_hunt_command(turb, body)
905 return new_puzzle(turb, body)
907 commands["/new"] = new
909 def new_puzzle(turb, body):
910 """Implementation of the "/puzzle new" command
912 This brings up a dialog box for creating a new puzzle.
915 channel_id = body['channel_id'][0]
916 trigger_id = body['trigger_id'][0]
918 hunt = hunt_for_channel(turb, channel_id)
921 return bot_reply("Sorry, this channel doesn't appear to "
922 + "be a hunt or puzzle channel")
924 round_options = hunt_rounds(turb, hunt['hunt_id'])
926 if len(round_options):
927 round_options_block = [
928 multi_select_block("Round(s)", "rounds",
929 "Existing round(s) this puzzle belongs to",
933 round_options_block = []
937 "private_metadata": json.dumps({
938 "hunt_id": hunt['hunt_id'],
940 "title": {"type": "plain_text", "text": "New Puzzle"},
941 "submit": { "type": "plain_text", "text": "Create" },
943 section_block(text_block("*For {}*".format(hunt['name']))),
944 input_block("Puzzle name", "name", "Name of the puzzle"),
945 input_block("Puzzle URL", "url", "External URL of puzzle",
947 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
948 * round_options_block,
949 input_block("New round(s)", "new_rounds",
950 "New round(s) this puzzle belongs to " +
956 result = turb.slack_client.views_open(trigger_id=trigger_id,
960 submission_handlers[result['view']['id']] = new_puzzle_submission
964 def new_puzzle_submission(turb, payload, metadata):
965 """Handler for the user submitting the new puzzle modal
967 This is the modal view presented to the user by the new_puzzle
971 # First, read all the various data from the request
972 meta = json.loads(metadata)
973 hunt_id = meta['hunt_id']
975 state = payload['view']['state']['values']
976 name = state['name']['name']['value']
977 url = state['url']['url']['value']
978 if state['meta']['meta']['selected_options']:
981 puzzle_type = 'plain'
982 if 'rounds' in state:
983 rounds = [option['value'] for option in
984 state['rounds']['rounds']['selected_options']]
987 new_rounds = state['new_rounds']['new_rounds']['value']
989 # Before doing anything, reject this puzzle if a puzzle already
990 # exists with the same URL.
992 existing = find_puzzle_for_url(turb, hunt_id, url)
994 return submission_error(
996 "Error: A puzzle with this URL already exists.")
998 # Create a Slack-channel-safe puzzle_id
999 puzzle_id = puzzle_id_from_name(name)
1001 # Create a channel for the puzzle
1002 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
1005 response = turb.slack_client.conversations_create(
1006 name=hunt_dash_channel)
1007 except SlackApiError as e:
1008 return submission_error(
1010 "Error creating Slack channel {}: {}"
1011 .format(hunt_dash_channel, e.response['error']))
1013 channel_id = response['channel']['id']
1015 # Add any new rounds to the database
1017 for round in new_rounds.split(','):
1018 # Drop any leading/trailing spaces from the round name
1019 round = round.strip()
1020 # Ignore any empty string
1023 rounds.append(round)
1024 turb.table.put_item(
1027 'SK': 'round-' + round
1031 # Construct a puzzle dict
1034 "puzzle_id": puzzle_id,
1035 "channel_id": channel_id,
1037 "status": 'unsolved',
1044 puzzle['rounds'] = rounds
1046 # Finally, compute the appropriate sort key
1047 puzzle["SK"] = puzzle_sort_key(puzzle)
1049 # Insert the newly-created puzzle into the database
1050 turb.table.put_item(Item=puzzle)
1054 def state(turb, body, args):
1055 """Implementation of the /state command
1057 The args string should be a brief sentence describing where things
1058 stand or what's needed."""
1060 channel_id = body['channel_id'][0]
1062 old_puzzle = puzzle_for_channel(turb, channel_id)
1066 "Sorry, the /state command only works in a puzzle channel")
1068 # Make a deep copy of the puzzle object
1069 puzzle = puzzle_copy(old_puzzle)
1071 # Update the puzzle in the database
1072 puzzle['state'] = args
1073 turb.table.put_item(Item=puzzle)
1075 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1079 commands["/state"] = state
1081 def tag(turb, body, args):
1082 """Implementation of the `/tag` command.
1084 Arg is either a tag to add (optionally prefixed with '+'), or if
1085 prefixed with '-' is a tag to remove.
1089 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1090 + "or `/tag -TAG_TO_REMOVE`.")
1092 channel_id = body['channel_id'][0]
1094 old_puzzle = puzzle_for_channel(turb, channel_id)
1098 "Sorry, the /tag command only works in a puzzle channel")
1109 # Force tag to all uppercase
1112 # Reject a tag that is not alphabetic or underscore A-Z_
1113 if not re.match(r'^[A-Z0-9_]*$', tag):
1114 return bot_reply("Sorry, tags can only contain letters, numbers, "
1115 + "and the underscore character.")
1117 if action == 'remove':
1118 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1119 return bot_reply("Nothing to do. This puzzle is not tagged "
1120 + "with the tag: {}".format(tag))
1122 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1123 return bot_reply("Nothing to do. This puzzle is already tagged "
1124 + "with the tag: {}".format(tag))
1126 # OK. Error checking is done. Let's get to work
1128 # Make a deep copy of the puzzle object
1129 puzzle = puzzle_copy(old_puzzle)
1131 if action == 'remove':
1132 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1134 if 'tags' not in puzzle:
1135 puzzle['tags'] = [tag]
1137 puzzle['tags'].append(tag)
1139 turb.table.put_item(Item=puzzle)
1141 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1145 commands["/tag"] = tag
1147 def solved(turb, body, args):
1148 """Implementation of the /solved command
1150 The args string should be a confirmed solution."""
1152 channel_id = body['channel_id'][0]
1153 user_id = body['user_id'][0]
1155 old_puzzle = puzzle_for_channel(turb, channel_id)
1158 return bot_reply("Sorry, this is not a puzzle channel.")
1162 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1164 # Make a deep copy of the puzzle object
1165 puzzle = puzzle_copy(old_puzzle)
1167 # Set the status and solution fields in the database
1168 puzzle['status'] = 'solved'
1169 puzzle['solution'].append(args)
1170 if 'state' in puzzle:
1172 turb.table.put_item(Item=puzzle)
1174 # Report the solution to the puzzle's channel
1176 turb.slack_client, channel_id,
1177 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1179 # Also report the solution to the hunt channel
1180 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1182 turb.slack_client, hunt['channel_id'],
1183 "Puzzle <{}|{}> has been solved!".format(
1184 puzzle['channel_url'],
1188 # And update the puzzle's description
1189 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1193 commands["/solved"] = solved
1195 def hunt(turb, body, args):
1196 """Implementation of the /hunt command
1198 The (optional) args string can be used to filter which puzzles to
1199 display. The first word can be one of 'all', 'unsolved', or
1200 'solved' and can be used to display only puzzles with the given
1201 status. If this first word is missing, this command will display
1202 only unsolved puzzles by default.
1204 Any remaining text in the args string will be interpreted as
1205 search terms. These will be split into separate terms on space
1206 characters, (though quotation marks can be used to include a space
1207 character in a term). All terms must match on a puzzle in order
1208 for that puzzle to be included. But a puzzle will be considered to
1209 match if any of the puzzle title, round title, puzzle URL, puzzle
1210 state, puzzle type, tags, or puzzle solution match. Matching will
1211 be performed without regard to case sensitivity and the search
1212 terms can include regular expression syntax.
1216 channel_id = body['channel_id'][0]
1217 response_url = body['response_url'][0]
1219 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1221 return new_hunt_command(turb, body)
1224 return edit_hunt_command(turb, body)
1228 # The first word can be a puzzle status and all remaining word
1229 # (if any) are search terms. _But_, if the first word is not a
1230 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1231 # words are search terms and we default status to 'unsolved'.
1232 split_args = args.split(' ', 1)
1233 status = split_args[0]
1234 if (len(split_args) > 1):
1235 terms = split_args[1]
1236 if status not in ('unsolved', 'solved', 'all'):
1242 # Separate search terms on spaces (but allow for quotation marks
1243 # to capture spaces in a search term)
1245 terms = shlex.split(terms)
1247 hunt = hunt_for_channel(turb, channel_id)
1250 return bot_reply("Sorry, this channel doesn't appear to "
1251 + "be a hunt or puzzle channel")
1253 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1255 requests.post(response_url,
1256 json = { 'blocks': blocks },
1257 headers = {'Content-type': 'application/json'}
1262 commands["/hunt"] = hunt
1264 def round(turb, body, args):
1265 """Implementation of the /round command
1267 Displays puzzles in the same round(s) as the puzzle for the
1270 The (optional) args string can be used to filter which puzzles to
1271 display. The first word can be one of 'all', 'unsolved', or
1272 'solved' and can be used to display only puzzles with the given
1273 status. If this first word is missing, this command will display
1274 all puzzles in the round by default.
1276 Any remaining text in the args string will be interpreted as
1277 search terms. These will be split into separate terms on space
1278 characters, (though quotation marks can be used to include a space
1279 character in a term). All terms must match on a puzzle in order
1280 for that puzzle to be included. But a puzzle will be considered to
1281 match if any of the puzzle title, round title, puzzle URL, puzzle
1282 state, or puzzle solution match. Matching will be performed
1283 without regard to case sensitivity and the search terms can
1284 include regular expression syntax.
1287 channel_id = body['channel_id'][0]
1288 response_url = body['response_url'][0]
1290 puzzle = puzzle_for_channel(turb, channel_id)
1291 hunt = hunt_for_channel(turb, channel_id)
1296 "This is not a puzzle channel, but is a hunt channel. "
1297 + "Use /hunt if you want to see all rounds for this hunt.")
1300 "Sorry, this channel doesn't appear to be a puzzle channel "
1301 + "so the `/round` command cannot work here.")
1305 # The first word can be a puzzle status and all remaining word
1306 # (if any) are search terms. _But_, if the first word is not a
1307 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1308 # words are search terms and we default status to 'unsolved'.
1309 split_args = args.split(' ', 1)
1310 status = split_args[0]
1311 if (len(split_args) > 1):
1312 terms = split_args[1]
1313 if status not in ('unsolved', 'solved', 'all'):
1319 # Separate search terms on spaces (but allow for quotation marks
1320 # to capture spaces in a search term)
1322 terms = shlex.split(terms)
1324 blocks = hunt_blocks(turb, hunt,
1325 puzzle_status=status, search_terms=terms,
1326 limit_to_rounds=puzzle.get('rounds', [])
1329 requests.post(response_url,
1330 json = { 'blocks': blocks },
1331 headers = {'Content-type': 'application/json'}
1336 commands["/round"] = round