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,
18 from turbot.round import round_quoted_puzzles_titles_answers
25 from botocore.exceptions import ClientError
26 from boto3.dynamodb.conditions import Key
27 from turbot.slack import slack_send_message
31 actions['button'] = {}
33 submission_handlers = {}
35 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
37 # Note: This restriction not only allows for hunt and puzzle ID values to
38 # be used as Slack channel names, but it also allows for '-' as a valid
39 # separator between a hunt and a puzzle ID (for example in the puzzle
40 # edit dialog where a single attribute must capture both values).
41 valid_id_re = r'^[_a-z0-9]+$'
43 lambda_ok = {'statusCode': 200}
45 def bot_reply(message):
46 """Construct a return value suitable for a bot reply
48 This is suitable as a way to give an error back to the user who
49 initiated a slash command, for example."""
56 def submission_error(field, error):
57 """Construct an error suitable for returning for an invalid submission.
59 Returning this value will prevent a submission and alert the user that
60 the given field is invalid because of the given error."""
62 print("Rejecting invalid modal submission: {}".format(error))
67 "Content-Type": "application/json"
70 "response_action": "errors",
77 def multi_static_select(turb, payload):
78 """Handler for the action of user entering a multi-select value"""
82 actions['multi_static_select'] = {"*": multi_static_select}
84 def edit_puzzle_command(turb, body):
85 """Implementation of the `/puzzle edit` command
87 As dispatched from the puzzle() function.
90 channel_id = body['channel_id'][0]
91 trigger_id = body['trigger_id'][0]
93 puzzle = puzzle_for_channel(turb, channel_id)
96 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
98 return edit_puzzle(turb, puzzle, trigger_id)
102 def edit_puzzle_button(turb, payload):
103 """Handler for the action of user pressing an edit_puzzle button"""
105 action_id = payload['actions'][0]['action_id']
106 response_url = payload['response_url']
107 trigger_id = payload['trigger_id']
109 (hunt_id, sort_key) = action_id.split('-', 1)
111 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
114 requests.post(response_url,
115 json = {"text": "Error: Puzzle not found!"},
116 headers = {"Content-type": "application/json"})
117 return bot_reply("Error: Puzzle not found.")
119 return edit_puzzle(turb, puzzle, trigger_id)
121 actions['button']['edit_puzzle'] = edit_puzzle_button
123 def edit_puzzle(turb, puzzle, trigger_id):
124 """Common code for implementing an edit puzzle dialog
126 This implementation is common whether the edit operation was invoked
127 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
130 round_options = hunt_rounds(turb, puzzle['hunt_id'])
132 if len(round_options):
133 round_options_block = [
134 multi_select_block("Round(s)", "rounds",
135 "Existing round(s) this puzzle belongs to",
137 initial_options=puzzle.get("rounds", None)),
140 round_options_block = []
143 if puzzle.get("status", "unsolved") == solved:
147 solution_list = puzzle.get("solution", [])
149 solution_str = ", ".join(solution_list)
153 "private_metadata": json.dumps({
154 "hunt_id": puzzle['hunt_id'],
156 "puzzle_id": puzzle['puzzle_id'],
157 "channel_id": puzzle["channel_id"],
158 "channel_url": puzzle["channel_url"],
159 "sheet_url": puzzle["sheet_url"],
161 "title": {"type": "plain_text", "text": "Edit Puzzle"},
162 "submit": { "type": "plain_text", "text": "Save" },
164 input_block("Puzzle name", "name", "Name of the puzzle",
165 initial_value=puzzle["name"]),
166 input_block("Puzzle URL", "url", "External URL of puzzle",
167 initial_value=puzzle.get("url", None),
169 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
170 checked=(puzzle.get('type', 'plain') == 'meta')),
171 * round_options_block,
172 input_block("New round(s)", "new_rounds",
173 "New round(s) this puzzle belongs to " +
176 input_block("State", "state",
177 "State of this puzzle (partial progress, next steps)",
178 initial_value=puzzle.get("state", None),
181 "Puzzle status", "Solved", "solved",
182 checked=(puzzle.get('status', 'unsolved') == 'solved')),
183 input_block("Solution", "solution",
184 "Solution(s) (comma-separated if multiple)",
185 initial_value=solution_str,
190 result = turb.slack_client.views_open(trigger_id=trigger_id,
194 submission_handlers[result['view']['id']] = edit_puzzle_submission
198 def edit_puzzle_submission(turb, payload, metadata):
199 """Handler for the user submitting the edit puzzle modal
201 This is the modal view presented to the user by the edit_puzzle
207 # First, read all the various data from the request
208 meta = json.loads(metadata)
209 puzzle['hunt_id'] = meta['hunt_id']
210 puzzle['SK'] = meta['SK']
211 puzzle['puzzle_id'] = meta['puzzle_id']
212 puzzle['channel_id'] = meta['channel_id']
213 puzzle['channel_url'] = meta['channel_url']
214 puzzle['sheet_url'] = meta['sheet_url']
216 state = payload['view']['state']['values']
217 user_id = payload['user']['id']
219 puzzle['name'] = state['name']['name']['value']
220 url = state['url']['url']['value']
223 if state['meta']['meta']['selected_options']:
224 puzzle['type'] = 'meta'
226 puzzle['type'] = 'plain'
227 rounds = [option['value'] for option in
228 state['rounds']['rounds']['selected_options']]
230 puzzle['rounds'] = rounds
231 new_rounds = state['new_rounds']['new_rounds']['value']
232 puzzle_state = state['state']['state']['value']
234 puzzle['state'] = puzzle_state
235 if state['solved']['solved']['selected_options']:
236 puzzle['status'] = 'solved'
238 puzzle['status'] = 'unsolved'
239 puzzle['solution'] = []
240 solution = state['solution']['solution']['value']
242 puzzle['solution'] = [
243 sol.strip() for sol in solution.split(',')
246 # Verify that there's a solution if the puzzle is mark solved
247 if puzzle['status'] == 'solved' and not puzzle['solution']:
248 return submission_error("solution",
249 "A solved puzzle requires a solution.")
251 if puzzle['status'] == 'unsolved' and puzzle['solution']:
252 return submission_error("solution",
253 "An unsolved puzzle should have no solution.")
255 # Add any new rounds to the database
257 if 'rounds' not in puzzle:
258 puzzle['rounds'] = []
259 for round in new_rounds.split(','):
260 # Drop any leading/trailing spaces from the round name
261 round = round.strip()
262 # Ignore any empty string
265 puzzle['rounds'].append(round)
268 'hunt_id': puzzle['hunt_id'],
269 'SK': 'round-' + round
273 # Get old puzzle from the database (to determine what's changed)
274 old_puzzle = find_puzzle_for_sort_key(turb,
278 # If we are changing puzzle type (meta -> plain or plain -> meta)
279 # the the sort key has to change, so compute the new one and delete
280 # the old item from the database.
282 # XXX: We should really be using a transaction here to combine the
283 # delete_item and the put_item into a single transaction, but
284 # the boto interface is annoying in that transactions are only on
285 # the "Client" object which has a totally different interface than
286 # the "Table" object I've been using so I haven't figured out how
289 if puzzle['type'] != old_puzzle.get('type', 'plain'):
290 puzzle['SK'] = puzzle_sort_key(puzzle)
291 turb.table.delete_item(Key={
292 'hunt_id': old_puzzle['hunt_id'],
293 'SK': old_puzzle['SK']
296 # Update the puzzle in the database
297 turb.table.put_item(Item=puzzle)
299 # Inform the puzzle channel about the edit
300 edit_message = "Puzzle edited by <@{}>".format(user_id)
301 blocks = ([section_block(text_block(edit_message+":\n"))] +
302 puzzle_blocks(puzzle, include_rounds=True))
304 turb.slack_client, puzzle['channel_id'],
305 edit_message, blocks=blocks)
307 # Also inform the hunt if the puzzle's solved status changed
308 if puzzle['status'] != old_puzzle['status']:
309 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
310 if puzzle['status'] == 'solved':
311 message = "Puzzle <{}|{}> has been solved!".format(
312 puzzle['channel_url'],
315 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
316 puzzle['channel_url'],
318 slack_send_message(turb.slack_client, hunt['channel_id'], message)
320 # We need to set the channel topic if any of puzzle name, url,
321 # state, status, or solution, has changed. Let's just do that
322 # unconditionally here.
323 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
327 def new_hunt(turb, payload):
328 """Handler for the action of user pressing the new_hunt button"""
332 "private_metadata": json.dumps({}),
333 "title": { "type": "plain_text", "text": "New Hunt" },
334 "submit": { "type": "plain_text", "text": "Create" },
336 input_block("Hunt name", "name", "Name of the hunt"),
337 input_block("Hunt ID", "hunt_id",
338 "Used as puzzle channel prefix "
339 + "(no spaces nor punctuation)"),
340 input_block("Hunt URL", "url", "External URL of hunt",
345 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
348 submission_handlers[result['view']['id']] = new_hunt_submission
352 actions['button']['new_hunt'] = new_hunt
354 def new_hunt_submission(turb, payload, metadata):
355 """Handler for the user submitting the new hunt modal
357 This is the modal view presented to the user by the new_hunt
360 state = payload['view']['state']['values']
361 user_id = payload['user']['id']
362 name = state['name']['name']['value']
363 hunt_id = state['hunt_id']['hunt_id']['value']
364 url = state['url']['url']['value']
366 # Validate that the hunt_id contains no invalid characters
367 if not re.match(valid_id_re, hunt_id):
368 return submission_error("hunt_id",
369 "Hunt ID can only contain lowercase letters, "
370 + "numbers, and underscores")
372 # Check to see if the turbot table exists
374 exists = turb.table.table_status in ("CREATING", "UPDATING",
379 # Create the turbot table if necessary.
381 turb.table = turb.db.create_table(
384 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
385 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
387 AttributeDefinitions=[
388 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
389 {'AttributeName': 'SK', 'AttributeType': 'S'},
390 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
391 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
392 {'AttributeName': 'url', 'AttributeType': 'S'}
394 ProvisionedThroughput={
395 'ReadCapacityUnits': 5,
396 'WriteCapacityUnits': 5
398 GlobalSecondaryIndexes=[
400 'IndexName': 'channel_id_index',
402 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
405 'ProjectionType': 'ALL'
407 'ProvisionedThroughput': {
408 'ReadCapacityUnits': 5,
409 'WriteCapacityUnits': 5
413 'IndexName': 'is_hunt_index',
415 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
418 'ProjectionType': 'ALL'
420 'ProvisionedThroughput': {
421 'ReadCapacityUnits': 5,
422 'WriteCapacityUnits': 5
426 LocalSecondaryIndexes = [
428 'IndexName': 'url_index',
430 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
431 {'AttributeName': 'url', 'KeyType': 'RANGE'},
434 'ProjectionType': 'ALL'
439 return submission_error(
441 "Still bootstrapping turbot table. Try again in a minute, please.")
443 # Create a channel for the hunt
445 response = turb.slack_client.conversations_create(name=hunt_id)
446 except SlackApiError as e:
447 return submission_error("hunt_id",
448 "Error creating Slack channel: {}"
449 .format(e.response['error']))
451 channel_id = response['channel']['id']
453 # Insert the newly-created hunt into the database
454 # (leaving it as non-active for now until the channel-created handler
455 # finishes fixing it up with a sheet and a companion table)
458 "SK": "hunt-{}".format(hunt_id),
460 "channel_id": channel_id,
466 turb.table.put_item(Item=item)
468 # Invite the initiating user to the channel
469 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
473 def view_submission(turb, payload):
474 """Handler for Slack interactive view submission
476 Specifically, those that have a payload type of 'view_submission'"""
478 view_id = payload['view']['id']
479 metadata = payload['view']['private_metadata']
481 if view_id in submission_handlers:
482 return submission_handlers[view_id](turb, payload, metadata)
484 print("Error: Unknown view ID: {}".format(view_id))
489 def rot(turb, body, args):
490 """Implementation of the /rot command
492 The args string should be as follows:
494 [count|*] String to be rotated
496 That is, the first word of the string is an optional number (or
497 the character '*'). If this is a number it indicates an amount to
498 rotate each character in the string. If the count is '*' or is not
499 present, then the string will be rotated through all possible 25
502 The result of the rotation is returned (with Slack formatting) in
503 the body of the response so that Slack will provide it as a reply
504 to the user who submitted the slash command."""
506 channel_name = body['channel_name'][0]
507 response_url = body['response_url'][0]
508 channel_id = body['channel_id'][0]
510 result = turbot.rot.rot(args)
512 if (channel_name == "directmessage"):
513 requests.post(response_url,
514 json = {"text": result},
515 headers = {"Content-type": "application/json"})
517 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
521 commands["/rot"] = rot
523 def get_table_item(turb, table_name, key, value):
524 """Get an item from the database 'table_name' with 'key' as 'value'
526 Returns a tuple of (item, table) if found and (None, None) otherwise."""
528 table = turb.db.Table(table_name)
530 response = table.get_item(Key={key: value})
532 if 'Item' in response:
533 return (response['Item'], table)
537 def db_entry_for_channel(turb, channel_id):
538 """Given a channel ID return the database item for this channel
540 If this channel is a registered hunt or puzzle channel, return the
541 corresponding row from the database for this channel. Otherwise,
544 Note: If you need to specifically ensure that the channel is a
545 puzzle or a hunt, please call puzzle_for_channel or
546 hunt_for_channel respectively.
549 response = turb.table.query(
550 IndexName = "channel_id_index",
551 KeyConditionExpression=Key("channel_id").eq(channel_id)
554 if response['Count'] == 0:
557 return response['Items'][0]
560 def puzzle_for_channel(turb, channel_id):
562 """Given a channel ID return the puzzle from the database for this channel
564 If the given channel_id is a puzzle's channel, this function
565 returns a dict filled with the attributes from the puzzle's entry
568 Otherwise, this function returns None.
571 entry = db_entry_for_channel(turb, channel_id)
573 if entry and entry['SK'].startswith('puzzle-'):
578 def hunt_for_channel(turb, channel_id):
580 """Given a channel ID return the hunt from the database for this channel
582 This works whether the original channel is a primary hunt channel,
583 or if it is one of the channels of a puzzle belonging to the hunt.
585 Returns None if channel does not belong to a hunt, otherwise a
586 dictionary with all fields from the hunt's row in the table,
587 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
590 entry = db_entry_for_channel(turb, channel_id)
592 # We're done if this channel doesn't exist in the database at all
596 # Also done if this channel is a hunt channel
597 if entry['SK'].startswith('hunt-'):
600 # Otherwise, (the channel is in the database, but is not a hunt),
601 # we expect this to be a puzzle channel instead
602 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
604 # python3.9 has a built-in removeprefix but AWS only has python3.8
605 def remove_prefix(text, prefix):
606 if text.startswith(prefix):
607 return text[len(prefix):]
610 def hunt_rounds(turb, hunt_id):
611 """Returns array of strings giving rounds that exist in the given hunt"""
613 response = turb.table.query(
614 KeyConditionExpression=(
615 Key('hunt_id').eq(hunt_id) &
616 Key('SK').begins_with('round-')
620 if response['Count'] == 0:
623 return [remove_prefix(option['SK'], 'round-')
624 for option in response['Items']]
626 def puzzle(turb, body, args):
627 """Implementation of the /puzzle command
629 The args string can be a sub-command:
631 /puzzle new: Bring up a dialog to create a new puzzle
633 /puzzle edit: Edit the puzzle for the current channel
635 Or with no argument at all:
637 /puzzle: Print details of the current puzzle (if in a puzzle channel)
641 return new_puzzle(turb, body)
644 return edit_puzzle_command(turb, body)
647 return bot_reply("Unknown syntax for `/puzzle` command. " +
648 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
649 "and `/puzzle new` to display, edit, or create " +
652 # For no arguments we print the current puzzle as a reply
653 channel_id = body['channel_id'][0]
654 response_url = body['response_url'][0]
656 puzzle = puzzle_for_channel(turb, channel_id)
659 hunt = hunt_for_channel(turb, channel_id)
662 "This is not a puzzle channel, but is a hunt channel. "
663 + "If you want to create a new puzzle for this hunt, use "
667 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
668 + "channel, so the `/puzzle` command cannot work here.")
670 blocks = puzzle_blocks(puzzle, include_rounds=True)
672 # For a meta puzzle, also display the titles and solutions for all
673 # puzzles in the same round.
674 if puzzle['type'] == 'meta':
675 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
677 # Drop this puzzle itself from the report
678 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
680 for round in puzzle.get('rounds', [None]):
681 answers = round_quoted_puzzles_titles_answers(round, puzzles)
683 section_block(text_block(
684 "*Feeder solutions from round {}*".format(
685 round if round else "<none>"
687 section_block(text_block(answers))
690 requests.post(response_url,
691 json = {'blocks': blocks},
692 headers = {'Content-type': 'application/json'}
697 commands["/puzzle"] = puzzle
699 def new_puzzle(turb, body):
700 """Implementation of the "/puzzle new" command
702 This brings up a dialog box for creating a new puzzle.
705 channel_id = body['channel_id'][0]
706 trigger_id = body['trigger_id'][0]
708 hunt = hunt_for_channel(turb, channel_id)
711 return bot_reply("Sorry, this channel doesn't appear to "
712 + "be a hunt or puzzle channel")
714 round_options = hunt_rounds(turb, hunt['hunt_id'])
716 if len(round_options):
717 round_options_block = [
718 multi_select_block("Round(s)", "rounds",
719 "Existing round(s) this puzzle belongs to",
723 round_options_block = []
727 "private_metadata": json.dumps({
728 "hunt_id": hunt['hunt_id'],
730 "title": {"type": "plain_text", "text": "New Puzzle"},
731 "submit": { "type": "plain_text", "text": "Create" },
733 section_block(text_block("*For {}*".format(hunt['name']))),
734 input_block("Puzzle name", "name", "Name of the puzzle"),
735 input_block("Puzzle URL", "url", "External URL of puzzle",
737 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
738 * round_options_block,
739 input_block("New round(s)", "new_rounds",
740 "New round(s) this puzzle belongs to " +
746 result = turb.slack_client.views_open(trigger_id=trigger_id,
750 submission_handlers[result['view']['id']] = new_puzzle_submission
754 def new_puzzle_submission(turb, payload, metadata):
755 """Handler for the user submitting the new puzzle modal
757 This is the modal view presented to the user by the new_puzzle
761 # First, read all the various data from the request
762 meta = json.loads(metadata)
763 hunt_id = meta['hunt_id']
765 state = payload['view']['state']['values']
766 name = state['name']['name']['value']
767 url = state['url']['url']['value']
768 if state['meta']['meta']['selected_options']:
771 puzzle_type = 'plain'
772 if 'rounds' in state:
773 rounds = [option['value'] for option in
774 state['rounds']['rounds']['selected_options']]
777 new_rounds = state['new_rounds']['new_rounds']['value']
779 # Before doing anything, reject this puzzle if a puzzle already
780 # exists with the same URL.
782 existing = find_puzzle_for_url(turb, hunt_id, url)
784 return submission_error(
786 "Error: A puzzle with this URL already exists.")
788 # Create a Slack-channel-safe puzzle_id
789 puzzle_id = puzzle_id_from_name(name)
791 # Create a channel for the puzzle
792 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
795 response = turb.slack_client.conversations_create(
796 name=hunt_dash_channel)
797 except SlackApiError as e:
798 return submission_error(
800 "Error creating Slack channel {}: {}"
801 .format(hunt_dash_channel, e.response['error']))
803 channel_id = response['channel']['id']
805 # Add any new rounds to the database
807 for round in new_rounds.split(','):
808 # Drop any leading/trailing spaces from the round name
809 round = round.strip()
810 # Ignore any empty string
817 'SK': 'round-' + round
821 # Construct a puzzle dict
824 "puzzle_id": puzzle_id,
825 "channel_id": channel_id,
827 "status": 'unsolved',
834 puzzle['rounds'] = rounds
836 # Finally, compute the appropriate sort key
837 puzzle["SK"] = puzzle_sort_key(puzzle)
839 # Insert the newly-created puzzle into the database
840 turb.table.put_item(Item=puzzle)
844 def state(turb, body, args):
845 """Implementation of the /state command
847 The args string should be a brief sentence describing where things
848 stand or what's needed."""
850 channel_id = body['channel_id'][0]
852 old_puzzle = puzzle_for_channel(turb, channel_id)
856 "Sorry, the /state command only works in a puzzle channel")
858 # Make a copy of the puzzle object
859 puzzle = old_puzzle.copy()
861 # Update the puzzle in the database
862 puzzle['state'] = args
863 turb.table.put_item(Item=puzzle)
865 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
869 commands["/state"] = state
871 def solved(turb, body, args):
872 """Implementation of the /solved command
874 The args string should be a confirmed solution."""
876 channel_id = body['channel_id'][0]
877 user_name = body['user_name'][0]
879 old_puzzle = puzzle_for_channel(turb, channel_id)
882 return bot_reply("Sorry, this is not a puzzle channel.")
886 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
888 # Make a copy of the puzzle object
889 puzzle = old_puzzle.copy()
891 # Set the status and solution fields in the database
892 puzzle['status'] = 'solved'
893 puzzle['solution'].append(args)
894 if 'state' in puzzle:
896 turb.table.put_item(Item=puzzle)
898 # Report the solution to the puzzle's channel
900 turb.slack_client, channel_id,
901 "Puzzle mark solved by {}: `{}`".format(user_name, args))
903 # Also report the solution to the hunt channel
904 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
906 turb.slack_client, hunt['channel_id'],
907 "Puzzle <{}|{}> has been solved!".format(
908 puzzle['channel_url'],
912 # And update the puzzle's description
913 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
917 commands["/solved"] = solved
919 def hunt(turb, body, args):
920 """Implementation of the /hunt command
922 The (optional) args string can be used to filter which puzzles to
923 display. The first word can be one of 'all', 'unsolved', or
924 'solved' and can be used to display only puzzles with the given
925 status. If this first word is missing, this command will display
926 only unsolved puzzles by default.
928 Any remaining text in the args string will be interpreted as
929 search terms. These will be split into separate terms on space
930 characters, (though quotation marks can be used to include a space
931 character in a term). All terms must match on a puzzle in order
932 for that puzzle to be included. But a puzzle will be considered to
933 match if any of the puzzle title, round title, puzzle URL, puzzle
934 state, or puzzle solution match. Matching will be performed
935 without regard to case sensitivity and the search terms can
936 include regular expression syntax.
939 channel_id = body['channel_id'][0]
940 response_url = body['response_url'][0]
944 # The first word can be a puzzle status and all remaining word
945 # (if any) are search terms. _But_, if the first word is not a
946 # valid puzzle status ('all', 'unsolved', 'solved'), then all
947 # words are search terms and we default status to 'unsolved'.
948 split_args = args.split(' ', 1)
949 status = split_args[0]
950 if (len(split_args) > 1):
951 terms = split_args[1]
952 if status not in ('unsolved', 'solved', 'all'):
958 # Separate search terms on spaces (but allow for quotation marks
959 # to capture spaces in a search term)
961 terms = shlex.split(terms)
963 hunt = hunt_for_channel(turb, channel_id)
966 return bot_reply("Sorry, this channel doesn't appear to "
967 + "be a hunt or puzzle channel")
969 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
971 requests.post(response_url,
972 json = { 'blocks': blocks },
973 headers = {'Content-type': 'application/json'}
978 commands["/hunt"] = hunt
980 def round(turb, body, args):
981 """Implementation of the /round command
983 Displays puzzles in the same round(s) as the puzzle for the
986 The (optional) args string can be used to filter which puzzles to
987 display. The first word can be one of 'all', 'unsolved', or
988 'solved' and can be used to display only puzzles with the given
989 status. If this first word is missing, this command will display
990 all puzzles in the round by default.
992 Any remaining text in the args string will be interpreted as
993 search terms. These will be split into separate terms on space
994 characters, (though quotation marks can be used to include a space
995 character in a term). All terms must match on a puzzle in order
996 for that puzzle to be included. But a puzzle will be considered to
997 match if any of the puzzle title, round title, puzzle URL, puzzle
998 state, or puzzle solution match. Matching will be performed
999 without regard to case sensitivity and the search terms can
1000 include regular expression syntax.
1003 channel_id = body['channel_id'][0]
1004 response_url = body['response_url'][0]
1006 puzzle = puzzle_for_channel(turb, channel_id)
1007 hunt = hunt_for_channel(turb, channel_id)
1012 "This is not a puzzle channel, but is a hunt channel. "
1013 + "Use /hunt if you want to see all rounds for this hunt.")
1016 "Sorry, this channel doesn't appear to be a puzzle channel "
1017 + "so the `/round` command cannot work here.")
1021 # The first word can be a puzzle status and all remaining word
1022 # (if any) are search terms. _But_, if the first word is not a
1023 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1024 # words are search terms and we default status to 'unsolved'.
1025 split_args = args.split(' ', 1)
1026 status = split_args[0]
1027 if (len(split_args) > 1):
1028 terms = split_args[1]
1029 if status not in ('unsolved', 'solved', 'all'):
1035 # Separate search terms on spaces (but allow for quotation marks
1036 # to capture spaces in a search term)
1038 terms = shlex.split(terms)
1040 blocks = hunt_blocks(turb, hunt,
1041 puzzle_status=status, search_terms=terms,
1042 limit_to_rounds=puzzle.get('rounds', [])
1045 requests.post(response_url,
1046 json = { 'blocks': blocks },
1047 headers = {'Content-type': 'application/json'}
1052 commands["/round"] = round