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_sort_key,
9 puzzle_update_channel_and_sheet,
20 from botocore.exceptions import ClientError
21 from boto3.dynamodb.conditions import Key
22 from turbot.slack import slack_send_message
26 actions['button'] = {}
28 submission_handlers = {}
30 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
32 # Note: This restriction not only allows for hunt and puzzle ID values to
33 # be used as Slack channel names, but it also allows for '-' as a valid
34 # separator between a hunt and a puzzle ID (for example in the puzzle
35 # edit dialog where a single attribute must capture both values).
36 valid_id_re = r'^[_a-z0-9]+$'
38 lambda_ok = {'statusCode': 200}
40 def bot_reply(message):
41 """Construct a return value suitable for a bot reply
43 This is suitable as a way to give an error back to the user who
44 initiated a slash command, for example."""
51 def submission_error(field, error):
52 """Construct an error suitable for returning for an invalid submission.
54 Returning this value will prevent a submission and alert the user that
55 the given field is invalid because of the given error."""
57 print("Rejecting invalid modal submission: {}".format(error))
62 "Content-Type": "application/json"
65 "response_action": "errors",
72 def multi_static_select(turb, payload):
73 """Handler for the action of user entering a multi-select value"""
77 actions['multi_static_select'] = {"*": multi_static_select}
79 def edit_puzzle_command(turb, body):
80 """Implementation of the `/puzzle edit` command
82 As dispatched from the puzzle() function.
85 channel_id = body['channel_id'][0]
86 trigger_id = body['trigger_id'][0]
88 puzzle = puzzle_for_channel(turb, channel_id)
91 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
93 return edit_puzzle(turb, puzzle, trigger_id)
97 def edit_puzzle_button(turb, payload):
98 """Handler for the action of user pressing an edit_puzzle button"""
100 action_id = payload['actions'][0]['action_id']
101 response_url = payload['response_url']
102 trigger_id = payload['trigger_id']
104 (hunt_id, sort_key) = action_id.split('-', 1)
106 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
109 requests.post(response_url,
110 json = {"text": "Error: Puzzle not found!"},
111 headers = {"Content-type": "application/json"})
112 return bot_reply("Error: Puzzle not found.")
114 return edit_puzzle(turb, puzzle, trigger_id)
116 actions['button']['edit_puzzle'] = edit_puzzle_button
118 def edit_puzzle(turb, puzzle, trigger_id):
119 """Common code for implementing an edit puzzle dialog
121 This implementation is common whether the edit operation was invoked
122 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
125 round_options = hunt_rounds(turb, puzzle['hunt_id'])
127 if len(round_options):
128 round_options_block = [
129 multi_select_block("Round(s)", "rounds",
130 "Existing round(s) this puzzle belongs to",
132 initial_options=puzzle.get("rounds", None)),
135 round_options_block = []
138 if puzzle.get("status", "unsolved") == solved:
142 solution_list = puzzle.get("solution", [])
144 solution_str = ", ".join(solution_list)
148 "private_metadata": json.dumps({
149 "hunt_id": puzzle['hunt_id'],
151 "puzzle_id": puzzle['puzzle_id'],
152 "channel_id": puzzle["channel_id"],
153 "channel_url": puzzle["channel_url"],
154 "sheet_url": puzzle["sheet_url"],
156 "title": {"type": "plain_text", "text": "Edit Puzzle"},
157 "submit": { "type": "plain_text", "text": "Save" },
159 input_block("Puzzle name", "name", "Name of the puzzle",
160 initial_value=puzzle["name"]),
161 input_block("Puzzle URL", "url", "External URL of puzzle",
162 initial_value=puzzle.get("url", None),
164 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
165 checked=(puzzle.get('type', 'plain') == 'meta')),
166 * round_options_block,
167 input_block("New round(s)", "new_rounds",
168 "New round(s) this puzzle belongs to " +
171 input_block("State", "state",
172 "State of this puzzle (partial progress, next steps)",
173 initial_value=puzzle.get("state", None),
176 "Puzzle status", "Solved", "solved",
177 checked=(puzzle.get('status', 'unsolved') == 'solved')),
178 input_block("Solution", "solution",
179 "Solution(s) (comma-separated if multiple)",
180 initial_value=solution_str,
185 result = turb.slack_client.views_open(trigger_id=trigger_id,
189 submission_handlers[result['view']['id']] = edit_puzzle_submission
193 def edit_puzzle_submission(turb, payload, metadata):
194 """Handler for the user submitting the edit puzzle modal
196 This is the modal view presented to the user by the edit_puzzle
202 # First, read all the various data from the request
203 meta = json.loads(metadata)
204 puzzle['hunt_id'] = meta['hunt_id']
205 puzzle['SK'] = meta['SK']
206 puzzle['puzzle_id'] = meta['puzzle_id']
207 puzzle['channel_id'] = meta['channel_id']
208 puzzle['channel_url'] = meta['channel_url']
209 puzzle['sheet_url'] = meta['sheet_url']
211 state = payload['view']['state']['values']
212 user_id = payload['user']['id']
214 puzzle['name'] = state['name']['name']['value']
215 url = state['url']['url']['value']
218 if state['meta']['meta']['selected_options']:
219 puzzle['type'] = 'meta'
221 puzzle['type'] = 'plain'
222 rounds = [option['value'] for option in
223 state['rounds']['rounds']['selected_options']]
225 puzzle['rounds'] = rounds
226 new_rounds = state['new_rounds']['new_rounds']['value']
227 puzzle_state = state['state']['state']['value']
229 puzzle['state'] = puzzle_state
230 if state['solved']['solved']['selected_options']:
231 puzzle['status'] = 'solved'
233 puzzle['status'] = 'unsolved'
234 puzzle['solution'] = []
235 solution = state['solution']['solution']['value']
237 puzzle['solution'] = [
238 sol.strip() for sol in solution.split(',')
241 # Verify that there's a solution if the puzzle is mark solved
242 if puzzle['status'] == 'solved' and not puzzle['solution']:
243 return submission_error("solution",
244 "A solved puzzle requires a solution.")
246 if puzzle['status'] == 'unsolved' and puzzle['solution']:
247 return submission_error("solution",
248 "An unsolved puzzle should have no solution.")
250 # Add any new rounds to the database
252 if 'rounds' not in puzzle:
253 puzzle['rounds'] = []
254 for round in new_rounds.split(','):
255 # Drop any leading/trailing spaces from the round name
256 round = round.strip()
257 # Ignore any empty string
260 puzzle['rounds'].append(round)
263 'hunt_id': puzzle['hunt_id'],
264 'SK': 'round-' + round
268 # Get old puzzle from the database (to determine what's changed)
269 old_puzzle = find_puzzle_for_sort_key(turb,
273 # If we are changing puzzle type (meta -> plain or plain -> meta)
274 # the the sort key has to change, so compute the new one and delete
275 # the old item from the database.
277 # XXX: We should really be using a transaction here to combine the
278 # delete_item and the put_item into a single transaction, but
279 # the boto interface is annoying in that transactions are only on
280 # the "Client" object which has a totally different interface than
281 # the "Table" object I've been using so I haven't figured out how
284 if puzzle['type'] != old_puzzle.get('type', 'plain'):
285 puzzle['SK'] = puzzle_sort_key(puzzle)
286 turb.table.delete_item(Key={
287 'hunt_id': old_puzzle['hunt_id'],
288 'SK': old_puzzle['SK']
291 # Update the puzzle in the database
292 turb.table.put_item(Item=puzzle)
294 # Inform the puzzle channel about the edit
295 edit_message = "Puzzle edited by <@{}>".format(user_id)
296 blocks = ([section_block(text_block(edit_message+":\n"))] +
297 puzzle_blocks(puzzle, include_rounds=True))
299 turb.slack_client, puzzle['channel_id'],
300 edit_message, blocks=blocks)
302 # Also inform the hunt if the puzzle's solved status changed
303 if puzzle['status'] != old_puzzle['status']:
304 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
305 if puzzle['status'] == 'solved':
306 message = "Puzzle <{}|{}> has been solved!".format(
307 puzzle['channel_url'],
310 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
311 puzzle['channel_url'],
313 slack_send_message(turb.slack_client, hunt['channel_id'], message)
315 # We need to set the channel topic if any of puzzle name, url,
316 # state, status, or solution, has changed. Let's just do that
317 # unconditionally here.
318 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
322 def new_hunt(turb, payload):
323 """Handler for the action of user pressing the new_hunt button"""
327 "private_metadata": json.dumps({}),
328 "title": { "type": "plain_text", "text": "New Hunt" },
329 "submit": { "type": "plain_text", "text": "Create" },
331 input_block("Hunt name", "name", "Name of the hunt"),
332 input_block("Hunt ID", "hunt_id",
333 "Used as puzzle channel prefix "
334 + "(no spaces nor punctuation)"),
335 input_block("Hunt URL", "url", "External URL of hunt",
340 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
343 submission_handlers[result['view']['id']] = new_hunt_submission
347 actions['button']['new_hunt'] = new_hunt
349 def new_hunt_submission(turb, payload, metadata):
350 """Handler for the user submitting the new hunt modal
352 This is the modal view presented to the user by the new_hunt
355 state = payload['view']['state']['values']
356 user_id = payload['user']['id']
357 name = state['name']['name']['value']
358 hunt_id = state['hunt_id']['hunt_id']['value']
359 url = state['url']['url']['value']
361 # Validate that the hunt_id contains no invalid characters
362 if not re.match(valid_id_re, hunt_id):
363 return submission_error("hunt_id",
364 "Hunt ID can only contain lowercase letters, "
365 + "numbers, and underscores")
367 # Check to see if the turbot table exists
369 exists = turb.table.table_status in ("CREATING", "UPDATING",
374 # Create the turbot table if necessary.
376 turb.table = turb.db.create_table(
379 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
380 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
382 AttributeDefinitions=[
383 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
384 {'AttributeName': 'SK', 'AttributeType': 'S'},
385 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
386 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
387 {'AttributeName': 'url', 'AttributeType': 'S'}
389 ProvisionedThroughput={
390 'ReadCapacityUnits': 5,
391 'WriteCapacityUnits': 5
393 GlobalSecondaryIndexes=[
395 'IndexName': 'channel_id_index',
397 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
400 'ProjectionType': 'ALL'
402 'ProvisionedThroughput': {
403 'ReadCapacityUnits': 5,
404 'WriteCapacityUnits': 5
408 'IndexName': 'is_hunt_index',
410 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
413 'ProjectionType': 'ALL'
415 'ProvisionedThroughput': {
416 'ReadCapacityUnits': 5,
417 'WriteCapacityUnits': 5
421 LocalSecondaryIndexes = [
423 'IndexName': 'url_index',
425 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
426 {'AttributeName': 'url', 'KeyType': 'RANGE'},
429 'ProjectionType': 'ALL'
434 return submission_error(
436 "Still bootstrapping turbot table. Try again in a minute, please.")
438 # Create a channel for the hunt
440 response = turb.slack_client.conversations_create(name=hunt_id)
441 except SlackApiError as e:
442 return submission_error("hunt_id",
443 "Error creating Slack channel: {}"
444 .format(e.response['error']))
446 channel_id = response['channel']['id']
448 # Insert the newly-created hunt into the database
449 # (leaving it as non-active for now until the channel-created handler
450 # finishes fixing it up with a sheet and a companion table)
453 "SK": "hunt-{}".format(hunt_id),
455 "channel_id": channel_id,
461 turb.table.put_item(Item=item)
463 # Invite the initiating user to the channel
464 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
468 def view_submission(turb, payload):
469 """Handler for Slack interactive view submission
471 Specifically, those that have a payload type of 'view_submission'"""
473 view_id = payload['view']['id']
474 metadata = payload['view']['private_metadata']
476 if view_id in submission_handlers:
477 return submission_handlers[view_id](turb, payload, metadata)
479 print("Error: Unknown view ID: {}".format(view_id))
484 def rot(turb, body, args):
485 """Implementation of the /rot command
487 The args string should be as follows:
489 [count|*] String to be rotated
491 That is, the first word of the string is an optional number (or
492 the character '*'). If this is a number it indicates an amount to
493 rotate each character in the string. If the count is '*' or is not
494 present, then the string will be rotated through all possible 25
497 The result of the rotation is returned (with Slack formatting) in
498 the body of the response so that Slack will provide it as a reply
499 to the user who submitted the slash command."""
501 channel_name = body['channel_name'][0]
502 response_url = body['response_url'][0]
503 channel_id = body['channel_id'][0]
505 result = turbot.rot.rot(args)
507 if (channel_name == "directmessage"):
508 requests.post(response_url,
509 json = {"text": result},
510 headers = {"Content-type": "application/json"})
512 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
516 commands["/rot"] = rot
518 def get_table_item(turb, table_name, key, value):
519 """Get an item from the database 'table_name' with 'key' as 'value'
521 Returns a tuple of (item, table) if found and (None, None) otherwise."""
523 table = turb.db.Table(table_name)
525 response = table.get_item(Key={key: value})
527 if 'Item' in response:
528 return (response['Item'], table)
532 def db_entry_for_channel(turb, channel_id):
533 """Given a channel ID return the database item for this channel
535 If this channel is a registered hunt or puzzle channel, return the
536 corresponding row from the database for this channel. Otherwise,
539 Note: If you need to specifically ensure that the channel is a
540 puzzle or a hunt, please call puzzle_for_channel or
541 hunt_for_channel respectively.
544 response = turb.table.query(
545 IndexName = "channel_id_index",
546 KeyConditionExpression=Key("channel_id").eq(channel_id)
549 if response['Count'] == 0:
552 return response['Items'][0]
555 def puzzle_for_channel(turb, channel_id):
557 """Given a channel ID return the puzzle from the database for this channel
559 If the given channel_id is a puzzle's channel, this function
560 returns a dict filled with the attributes from the puzzle's entry
563 Otherwise, this function returns None.
566 entry = db_entry_for_channel(turb, channel_id)
568 if entry and entry['SK'].startswith('puzzle-'):
573 def hunt_for_channel(turb, channel_id):
575 """Given a channel ID return the hunt from the database for this channel
577 This works whether the original channel is a primary hunt channel,
578 or if it is one of the channels of a puzzle belonging to the hunt.
580 Returns None if channel does not belong to a hunt, otherwise a
581 dictionary with all fields from the hunt's row in the table,
582 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
585 entry = db_entry_for_channel(turb, channel_id)
587 # We're done if this channel doesn't exist in the database at all
591 # Also done if this channel is a hunt channel
592 if entry['SK'].startswith('hunt-'):
595 # Otherwise, (the channel is in the database, but is not a hunt),
596 # we expect this to be a puzzle channel instead
597 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
599 # python3.9 has a built-in removeprefix but AWS only has python3.8
600 def remove_prefix(text, prefix):
601 if text.startswith(prefix):
602 return text[len(prefix):]
605 def hunt_rounds(turb, hunt_id):
606 """Returns array of strings giving rounds that exist in the given hunt"""
608 response = turb.table.query(
609 KeyConditionExpression=(
610 Key('hunt_id').eq(hunt_id) &
611 Key('SK').begins_with('round-')
615 if response['Count'] == 0:
618 return [remove_prefix(option['SK'], 'round-')
619 for option in response['Items']]
621 def puzzle(turb, body, args):
622 """Implementation of the /puzzle command
624 The args string can be a sub-command:
626 /puzzle new: Bring up a dialog to create a new puzzle
628 /puzzle edit: Edit the puzzle for the current channel
630 Or with no argument at all:
632 /puzzle: Print details of the current puzzle (if in a puzzle channel)
636 return new_puzzle(turb, body)
639 return edit_puzzle_command(turb, body)
642 return bot_reply("Unknown syntax for `/puzzle` command. " +
643 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
644 "and `/puzzle new` to display, edit, or create " +
647 # For no arguments we print the current puzzle as a reply
648 channel_id = body['channel_id'][0]
649 response_url = body['response_url'][0]
651 puzzle = puzzle_for_channel(turb, channel_id)
654 hunt = hunt_for_channel(turb, channel_id)
657 "This is not a puzzle channel, but is a hunt channel. "
658 + "If you want to create a new puzzle for this hunt, use "
662 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
663 + "channel, so the `/puzzle` command cannot work here.")
665 blocks = puzzle_blocks(puzzle, include_rounds=True)
667 requests.post(response_url,
668 json = {'blocks': blocks},
669 headers = {'Content-type': 'application/json'}
674 commands["/puzzle"] = puzzle
676 def new_puzzle(turb, body):
677 """Implementation of the "/puzzle new" command
679 This brings up a dialog box for creating a new puzzle.
682 channel_id = body['channel_id'][0]
683 trigger_id = body['trigger_id'][0]
685 hunt = hunt_for_channel(turb, channel_id)
688 return bot_reply("Sorry, this channel doesn't appear to "
689 + "be a hunt or puzzle channel")
691 round_options = hunt_rounds(turb, hunt['hunt_id'])
693 if len(round_options):
694 round_options_block = [
695 multi_select_block("Round(s)", "rounds",
696 "Existing round(s) this puzzle belongs to",
700 round_options_block = []
704 "private_metadata": json.dumps({
705 "hunt_id": hunt['hunt_id'],
707 "title": {"type": "plain_text", "text": "New Puzzle"},
708 "submit": { "type": "plain_text", "text": "Create" },
710 section_block(text_block("*For {}*".format(hunt['name']))),
711 input_block("Puzzle name", "name", "Name of the puzzle"),
712 input_block("Puzzle URL", "url", "External URL of puzzle",
714 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
715 * round_options_block,
716 input_block("New round(s)", "new_rounds",
717 "New round(s) this puzzle belongs to " +
723 result = turb.slack_client.views_open(trigger_id=trigger_id,
727 submission_handlers[result['view']['id']] = new_puzzle_submission
731 def new_puzzle_submission(turb, payload, metadata):
732 """Handler for the user submitting the new puzzle modal
734 This is the modal view presented to the user by the new_puzzle
738 # First, read all the various data from the request
739 meta = json.loads(metadata)
740 hunt_id = meta['hunt_id']
742 state = payload['view']['state']['values']
743 name = state['name']['name']['value']
744 url = state['url']['url']['value']
745 if state['meta']['meta']['selected_options']:
748 puzzle_type = 'plain'
749 if 'rounds' in state:
750 rounds = [option['value'] for option in
751 state['rounds']['rounds']['selected_options']]
754 new_rounds = state['new_rounds']['new_rounds']['value']
756 # Before doing anything, reject this puzzle if a puzzle already
757 # exists with the same URL.
759 existing = find_puzzle_for_url(turb, hunt_id, url)
761 return submission_error(
763 "Error: A puzzle with this URL already exists.")
765 # Create a Slack-channel-safe puzzle_id
766 puzzle_id = puzzle_id_from_name(name)
768 # Create a channel for the puzzle
769 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
772 response = turb.slack_client.conversations_create(
773 name=hunt_dash_channel)
774 except SlackApiError as e:
775 return submission_error(
777 "Error creating Slack channel {}: {}"
778 .format(hunt_dash_channel, e.response['error']))
780 channel_id = response['channel']['id']
782 # Add any new rounds to the database
784 for round in new_rounds.split(','):
785 # Drop any leading/trailing spaces from the round name
786 round = round.strip()
787 # Ignore any empty string
794 'SK': 'round-' + round
798 # Construct a puzzle dict
801 "puzzle_id": puzzle_id,
802 "channel_id": channel_id,
804 "status": 'unsolved',
811 puzzle['rounds'] = rounds
813 # Finally, compute the appropriate sort key
814 puzzle["SK"] = puzzle_sort_key(puzzle)
816 # Insert the newly-created puzzle into the database
817 turb.table.put_item(Item=puzzle)
821 def state(turb, body, args):
822 """Implementation of the /state command
824 The args string should be a brief sentence describing where things
825 stand or what's needed."""
827 channel_id = body['channel_id'][0]
829 old_puzzle = puzzle_for_channel(turb, channel_id)
833 "Sorry, the /state command only works in a puzzle channel")
835 # Make a copy of the puzzle object
836 puzzle = old_puzzle.copy()
838 # Update the puzzle in the database
839 puzzle['state'] = args
840 turb.table.put_item(Item=puzzle)
842 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
846 commands["/state"] = state
848 def solved(turb, body, args):
849 """Implementation of the /solved command
851 The args string should be a confirmed solution."""
853 channel_id = body['channel_id'][0]
854 user_name = body['user_name'][0]
856 old_puzzle = puzzle_for_channel(turb, channel_id)
859 return bot_reply("Sorry, this is not a puzzle channel.")
863 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
865 # Make a copy of the puzzle object
866 puzzle = old_puzzle.copy()
868 # Set the status and solution fields in the database
869 puzzle['status'] = 'solved'
870 puzzle['solution'].append(args)
871 if 'state' in puzzle:
873 turb.table.put_item(Item=puzzle)
875 # Report the solution to the puzzle's channel
877 turb.slack_client, channel_id,
878 "Puzzle mark solved by {}: `{}`".format(user_name, args))
880 # Also report the solution to the hunt channel
881 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
883 turb.slack_client, hunt['channel_id'],
884 "Puzzle <{}|{}> has been solved!".format(
885 puzzle['channel_url'],
889 # And update the puzzle's description
890 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
894 commands["/solved"] = solved
896 def hunt(turb, body, args):
897 """Implementation of the /hunt command
899 The (optional) args string can be used to filter which puzzles to
900 display. The first word can be one of 'all', 'unsolved', or
901 'solved' and can be used to display only puzzles with the given
902 status. If this first word is missing, this command will display
903 only unsolved puzzles by default.
905 Any remaining text in the args string will be interpreted as
906 search terms. These will be split into separate terms on space
907 characters, (though quotation marks can be used to include a space
908 character in a term). All terms must match on a puzzle in order
909 for that puzzle to be included. But a puzzle will be considered to
910 match if any of the puzzle title, round title, puzzle URL, puzzle
911 state, or puzzle solution match. Matching will be performed
912 without regard to case sensitivity and the search terms can
913 include regular expression syntax.
916 channel_id = body['channel_id'][0]
917 response_url = body['response_url'][0]
921 # The first word can be a puzzle status and all remaining word
922 # (if any) are search terms. _But_, if the first word is not a
923 # valid puzzle status ('all', 'unsolved', 'solved'), then all
924 # words are search terms and we default status to 'unsolved'.
925 split_args = args.split(' ', 1)
926 status = split_args[0]
927 if (len(split_args) > 1):
928 terms = split_args[1]
929 if status not in ('unsolved', 'solved', 'all'):
935 # Separate search terms on spaces (but allow for quotation marks
936 # to capture spaces in a search term)
938 terms = shlex.split(terms)
940 hunt = hunt_for_channel(turb, channel_id)
943 return bot_reply("Sorry, this channel doesn't appear to "
944 + "be a hunt or puzzle channel")
946 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
948 requests.post(response_url,
949 json = { 'blocks': blocks },
950 headers = {'Content-type': 'application/json'}
955 commands["/hunt"] = hunt
957 def round(turb, body, args):
958 """Implementation of the /round command
960 Displays puzzles in the same round(s) as the puzzle for the
963 The (optional) args string can be used to filter which puzzles to
964 display. The first word can be one of 'all', 'unsolved', or
965 'solved' and can be used to display only puzzles with the given
966 status. If this first word is missing, this command will display
967 all puzzles in the round by default.
969 Any remaining text in the args string will be interpreted as
970 search terms. These will be split into separate terms on space
971 characters, (though quotation marks can be used to include a space
972 character in a term). All terms must match on a puzzle in order
973 for that puzzle to be included. But a puzzle will be considered to
974 match if any of the puzzle title, round title, puzzle URL, puzzle
975 state, or puzzle solution match. Matching will be performed
976 without regard to case sensitivity and the search terms can
977 include regular expression syntax.
980 channel_id = body['channel_id'][0]
981 response_url = body['response_url'][0]
983 puzzle = puzzle_for_channel(turb, channel_id)
984 hunt = hunt_for_channel(turb, channel_id)
989 "This is not a puzzle channel, but is a hunt channel. "
990 + "Use /hunt if you want to see all rounds for this hunt.")
993 "Sorry, this channel doesn't appear to be a puzzle channel "
994 + "so the `/round` command cannot work here.")
998 # The first word can be a puzzle status and all remaining word
999 # (if any) are search terms. _But_, if the first word is not a
1000 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1001 # words are search terms and we default status to 'unsolved'.
1002 split_args = args.split(' ', 1)
1003 status = split_args[0]
1004 if (len(split_args) > 1):
1005 terms = split_args[1]
1006 if status not in ('unsolved', 'solved', 'all'):
1012 # Separate search terms on spaces (but allow for quotation marks
1013 # to capture spaces in a search term)
1015 terms = shlex.split(terms)
1017 blocks = hunt_blocks(turb, hunt,
1018 puzzle_status=status, search_terms=terms,
1019 limit_to_rounds=puzzle.get('rounds', [])
1022 requests.post(response_url,
1023 json = { 'blocks': blocks },
1024 headers = {'Content-type': 'application/json'}
1029 commands["/round"] = round