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 This can be used to create a new hunt ("/new hunt") or a new
737 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
738 default behavior (as it is much more common).
740 This operations are identical to the existing "/hunt new" and
741 "/puzzle new". I don't know that that redundancy is actually
742 helpful in the interface. But at least having both allows us to
743 experiment and decide which is more natural and should be kept
748 return new_hunt_command(turb, body)
750 return new_puzzle(turb, body)
752 commands["/new"] = new
754 def new_puzzle(turb, body):
755 """Implementation of the "/puzzle new" command
757 This brings up a dialog box for creating a new puzzle.
760 channel_id = body['channel_id'][0]
761 trigger_id = body['trigger_id'][0]
763 hunt = hunt_for_channel(turb, channel_id)
766 return bot_reply("Sorry, this channel doesn't appear to "
767 + "be a hunt or puzzle channel")
769 round_options = hunt_rounds(turb, hunt['hunt_id'])
771 if len(round_options):
772 round_options_block = [
773 multi_select_block("Round(s)", "rounds",
774 "Existing round(s) this puzzle belongs to",
778 round_options_block = []
782 "private_metadata": json.dumps({
783 "hunt_id": hunt['hunt_id'],
785 "title": {"type": "plain_text", "text": "New Puzzle"},
786 "submit": { "type": "plain_text", "text": "Create" },
788 section_block(text_block("*For {}*".format(hunt['name']))),
789 input_block("Puzzle name", "name", "Name of the puzzle"),
790 input_block("Puzzle URL", "url", "External URL of puzzle",
792 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
793 * round_options_block,
794 input_block("New round(s)", "new_rounds",
795 "New round(s) this puzzle belongs to " +
801 result = turb.slack_client.views_open(trigger_id=trigger_id,
805 submission_handlers[result['view']['id']] = new_puzzle_submission
809 def new_puzzle_submission(turb, payload, metadata):
810 """Handler for the user submitting the new puzzle modal
812 This is the modal view presented to the user by the new_puzzle
816 # First, read all the various data from the request
817 meta = json.loads(metadata)
818 hunt_id = meta['hunt_id']
820 state = payload['view']['state']['values']
821 name = state['name']['name']['value']
822 url = state['url']['url']['value']
823 if state['meta']['meta']['selected_options']:
826 puzzle_type = 'plain'
827 if 'rounds' in state:
828 rounds = [option['value'] for option in
829 state['rounds']['rounds']['selected_options']]
832 new_rounds = state['new_rounds']['new_rounds']['value']
834 # Before doing anything, reject this puzzle if a puzzle already
835 # exists with the same URL.
837 existing = find_puzzle_for_url(turb, hunt_id, url)
839 return submission_error(
841 "Error: A puzzle with this URL already exists.")
843 # Create a Slack-channel-safe puzzle_id
844 puzzle_id = puzzle_id_from_name(name)
846 # Create a channel for the puzzle
847 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
850 response = turb.slack_client.conversations_create(
851 name=hunt_dash_channel)
852 except SlackApiError as e:
853 return submission_error(
855 "Error creating Slack channel {}: {}"
856 .format(hunt_dash_channel, e.response['error']))
858 channel_id = response['channel']['id']
860 # Add any new rounds to the database
862 for round in new_rounds.split(','):
863 # Drop any leading/trailing spaces from the round name
864 round = round.strip()
865 # Ignore any empty string
872 'SK': 'round-' + round
876 # Construct a puzzle dict
879 "puzzle_id": puzzle_id,
880 "channel_id": channel_id,
882 "status": 'unsolved',
889 puzzle['rounds'] = rounds
891 # Finally, compute the appropriate sort key
892 puzzle["SK"] = puzzle_sort_key(puzzle)
894 # Insert the newly-created puzzle into the database
895 turb.table.put_item(Item=puzzle)
899 def state(turb, body, args):
900 """Implementation of the /state command
902 The args string should be a brief sentence describing where things
903 stand or what's needed."""
905 channel_id = body['channel_id'][0]
907 old_puzzle = puzzle_for_channel(turb, channel_id)
911 "Sorry, the /state command only works in a puzzle channel")
913 # Make a deep copy of the puzzle object
914 puzzle = puzzle_copy(old_puzzle)
916 # Update the puzzle in the database
917 puzzle['state'] = args
918 turb.table.put_item(Item=puzzle)
920 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
924 commands["/state"] = state
926 def tag(turb, body, args):
927 """Implementation of the `/tag` command.
929 Arg is either a tag to add (optionally prefixed with '+'), or if
930 prefixed with '-' is a tag to remove.
934 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
935 + "or `/tag -TAG_TO_REMOVE`.")
937 channel_id = body['channel_id'][0]
939 old_puzzle = puzzle_for_channel(turb, channel_id)
943 "Sorry, the /tag command only works in a puzzle channel")
954 # Force tag to all uppercase
957 # Reject a tag that is not alphabetic or underscore A-Z_
958 if not re.match(r'^[A-Z0-9_]*$', tag):
959 return bot_reply("Sorry, tags can only contain letters, numbers, "
960 + "and the underscore character.")
962 if action == 'remove':
963 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
964 return bot_reply("Nothing to do. This puzzle is not tagged "
965 + "with the tag: {}".format(tag))
967 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
968 return bot_reply("Nothing to do. This puzzle is already tagged "
969 + "with the tag: {}".format(tag))
971 # OK. Error checking is done. Let's get to work
973 # Make a deep copy of the puzzle object
974 puzzle = puzzle_copy(old_puzzle)
976 if action == 'remove':
977 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
979 if 'tags' not in puzzle:
980 puzzle['tags'] = [tag]
982 puzzle['tags'].append(tag)
984 turb.table.put_item(Item=puzzle)
986 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
990 commands["/tag"] = tag
992 def solved(turb, body, args):
993 """Implementation of the /solved command
995 The args string should be a confirmed solution."""
997 channel_id = body['channel_id'][0]
998 user_id = body['user_id'][0]
1000 old_puzzle = puzzle_for_channel(turb, channel_id)
1003 return bot_reply("Sorry, this is not a puzzle channel.")
1007 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1009 # Make a deep copy of the puzzle object
1010 puzzle = puzzle_copy(old_puzzle)
1012 # Set the status and solution fields in the database
1013 puzzle['status'] = 'solved'
1014 puzzle['solution'].append(args)
1015 if 'state' in puzzle:
1017 turb.table.put_item(Item=puzzle)
1019 # Report the solution to the puzzle's channel
1021 turb.slack_client, channel_id,
1022 "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1024 # Also report the solution to the hunt channel
1025 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1027 turb.slack_client, hunt['channel_id'],
1028 "Puzzle <{}|{}> has been solved!".format(
1029 puzzle['channel_url'],
1033 # And update the puzzle's description
1034 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1038 commands["/solved"] = solved
1040 def hunt(turb, body, args):
1041 """Implementation of the /hunt command
1043 The (optional) args string can be used to filter which puzzles to
1044 display. The first word can be one of 'all', 'unsolved', or
1045 'solved' and can be used to display only puzzles with the given
1046 status. If this first word is missing, this command will display
1047 only unsolved puzzles by default.
1049 Any remaining text in the args string will be interpreted as
1050 search terms. These will be split into separate terms on space
1051 characters, (though quotation marks can be used to include a space
1052 character in a term). All terms must match on a puzzle in order
1053 for that puzzle to be included. But a puzzle will be considered to
1054 match if any of the puzzle title, round title, puzzle URL, puzzle
1055 state, puzzle type, tags, or puzzle solution match. Matching will
1056 be performed without regard to case sensitivity and the search
1057 terms can include regular expression syntax.
1061 channel_id = body['channel_id'][0]
1062 response_url = body['response_url'][0]
1064 # First, farm off "/hunt new" as a separate command
1066 return new_hunt_command(turb, body)
1070 # The first word can be a puzzle status and all remaining word
1071 # (if any) are search terms. _But_, if the first word is not a
1072 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1073 # words are search terms and we default status to 'unsolved'.
1074 split_args = args.split(' ', 1)
1075 status = split_args[0]
1076 if (len(split_args) > 1):
1077 terms = split_args[1]
1078 if status not in ('unsolved', 'solved', 'all'):
1084 # Separate search terms on spaces (but allow for quotation marks
1085 # to capture spaces in a search term)
1087 terms = shlex.split(terms)
1089 hunt = hunt_for_channel(turb, channel_id)
1092 return bot_reply("Sorry, this channel doesn't appear to "
1093 + "be a hunt or puzzle channel")
1095 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1097 requests.post(response_url,
1098 json = { 'blocks': blocks },
1099 headers = {'Content-type': 'application/json'}
1104 commands["/hunt"] = hunt
1106 def round(turb, body, args):
1107 """Implementation of the /round command
1109 Displays puzzles in the same round(s) as the puzzle for the
1112 The (optional) args string can be used to filter which puzzles to
1113 display. The first word can be one of 'all', 'unsolved', or
1114 'solved' and can be used to display only puzzles with the given
1115 status. If this first word is missing, this command will display
1116 all puzzles in the round by default.
1118 Any remaining text in the args string will be interpreted as
1119 search terms. These will be split into separate terms on space
1120 characters, (though quotation marks can be used to include a space
1121 character in a term). All terms must match on a puzzle in order
1122 for that puzzle to be included. But a puzzle will be considered to
1123 match if any of the puzzle title, round title, puzzle URL, puzzle
1124 state, or puzzle solution match. Matching will be performed
1125 without regard to case sensitivity and the search terms can
1126 include regular expression syntax.
1129 channel_id = body['channel_id'][0]
1130 response_url = body['response_url'][0]
1132 puzzle = puzzle_for_channel(turb, channel_id)
1133 hunt = hunt_for_channel(turb, channel_id)
1138 "This is not a puzzle channel, but is a hunt channel. "
1139 + "Use /hunt if you want to see all rounds for this hunt.")
1142 "Sorry, this channel doesn't appear to be a puzzle channel "
1143 + "so the `/round` command cannot work here.")
1147 # The first word can be a puzzle status and all remaining word
1148 # (if any) are search terms. _But_, if the first word is not a
1149 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1150 # words are search terms and we default status to 'unsolved'.
1151 split_args = args.split(' ', 1)
1152 status = split_args[0]
1153 if (len(split_args) > 1):
1154 terms = split_args[1]
1155 if status not in ('unsolved', 'solved', 'all'):
1161 # Separate search terms on spaces (but allow for quotation marks
1162 # to capture spaces in a search term)
1164 terms = shlex.split(terms)
1166 blocks = hunt_blocks(turb, hunt,
1167 puzzle_status=status, search_terms=terms,
1168 limit_to_rounds=puzzle.get('rounds', [])
1171 requests.post(response_url,
1172 json = { 'blocks': blocks },
1173 headers = {'Content-type': 'application/json'}
1178 commands["/round"] = round