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 hunt` or `/edit puzzle`, (and if issued as
90 just `/edit` will default to editing the current puzzle.
92 These are simply shortcuts for `/hunt edit` and `/puzzle edit`.
96 return edit_hunt_command(turb, body)
98 return edit_puzzle_command(turb, body)
100 commands["/edit"] = edit
103 def edit_puzzle_command(turb, body):
104 """Implementation of the `/puzzle edit` command
106 As dispatched from the puzzle() function.
109 channel_id = body['channel_id'][0]
110 trigger_id = body['trigger_id'][0]
112 puzzle = puzzle_for_channel(turb, channel_id)
115 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
117 return edit_puzzle(turb, puzzle, trigger_id)
119 def edit_puzzle_button(turb, payload):
120 """Handler for the action of user pressing an edit_puzzle button"""
122 action_id = payload['actions'][0]['action_id']
123 response_url = payload['response_url']
124 trigger_id = payload['trigger_id']
126 (hunt_id, sort_key) = action_id.split('-', 1)
128 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
131 requests.post(response_url,
132 json = {"text": "Error: Puzzle not found!"},
133 headers = {"Content-type": "application/json"})
134 return bot_reply("Error: Puzzle not found.")
136 return edit_puzzle(turb, puzzle, trigger_id)
138 actions['button']['edit_puzzle'] = edit_puzzle_button
140 def edit_puzzle(turb, puzzle, trigger_id):
141 """Common code for implementing an edit puzzle dialog
143 This implementation is common whether the edit operation was invoked
144 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
147 round_options = hunt_rounds(turb, puzzle['hunt_id'])
149 if len(round_options):
150 round_options_block = [
151 multi_select_block("Round(s)", "rounds",
152 "Existing round(s) this puzzle belongs to",
154 initial_options=puzzle.get("rounds", None)),
157 round_options_block = []
160 if puzzle.get("status", "unsolved") == solved:
164 solution_list = puzzle.get("solution", [])
166 solution_str = ", ".join(solution_list)
170 "private_metadata": json.dumps({
171 "hunt_id": puzzle['hunt_id'],
173 "puzzle_id": puzzle['puzzle_id'],
174 "channel_id": puzzle["channel_id"],
175 "channel_url": puzzle["channel_url"],
176 "sheet_url": puzzle["sheet_url"],
178 "title": {"type": "plain_text", "text": "Edit Puzzle"},
179 "submit": { "type": "plain_text", "text": "Save" },
181 input_block("Puzzle name", "name", "Name of the puzzle",
182 initial_value=puzzle["name"]),
183 input_block("Puzzle URL", "url", "External URL of puzzle",
184 initial_value=puzzle.get("url", None),
186 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
187 checked=(puzzle.get('type', 'plain') == 'meta')),
188 * round_options_block,
189 input_block("New round(s)", "new_rounds",
190 "New round(s) this puzzle belongs to " +
193 input_block("State", "state",
194 "State of this puzzle (partial progress, next steps)",
195 initial_value=puzzle.get("state", None),
198 "Puzzle status", "Solved", "solved",
199 checked=(puzzle.get('status', 'unsolved') == 'solved')),
200 input_block("Solution", "solution",
201 "Solution(s) (comma-separated if multiple)",
202 initial_value=solution_str,
207 result = turb.slack_client.views_open(trigger_id=trigger_id,
211 submission_handlers[result['view']['id']] = edit_puzzle_submission
215 def edit_puzzle_submission(turb, payload, metadata):
216 """Handler for the user submitting the edit puzzle modal
218 This is the modal view presented to the user by the edit_puzzle
224 # First, read all the various data from the request
225 meta = json.loads(metadata)
226 puzzle['hunt_id'] = meta['hunt_id']
227 puzzle['SK'] = meta['SK']
228 puzzle['puzzle_id'] = meta['puzzle_id']
229 puzzle['channel_id'] = meta['channel_id']
230 puzzle['channel_url'] = meta['channel_url']
231 puzzle['sheet_url'] = meta['sheet_url']
233 state = payload['view']['state']['values']
234 user_id = payload['user']['id']
236 puzzle['name'] = state['name']['name']['value']
237 url = state['url']['url']['value']
240 if state['meta']['meta']['selected_options']:
241 puzzle['type'] = 'meta'
243 puzzle['type'] = 'plain'
244 rounds = [option['value'] for option in
245 state['rounds']['rounds']['selected_options']]
247 puzzle['rounds'] = rounds
248 new_rounds = state['new_rounds']['new_rounds']['value']
249 puzzle_state = state['state']['state']['value']
251 puzzle['state'] = puzzle_state
252 if state['solved']['solved']['selected_options']:
253 puzzle['status'] = 'solved'
255 puzzle['status'] = 'unsolved'
256 puzzle['solution'] = []
257 solution = state['solution']['solution']['value']
259 puzzle['solution'] = [
260 sol.strip() for sol in solution.split(',')
263 # Verify that there's a solution if the puzzle is mark solved
264 if puzzle['status'] == 'solved' and not puzzle['solution']:
265 return submission_error("solution",
266 "A solved puzzle requires a solution.")
268 if puzzle['status'] == 'unsolved' and puzzle['solution']:
269 return submission_error("solution",
270 "An unsolved puzzle should have no solution.")
272 # Add any new rounds to the database
274 if 'rounds' not in puzzle:
275 puzzle['rounds'] = []
276 for round in new_rounds.split(','):
277 # Drop any leading/trailing spaces from the round name
278 round = round.strip()
279 # Ignore any empty string
282 puzzle['rounds'].append(round)
285 'hunt_id': puzzle['hunt_id'],
286 'SK': 'round-' + round
290 # Get old puzzle from the database (to determine what's changed)
291 old_puzzle = find_puzzle_for_sort_key(turb,
295 # If we are changing puzzle type (meta -> plain or plain -> meta)
296 # the the sort key has to change, so compute the new one and delete
297 # the old item from the database.
299 # XXX: We should really be using a transaction here to combine the
300 # delete_item and the put_item into a single transaction, but
301 # the boto interface is annoying in that transactions are only on
302 # the "Client" object which has a totally different interface than
303 # the "Table" object I've been using so I haven't figured out how
306 if puzzle['type'] != old_puzzle.get('type', 'plain'):
307 puzzle['SK'] = puzzle_sort_key(puzzle)
308 turb.table.delete_item(Key={
309 'hunt_id': old_puzzle['hunt_id'],
310 'SK': old_puzzle['SK']
313 # Update the puzzle in the database
314 turb.table.put_item(Item=puzzle)
316 # Inform the puzzle channel about the edit
317 edit_message = "Puzzle edited by <@{}>".format(user_id)
318 blocks = ([section_block(text_block(edit_message+":\n"))] +
319 puzzle_blocks(puzzle, include_rounds=True))
321 turb.slack_client, puzzle['channel_id'],
322 edit_message, blocks=blocks)
324 # Also inform the hunt if the puzzle's solved status changed
325 if puzzle['status'] != old_puzzle['status']:
326 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
327 if puzzle['status'] == 'solved':
328 message = "Puzzle <{}|{}> has been solved!".format(
329 puzzle['channel_url'],
332 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
333 puzzle['channel_url'],
335 slack_send_message(turb.slack_client, hunt['channel_id'], message)
337 # We need to set the channel topic if any of puzzle name, url,
338 # state, status, or solution, has changed. Let's just do that
339 # unconditionally here.
340 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
344 def edit_hunt_command(turb, body):
345 """Implementation of the `/hunt edit` command
347 As dispatched from the hunt() function.
350 channel_id = body['channel_id'][0]
351 trigger_id = body['trigger_id'][0]
353 hunt = hunt_for_channel(turb, channel_id)
356 return bot_reply("Sorry, this does not appear to be a hunt channel.")
358 return edit_hunt(turb, hunt, trigger_id)
360 def edit_hunt_button(turb, payload):
361 """Handler for the action of user pressing an edit_hunt button"""
363 hunt_id = payload['actions'][0]['action_id']
364 response_url = payload['response_url']
365 trigger_id = payload['trigger_id']
367 hunt = find_hunt_for_hunt_id(hunt_id)
370 requests.post(response_url,
371 json = {"text": "Error: Hunt not found!"},
372 headers = {"Content-type": "application/json"})
373 return bot_reply("Error: Hunt not found.")
375 return edit_hunt(turb, hunt, trigger_id)
377 actions['button']['edit_hunt'] = edit_hunt_button
379 def edit_hunt(turb, hunt, trigger_id):
380 """Common code for implementing an edit hunt dialog
382 This implementation is common whether the edit operation was invoked
383 by a button (edit_hunt_button) or a command (edit_hunt_command).
388 "private_metadata": json.dumps({
389 "hunt_id": hunt["hunt_id"],
391 "is_hunt": hunt["is_hunt"],
392 "channel_id": hunt["channel_id"],
393 "sheet_url": hunt["sheet_url"],
394 "folder_id": hunt["folder_id"],
396 "title": { "type": "plain_text", "text": "Edit Hunt" },
397 "submit": { "type": "plain_text", "text": "Save" },
399 input_block("Hunt name", "name", "Name of the hunt",
400 initial_value=hunt["name"]),
401 input_block("Hunt URL", "url", "External URL of hunt",
402 initial_value=hunt.get("url", None),
404 checkbox_block("Is this hunt active?", "Active", "active",
405 checked=(hunt.get('active', False)))
409 result = turb.slack_client.views_open(trigger_id=trigger_id,
413 submission_handlers[result['view']['id']] = edit_hunt_submission
417 def edit_hunt_submission(turb, payload, metadata):
418 """Handler for the user submitting the edit hunt modal
420 This is the modal view presented by the edit_hunt function above.
425 # First, read all the various data from the request
426 meta = json.loads(metadata)
427 hunt['hunt_id'] = meta['hunt_id']
428 hunt['SK'] = meta['SK']
429 hunt['is_hunt'] = meta['is_hunt']
430 hunt['channel_id'] = meta['channel_id']
431 hunt['sheet_url'] = meta['sheet_url']
432 hunt['folder_id'] = meta['folder_id']
434 state = payload['view']['state']['values']
435 user_id = payload['user']['id']
437 hunt['name'] = state['name']['name']['value']
438 url = state['url']['url']['value']
442 if state['active']['active']['selected_options']:
443 hunt['active'] = True
445 hunt['active'] = False
447 # Update the hunt in the database
448 turb.table.put_item(Item=hunt)
450 # Inform the hunt channel about the edit
451 edit_message = "Hunt edited by <@{}>".format(user_id)
453 section_block(text_block(edit_message)),
454 section_block(text_block("Hunt name: {}".format(hunt['name']))),
457 url = hunt.get('url', None)
460 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
464 turb.slack_client, hunt['channel_id'],
465 edit_message, blocks=blocks)
469 def new_hunt_command(turb, body):
470 """Implementation of the '/hunt new' command
472 As dispatched from the hunt() function.
475 trigger_id = body['trigger_id'][0]
477 return new_hunt(turb, trigger_id)
479 def new_hunt_button(turb, payload):
480 """Handler for the action of user pressing the new_hunt button"""
482 trigger_id = payload['trigger_id']
484 return new_hunt(turb, trigger_id)
486 def new_hunt(turb, trigger_id):
487 """Common code for implementing a new hunt dialog
489 This implementation is common whether the operations was invoked
490 by a button (new_hunt_button) or a command (new_hunt_command).
495 "private_metadata": json.dumps({}),
496 "title": { "type": "plain_text", "text": "New Hunt" },
497 "submit": { "type": "plain_text", "text": "Create" },
499 input_block("Hunt name", "name", "Name of the hunt"),
500 input_block("Hunt ID", "hunt_id",
501 "Used as puzzle channel prefix "
502 + "(no spaces nor punctuation)"),
503 input_block("Hunt URL", "url", "External URL of hunt",
508 result = turb.slack_client.views_open(trigger_id=trigger_id,
511 submission_handlers[result['view']['id']] = new_hunt_submission
515 actions['button']['new_hunt'] = new_hunt
517 def new_hunt_submission(turb, payload, metadata):
518 """Handler for the user submitting the new hunt modal
520 This is the modal view presented to the user by the new_hunt
523 state = payload['view']['state']['values']
524 user_id = payload['user']['id']
525 name = state['name']['name']['value']
526 hunt_id = state['hunt_id']['hunt_id']['value']
527 url = state['url']['url']['value']
529 # Validate that the hunt_id contains no invalid characters
530 if not re.match(valid_id_re, hunt_id):
531 return submission_error("hunt_id",
532 "Hunt ID can only contain lowercase letters, "
533 + "numbers, and underscores")
535 # Check to see if the turbot table exists
537 exists = turb.table.table_status in ("CREATING", "UPDATING",
542 # Create the turbot table if necessary.
544 turb.table = turb.db.create_table(
547 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
548 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
550 AttributeDefinitions=[
551 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
552 {'AttributeName': 'SK', 'AttributeType': 'S'},
553 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
554 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
555 {'AttributeName': 'url', 'AttributeType': 'S'}
557 ProvisionedThroughput={
558 'ReadCapacityUnits': 5,
559 'WriteCapacityUnits': 5
561 GlobalSecondaryIndexes=[
563 'IndexName': 'channel_id_index',
565 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
568 'ProjectionType': 'ALL'
570 'ProvisionedThroughput': {
571 'ReadCapacityUnits': 5,
572 'WriteCapacityUnits': 5
576 'IndexName': 'is_hunt_index',
578 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
581 'ProjectionType': 'ALL'
583 'ProvisionedThroughput': {
584 'ReadCapacityUnits': 5,
585 'WriteCapacityUnits': 5
589 LocalSecondaryIndexes = [
591 'IndexName': 'url_index',
593 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
594 {'AttributeName': 'url', 'KeyType': 'RANGE'},
597 'ProjectionType': 'ALL'
602 return submission_error(
604 "Still bootstrapping turbot table. Try again in a minute, please.")
606 # Create a channel for the hunt
608 response = turb.slack_client.conversations_create(name=hunt_id)
609 except SlackApiError as e:
610 return submission_error("hunt_id",
611 "Error creating Slack channel: {}"
612 .format(e.response['error']))
614 channel_id = response['channel']['id']
616 # Insert the newly-created hunt into the database
617 # (leaving it as non-active for now until the channel-created handler
618 # finishes fixing it up with a sheet and a companion table)
621 "SK": "hunt-{}".format(hunt_id),
623 "channel_id": channel_id,
629 turb.table.put_item(Item=item)
631 # Invite the initiating user to the channel
632 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
636 def view_submission(turb, payload):
637 """Handler for Slack interactive view submission
639 Specifically, those that have a payload type of 'view_submission'"""
641 view_id = payload['view']['id']
642 metadata = payload['view']['private_metadata']
644 if view_id in submission_handlers:
645 return submission_handlers[view_id](turb, payload, metadata)
647 print("Error: Unknown view ID: {}".format(view_id))
652 def rot(turb, body, args):
653 """Implementation of the /rot command
655 The args string should be as follows:
657 [count|*] String to be rotated
659 That is, the first word of the string is an optional number (or
660 the character '*'). If this is a number it indicates an amount to
661 rotate each character in the string. If the count is '*' or is not
662 present, then the string will be rotated through all possible 25
665 The result of the rotation is returned (with Slack formatting) in
666 the body of the response so that Slack will provide it as a reply
667 to the user who submitted the slash command."""
669 channel_name = body['channel_name'][0]
670 response_url = body['response_url'][0]
671 channel_id = body['channel_id'][0]
673 result = turbot.rot.rot(args)
675 if (channel_name == "directmessage"):
676 requests.post(response_url,
677 json = {"text": result},
678 headers = {"Content-type": "application/json"})
680 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
684 commands["/rot"] = rot
686 def get_table_item(turb, table_name, key, value):
687 """Get an item from the database 'table_name' with 'key' as 'value'
689 Returns a tuple of (item, table) if found and (None, None) otherwise."""
691 table = turb.db.Table(table_name)
693 response = table.get_item(Key={key: value})
695 if 'Item' in response:
696 return (response['Item'], table)
700 def db_entry_for_channel(turb, channel_id):
701 """Given a channel ID return the database item for this channel
703 If this channel is a registered hunt or puzzle channel, return the
704 corresponding row from the database for this channel. Otherwise,
707 Note: If you need to specifically ensure that the channel is a
708 puzzle or a hunt, please call puzzle_for_channel or
709 hunt_for_channel respectively.
712 response = turb.table.query(
713 IndexName = "channel_id_index",
714 KeyConditionExpression=Key("channel_id").eq(channel_id)
717 if response['Count'] == 0:
720 return response['Items'][0]
723 def puzzle_for_channel(turb, channel_id):
725 """Given a channel ID return the puzzle from the database for this channel
727 If the given channel_id is a puzzle's channel, this function
728 returns a dict filled with the attributes from the puzzle's entry
731 Otherwise, this function returns None.
734 entry = db_entry_for_channel(turb, channel_id)
736 if entry and entry['SK'].startswith('puzzle-'):
741 def hunt_for_channel(turb, channel_id):
743 """Given a channel ID return the hunt from the database for this channel
745 This works whether the original channel is a primary hunt channel,
746 or if it is one of the channels of a puzzle belonging to the hunt.
748 Returns None if channel does not belong to a hunt, otherwise a
749 dictionary with all fields from the hunt's row in the table,
750 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
753 entry = db_entry_for_channel(turb, channel_id)
755 # We're done if this channel doesn't exist in the database at all
759 # Also done if this channel is a hunt channel
760 if entry['SK'].startswith('hunt-'):
763 # Otherwise, (the channel is in the database, but is not a hunt),
764 # we expect this to be a puzzle channel instead
765 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
767 # python3.9 has a built-in removeprefix but AWS only has python3.8
768 def remove_prefix(text, prefix):
769 if text.startswith(prefix):
770 return text[len(prefix):]
773 def hunt_rounds(turb, hunt_id):
774 """Returns array of strings giving rounds that exist in the given hunt"""
776 response = turb.table.query(
777 KeyConditionExpression=(
778 Key('hunt_id').eq(hunt_id) &
779 Key('SK').begins_with('round-')
783 if response['Count'] == 0:
786 return [remove_prefix(option['SK'], 'round-')
787 for option in response['Items']]
789 def puzzle(turb, body, args):
790 """Implementation of the /puzzle command
792 The args string can be a sub-command:
794 /puzzle new: Bring up a dialog to create a new puzzle
796 /puzzle edit: Edit the puzzle for the current channel
798 Or with no argument at all:
800 /puzzle: Print details of the current puzzle (if in a puzzle channel)
804 return new_puzzle(turb, body)
807 return edit_puzzle_command(turb, body)
810 return bot_reply("Unknown syntax for `/puzzle` command. " +
811 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
812 "and `/puzzle new` to display, edit, or create " +
815 # For no arguments we print the current puzzle as a reply
816 channel_id = body['channel_id'][0]
817 response_url = body['response_url'][0]
819 puzzle = puzzle_for_channel(turb, channel_id)
822 hunt = hunt_for_channel(turb, channel_id)
825 "This is not a puzzle channel, but is a hunt channel. "
826 + "If you want to create a new puzzle for this hunt, use "
830 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
831 + "channel, so the `/puzzle` command cannot work here.")
833 blocks = puzzle_blocks(puzzle, include_rounds=True)
835 # For a meta puzzle, also display the titles and solutions for all
836 # puzzles in the same round.
837 if puzzle.get('type', 'plain') == 'meta':
838 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
840 # Drop this puzzle itself from the report
841 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
843 for round in puzzle.get('rounds', [None]):
844 answers = round_quoted_puzzles_titles_answers(round, puzzles)
846 section_block(text_block(
847 "*Feeder solutions from round {}*".format(
848 round if round else "<none>"
850 section_block(text_block(answers))
853 requests.post(response_url,
854 json = {'blocks': blocks},
855 headers = {'Content-type': 'application/json'}
860 commands["/puzzle"] = puzzle
862 def new(turb, body, args):
863 """Implementation of the `/new` command
865 This can be used to create a new hunt ("/new hunt") or a new
866 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
867 default behavior (as it is much more common).
869 This operations are identical to the existing "/hunt new" and
870 "/puzzle new". I don't know that that redundancy is actually
871 helpful in the interface. But at least having both allows us to
872 experiment and decide which is more natural and should be kept
877 return new_hunt_command(turb, body)
879 return new_puzzle(turb, body)
881 commands["/new"] = new
883 def new_puzzle(turb, body):
884 """Implementation of the "/puzzle new" command
886 This brings up a dialog box for creating a new puzzle.
889 channel_id = body['channel_id'][0]
890 trigger_id = body['trigger_id'][0]
892 hunt = hunt_for_channel(turb, channel_id)
895 return bot_reply("Sorry, this channel doesn't appear to "
896 + "be a hunt or puzzle channel")
898 round_options = hunt_rounds(turb, hunt['hunt_id'])
900 if len(round_options):
901 round_options_block = [
902 multi_select_block("Round(s)", "rounds",
903 "Existing round(s) this puzzle belongs to",
907 round_options_block = []
911 "private_metadata": json.dumps({
912 "hunt_id": hunt['hunt_id'],
914 "title": {"type": "plain_text", "text": "New Puzzle"},
915 "submit": { "type": "plain_text", "text": "Create" },
917 section_block(text_block("*For {}*".format(hunt['name']))),
918 input_block("Puzzle name", "name", "Name of the puzzle"),
919 input_block("Puzzle URL", "url", "External URL of puzzle",
921 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
922 * round_options_block,
923 input_block("New round(s)", "new_rounds",
924 "New round(s) this puzzle belongs to " +
930 result = turb.slack_client.views_open(trigger_id=trigger_id,
934 submission_handlers[result['view']['id']] = new_puzzle_submission
938 def new_puzzle_submission(turb, payload, metadata):
939 """Handler for the user submitting the new puzzle modal
941 This is the modal view presented to the user by the new_puzzle
945 # First, read all the various data from the request
946 meta = json.loads(metadata)
947 hunt_id = meta['hunt_id']
949 state = payload['view']['state']['values']
950 name = state['name']['name']['value']
951 url = state['url']['url']['value']
952 if state['meta']['meta']['selected_options']:
955 puzzle_type = 'plain'
956 if 'rounds' in state:
957 rounds = [option['value'] for option in
958 state['rounds']['rounds']['selected_options']]
961 new_rounds = state['new_rounds']['new_rounds']['value']
963 # Before doing anything, reject this puzzle if a puzzle already
964 # exists with the same URL.
966 existing = find_puzzle_for_url(turb, hunt_id, url)
968 return submission_error(
970 "Error: A puzzle with this URL already exists.")
972 # Create a Slack-channel-safe puzzle_id
973 puzzle_id = puzzle_id_from_name(name)
975 # Create a channel for the puzzle
976 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
979 response = turb.slack_client.conversations_create(
980 name=hunt_dash_channel)
981 except SlackApiError as e:
982 return submission_error(
984 "Error creating Slack channel {}: {}"
985 .format(hunt_dash_channel, e.response['error']))
987 channel_id = response['channel']['id']
989 # Add any new rounds to the database
991 for round in new_rounds.split(','):
992 # Drop any leading/trailing spaces from the round name
993 round = round.strip()
994 # Ignore any empty string
1001 'SK': 'round-' + round
1005 # Construct a puzzle dict
1008 "puzzle_id": puzzle_id,
1009 "channel_id": channel_id,
1011 "status": 'unsolved',
1018 puzzle['rounds'] = rounds
1020 # Finally, compute the appropriate sort key
1021 puzzle["SK"] = puzzle_sort_key(puzzle)
1023 # Insert the newly-created puzzle into the database
1024 turb.table.put_item(Item=puzzle)
1028 def state(turb, body, args):
1029 """Implementation of the /state command
1031 The args string should be a brief sentence describing where things
1032 stand or what's needed."""
1034 channel_id = body['channel_id'][0]
1036 old_puzzle = puzzle_for_channel(turb, channel_id)
1040 "Sorry, the /state command only works in a puzzle channel")
1042 # Make a deep copy of the puzzle object
1043 puzzle = puzzle_copy(old_puzzle)
1045 # Update the puzzle in the database
1046 puzzle['state'] = args
1047 turb.table.put_item(Item=puzzle)
1049 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1053 commands["/state"] = state
1055 def tag(turb, body, args):
1056 """Implementation of the `/tag` command.
1058 Arg is either a tag to add (optionally prefixed with '+'), or if
1059 prefixed with '-' is a tag to remove.
1063 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1064 + "or `/tag -TAG_TO_REMOVE`.")
1066 channel_id = body['channel_id'][0]
1068 old_puzzle = puzzle_for_channel(turb, channel_id)
1072 "Sorry, the /tag command only works in a puzzle channel")
1083 # Force tag to all uppercase
1086 # Reject a tag that is not alphabetic or underscore A-Z_
1087 if not re.match(r'^[A-Z0-9_]*$', tag):
1088 return bot_reply("Sorry, tags can only contain letters, numbers, "
1089 + "and the underscore character.")
1091 if action == 'remove':
1092 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1093 return bot_reply("Nothing to do. This puzzle is not tagged "
1094 + "with the tag: {}".format(tag))
1096 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1097 return bot_reply("Nothing to do. This puzzle is already tagged "
1098 + "with the tag: {}".format(tag))
1100 # OK. Error checking is done. Let's get to work
1102 # Make a deep copy of the puzzle object
1103 puzzle = puzzle_copy(old_puzzle)
1105 if action == 'remove':
1106 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1108 if 'tags' not in puzzle:
1109 puzzle['tags'] = [tag]
1111 puzzle['tags'].append(tag)
1113 turb.table.put_item(Item=puzzle)
1115 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1119 commands["/tag"] = tag
1121 def solved(turb, body, args):
1122 """Implementation of the /solved command
1124 The args string should be a confirmed solution."""
1126 channel_id = body['channel_id'][0]
1127 user_id = body['user_id'][0]
1129 old_puzzle = puzzle_for_channel(turb, channel_id)
1132 return bot_reply("Sorry, this is not a puzzle channel.")
1136 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1138 # Make a deep copy of the puzzle object
1139 puzzle = puzzle_copy(old_puzzle)
1141 # Set the status and solution fields in the database
1142 puzzle['status'] = 'solved'
1143 puzzle['solution'].append(args)
1144 if 'state' in puzzle:
1146 turb.table.put_item(Item=puzzle)
1148 # Report the solution to the puzzle's channel
1150 turb.slack_client, channel_id,
1151 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1153 # Also report the solution to the hunt channel
1154 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1156 turb.slack_client, hunt['channel_id'],
1157 "Puzzle <{}|{}> has been solved!".format(
1158 puzzle['channel_url'],
1162 # And update the puzzle's description
1163 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1167 commands["/solved"] = solved
1169 def hunt(turb, body, args):
1170 """Implementation of the /hunt command
1172 The (optional) args string can be used to filter which puzzles to
1173 display. The first word can be one of 'all', 'unsolved', or
1174 'solved' and can be used to display only puzzles with the given
1175 status. If this first word is missing, this command will display
1176 only unsolved puzzles by default.
1178 Any remaining text in the args string will be interpreted as
1179 search terms. These will be split into separate terms on space
1180 characters, (though quotation marks can be used to include a space
1181 character in a term). All terms must match on a puzzle in order
1182 for that puzzle to be included. But a puzzle will be considered to
1183 match if any of the puzzle title, round title, puzzle URL, puzzle
1184 state, puzzle type, tags, or puzzle solution match. Matching will
1185 be performed without regard to case sensitivity and the search
1186 terms can include regular expression syntax.
1190 channel_id = body['channel_id'][0]
1191 response_url = body['response_url'][0]
1193 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1195 return new_hunt_command(turb, body)
1198 return edit_hunt_command(turb, body)
1202 # The first word can be a puzzle status and all remaining word
1203 # (if any) are search terms. _But_, if the first word is not a
1204 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1205 # words are search terms and we default status to 'unsolved'.
1206 split_args = args.split(' ', 1)
1207 status = split_args[0]
1208 if (len(split_args) > 1):
1209 terms = split_args[1]
1210 if status not in ('unsolved', 'solved', 'all'):
1216 # Separate search terms on spaces (but allow for quotation marks
1217 # to capture spaces in a search term)
1219 terms = shlex.split(terms)
1221 hunt = hunt_for_channel(turb, channel_id)
1224 return bot_reply("Sorry, this channel doesn't appear to "
1225 + "be a hunt or puzzle channel")
1227 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1229 requests.post(response_url,
1230 json = { 'blocks': blocks },
1231 headers = {'Content-type': 'application/json'}
1236 commands["/hunt"] = hunt
1238 def round(turb, body, args):
1239 """Implementation of the /round command
1241 Displays puzzles in the same round(s) as the puzzle for the
1244 The (optional) args string can be used to filter which puzzles to
1245 display. The first word can be one of 'all', 'unsolved', or
1246 'solved' and can be used to display only puzzles with the given
1247 status. If this first word is missing, this command will display
1248 all puzzles in the round by default.
1250 Any remaining text in the args string will be interpreted as
1251 search terms. These will be split into separate terms on space
1252 characters, (though quotation marks can be used to include a space
1253 character in a term). All terms must match on a puzzle in order
1254 for that puzzle to be included. But a puzzle will be considered to
1255 match if any of the puzzle title, round title, puzzle URL, puzzle
1256 state, or puzzle solution match. Matching will be performed
1257 without regard to case sensitivity and the search terms can
1258 include regular expression syntax.
1261 channel_id = body['channel_id'][0]
1262 response_url = body['response_url'][0]
1264 puzzle = puzzle_for_channel(turb, channel_id)
1265 hunt = hunt_for_channel(turb, channel_id)
1270 "This is not a puzzle channel, but is a hunt channel. "
1271 + "Use /hunt if you want to see all rounds for this hunt.")
1274 "Sorry, this channel doesn't appear to be a puzzle channel "
1275 + "so the `/round` command cannot work here.")
1279 # The first word can be a puzzle status and all remaining word
1280 # (if any) are search terms. _But_, if the first word is not a
1281 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1282 # words are search terms and we default status to 'unsolved'.
1283 split_args = args.split(' ', 1)
1284 status = split_args[0]
1285 if (len(split_args) > 1):
1286 terms = split_args[1]
1287 if status not in ('unsolved', 'solved', 'all'):
1293 # Separate search terms on spaces (but allow for quotation marks
1294 # to capture spaces in a search term)
1296 terms = shlex.split(terms)
1298 blocks = hunt_blocks(turb, hunt,
1299 puzzle_status=status, search_terms=terms,
1300 limit_to_rounds=puzzle.get('rounds', [])
1303 requests.post(response_url,
1304 json = { 'blocks': blocks },
1305 headers = {'Content-type': 'application/json'}
1310 commands["/round"] = round