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 * round_options_block,
164 input_block("New round(s)", "new_rounds",
165 "New round(s) this puzzle belongs to " +
168 input_block("State", "state",
169 "State of this puzzle (partial progress, next steps)",
170 initial_value=puzzle.get("state", None),
173 "Puzzle status", "Solved", "solved",
174 checked=(puzzle.get('status', 'unsolved') == 'solved')),
175 input_block("Solution", "solution",
176 "Solution(s) (comma-separated if multiple)",
177 initial_value=solution_str,
182 result = turb.slack_client.views_open(trigger_id=trigger_id,
186 submission_handlers[result['view']['id']] = edit_puzzle_submission
190 def edit_puzzle_submission(turb, payload, metadata):
191 """Handler for the user submitting the edit puzzle modal
193 This is the modal view presented to the user by the edit_puzzle
199 # First, read all the various data from the request
200 meta = json.loads(metadata)
201 puzzle['hunt_id'] = meta['hunt_id']
202 puzzle['SK'] = meta['SK']
203 puzzle['puzzle_id'] = meta['puzzle_id']
204 puzzle['channel_id'] = meta['channel_id']
205 puzzle['channel_url'] = meta['channel_url']
206 puzzle['sheet_url'] = meta['sheet_url']
208 state = payload['view']['state']['values']
209 user_id = payload['user']['id']
211 puzzle['name'] = state['name']['name']['value']
212 url = state['url']['url']['value']
215 rounds = [option['value'] for option in
216 state['rounds']['rounds']['selected_options']]
218 puzzle['rounds'] = rounds
219 new_rounds = state['new_rounds']['new_rounds']['value']
220 puzzle_state = state['state']['state']['value']
222 puzzle['state'] = puzzle_state
223 if state['solved']['solved']['selected_options']:
224 puzzle['status'] = 'solved'
226 puzzle['status'] = 'unsolved'
227 puzzle['solution'] = []
228 solution = state['solution']['solution']['value']
230 puzzle['solution'] = [
231 sol.strip() for sol in solution.split(',')
234 # Verify that there's a solution if the puzzle is mark solved
235 if puzzle['status'] == 'solved' and not puzzle['solution']:
236 return submission_error("solution",
237 "A solved puzzle requires a solution.")
239 if puzzle['status'] == 'unsolved' and puzzle['solution']:
240 return submission_error("solution",
241 "An unsolved puzzle should have no solution.")
243 # Add any new rounds to the database
245 if 'rounds' not in puzzle:
246 puzzle['rounds'] = []
247 for round in new_rounds.split(','):
248 # Drop any leading/trailing spaces from the round name
249 round = round.strip()
250 # Ignore any empty string
253 puzzle['rounds'].append(round)
256 'hunt_id': puzzle['hunt_id'],
257 'SK': 'round-' + round
261 # Get old puzzle from the database (to determine what's changed)
262 old_puzzle = find_puzzle_for_puzzle_id(turb,
266 # Update the puzzle in the database
267 turb.table.put_item(Item=puzzle)
269 # Inform the puzzle channel about the edit
270 edit_message = "Puzzle edited by <@{}>".format(user_id)
271 blocks = ([section_block(text_block(edit_message+":\n"))] +
272 puzzle_blocks(puzzle, include_rounds=True))
274 turb.slack_client, puzzle['channel_id'],
275 edit_message, blocks=blocks)
277 # Also inform the hunt if the puzzle's solved status changed
278 if puzzle['status'] != old_puzzle['status']:
279 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
280 if puzzle['status'] == 'solved':
281 message = "Puzzle <{}|{}> has been solved!".format(
282 puzzle['channel_url'],
285 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
286 puzzle['channel_url'],
288 slack_send_message(turb.slack_client, hunt['channel_id'], message)
290 # We need to set the channel topic if any of puzzle name, url,
291 # state, status, or solution, has changed. Let's just do that
292 # unconditionally here.
293 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
297 def new_hunt(turb, payload):
298 """Handler for the action of user pressing the new_hunt button"""
302 "private_metadata": json.dumps({}),
303 "title": { "type": "plain_text", "text": "New Hunt" },
304 "submit": { "type": "plain_text", "text": "Create" },
306 input_block("Hunt name", "name", "Name of the hunt"),
307 input_block("Hunt ID", "hunt_id",
308 "Used as puzzle channel prefix "
309 + "(no spaces nor punctuation)"),
310 input_block("Hunt URL", "url", "External URL of hunt",
315 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
318 submission_handlers[result['view']['id']] = new_hunt_submission
322 actions['button']['new_hunt'] = new_hunt
324 def new_hunt_submission(turb, payload, metadata):
325 """Handler for the user submitting the new hunt modal
327 This is the modal view presented to the user by the new_hunt
330 state = payload['view']['state']['values']
331 user_id = payload['user']['id']
332 name = state['name']['name']['value']
333 hunt_id = state['hunt_id']['hunt_id']['value']
334 url = state['url']['url']['value']
336 # Validate that the hunt_id contains no invalid characters
337 if not re.match(valid_id_re, hunt_id):
338 return submission_error("hunt_id",
339 "Hunt ID can only contain lowercase letters, "
340 + "numbers, and underscores")
342 # Check to see if the turbot table exists
344 exists = turb.table.table_status in ("CREATING", "UPDATING",
349 # Create the turbot table if necessary.
351 turb.table = turb.db.create_table(
354 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
355 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
357 AttributeDefinitions=[
358 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
359 {'AttributeName': 'SK', 'AttributeType': 'S'},
360 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
361 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
362 {'AttributeName': 'url', 'AttributeType': 'S'}
364 ProvisionedThroughput={
365 'ReadCapacityUnits': 5,
366 'WriteCapacityUnits': 5
368 GlobalSecondaryIndexes=[
370 'IndexName': 'channel_id_index',
372 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
375 'ProjectionType': 'ALL'
377 'ProvisionedThroughput': {
378 'ReadCapacityUnits': 5,
379 'WriteCapacityUnits': 5
383 'IndexName': 'is_hunt_index',
385 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
388 'ProjectionType': 'ALL'
390 'ProvisionedThroughput': {
391 'ReadCapacityUnits': 5,
392 'WriteCapacityUnits': 5
396 LocalSecondaryIndexes = [
398 'IndexName': 'url_index',
400 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
401 {'AttributeName': 'url', 'KeyType': 'RANGE'},
404 'ProjectionType': 'ALL'
409 return submission_error(
411 "Still bootstrapping turbot table. Try again in a minute, please.")
413 # Create a channel for the hunt
415 response = turb.slack_client.conversations_create(name=hunt_id)
416 except SlackApiError as e:
417 return submission_error("hunt_id",
418 "Error creating Slack channel: {}"
419 .format(e.response['error']))
421 channel_id = response['channel']['id']
423 # Insert the newly-created hunt into the database
424 # (leaving it as non-active for now until the channel-created handler
425 # finishes fixing it up with a sheet and a companion table)
428 "SK": "hunt-{}".format(hunt_id),
430 "channel_id": channel_id,
436 turb.table.put_item(Item=item)
438 # Invite the initiating user to the channel
439 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
443 def view_submission(turb, payload):
444 """Handler for Slack interactive view submission
446 Specifically, those that have a payload type of 'view_submission'"""
448 view_id = payload['view']['id']
449 metadata = payload['view']['private_metadata']
451 if view_id in submission_handlers:
452 return submission_handlers[view_id](turb, payload, metadata)
454 print("Error: Unknown view ID: {}".format(view_id))
459 def rot(turb, body, args):
460 """Implementation of the /rot command
462 The args string should be as follows:
464 [count|*] String to be rotated
466 That is, the first word of the string is an optional number (or
467 the character '*'). If this is a number it indicates an amount to
468 rotate each character in the string. If the count is '*' or is not
469 present, then the string will be rotated through all possible 25
472 The result of the rotation is returned (with Slack formatting) in
473 the body of the response so that Slack will provide it as a reply
474 to the user who submitted the slash command."""
476 channel_name = body['channel_name'][0]
477 response_url = body['response_url'][0]
478 channel_id = body['channel_id'][0]
480 result = turbot.rot.rot(args)
482 if (channel_name == "directmessage"):
483 requests.post(response_url,
484 json = {"text": result},
485 headers = {"Content-type": "application/json"})
487 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
491 commands["/rot"] = rot
493 def get_table_item(turb, table_name, key, value):
494 """Get an item from the database 'table_name' with 'key' as 'value'
496 Returns a tuple of (item, table) if found and (None, None) otherwise."""
498 table = turb.db.Table(table_name)
500 response = table.get_item(Key={key: value})
502 if 'Item' in response:
503 return (response['Item'], table)
507 def db_entry_for_channel(turb, channel_id):
508 """Given a channel ID return the database item for this channel
510 If this channel is a registered hunt or puzzle channel, return the
511 corresponding row from the database for this channel. Otherwise,
514 Note: If you need to specifically ensure that the channel is a
515 puzzle or a hunt, please call puzzle_for_channel or
516 hunt_for_channel respectively.
519 response = turb.table.query(
520 IndexName = "channel_id_index",
521 KeyConditionExpression=Key("channel_id").eq(channel_id)
524 if response['Count'] == 0:
527 return response['Items'][0]
530 def puzzle_for_channel(turb, channel_id):
532 """Given a channel ID return the puzzle from the database for this channel
534 If the given channel_id is a puzzle's channel, this function
535 returns a dict filled with the attributes from the puzzle's entry
538 Otherwise, this function returns None.
541 entry = db_entry_for_channel(turb, channel_id)
543 if entry and entry['SK'].startswith('puzzle-'):
548 def hunt_for_channel(turb, channel_id):
550 """Given a channel ID return the hunt from the database for this channel
552 This works whether the original channel is a primary hunt channel,
553 or if it is one of the channels of a puzzle belonging to the hunt.
555 Returns None if channel does not belong to a hunt, otherwise a
556 dictionary with all fields from the hunt's row in the table,
557 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
560 entry = db_entry_for_channel(turb, channel_id)
562 # We're done if this channel doesn't exist in the database at all
566 # Also done if this channel is a hunt channel
567 if entry['SK'].startswith('hunt-'):
570 # Otherwise, (the channel is in the database, but is not a hunt),
571 # we expect this to be a puzzle channel instead
572 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
574 # python3.9 has a built-in removeprefix but AWS only has python3.8
575 def remove_prefix(text, prefix):
576 if text.startswith(prefix):
577 return text[len(prefix):]
580 def hunt_rounds(turb, hunt_id):
581 """Returns array of strings giving rounds that exist in the given hunt"""
583 response = turb.table.query(
584 KeyConditionExpression=(
585 Key('hunt_id').eq(hunt_id) &
586 Key('SK').begins_with('round-')
590 if response['Count'] == 0:
593 return [remove_prefix(option['SK'], 'round-')
594 for option in response['Items']]
596 def puzzle(turb, body, args):
597 """Implementation of the /puzzle command
599 The args string can be a sub-command:
601 /puzzle new: Bring up a dialog to create a new puzzle
603 /puzzle edit: Edit the puzzle for the current channel
605 Or with no argument at all:
607 /puzzle: Print details of the current puzzle (if in a puzzle channel)
611 return new_puzzle(turb, body)
614 return edit_puzzle_command(turb, body)
617 return bot_reply("Unknown syntax for `/puzzle` command. " +
618 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
619 "and `/puzzle new` to display, edit, or create " +
622 # For no arguments we print the current puzzle as a reply
623 channel_id = body['channel_id'][0]
624 response_url = body['response_url'][0]
626 puzzle = puzzle_for_channel(turb, channel_id)
629 hunt = hunt_for_channel(turb, channel_id)
632 "This is not a puzzle channel, but is a hunt channel. "
633 + "If you want to create a new puzzle for this hunt, use "
637 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
638 + "channel, so the `/puzzle` command cannot work here.")
640 blocks = puzzle_blocks(puzzle, include_rounds=True)
642 requests.post(response_url,
643 json = {'blocks': blocks},
644 headers = {'Content-type': 'application/json'}
649 commands["/puzzle"] = puzzle
651 def new_puzzle(turb, body):
652 """Implementation of the "/puzzle new" command
654 This brings up a dialog box for creating a new puzzle.
657 channel_id = body['channel_id'][0]
658 trigger_id = body['trigger_id'][0]
660 hunt = hunt_for_channel(turb, channel_id)
663 return bot_reply("Sorry, this channel doesn't appear to "
664 + "be a hunt or puzzle channel")
666 round_options = hunt_rounds(turb, hunt['hunt_id'])
668 if len(round_options):
669 round_options_block = [
670 multi_select_block("Round(s)", "rounds",
671 "Existing round(s) this puzzle belongs to",
675 round_options_block = []
679 "private_metadata": json.dumps({
680 "hunt_id": hunt['hunt_id'],
682 "title": {"type": "plain_text", "text": "New Puzzle"},
683 "submit": { "type": "plain_text", "text": "Create" },
685 section_block(text_block("*For {}*".format(hunt['name']))),
686 input_block("Puzzle name", "name", "Name of the puzzle"),
687 input_block("Puzzle URL", "url", "External URL of puzzle",
689 * round_options_block,
690 input_block("New round(s)", "new_rounds",
691 "New round(s) this puzzle belongs to " +
697 result = turb.slack_client.views_open(trigger_id=trigger_id,
701 submission_handlers[result['view']['id']] = new_puzzle_submission
705 def new_puzzle_submission(turb, payload, metadata):
706 """Handler for the user submitting the new puzzle modal
708 This is the modal view presented to the user by the new_puzzle
712 # First, read all the various data from the request
713 meta = json.loads(metadata)
714 hunt_id = meta['hunt_id']
716 state = payload['view']['state']['values']
717 name = state['name']['name']['value']
718 url = state['url']['url']['value']
719 if 'rounds' in state:
720 rounds = [option['value'] for option in
721 state['rounds']['rounds']['selected_options']]
724 new_rounds = state['new_rounds']['new_rounds']['value']
726 # Before doing anything, reject this puzzle if a puzzle already
727 # exists with the same URL.
729 existing = find_puzzle_for_url(turb, hunt_id, url)
731 return submission_error(
733 "Error: A puzzle with this URL already exists.")
735 # Create a Slack-channel-safe puzzle_id
736 puzzle_id = puzzle_id_from_name(name)
738 # Create a channel for the puzzle
739 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
742 response = turb.slack_client.conversations_create(
743 name=hunt_dash_channel)
744 except SlackApiError as e:
745 return submission_error(
747 "Error creating Slack channel {}: {}"
748 .format(hunt_dash_channel, e.response['error']))
750 channel_id = response['channel']['id']
752 # Add any new rounds to the database
754 for round in new_rounds.split(','):
755 # Drop any leading/trailing spaces from the round name
756 round = round.strip()
757 # Ignore any empty string
764 'SK': 'round-' + round
768 # Insert the newly-created puzzle into the database
771 "SK": "puzzle-{}".format(puzzle_id),
772 "puzzle_id": puzzle_id,
773 "channel_id": channel_id,
775 "status": 'unsolved',
781 item['rounds'] = rounds
782 turb.table.put_item(Item=item)
786 def state(turb, body, args):
787 """Implementation of the /state command
789 The args string should be a brief sentence describing where things
790 stand or what's needed."""
792 channel_id = body['channel_id'][0]
794 old_puzzle = puzzle_for_channel(turb, channel_id)
798 "Sorry, the /state command only works in a puzzle channel")
800 # Make a copy of the puzzle object
801 puzzle = old_puzzle.copy()
803 # Update the puzzle in the database
804 puzzle['state'] = args
805 turb.table.put_item(Item=puzzle)
807 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
811 commands["/state"] = state
813 def solved(turb, body, args):
814 """Implementation of the /solved command
816 The args string should be a confirmed solution."""
818 channel_id = body['channel_id'][0]
819 user_name = body['user_name'][0]
821 old_puzzle = puzzle_for_channel(turb, channel_id)
824 return bot_reply("Sorry, this is not a puzzle channel.")
828 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
830 # Make a copy of the puzzle object
831 puzzle = old_puzzle.copy()
833 # Set the status and solution fields in the database
834 puzzle['status'] = 'solved'
835 puzzle['solution'].append(args)
836 if 'state' in puzzle:
838 turb.table.put_item(Item=puzzle)
840 # Report the solution to the puzzle's channel
842 turb.slack_client, channel_id,
843 "Puzzle mark solved by {}: `{}`".format(user_name, args))
845 # Also report the solution to the hunt channel
846 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
848 turb.slack_client, hunt['channel_id'],
849 "Puzzle <{}|{}> has been solved!".format(
850 puzzle['channel_url'],
854 # And update the puzzle's description
855 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
859 commands["/solved"] = solved
861 def hunt(turb, body, args):
862 """Implementation of the /hunt command
864 The (optional) args string can be used to filter which puzzles to
865 display. The first word can be one of 'all', 'unsolved', or
866 'solved' and can be used to display only puzzles with the given
867 status. If this first word is missing, this command will display
868 only unsolved puzzles by default.
870 Any remaining text in the args string will be interpreted as
871 search terms. These will be split into separate terms on space
872 characters, (though quotation marks can be used to include a space
873 character in a term). All terms must match on a puzzle in order
874 for that puzzle to be included. But a puzzle will be considered to
875 match if any of the puzzle title, round title, puzzle URL, puzzle
876 state, or puzzle solution match. Matching will be performed
877 without regard to case sensitivity and the search terms can
878 include regular expression syntax.
881 channel_id = body['channel_id'][0]
882 response_url = body['response_url'][0]
886 # The first word can be a puzzle status and all remaining word
887 # (if any) are search terms. _But_, if the first word is not a
888 # valid puzzle status ('all', 'unsolved', 'solved'), then all
889 # words are search terms and we default status to 'unsolved'.
890 split_args = args.split(' ', 1)
891 status = split_args[0]
892 if (len(split_args) > 1):
893 terms = split_args[1]
894 if status not in ('unsolved', 'solved', 'all'):
900 # Separate search terms on spaces (but allow for quotation marks
901 # to capture spaces in a search term)
903 terms = shlex.split(terms)
905 hunt = hunt_for_channel(turb, channel_id)
908 return bot_reply("Sorry, this channel doesn't appear to "
909 + "be a hunt or puzzle channel")
911 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
913 requests.post(response_url,
914 json = { 'blocks': blocks },
915 headers = {'Content-type': 'application/json'}
920 commands["/hunt"] = hunt
922 def round(turb, body, args):
923 """Implementation of the /round command
925 Displays puzzles in the same round(s) as the puzzle for the
928 The (optional) args string can be used to filter which puzzles to
929 display. The first word can be one of 'all', 'unsolved', or
930 'solved' and can be used to display only puzzles with the given
931 status. If this first word is missing, this command will display
932 all puzzles in the round by default.
934 Any remaining text in the args string will be interpreted as
935 search terms. These will be split into separate terms on space
936 characters, (though quotation marks can be used to include a space
937 character in a term). All terms must match on a puzzle in order
938 for that puzzle to be included. But a puzzle will be considered to
939 match if any of the puzzle title, round title, puzzle URL, puzzle
940 state, or puzzle solution match. Matching will be performed
941 without regard to case sensitivity and the search terms can
942 include regular expression syntax.
945 channel_id = body['channel_id'][0]
946 response_url = body['response_url'][0]
948 puzzle = puzzle_for_channel(turb, channel_id)
949 hunt = hunt_for_channel(turb, channel_id)
954 "This is not a puzzle channel, but is a hunt channel. "
955 + "Use /hunt if you want to see all rounds for this hunt.")
958 "Sorry, this channel doesn't appear to be a puzzle channel "
959 + "so the `/round` command cannot work here.")
963 # The first word can be a puzzle status and all remaining word
964 # (if any) are search terms. _But_, if the first word is not a
965 # valid puzzle status ('all', 'unsolved', 'solved'), then all
966 # words are search terms and we default status to 'unsolved'.
967 split_args = args.split(' ', 1)
968 status = split_args[0]
969 if (len(split_args) > 1):
970 terms = split_args[1]
971 if status not in ('unsolved', 'solved', 'all'):
977 # Separate search terms on spaces (but allow for quotation marks
978 # to capture spaces in a search term)
980 terms = shlex.split(terms)
982 blocks = hunt_blocks(turb, hunt,
983 puzzle_status=status, search_terms=terms,
984 limit_to_rounds=puzzle.get('rounds', [])
987 requests.post(response_url,
988 json = { 'blocks': blocks },
989 headers = {'Content-type': 'application/json'}
994 commands["/round"] = round