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):
86 """Implementation of the `/edit` command
88 To edit the puzzle for the current channel.
90 This is simply a shortcut for `/puzzle edit`.
93 return edit_puzzle_command(turb, body)
95 commands["/edit"] = edit
98 def edit_puzzle_command(turb, body):
99 """Implementation of the `/puzzle edit` command
101 As dispatched from the puzzle() function.
104 channel_id = body['channel_id'][0]
105 trigger_id = body['trigger_id'][0]
107 puzzle = puzzle_for_channel(turb, channel_id)
110 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
112 return edit_puzzle(turb, puzzle, trigger_id)
116 def edit_puzzle_button(turb, payload):
117 """Handler for the action of user pressing an edit_puzzle button"""
119 action_id = payload['actions'][0]['action_id']
120 response_url = payload['response_url']
121 trigger_id = payload['trigger_id']
123 (hunt_id, sort_key) = action_id.split('-', 1)
125 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
128 requests.post(response_url,
129 json = {"text": "Error: Puzzle not found!"},
130 headers = {"Content-type": "application/json"})
131 return bot_reply("Error: Puzzle not found.")
133 return edit_puzzle(turb, puzzle, trigger_id)
135 actions['button']['edit_puzzle'] = edit_puzzle_button
137 def edit_puzzle(turb, puzzle, trigger_id):
138 """Common code for implementing an edit puzzle dialog
140 This implementation is common whether the edit operation was invoked
141 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
144 round_options = hunt_rounds(turb, puzzle['hunt_id'])
146 if len(round_options):
147 round_options_block = [
148 multi_select_block("Round(s)", "rounds",
149 "Existing round(s) this puzzle belongs to",
151 initial_options=puzzle.get("rounds", None)),
154 round_options_block = []
157 if puzzle.get("status", "unsolved") == solved:
161 solution_list = puzzle.get("solution", [])
163 solution_str = ", ".join(solution_list)
167 "private_metadata": json.dumps({
168 "hunt_id": puzzle['hunt_id'],
170 "puzzle_id": puzzle['puzzle_id'],
171 "channel_id": puzzle["channel_id"],
172 "channel_url": puzzle["channel_url"],
173 "sheet_url": puzzle["sheet_url"],
175 "title": {"type": "plain_text", "text": "Edit Puzzle"},
176 "submit": { "type": "plain_text", "text": "Save" },
178 input_block("Puzzle name", "name", "Name of the puzzle",
179 initial_value=puzzle["name"]),
180 input_block("Puzzle URL", "url", "External URL of puzzle",
181 initial_value=puzzle.get("url", None),
183 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
184 checked=(puzzle.get('type', 'plain') == 'meta')),
185 * round_options_block,
186 input_block("New round(s)", "new_rounds",
187 "New round(s) this puzzle belongs to " +
190 input_block("State", "state",
191 "State of this puzzle (partial progress, next steps)",
192 initial_value=puzzle.get("state", None),
195 "Puzzle status", "Solved", "solved",
196 checked=(puzzle.get('status', 'unsolved') == 'solved')),
197 input_block("Solution", "solution",
198 "Solution(s) (comma-separated if multiple)",
199 initial_value=solution_str,
204 result = turb.slack_client.views_open(trigger_id=trigger_id,
208 submission_handlers[result['view']['id']] = edit_puzzle_submission
212 def edit_puzzle_submission(turb, payload, metadata):
213 """Handler for the user submitting the edit puzzle modal
215 This is the modal view presented to the user by the edit_puzzle
221 # First, read all the various data from the request
222 meta = json.loads(metadata)
223 puzzle['hunt_id'] = meta['hunt_id']
224 puzzle['SK'] = meta['SK']
225 puzzle['puzzle_id'] = meta['puzzle_id']
226 puzzle['channel_id'] = meta['channel_id']
227 puzzle['channel_url'] = meta['channel_url']
228 puzzle['sheet_url'] = meta['sheet_url']
230 state = payload['view']['state']['values']
231 user_id = payload['user']['id']
233 puzzle['name'] = state['name']['name']['value']
234 url = state['url']['url']['value']
237 if state['meta']['meta']['selected_options']:
238 puzzle['type'] = 'meta'
240 puzzle['type'] = 'plain'
241 rounds = [option['value'] for option in
242 state['rounds']['rounds']['selected_options']]
244 puzzle['rounds'] = rounds
245 new_rounds = state['new_rounds']['new_rounds']['value']
246 puzzle_state = state['state']['state']['value']
248 puzzle['state'] = puzzle_state
249 if state['solved']['solved']['selected_options']:
250 puzzle['status'] = 'solved'
252 puzzle['status'] = 'unsolved'
253 puzzle['solution'] = []
254 solution = state['solution']['solution']['value']
256 puzzle['solution'] = [
257 sol.strip() for sol in solution.split(',')
260 # Verify that there's a solution if the puzzle is mark solved
261 if puzzle['status'] == 'solved' and not puzzle['solution']:
262 return submission_error("solution",
263 "A solved puzzle requires a solution.")
265 if puzzle['status'] == 'unsolved' and puzzle['solution']:
266 return submission_error("solution",
267 "An unsolved puzzle should have no solution.")
269 # Add any new rounds to the database
271 if 'rounds' not in puzzle:
272 puzzle['rounds'] = []
273 for round in new_rounds.split(','):
274 # Drop any leading/trailing spaces from the round name
275 round = round.strip()
276 # Ignore any empty string
279 puzzle['rounds'].append(round)
282 'hunt_id': puzzle['hunt_id'],
283 'SK': 'round-' + round
287 # Get old puzzle from the database (to determine what's changed)
288 old_puzzle = find_puzzle_for_sort_key(turb,
292 # If we are changing puzzle type (meta -> plain or plain -> meta)
293 # the the sort key has to change, so compute the new one and delete
294 # the old item from the database.
296 # XXX: We should really be using a transaction here to combine the
297 # delete_item and the put_item into a single transaction, but
298 # the boto interface is annoying in that transactions are only on
299 # the "Client" object which has a totally different interface than
300 # the "Table" object I've been using so I haven't figured out how
303 if puzzle['type'] != old_puzzle.get('type', 'plain'):
304 puzzle['SK'] = puzzle_sort_key(puzzle)
305 turb.table.delete_item(Key={
306 'hunt_id': old_puzzle['hunt_id'],
307 'SK': old_puzzle['SK']
310 # Update the puzzle in the database
311 turb.table.put_item(Item=puzzle)
313 # Inform the puzzle channel about the edit
314 edit_message = "Puzzle edited by <@{}>".format(user_id)
315 blocks = ([section_block(text_block(edit_message+":\n"))] +
316 puzzle_blocks(puzzle, include_rounds=True))
318 turb.slack_client, puzzle['channel_id'],
319 edit_message, blocks=blocks)
321 # Also inform the hunt if the puzzle's solved status changed
322 if puzzle['status'] != old_puzzle['status']:
323 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
324 if puzzle['status'] == 'solved':
325 message = "Puzzle <{}|{}> has been solved!".format(
326 puzzle['channel_url'],
329 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
330 puzzle['channel_url'],
332 slack_send_message(turb.slack_client, hunt['channel_id'], message)
334 # We need to set the channel topic if any of puzzle name, url,
335 # state, status, or solution, has changed. Let's just do that
336 # unconditionally here.
337 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
341 def new_hunt(turb, payload):
342 """Handler for the action of user pressing the new_hunt button"""
346 "private_metadata": json.dumps({}),
347 "title": { "type": "plain_text", "text": "New Hunt" },
348 "submit": { "type": "plain_text", "text": "Create" },
350 input_block("Hunt name", "name", "Name of the hunt"),
351 input_block("Hunt ID", "hunt_id",
352 "Used as puzzle channel prefix "
353 + "(no spaces nor punctuation)"),
354 input_block("Hunt URL", "url", "External URL of hunt",
359 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
362 submission_handlers[result['view']['id']] = new_hunt_submission
366 actions['button']['new_hunt'] = new_hunt
368 def new_hunt_submission(turb, payload, metadata):
369 """Handler for the user submitting the new hunt modal
371 This is the modal view presented to the user by the new_hunt
374 state = payload['view']['state']['values']
375 user_id = payload['user']['id']
376 name = state['name']['name']['value']
377 hunt_id = state['hunt_id']['hunt_id']['value']
378 url = state['url']['url']['value']
380 # Validate that the hunt_id contains no invalid characters
381 if not re.match(valid_id_re, hunt_id):
382 return submission_error("hunt_id",
383 "Hunt ID can only contain lowercase letters, "
384 + "numbers, and underscores")
386 # Check to see if the turbot table exists
388 exists = turb.table.table_status in ("CREATING", "UPDATING",
393 # Create the turbot table if necessary.
395 turb.table = turb.db.create_table(
398 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
399 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
401 AttributeDefinitions=[
402 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
403 {'AttributeName': 'SK', 'AttributeType': 'S'},
404 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
405 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
406 {'AttributeName': 'url', 'AttributeType': 'S'}
408 ProvisionedThroughput={
409 'ReadCapacityUnits': 5,
410 'WriteCapacityUnits': 5
412 GlobalSecondaryIndexes=[
414 'IndexName': 'channel_id_index',
416 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
419 'ProjectionType': 'ALL'
421 'ProvisionedThroughput': {
422 'ReadCapacityUnits': 5,
423 'WriteCapacityUnits': 5
427 'IndexName': 'is_hunt_index',
429 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
432 'ProjectionType': 'ALL'
434 'ProvisionedThroughput': {
435 'ReadCapacityUnits': 5,
436 'WriteCapacityUnits': 5
440 LocalSecondaryIndexes = [
442 'IndexName': 'url_index',
444 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
445 {'AttributeName': 'url', 'KeyType': 'RANGE'},
448 'ProjectionType': 'ALL'
453 return submission_error(
455 "Still bootstrapping turbot table. Try again in a minute, please.")
457 # Create a channel for the hunt
459 response = turb.slack_client.conversations_create(name=hunt_id)
460 except SlackApiError as e:
461 return submission_error("hunt_id",
462 "Error creating Slack channel: {}"
463 .format(e.response['error']))
465 channel_id = response['channel']['id']
467 # Insert the newly-created hunt into the database
468 # (leaving it as non-active for now until the channel-created handler
469 # finishes fixing it up with a sheet and a companion table)
472 "SK": "hunt-{}".format(hunt_id),
474 "channel_id": channel_id,
480 turb.table.put_item(Item=item)
482 # Invite the initiating user to the channel
483 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
487 def view_submission(turb, payload):
488 """Handler for Slack interactive view submission
490 Specifically, those that have a payload type of 'view_submission'"""
492 view_id = payload['view']['id']
493 metadata = payload['view']['private_metadata']
495 if view_id in submission_handlers:
496 return submission_handlers[view_id](turb, payload, metadata)
498 print("Error: Unknown view ID: {}".format(view_id))
503 def rot(turb, body, args):
504 """Implementation of the /rot command
506 The args string should be as follows:
508 [count|*] String to be rotated
510 That is, the first word of the string is an optional number (or
511 the character '*'). If this is a number it indicates an amount to
512 rotate each character in the string. If the count is '*' or is not
513 present, then the string will be rotated through all possible 25
516 The result of the rotation is returned (with Slack formatting) in
517 the body of the response so that Slack will provide it as a reply
518 to the user who submitted the slash command."""
520 channel_name = body['channel_name'][0]
521 response_url = body['response_url'][0]
522 channel_id = body['channel_id'][0]
524 result = turbot.rot.rot(args)
526 if (channel_name == "directmessage"):
527 requests.post(response_url,
528 json = {"text": result},
529 headers = {"Content-type": "application/json"})
531 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
535 commands["/rot"] = rot
537 def get_table_item(turb, table_name, key, value):
538 """Get an item from the database 'table_name' with 'key' as 'value'
540 Returns a tuple of (item, table) if found and (None, None) otherwise."""
542 table = turb.db.Table(table_name)
544 response = table.get_item(Key={key: value})
546 if 'Item' in response:
547 return (response['Item'], table)
551 def db_entry_for_channel(turb, channel_id):
552 """Given a channel ID return the database item for this channel
554 If this channel is a registered hunt or puzzle channel, return the
555 corresponding row from the database for this channel. Otherwise,
558 Note: If you need to specifically ensure that the channel is a
559 puzzle or a hunt, please call puzzle_for_channel or
560 hunt_for_channel respectively.
563 response = turb.table.query(
564 IndexName = "channel_id_index",
565 KeyConditionExpression=Key("channel_id").eq(channel_id)
568 if response['Count'] == 0:
571 return response['Items'][0]
574 def puzzle_for_channel(turb, channel_id):
576 """Given a channel ID return the puzzle from the database for this channel
578 If the given channel_id is a puzzle's channel, this function
579 returns a dict filled with the attributes from the puzzle's entry
582 Otherwise, this function returns None.
585 entry = db_entry_for_channel(turb, channel_id)
587 if entry and entry['SK'].startswith('puzzle-'):
592 def hunt_for_channel(turb, channel_id):
594 """Given a channel ID return the hunt from the database for this channel
596 This works whether the original channel is a primary hunt channel,
597 or if it is one of the channels of a puzzle belonging to the hunt.
599 Returns None if channel does not belong to a hunt, otherwise a
600 dictionary with all fields from the hunt's row in the table,
601 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
604 entry = db_entry_for_channel(turb, channel_id)
606 # We're done if this channel doesn't exist in the database at all
610 # Also done if this channel is a hunt channel
611 if entry['SK'].startswith('hunt-'):
614 # Otherwise, (the channel is in the database, but is not a hunt),
615 # we expect this to be a puzzle channel instead
616 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
618 # python3.9 has a built-in removeprefix but AWS only has python3.8
619 def remove_prefix(text, prefix):
620 if text.startswith(prefix):
621 return text[len(prefix):]
624 def hunt_rounds(turb, hunt_id):
625 """Returns array of strings giving rounds that exist in the given hunt"""
627 response = turb.table.query(
628 KeyConditionExpression=(
629 Key('hunt_id').eq(hunt_id) &
630 Key('SK').begins_with('round-')
634 if response['Count'] == 0:
637 return [remove_prefix(option['SK'], 'round-')
638 for option in response['Items']]
640 def puzzle(turb, body, args):
641 """Implementation of the /puzzle command
643 The args string can be a sub-command:
645 /puzzle new: Bring up a dialog to create a new puzzle
647 /puzzle edit: Edit the puzzle for the current channel
649 Or with no argument at all:
651 /puzzle: Print details of the current puzzle (if in a puzzle channel)
655 return new_puzzle(turb, body)
658 return edit_puzzle_command(turb, body)
661 return bot_reply("Unknown syntax for `/puzzle` command. " +
662 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
663 "and `/puzzle new` to display, edit, or create " +
666 # For no arguments we print the current puzzle as a reply
667 channel_id = body['channel_id'][0]
668 response_url = body['response_url'][0]
670 puzzle = puzzle_for_channel(turb, channel_id)
673 hunt = hunt_for_channel(turb, channel_id)
676 "This is not a puzzle channel, but is a hunt channel. "
677 + "If you want to create a new puzzle for this hunt, use "
681 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
682 + "channel, so the `/puzzle` command cannot work here.")
684 blocks = puzzle_blocks(puzzle, include_rounds=True)
686 # For a meta puzzle, also display the titles and solutions for all
687 # puzzles in the same round.
688 if puzzle['type'] == 'meta':
689 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
691 # Drop this puzzle itself from the report
692 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
694 for round in puzzle.get('rounds', [None]):
695 answers = round_quoted_puzzles_titles_answers(round, puzzles)
697 section_block(text_block(
698 "*Feeder solutions from round {}*".format(
699 round if round else "<none>"
701 section_block(text_block(answers))
704 requests.post(response_url,
705 json = {'blocks': blocks},
706 headers = {'Content-type': 'application/json'}
711 commands["/puzzle"] = puzzle
713 def new(turb, body, args):
714 """Implementation of the `/new` command
716 To create a new puzzle.
718 This is simply a shortcut for `/puzzle new`.
721 return new_puzzle(turb, body)
723 commands["/new"] = new
725 def new_puzzle(turb, body):
726 """Implementation of the "/puzzle new" command
728 This brings up a dialog box for creating a new puzzle.
731 channel_id = body['channel_id'][0]
732 trigger_id = body['trigger_id'][0]
734 hunt = hunt_for_channel(turb, channel_id)
737 return bot_reply("Sorry, this channel doesn't appear to "
738 + "be a hunt or puzzle channel")
740 round_options = hunt_rounds(turb, hunt['hunt_id'])
742 if len(round_options):
743 round_options_block = [
744 multi_select_block("Round(s)", "rounds",
745 "Existing round(s) this puzzle belongs to",
749 round_options_block = []
753 "private_metadata": json.dumps({
754 "hunt_id": hunt['hunt_id'],
756 "title": {"type": "plain_text", "text": "New Puzzle"},
757 "submit": { "type": "plain_text", "text": "Create" },
759 section_block(text_block("*For {}*".format(hunt['name']))),
760 input_block("Puzzle name", "name", "Name of the puzzle"),
761 input_block("Puzzle URL", "url", "External URL of puzzle",
763 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
764 * round_options_block,
765 input_block("New round(s)", "new_rounds",
766 "New round(s) this puzzle belongs to " +
772 result = turb.slack_client.views_open(trigger_id=trigger_id,
776 submission_handlers[result['view']['id']] = new_puzzle_submission
780 def new_puzzle_submission(turb, payload, metadata):
781 """Handler for the user submitting the new puzzle modal
783 This is the modal view presented to the user by the new_puzzle
787 # First, read all the various data from the request
788 meta = json.loads(metadata)
789 hunt_id = meta['hunt_id']
791 state = payload['view']['state']['values']
792 name = state['name']['name']['value']
793 url = state['url']['url']['value']
794 if state['meta']['meta']['selected_options']:
797 puzzle_type = 'plain'
798 if 'rounds' in state:
799 rounds = [option['value'] for option in
800 state['rounds']['rounds']['selected_options']]
803 new_rounds = state['new_rounds']['new_rounds']['value']
805 # Before doing anything, reject this puzzle if a puzzle already
806 # exists with the same URL.
808 existing = find_puzzle_for_url(turb, hunt_id, url)
810 return submission_error(
812 "Error: A puzzle with this URL already exists.")
814 # Create a Slack-channel-safe puzzle_id
815 puzzle_id = puzzle_id_from_name(name)
817 # Create a channel for the puzzle
818 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
821 response = turb.slack_client.conversations_create(
822 name=hunt_dash_channel)
823 except SlackApiError as e:
824 return submission_error(
826 "Error creating Slack channel {}: {}"
827 .format(hunt_dash_channel, e.response['error']))
829 channel_id = response['channel']['id']
831 # Add any new rounds to the database
833 for round in new_rounds.split(','):
834 # Drop any leading/trailing spaces from the round name
835 round = round.strip()
836 # Ignore any empty string
843 'SK': 'round-' + round
847 # Construct a puzzle dict
850 "puzzle_id": puzzle_id,
851 "channel_id": channel_id,
853 "status": 'unsolved',
860 puzzle['rounds'] = rounds
862 # Finally, compute the appropriate sort key
863 puzzle["SK"] = puzzle_sort_key(puzzle)
865 # Insert the newly-created puzzle into the database
866 turb.table.put_item(Item=puzzle)
870 def state(turb, body, args):
871 """Implementation of the /state command
873 The args string should be a brief sentence describing where things
874 stand or what's needed."""
876 channel_id = body['channel_id'][0]
878 old_puzzle = puzzle_for_channel(turb, channel_id)
882 "Sorry, the /state command only works in a puzzle channel")
884 # Make a copy of the puzzle object
885 puzzle = old_puzzle.copy()
887 # Update the puzzle in the database
888 puzzle['state'] = args
889 turb.table.put_item(Item=puzzle)
891 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
895 commands["/state"] = state
897 def tag(turb, body, args):
898 """Implementation of the `/tag` command.
900 Arg is either a tag to add (optionally prefixed with '+'), or if
901 prefixed with '-' is a tag to remove.
905 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
906 + "or `/tag -TAG_TO_REMOVE`.")
908 channel_id = body['channel_id'][0]
910 old_puzzle = puzzle_for_channel(turb, channel_id)
914 "Sorry, the /tag command only works in a puzzle channel")
925 # Force tag to all uppercase
928 # Reject a tag that is not alphabetic or underscore A-Z_
929 if not re.match(r'^[A-Z_]*$', tag):
930 return bot_reply("Sorry, tags can only contain letters "
931 + "and the underscore character.")
933 if action == 'remove':
934 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
935 return bot_reply("Nothing to do. This puzzle is not tagged "
936 + "with the tag: {}".format(tag))
938 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
939 return bot_reply("Nothing to do. This puzzle is already tagged "
940 + "with the tag: {}".format(tag))
942 # OK. Error checking is done. Let's get to work
944 # Make a copy of the puzzle object
945 puzzle = old_puzzle.copy()
947 if action == 'remove':
948 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
950 if 'tags' not in puzzle:
951 puzzle['tags'] = [tag]
953 puzzle['tags'].append(tag)
955 turb.table.put_item(Item=puzzle)
957 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
961 commands["/tag"] = tag
963 def solved(turb, body, args):
964 """Implementation of the /solved command
966 The args string should be a confirmed solution."""
968 channel_id = body['channel_id'][0]
969 user_name = body['user_name'][0]
971 old_puzzle = puzzle_for_channel(turb, channel_id)
974 return bot_reply("Sorry, this is not a puzzle channel.")
978 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
980 # Make a copy of the puzzle object
981 puzzle = old_puzzle.copy()
983 # Set the status and solution fields in the database
984 puzzle['status'] = 'solved'
985 puzzle['solution'].append(args)
986 if 'state' in puzzle:
988 turb.table.put_item(Item=puzzle)
990 # Report the solution to the puzzle's channel
992 turb.slack_client, channel_id,
993 "Puzzle mark solved by {}: `{}`".format(user_name, args))
995 # Also report the solution to the hunt channel
996 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
998 turb.slack_client, hunt['channel_id'],
999 "Puzzle <{}|{}> has been solved!".format(
1000 puzzle['channel_url'],
1004 # And update the puzzle's description
1005 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1009 commands["/solved"] = solved
1011 def hunt(turb, body, args):
1012 """Implementation of the /hunt command
1014 The (optional) args string can be used to filter which puzzles to
1015 display. The first word can be one of 'all', 'unsolved', or
1016 'solved' and can be used to display only puzzles with the given
1017 status. If this first word is missing, this command will display
1018 only unsolved puzzles by default.
1020 Any remaining text in the args string will be interpreted as
1021 search terms. These will be split into separate terms on space
1022 characters, (though quotation marks can be used to include a space
1023 character in a term). All terms must match on a puzzle in order
1024 for that puzzle to be included. But a puzzle will be considered to
1025 match if any of the puzzle title, round title, puzzle URL, puzzle
1026 state, or puzzle solution match. Matching will be performed
1027 without regard to case sensitivity and the search terms can
1028 include regular expression syntax.
1031 channel_id = body['channel_id'][0]
1032 response_url = body['response_url'][0]
1036 # The first word can be a puzzle status and all remaining word
1037 # (if any) are search terms. _But_, if the first word is not a
1038 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1039 # words are search terms and we default status to 'unsolved'.
1040 split_args = args.split(' ', 1)
1041 status = split_args[0]
1042 if (len(split_args) > 1):
1043 terms = split_args[1]
1044 if status not in ('unsolved', 'solved', 'all'):
1050 # Separate search terms on spaces (but allow for quotation marks
1051 # to capture spaces in a search term)
1053 terms = shlex.split(terms)
1055 hunt = hunt_for_channel(turb, channel_id)
1058 return bot_reply("Sorry, this channel doesn't appear to "
1059 + "be a hunt or puzzle channel")
1061 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1063 requests.post(response_url,
1064 json = { 'blocks': blocks },
1065 headers = {'Content-type': 'application/json'}
1070 commands["/hunt"] = hunt
1072 def round(turb, body, args):
1073 """Implementation of the /round command
1075 Displays puzzles in the same round(s) as the puzzle for the
1078 The (optional) args string can be used to filter which puzzles to
1079 display. The first word can be one of 'all', 'unsolved', or
1080 'solved' and can be used to display only puzzles with the given
1081 status. If this first word is missing, this command will display
1082 all puzzles in the round by default.
1084 Any remaining text in the args string will be interpreted as
1085 search terms. These will be split into separate terms on space
1086 characters, (though quotation marks can be used to include a space
1087 character in a term). All terms must match on a puzzle in order
1088 for that puzzle to be included. But a puzzle will be considered to
1089 match if any of the puzzle title, round title, puzzle URL, puzzle
1090 state, or puzzle solution match. Matching will be performed
1091 without regard to case sensitivity and the search terms can
1092 include regular expression syntax.
1095 channel_id = body['channel_id'][0]
1096 response_url = body['response_url'][0]
1098 puzzle = puzzle_for_channel(turb, channel_id)
1099 hunt = hunt_for_channel(turb, channel_id)
1104 "This is not a puzzle channel, but is a hunt channel. "
1105 + "Use /hunt if you want to see all rounds for this hunt.")
1108 "Sorry, this channel doesn't appear to be a puzzle channel "
1109 + "so the `/round` command cannot work here.")
1113 # The first word can be a puzzle status and all remaining word
1114 # (if any) are search terms. _But_, if the first word is not a
1115 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1116 # words are search terms and we default status to 'unsolved'.
1117 split_args = args.split(' ', 1)
1118 status = split_args[0]
1119 if (len(split_args) > 1):
1120 terms = split_args[1]
1121 if status not in ('unsolved', 'solved', 'all'):
1127 # Separate search terms on spaces (but allow for quotation marks
1128 # to capture spaces in a search term)
1130 terms = shlex.split(terms)
1132 blocks = hunt_blocks(turb, hunt,
1133 puzzle_status=status, search_terms=terms,
1134 limit_to_rounds=puzzle.get('rounds', [])
1137 requests.post(response_url,
1138 json = { 'blocks': blocks },
1139 headers = {'Content-type': 'application/json'}
1144 commands["/round"] = round