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(turb, body, args):
85 """Implementation of the `/edit` command
87 To edit the puzzle for the current channel.
89 This is simply a shortcut for `/puzzle edit`.
92 return edit_puzzle_command(turb, body)
94 commands["/edit"] = edit
97 def edit_puzzle_command(turb, body):
98 """Implementation of the `/puzzle edit` command
100 As dispatched from the puzzle() function.
103 channel_id = body['channel_id'][0]
104 trigger_id = body['trigger_id'][0]
106 puzzle = puzzle_for_channel(turb, channel_id)
109 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
111 return edit_puzzle(turb, puzzle, trigger_id)
115 def edit_puzzle_button(turb, payload):
116 """Handler for the action of user pressing an edit_puzzle button"""
118 action_id = payload['actions'][0]['action_id']
119 response_url = payload['response_url']
120 trigger_id = payload['trigger_id']
122 (hunt_id, sort_key) = action_id.split('-', 1)
124 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
127 requests.post(response_url,
128 json = {"text": "Error: Puzzle not found!"},
129 headers = {"Content-type": "application/json"})
130 return bot_reply("Error: Puzzle not found.")
132 return edit_puzzle(turb, puzzle, trigger_id)
134 actions['button']['edit_puzzle'] = edit_puzzle_button
136 def edit_puzzle(turb, puzzle, trigger_id):
137 """Common code for implementing an edit puzzle dialog
139 This implementation is common whether the edit operation was invoked
140 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
143 round_options = hunt_rounds(turb, puzzle['hunt_id'])
145 if len(round_options):
146 round_options_block = [
147 multi_select_block("Round(s)", "rounds",
148 "Existing round(s) this puzzle belongs to",
150 initial_options=puzzle.get("rounds", None)),
153 round_options_block = []
156 if puzzle.get("status", "unsolved") == solved:
160 solution_list = puzzle.get("solution", [])
162 solution_str = ", ".join(solution_list)
166 "private_metadata": json.dumps({
167 "hunt_id": puzzle['hunt_id'],
169 "puzzle_id": puzzle['puzzle_id'],
170 "channel_id": puzzle["channel_id"],
171 "channel_url": puzzle["channel_url"],
172 "sheet_url": puzzle["sheet_url"],
174 "title": {"type": "plain_text", "text": "Edit Puzzle"},
175 "submit": { "type": "plain_text", "text": "Save" },
177 input_block("Puzzle name", "name", "Name of the puzzle",
178 initial_value=puzzle["name"]),
179 input_block("Puzzle URL", "url", "External URL of puzzle",
180 initial_value=puzzle.get("url", None),
182 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
183 checked=(puzzle.get('type', 'plain') == 'meta')),
184 * round_options_block,
185 input_block("New round(s)", "new_rounds",
186 "New round(s) this puzzle belongs to " +
189 input_block("State", "state",
190 "State of this puzzle (partial progress, next steps)",
191 initial_value=puzzle.get("state", None),
194 "Puzzle status", "Solved", "solved",
195 checked=(puzzle.get('status', 'unsolved') == 'solved')),
196 input_block("Solution", "solution",
197 "Solution(s) (comma-separated if multiple)",
198 initial_value=solution_str,
203 result = turb.slack_client.views_open(trigger_id=trigger_id,
207 submission_handlers[result['view']['id']] = edit_puzzle_submission
211 def edit_puzzle_submission(turb, payload, metadata):
212 """Handler for the user submitting the edit puzzle modal
214 This is the modal view presented to the user by the edit_puzzle
220 # First, read all the various data from the request
221 meta = json.loads(metadata)
222 puzzle['hunt_id'] = meta['hunt_id']
223 puzzle['SK'] = meta['SK']
224 puzzle['puzzle_id'] = meta['puzzle_id']
225 puzzle['channel_id'] = meta['channel_id']
226 puzzle['channel_url'] = meta['channel_url']
227 puzzle['sheet_url'] = meta['sheet_url']
229 state = payload['view']['state']['values']
230 user_id = payload['user']['id']
232 puzzle['name'] = state['name']['name']['value']
233 url = state['url']['url']['value']
236 if state['meta']['meta']['selected_options']:
237 puzzle['type'] = 'meta'
239 puzzle['type'] = 'plain'
240 rounds = [option['value'] for option in
241 state['rounds']['rounds']['selected_options']]
243 puzzle['rounds'] = rounds
244 new_rounds = state['new_rounds']['new_rounds']['value']
245 puzzle_state = state['state']['state']['value']
247 puzzle['state'] = puzzle_state
248 if state['solved']['solved']['selected_options']:
249 puzzle['status'] = 'solved'
251 puzzle['status'] = 'unsolved'
252 puzzle['solution'] = []
253 solution = state['solution']['solution']['value']
255 puzzle['solution'] = [
256 sol.strip() for sol in solution.split(',')
259 # Verify that there's a solution if the puzzle is mark solved
260 if puzzle['status'] == 'solved' and not puzzle['solution']:
261 return submission_error("solution",
262 "A solved puzzle requires a solution.")
264 if puzzle['status'] == 'unsolved' and puzzle['solution']:
265 return submission_error("solution",
266 "An unsolved puzzle should have no solution.")
268 # Add any new rounds to the database
270 if 'rounds' not in puzzle:
271 puzzle['rounds'] = []
272 for round in new_rounds.split(','):
273 # Drop any leading/trailing spaces from the round name
274 round = round.strip()
275 # Ignore any empty string
278 puzzle['rounds'].append(round)
281 'hunt_id': puzzle['hunt_id'],
282 'SK': 'round-' + round
286 # Get old puzzle from the database (to determine what's changed)
287 old_puzzle = find_puzzle_for_sort_key(turb,
291 # If we are changing puzzle type (meta -> plain or plain -> meta)
292 # the the sort key has to change, so compute the new one and delete
293 # the old item from the database.
295 # XXX: We should really be using a transaction here to combine the
296 # delete_item and the put_item into a single transaction, but
297 # the boto interface is annoying in that transactions are only on
298 # the "Client" object which has a totally different interface than
299 # the "Table" object I've been using so I haven't figured out how
302 if puzzle['type'] != old_puzzle.get('type', 'plain'):
303 puzzle['SK'] = puzzle_sort_key(puzzle)
304 turb.table.delete_item(Key={
305 'hunt_id': old_puzzle['hunt_id'],
306 'SK': old_puzzle['SK']
309 # Update the puzzle in the database
310 turb.table.put_item(Item=puzzle)
312 # Inform the puzzle channel about the edit
313 edit_message = "Puzzle edited by <@{}>".format(user_id)
314 blocks = ([section_block(text_block(edit_message+":\n"))] +
315 puzzle_blocks(puzzle, include_rounds=True))
317 turb.slack_client, puzzle['channel_id'],
318 edit_message, blocks=blocks)
320 # Also inform the hunt if the puzzle's solved status changed
321 if puzzle['status'] != old_puzzle['status']:
322 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
323 if puzzle['status'] == 'solved':
324 message = "Puzzle <{}|{}> has been solved!".format(
325 puzzle['channel_url'],
328 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
329 puzzle['channel_url'],
331 slack_send_message(turb.slack_client, hunt['channel_id'], message)
333 # We need to set the channel topic if any of puzzle name, url,
334 # state, status, or solution, has changed. Let's just do that
335 # unconditionally here.
336 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
340 def new_hunt(turb, payload):
341 """Handler for the action of user pressing the new_hunt button"""
345 "private_metadata": json.dumps({}),
346 "title": { "type": "plain_text", "text": "New Hunt" },
347 "submit": { "type": "plain_text", "text": "Create" },
349 input_block("Hunt name", "name", "Name of the hunt"),
350 input_block("Hunt ID", "hunt_id",
351 "Used as puzzle channel prefix "
352 + "(no spaces nor punctuation)"),
353 input_block("Hunt URL", "url", "External URL of hunt",
358 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
361 submission_handlers[result['view']['id']] = new_hunt_submission
365 actions['button']['new_hunt'] = new_hunt
367 def new_hunt_submission(turb, payload, metadata):
368 """Handler for the user submitting the new hunt modal
370 This is the modal view presented to the user by the new_hunt
373 state = payload['view']['state']['values']
374 user_id = payload['user']['id']
375 name = state['name']['name']['value']
376 hunt_id = state['hunt_id']['hunt_id']['value']
377 url = state['url']['url']['value']
379 # Validate that the hunt_id contains no invalid characters
380 if not re.match(valid_id_re, hunt_id):
381 return submission_error("hunt_id",
382 "Hunt ID can only contain lowercase letters, "
383 + "numbers, and underscores")
385 # Check to see if the turbot table exists
387 exists = turb.table.table_status in ("CREATING", "UPDATING",
392 # Create the turbot table if necessary.
394 turb.table = turb.db.create_table(
397 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
398 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
400 AttributeDefinitions=[
401 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
402 {'AttributeName': 'SK', 'AttributeType': 'S'},
403 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
404 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
405 {'AttributeName': 'url', 'AttributeType': 'S'}
407 ProvisionedThroughput={
408 'ReadCapacityUnits': 5,
409 'WriteCapacityUnits': 5
411 GlobalSecondaryIndexes=[
413 'IndexName': 'channel_id_index',
415 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
418 'ProjectionType': 'ALL'
420 'ProvisionedThroughput': {
421 'ReadCapacityUnits': 5,
422 'WriteCapacityUnits': 5
426 'IndexName': 'is_hunt_index',
428 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
431 'ProjectionType': 'ALL'
433 'ProvisionedThroughput': {
434 'ReadCapacityUnits': 5,
435 'WriteCapacityUnits': 5
439 LocalSecondaryIndexes = [
441 'IndexName': 'url_index',
443 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
444 {'AttributeName': 'url', 'KeyType': 'RANGE'},
447 'ProjectionType': 'ALL'
452 return submission_error(
454 "Still bootstrapping turbot table. Try again in a minute, please.")
456 # Create a channel for the hunt
458 response = turb.slack_client.conversations_create(name=hunt_id)
459 except SlackApiError as e:
460 return submission_error("hunt_id",
461 "Error creating Slack channel: {}"
462 .format(e.response['error']))
464 channel_id = response['channel']['id']
466 # Insert the newly-created hunt into the database
467 # (leaving it as non-active for now until the channel-created handler
468 # finishes fixing it up with a sheet and a companion table)
471 "SK": "hunt-{}".format(hunt_id),
473 "channel_id": channel_id,
479 turb.table.put_item(Item=item)
481 # Invite the initiating user to the channel
482 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
486 def view_submission(turb, payload):
487 """Handler for Slack interactive view submission
489 Specifically, those that have a payload type of 'view_submission'"""
491 view_id = payload['view']['id']
492 metadata = payload['view']['private_metadata']
494 if view_id in submission_handlers:
495 return submission_handlers[view_id](turb, payload, metadata)
497 print("Error: Unknown view ID: {}".format(view_id))
502 def rot(turb, body, args):
503 """Implementation of the /rot command
505 The args string should be as follows:
507 [count|*] String to be rotated
509 That is, the first word of the string is an optional number (or
510 the character '*'). If this is a number it indicates an amount to
511 rotate each character in the string. If the count is '*' or is not
512 present, then the string will be rotated through all possible 25
515 The result of the rotation is returned (with Slack formatting) in
516 the body of the response so that Slack will provide it as a reply
517 to the user who submitted the slash command."""
519 channel_name = body['channel_name'][0]
520 response_url = body['response_url'][0]
521 channel_id = body['channel_id'][0]
523 result = turbot.rot.rot(args)
525 if (channel_name == "directmessage"):
526 requests.post(response_url,
527 json = {"text": result},
528 headers = {"Content-type": "application/json"})
530 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
534 commands["/rot"] = rot
536 def get_table_item(turb, table_name, key, value):
537 """Get an item from the database 'table_name' with 'key' as 'value'
539 Returns a tuple of (item, table) if found and (None, None) otherwise."""
541 table = turb.db.Table(table_name)
543 response = table.get_item(Key={key: value})
545 if 'Item' in response:
546 return (response['Item'], table)
550 def db_entry_for_channel(turb, channel_id):
551 """Given a channel ID return the database item for this channel
553 If this channel is a registered hunt or puzzle channel, return the
554 corresponding row from the database for this channel. Otherwise,
557 Note: If you need to specifically ensure that the channel is a
558 puzzle or a hunt, please call puzzle_for_channel or
559 hunt_for_channel respectively.
562 response = turb.table.query(
563 IndexName = "channel_id_index",
564 KeyConditionExpression=Key("channel_id").eq(channel_id)
567 if response['Count'] == 0:
570 return response['Items'][0]
573 def puzzle_for_channel(turb, channel_id):
575 """Given a channel ID return the puzzle from the database for this channel
577 If the given channel_id is a puzzle's channel, this function
578 returns a dict filled with the attributes from the puzzle's entry
581 Otherwise, this function returns None.
584 entry = db_entry_for_channel(turb, channel_id)
586 if entry and entry['SK'].startswith('puzzle-'):
591 def hunt_for_channel(turb, channel_id):
593 """Given a channel ID return the hunt from the database for this channel
595 This works whether the original channel is a primary hunt channel,
596 or if it is one of the channels of a puzzle belonging to the hunt.
598 Returns None if channel does not belong to a hunt, otherwise a
599 dictionary with all fields from the hunt's row in the table,
600 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
603 entry = db_entry_for_channel(turb, channel_id)
605 # We're done if this channel doesn't exist in the database at all
609 # Also done if this channel is a hunt channel
610 if entry['SK'].startswith('hunt-'):
613 # Otherwise, (the channel is in the database, but is not a hunt),
614 # we expect this to be a puzzle channel instead
615 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
617 # python3.9 has a built-in removeprefix but AWS only has python3.8
618 def remove_prefix(text, prefix):
619 if text.startswith(prefix):
620 return text[len(prefix):]
623 def hunt_rounds(turb, hunt_id):
624 """Returns array of strings giving rounds that exist in the given hunt"""
626 response = turb.table.query(
627 KeyConditionExpression=(
628 Key('hunt_id').eq(hunt_id) &
629 Key('SK').begins_with('round-')
633 if response['Count'] == 0:
636 return [remove_prefix(option['SK'], 'round-')
637 for option in response['Items']]
639 def puzzle(turb, body, args):
640 """Implementation of the /puzzle command
642 The args string can be a sub-command:
644 /puzzle new: Bring up a dialog to create a new puzzle
646 /puzzle edit: Edit the puzzle for the current channel
648 Or with no argument at all:
650 /puzzle: Print details of the current puzzle (if in a puzzle channel)
654 return new_puzzle(turb, body)
657 return edit_puzzle_command(turb, body)
660 return bot_reply("Unknown syntax for `/puzzle` command. " +
661 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
662 "and `/puzzle new` to display, edit, or create " +
665 # For no arguments we print the current puzzle as a reply
666 channel_id = body['channel_id'][0]
667 response_url = body['response_url'][0]
669 puzzle = puzzle_for_channel(turb, channel_id)
672 hunt = hunt_for_channel(turb, channel_id)
675 "This is not a puzzle channel, but is a hunt channel. "
676 + "If you want to create a new puzzle for this hunt, use "
680 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
681 + "channel, so the `/puzzle` command cannot work here.")
683 blocks = puzzle_blocks(puzzle, include_rounds=True)
685 # For a meta puzzle, also display the titles and solutions for all
686 # puzzles in the same round.
687 if puzzle['type'] == 'meta':
688 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
690 # Drop this puzzle itself from the report
691 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
693 for round in puzzle.get('rounds', [None]):
694 answers = round_quoted_puzzles_titles_answers(round, puzzles)
696 section_block(text_block(
697 "*Feeder solutions from round {}*".format(
698 round if round else "<none>"
700 section_block(text_block(answers))
703 requests.post(response_url,
704 json = {'blocks': blocks},
705 headers = {'Content-type': 'application/json'}
710 commands["/puzzle"] = puzzle
712 def new(turb, body, args):
713 """Implementation of the `/new` command
715 To create a new puzzle.
717 This is simply a shortcut for `/puzzle new`.
720 return new_puzzle(turb, body)
722 commands["/new"] = new
724 def new_puzzle(turb, body):
725 """Implementation of the "/puzzle new" command
727 This brings up a dialog box for creating a new puzzle.
730 channel_id = body['channel_id'][0]
731 trigger_id = body['trigger_id'][0]
733 hunt = hunt_for_channel(turb, channel_id)
736 return bot_reply("Sorry, this channel doesn't appear to "
737 + "be a hunt or puzzle channel")
739 round_options = hunt_rounds(turb, hunt['hunt_id'])
741 if len(round_options):
742 round_options_block = [
743 multi_select_block("Round(s)", "rounds",
744 "Existing round(s) this puzzle belongs to",
748 round_options_block = []
752 "private_metadata": json.dumps({
753 "hunt_id": hunt['hunt_id'],
755 "title": {"type": "plain_text", "text": "New Puzzle"},
756 "submit": { "type": "plain_text", "text": "Create" },
758 section_block(text_block("*For {}*".format(hunt['name']))),
759 input_block("Puzzle name", "name", "Name of the puzzle"),
760 input_block("Puzzle URL", "url", "External URL of puzzle",
762 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
763 * round_options_block,
764 input_block("New round(s)", "new_rounds",
765 "New round(s) this puzzle belongs to " +
771 result = turb.slack_client.views_open(trigger_id=trigger_id,
775 submission_handlers[result['view']['id']] = new_puzzle_submission
779 def new_puzzle_submission(turb, payload, metadata):
780 """Handler for the user submitting the new puzzle modal
782 This is the modal view presented to the user by the new_puzzle
786 # First, read all the various data from the request
787 meta = json.loads(metadata)
788 hunt_id = meta['hunt_id']
790 state = payload['view']['state']['values']
791 name = state['name']['name']['value']
792 url = state['url']['url']['value']
793 if state['meta']['meta']['selected_options']:
796 puzzle_type = 'plain'
797 if 'rounds' in state:
798 rounds = [option['value'] for option in
799 state['rounds']['rounds']['selected_options']]
802 new_rounds = state['new_rounds']['new_rounds']['value']
804 # Before doing anything, reject this puzzle if a puzzle already
805 # exists with the same URL.
807 existing = find_puzzle_for_url(turb, hunt_id, url)
809 return submission_error(
811 "Error: A puzzle with this URL already exists.")
813 # Create a Slack-channel-safe puzzle_id
814 puzzle_id = puzzle_id_from_name(name)
816 # Create a channel for the puzzle
817 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
820 response = turb.slack_client.conversations_create(
821 name=hunt_dash_channel)
822 except SlackApiError as e:
823 return submission_error(
825 "Error creating Slack channel {}: {}"
826 .format(hunt_dash_channel, e.response['error']))
828 channel_id = response['channel']['id']
830 # Add any new rounds to the database
832 for round in new_rounds.split(','):
833 # Drop any leading/trailing spaces from the round name
834 round = round.strip()
835 # Ignore any empty string
842 'SK': 'round-' + round
846 # Construct a puzzle dict
849 "puzzle_id": puzzle_id,
850 "channel_id": channel_id,
852 "status": 'unsolved',
859 puzzle['rounds'] = rounds
861 # Finally, compute the appropriate sort key
862 puzzle["SK"] = puzzle_sort_key(puzzle)
864 # Insert the newly-created puzzle into the database
865 turb.table.put_item(Item=puzzle)
869 def state(turb, body, args):
870 """Implementation of the /state command
872 The args string should be a brief sentence describing where things
873 stand or what's needed."""
875 channel_id = body['channel_id'][0]
877 old_puzzle = puzzle_for_channel(turb, channel_id)
881 "Sorry, the /state command only works in a puzzle channel")
883 # Make a copy of the puzzle object
884 puzzle = old_puzzle.copy()
886 # Update the puzzle in the database
887 puzzle['state'] = args
888 turb.table.put_item(Item=puzzle)
890 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
894 commands["/state"] = state
896 def solved(turb, body, args):
897 """Implementation of the /solved command
899 The args string should be a confirmed solution."""
901 channel_id = body['channel_id'][0]
902 user_name = body['user_name'][0]
904 old_puzzle = puzzle_for_channel(turb, channel_id)
907 return bot_reply("Sorry, this is not a puzzle channel.")
911 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
913 # Make a copy of the puzzle object
914 puzzle = old_puzzle.copy()
916 # Set the status and solution fields in the database
917 puzzle['status'] = 'solved'
918 puzzle['solution'].append(args)
919 if 'state' in puzzle:
921 turb.table.put_item(Item=puzzle)
923 # Report the solution to the puzzle's channel
925 turb.slack_client, channel_id,
926 "Puzzle mark solved by {}: `{}`".format(user_name, args))
928 # Also report the solution to the hunt channel
929 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
931 turb.slack_client, hunt['channel_id'],
932 "Puzzle <{}|{}> has been solved!".format(
933 puzzle['channel_url'],
937 # And update the puzzle's description
938 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
942 commands["/solved"] = solved
944 def hunt(turb, body, args):
945 """Implementation of the /hunt command
947 The (optional) args string can be used to filter which puzzles to
948 display. The first word can be one of 'all', 'unsolved', or
949 'solved' and can be used to display only puzzles with the given
950 status. If this first word is missing, this command will display
951 only unsolved puzzles by default.
953 Any remaining text in the args string will be interpreted as
954 search terms. These will be split into separate terms on space
955 characters, (though quotation marks can be used to include a space
956 character in a term). All terms must match on a puzzle in order
957 for that puzzle to be included. But a puzzle will be considered to
958 match if any of the puzzle title, round title, puzzle URL, puzzle
959 state, or puzzle solution match. Matching will be performed
960 without regard to case sensitivity and the search terms can
961 include regular expression syntax.
964 channel_id = body['channel_id'][0]
965 response_url = body['response_url'][0]
969 # The first word can be a puzzle status and all remaining word
970 # (if any) are search terms. _But_, if the first word is not a
971 # valid puzzle status ('all', 'unsolved', 'solved'), then all
972 # words are search terms and we default status to 'unsolved'.
973 split_args = args.split(' ', 1)
974 status = split_args[0]
975 if (len(split_args) > 1):
976 terms = split_args[1]
977 if status not in ('unsolved', 'solved', 'all'):
983 # Separate search terms on spaces (but allow for quotation marks
984 # to capture spaces in a search term)
986 terms = shlex.split(terms)
988 hunt = hunt_for_channel(turb, channel_id)
991 return bot_reply("Sorry, this channel doesn't appear to "
992 + "be a hunt or puzzle channel")
994 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
996 requests.post(response_url,
997 json = { 'blocks': blocks },
998 headers = {'Content-type': 'application/json'}
1003 commands["/hunt"] = hunt
1005 def round(turb, body, args):
1006 """Implementation of the /round command
1008 Displays puzzles in the same round(s) as the puzzle for the
1011 The (optional) args string can be used to filter which puzzles to
1012 display. The first word can be one of 'all', 'unsolved', or
1013 'solved' and can be used to display only puzzles with the given
1014 status. If this first word is missing, this command will display
1015 all puzzles in the round by default.
1017 Any remaining text in the args string will be interpreted as
1018 search terms. These will be split into separate terms on space
1019 characters, (though quotation marks can be used to include a space
1020 character in a term). All terms must match on a puzzle in order
1021 for that puzzle to be included. But a puzzle will be considered to
1022 match if any of the puzzle title, round title, puzzle URL, puzzle
1023 state, or puzzle solution match. Matching will be performed
1024 without regard to case sensitivity and the search terms can
1025 include regular expression syntax.
1028 channel_id = body['channel_id'][0]
1029 response_url = body['response_url'][0]
1031 puzzle = puzzle_for_channel(turb, channel_id)
1032 hunt = hunt_for_channel(turb, channel_id)
1037 "This is not a puzzle channel, but is a hunt channel. "
1038 + "Use /hunt if you want to see all rounds for this hunt.")
1041 "Sorry, this channel doesn't appear to be a puzzle channel "
1042 + "so the `/round` command cannot work here.")
1046 # The first word can be a puzzle status and all remaining word
1047 # (if any) are search terms. _But_, if the first word is not a
1048 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1049 # words are search terms and we default status to 'unsolved'.
1050 split_args = args.split(' ', 1)
1051 status = split_args[0]
1052 if (len(split_args) > 1):
1053 terms = split_args[1]
1054 if status not in ('unsolved', 'solved', 'all'):
1060 # Separate search terms on spaces (but allow for quotation marks
1061 # to capture spaces in a search term)
1063 terms = shlex.split(terms)
1065 blocks = hunt_blocks(turb, hunt,
1066 puzzle_status=status, search_terms=terms,
1067 limit_to_rounds=puzzle.get('rounds', [])
1070 requests.post(response_url,
1071 json = { 'blocks': blocks },
1072 headers = {'Content-type': 'application/json'}
1077 commands["/round"] = round