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 find_hunt_for_hunt_id, hunt_blocks
6 from turbot.puzzle import (
8 find_puzzle_for_puzzle_id,
9 puzzle_update_channel_and_sheet,
19 from botocore.exceptions import ClientError
20 from boto3.dynamodb.conditions import Key
21 from turbot.slack import slack_send_message
25 actions['button'] = {}
27 submission_handlers = {}
29 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
31 # Note: This restriction not only allows for hunt and puzzle ID values to
32 # be used as Slack channel names, but it also allows for '-' as a valid
33 # separator between a hunt and a puzzle ID (for example in the puzzle
34 # edit dialog where a single attribute must capture both values).
35 valid_id_re = r'^[_a-z0-9]+$'
37 lambda_ok = {'statusCode': 200}
39 def bot_reply(message):
40 """Construct a return value suitable for a bot reply
42 This is suitable as a way to give an error back to the user who
43 initiated a slash command, for example."""
50 def submission_error(field, error):
51 """Construct an error suitable for returning for an invalid submission.
53 Returning this value will prevent a submission and alert the user that
54 the given field is invalid because of the given error."""
56 print("Rejecting invalid modal submission: {}".format(error))
61 "Content-Type": "application/json"
64 "response_action": "errors",
71 def multi_static_select(turb, payload):
72 """Handler for the action of user entering a multi-select value"""
76 actions['multi_static_select'] = {"*": multi_static_select}
78 def edit_puzzle_command(turb, body):
79 """Implementation of the `/puzzle edit` command
81 As dispatched from the puzzle() function.
84 channel_id = body['channel_id'][0]
85 trigger_id = body['trigger_id'][0]
87 puzzle = puzzle_for_channel(turb, channel_id)
90 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
92 return edit_puzzle(turb, puzzle, trigger_id)
96 def edit_puzzle_button(turb, payload):
97 """Handler for the action of user pressing an edit_puzzle button"""
99 action_id = payload['actions'][0]['action_id']
100 response_url = payload['response_url']
101 trigger_id = payload['trigger_id']
103 (hunt_id, puzzle_id) = action_id.split('-', 1)
105 puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
108 requests.post(response_url,
109 json = {"text": "Error: Puzzle not found!"},
110 headers = {"Content-type": "application/json"})
111 return bot_reply("Error: Puzzle not found.")
113 return edit_puzzle(turb, puzzle, trigger_id)
115 actions['button']['edit_puzzle'] = edit_puzzle_button
117 def edit_puzzle(turb, puzzle, trigger_id):
118 """Common code for implementing an edit puzzle dialog
120 This implementation is common whether the edit operation was invoked
121 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
124 round_options = hunt_rounds(turb, puzzle['hunt_id'])
126 if len(round_options):
127 round_options_block = [
128 multi_select_block("Round(s)", "rounds",
129 "Existing round(s) this puzzle belongs to",
131 initial_options=puzzle.get("rounds", None)),
134 round_options_block = []
137 if puzzle.get("status", "unsolved") == solved:
141 solution_list = puzzle.get("solution", [])
143 solution_str = ", ".join(solution_list)
147 "private_metadata": json.dumps({
148 "hunt_id": puzzle['hunt_id'],
150 "puzzle_id": puzzle['puzzle_id'],
151 "channel_id": puzzle["channel_id"],
152 "channel_url": puzzle["channel_url"],
153 "sheet_url": puzzle["sheet_url"],
155 "title": {"type": "plain_text", "text": "Edit Puzzle"},
156 "submit": { "type": "plain_text", "text": "Save" },
158 input_block("Puzzle name", "name", "Name of the puzzle",
159 initial_value=puzzle["name"]),
160 input_block("Puzzle URL", "url", "External URL of puzzle",
161 initial_value=puzzle.get("url", None),
163 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
164 checked=(puzzle.get('type', 'plain') == 'meta')),
165 * round_options_block,
166 input_block("New round(s)", "new_rounds",
167 "New round(s) this puzzle belongs to " +
170 input_block("State", "state",
171 "State of this puzzle (partial progress, next steps)",
172 initial_value=puzzle.get("state", None),
175 "Puzzle status", "Solved", "solved",
176 checked=(puzzle.get('status', 'unsolved') == 'solved')),
177 input_block("Solution", "solution",
178 "Solution(s) (comma-separated if multiple)",
179 initial_value=solution_str,
184 result = turb.slack_client.views_open(trigger_id=trigger_id,
188 submission_handlers[result['view']['id']] = edit_puzzle_submission
192 def edit_puzzle_submission(turb, payload, metadata):
193 """Handler for the user submitting the edit puzzle modal
195 This is the modal view presented to the user by the edit_puzzle
201 # First, read all the various data from the request
202 meta = json.loads(metadata)
203 puzzle['hunt_id'] = meta['hunt_id']
204 puzzle['SK'] = meta['SK']
205 puzzle['puzzle_id'] = meta['puzzle_id']
206 puzzle['channel_id'] = meta['channel_id']
207 puzzle['channel_url'] = meta['channel_url']
208 puzzle['sheet_url'] = meta['sheet_url']
210 state = payload['view']['state']['values']
211 user_id = payload['user']['id']
213 puzzle['name'] = state['name']['name']['value']
214 url = state['url']['url']['value']
217 if state['meta']['meta']['selected_options']:
218 puzzle['type'] = 'meta'
220 puzzle['type'] = 'plain'
221 rounds = [option['value'] for option in
222 state['rounds']['rounds']['selected_options']]
224 puzzle['rounds'] = rounds
225 new_rounds = state['new_rounds']['new_rounds']['value']
226 puzzle_state = state['state']['state']['value']
228 puzzle['state'] = puzzle_state
229 if state['solved']['solved']['selected_options']:
230 puzzle['status'] = 'solved'
232 puzzle['status'] = 'unsolved'
233 puzzle['solution'] = []
234 solution = state['solution']['solution']['value']
236 puzzle['solution'] = [
237 sol.strip() for sol in solution.split(',')
240 # Verify that there's a solution if the puzzle is mark solved
241 if puzzle['status'] == 'solved' and not puzzle['solution']:
242 return submission_error("solution",
243 "A solved puzzle requires a solution.")
245 if puzzle['status'] == 'unsolved' and puzzle['solution']:
246 return submission_error("solution",
247 "An unsolved puzzle should have no solution.")
249 # Add any new rounds to the database
251 if 'rounds' not in puzzle:
252 puzzle['rounds'] = []
253 for round in new_rounds.split(','):
254 # Drop any leading/trailing spaces from the round name
255 round = round.strip()
256 # Ignore any empty string
259 puzzle['rounds'].append(round)
262 'hunt_id': puzzle['hunt_id'],
263 'SK': 'round-' + round
267 # Get old puzzle from the database (to determine what's changed)
268 old_puzzle = find_puzzle_for_puzzle_id(turb,
272 # Update the puzzle in the database
273 turb.table.put_item(Item=puzzle)
275 # Inform the puzzle channel about the edit
276 edit_message = "Puzzle edited by <@{}>".format(user_id)
277 blocks = ([section_block(text_block(edit_message+":\n"))] +
278 puzzle_blocks(puzzle, include_rounds=True))
280 turb.slack_client, puzzle['channel_id'],
281 edit_message, blocks=blocks)
283 # Also inform the hunt if the puzzle's solved status changed
284 if puzzle['status'] != old_puzzle['status']:
285 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
286 if puzzle['status'] == 'solved':
287 message = "Puzzle <{}|{}> has been solved!".format(
288 puzzle['channel_url'],
291 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
292 puzzle['channel_url'],
294 slack_send_message(turb.slack_client, hunt['channel_id'], message)
296 # We need to set the channel topic if any of puzzle name, url,
297 # state, status, or solution, has changed. Let's just do that
298 # unconditionally here.
299 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
303 def new_hunt(turb, payload):
304 """Handler for the action of user pressing the new_hunt button"""
308 "private_metadata": json.dumps({}),
309 "title": { "type": "plain_text", "text": "New Hunt" },
310 "submit": { "type": "plain_text", "text": "Create" },
312 input_block("Hunt name", "name", "Name of the hunt"),
313 input_block("Hunt ID", "hunt_id",
314 "Used as puzzle channel prefix "
315 + "(no spaces nor punctuation)"),
316 input_block("Hunt URL", "url", "External URL of hunt",
321 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
324 submission_handlers[result['view']['id']] = new_hunt_submission
328 actions['button']['new_hunt'] = new_hunt
330 def new_hunt_submission(turb, payload, metadata):
331 """Handler for the user submitting the new hunt modal
333 This is the modal view presented to the user by the new_hunt
336 state = payload['view']['state']['values']
337 user_id = payload['user']['id']
338 name = state['name']['name']['value']
339 hunt_id = state['hunt_id']['hunt_id']['value']
340 url = state['url']['url']['value']
342 # Validate that the hunt_id contains no invalid characters
343 if not re.match(valid_id_re, hunt_id):
344 return submission_error("hunt_id",
345 "Hunt ID can only contain lowercase letters, "
346 + "numbers, and underscores")
348 # Check to see if the turbot table exists
350 exists = turb.table.table_status in ("CREATING", "UPDATING",
355 # Create the turbot table if necessary.
357 turb.table = turb.db.create_table(
360 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
361 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
363 AttributeDefinitions=[
364 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
365 {'AttributeName': 'SK', 'AttributeType': 'S'},
366 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
367 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
368 {'AttributeName': 'url', 'AttributeType': 'S'}
370 ProvisionedThroughput={
371 'ReadCapacityUnits': 5,
372 'WriteCapacityUnits': 5
374 GlobalSecondaryIndexes=[
376 'IndexName': 'channel_id_index',
378 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
381 'ProjectionType': 'ALL'
383 'ProvisionedThroughput': {
384 'ReadCapacityUnits': 5,
385 'WriteCapacityUnits': 5
389 'IndexName': 'is_hunt_index',
391 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
394 'ProjectionType': 'ALL'
396 'ProvisionedThroughput': {
397 'ReadCapacityUnits': 5,
398 'WriteCapacityUnits': 5
402 LocalSecondaryIndexes = [
404 'IndexName': 'url_index',
406 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
407 {'AttributeName': 'url', 'KeyType': 'RANGE'},
410 'ProjectionType': 'ALL'
415 return submission_error(
417 "Still bootstrapping turbot table. Try again in a minute, please.")
419 # Create a channel for the hunt
421 response = turb.slack_client.conversations_create(name=hunt_id)
422 except SlackApiError as e:
423 return submission_error("hunt_id",
424 "Error creating Slack channel: {}"
425 .format(e.response['error']))
427 channel_id = response['channel']['id']
429 # Insert the newly-created hunt into the database
430 # (leaving it as non-active for now until the channel-created handler
431 # finishes fixing it up with a sheet and a companion table)
434 "SK": "hunt-{}".format(hunt_id),
436 "channel_id": channel_id,
442 turb.table.put_item(Item=item)
444 # Invite the initiating user to the channel
445 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
449 def view_submission(turb, payload):
450 """Handler for Slack interactive view submission
452 Specifically, those that have a payload type of 'view_submission'"""
454 view_id = payload['view']['id']
455 metadata = payload['view']['private_metadata']
457 if view_id in submission_handlers:
458 return submission_handlers[view_id](turb, payload, metadata)
460 print("Error: Unknown view ID: {}".format(view_id))
465 def rot(turb, body, args):
466 """Implementation of the /rot command
468 The args string should be as follows:
470 [count|*] String to be rotated
472 That is, the first word of the string is an optional number (or
473 the character '*'). If this is a number it indicates an amount to
474 rotate each character in the string. If the count is '*' or is not
475 present, then the string will be rotated through all possible 25
478 The result of the rotation is returned (with Slack formatting) in
479 the body of the response so that Slack will provide it as a reply
480 to the user who submitted the slash command."""
482 channel_name = body['channel_name'][0]
483 response_url = body['response_url'][0]
484 channel_id = body['channel_id'][0]
486 result = turbot.rot.rot(args)
488 if (channel_name == "directmessage"):
489 requests.post(response_url,
490 json = {"text": result},
491 headers = {"Content-type": "application/json"})
493 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
497 commands["/rot"] = rot
499 def get_table_item(turb, table_name, key, value):
500 """Get an item from the database 'table_name' with 'key' as 'value'
502 Returns a tuple of (item, table) if found and (None, None) otherwise."""
504 table = turb.db.Table(table_name)
506 response = table.get_item(Key={key: value})
508 if 'Item' in response:
509 return (response['Item'], table)
513 def db_entry_for_channel(turb, channel_id):
514 """Given a channel ID return the database item for this channel
516 If this channel is a registered hunt or puzzle channel, return the
517 corresponding row from the database for this channel. Otherwise,
520 Note: If you need to specifically ensure that the channel is a
521 puzzle or a hunt, please call puzzle_for_channel or
522 hunt_for_channel respectively.
525 response = turb.table.query(
526 IndexName = "channel_id_index",
527 KeyConditionExpression=Key("channel_id").eq(channel_id)
530 if response['Count'] == 0:
533 return response['Items'][0]
536 def puzzle_for_channel(turb, channel_id):
538 """Given a channel ID return the puzzle from the database for this channel
540 If the given channel_id is a puzzle's channel, this function
541 returns a dict filled with the attributes from the puzzle's entry
544 Otherwise, this function returns None.
547 entry = db_entry_for_channel(turb, channel_id)
549 if entry and entry['SK'].startswith('puzzle-'):
554 def hunt_for_channel(turb, channel_id):
556 """Given a channel ID return the hunt from the database for this channel
558 This works whether the original channel is a primary hunt channel,
559 or if it is one of the channels of a puzzle belonging to the hunt.
561 Returns None if channel does not belong to a hunt, otherwise a
562 dictionary with all fields from the hunt's row in the table,
563 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
566 entry = db_entry_for_channel(turb, channel_id)
568 # We're done if this channel doesn't exist in the database at all
572 # Also done if this channel is a hunt channel
573 if entry['SK'].startswith('hunt-'):
576 # Otherwise, (the channel is in the database, but is not a hunt),
577 # we expect this to be a puzzle channel instead
578 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
580 # python3.9 has a built-in removeprefix but AWS only has python3.8
581 def remove_prefix(text, prefix):
582 if text.startswith(prefix):
583 return text[len(prefix):]
586 def hunt_rounds(turb, hunt_id):
587 """Returns array of strings giving rounds that exist in the given hunt"""
589 response = turb.table.query(
590 KeyConditionExpression=(
591 Key('hunt_id').eq(hunt_id) &
592 Key('SK').begins_with('round-')
596 if response['Count'] == 0:
599 return [remove_prefix(option['SK'], 'round-')
600 for option in response['Items']]
602 def puzzle(turb, body, args):
603 """Implementation of the /puzzle command
605 The args string can be a sub-command:
607 /puzzle new: Bring up a dialog to create a new puzzle
609 /puzzle edit: Edit the puzzle for the current channel
611 Or with no argument at all:
613 /puzzle: Print details of the current puzzle (if in a puzzle channel)
617 return new_puzzle(turb, body)
620 return edit_puzzle_command(turb, body)
623 return bot_reply("Unknown syntax for `/puzzle` command. " +
624 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
625 "and `/puzzle new` to display, edit, or create " +
628 # For no arguments we print the current puzzle as a reply
629 channel_id = body['channel_id'][0]
630 response_url = body['response_url'][0]
632 puzzle = puzzle_for_channel(turb, channel_id)
635 hunt = hunt_for_channel(turb, channel_id)
638 "This is not a puzzle channel, but is a hunt channel. "
639 + "If you want to create a new puzzle for this hunt, use "
643 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
644 + "channel, so the `/puzzle` command cannot work here.")
646 blocks = puzzle_blocks(puzzle, include_rounds=True)
648 requests.post(response_url,
649 json = {'blocks': blocks},
650 headers = {'Content-type': 'application/json'}
655 commands["/puzzle"] = puzzle
657 def new_puzzle(turb, body):
658 """Implementation of the "/puzzle new" command
660 This brings up a dialog box for creating a new puzzle.
663 channel_id = body['channel_id'][0]
664 trigger_id = body['trigger_id'][0]
666 hunt = hunt_for_channel(turb, channel_id)
669 return bot_reply("Sorry, this channel doesn't appear to "
670 + "be a hunt or puzzle channel")
672 round_options = hunt_rounds(turb, hunt['hunt_id'])
674 if len(round_options):
675 round_options_block = [
676 multi_select_block("Round(s)", "rounds",
677 "Existing round(s) this puzzle belongs to",
681 round_options_block = []
685 "private_metadata": json.dumps({
686 "hunt_id": hunt['hunt_id'],
688 "title": {"type": "plain_text", "text": "New Puzzle"},
689 "submit": { "type": "plain_text", "text": "Create" },
691 section_block(text_block("*For {}*".format(hunt['name']))),
692 input_block("Puzzle name", "name", "Name of the puzzle"),
693 input_block("Puzzle URL", "url", "External URL of puzzle",
695 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
696 * round_options_block,
697 input_block("New round(s)", "new_rounds",
698 "New round(s) this puzzle belongs to " +
704 result = turb.slack_client.views_open(trigger_id=trigger_id,
708 submission_handlers[result['view']['id']] = new_puzzle_submission
712 def new_puzzle_submission(turb, payload, metadata):
713 """Handler for the user submitting the new puzzle modal
715 This is the modal view presented to the user by the new_puzzle
719 # First, read all the various data from the request
720 meta = json.loads(metadata)
721 hunt_id = meta['hunt_id']
723 state = payload['view']['state']['values']
724 name = state['name']['name']['value']
725 url = state['url']['url']['value']
726 if state['meta']['meta']['selected_options']:
729 puzzle_type = 'plain'
730 if 'rounds' in state:
731 rounds = [option['value'] for option in
732 state['rounds']['rounds']['selected_options']]
735 new_rounds = state['new_rounds']['new_rounds']['value']
737 # Before doing anything, reject this puzzle if a puzzle already
738 # exists with the same URL.
740 existing = find_puzzle_for_url(turb, hunt_id, url)
742 return submission_error(
744 "Error: A puzzle with this URL already exists.")
746 # Create a Slack-channel-safe puzzle_id
747 puzzle_id = puzzle_id_from_name(name)
749 # Create a channel for the puzzle
750 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
753 response = turb.slack_client.conversations_create(
754 name=hunt_dash_channel)
755 except SlackApiError as e:
756 return submission_error(
758 "Error creating Slack channel {}: {}"
759 .format(hunt_dash_channel, e.response['error']))
761 channel_id = response['channel']['id']
763 # Add any new rounds to the database
765 for round in new_rounds.split(','):
766 # Drop any leading/trailing spaces from the round name
767 round = round.strip()
768 # Ignore any empty string
775 'SK': 'round-' + round
779 # Construct a puzzle dict
782 "SK": "puzzle-{}".format(puzzle_id),
783 "puzzle_id": puzzle_id,
784 "channel_id": channel_id,
786 "status": 'unsolved',
793 puzzle['rounds'] = rounds
795 # Insert the newly-created puzzle into the database
796 turb.table.put_item(Item=puzzle)
800 def state(turb, body, args):
801 """Implementation of the /state command
803 The args string should be a brief sentence describing where things
804 stand or what's needed."""
806 channel_id = body['channel_id'][0]
808 old_puzzle = puzzle_for_channel(turb, channel_id)
812 "Sorry, the /state command only works in a puzzle channel")
814 # Make a copy of the puzzle object
815 puzzle = old_puzzle.copy()
817 # Update the puzzle in the database
818 puzzle['state'] = args
819 turb.table.put_item(Item=puzzle)
821 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
825 commands["/state"] = state
827 def solved(turb, body, args):
828 """Implementation of the /solved command
830 The args string should be a confirmed solution."""
832 channel_id = body['channel_id'][0]
833 user_name = body['user_name'][0]
835 old_puzzle = puzzle_for_channel(turb, channel_id)
838 return bot_reply("Sorry, this is not a puzzle channel.")
842 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
844 # Make a copy of the puzzle object
845 puzzle = old_puzzle.copy()
847 # Set the status and solution fields in the database
848 puzzle['status'] = 'solved'
849 puzzle['solution'].append(args)
850 if 'state' in puzzle:
852 turb.table.put_item(Item=puzzle)
854 # Report the solution to the puzzle's channel
856 turb.slack_client, channel_id,
857 "Puzzle mark solved by {}: `{}`".format(user_name, args))
859 # Also report the solution to the hunt channel
860 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
862 turb.slack_client, hunt['channel_id'],
863 "Puzzle <{}|{}> has been solved!".format(
864 puzzle['channel_url'],
868 # And update the puzzle's description
869 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
873 commands["/solved"] = solved
875 def hunt(turb, body, args):
876 """Implementation of the /hunt command
878 The (optional) args string can be used to filter which puzzles to
879 display. The first word can be one of 'all', 'unsolved', or
880 'solved' and can be used to display only puzzles with the given
881 status. If this first word is missing, this command will display
882 only unsolved puzzles by default.
884 Any remaining text in the args string will be interpreted as
885 search terms. These will be split into separate terms on space
886 characters, (though quotation marks can be used to include a space
887 character in a term). All terms must match on a puzzle in order
888 for that puzzle to be included. But a puzzle will be considered to
889 match if any of the puzzle title, round title, puzzle URL, puzzle
890 state, or puzzle solution match. Matching will be performed
891 without regard to case sensitivity and the search terms can
892 include regular expression syntax.
895 channel_id = body['channel_id'][0]
896 response_url = body['response_url'][0]
900 # The first word can be a puzzle status and all remaining word
901 # (if any) are search terms. _But_, if the first word is not a
902 # valid puzzle status ('all', 'unsolved', 'solved'), then all
903 # words are search terms and we default status to 'unsolved'.
904 split_args = args.split(' ', 1)
905 status = split_args[0]
906 if (len(split_args) > 1):
907 terms = split_args[1]
908 if status not in ('unsolved', 'solved', 'all'):
914 # Separate search terms on spaces (but allow for quotation marks
915 # to capture spaces in a search term)
917 terms = shlex.split(terms)
919 hunt = hunt_for_channel(turb, channel_id)
922 return bot_reply("Sorry, this channel doesn't appear to "
923 + "be a hunt or puzzle channel")
925 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
927 requests.post(response_url,
928 json = { 'blocks': blocks },
929 headers = {'Content-type': 'application/json'}
934 commands["/hunt"] = hunt
936 def round(turb, body, args):
937 """Implementation of the /round command
939 Displays puzzles in the same round(s) as the puzzle for the
942 The (optional) args string can be used to filter which puzzles to
943 display. The first word can be one of 'all', 'unsolved', or
944 'solved' and can be used to display only puzzles with the given
945 status. If this first word is missing, this command will display
946 all puzzles in the round by default.
948 Any remaining text in the args string will be interpreted as
949 search terms. These will be split into separate terms on space
950 characters, (though quotation marks can be used to include a space
951 character in a term). All terms must match on a puzzle in order
952 for that puzzle to be included. But a puzzle will be considered to
953 match if any of the puzzle title, round title, puzzle URL, puzzle
954 state, or puzzle solution match. Matching will be performed
955 without regard to case sensitivity and the search terms can
956 include regular expression syntax.
959 channel_id = body['channel_id'][0]
960 response_url = body['response_url'][0]
962 puzzle = puzzle_for_channel(turb, channel_id)
963 hunt = hunt_for_channel(turb, channel_id)
968 "This is not a puzzle channel, but is a hunt channel. "
969 + "Use /hunt if you want to see all rounds for this hunt.")
972 "Sorry, this channel doesn't appear to be a puzzle channel "
973 + "so the `/round` command cannot work here.")
977 # The first word can be a puzzle status and all remaining word
978 # (if any) are search terms. _But_, if the first word is not a
979 # valid puzzle status ('all', 'unsolved', 'solved'), then all
980 # words are search terms and we default status to 'unsolved'.
981 split_args = args.split(' ', 1)
982 status = split_args[0]
983 if (len(split_args) > 1):
984 terms = split_args[1]
985 if status not in ('unsolved', 'solved', 'all'):
991 # Separate search terms on spaces (but allow for quotation marks
992 # to capture spaces in a search term)
994 terms = shlex.split(terms)
996 blocks = hunt_blocks(turb, hunt,
997 puzzle_status=status, search_terms=terms,
998 limit_to_rounds=puzzle.get('rounds', [])
1001 requests.post(response_url,
1002 json = { 'blocks': blocks },
1003 headers = {'Content-type': 'application/json'}
1008 commands["/round"] = round