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)
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_command(turb, body):
341 """Implementation of the '/hunt new' command
343 As dispatched from the hunt() function.
346 trigger_id = body['trigger_id'][0]
348 return new_hunt(turb, trigger_id)
350 def new_hunt_button(turb, payload):
351 """Handler for the action of user pressing the new_hunt button"""
353 trigger_id = payload['trigger_id']
355 return new_hunt(turb, trigger_id)
357 def new_hunt(turb, trigger_id):
358 """Common code for implementing a new hunt dialog
360 This implementation is common whether the operations was invoked
361 by a button (new_hunt_button) or a command (new_hunt_command).
366 "private_metadata": json.dumps({}),
367 "title": { "type": "plain_text", "text": "New Hunt" },
368 "submit": { "type": "plain_text", "text": "Create" },
370 input_block("Hunt name", "name", "Name of the hunt"),
371 input_block("Hunt ID", "hunt_id",
372 "Used as puzzle channel prefix "
373 + "(no spaces nor punctuation)"),
374 input_block("Hunt URL", "url", "External URL of hunt",
379 result = turb.slack_client.views_open(trigger_id=trigger_id,
382 submission_handlers[result['view']['id']] = new_hunt_submission
386 actions['button']['new_hunt'] = new_hunt
388 def new_hunt_submission(turb, payload, metadata):
389 """Handler for the user submitting the new hunt modal
391 This is the modal view presented to the user by the new_hunt
394 state = payload['view']['state']['values']
395 user_id = payload['user']['id']
396 name = state['name']['name']['value']
397 hunt_id = state['hunt_id']['hunt_id']['value']
398 url = state['url']['url']['value']
400 # Validate that the hunt_id contains no invalid characters
401 if not re.match(valid_id_re, hunt_id):
402 return submission_error("hunt_id",
403 "Hunt ID can only contain lowercase letters, "
404 + "numbers, and underscores")
406 # Check to see if the turbot table exists
408 exists = turb.table.table_status in ("CREATING", "UPDATING",
413 # Create the turbot table if necessary.
415 turb.table = turb.db.create_table(
418 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
419 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
421 AttributeDefinitions=[
422 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
423 {'AttributeName': 'SK', 'AttributeType': 'S'},
424 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
425 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
426 {'AttributeName': 'url', 'AttributeType': 'S'}
428 ProvisionedThroughput={
429 'ReadCapacityUnits': 5,
430 'WriteCapacityUnits': 5
432 GlobalSecondaryIndexes=[
434 'IndexName': 'channel_id_index',
436 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
439 'ProjectionType': 'ALL'
441 'ProvisionedThroughput': {
442 'ReadCapacityUnits': 5,
443 'WriteCapacityUnits': 5
447 'IndexName': 'is_hunt_index',
449 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
452 'ProjectionType': 'ALL'
454 'ProvisionedThroughput': {
455 'ReadCapacityUnits': 5,
456 'WriteCapacityUnits': 5
460 LocalSecondaryIndexes = [
462 'IndexName': 'url_index',
464 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
465 {'AttributeName': 'url', 'KeyType': 'RANGE'},
468 'ProjectionType': 'ALL'
473 return submission_error(
475 "Still bootstrapping turbot table. Try again in a minute, please.")
477 # Create a channel for the hunt
479 response = turb.slack_client.conversations_create(name=hunt_id)
480 except SlackApiError as e:
481 return submission_error("hunt_id",
482 "Error creating Slack channel: {}"
483 .format(e.response['error']))
485 channel_id = response['channel']['id']
487 # Insert the newly-created hunt into the database
488 # (leaving it as non-active for now until the channel-created handler
489 # finishes fixing it up with a sheet and a companion table)
492 "SK": "hunt-{}".format(hunt_id),
494 "channel_id": channel_id,
500 turb.table.put_item(Item=item)
502 # Invite the initiating user to the channel
503 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
507 def view_submission(turb, payload):
508 """Handler for Slack interactive view submission
510 Specifically, those that have a payload type of 'view_submission'"""
512 view_id = payload['view']['id']
513 metadata = payload['view']['private_metadata']
515 if view_id in submission_handlers:
516 return submission_handlers[view_id](turb, payload, metadata)
518 print("Error: Unknown view ID: {}".format(view_id))
523 def rot(turb, body, args):
524 """Implementation of the /rot command
526 The args string should be as follows:
528 [count|*] String to be rotated
530 That is, the first word of the string is an optional number (or
531 the character '*'). If this is a number it indicates an amount to
532 rotate each character in the string. If the count is '*' or is not
533 present, then the string will be rotated through all possible 25
536 The result of the rotation is returned (with Slack formatting) in
537 the body of the response so that Slack will provide it as a reply
538 to the user who submitted the slash command."""
540 channel_name = body['channel_name'][0]
541 response_url = body['response_url'][0]
542 channel_id = body['channel_id'][0]
544 result = turbot.rot.rot(args)
546 if (channel_name == "directmessage"):
547 requests.post(response_url,
548 json = {"text": result},
549 headers = {"Content-type": "application/json"})
551 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
555 commands["/rot"] = rot
557 def get_table_item(turb, table_name, key, value):
558 """Get an item from the database 'table_name' with 'key' as 'value'
560 Returns a tuple of (item, table) if found and (None, None) otherwise."""
562 table = turb.db.Table(table_name)
564 response = table.get_item(Key={key: value})
566 if 'Item' in response:
567 return (response['Item'], table)
571 def db_entry_for_channel(turb, channel_id):
572 """Given a channel ID return the database item for this channel
574 If this channel is a registered hunt or puzzle channel, return the
575 corresponding row from the database for this channel. Otherwise,
578 Note: If you need to specifically ensure that the channel is a
579 puzzle or a hunt, please call puzzle_for_channel or
580 hunt_for_channel respectively.
583 response = turb.table.query(
584 IndexName = "channel_id_index",
585 KeyConditionExpression=Key("channel_id").eq(channel_id)
588 if response['Count'] == 0:
591 return response['Items'][0]
594 def puzzle_for_channel(turb, channel_id):
596 """Given a channel ID return the puzzle from the database for this channel
598 If the given channel_id is a puzzle's channel, this function
599 returns a dict filled with the attributes from the puzzle's entry
602 Otherwise, this function returns None.
605 entry = db_entry_for_channel(turb, channel_id)
607 if entry and entry['SK'].startswith('puzzle-'):
612 def hunt_for_channel(turb, channel_id):
614 """Given a channel ID return the hunt from the database for this channel
616 This works whether the original channel is a primary hunt channel,
617 or if it is one of the channels of a puzzle belonging to the hunt.
619 Returns None if channel does not belong to a hunt, otherwise a
620 dictionary with all fields from the hunt's row in the table,
621 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
624 entry = db_entry_for_channel(turb, channel_id)
626 # We're done if this channel doesn't exist in the database at all
630 # Also done if this channel is a hunt channel
631 if entry['SK'].startswith('hunt-'):
634 # Otherwise, (the channel is in the database, but is not a hunt),
635 # we expect this to be a puzzle channel instead
636 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
638 # python3.9 has a built-in removeprefix but AWS only has python3.8
639 def remove_prefix(text, prefix):
640 if text.startswith(prefix):
641 return text[len(prefix):]
644 def hunt_rounds(turb, hunt_id):
645 """Returns array of strings giving rounds that exist in the given hunt"""
647 response = turb.table.query(
648 KeyConditionExpression=(
649 Key('hunt_id').eq(hunt_id) &
650 Key('SK').begins_with('round-')
654 if response['Count'] == 0:
657 return [remove_prefix(option['SK'], 'round-')
658 for option in response['Items']]
660 def puzzle(turb, body, args):
661 """Implementation of the /puzzle command
663 The args string can be a sub-command:
665 /puzzle new: Bring up a dialog to create a new puzzle
667 /puzzle edit: Edit the puzzle for the current channel
669 Or with no argument at all:
671 /puzzle: Print details of the current puzzle (if in a puzzle channel)
675 return new_puzzle(turb, body)
678 return edit_puzzle_command(turb, body)
681 return bot_reply("Unknown syntax for `/puzzle` command. " +
682 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
683 "and `/puzzle new` to display, edit, or create " +
686 # For no arguments we print the current puzzle as a reply
687 channel_id = body['channel_id'][0]
688 response_url = body['response_url'][0]
690 puzzle = puzzle_for_channel(turb, channel_id)
693 hunt = hunt_for_channel(turb, channel_id)
696 "This is not a puzzle channel, but is a hunt channel. "
697 + "If you want to create a new puzzle for this hunt, use "
701 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
702 + "channel, so the `/puzzle` command cannot work here.")
704 blocks = puzzle_blocks(puzzle, include_rounds=True)
706 # For a meta puzzle, also display the titles and solutions for all
707 # puzzles in the same round.
708 if puzzle.get('type', 'plain') == 'meta':
709 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
711 # Drop this puzzle itself from the report
712 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
714 for round in puzzle.get('rounds', [None]):
715 answers = round_quoted_puzzles_titles_answers(round, puzzles)
717 section_block(text_block(
718 "*Feeder solutions from round {}*".format(
719 round if round else "<none>"
721 section_block(text_block(answers))
724 requests.post(response_url,
725 json = {'blocks': blocks},
726 headers = {'Content-type': 'application/json'}
731 commands["/puzzle"] = puzzle
733 def new(turb, body, args):
734 """Implementation of the `/new` command
736 To create a new puzzle.
738 This is simply a shortcut for `/puzzle new`.
741 return new_puzzle(turb, body)
743 commands["/new"] = new
745 def new_puzzle(turb, body):
746 """Implementation of the "/puzzle new" command
748 This brings up a dialog box for creating a new puzzle.
751 channel_id = body['channel_id'][0]
752 trigger_id = body['trigger_id'][0]
754 hunt = hunt_for_channel(turb, channel_id)
757 return bot_reply("Sorry, this channel doesn't appear to "
758 + "be a hunt or puzzle channel")
760 round_options = hunt_rounds(turb, hunt['hunt_id'])
762 if len(round_options):
763 round_options_block = [
764 multi_select_block("Round(s)", "rounds",
765 "Existing round(s) this puzzle belongs to",
769 round_options_block = []
773 "private_metadata": json.dumps({
774 "hunt_id": hunt['hunt_id'],
776 "title": {"type": "plain_text", "text": "New Puzzle"},
777 "submit": { "type": "plain_text", "text": "Create" },
779 section_block(text_block("*For {}*".format(hunt['name']))),
780 input_block("Puzzle name", "name", "Name of the puzzle"),
781 input_block("Puzzle URL", "url", "External URL of puzzle",
783 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
784 * round_options_block,
785 input_block("New round(s)", "new_rounds",
786 "New round(s) this puzzle belongs to " +
792 result = turb.slack_client.views_open(trigger_id=trigger_id,
796 submission_handlers[result['view']['id']] = new_puzzle_submission
800 def new_puzzle_submission(turb, payload, metadata):
801 """Handler for the user submitting the new puzzle modal
803 This is the modal view presented to the user by the new_puzzle
807 # First, read all the various data from the request
808 meta = json.loads(metadata)
809 hunt_id = meta['hunt_id']
811 state = payload['view']['state']['values']
812 name = state['name']['name']['value']
813 url = state['url']['url']['value']
814 if state['meta']['meta']['selected_options']:
817 puzzle_type = 'plain'
818 if 'rounds' in state:
819 rounds = [option['value'] for option in
820 state['rounds']['rounds']['selected_options']]
823 new_rounds = state['new_rounds']['new_rounds']['value']
825 # Before doing anything, reject this puzzle if a puzzle already
826 # exists with the same URL.
828 existing = find_puzzle_for_url(turb, hunt_id, url)
830 return submission_error(
832 "Error: A puzzle with this URL already exists.")
834 # Create a Slack-channel-safe puzzle_id
835 puzzle_id = puzzle_id_from_name(name)
837 # Create a channel for the puzzle
838 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
841 response = turb.slack_client.conversations_create(
842 name=hunt_dash_channel)
843 except SlackApiError as e:
844 return submission_error(
846 "Error creating Slack channel {}: {}"
847 .format(hunt_dash_channel, e.response['error']))
849 channel_id = response['channel']['id']
851 # Add any new rounds to the database
853 for round in new_rounds.split(','):
854 # Drop any leading/trailing spaces from the round name
855 round = round.strip()
856 # Ignore any empty string
863 'SK': 'round-' + round
867 # Construct a puzzle dict
870 "puzzle_id": puzzle_id,
871 "channel_id": channel_id,
873 "status": 'unsolved',
880 puzzle['rounds'] = rounds
882 # Finally, compute the appropriate sort key
883 puzzle["SK"] = puzzle_sort_key(puzzle)
885 # Insert the newly-created puzzle into the database
886 turb.table.put_item(Item=puzzle)
890 def state(turb, body, args):
891 """Implementation of the /state command
893 The args string should be a brief sentence describing where things
894 stand or what's needed."""
896 channel_id = body['channel_id'][0]
898 old_puzzle = puzzle_for_channel(turb, channel_id)
902 "Sorry, the /state command only works in a puzzle channel")
904 # Make a deep copy of the puzzle object
905 puzzle = puzzle_copy(old_puzzle)
907 # Update the puzzle in the database
908 puzzle['state'] = args
909 turb.table.put_item(Item=puzzle)
911 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
915 commands["/state"] = state
917 def tag(turb, body, args):
918 """Implementation of the `/tag` command.
920 Arg is either a tag to add (optionally prefixed with '+'), or if
921 prefixed with '-' is a tag to remove.
925 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
926 + "or `/tag -TAG_TO_REMOVE`.")
928 channel_id = body['channel_id'][0]
930 old_puzzle = puzzle_for_channel(turb, channel_id)
934 "Sorry, the /tag command only works in a puzzle channel")
945 # Force tag to all uppercase
948 # Reject a tag that is not alphabetic or underscore A-Z_
949 if not re.match(r'^[A-Z0-9_]*$', tag):
950 return bot_reply("Sorry, tags can only contain letters, numbers, "
951 + "and the underscore character.")
953 if action == 'remove':
954 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
955 return bot_reply("Nothing to do. This puzzle is not tagged "
956 + "with the tag: {}".format(tag))
958 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
959 return bot_reply("Nothing to do. This puzzle is already tagged "
960 + "with the tag: {}".format(tag))
962 # OK. Error checking is done. Let's get to work
964 # Make a deep copy of the puzzle object
965 puzzle = puzzle_copy(old_puzzle)
967 if action == 'remove':
968 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
970 if 'tags' not in puzzle:
971 puzzle['tags'] = [tag]
973 puzzle['tags'].append(tag)
975 turb.table.put_item(Item=puzzle)
977 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
981 commands["/tag"] = tag
983 def solved(turb, body, args):
984 """Implementation of the /solved command
986 The args string should be a confirmed solution."""
988 channel_id = body['channel_id'][0]
989 user_id = body['user_id'][0]
991 old_puzzle = puzzle_for_channel(turb, channel_id)
994 return bot_reply("Sorry, this is not a puzzle channel.")
998 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1000 # Make a deep copy of the puzzle object
1001 puzzle = puzzle_copy(old_puzzle)
1003 # Set the status and solution fields in the database
1004 puzzle['status'] = 'solved'
1005 puzzle['solution'].append(args)
1006 if 'state' in puzzle:
1008 turb.table.put_item(Item=puzzle)
1010 # Report the solution to the puzzle's channel
1012 turb.slack_client, channel_id,
1013 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1015 # Also report the solution to the hunt channel
1016 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1018 turb.slack_client, hunt['channel_id'],
1019 "Puzzle <{}|{}> has been solved!".format(
1020 puzzle['channel_url'],
1024 # And update the puzzle's description
1025 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1029 commands["/solved"] = solved
1031 def hunt(turb, body, args):
1032 """Implementation of the /hunt command
1034 The (optional) args string can be used to filter which puzzles to
1035 display. The first word can be one of 'all', 'unsolved', or
1036 'solved' and can be used to display only puzzles with the given
1037 status. If this first word is missing, this command will display
1038 only unsolved puzzles by default.
1040 Any remaining text in the args string will be interpreted as
1041 search terms. These will be split into separate terms on space
1042 characters, (though quotation marks can be used to include a space
1043 character in a term). All terms must match on a puzzle in order
1044 for that puzzle to be included. But a puzzle will be considered to
1045 match if any of the puzzle title, round title, puzzle URL, puzzle
1046 state, puzzle type, tags, or puzzle solution match. Matching will
1047 be performed without regard to case sensitivity and the search
1048 terms can include regular expression syntax.
1052 channel_id = body['channel_id'][0]
1053 response_url = body['response_url'][0]
1055 # First, farm off "/hunt new" as a separate command
1057 return new_hunt_command(turb, body)
1061 # The first word can be a puzzle status and all remaining word
1062 # (if any) are search terms. _But_, if the first word is not a
1063 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1064 # words are search terms and we default status to 'unsolved'.
1065 split_args = args.split(' ', 1)
1066 status = split_args[0]
1067 if (len(split_args) > 1):
1068 terms = split_args[1]
1069 if status not in ('unsolved', 'solved', 'all'):
1075 # Separate search terms on spaces (but allow for quotation marks
1076 # to capture spaces in a search term)
1078 terms = shlex.split(terms)
1080 hunt = hunt_for_channel(turb, channel_id)
1083 return bot_reply("Sorry, this channel doesn't appear to "
1084 + "be a hunt or puzzle channel")
1086 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1088 requests.post(response_url,
1089 json = { 'blocks': blocks },
1090 headers = {'Content-type': 'application/json'}
1095 commands["/hunt"] = hunt
1097 def round(turb, body, args):
1098 """Implementation of the /round command
1100 Displays puzzles in the same round(s) as the puzzle for the
1103 The (optional) args string can be used to filter which puzzles to
1104 display. The first word can be one of 'all', 'unsolved', or
1105 'solved' and can be used to display only puzzles with the given
1106 status. If this first word is missing, this command will display
1107 all puzzles in the round by default.
1109 Any remaining text in the args string will be interpreted as
1110 search terms. These will be split into separate terms on space
1111 characters, (though quotation marks can be used to include a space
1112 character in a term). All terms must match on a puzzle in order
1113 for that puzzle to be included. But a puzzle will be considered to
1114 match if any of the puzzle title, round title, puzzle URL, puzzle
1115 state, or puzzle solution match. Matching will be performed
1116 without regard to case sensitivity and the search terms can
1117 include regular expression syntax.
1120 channel_id = body['channel_id'][0]
1121 response_url = body['response_url'][0]
1123 puzzle = puzzle_for_channel(turb, channel_id)
1124 hunt = hunt_for_channel(turb, channel_id)
1129 "This is not a puzzle channel, but is a hunt channel. "
1130 + "Use /hunt if you want to see all rounds for this hunt.")
1133 "Sorry, this channel doesn't appear to be a puzzle channel "
1134 + "so the `/round` command cannot work here.")
1138 # The first word can be a puzzle status and all remaining word
1139 # (if any) are search terms. _But_, if the first word is not a
1140 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1141 # words are search terms and we default status to 'unsolved'.
1142 split_args = args.split(' ', 1)
1143 status = split_args[0]
1144 if (len(split_args) > 1):
1145 terms = split_args[1]
1146 if status not in ('unsolved', 'solved', 'all'):
1152 # Separate search terms on spaces (but allow for quotation marks
1153 # to capture spaces in a search term)
1155 terms = shlex.split(terms)
1157 blocks = hunt_blocks(turb, hunt,
1158 puzzle_status=status, search_terms=terms,
1159 limit_to_rounds=puzzle.get('rounds', [])
1162 requests.post(response_url,
1163 json = { 'blocks': blocks },
1164 headers = {'Content-type': 'application/json'}
1169 commands["/round"] = round