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 find_puzzle_for_puzzle_id,
14 puzzle_update_channel_and_sheet,
21 from turbot.round import round_quoted_puzzles_titles_answers
22 from turbot.help import turbot_help
23 from turbot.have_you_tried import have_you_tried
30 from botocore.exceptions import ClientError
31 from boto3.dynamodb.conditions import Key
32 from turbot.slack import slack_send_message
36 actions['button'] = {}
38 submission_handlers = {}
40 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
42 # Note: This restriction not only allows for hunt and puzzle ID values to
43 # be used as Slack channel names, but it also allows for '-' as a valid
44 # separator between a hunt and a puzzle ID (for example in the puzzle
45 # edit dialog where a single attribute must capture both values).
46 valid_id_re = r'^[_a-z0-9]+$'
48 lambda_ok = {'statusCode': 200}
50 def bot_reply(message):
51 """Construct a return value suitable for a bot reply
53 This is suitable as a way to give an error back to the user who
54 initiated a slash command, for example."""
61 def submission_error(field, error):
62 """Construct an error suitable for returning for an invalid submission.
64 Returning this value will prevent a submission and alert the user that
65 the given field is invalid because of the given error."""
67 print("Rejecting invalid modal submission: {}".format(error))
72 "Content-Type": "application/json"
75 "response_action": "errors",
82 def multi_static_select(turb, payload):
83 """Handler for the action of user entering a multi-select value"""
87 actions['multi_static_select'] = {"*": multi_static_select}
89 def edit(turb, body, args):
91 """Implementation of the `/edit` command
93 This can be used as `/edit` (with no arguments) in either a hunt
94 or a puzzle channel to edit that hunt or puzzle. It can also be
95 called explicitly as `/edit hunt` to edit a hunt even from a
98 In any case, the operation is identical to `/hunt edit` or
102 # If we have an explicit argument, do what it says to do
104 return edit_hunt_command(turb, body)
107 return edit_puzzle_command(turb, body)
109 # Any other argument string is an error
111 return bot_reply("Error: Unexpected argument: {}\n".format(args) +
112 "Usage: `/edit puzzle`, `/edit hunt`, or " +
113 "`/edit` (to choose based on channel)"
116 # No explicit argument, so select what to edit based on the current channel
117 channel_id = body['channel_id'][0]
118 trigger_id = body['trigger_id'][0]
120 puzzle = puzzle_for_channel(turb, channel_id)
122 return edit_puzzle(turb, puzzle, trigger_id)
124 hunt = hunt_for_channel(turb, channel_id)
126 return edit_hunt(turb, hunt, trigger_id)
128 return bot_reply("Sorry, `/edit` only works in a hunt or puzzle channel.")
130 commands["/edit"] = edit
133 def edit_puzzle_command(turb, body):
134 """Implementation of the `/puzzle edit` command
136 As dispatched from the puzzle() function.
139 channel_id = body['channel_id'][0]
140 trigger_id = body['trigger_id'][0]
142 puzzle = puzzle_for_channel(turb, channel_id)
145 return bot_reply("Sorry, this does not appear to be a puzzle channel.")
147 return edit_puzzle(turb, puzzle, trigger_id)
149 def edit_puzzle_button(turb, payload):
150 """Handler for the action of user pressing an edit_puzzle button"""
152 action_id = payload['actions'][0]['action_id']
153 trigger_id = payload['trigger_id']
155 (hunt_id, sort_key) = action_id.split('-', 1)
157 puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
160 return bot_reply("Error: Puzzle not found.")
162 return edit_puzzle(turb, puzzle, trigger_id)
164 actions['button']['edit_puzzle'] = edit_puzzle_button
166 def edit_puzzle(turb, puzzle, trigger_id):
167 """Common code for implementing an edit puzzle dialog
169 This implementation is common whether the edit operation was invoked
170 by a button (edit_puzzle_button) or a command (edit_puzzle_command).
173 round_options = hunt_rounds(turb, puzzle['hunt_id'])
175 if len(round_options):
176 round_options_block = [
177 multi_select_block("Round(s)", "rounds",
178 "Existing round(s) this puzzle belongs to",
180 initial_options=puzzle.get("rounds", None)),
183 round_options_block = []
186 if puzzle.get("status", "unsolved") == solved:
190 solution_list = puzzle.get("solution", [])
192 solution_str = ", ".join(solution_list)
196 "private_metadata": json.dumps({
197 "hunt_id": puzzle['hunt_id'],
199 "puzzle_id": puzzle['puzzle_id'],
200 "channel_id": puzzle["channel_id"],
201 "channel_url": puzzle["channel_url"],
202 "sheet_url": puzzle["sheet_url"],
204 "title": {"type": "plain_text", "text": "Edit Puzzle"},
205 "submit": { "type": "plain_text", "text": "Save" },
207 input_block("Puzzle name", "name", "Name of the puzzle",
208 initial_value=puzzle["name"]),
209 input_block("Puzzle URL", "url", "External URL of puzzle",
210 initial_value=puzzle.get("url", None),
212 checkbox_block("Is this a meta puzzle?", "Meta", "meta",
213 checked=(puzzle.get('type', 'plain') == 'meta')),
214 * round_options_block,
215 input_block("New round(s)", "new_rounds",
216 "New round(s) this puzzle belongs to " +
219 input_block("State", "state",
220 "State of this puzzle (partial progress, next steps)",
221 initial_value=puzzle.get("state", None),
224 "Puzzle status", "Solved", "solved",
225 checked=(puzzle.get('status', 'unsolved') == 'solved')),
226 input_block("Solution", "solution",
227 "Solution(s) (comma-separated if multiple)",
228 initial_value=solution_str,
233 result = turb.slack_client.views_open(trigger_id=trigger_id,
237 submission_handlers[result['view']['id']] = edit_puzzle_submission
241 def edit_puzzle_submission(turb, payload, metadata):
242 """Handler for the user submitting the edit puzzle modal
244 This is the modal view presented to the user by the edit_puzzle
250 # First, read all the various data from the request
251 meta = json.loads(metadata)
252 puzzle['hunt_id'] = meta['hunt_id']
253 puzzle['SK'] = meta['SK']
254 puzzle['puzzle_id'] = meta['puzzle_id']
255 puzzle['channel_id'] = meta['channel_id']
256 puzzle['channel_url'] = meta['channel_url']
257 puzzle['sheet_url'] = meta['sheet_url']
259 state = payload['view']['state']['values']
260 user_id = payload['user']['id']
262 puzzle['name'] = state['name']['name']['value']
263 url = state['url']['url']['value']
266 if state['meta']['meta']['selected_options']:
267 puzzle['type'] = 'meta'
269 puzzle['type'] = 'plain'
270 if 'rounds' in state:
271 rounds = [option['value'] for option in
272 state['rounds']['rounds']['selected_options']]
274 puzzle['rounds'] = rounds
275 new_rounds = state['new_rounds']['new_rounds']['value']
276 puzzle_state = state['state']['state']['value']
278 puzzle['state'] = puzzle_state
279 if state['solved']['solved']['selected_options']:
280 puzzle['status'] = 'solved'
282 puzzle['status'] = 'unsolved'
283 puzzle['solution'] = []
284 solution = state['solution']['solution']['value']
286 # Construct a list from a set to avoid any duplicates
287 puzzle['solution'] = list({
288 sol.strip() for sol in solution.split(',')
291 # Verify that there's a solution if the puzzle is mark solved
292 if puzzle['status'] == 'solved' and not puzzle['solution']:
293 return submission_error("solution",
294 "A solved puzzle requires a solution.")
296 if puzzle['status'] == 'unsolved' and puzzle['solution']:
297 return submission_error("solution",
298 "An unsolved puzzle should have no solution.")
300 # Add any new rounds to the database
302 if 'rounds' not in puzzle:
303 puzzle['rounds'] = []
304 for round in new_rounds.split(','):
305 # Drop any leading/trailing spaces from the round name
306 round = round.strip()
307 # Ignore any empty string
310 puzzle['rounds'].append(round)
313 'hunt_id': puzzle['hunt_id'],
314 'SK': 'round-' + round
318 # Get old puzzle from the database (to determine what's changed)
319 old_puzzle = find_puzzle_for_sort_key(turb,
323 # If we are changing puzzle type (meta -> plain or plain -> meta)
324 # then the sort key has to change, so compute the new one and delete
325 # the old item from the database.
327 # XXX: We should really be using a transaction here to combine the
328 # delete_item and the put_item into a single transaction, but
329 # the boto interface is annoying in that transactions are only on
330 # the "Client" object which has a totally different interface than
331 # the "Table" object I've been using so I haven't figured out how
334 if puzzle['type'] != old_puzzle.get('type', 'plain'):
335 puzzle['SK'] = puzzle_sort_key(puzzle)
336 turb.table.delete_item(Key={
337 'hunt_id': old_puzzle['hunt_id'],
338 'SK': old_puzzle['SK']
341 # Update the puzzle in the database
342 turb.table.put_item(Item=puzzle)
344 # Inform the puzzle channel about the edit
345 edit_message = "Puzzle edited by <@{}>".format(user_id)
346 blocks = ([section_block(text_block(edit_message+":\n"))] +
347 puzzle_blocks(puzzle, include_rounds=True))
349 turb.slack_client, puzzle['channel_id'],
350 edit_message, blocks=blocks)
352 # Also inform the hunt if the puzzle's solved status changed
353 if puzzle['status'] != old_puzzle['status']:
354 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
355 if puzzle['status'] == 'solved':
356 message = "Puzzle <{}|{}> has been solved!".format(
357 puzzle['channel_url'],
360 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
361 puzzle['channel_url'],
363 slack_send_message(turb.slack_client, hunt['channel_id'], message)
365 # We need to set the channel topic if any of puzzle name, url,
366 # state, status, or solution, has changed. Let's just do that
367 # unconditionally here.
368 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
372 def edit_hunt_command(turb, body):
373 """Implementation of the `/hunt edit` command
375 As dispatched from the hunt() function.
378 channel_id = body['channel_id'][0]
379 trigger_id = body['trigger_id'][0]
381 hunt = hunt_for_channel(turb, channel_id)
384 return bot_reply("Sorry, this does not appear to be a hunt channel.")
386 return edit_hunt(turb, hunt, trigger_id)
388 def edit_hunt_button(turb, payload):
389 """Handler for the action of user pressing an edit_hunt button"""
391 hunt_id = payload['actions'][0]['action_id']
392 trigger_id = payload['trigger_id']
394 hunt = find_hunt_for_hunt_id(turb, hunt_id)
397 return bot_reply("Error: Hunt not found.")
399 return edit_hunt(turb, hunt, trigger_id)
401 actions['button']['edit_hunt'] = edit_hunt_button
403 def edit_hunt(turb, hunt, trigger_id):
404 """Common code for implementing an edit hunt dialog
406 This implementation is common whether the edit operation was invoked
407 by a button (edit_hunt_button) or a command (edit_hunt_command).
412 "private_metadata": json.dumps({
413 "hunt_id": hunt["hunt_id"],
415 "is_hunt": hunt["is_hunt"],
416 "channel_id": hunt["channel_id"],
417 "sheet_url": hunt["sheet_url"],
418 "folder_id": hunt["folder_id"],
420 "title": { "type": "plain_text", "text": "Edit Hunt" },
421 "submit": { "type": "plain_text", "text": "Save" },
423 input_block("Hunt name", "name", "Name of the hunt",
424 initial_value=hunt["name"]),
425 input_block("Hunt URL", "url", "External URL of hunt",
426 initial_value=hunt.get("url", None),
428 checkbox_block("Is this hunt active?", "Active", "active",
429 checked=(hunt.get('active', False)))
433 result = turb.slack_client.views_open(trigger_id=trigger_id,
437 submission_handlers[result['view']['id']] = edit_hunt_submission
441 def edit_hunt_submission(turb, payload, metadata):
442 """Handler for the user submitting the edit hunt modal
444 This is the modal view presented by the edit_hunt function above.
449 # First, read all the various data from the request
450 meta = json.loads(metadata)
451 hunt['hunt_id'] = meta['hunt_id']
452 hunt['SK'] = meta['SK']
453 hunt['is_hunt'] = meta['is_hunt']
454 hunt['channel_id'] = meta['channel_id']
455 hunt['sheet_url'] = meta['sheet_url']
456 hunt['folder_id'] = meta['folder_id']
458 state = payload['view']['state']['values']
459 user_id = payload['user']['id']
461 hunt['name'] = state['name']['name']['value']
462 url = state['url']['url']['value']
466 if state['active']['active']['selected_options']:
467 hunt['active'] = True
469 hunt['active'] = False
471 # Update the hunt in the database
472 turb.table.put_item(Item=hunt)
474 # Inform the hunt channel about the edit
475 edit_message = "Hunt edited by <@{}>".format(user_id)
477 section_block(text_block(edit_message)),
478 section_block(text_block("Hunt name: {}".format(hunt['name']))),
481 url = hunt.get('url', None)
484 section_block(text_block("Hunt URL: {}".format(hunt['url'])))
488 turb.slack_client, hunt['channel_id'],
489 edit_message, blocks=blocks)
493 def new_hunt_command(turb, body):
494 """Implementation of the '/hunt new' command
496 As dispatched from the hunt() function.
499 trigger_id = body['trigger_id'][0]
501 return new_hunt(turb, trigger_id)
503 def new_hunt_button(turb, payload):
504 """Handler for the action of user pressing the new_hunt button"""
506 trigger_id = payload['trigger_id']
508 return new_hunt(turb, trigger_id)
510 def new_hunt(turb, trigger_id):
511 """Common code for implementing a new hunt dialog
513 This implementation is common whether the operations was invoked
514 by a button (new_hunt_button) or a command (new_hunt_command).
519 "private_metadata": json.dumps({}),
520 "title": { "type": "plain_text", "text": "New Hunt" },
521 "submit": { "type": "plain_text", "text": "Create" },
523 input_block("Hunt name", "name", "Name of the hunt"),
524 input_block("Hunt ID", "hunt_id",
525 "Used as puzzle channel prefix "
526 + "(no spaces nor punctuation)"),
527 input_block("Hunt URL", "url", "External URL of hunt",
532 result = turb.slack_client.views_open(trigger_id=trigger_id,
535 submission_handlers[result['view']['id']] = new_hunt_submission
539 actions['button']['new_hunt'] = new_hunt_button
541 def new_hunt_submission(turb, payload, metadata):
542 """Handler for the user submitting the new hunt modal
544 This is the modal view presented to the user by the new_hunt
547 state = payload['view']['state']['values']
548 user_id = payload['user']['id']
549 name = state['name']['name']['value']
550 hunt_id = state['hunt_id']['hunt_id']['value']
551 url = state['url']['url']['value']
553 # Validate that the hunt_id contains no invalid characters
554 if not re.match(valid_id_re, hunt_id):
555 return submission_error("hunt_id",
556 "Hunt ID can only contain lowercase letters, "
557 + "numbers, and underscores")
559 # Check to see if the turbot table exists
561 exists = turb.table.table_status in ("CREATING", "UPDATING",
566 # Create the turbot table if necessary.
568 turb.table = turb.db.create_table(
571 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
572 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
574 AttributeDefinitions=[
575 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
576 {'AttributeName': 'SK', 'AttributeType': 'S'},
577 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
578 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
579 {'AttributeName': 'url', 'AttributeType': 'S'},
580 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
582 ProvisionedThroughput={
583 'ReadCapacityUnits': 5,
584 'WriteCapacityUnits': 5
586 GlobalSecondaryIndexes=[
588 'IndexName': 'channel_id_index',
590 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
593 'ProjectionType': 'ALL'
595 'ProvisionedThroughput': {
596 'ReadCapacityUnits': 5,
597 'WriteCapacityUnits': 5
601 'IndexName': 'is_hunt_index',
603 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
606 'ProjectionType': 'ALL'
608 'ProvisionedThroughput': {
609 'ReadCapacityUnits': 5,
610 'WriteCapacityUnits': 5
614 LocalSecondaryIndexes = [
616 'IndexName': 'url_index',
618 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
619 {'AttributeName': 'url', 'KeyType': 'RANGE'},
622 'ProjectionType': 'ALL'
626 'IndexName': 'puzzle_id_index',
628 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
629 {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
632 'ProjectionType': 'ALL'
637 return submission_error(
639 "Still bootstrapping turbot table. Try again in a minute, please.")
641 # Create a channel for the hunt
643 response = turb.slack_client.conversations_create(name=hunt_id)
644 except SlackApiError as e:
645 return submission_error("hunt_id",
646 "Error creating Slack channel: {}"
647 .format(e.response['error']))
649 channel_id = response['channel']['id']
651 # Insert the newly-created hunt into the database
652 # (leaving it as non-active for now until the channel-created handler
653 # finishes fixing it up with a sheet and a companion table)
656 "SK": "hunt-{}".format(hunt_id),
658 "channel_id": channel_id,
664 turb.table.put_item(Item=item)
666 # Invite the initiating user to the channel
667 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
671 def view_submission(turb, payload):
672 """Handler for Slack interactive view submission
674 Specifically, those that have a payload type of 'view_submission'"""
676 view_id = payload['view']['id']
677 metadata = payload['view']['private_metadata']
679 if view_id in submission_handlers:
680 return submission_handlers[view_id](turb, payload, metadata)
682 print("Error: Unknown view ID: {}".format(view_id))
687 def rot(turb, body, args):
688 """Implementation of the /rot command
690 The args string should be as follows:
692 [count|*] String to be rotated
694 That is, the first word of the string is an optional number (or
695 the character '*'). If this is a number it indicates an amount to
696 rotate each character in the string. If the count is '*' or is not
697 present, then the string will be rotated through all possible 25
700 The result of the rotation is returned (with Slack formatting) in
701 the body of the response so that Slack will provide it as a reply
702 to the user who submitted the slash command."""
704 channel_name = body['channel_name'][0]
705 response_url = body['response_url'][0]
706 channel_id = body['channel_id'][0]
708 result = turbot.rot.rot(args)
710 if (channel_name == "directmessage"):
711 requests.post(response_url,
712 json = {"text": result},
713 headers = {"Content-type": "application/json"})
715 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
719 commands["/rot"] = rot
721 def get_table_item(turb, table_name, key, value):
722 """Get an item from the database 'table_name' with 'key' as 'value'
724 Returns a tuple of (item, table) if found and (None, None) otherwise."""
726 table = turb.db.Table(table_name)
728 response = table.get_item(Key={key: value})
730 if 'Item' in response:
731 return (response['Item'], table)
735 def db_entry_for_channel(turb, channel_id):
736 """Given a channel ID return the database item for this channel
738 If this channel is a registered hunt or puzzle channel, return the
739 corresponding row from the database for this channel. Otherwise,
742 Note: If you need to specifically ensure that the channel is a
743 puzzle or a hunt, please call puzzle_for_channel or
744 hunt_for_channel respectively.
747 response = turb.table.query(
748 IndexName = "channel_id_index",
749 KeyConditionExpression=Key("channel_id").eq(channel_id)
752 if response['Count'] == 0:
755 return response['Items'][0]
758 def puzzle_for_channel(turb, channel_id):
760 """Given a channel ID return the puzzle from the database for this channel
762 If the given channel_id is a puzzle's channel, this function
763 returns a dict filled with the attributes from the puzzle's entry
766 Otherwise, this function returns None.
769 entry = db_entry_for_channel(turb, channel_id)
771 if entry and entry['SK'].startswith('puzzle-'):
776 def hunt_for_channel(turb, channel_id):
778 """Given a channel ID return the hunt from the database for this channel
780 This works whether the original channel is a primary hunt channel,
781 or if it is one of the channels of a puzzle belonging to the hunt.
783 Returns None if channel does not belong to a hunt, otherwise a
784 dictionary with all fields from the hunt's row in the table,
785 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
788 entry = db_entry_for_channel(turb, channel_id)
790 # We're done if this channel doesn't exist in the database at all
794 # Also done if this channel is a hunt channel
795 if entry['SK'].startswith('hunt-'):
798 # Otherwise, (the channel is in the database, but is not a hunt),
799 # we expect this to be a puzzle channel instead
800 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
802 # python3.9 has a built-in removeprefix but AWS only has python3.8
803 def remove_prefix(text, prefix):
804 if text.startswith(prefix):
805 return text[len(prefix):]
808 def hunt_rounds(turb, hunt_id):
809 """Returns array of strings giving rounds that exist in the given hunt"""
811 response = turb.table.query(
812 KeyConditionExpression=(
813 Key('hunt_id').eq(hunt_id) &
814 Key('SK').begins_with('round-')
818 if response['Count'] == 0:
821 return [remove_prefix(option['SK'], 'round-')
822 for option in response['Items']]
824 def puzzle(turb, body, args):
825 """Implementation of the /puzzle command
827 The args string can be a sub-command:
829 /puzzle new: Bring up a dialog to create a new puzzle
831 /puzzle edit: Edit the puzzle for the current channel
833 Or with no argument at all:
835 /puzzle: Print details of the current puzzle (if in a puzzle channel)
839 return new_puzzle(turb, body)
842 return edit_puzzle_command(turb, body)
845 return bot_reply("Unknown syntax for `/puzzle` command. " +
846 "Valid commands are: `/puzzle`, `/puzzle edit`, " +
847 "and `/puzzle new` to display, edit, or create " +
850 # For no arguments we print the current puzzle as a reply
851 channel_id = body['channel_id'][0]
852 response_url = body['response_url'][0]
854 puzzle = puzzle_for_channel(turb, channel_id)
857 hunt = hunt_for_channel(turb, channel_id)
860 "This is not a puzzle channel, but is a hunt channel. "
861 + "If you want to create a new puzzle for this hunt, use "
865 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
866 + "channel, so the `/puzzle` command cannot work here.")
868 blocks = puzzle_blocks(puzzle, include_rounds=True)
870 # For a meta puzzle, also display the titles and solutions for all
871 # puzzles in the same round.
872 if puzzle.get('type', 'plain') == 'meta':
873 puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
875 # Drop this puzzle itself from the report
876 puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
878 for round in puzzle.get('rounds', [None]):
879 answers = round_quoted_puzzles_titles_answers(round, puzzles)
881 section_block(text_block(
882 "*Feeder solutions from round {}*".format(
883 round if round else "<none>"
885 section_block(text_block(answers))
888 requests.post(response_url,
889 json = {'blocks': blocks},
890 headers = {'Content-type': 'application/json'}
895 commands["/puzzle"] = puzzle
897 def new(turb, body, args):
898 """Implementation of the `/new` command
900 This can be used to create a new hunt ("/new hunt") or a new
901 puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
902 default behavior (as it is much more common).
904 This operations are identical to the existing "/hunt new" and
905 "/puzzle new". I don't know that that redundancy is actually
906 helpful in the interface. But at least having both allows us to
907 experiment and decide which is more natural and should be kept
912 return new_hunt_command(turb, body)
914 return new_puzzle(turb, body)
916 commands["/new"] = new
918 def new_puzzle(turb, body):
919 """Implementation of the "/puzzle new" command
921 This brings up a dialog box for creating a new puzzle.
924 channel_id = body['channel_id'][0]
925 trigger_id = body['trigger_id'][0]
927 hunt = hunt_for_channel(turb, channel_id)
930 return bot_reply("Sorry, this channel doesn't appear to "
931 + "be a hunt or puzzle channel")
933 # We used puzzle (if available) to select the initial round(s)
934 puzzle = puzzle_for_channel(turb, channel_id)
935 initial_rounds = None
937 initial_rounds=puzzle.get("rounds", None)
939 round_options = hunt_rounds(turb, hunt['hunt_id'])
941 if len(round_options):
942 round_options_block = [
943 multi_select_block("Round(s)", "rounds",
944 "Existing round(s) this puzzle belongs to",
946 initial_options=initial_rounds)
949 round_options_block = []
953 "private_metadata": json.dumps({
954 "hunt_id": hunt['hunt_id'],
956 "title": {"type": "plain_text", "text": "New Puzzle"},
957 "submit": { "type": "plain_text", "text": "Create" },
959 section_block(text_block("*For {}*".format(hunt['name']))),
960 input_block("Puzzle name", "name", "Name of the puzzle"),
961 input_block("Puzzle URL", "url", "External URL of puzzle",
963 checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
964 * round_options_block,
965 input_block("New round(s)", "new_rounds",
966 "New round(s) this puzzle belongs to " +
972 result = turb.slack_client.views_open(trigger_id=trigger_id,
976 submission_handlers[result['view']['id']] = new_puzzle_submission
980 def new_puzzle_submission(turb, payload, metadata):
981 """Handler for the user submitting the new puzzle modal
983 This is the modal view presented to the user by the new_puzzle
987 # First, read all the various data from the request
988 meta = json.loads(metadata)
989 hunt_id = meta['hunt_id']
991 state = payload['view']['state']['values']
993 # And start loading data into a puzzle dict
995 puzzle['hunt_id'] = hunt_id
996 puzzle['name'] = state['name']['name']['value']
997 url = state['url']['url']['value']
1000 if state['meta']['meta']['selected_options']:
1001 puzzle['type'] = 'meta'
1003 puzzle['type'] = 'plain'
1004 if 'rounds' in state:
1005 rounds = [option['value'] for option in
1006 state['rounds']['rounds']['selected_options']]
1009 new_rounds = state['new_rounds']['new_rounds']['value']
1011 # Create a Slack-channel-safe puzzle_id
1012 puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1014 # Before doing anything, reject this puzzle if a puzzle already
1015 # exists with the same puzzle_id or url
1016 existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1018 return submission_error(
1020 "Error: This name collides with an existing puzzle.")
1023 existing = find_puzzle_for_url(turb, hunt_id, url)
1025 return submission_error(
1027 "Error: A puzzle with this URL already exists.")
1029 # Add any new rounds to the database
1031 for round in new_rounds.split(','):
1032 # Drop any leading/trailing spaces from the round name
1033 round = round.strip()
1034 # Ignore any empty string
1037 rounds.append(round)
1038 turb.table.put_item(
1041 'SK': 'round-' + round
1046 puzzle['rounds'] = rounds
1048 puzzle['solution'] = []
1049 puzzle['status'] = 'unsolved'
1051 # Create a channel for the puzzle
1052 channel_name = puzzle_channel_name(puzzle)
1055 response = turb.slack_client.conversations_create(
1057 except SlackApiError as e:
1058 return submission_error(
1060 "Error creating Slack channel {}: {}"
1061 .format(channel_name, e.response['error']))
1063 puzzle['channel_id'] = response['channel']['id']
1065 # Finally, compute the appropriate sort key
1066 puzzle["SK"] = puzzle_sort_key(puzzle)
1068 # Insert the newly-created puzzle into the database
1069 turb.table.put_item(Item=puzzle)
1073 def state(turb, body, args):
1074 """Implementation of the /state command
1076 The args string should be a brief sentence describing where things
1077 stand or what's needed."""
1079 channel_id = body['channel_id'][0]
1081 old_puzzle = puzzle_for_channel(turb, channel_id)
1085 "Sorry, the /state command only works in a puzzle channel")
1087 # Make a deep copy of the puzzle object
1088 puzzle = puzzle_copy(old_puzzle)
1090 # Update the puzzle in the database
1091 puzzle['state'] = args
1092 turb.table.put_item(Item=puzzle)
1094 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1098 commands["/state"] = state
1100 def tag(turb, body, args):
1101 """Implementation of the `/tag` command.
1103 Arg is either a tag to add (optionally prefixed with '+'), or if
1104 prefixed with '-' is a tag to remove.
1108 return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1109 + "or `/tag -TAG_TO_REMOVE`.")
1111 channel_id = body['channel_id'][0]
1113 old_puzzle = puzzle_for_channel(turb, channel_id)
1117 "Sorry, the /tag command only works in a puzzle channel")
1128 # Force tag to all uppercase
1131 # Reject a tag that is not alphabetic or underscore A-Z_
1132 if not re.match(r'^[A-Z0-9_]*$', tag):
1133 return bot_reply("Sorry, tags can only contain letters, numbers, "
1134 + "and the underscore character.")
1136 if action == 'remove':
1137 if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1138 return bot_reply("Nothing to do. This puzzle is not tagged "
1139 + "with the tag: {}".format(tag))
1141 if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1142 return bot_reply("Nothing to do. This puzzle is already tagged "
1143 + "with the tag: {}".format(tag))
1145 # OK. Error checking is done. Let's get to work
1147 # Make a deep copy of the puzzle object
1148 puzzle = puzzle_copy(old_puzzle)
1150 if action == 'remove':
1151 puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1153 if 'tags' not in puzzle:
1154 puzzle['tags'] = [tag]
1156 puzzle['tags'].append(tag)
1158 turb.table.put_item(Item=puzzle)
1160 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1164 commands["/tag"] = tag
1166 def solved(turb, body, args):
1167 """Implementation of the /solved command
1169 The args string should be a confirmed solution."""
1171 channel_id = body['channel_id'][0]
1172 user_id = body['user_id'][0]
1174 old_puzzle = puzzle_for_channel(turb, channel_id)
1177 return bot_reply("Sorry, this is not a puzzle channel.")
1181 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1183 # Make a deep copy of the puzzle object
1184 puzzle = puzzle_copy(old_puzzle)
1186 # Set the status and solution fields in the database
1187 puzzle['status'] = 'solved'
1189 # Don't append a duplicate solution
1190 if args not in puzzle['solution']:
1191 puzzle['solution'].append(args)
1192 if 'state' in puzzle:
1194 turb.table.put_item(Item=puzzle)
1196 # Report the solution to the puzzle's channel
1198 turb.slack_client, channel_id,
1199 "Puzzle marked solved by <@{}>: `{}`".format(user_id, args))
1201 # Also report the solution to the hunt channel
1202 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1204 turb.slack_client, hunt['channel_id'],
1205 "Puzzle <{}|{}> has been solved!".format(
1206 puzzle['channel_url'],
1210 # And update the puzzle's description
1211 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1215 commands["/solved"] = solved
1217 def delete(turb, body, args):
1218 """Implementation of the /delete command
1220 The argument to this command is the ID of a hunt.
1222 The command will report an error if the specified hunt is active.
1224 If the hunt is inactive, this command will archive all channels
1229 return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`")
1232 hunt = find_hunt_for_hunt_id(turb, hunt_id)
1235 return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id))
1239 "Error, refusing to delete active hunt \"{}\".".format(hunt_id)
1242 if hunt['hunt_id'] != hunt_id:
1244 "Error, expected hunt ID of \"{}\" but found \"{}\".".format(
1245 hunt_id, hunt['hunt_id']
1249 puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
1251 for puzzle in puzzles:
1252 channel_id = puzzle['channel_id']
1253 turb.slack_client.conversations_archive(channel=channel_id)
1255 turb.slack_client.conversations_archive(channel=hunt['channel_id'])
1259 commands["/delete"] = delete
1261 def hunt(turb, body, args):
1262 """Implementation of the /hunt command
1264 The (optional) args string can be used to filter which puzzles to
1265 display. The first word can be one of 'all', 'unsolved', or
1266 'solved' and can be used to display only puzzles with the given
1267 status. If this first word is missing, this command will display
1268 only unsolved puzzles by default.
1270 Any remaining text in the args string will be interpreted as
1271 search terms. These will be split into separate terms on space
1272 characters, (though quotation marks can be used to include a space
1273 character in a term). All terms must match on a puzzle in order
1274 for that puzzle to be included. But a puzzle will be considered to
1275 match if any of the puzzle title, round title, puzzle URL, puzzle
1276 state, puzzle type, tags, or puzzle solution match. Matching will
1277 be performed without regard to case sensitivity and the search
1278 terms can include regular expression syntax.
1282 channel_id = body['channel_id'][0]
1283 response_url = body['response_url'][0]
1285 # First, farm off "/hunt new" and "/hunt edit" a separate commands
1287 return new_hunt_command(turb, body)
1290 return edit_hunt_command(turb, body)
1294 # The first word can be a puzzle status and all remaining word
1295 # (if any) are search terms. _But_, if the first word is not a
1296 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1297 # words are search terms and we default status to 'unsolved'.
1298 split_args = args.split(' ', 1)
1299 status = split_args[0]
1300 if (len(split_args) > 1):
1301 terms = split_args[1]
1302 if status not in ('unsolved', 'solved', 'all'):
1308 # Separate search terms on spaces (but allow for quotation marks
1309 # to capture spaces in a search term)
1311 terms = shlex.split(terms)
1313 hunt = hunt_for_channel(turb, channel_id)
1316 return bot_reply("Sorry, this channel doesn't appear to "
1317 + "be a hunt or puzzle channel")
1319 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1321 for block in blocks:
1322 if len(block) > 100:
1324 requests.post(response_url,
1325 json = { 'blocks': block },
1326 headers = {'Content-type': 'application/json'}
1331 commands["/hunt"] = hunt
1333 def round(turb, body, args):
1334 """Implementation of the /round command
1336 Displays puzzles in the same round(s) as the puzzle for the
1339 The (optional) args string can be used to filter which puzzles to
1340 display. The first word can be one of 'all', 'unsolved', or
1341 'solved' and can be used to display only puzzles with the given
1342 status. If this first word is missing, this command will display
1343 all puzzles in the round by default.
1345 Any remaining text in the args string will be interpreted as
1346 search terms. These will be split into separate terms on space
1347 characters, (though quotation marks can be used to include a space
1348 character in a term). All terms must match on a puzzle in order
1349 for that puzzle to be included. But a puzzle will be considered to
1350 match if any of the puzzle title, round title, puzzle URL, puzzle
1351 state, or puzzle solution match. Matching will be performed
1352 without regard to case sensitivity and the search terms can
1353 include regular expression syntax.
1356 channel_id = body['channel_id'][0]
1357 response_url = body['response_url'][0]
1359 puzzle = puzzle_for_channel(turb, channel_id)
1360 hunt = hunt_for_channel(turb, channel_id)
1365 "This is not a puzzle channel, but is a hunt channel. "
1366 + "Use /hunt if you want to see all rounds for this hunt.")
1369 "Sorry, this channel doesn't appear to be a puzzle channel "
1370 + "so the `/round` command cannot work here.")
1374 # The first word can be a puzzle status and all remaining word
1375 # (if any) are search terms. _But_, if the first word is not a
1376 # valid puzzle status ('all', 'unsolved', 'solved'), then all
1377 # words are search terms and we default status to 'unsolved'.
1378 split_args = args.split(' ', 1)
1379 status = split_args[0]
1380 if (len(split_args) > 1):
1381 terms = split_args[1]
1382 if status not in ('unsolved', 'solved', 'all'):
1388 # Separate search terms on spaces (but allow for quotation marks
1389 # to capture spaces in a search term)
1391 terms = shlex.split(terms)
1393 blocks = hunt_blocks(turb, hunt,
1394 puzzle_status=status, search_terms=terms,
1395 limit_to_rounds=puzzle.get('rounds', [])
1398 for block in blocks:
1399 if len(block) > 100:
1401 requests.post(response_url,
1402 json = { 'blocks': block },
1403 headers = {'Content-type': 'application/json'}
1408 commands["/round"] = round
1410 def help_command(turb, body, args):
1411 """Implementation of the /help command
1413 Displays help on how to use Turbot.
1416 channel_id = body['channel_id'][0]
1417 response_url = body['response_url'][0]
1418 user_id = body['user_id'][0]
1420 # Process "/help me" first. It calls out to have_you_tried rather
1421 # than going through our help system.
1423 # Also, it reports in the current channel, (where all other help
1424 # output is reported privately to the invoking user).
1426 to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1427 user_id, have_you_tried())
1429 # We'll try first to reply directly to the channel (for the benefit
1430 # of anyone else in the same channel that might be stuck too.
1432 # But if this doesn't work, (direct message or private channel),
1433 # then we can instead reply with an ephemeral message by using
1436 turb.slack_client.chat_postMessage(
1437 channel=channel_id, text=to_try)
1438 except SlackApiError:
1439 requests.post(response_url,
1440 json = {"text": to_try},
1441 headers = {"Content-type": "application/json"})
1444 help_string = turbot_help(args)
1446 requests.post(response_url,
1447 json = {"text": help_string},
1448 headers = {"Content-type": "application/json"})
1452 commands["/help"] = help_command