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,
19 from turbot.round import round_quoted_puzzles_titles_answers
26 from botocore.exceptions import ClientError
27 from boto3.dynamodb.conditions import Key
28 from turbot.slack import slack_send_message
32 actions['button'] = {}
34 submission_handlers = {}
36 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
38 # Note: This restriction not only allows for hunt and puzzle ID values to
39 # be used as Slack channel names, but it also allows for '-' as a valid
40 # separator between a hunt and a puzzle ID (for example in the puzzle
41 # edit dialog where a single attribute must capture both values).
42 valid_id_re = r'^[_a-z0-9]+$'
44 lambda_ok = {'statusCode': 200}
46 def bot_reply(message):
47 """Construct a return value suitable for a bot reply
49 This is suitable as a way to give an error back to the user who
50 initiated a slash command, for example."""
57 def submission_error(field, error):
58 """Construct an error suitable for returning for an invalid submission.
60 Returning this value will prevent a submission and alert the user that
61 the given field is invalid because of the given error."""
63 print("Rejecting invalid modal submission: {}".format(error))
68 "Content-Type": "application/json"
71 "response_action": "errors",
78 def multi_static_select(turb, payload):
79 """Handler for the action of user entering a multi-select value"""
83 actions['multi_static_select'] = {"*": multi_static_select}
85 def edit(turb, body, args):
87 """Implementation of the `/edit` command
89 To edit the puzzle for the current channel.
91 This is simply a shortcut for `/puzzle edit`.
94 return edit_puzzle_command(turb, body)
96 commands["/edit"] = edit
99 def edit_puzzle_command(turb, body):
100 """Implementation of the `/puzzle edit` command
102 As dispatched from the puzzle() function.
105 channel_id = body['channel_id'][0]
106 trigger_id = body['trigger_id'][0]
108 puzzle = puzzle_for_channel(turb, channel_id)
111 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
113 return edit_puzzle(turb, puzzle, trigger_id)
117 def edit_puzzle_button(turb, payload):
118 """Handler for the action of user pressing an edit_puzzle button"""
120 action_id = payload['actions'][0]['action_id']
121 response_url = payload['response_url']
122 trigger_id = payload['trigger_id']
124 (hunt_id, sort_key) = action_id.split('-', 1)
126 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
129 requests.post(response_url,
130 json = {"text": "Error: Puzzle not found!"},
131 headers = {"Content-type": "application/json"})
132 return bot_reply("Error: Puzzle not found.")
134 return edit_puzzle(turb, puzzle, trigger_id)
136 actions['button']['edit_puzzle'] = edit_puzzle_button
138 def edit_puzzle(turb, puzzle, trigger_id):
139 """Common code for implementing an edit puzzle dialog
141 This implementation is common whether the edit operation was invoked
142 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
145 round_options = hunt_rounds(turb, puzzle['hunt_id'])
147 if len(round_options):
148 round_options_block = [
149 multi_select_block("Round(s)", "rounds",
150 "Existing round(s) this puzzle belongs to",
152 initial_options=puzzle.get("rounds", None)),
155 round_options_block = []
158 if puzzle.get("status", "unsolved") == solved:
162 solution_list = puzzle.get("solution", [])
164 solution_str = ", ".join(solution_list)
168 "private_metadata": json.dumps({
169 "hunt_id": puzzle['hunt_id'],
171 "puzzle_id": puzzle['puzzle_id'],
172 "channel_id": puzzle["channel_id"],
173 "channel_url": puzzle["channel_url"],
174 "sheet_url": puzzle["sheet_url"],
176 "title": {"type": "plain_text", "text": "Edit Puzzle"},
177 "submit": { "type": "plain_text", "text": "Save" },
179 input_block("Puzzle name", "name", "Name of the puzzle",
180 initial_value=puzzle["name"]),
181 input_block("Puzzle URL", "url", "External URL of puzzle",
182 initial_value=puzzle.get("url", None),
184 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
185 checked=(puzzle.get('type', 'plain') == 'meta')),
186 * round_options_block,
187 input_block("New round(s)", "new_rounds",
188 "New round(s) this puzzle belongs to " +
191 input_block("State", "state",
192 "State of this puzzle (partial progress, next steps)",
193 initial_value=puzzle.get("state", None),
196 "Puzzle status", "Solved", "solved",
197 checked=(puzzle.get('status', 'unsolved') == 'solved')),
198 input_block("Solution", "solution",
199 "Solution(s) (comma-separated if multiple)",
200 initial_value=solution_str,
205 result = turb.slack_client.views_open(trigger_id=trigger_id,
209 submission_handlers[result['view']['id']] = edit_puzzle_submission
213 def edit_puzzle_submission(turb, payload, metadata):
214 """Handler for the user submitting the edit puzzle modal
216 This is the modal view presented to the user by the edit_puzzle
222 # First, read all the various data from the request
223 meta = json.loads(metadata)
224 puzzle['hunt_id'] = meta['hunt_id']
225 puzzle['SK'] = meta['SK']
226 puzzle['puzzle_id'] = meta['puzzle_id']
227 puzzle['channel_id'] = meta['channel_id']
228 puzzle['channel_url'] = meta['channel_url']
229 puzzle['sheet_url'] = meta['sheet_url']
231 state = payload['view']['state']['values']
232 user_id = payload['user']['id']
234 puzzle['name'] = state['name']['name']['value']
235 url = state['url']['url']['value']
238 if state['meta']['meta']['selected_options']:
239 puzzle['type'] = 'meta'
241 puzzle['type'] = 'plain'
242 rounds = [option['value'] for option in
243 state['rounds']['rounds']['selected_options']]
245 puzzle['rounds'] = rounds
246 new_rounds = state['new_rounds']['new_rounds']['value']
247 puzzle_state = state['state']['state']['value']
249 puzzle['state'] = puzzle_state
250 if state['solved']['solved']['selected_options']:
251 puzzle['status'] = 'solved'
253 puzzle['status'] = 'unsolved'
254 puzzle['solution'] = []
255 solution = state['solution']['solution']['value']
257 puzzle['solution'] = [
258 sol.strip() for sol in solution.split(',')
261 # Verify that there's a solution if the puzzle is mark solved
262 if puzzle['status'] == 'solved' and not puzzle['solution']:
263 return submission_error("solution",
264 "A solved puzzle requires a solution.")
266 if puzzle['status'] == 'unsolved' and puzzle['solution']:
267 return submission_error("solution",
268 "An unsolved puzzle should have no solution.")
270 # Add any new rounds to the database
272 if 'rounds' not in puzzle:
273 puzzle['rounds'] = []
274 for round in new_rounds.split(','):
275 # Drop any leading/trailing spaces from the round name
276 round = round.strip()
277 # Ignore any empty string
280 puzzle['rounds'].append(round)
283 'hunt_id': puzzle['hunt_id'],
284 'SK': 'round-' + round
288 # Get old puzzle from the database (to determine what's changed)
289 old_puzzle = find_puzzle_for_sort_key(turb,
293 # If we are changing puzzle type (meta -> plain or plain -> meta)
294 # the the sort key has to change, so compute the new one and delete
295 # the old item from the database.
297 # XXX: We should really be using a transaction here to combine the
298 # delete_item and the put_item into a single transaction, but
299 # the boto interface is annoying in that transactions are only on
300 # the "Client" object which has a totally different interface than
301 # the "Table" object I've been using so I haven't figured out how
304 if puzzle['type'] != old_puzzle.get('type', 'plain'):
305 puzzle['SK'] = puzzle_sort_key(puzzle)
306 turb.table.delete_item(Key={
307 'hunt_id': old_puzzle['hunt_id'],
308 'SK': old_puzzle['SK']
311 # Update the puzzle in the database
312 turb.table.put_item(Item=puzzle)
314 # Inform the puzzle channel about the edit
315 edit_message = "Puzzle edited by <@{}>".format(user_id)
316 blocks = ([section_block(text_block(edit_message+":\n"))] +
317 puzzle_blocks(puzzle, include_rounds=True))
319 turb.slack_client, puzzle['channel_id'],
320 edit_message, blocks=blocks)
322 # Also inform the hunt if the puzzle's solved status changed
323 if puzzle['status'] != old_puzzle['status']:
324 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
325 if puzzle['status'] == 'solved':
326 message = "Puzzle <{}|{}> has been solved!".format(
327 puzzle['channel_url'],
330 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
331 puzzle['channel_url'],
333 slack_send_message(turb.slack_client, hunt['channel_id'], message)
335 # We need to set the channel topic if any of puzzle name, url,
336 # state, status, or solution, has changed. Let's just do that
337 # unconditionally here.
338 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
342 def new_hunt(turb, payload):
343 """Handler for the action of user pressing the new_hunt button"""
347 "private_metadata": json.dumps({}),
348 "title": { "type": "plain_text", "text": "New Hunt" },
349 "submit": { "type": "plain_text", "text": "Create" },
351 input_block("Hunt name", "name", "Name of the hunt"),
352 input_block("Hunt ID", "hunt_id",
353 "Used as puzzle channel prefix "
354 + "(no spaces nor punctuation)"),
355 input_block("Hunt URL", "url", "External URL of hunt",
360 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
363 submission_handlers[result['view']['id']] = new_hunt_submission
367 actions['button']['new_hunt'] = new_hunt
369 def new_hunt_submission(turb, payload, metadata):
370 """Handler for the user submitting the new hunt modal
372 This is the modal view presented to the user by the new_hunt
375 state = payload['view']['state']['values']
376 user_id = payload['user']['id']
377 name = state['name']['name']['value']
378 hunt_id = state['hunt_id']['hunt_id']['value']
379 url = state['url']['url']['value']
381 # Validate that the hunt_id contains no invalid characters
382 if not re.match(valid_id_re, hunt_id):
383 return submission_error("hunt_id",
384 "Hunt ID can only contain lowercase letters, "
385 + "numbers, and underscores")
387 # Check to see if the turbot table exists
389 exists = turb.table.table_status in ("CREATING", "UPDATING",
394 # Create the turbot table if necessary.
396 turb.table = turb.db.create_table(
399 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
400 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
402 AttributeDefinitions=[
403 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
404 {'AttributeName': 'SK', 'AttributeType': 'S'},
405 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
406 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
407 {'AttributeName': 'url', 'AttributeType': 'S'}
409 ProvisionedThroughput={
410 'ReadCapacityUnits': 5,
411 'WriteCapacityUnits': 5
413 GlobalSecondaryIndexes=[
415 'IndexName': 'channel_id_index',
417 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
420 'ProjectionType': 'ALL'
422 'ProvisionedThroughput': {
423 'ReadCapacityUnits': 5,
424 'WriteCapacityUnits': 5
428 'IndexName': 'is_hunt_index',
430 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
433 'ProjectionType': 'ALL'
435 'ProvisionedThroughput': {
436 'ReadCapacityUnits': 5,
437 'WriteCapacityUnits': 5
441 LocalSecondaryIndexes = [
443 'IndexName': 'url_index',
445 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
446 {'AttributeName': 'url', 'KeyType': 'RANGE'},
449 'ProjectionType': 'ALL'
454 return submission_error(
456 "Still bootstrapping turbot table. Try again in a minute, please.")
458 # Create a channel for the hunt
460 response = turb.slack_client.conversations_create(name=hunt_id)
461 except SlackApiError as e:
462 return submission_error("hunt_id",
463 "Error creating Slack channel: {}"
464 .format(e.response['error']))
466 channel_id = response['channel']['id']
468 # Insert the newly-created hunt into the database
469 # (leaving it as non-active for now until the channel-created handler
470 # finishes fixing it up with a sheet and a companion table)
473 "SK": "hunt-{}".format(hunt_id),
475 "channel_id": channel_id,
481 turb.table.put_item(Item=item)
483 # Invite the initiating user to the channel
484 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
488 def view_submission(turb, payload):
489 """Handler for Slack interactive view submission
491 Specifically, those that have a payload type of 'view_submission'"""
493 view_id = payload['view']['id']
494 metadata = payload['view']['private_metadata']
496 if view_id in submission_handlers:
497 return submission_handlers[view_id](turb, payload, metadata)
499 print("Error: Unknown view ID: {}".format(view_id))
504 def rot(turb, body, args):
505 """Implementation of the /rot command
507 The args string should be as follows:
509 [count|*] String to be rotated
511 That is, the first word of the string is an optional number (or
512 the character '*'). If this is a number it indicates an amount to
513 rotate each character in the string. If the count is '*' or is not
514 present, then the string will be rotated through all possible 25
517 The result of the rotation is returned (with Slack formatting) in
518 the body of the response so that Slack will provide it as a reply
519 to the user who submitted the slash command."""
521 channel_name = body['channel_name'][0]
522 response_url = body['response_url'][0]
523 channel_id = body['channel_id'][0]
525 result = turbot.rot.rot(args)
527 if (channel_name == "directmessage"):
528 requests.post(response_url,
529 json = {"text": result},
530 headers = {"Content-type": "application/json"})
532 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
536 commands["/rot"] = rot
538 def get_table_item(turb, table_name, key, value):
539 """Get an item from the database 'table_name' with 'key' as 'value'
541 Returns a tuple of (item, table) if found and (None, None) otherwise."""
543 table = turb.db.Table(table_name)
545 response = table.get_item(Key={key: value})
547 if 'Item' in response:
548 return (response['Item'], table)
552 def db_entry_for_channel(turb, channel_id):
553 """Given a channel ID return the database item for this channel
555 If this channel is a registered hunt or puzzle channel, return the
556 corresponding row from the database for this channel. Otherwise,
559 Note: If you need to specifically ensure that the channel is a
560 puzzle or a hunt, please call puzzle_for_channel or
561 hunt_for_channel respectively.
564 response = turb.table.query(
565 IndexName = "channel_id_index",
566 KeyConditionExpression=Key("channel_id").eq(channel_id)
569 if response['Count'] == 0:
572 return response['Items'][0]
575 def puzzle_for_channel(turb, channel_id):
577 """Given a channel ID return the puzzle from the database for this channel
579 If the given channel_id is a puzzle's channel, this function
580 returns a dict filled with the attributes from the puzzle's entry
583 Otherwise, this function returns None.
586 entry = db_entry_for_channel(turb, channel_id)
588 if entry and entry['SK'].startswith('puzzle-'):
593 def hunt_for_channel(turb, channel_id):
595 """Given a channel ID return the hunt from the database for this channel
597 This works whether the original channel is a primary hunt channel,
598 or if it is one of the channels of a puzzle belonging to the hunt.
600 Returns None if channel does not belong to a hunt, otherwise a
601 dictionary with all fields from the hunt's row in the table,
602 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
605 entry = db_entry_for_channel(turb, channel_id)
607 # We're done if this channel doesn't exist in the database at all
611 # Also done if this channel is a hunt channel
612 if entry['SK'].startswith('hunt-'):
615 # Otherwise, (the channel is in the database, but is not a hunt),
616 # we expect this to be a puzzle channel instead
617 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
619 # python3.9 has a built-in removeprefix but AWS only has python3.8
620 def remove_prefix(text, prefix):
621 if text.startswith(prefix):
622 return text[len(prefix):]
625 def hunt_rounds(turb, hunt_id):
626 """Returns array of strings giving rounds that exist in the given hunt"""
628 response = turb.table.query(
629 KeyConditionExpression=(
630 Key('hunt_id').eq(hunt_id) &
631 Key('SK').begins_with('round-')
635 if response['Count'] == 0:
638 return [remove_prefix(option['SK'], 'round-')
639 for option in response['Items']]
641 def puzzle(turb, body, args):
642 """Implementation of the /puzzle command
644 The args string can be a sub-command:
646 /puzzle new: Bring up a dialog to create a new puzzle
648 /puzzle edit: Edit the puzzle for the current channel
650 Or with no argument at all:
652 /puzzle: Print details of the current puzzle (if in a puzzle channel)
656 return new_puzzle(turb, body)
659 return edit_puzzle_command(turb, body)
662 return bot_reply("Unknown syntax for `/puzzle` command. " +
663 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
664 "and `/puzzle new` to display, edit, or create " +
667 # For no arguments we print the current puzzle as a reply
668 channel_id = body['channel_id'][0]
669 response_url = body['response_url'][0]
671 puzzle = puzzle_for_channel(turb, channel_id)
674 hunt = hunt_for_channel(turb, channel_id)
677 "This is not a puzzle channel, but is a hunt channel. "
678 + "If you want to create a new puzzle for this hunt, use "
682 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
683 + "channel, so the `/puzzle` command cannot work here.")
685 blocks = puzzle_blocks(puzzle, include_rounds=True)
687 # For a meta puzzle, also display the titles and solutions for all
688 # puzzles in the same round.
689 if puzzle['type'] == 'meta':
690 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
692 # Drop this puzzle itself from the report
693 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
695 for round in puzzle.get('rounds', [None]):
696 answers = round_quoted_puzzles_titles_answers(round, puzzles)
698 section_block(text_block(
699 "*Feeder solutions from round {}*".format(
700 round if round else "<none>"
702 section_block(text_block(answers))
705 requests.post(response_url,
706 json = {'blocks': blocks},
707 headers = {'Content-type': 'application/json'}
712 commands["/puzzle"] = puzzle
714 def new(turb, body, args):
715 """Implementation of the `/new` command
717 To create a new puzzle.
719 This is simply a shortcut for `/puzzle new`.
722 return new_puzzle(turb, body)
724 commands["/new"] = new
726 def new_puzzle(turb, body):
727 """Implementation of the "/puzzle new" command
729 This brings up a dialog box for creating a new puzzle.
732 channel_id = body['channel_id'][0]
733 trigger_id = body['trigger_id'][0]
735 hunt = hunt_for_channel(turb, channel_id)
738 return bot_reply("Sorry, this channel doesn't appear to "
739 + "be a hunt or puzzle channel")
741 round_options = hunt_rounds(turb, hunt['hunt_id'])
743 if len(round_options):
744 round_options_block = [
745 multi_select_block("Round(s)", "rounds",
746 "Existing round(s) this puzzle belongs to",
750 round_options_block = []
754 "private_metadata": json.dumps({
755 "hunt_id": hunt['hunt_id'],
757 "title": {"type": "plain_text", "text": "New Puzzle"},
758 "submit": { "type": "plain_text", "text": "Create" },
760 section_block(text_block("*For {}*".format(hunt['name']))),
761 input_block("Puzzle name", "name", "Name of the puzzle"),
762 input_block("Puzzle URL", "url", "External URL of puzzle",
764 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
765 * round_options_block,
766 input_block("New round(s)", "new_rounds",
767 "New round(s) this puzzle belongs to " +
773 result = turb.slack_client.views_open(trigger_id=trigger_id,
777 submission_handlers[result['view']['id']] = new_puzzle_submission
781 def new_puzzle_submission(turb, payload, metadata):
782 """Handler for the user submitting the new puzzle modal
784 This is the modal view presented to the user by the new_puzzle
788 # First, read all the various data from the request
789 meta = json.loads(metadata)
790 hunt_id = meta['hunt_id']
792 state = payload['view']['state']['values']
793 name = state['name']['name']['value']
794 url = state['url']['url']['value']
795 if state['meta']['meta']['selected_options']:
798 puzzle_type = 'plain'
799 if 'rounds' in state:
800 rounds = [option['value'] for option in
801 state['rounds']['rounds']['selected_options']]
804 new_rounds = state['new_rounds']['new_rounds']['value']
806 # Before doing anything, reject this puzzle if a puzzle already
807 # exists with the same URL.
809 existing = find_puzzle_for_url(turb, hunt_id, url)
811 return submission_error(
813 "Error: A puzzle with this URL already exists.")
815 # Create a Slack-channel-safe puzzle_id
816 puzzle_id = puzzle_id_from_name(name)
818 # Create a channel for the puzzle
819 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
822 response = turb.slack_client.conversations_create(
823 name=hunt_dash_channel)
824 except SlackApiError as e:
825 return submission_error(
827 "Error creating Slack channel {}: {}"
828 .format(hunt_dash_channel, e.response['error']))
830 channel_id = response['channel']['id']
832 # Add any new rounds to the database
834 for round in new_rounds.split(','):
835 # Drop any leading/trailing spaces from the round name
836 round = round.strip()
837 # Ignore any empty string
844 'SK': 'round-' + round
848 # Construct a puzzle dict
851 "puzzle_id": puzzle_id,
852 "channel_id": channel_id,
854 "status": 'unsolved',
861 puzzle['rounds'] = rounds
863 # Finally, compute the appropriate sort key
864 puzzle["SK"] = puzzle_sort_key(puzzle)
866 # Insert the newly-created puzzle into the database
867 turb.table.put_item(Item=puzzle)
871 def state(turb, body, args):
872 """Implementation of the /state command
874 The args string should be a brief sentence describing where things
875 stand or what's needed."""
877 channel_id = body['channel_id'][0]
879 old_puzzle = puzzle_for_channel(turb, channel_id)
883 "Sorry, the /state command only works in a puzzle channel")
885 # Make a deep copy of the puzzle object
886 puzzle = puzzle_copy(old_puzzle)
888 # Update the puzzle in the database
889 puzzle['state'] = args
890 turb.table.put_item(Item=puzzle)
892 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
896 commands["/state"] = state
898 def tag(turb, body, args):
899 """Implementation of the `/tag` command.
901 Arg is either a tag to add (optionally prefixed with '+'), or if
902 prefixed with '-' is a tag to remove.
906 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
907 + "or `/tag -TAG_TO_REMOVE`.")
909 channel_id = body['channel_id'][0]
911 old_puzzle = puzzle_for_channel(turb, channel_id)
915 "Sorry, the /tag command only works in a puzzle channel")
926 # Force tag to all uppercase
929 # Reject a tag that is not alphabetic or underscore A-Z_
930 if not re.match(r'^[A-Z_]*$', tag):
931 return bot_reply("Sorry, tags can only contain letters "
932 + "and the underscore character.")
934 if action == 'remove':
935 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
936 return bot_reply("Nothing to do. This puzzle is not tagged "
937 + "with the tag: {}".format(tag))
939 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
940 return bot_reply("Nothing to do. This puzzle is already tagged "
941 + "with the tag: {}".format(tag))
943 # OK. Error checking is done. Let's get to work
945 # Make a deep copy of the puzzle object
946 puzzle = puzzle_copy(old_puzzle)
948 if action == 'remove':
949 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
951 if 'tags' not in puzzle:
952 puzzle['tags'] = [tag]
954 puzzle['tags'].append(tag)
956 turb.table.put_item(Item=puzzle)
958 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
962 commands["/tag"] = tag
964 def solved(turb, body, args):
965 """Implementation of the /solved command
967 The args string should be a confirmed solution."""
969 channel_id = body['channel_id'][0]
970 user_name = body['user_name'][0]
972 old_puzzle = puzzle_for_channel(turb, channel_id)
975 return bot_reply("Sorry, this is not a puzzle channel.")
979 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
981 # Make a deep copy of the puzzle object
982 puzzle = puzzle_copy(old_puzzle)
984 # Set the status and solution fields in the database
985 puzzle['status'] = 'solved'
986 puzzle['solution'].append(args)
987 if 'state' in puzzle:
989 turb.table.put_item(Item=puzzle)
991 # Report the solution to the puzzle's channel
993 turb.slack_client, channel_id,
994 "Puzzle mark solved by {}: `{}`".format(user_name, args))
996 # Also report the solution to the hunt channel
997 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
999 turb.slack_client, hunt['channel_id'],
1000 "Puzzle <{}|{}> has been solved!".format(
1001 puzzle['channel_url'],
1005 # And update the puzzle's description
1006 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1010 commands["/solved"] = solved
1012 def hunt(turb, body, args):
1013 """Implementation of the /hunt command
1015 The (optional) args string can be used to filter which puzzles to
1016 display. The first word can be one of 'all', 'unsolved', or
1017 'solved' and can be used to display only puzzles with the given
1018 status. If this first word is missing, this command will display
1019 only unsolved puzzles by default.
1021 Any remaining text in the args string will be interpreted as
1022 search terms. These will be split into separate terms on space
1023 characters, (though quotation marks can be used to include a space
1024 character in a term). All terms must match on a puzzle in order
1025 for that puzzle to be included. But a puzzle will be considered to
1026 match if any of the puzzle title, round title, puzzle URL, puzzle
1027 state, or puzzle solution match. Matching will be performed
1028 without regard to case sensitivity and the search terms can
1029 include regular expression syntax.
1032 channel_id = body['channel_id'][0]
1033 response_url = body['response_url'][0]
1037 # The first word can be a puzzle status and all remaining word
1038 # (if any) are search terms. _But_, if the first word is not a
1039 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1040 # words are search terms and we default status to 'unsolved'.
1041 split_args = args.split(' ', 1)
1042 status = split_args[0]
1043 if (len(split_args) > 1):
1044 terms = split_args[1]
1045 if status not in ('unsolved', 'solved', 'all'):
1051 # Separate search terms on spaces (but allow for quotation marks
1052 # to capture spaces in a search term)
1054 terms = shlex.split(terms)
1056 hunt = hunt_for_channel(turb, channel_id)
1059 return bot_reply("Sorry, this channel doesn't appear to "
1060 + "be a hunt or puzzle channel")
1062 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1064 requests.post(response_url,
1065 json = { 'blocks': blocks },
1066 headers = {'Content-type': 'application/json'}
1071 commands["/hunt"] = hunt
1073 def round(turb, body, args):
1074 """Implementation of the /round command
1076 Displays puzzles in the same round(s) as the puzzle for the
1079 The (optional) args string can be used to filter which puzzles to
1080 display. The first word can be one of 'all', 'unsolved', or
1081 'solved' and can be used to display only puzzles with the given
1082 status. If this first word is missing, this command will display
1083 all puzzles in the round by default.
1085 Any remaining text in the args string will be interpreted as
1086 search terms. These will be split into separate terms on space
1087 characters, (though quotation marks can be used to include a space
1088 character in a term). All terms must match on a puzzle in order
1089 for that puzzle to be included. But a puzzle will be considered to
1090 match if any of the puzzle title, round title, puzzle URL, puzzle
1091 state, or puzzle solution match. Matching will be performed
1092 without regard to case sensitivity and the search terms can
1093 include regular expression syntax.
1096 channel_id = body['channel_id'][0]
1097 response_url = body['response_url'][0]
1099 puzzle = puzzle_for_channel(turb, channel_id)
1100 hunt = hunt_for_channel(turb, channel_id)
1105 "This is not a puzzle channel, but is a hunt channel. "
1106 + "Use /hunt if you want to see all rounds for this hunt.")
1109 "Sorry, this channel doesn't appear to be a puzzle channel "
1110 + "so the `/round` command cannot work here.")
1114 # The first word can be a puzzle status and all remaining word
1115 # (if any) are search terms. _But_, if the first word is not a
1116 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1117 # words are search terms and we default status to 'unsolved'.
1118 split_args = args.split(' ', 1)
1119 status = split_args[0]
1120 if (len(split_args) > 1):
1121 terms = split_args[1]
1122 if status not in ('unsolved', 'solved', 'all'):
1128 # Separate search terms on spaces (but allow for quotation marks
1129 # to capture spaces in a search term)
1131 terms = shlex.split(terms)
1133 blocks = hunt_blocks(turb, hunt,
1134 puzzle_status=status, search_terms=terms,
1135 limit_to_rounds=puzzle.get('rounds', [])
1138 requests.post(response_url,
1139 json = { 'blocks': blocks },
1140 headers = {'Content-type': 'application/json'}
1145 commands["/round"] = round