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
20 from turbot.help import turbot_help
27 from botocore.exceptions import ClientError
28 from boto3.dynamodb.conditions import Key
29 from turbot.slack import slack_send_message
33 actions['button'] = {}
35 submission_handlers = {}
37 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
39 # Note: This restriction not only allows for hunt and puzzle ID values to
40 # be used as Slack channel names, but it also allows for '-' as a valid
41 # separator between a hunt and a puzzle ID (for example in the puzzle
42 # edit dialog where a single attribute must capture both values).
43 valid_id_re = r'^[_a-z0-9]+$'
45 lambda_ok = {'statusCode': 200}
47 def bot_reply(message):
48 """Construct a return value suitable for a bot reply
50 This is suitable as a way to give an error back to the user who
51 initiated a slash command, for example."""
58 def submission_error(field, error):
59 """Construct an error suitable for returning for an invalid submission.
61 Returning this value will prevent a submission and alert the user that
62 the given field is invalid because of the given error."""
64 print("Rejecting invalid modal submission: {}".format(error))
69 "Content-Type": "application/json"
72 "response_action": "errors",
79 def multi_static_select(turb, payload):
80 """Handler for the action of user entering a multi-select value"""
84 actions['multi_static_select'] = {"*": multi_static_select}
86 def edit(turb, body, args):
88 """Implementation of the `/edit` command
90 This can be used as `/edit` (with no arguments) in either a hunt
91 or a puzzle channel to edit that hunt or puzzle. It can also be
92 called explicitly as `/edit hunt` to edit a hunt even from a
95 In any case, the operation is identical to `/hunt edit` or
99 # If we have an explicit argument, do what it says to do
101 return edit_hunt_command(turb, body)
104 return edit_puzzle_command(turb, body)
106 # Any other argument string is an error
108 return bot_reply("Error: Unexpected argument: {}\n".format(args) +
109 "Usage: `/edit puzzle`, `/edit hunt`, or " +
110 "`/edit` (to choose based on channel)"
113 # No explicit argument, so select what to edit based on the current channel
114 channel_id = body['channel_id'][0]
115 trigger_id = body['trigger_id'][0]
117 puzzle = puzzle_for_channel(turb, channel_id)
119 return edit_puzzle(turb, puzzle, trigger_id)
121 hunt = hunt_for_channel(turb, channel_id)
123 return edit_hunt(turb, hunt, trigger_id)
125 return bot_reply("Sorry, `/edit` only works in a hunt or puzzle channel.")
127 commands["/edit"] = edit
130 def edit_puzzle_command(turb, body):
131 """Implementation of the `/puzzle edit` command
133 As dispatched from the puzzle() function.
136 channel_id = body['channel_id'][0]
137 trigger_id = body['trigger_id'][0]
139 puzzle = puzzle_for_channel(turb, channel_id)
142 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
144 return edit_puzzle(turb, puzzle, trigger_id)
146 def edit_puzzle_button(turb, payload):
147 """Handler for the action of user pressing an edit_puzzle button"""
149 action_id = payload['actions'][0]['action_id']
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 return bot_reply("Error: Puzzle not found.")
159 return edit_puzzle(turb, puzzle, trigger_id)
161 actions['button']['edit_puzzle'] = edit_puzzle_button
163 def edit_puzzle(turb, puzzle, trigger_id):
164 """Common code for implementing an edit puzzle dialog
166 This implementation is common whether the edit operation was invoked
167 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
170 round_options = hunt_rounds(turb, puzzle['hunt_id'])
172 if len(round_options):
173 round_options_block = [
174 multi_select_block("Round(s)", "rounds",
175 "Existing round(s) this puzzle belongs to",
177 initial_options=puzzle.get("rounds", None)),
180 round_options_block = []
183 if puzzle.get("status", "unsolved") == solved:
187 solution_list = puzzle.get("solution", [])
189 solution_str = ", ".join(solution_list)
193 "private_metadata": json.dumps({
194 "hunt_id": puzzle['hunt_id'],
196 "puzzle_id": puzzle['puzzle_id'],
197 "channel_id": puzzle["channel_id"],
198 "channel_url": puzzle["channel_url"],
199 "sheet_url": puzzle["sheet_url"],
201 "title": {"type": "plain_text", "text": "Edit Puzzle"},
202 "submit": { "type": "plain_text", "text": "Save" },
204 input_block("Puzzle name", "name", "Name of the puzzle",
205 initial_value=puzzle["name"]),
206 input_block("Puzzle URL", "url", "External URL of puzzle",
207 initial_value=puzzle.get("url", None),
209 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
210 checked=(puzzle.get('type', 'plain') == 'meta')),
211 * round_options_block,
212 input_block("New round(s)", "new_rounds",
213 "New round(s) this puzzle belongs to " +
216 input_block("State", "state",
217 "State of this puzzle (partial progress, next steps)",
218 initial_value=puzzle.get("state", None),
221 "Puzzle status", "Solved", "solved",
222 checked=(puzzle.get('status', 'unsolved') == 'solved')),
223 input_block("Solution", "solution",
224 "Solution(s) (comma-separated if multiple)",
225 initial_value=solution_str,
230 result = turb.slack_client.views_open(trigger_id=trigger_id,
234 submission_handlers[result['view']['id']] = edit_puzzle_submission
238 def edit_puzzle_submission(turb, payload, metadata):
239 """Handler for the user submitting the edit puzzle modal
241 This is the modal view presented to the user by the edit_puzzle
247 # First, read all the various data from the request
248 meta = json.loads(metadata)
249 puzzle['hunt_id'] = meta['hunt_id']
250 puzzle['SK'] = meta['SK']
251 puzzle['puzzle_id'] = meta['puzzle_id']
252 puzzle['channel_id'] = meta['channel_id']
253 puzzle['channel_url'] = meta['channel_url']
254 puzzle['sheet_url'] = meta['sheet_url']
256 state = payload['view']['state']['values']
257 user_id = payload['user']['id']
259 puzzle['name'] = state['name']['name']['value']
260 url = state['url']['url']['value']
263 if state['meta']['meta']['selected_options']:
264 puzzle['type'] = 'meta'
266 puzzle['type'] = 'plain'
267 rounds = [option['value'] for option in
268 state['rounds']['rounds']['selected_options']]
270 puzzle['rounds'] = rounds
271 new_rounds = state['new_rounds']['new_rounds']['value']
272 puzzle_state = state['state']['state']['value']
274 puzzle['state'] = puzzle_state
275 if state['solved']['solved']['selected_options']:
276 puzzle['status'] = 'solved'
278 puzzle['status'] = 'unsolved'
279 puzzle['solution'] = []
280 solution = state['solution']['solution']['value']
282 puzzle['solution'] = [
283 sol.strip() for sol in solution.split(',')
286 # Verify that there's a solution if the puzzle is mark solved
287 if puzzle['status'] == 'solved' and not puzzle['solution']:
288 return submission_error("solution",
289 "A solved puzzle requires a solution.")
291 if puzzle['status'] == 'unsolved' and puzzle['solution']:
292 return submission_error("solution",
293 "An unsolved puzzle should have no solution.")
295 # Add any new rounds to the database
297 if 'rounds' not in puzzle:
298 puzzle['rounds'] = []
299 for round in new_rounds.split(','):
300 # Drop any leading/trailing spaces from the round name
301 round = round.strip()
302 # Ignore any empty string
305 puzzle['rounds'].append(round)
308 'hunt_id': puzzle['hunt_id'],
309 'SK': 'round-' + round
313 # Get old puzzle from the database (to determine what's changed)
314 old_puzzle = find_puzzle_for_sort_key(turb,
318 # If we are changing puzzle type (meta -> plain or plain -> meta)
319 # the the sort key has to change, so compute the new one and delete
320 # the old item from the database.
322 # XXX: We should really be using a transaction here to combine the
323 # delete_item and the put_item into a single transaction, but
324 # the boto interface is annoying in that transactions are only on
325 # the "Client" object which has a totally different interface than
326 # the "Table" object I've been using so I haven't figured out how
329 if puzzle['type'] != old_puzzle.get('type', 'plain'):
330 puzzle['SK'] = puzzle_sort_key(puzzle)
331 turb.table.delete_item(Key={
332 'hunt_id': old_puzzle['hunt_id'],
333 'SK': old_puzzle['SK']
336 # Update the puzzle in the database
337 turb.table.put_item(Item=puzzle)
339 # Inform the puzzle channel about the edit
340 edit_message = "Puzzle edited by <@{}>".format(user_id)
341 blocks = ([section_block(text_block(edit_message+":\n"))] +
342 puzzle_blocks(puzzle, include_rounds=True))
344 turb.slack_client, puzzle['channel_id'],
345 edit_message, blocks=blocks)
347 # Also inform the hunt if the puzzle's solved status changed
348 if puzzle['status'] != old_puzzle['status']:
349 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
350 if puzzle['status'] == 'solved':
351 message = "Puzzle <{}|{}> has been solved!".format(
352 puzzle['channel_url'],
355 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
356 puzzle['channel_url'],
358 slack_send_message(turb.slack_client, hunt['channel_id'], message)
360 # We need to set the channel topic if any of puzzle name, url,
361 # state, status, or solution, has changed. Let's just do that
362 # unconditionally here.
363 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
367 def edit_hunt_command(turb, body):
368 """Implementation of the `/hunt edit` command
370 As dispatched from the hunt() function.
373 channel_id = body['channel_id'][0]
374 trigger_id = body['trigger_id'][0]
376 hunt = hunt_for_channel(turb, channel_id)
379 return bot_reply("Sorry, this does not appear to be a hunt channel.")
381 return edit_hunt(turb, hunt, trigger_id)
383 def edit_hunt_button(turb, payload):
384 """Handler for the action of user pressing an edit_hunt button"""
386 hunt_id = payload['actions'][0]['action_id']
387 trigger_id = payload['trigger_id']
389 hunt = find_hunt_for_hunt_id(turb, hunt_id)
392 return bot_reply("Error: Hunt not found.")
394 return edit_hunt(turb, hunt, trigger_id)
396 actions['button']['edit_hunt'] = edit_hunt_button
398 def edit_hunt(turb, hunt, trigger_id):
399 """Common code for implementing an edit hunt dialog
401 This implementation is common whether the edit operation was invoked
402 by a button (edit_hunt_button) or a command (edit_hunt_command).
407 "private_metadata": json.dumps({
408 "hunt_id": hunt["hunt_id"],
410 "is_hunt": hunt["is_hunt"],
411 "channel_id": hunt["channel_id"],
412 "sheet_url": hunt["sheet_url"],
413 "folder_id": hunt["folder_id"],
415 "title": { "type": "plain_text", "text": "Edit Hunt" },
416 "submit": { "type": "plain_text", "text": "Save" },
418 input_block("Hunt name", "name", "Name of the hunt",
419 initial_value=hunt["name"]),
420 input_block("Hunt URL", "url", "External URL of hunt",
421 initial_value=hunt.get("url", None),
423 checkbox_block("Is this hunt active?", "Active", "active",
424 checked=(hunt.get('active', False)))
428 result = turb.slack_client.views_open(trigger_id=trigger_id,
432 submission_handlers[result['view']['id']] = edit_hunt_submission
436 def edit_hunt_submission(turb, payload, metadata):
437 """Handler for the user submitting the edit hunt modal
439 This is the modal view presented by the edit_hunt function above.
444 # First, read all the various data from the request
445 meta = json.loads(metadata)
446 hunt['hunt_id'] = meta['hunt_id']
447 hunt['SK'] = meta['SK']
448 hunt['is_hunt'] = meta['is_hunt']
449 hunt['channel_id'] = meta['channel_id']
450 hunt['sheet_url'] = meta['sheet_url']
451 hunt['folder_id'] = meta['folder_id']
453 state = payload['view']['state']['values']
454 user_id = payload['user']['id']
456 hunt['name'] = state['name']['name']['value']
457 url = state['url']['url']['value']
461 if state['active']['active']['selected_options']:
462 hunt['active'] = True
464 hunt['active'] = False
466 # Update the hunt in the database
467 turb.table.put_item(Item=hunt)
469 # Inform the hunt channel about the edit
470 edit_message = "Hunt edited by <@{}>".format(user_id)
472 section_block(text_block(edit_message)),
473 section_block(text_block("Hunt name: {}".format(hunt['name']))),
476 url = hunt.get('url', None)
479 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
483 turb.slack_client, hunt['channel_id'],
484 edit_message, blocks=blocks)
488 def new_hunt_command(turb, body):
489 """Implementation of the '/hunt new' command
491 As dispatched from the hunt() function.
494 trigger_id = body['trigger_id'][0]
496 return new_hunt(turb, trigger_id)
498 def new_hunt_button(turb, payload):
499 """Handler for the action of user pressing the new_hunt button"""
501 trigger_id = payload['trigger_id']
503 return new_hunt(turb, trigger_id)
505 def new_hunt(turb, trigger_id):
506 """Common code for implementing a new hunt dialog
508 This implementation is common whether the operations was invoked
509 by a button (new_hunt_button) or a command (new_hunt_command).
514 "private_metadata": json.dumps({}),
515 "title": { "type": "plain_text", "text": "New Hunt" },
516 "submit": { "type": "plain_text", "text": "Create" },
518 input_block("Hunt name", "name", "Name of the hunt"),
519 input_block("Hunt ID", "hunt_id",
520 "Used as puzzle channel prefix "
521 + "(no spaces nor punctuation)"),
522 input_block("Hunt URL", "url", "External URL of hunt",
527 result = turb.slack_client.views_open(trigger_id=trigger_id,
530 submission_handlers[result['view']['id']] = new_hunt_submission
534 actions['button']['new_hunt'] = new_hunt
536 def new_hunt_submission(turb, payload, metadata):
537 """Handler for the user submitting the new hunt modal
539 This is the modal view presented to the user by the new_hunt
542 state = payload['view']['state']['values']
543 user_id = payload['user']['id']
544 name = state['name']['name']['value']
545 hunt_id = state['hunt_id']['hunt_id']['value']
546 url = state['url']['url']['value']
548 # Validate that the hunt_id contains no invalid characters
549 if not re.match(valid_id_re, hunt_id):
550 return submission_error("hunt_id",
551 "Hunt ID can only contain lowercase letters, "
552 + "numbers, and underscores")
554 # Check to see if the turbot table exists
556 exists = turb.table.table_status in ("CREATING", "UPDATING",
561 # Create the turbot table if necessary.
563 turb.table = turb.db.create_table(
566 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
567 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
569 AttributeDefinitions=[
570 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
571 {'AttributeName': 'SK', 'AttributeType': 'S'},
572 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
573 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
574 {'AttributeName': 'url', 'AttributeType': 'S'}
576 ProvisionedThroughput={
577 'ReadCapacityUnits': 5,
578 'WriteCapacityUnits': 5
580 GlobalSecondaryIndexes=[
582 'IndexName': 'channel_id_index',
584 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
587 'ProjectionType': 'ALL'
589 'ProvisionedThroughput': {
590 'ReadCapacityUnits': 5,
591 'WriteCapacityUnits': 5
595 'IndexName': 'is_hunt_index',
597 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
600 'ProjectionType': 'ALL'
602 'ProvisionedThroughput': {
603 'ReadCapacityUnits': 5,
604 'WriteCapacityUnits': 5
608 LocalSecondaryIndexes = [
610 'IndexName': 'url_index',
612 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
613 {'AttributeName': 'url', 'KeyType': 'RANGE'},
616 'ProjectionType': 'ALL'
621 return submission_error(
623 "Still bootstrapping turbot table. Try again in a minute, please.")
625 # Create a channel for the hunt
627 response = turb.slack_client.conversations_create(name=hunt_id)
628 except SlackApiError as e:
629 return submission_error("hunt_id",
630 "Error creating Slack channel: {}"
631 .format(e.response['error']))
633 channel_id = response['channel']['id']
635 # Insert the newly-created hunt into the database
636 # (leaving it as non-active for now until the channel-created handler
637 # finishes fixing it up with a sheet and a companion table)
640 "SK": "hunt-{}".format(hunt_id),
642 "channel_id": channel_id,
648 turb.table.put_item(Item=item)
650 # Invite the initiating user to the channel
651 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
655 def view_submission(turb, payload):
656 """Handler for Slack interactive view submission
658 Specifically, those that have a payload type of 'view_submission'"""
660 view_id = payload['view']['id']
661 metadata = payload['view']['private_metadata']
663 if view_id in submission_handlers:
664 return submission_handlers[view_id](turb, payload, metadata)
666 print("Error: Unknown view ID: {}".format(view_id))
671 def rot(turb, body, args):
672 """Implementation of the /rot command
674 The args string should be as follows:
676 [count|*] String to be rotated
678 That is, the first word of the string is an optional number (or
679 the character '*'). If this is a number it indicates an amount to
680 rotate each character in the string. If the count is '*' or is not
681 present, then the string will be rotated through all possible 25
684 The result of the rotation is returned (with Slack formatting) in
685 the body of the response so that Slack will provide it as a reply
686 to the user who submitted the slash command."""
688 channel_name = body['channel_name'][0]
689 response_url = body['response_url'][0]
690 channel_id = body['channel_id'][0]
692 result = turbot.rot.rot(args)
694 if (channel_name == "directmessage"):
695 requests.post(response_url,
696 json = {"text": result},
697 headers = {"Content-type": "application/json"})
699 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
703 commands["/rot"] = rot
705 def get_table_item(turb, table_name, key, value):
706 """Get an item from the database 'table_name' with 'key' as 'value'
708 Returns a tuple of (item, table) if found and (None, None) otherwise."""
710 table = turb.db.Table(table_name)
712 response = table.get_item(Key={key: value})
714 if 'Item' in response:
715 return (response['Item'], table)
719 def db_entry_for_channel(turb, channel_id):
720 """Given a channel ID return the database item for this channel
722 If this channel is a registered hunt or puzzle channel, return the
723 corresponding row from the database for this channel. Otherwise,
726 Note: If you need to specifically ensure that the channel is a
727 puzzle or a hunt, please call puzzle_for_channel or
728 hunt_for_channel respectively.
731 response = turb.table.query(
732 IndexName = "channel_id_index",
733 KeyConditionExpression=Key("channel_id").eq(channel_id)
736 if response['Count'] == 0:
739 return response['Items'][0]
742 def puzzle_for_channel(turb, channel_id):
744 """Given a channel ID return the puzzle from the database for this channel
746 If the given channel_id is a puzzle's channel, this function
747 returns a dict filled with the attributes from the puzzle's entry
750 Otherwise, this function returns None.
753 entry = db_entry_for_channel(turb, channel_id)
755 if entry and entry['SK'].startswith('puzzle-'):
760 def hunt_for_channel(turb, channel_id):
762 """Given a channel ID return the hunt from the database for this channel
764 This works whether the original channel is a primary hunt channel,
765 or if it is one of the channels of a puzzle belonging to the hunt.
767 Returns None if channel does not belong to a hunt, otherwise a
768 dictionary with all fields from the hunt's row in the table,
769 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
772 entry = db_entry_for_channel(turb, channel_id)
774 # We're done if this channel doesn't exist in the database at all
778 # Also done if this channel is a hunt channel
779 if entry['SK'].startswith('hunt-'):
782 # Otherwise, (the channel is in the database, but is not a hunt),
783 # we expect this to be a puzzle channel instead
784 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
786 # python3.9 has a built-in removeprefix but AWS only has python3.8
787 def remove_prefix(text, prefix):
788 if text.startswith(prefix):
789 return text[len(prefix):]
792 def hunt_rounds(turb, hunt_id):
793 """Returns array of strings giving rounds that exist in the given hunt"""
795 response = turb.table.query(
796 KeyConditionExpression=(
797 Key('hunt_id').eq(hunt_id) &
798 Key('SK').begins_with('round-')
802 if response['Count'] == 0:
805 return [remove_prefix(option['SK'], 'round-')
806 for option in response['Items']]
808 def puzzle(turb, body, args):
809 """Implementation of the /puzzle command
811 The args string can be a sub-command:
813 /puzzle new: Bring up a dialog to create a new puzzle
815 /puzzle edit: Edit the puzzle for the current channel
817 Or with no argument at all:
819 /puzzle: Print details of the current puzzle (if in a puzzle channel)
823 return new_puzzle(turb, body)
826 return edit_puzzle_command(turb, body)
829 return bot_reply("Unknown syntax for `/puzzle` command. " +
830 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
831 "and `/puzzle new` to display, edit, or create " +
834 # For no arguments we print the current puzzle as a reply
835 channel_id = body['channel_id'][0]
836 response_url = body['response_url'][0]
838 puzzle = puzzle_for_channel(turb, channel_id)
841 hunt = hunt_for_channel(turb, channel_id)
844 "This is not a puzzle channel, but is a hunt channel. "
845 + "If you want to create a new puzzle for this hunt, use "
849 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
850 + "channel, so the `/puzzle` command cannot work here.")
852 blocks = puzzle_blocks(puzzle, include_rounds=True)
854 # For a meta puzzle, also display the titles and solutions for all
855 # puzzles in the same round.
856 if puzzle.get('type', 'plain') == 'meta':
857 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
859 # Drop this puzzle itself from the report
860 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
862 for round in puzzle.get('rounds', [None]):
863 answers = round_quoted_puzzles_titles_answers(round, puzzles)
865 section_block(text_block(
866 "*Feeder solutions from round {}*".format(
867 round if round else "<none>"
869 section_block(text_block(answers))
872 requests.post(response_url,
873 json = {'blocks': blocks},
874 headers = {'Content-type': 'application/json'}
879 commands["/puzzle"] = puzzle
881 def new(turb, body, args):
882 """Implementation of the `/new` command
884 This can be used to create a new hunt ("/new hunt") or a new
885 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
886 default behavior (as it is much more common).
888 This operations are identical to the existing "/hunt new" and
889 "/puzzle new". I don't know that that redundancy is actually
890 helpful in the interface. But at least having both allows us to
891 experiment and decide which is more natural and should be kept
896 return new_hunt_command(turb, body)
898 return new_puzzle(turb, body)
900 commands["/new"] = new
902 def new_puzzle(turb, body):
903 """Implementation of the "/puzzle new" command
905 This brings up a dialog box for creating a new puzzle.
908 channel_id = body['channel_id'][0]
909 trigger_id = body['trigger_id'][0]
911 hunt = hunt_for_channel(turb, channel_id)
914 return bot_reply("Sorry, this channel doesn't appear to "
915 + "be a hunt or puzzle channel")
917 round_options = hunt_rounds(turb, hunt['hunt_id'])
919 if len(round_options):
920 round_options_block = [
921 multi_select_block("Round(s)", "rounds",
922 "Existing round(s) this puzzle belongs to",
926 round_options_block = []
930 "private_metadata": json.dumps({
931 "hunt_id": hunt['hunt_id'],
933 "title": {"type": "plain_text", "text": "New Puzzle"},
934 "submit": { "type": "plain_text", "text": "Create" },
936 section_block(text_block("*For {}*".format(hunt['name']))),
937 input_block("Puzzle name", "name", "Name of the puzzle"),
938 input_block("Puzzle URL", "url", "External URL of puzzle",
940 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
941 * round_options_block,
942 input_block("New round(s)", "new_rounds",
943 "New round(s) this puzzle belongs to " +
949 result = turb.slack_client.views_open(trigger_id=trigger_id,
953 submission_handlers[result['view']['id']] = new_puzzle_submission
957 def new_puzzle_submission(turb, payload, metadata):
958 """Handler for the user submitting the new puzzle modal
960 This is the modal view presented to the user by the new_puzzle
964 # First, read all the various data from the request
965 meta = json.loads(metadata)
966 hunt_id = meta['hunt_id']
968 state = payload['view']['state']['values']
969 name = state['name']['name']['value']
970 url = state['url']['url']['value']
971 if state['meta']['meta']['selected_options']:
974 puzzle_type = 'plain'
975 if 'rounds' in state:
976 rounds = [option['value'] for option in
977 state['rounds']['rounds']['selected_options']]
980 new_rounds = state['new_rounds']['new_rounds']['value']
982 # Before doing anything, reject this puzzle if a puzzle already
983 # exists with the same URL.
985 existing = find_puzzle_for_url(turb, hunt_id, url)
987 return submission_error(
989 "Error: A puzzle with this URL already exists.")
991 # Create a Slack-channel-safe puzzle_id
992 puzzle_id = puzzle_id_from_name(name)
994 # Create a channel for the puzzle
995 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
998 response = turb.slack_client.conversations_create(
999 name=hunt_dash_channel)
1000 except SlackApiError as e:
1001 return submission_error(
1003 "Error creating Slack channel {}: {}"
1004 .format(hunt_dash_channel, e.response['error']))
1006 channel_id = response['channel']['id']
1008 # Add any new rounds to the database
1010 for round in new_rounds.split(','):
1011 # Drop any leading/trailing spaces from the round name
1012 round = round.strip()
1013 # Ignore any empty string
1016 rounds.append(round)
1017 turb.table.put_item(
1020 'SK': 'round-' + round
1024 # Construct a puzzle dict
1027 "puzzle_id": puzzle_id,
1028 "channel_id": channel_id,
1030 "status": 'unsolved',
1037 puzzle['rounds'] = rounds
1039 # Finally, compute the appropriate sort key
1040 puzzle["SK"] = puzzle_sort_key(puzzle)
1042 # Insert the newly-created puzzle into the database
1043 turb.table.put_item(Item=puzzle)
1047 def state(turb, body, args):
1048 """Implementation of the /state command
1050 The args string should be a brief sentence describing where things
1051 stand or what's needed."""
1053 channel_id = body['channel_id'][0]
1055 old_puzzle = puzzle_for_channel(turb, channel_id)
1059 "Sorry, the /state command only works in a puzzle channel")
1061 # Make a deep copy of the puzzle object
1062 puzzle = puzzle_copy(old_puzzle)
1064 # Update the puzzle in the database
1065 puzzle['state'] = args
1066 turb.table.put_item(Item=puzzle)
1068 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1072 commands["/state"] = state
1074 def tag(turb, body, args):
1075 """Implementation of the `/tag` command.
1077 Arg is either a tag to add (optionally prefixed with '+'), or if
1078 prefixed with '-' is a tag to remove.
1082 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1083 + "or `/tag -TAG_TO_REMOVE`.")
1085 channel_id = body['channel_id'][0]
1087 old_puzzle = puzzle_for_channel(turb, channel_id)
1091 "Sorry, the /tag command only works in a puzzle channel")
1102 # Force tag to all uppercase
1105 # Reject a tag that is not alphabetic or underscore A-Z_
1106 if not re.match(r'^[A-Z0-9_]*$', tag):
1107 return bot_reply("Sorry, tags can only contain letters, numbers, "
1108 + "and the underscore character.")
1110 if action == 'remove':
1111 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1112 return bot_reply("Nothing to do. This puzzle is not tagged "
1113 + "with the tag: {}".format(tag))
1115 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1116 return bot_reply("Nothing to do. This puzzle is already tagged "
1117 + "with the tag: {}".format(tag))
1119 # OK. Error checking is done. Let's get to work
1121 # Make a deep copy of the puzzle object
1122 puzzle = puzzle_copy(old_puzzle)
1124 if action == 'remove':
1125 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1127 if 'tags' not in puzzle:
1128 puzzle['tags'] = [tag]
1130 puzzle['tags'].append(tag)
1132 turb.table.put_item(Item=puzzle)
1134 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1138 commands["/tag"] = tag
1140 def solved(turb, body, args):
1141 """Implementation of the /solved command
1143 The args string should be a confirmed solution."""
1145 channel_id = body['channel_id'][0]
1146 user_id = body['user_id'][0]
1148 old_puzzle = puzzle_for_channel(turb, channel_id)
1151 return bot_reply("Sorry, this is not a puzzle channel.")
1155 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1157 # Make a deep copy of the puzzle object
1158 puzzle = puzzle_copy(old_puzzle)
1160 # Set the status and solution fields in the database
1161 puzzle['status'] = 'solved'
1162 puzzle['solution'].append(args)
1163 if 'state' in puzzle:
1165 turb.table.put_item(Item=puzzle)
1167 # Report the solution to the puzzle's channel
1169 turb.slack_client, channel_id,
1170 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1172 # Also report the solution to the hunt channel
1173 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1175 turb.slack_client, hunt['channel_id'],
1176 "Puzzle <{}|{}> has been solved!".format(
1177 puzzle['channel_url'],
1181 # And update the puzzle's description
1182 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1186 commands["/solved"] = solved
1188 def hunt(turb, body, args):
1189 """Implementation of the /hunt command
1191 The (optional) args string can be used to filter which puzzles to
1192 display. The first word can be one of 'all', 'unsolved', or
1193 'solved' and can be used to display only puzzles with the given
1194 status. If this first word is missing, this command will display
1195 only unsolved puzzles by default.
1197 Any remaining text in the args string will be interpreted as
1198 search terms. These will be split into separate terms on space
1199 characters, (though quotation marks can be used to include a space
1200 character in a term). All terms must match on a puzzle in order
1201 for that puzzle to be included. But a puzzle will be considered to
1202 match if any of the puzzle title, round title, puzzle URL, puzzle
1203 state, puzzle type, tags, or puzzle solution match. Matching will
1204 be performed without regard to case sensitivity and the search
1205 terms can include regular expression syntax.
1209 channel_id = body['channel_id'][0]
1210 response_url = body['response_url'][0]
1212 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1214 return new_hunt_command(turb, body)
1217 return edit_hunt_command(turb, body)
1221 # The first word can be a puzzle status and all remaining word
1222 # (if any) are search terms. _But_, if the first word is not a
1223 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1224 # words are search terms and we default status to 'unsolved'.
1225 split_args = args.split(' ', 1)
1226 status = split_args[0]
1227 if (len(split_args) > 1):
1228 terms = split_args[1]
1229 if status not in ('unsolved', 'solved', 'all'):
1235 # Separate search terms on spaces (but allow for quotation marks
1236 # to capture spaces in a search term)
1238 terms = shlex.split(terms)
1240 hunt = hunt_for_channel(turb, channel_id)
1243 return bot_reply("Sorry, this channel doesn't appear to "
1244 + "be a hunt or puzzle channel")
1246 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1248 requests.post(response_url,
1249 json = { 'blocks': blocks },
1250 headers = {'Content-type': 'application/json'}
1255 commands["/hunt"] = hunt
1257 def round(turb, body, args):
1258 """Implementation of the /round command
1260 Displays puzzles in the same round(s) as the puzzle for the
1263 The (optional) args string can be used to filter which puzzles to
1264 display. The first word can be one of 'all', 'unsolved', or
1265 'solved' and can be used to display only puzzles with the given
1266 status. If this first word is missing, this command will display
1267 all puzzles in the round by default.
1269 Any remaining text in the args string will be interpreted as
1270 search terms. These will be split into separate terms on space
1271 characters, (though quotation marks can be used to include a space
1272 character in a term). All terms must match on a puzzle in order
1273 for that puzzle to be included. But a puzzle will be considered to
1274 match if any of the puzzle title, round title, puzzle URL, puzzle
1275 state, or puzzle solution match. Matching will be performed
1276 without regard to case sensitivity and the search terms can
1277 include regular expression syntax.
1280 channel_id = body['channel_id'][0]
1281 response_url = body['response_url'][0]
1283 puzzle = puzzle_for_channel(turb, channel_id)
1284 hunt = hunt_for_channel(turb, channel_id)
1289 "This is not a puzzle channel, but is a hunt channel. "
1290 + "Use /hunt if you want to see all rounds for this hunt.")
1293 "Sorry, this channel doesn't appear to be a puzzle channel "
1294 + "so the `/round` command cannot work here.")
1298 # The first word can be a puzzle status and all remaining word
1299 # (if any) are search terms. _But_, if the first word is not a
1300 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1301 # words are search terms and we default status to 'unsolved'.
1302 split_args = args.split(' ', 1)
1303 status = split_args[0]
1304 if (len(split_args) > 1):
1305 terms = split_args[1]
1306 if status not in ('unsolved', 'solved', 'all'):
1312 # Separate search terms on spaces (but allow for quotation marks
1313 # to capture spaces in a search term)
1315 terms = shlex.split(terms)
1317 blocks = hunt_blocks(turb, hunt,
1318 puzzle_status=status, search_terms=terms,
1319 limit_to_rounds=puzzle.get('rounds', [])
1322 requests.post(response_url,
1323 json = { 'blocks': blocks },
1324 headers = {'Content-type': 'application/json'}
1329 commands["/round"] = round
1331 def help_command(turb, body, args):
1332 """Implementation of the /help command
1334 Displays help on how to use Turbot.
1337 channel_id = body['channel_id'][0]
1338 response_url = body['response_url'][0]
1340 help_string = turbot_help(args)
1342 # The "/help me" command is special in that it reports in the
1343 # current channel, (where all other commands report privately to
1344 # the invoking user).
1346 turb.slack_client.chat_postMessage(
1347 channel=channel_id, text=help_string)
1349 requests.post(response_url,
1350 json = {"text": help_string},
1351 headers = {"Content-type": "application/json"})
1355 commands["/help"] = help_command