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 To edit the puzzle for the current channel.
91 This is simply a shortcut for `/puzzle edit`.
94 return edit_puzzle_command(turb, body)
96 commands["/edit"] = edit
99 def edit_puzzle_command(turb, body):
100 """Implementation of the `/puzzle edit` command
102 As dispatched from the puzzle() function.
105 channel_id = body['channel_id'][0]
106 trigger_id = body['trigger_id'][0]
108 puzzle = puzzle_for_channel(turb, channel_id)
111 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
113 return edit_puzzle(turb, puzzle, trigger_id)
115 def edit_puzzle_button(turb, payload):
116 """Handler for the action of user pressing an edit_puzzle button"""
118 action_id = payload['actions'][0]['action_id']
119 response_url = payload['response_url']
120 trigger_id = payload['trigger_id']
122 (hunt_id, sort_key) = action_id.split('-', 1)
124 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
127 requests.post(response_url,
128 json = {"text": "Error: Puzzle not found!"},
129 headers = {"Content-type": "application/json"})
130 return bot_reply("Error: Puzzle not found.")
132 return edit_puzzle(turb, puzzle, trigger_id)
134 actions['button']['edit_puzzle'] = edit_puzzle_button
136 def edit_puzzle(turb, puzzle, trigger_id):
137 """Common code for implementing an edit puzzle dialog
139 This implementation is common whether the edit operation was invoked
140 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
143 round_options = hunt_rounds(turb, puzzle['hunt_id'])
145 if len(round_options):
146 round_options_block = [
147 multi_select_block("Round(s)", "rounds",
148 "Existing round(s) this puzzle belongs to",
150 initial_options=puzzle.get("rounds", None)),
153 round_options_block = []
156 if puzzle.get("status", "unsolved") == solved:
160 solution_list = puzzle.get("solution", [])
162 solution_str = ", ".join(solution_list)
166 "private_metadata": json.dumps({
167 "hunt_id": puzzle['hunt_id'],
169 "puzzle_id": puzzle['puzzle_id'],
170 "channel_id": puzzle["channel_id"],
171 "channel_url": puzzle["channel_url"],
172 "sheet_url": puzzle["sheet_url"],
174 "title": {"type": "plain_text", "text": "Edit Puzzle"},
175 "submit": { "type": "plain_text", "text": "Save" },
177 input_block("Puzzle name", "name", "Name of the puzzle",
178 initial_value=puzzle["name"]),
179 input_block("Puzzle URL", "url", "External URL of puzzle",
180 initial_value=puzzle.get("url", None),
182 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
183 checked=(puzzle.get('type', 'plain') == 'meta')),
184 * round_options_block,
185 input_block("New round(s)", "new_rounds",
186 "New round(s) this puzzle belongs to " +
189 input_block("State", "state",
190 "State of this puzzle (partial progress, next steps)",
191 initial_value=puzzle.get("state", None),
194 "Puzzle status", "Solved", "solved",
195 checked=(puzzle.get('status', 'unsolved') == 'solved')),
196 input_block("Solution", "solution",
197 "Solution(s) (comma-separated if multiple)",
198 initial_value=solution_str,
203 result = turb.slack_client.views_open(trigger_id=trigger_id,
207 submission_handlers[result['view']['id']] = edit_puzzle_submission
211 def edit_puzzle_submission(turb, payload, metadata):
212 """Handler for the user submitting the edit puzzle modal
214 This is the modal view presented to the user by the edit_puzzle
220 # First, read all the various data from the request
221 meta = json.loads(metadata)
222 puzzle['hunt_id'] = meta['hunt_id']
223 puzzle['SK'] = meta['SK']
224 puzzle['puzzle_id'] = meta['puzzle_id']
225 puzzle['channel_id'] = meta['channel_id']
226 puzzle['channel_url'] = meta['channel_url']
227 puzzle['sheet_url'] = meta['sheet_url']
229 state = payload['view']['state']['values']
230 user_id = payload['user']['id']
232 puzzle['name'] = state['name']['name']['value']
233 url = state['url']['url']['value']
236 if state['meta']['meta']['selected_options']:
237 puzzle['type'] = 'meta'
239 puzzle['type'] = 'plain'
240 rounds = [option['value'] for option in
241 state['rounds']['rounds']['selected_options']]
243 puzzle['rounds'] = rounds
244 new_rounds = state['new_rounds']['new_rounds']['value']
245 puzzle_state = state['state']['state']['value']
247 puzzle['state'] = puzzle_state
248 if state['solved']['solved']['selected_options']:
249 puzzle['status'] = 'solved'
251 puzzle['status'] = 'unsolved'
252 puzzle['solution'] = []
253 solution = state['solution']['solution']['value']
255 puzzle['solution'] = [
256 sol.strip() for sol in solution.split(',')
259 # Verify that there's a solution if the puzzle is mark solved
260 if puzzle['status'] == 'solved' and not puzzle['solution']:
261 return submission_error("solution",
262 "A solved puzzle requires a solution.")
264 if puzzle['status'] == 'unsolved' and puzzle['solution']:
265 return submission_error("solution",
266 "An unsolved puzzle should have no solution.")
268 # Add any new rounds to the database
270 if 'rounds' not in puzzle:
271 puzzle['rounds'] = []
272 for round in new_rounds.split(','):
273 # Drop any leading/trailing spaces from the round name
274 round = round.strip()
275 # Ignore any empty string
278 puzzle['rounds'].append(round)
281 'hunt_id': puzzle['hunt_id'],
282 'SK': 'round-' + round
286 # Get old puzzle from the database (to determine what's changed)
287 old_puzzle = find_puzzle_for_sort_key(turb,
291 # If we are changing puzzle type (meta -> plain or plain -> meta)
292 # the the sort key has to change, so compute the new one and delete
293 # the old item from the database.
295 # XXX: We should really be using a transaction here to combine the
296 # delete_item and the put_item into a single transaction, but
297 # the boto interface is annoying in that transactions are only on
298 # the "Client" object which has a totally different interface than
299 # the "Table" object I've been using so I haven't figured out how
302 if puzzle['type'] != old_puzzle.get('type', 'plain'):
303 puzzle['SK'] = puzzle_sort_key(puzzle)
304 turb.table.delete_item(Key={
305 'hunt_id': old_puzzle['hunt_id'],
306 'SK': old_puzzle['SK']
309 # Update the puzzle in the database
310 turb.table.put_item(Item=puzzle)
312 # Inform the puzzle channel about the edit
313 edit_message = "Puzzle edited by <@{}>".format(user_id)
314 blocks = ([section_block(text_block(edit_message+":\n"))] +
315 puzzle_blocks(puzzle, include_rounds=True))
317 turb.slack_client, puzzle['channel_id'],
318 edit_message, blocks=blocks)
320 # Also inform the hunt if the puzzle's solved status changed
321 if puzzle['status'] != old_puzzle['status']:
322 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
323 if puzzle['status'] == 'solved':
324 message = "Puzzle <{}|{}> has been solved!".format(
325 puzzle['channel_url'],
328 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
329 puzzle['channel_url'],
331 slack_send_message(turb.slack_client, hunt['channel_id'], message)
333 # We need to set the channel topic if any of puzzle name, url,
334 # state, status, or solution, has changed. Let's just do that
335 # unconditionally here.
336 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
340 def edit_hunt_command(turb, body):
341 """Implementation of the `/hunt edit` command
343 As dispatched from the hunt() function.
346 channel_id = body['channel_id'][0]
347 trigger_id = body['trigger_id'][0]
349 hunt = hunt_for_channel(turb, channel_id)
352 return bot_reply("Sorry, this does not appear to be a hunt channel.")
354 return edit_hunt(turb, hunt, trigger_id)
356 def edit_hunt_button(turb, payload):
357 """Handler for the action of user pressing an edit_hunt button"""
359 hunt_id = payload['actions'][0]['action_id']
360 response_url = payload['response_url']
361 trigger_id = payload['trigger_id']
363 hunt = find_hunt_for_hunt_id(hunt_id)
366 requests.post(response_url,
367 json = {"text": "Error: Hunt not found!"},
368 headers = {"Content-type": "application/json"})
369 return bot_reply("Error: Hunt not found.")
371 return edit_hunt(turb, hunt, trigger_id)
373 actions['button']['edit_hunt'] = edit_hunt_button
375 def edit_hunt(turb, hunt, trigger_id):
376 """Common code for implementing an edit hunt dialog
378 This implementation is common whether the edit operation was invoked
379 by a button (edit_hunt_button) or a command (edit_hunt_command).
384 "private_metadata": json.dumps({
385 "hunt_id": hunt["hunt_id"],
387 "is_hunt": hunt["is_hunt"],
388 "channel_id": hunt["channel_id"],
389 "sheet_url": hunt["sheet_url"],
390 "folder_id": hunt["folder_id"],
392 "title": { "type": "plain_text", "text": "Edit Hunt" },
393 "submit": { "type": "plain_text", "text": "Save" },
395 input_block("Hunt name", "name", "Name of the hunt",
396 initial_value=hunt["name"]),
397 input_block("Hunt URL", "url", "External URL of hunt",
398 initial_value=hunt.get("url", None),
400 checkbox_block("Is this hunt active?", "Active", "active",
401 checked=(hunt.get('active', False)))
405 result = turb.slack_client.views_open(trigger_id=trigger_id,
409 submission_handlers[result['view']['id']] = edit_hunt_submission
413 def edit_hunt_submission(turb, payload, metadata):
414 """Handler for the user submitting the edit hunt modal
416 This is the modal view presented by the edit_hunt function above.
421 # First, read all the various data from the request
422 meta = json.loads(metadata)
423 hunt['hunt_id'] = meta['hunt_id']
424 hunt['SK'] = meta['SK']
425 hunt['is_hunt'] = meta['is_hunt']
426 hunt['channel_id'] = meta['channel_id']
427 hunt['sheet_url'] = meta['sheet_url']
428 hunt['folder_id'] = meta['folder_id']
430 state = payload['view']['state']['values']
431 user_id = payload['user']['id']
433 hunt['name'] = state['name']['name']['value']
434 url = state['url']['url']['value']
438 if state['active']['active']['selected_options']:
439 hunt['active'] = True
441 hunt['active'] = False
443 # Update the hunt in the database
444 turb.table.put_item(Item=hunt)
446 # Inform the hunt channel about the edit
447 edit_message = "Hunt edited by <@{}>".format(user_id)
449 section_block(text_block(edit_message)),
450 section_block(text_block("Hunt name: {}".format(hunt['name']))),
453 url = hunt.get('url', None)
456 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
460 turb.slack_client, hunt['channel_id'],
461 edit_message, blocks=blocks)
465 def new_hunt_command(turb, body):
466 """Implementation of the '/hunt new' command
468 As dispatched from the hunt() function.
471 trigger_id = body['trigger_id'][0]
473 return new_hunt(turb, trigger_id)
475 def new_hunt_button(turb, payload):
476 """Handler for the action of user pressing the new_hunt button"""
478 trigger_id = payload['trigger_id']
480 return new_hunt(turb, trigger_id)
482 def new_hunt(turb, trigger_id):
483 """Common code for implementing a new hunt dialog
485 This implementation is common whether the operations was invoked
486 by a button (new_hunt_button) or a command (new_hunt_command).
491 "private_metadata": json.dumps({}),
492 "title": { "type": "plain_text", "text": "New Hunt" },
493 "submit": { "type": "plain_text", "text": "Create" },
495 input_block("Hunt name", "name", "Name of the hunt"),
496 input_block("Hunt ID", "hunt_id",
497 "Used as puzzle channel prefix "
498 + "(no spaces nor punctuation)"),
499 input_block("Hunt URL", "url", "External URL of hunt",
504 result = turb.slack_client.views_open(trigger_id=trigger_id,
507 submission_handlers[result['view']['id']] = new_hunt_submission
511 actions['button']['new_hunt'] = new_hunt
513 def new_hunt_submission(turb, payload, metadata):
514 """Handler for the user submitting the new hunt modal
516 This is the modal view presented to the user by the new_hunt
519 state = payload['view']['state']['values']
520 user_id = payload['user']['id']
521 name = state['name']['name']['value']
522 hunt_id = state['hunt_id']['hunt_id']['value']
523 url = state['url']['url']['value']
525 # Validate that the hunt_id contains no invalid characters
526 if not re.match(valid_id_re, hunt_id):
527 return submission_error("hunt_id",
528 "Hunt ID can only contain lowercase letters, "
529 + "numbers, and underscores")
531 # Check to see if the turbot table exists
533 exists = turb.table.table_status in ("CREATING", "UPDATING",
538 # Create the turbot table if necessary.
540 turb.table = turb.db.create_table(
543 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
544 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
546 AttributeDefinitions=[
547 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
548 {'AttributeName': 'SK', 'AttributeType': 'S'},
549 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
550 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
551 {'AttributeName': 'url', 'AttributeType': 'S'}
553 ProvisionedThroughput={
554 'ReadCapacityUnits': 5,
555 'WriteCapacityUnits': 5
557 GlobalSecondaryIndexes=[
559 'IndexName': 'channel_id_index',
561 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
564 'ProjectionType': 'ALL'
566 'ProvisionedThroughput': {
567 'ReadCapacityUnits': 5,
568 'WriteCapacityUnits': 5
572 'IndexName': 'is_hunt_index',
574 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
577 'ProjectionType': 'ALL'
579 'ProvisionedThroughput': {
580 'ReadCapacityUnits': 5,
581 'WriteCapacityUnits': 5
585 LocalSecondaryIndexes = [
587 'IndexName': 'url_index',
589 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
590 {'AttributeName': 'url', 'KeyType': 'RANGE'},
593 'ProjectionType': 'ALL'
598 return submission_error(
600 "Still bootstrapping turbot table. Try again in a minute, please.")
602 # Create a channel for the hunt
604 response = turb.slack_client.conversations_create(name=hunt_id)
605 except SlackApiError as e:
606 return submission_error("hunt_id",
607 "Error creating Slack channel: {}"
608 .format(e.response['error']))
610 channel_id = response['channel']['id']
612 # Insert the newly-created hunt into the database
613 # (leaving it as non-active for now until the channel-created handler
614 # finishes fixing it up with a sheet and a companion table)
617 "SK": "hunt-{}".format(hunt_id),
619 "channel_id": channel_id,
625 turb.table.put_item(Item=item)
627 # Invite the initiating user to the channel
628 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
632 def view_submission(turb, payload):
633 """Handler for Slack interactive view submission
635 Specifically, those that have a payload type of 'view_submission'"""
637 view_id = payload['view']['id']
638 metadata = payload['view']['private_metadata']
640 if view_id in submission_handlers:
641 return submission_handlers[view_id](turb, payload, metadata)
643 print("Error: Unknown view ID: {}".format(view_id))
648 def rot(turb, body, args):
649 """Implementation of the /rot command
651 The args string should be as follows:
653 [count|*] String to be rotated
655 That is, the first word of the string is an optional number (or
656 the character '*'). If this is a number it indicates an amount to
657 rotate each character in the string. If the count is '*' or is not
658 present, then the string will be rotated through all possible 25
661 The result of the rotation is returned (with Slack formatting) in
662 the body of the response so that Slack will provide it as a reply
663 to the user who submitted the slash command."""
665 channel_name = body['channel_name'][0]
666 response_url = body['response_url'][0]
667 channel_id = body['channel_id'][0]
669 result = turbot.rot.rot(args)
671 if (channel_name == "directmessage"):
672 requests.post(response_url,
673 json = {"text": result},
674 headers = {"Content-type": "application/json"})
676 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
680 commands["/rot"] = rot
682 def get_table_item(turb, table_name, key, value):
683 """Get an item from the database 'table_name' with 'key' as 'value'
685 Returns a tuple of (item, table) if found and (None, None) otherwise."""
687 table = turb.db.Table(table_name)
689 response = table.get_item(Key={key: value})
691 if 'Item' in response:
692 return (response['Item'], table)
696 def db_entry_for_channel(turb, channel_id):
697 """Given a channel ID return the database item for this channel
699 If this channel is a registered hunt or puzzle channel, return the
700 corresponding row from the database for this channel. Otherwise,
703 Note: If you need to specifically ensure that the channel is a
704 puzzle or a hunt, please call puzzle_for_channel or
705 hunt_for_channel respectively.
708 response = turb.table.query(
709 IndexName = "channel_id_index",
710 KeyConditionExpression=Key("channel_id").eq(channel_id)
713 if response['Count'] == 0:
716 return response['Items'][0]
719 def puzzle_for_channel(turb, channel_id):
721 """Given a channel ID return the puzzle from the database for this channel
723 If the given channel_id is a puzzle's channel, this function
724 returns a dict filled with the attributes from the puzzle's entry
727 Otherwise, this function returns None.
730 entry = db_entry_for_channel(turb, channel_id)
732 if entry and entry['SK'].startswith('puzzle-'):
737 def hunt_for_channel(turb, channel_id):
739 """Given a channel ID return the hunt from the database for this channel
741 This works whether the original channel is a primary hunt channel,
742 or if it is one of the channels of a puzzle belonging to the hunt.
744 Returns None if channel does not belong to a hunt, otherwise a
745 dictionary with all fields from the hunt's row in the table,
746 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
749 entry = db_entry_for_channel(turb, channel_id)
751 # We're done if this channel doesn't exist in the database at all
755 # Also done if this channel is a hunt channel
756 if entry['SK'].startswith('hunt-'):
759 # Otherwise, (the channel is in the database, but is not a hunt),
760 # we expect this to be a puzzle channel instead
761 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
763 # python3.9 has a built-in removeprefix but AWS only has python3.8
764 def remove_prefix(text, prefix):
765 if text.startswith(prefix):
766 return text[len(prefix):]
769 def hunt_rounds(turb, hunt_id):
770 """Returns array of strings giving rounds that exist in the given hunt"""
772 response = turb.table.query(
773 KeyConditionExpression=(
774 Key('hunt_id').eq(hunt_id) &
775 Key('SK').begins_with('round-')
779 if response['Count'] == 0:
782 return [remove_prefix(option['SK'], 'round-')
783 for option in response['Items']]
785 def puzzle(turb, body, args):
786 """Implementation of the /puzzle command
788 The args string can be a sub-command:
790 /puzzle new: Bring up a dialog to create a new puzzle
792 /puzzle edit: Edit the puzzle for the current channel
794 Or with no argument at all:
796 /puzzle: Print details of the current puzzle (if in a puzzle channel)
800 return new_puzzle(turb, body)
803 return edit_puzzle_command(turb, body)
806 return bot_reply("Unknown syntax for `/puzzle` command. " +
807 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
808 "and `/puzzle new` to display, edit, or create " +
811 # For no arguments we print the current puzzle as a reply
812 channel_id = body['channel_id'][0]
813 response_url = body['response_url'][0]
815 puzzle = puzzle_for_channel(turb, channel_id)
818 hunt = hunt_for_channel(turb, channel_id)
821 "This is not a puzzle channel, but is a hunt channel. "
822 + "If you want to create a new puzzle for this hunt, use "
826 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
827 + "channel, so the `/puzzle` command cannot work here.")
829 blocks = puzzle_blocks(puzzle, include_rounds=True)
831 # For a meta puzzle, also display the titles and solutions for all
832 # puzzles in the same round.
833 if puzzle.get('type', 'plain') == 'meta':
834 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
836 # Drop this puzzle itself from the report
837 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
839 for round in puzzle.get('rounds', [None]):
840 answers = round_quoted_puzzles_titles_answers(round, puzzles)
842 section_block(text_block(
843 "*Feeder solutions from round {}*".format(
844 round if round else "<none>"
846 section_block(text_block(answers))
849 requests.post(response_url,
850 json = {'blocks': blocks},
851 headers = {'Content-type': 'application/json'}
856 commands["/puzzle"] = puzzle
858 def new(turb, body, args):
859 """Implementation of the `/new` command
861 This can be used to create a new hunt ("/new hunt") or a new
862 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
863 default behavior (as it is much more common).
865 This operations are identical to the existing "/hunt new" and
866 "/puzzle new". I don't know that that redundancy is actually
867 helpful in the interface. But at least having both allows us to
868 experiment and decide which is more natural and should be kept
873 return new_hunt_command(turb, body)
875 return new_puzzle(turb, body)
877 commands["/new"] = new
879 def new_puzzle(turb, body):
880 """Implementation of the "/puzzle new" command
882 This brings up a dialog box for creating a new puzzle.
885 channel_id = body['channel_id'][0]
886 trigger_id = body['trigger_id'][0]
888 hunt = hunt_for_channel(turb, channel_id)
891 return bot_reply("Sorry, this channel doesn't appear to "
892 + "be a hunt or puzzle channel")
894 round_options = hunt_rounds(turb, hunt['hunt_id'])
896 if len(round_options):
897 round_options_block = [
898 multi_select_block("Round(s)", "rounds",
899 "Existing round(s) this puzzle belongs to",
903 round_options_block = []
907 "private_metadata": json.dumps({
908 "hunt_id": hunt['hunt_id'],
910 "title": {"type": "plain_text", "text": "New Puzzle"},
911 "submit": { "type": "plain_text", "text": "Create" },
913 section_block(text_block("*For {}*".format(hunt['name']))),
914 input_block("Puzzle name", "name", "Name of the puzzle"),
915 input_block("Puzzle URL", "url", "External URL of puzzle",
917 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
918 * round_options_block,
919 input_block("New round(s)", "new_rounds",
920 "New round(s) this puzzle belongs to " +
926 result = turb.slack_client.views_open(trigger_id=trigger_id,
930 submission_handlers[result['view']['id']] = new_puzzle_submission
934 def new_puzzle_submission(turb, payload, metadata):
935 """Handler for the user submitting the new puzzle modal
937 This is the modal view presented to the user by the new_puzzle
941 # First, read all the various data from the request
942 meta = json.loads(metadata)
943 hunt_id = meta['hunt_id']
945 state = payload['view']['state']['values']
946 name = state['name']['name']['value']
947 url = state['url']['url']['value']
948 if state['meta']['meta']['selected_options']:
951 puzzle_type = 'plain'
952 if 'rounds' in state:
953 rounds = [option['value'] for option in
954 state['rounds']['rounds']['selected_options']]
957 new_rounds = state['new_rounds']['new_rounds']['value']
959 # Before doing anything, reject this puzzle if a puzzle already
960 # exists with the same URL.
962 existing = find_puzzle_for_url(turb, hunt_id, url)
964 return submission_error(
966 "Error: A puzzle with this URL already exists.")
968 # Create a Slack-channel-safe puzzle_id
969 puzzle_id = puzzle_id_from_name(name)
971 # Create a channel for the puzzle
972 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
975 response = turb.slack_client.conversations_create(
976 name=hunt_dash_channel)
977 except SlackApiError as e:
978 return submission_error(
980 "Error creating Slack channel {}: {}"
981 .format(hunt_dash_channel, e.response['error']))
983 channel_id = response['channel']['id']
985 # Add any new rounds to the database
987 for round in new_rounds.split(','):
988 # Drop any leading/trailing spaces from the round name
989 round = round.strip()
990 # Ignore any empty string
997 'SK': 'round-' + round
1001 # Construct a puzzle dict
1004 "puzzle_id": puzzle_id,
1005 "channel_id": channel_id,
1007 "status": 'unsolved',
1014 puzzle['rounds'] = rounds
1016 # Finally, compute the appropriate sort key
1017 puzzle["SK"] = puzzle_sort_key(puzzle)
1019 # Insert the newly-created puzzle into the database
1020 turb.table.put_item(Item=puzzle)
1024 def state(turb, body, args):
1025 """Implementation of the /state command
1027 The args string should be a brief sentence describing where things
1028 stand or what's needed."""
1030 channel_id = body['channel_id'][0]
1032 old_puzzle = puzzle_for_channel(turb, channel_id)
1036 "Sorry, the /state command only works in a puzzle channel")
1038 # Make a deep copy of the puzzle object
1039 puzzle = puzzle_copy(old_puzzle)
1041 # Update the puzzle in the database
1042 puzzle['state'] = args
1043 turb.table.put_item(Item=puzzle)
1045 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1049 commands["/state"] = state
1051 def tag(turb, body, args):
1052 """Implementation of the `/tag` command.
1054 Arg is either a tag to add (optionally prefixed with '+'), or if
1055 prefixed with '-' is a tag to remove.
1059 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1060 + "or `/tag -TAG_TO_REMOVE`.")
1062 channel_id = body['channel_id'][0]
1064 old_puzzle = puzzle_for_channel(turb, channel_id)
1068 "Sorry, the /tag command only works in a puzzle channel")
1079 # Force tag to all uppercase
1082 # Reject a tag that is not alphabetic or underscore A-Z_
1083 if not re.match(r'^[A-Z0-9_]*$', tag):
1084 return bot_reply("Sorry, tags can only contain letters, numbers, "
1085 + "and the underscore character.")
1087 if action == 'remove':
1088 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1089 return bot_reply("Nothing to do. This puzzle is not tagged "
1090 + "with the tag: {}".format(tag))
1092 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1093 return bot_reply("Nothing to do. This puzzle is already tagged "
1094 + "with the tag: {}".format(tag))
1096 # OK. Error checking is done. Let's get to work
1098 # Make a deep copy of the puzzle object
1099 puzzle = puzzle_copy(old_puzzle)
1101 if action == 'remove':
1102 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1104 if 'tags' not in puzzle:
1105 puzzle['tags'] = [tag]
1107 puzzle['tags'].append(tag)
1109 turb.table.put_item(Item=puzzle)
1111 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1115 commands["/tag"] = tag
1117 def solved(turb, body, args):
1118 """Implementation of the /solved command
1120 The args string should be a confirmed solution."""
1122 channel_id = body['channel_id'][0]
1123 user_id = body['user_id'][0]
1125 old_puzzle = puzzle_for_channel(turb, channel_id)
1128 return bot_reply("Sorry, this is not a puzzle channel.")
1132 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1134 # Make a deep copy of the puzzle object
1135 puzzle = puzzle_copy(old_puzzle)
1137 # Set the status and solution fields in the database
1138 puzzle['status'] = 'solved'
1139 puzzle['solution'].append(args)
1140 if 'state' in puzzle:
1142 turb.table.put_item(Item=puzzle)
1144 # Report the solution to the puzzle's channel
1146 turb.slack_client, channel_id,
1147 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1149 # Also report the solution to the hunt channel
1150 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1152 turb.slack_client, hunt['channel_id'],
1153 "Puzzle <{}|{}> has been solved!".format(
1154 puzzle['channel_url'],
1158 # And update the puzzle's description
1159 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1163 commands["/solved"] = solved
1165 def hunt(turb, body, args):
1166 """Implementation of the /hunt command
1168 The (optional) args string can be used to filter which puzzles to
1169 display. The first word can be one of 'all', 'unsolved', or
1170 'solved' and can be used to display only puzzles with the given
1171 status. If this first word is missing, this command will display
1172 only unsolved puzzles by default.
1174 Any remaining text in the args string will be interpreted as
1175 search terms. These will be split into separate terms on space
1176 characters, (though quotation marks can be used to include a space
1177 character in a term). All terms must match on a puzzle in order
1178 for that puzzle to be included. But a puzzle will be considered to
1179 match if any of the puzzle title, round title, puzzle URL, puzzle
1180 state, puzzle type, tags, or puzzle solution match. Matching will
1181 be performed without regard to case sensitivity and the search
1182 terms can include regular expression syntax.
1186 channel_id = body['channel_id'][0]
1187 response_url = body['response_url'][0]
1189 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1191 return new_hunt_command(turb, body)
1194 return edit_hunt_command(turb, body)
1198 # The first word can be a puzzle status and all remaining word
1199 # (if any) are search terms. _But_, if the first word is not a
1200 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1201 # words are search terms and we default status to 'unsolved'.
1202 split_args = args.split(' ', 1)
1203 status = split_args[0]
1204 if (len(split_args) > 1):
1205 terms = split_args[1]
1206 if status not in ('unsolved', 'solved', 'all'):
1212 # Separate search terms on spaces (but allow for quotation marks
1213 # to capture spaces in a search term)
1215 terms = shlex.split(terms)
1217 hunt = hunt_for_channel(turb, channel_id)
1220 return bot_reply("Sorry, this channel doesn't appear to "
1221 + "be a hunt or puzzle channel")
1223 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1225 requests.post(response_url,
1226 json = { 'blocks': blocks },
1227 headers = {'Content-type': 'application/json'}
1232 commands["/hunt"] = hunt
1234 def round(turb, body, args):
1235 """Implementation of the /round command
1237 Displays puzzles in the same round(s) as the puzzle for the
1240 The (optional) args string can be used to filter which puzzles to
1241 display. The first word can be one of 'all', 'unsolved', or
1242 'solved' and can be used to display only puzzles with the given
1243 status. If this first word is missing, this command will display
1244 all puzzles in the round by default.
1246 Any remaining text in the args string will be interpreted as
1247 search terms. These will be split into separate terms on space
1248 characters, (though quotation marks can be used to include a space
1249 character in a term). All terms must match on a puzzle in order
1250 for that puzzle to be included. But a puzzle will be considered to
1251 match if any of the puzzle title, round title, puzzle URL, puzzle
1252 state, or puzzle solution match. Matching will be performed
1253 without regard to case sensitivity and the search terms can
1254 include regular expression syntax.
1257 channel_id = body['channel_id'][0]
1258 response_url = body['response_url'][0]
1260 puzzle = puzzle_for_channel(turb, channel_id)
1261 hunt = hunt_for_channel(turb, channel_id)
1266 "This is not a puzzle channel, but is a hunt channel. "
1267 + "Use /hunt if you want to see all rounds for this hunt.")
1270 "Sorry, this channel doesn't appear to be a puzzle channel "
1271 + "so the `/round` command cannot work here.")
1275 # The first word can be a puzzle status and all remaining word
1276 # (if any) are search terms. _But_, if the first word is not a
1277 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1278 # words are search terms and we default status to 'unsolved'.
1279 split_args = args.split(' ', 1)
1280 status = split_args[0]
1281 if (len(split_args) > 1):
1282 terms = split_args[1]
1283 if status not in ('unsolved', 'solved', 'all'):
1289 # Separate search terms on spaces (but allow for quotation marks
1290 # to capture spaces in a search term)
1292 terms = shlex.split(terms)
1294 blocks = hunt_blocks(turb, hunt,
1295 puzzle_status=status, search_terms=terms,
1296 limit_to_rounds=puzzle.get('rounds', [])
1299 requests.post(response_url,
1300 json = { 'blocks': blocks },
1301 headers = {'Content-type': 'application/json'}
1306 commands["/round"] = round