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 find_hunt_for_hunt_id, hunt_blocks
6 from turbot.puzzle import (
8 find_puzzle_for_puzzle_id,
9 puzzle_update_channel_and_sheet,
19 from botocore.exceptions import ClientError
20 from boto3.dynamodb.conditions import Key
21 from turbot.slack import slack_send_message
25 actions['button'] = {}
27 submission_handlers = {}
29 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
31 # Note: This restriction not only allows for hunt and puzzle ID values to
32 # be used as Slack channel names, but it also allows for '-' as a valid
33 # separator between a hunt and a puzzle ID (for example in the puzzle
34 # edit dialog where a single attribute must capture both values).
35 valid_id_re = r'^[_a-z0-9]+$'
37 lambda_ok = {'statusCode': 200}
39 def bot_reply(message):
40 """Construct a return value suitable for a bot reply
42 This is suitable as a way to give an error back to the user who
43 initiated a slash command, for example."""
50 def submission_error(field, error):
51 """Construct an error suitable for returning for an invalid submission.
53 Returning this value will prevent a submission and alert the user that
54 the given field is invalid because of the given error."""
56 print("Rejecting invalid modal submission: {}".format(error))
61 "Content-Type": "application/json"
64 "response_action": "errors",
71 def multi_static_select(turb, payload):
72 """Handler for the action of user entering a multi-select value"""
76 actions['multi_static_select'] = {"*": multi_static_select}
78 def edit_puzzle(turb, payload):
79 """Handler for the action of user pressing an edit_puzzle button"""
81 action_id = payload['actions'][0]['action_id']
82 response_url = payload['response_url']
83 trigger_id = payload['trigger_id']
85 (hunt_id, puzzle_id) = action_id.split('-', 1)
87 puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
90 requests.post(response_url,
91 json = {"text": "Error: Puzzle not found!"},
92 headers = {"Content-type": "application/json"})
93 return bot_reply("Error: Puzzle not found.")
95 round_options = hunt_rounds(turb, hunt_id)
97 if len(round_options):
98 round_options_block = [
99 multi_select_block("Round(s)", "rounds",
100 "Existing round(s) this puzzle belongs to",
102 initial_options=puzzle.get("rounds", None)),
105 round_options_block = []
108 if puzzle.get("status", "unsolved") == solved:
112 solution_list = puzzle.get("solution", [])
114 solution_str = ", ".join(solution_list)
118 "private_metadata": json.dumps({
121 "puzzle_id": puzzle_id,
122 "channel_id": puzzle["channel_id"],
123 "channel_url": puzzle["channel_url"],
124 "sheet_url": puzzle["sheet_url"],
126 "title": {"type": "plain_text", "text": "Edit Puzzle"},
127 "submit": { "type": "plain_text", "text": "Save" },
129 input_block("Puzzle name", "name", "Name of the puzzle",
130 initial_value=puzzle["name"]),
131 input_block("Puzzle URL", "url", "External URL of puzzle",
132 initial_value=puzzle.get("url", None),
134 * round_options_block,
135 input_block("New round(s)", "new_rounds",
136 "New round(s) this puzzle belongs to " +
139 input_block("State", "state",
140 "State of this puzzle (partial progress, next steps)",
141 initial_value=puzzle.get("state", None),
144 "Puzzle status", "Solved", "solved",
145 checked=(puzzle.get('status', 'unsolved') == 'solved')),
146 input_block("Solution", "solution",
147 "Solution(s) (comma-separated if multiple)",
148 initial_value=solution_str,
153 result = turb.slack_client.views_open(trigger_id=trigger_id,
157 submission_handlers[result['view']['id']] = edit_puzzle_submission
161 actions['button']['edit_puzzle'] = edit_puzzle
163 def edit_puzzle_submission(turb, payload, metadata):
164 """Handler for the user submitting the edit puzzle modal
166 This is the modal view presented to the user by the edit_puzzle
172 # First, read all the various data from the request
173 meta = json.loads(metadata)
174 puzzle['hunt_id'] = meta['hunt_id']
175 puzzle['SK'] = meta['SK']
176 puzzle['puzzle_id'] = meta['puzzle_id']
177 puzzle['channel_id'] = meta['channel_id']
178 puzzle['channel_url'] = meta['channel_url']
179 puzzle['sheet_url'] = meta['sheet_url']
181 state = payload['view']['state']['values']
182 user_id = payload['user']['id']
184 puzzle['name'] = state['name']['name']['value']
185 url = state['url']['url']['value']
188 rounds = [option['value'] for option in
189 state['rounds']['rounds']['selected_options']]
191 puzzle['rounds'] = rounds
192 new_rounds = state['new_rounds']['new_rounds']['value']
193 puzzle_state = state['state']['state']['value']
195 puzzle['state'] = puzzle_state
196 if state['solved']['solved']['selected_options']:
197 puzzle['status'] = 'solved'
199 puzzle['status'] = 'unsolved'
200 puzzle['solution'] = []
201 solution = state['solution']['solution']['value']
203 puzzle['solution'] = [
204 sol.strip() for sol in solution.split(',')
207 # Verify that there's a solution if the puzzle is mark solved
208 if puzzle['status'] == 'solved' and not puzzle['solution']:
209 return submission_error("solution",
210 "A solved puzzle requires a solution.")
212 if puzzle['status'] == 'unsolved' and puzzle['solution']:
213 return submission_error("solution",
214 "An unsolved puzzle should have no solution.")
216 # Add any new rounds to the database
218 if 'rounds' not in puzzle:
219 puzzle['rounds'] = []
220 for round in new_rounds.split(','):
221 # Drop any leading/trailing spaces from the round name
222 round = round.strip()
223 # Ignore any empty string
226 puzzle['rounds'].append(round)
229 'hunt_id': puzzle['hunt_id'],
230 'SK': 'round-' + round
234 # Get old puzzle from the database (to determine what's changed)
235 old_puzzle = find_puzzle_for_puzzle_id(turb,
239 # Update the puzzle in the database
240 turb.table.put_item(Item=puzzle)
242 # Inform the puzzle channel about the edit
243 edit_message = "Puzzle edited by <@{}>".format(user_id)
244 blocks = ([section_block(text_block(edit_message+":\n"))] +
245 puzzle_blocks(puzzle, include_rounds=True))
247 turb.slack_client, puzzle['channel_id'],
248 edit_message, blocks=blocks)
250 # Also inform the hunt if the puzzle's solved status changed
251 if puzzle['status'] != old_puzzle['status']:
252 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
253 if puzzle['status'] == 'solved':
254 message = "Puzzle <{}|{}> has been solved!".format(
255 puzzle['channel_url'],
258 message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
259 puzzle['channel_url'],
261 slack_send_message(turb.slack_client, hunt['channel_id'], message)
263 # We need to set the channel topic if any of puzzle name, url,
264 # state, status, or solution, has changed. Let's just do that
265 # unconditionally here.
266 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
270 def new_hunt(turb, payload):
271 """Handler for the action of user pressing the new_hunt button"""
275 "private_metadata": json.dumps({}),
276 "title": { "type": "plain_text", "text": "New Hunt" },
277 "submit": { "type": "plain_text", "text": "Create" },
279 input_block("Hunt name", "name", "Name of the hunt"),
280 input_block("Hunt ID", "hunt_id",
281 "Used as puzzle channel prefix "
282 + "(no spaces nor punctuation)"),
283 input_block("Hunt URL", "url", "External URL of hunt",
288 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
291 submission_handlers[result['view']['id']] = new_hunt_submission
295 actions['button']['new_hunt'] = new_hunt
297 def new_hunt_submission(turb, payload, metadata):
298 """Handler for the user submitting the new hunt modal
300 This is the modal view presented to the user by the new_hunt
303 state = payload['view']['state']['values']
304 user_id = payload['user']['id']
305 name = state['name']['name']['value']
306 hunt_id = state['hunt_id']['hunt_id']['value']
307 url = state['url']['url']['value']
309 # Validate that the hunt_id contains no invalid characters
310 if not re.match(valid_id_re, hunt_id):
311 return submission_error("hunt_id",
312 "Hunt ID can only contain lowercase letters, "
313 + "numbers, and underscores")
315 # Check to see if the turbot table exists
317 exists = turb.table.table_status in ("CREATING", "UPDATING",
322 # Create the turbot table if necessary.
324 turb.table = turb.db.create_table(
327 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
328 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
330 AttributeDefinitions=[
331 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
332 {'AttributeName': 'SK', 'AttributeType': 'S'},
333 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
334 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
335 {'AttributeName': 'url', 'AttributeType': 'S'}
337 ProvisionedThroughput={
338 'ReadCapacityUnits': 5,
339 'WriteCapacityUnits': 5
341 GlobalSecondaryIndexes=[
343 'IndexName': 'channel_id_index',
345 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
348 'ProjectionType': 'ALL'
350 'ProvisionedThroughput': {
351 'ReadCapacityUnits': 5,
352 'WriteCapacityUnits': 5
356 'IndexName': 'is_hunt_index',
358 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
361 'ProjectionType': 'ALL'
363 'ProvisionedThroughput': {
364 'ReadCapacityUnits': 5,
365 'WriteCapacityUnits': 5
369 LocalSecondaryIndexes = [
371 'IndexName': 'url_index',
373 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
374 {'AttributeName': 'url', 'KeyType': 'RANGE'},
377 'ProjectionType': 'ALL'
382 return submission_error(
384 "Still bootstrapping turbot table. Try again in a minute, please.")
386 # Create a channel for the hunt
388 response = turb.slack_client.conversations_create(name=hunt_id)
389 except SlackApiError as e:
390 return submission_error("hunt_id",
391 "Error creating Slack channel: {}"
392 .format(e.response['error']))
394 channel_id = response['channel']['id']
396 # Insert the newly-created hunt into the database
397 # (leaving it as non-active for now until the channel-created handler
398 # finishes fixing it up with a sheet and a companion table)
401 "SK": "hunt-{}".format(hunt_id),
403 "channel_id": channel_id,
409 turb.table.put_item(Item=item)
411 # Invite the initiating user to the channel
412 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
416 def view_submission(turb, payload):
417 """Handler for Slack interactive view submission
419 Specifically, those that have a payload type of 'view_submission'"""
421 view_id = payload['view']['id']
422 metadata = payload['view']['private_metadata']
424 if view_id in submission_handlers:
425 return submission_handlers[view_id](turb, payload, metadata)
427 print("Error: Unknown view ID: {}".format(view_id))
432 def rot(turb, body, args):
433 """Implementation of the /rot command
435 The args string should be as follows:
437 [count|*] String to be rotated
439 That is, the first word of the string is an optional number (or
440 the character '*'). If this is a number it indicates an amount to
441 rotate each character in the string. If the count is '*' or is not
442 present, then the string will be rotated through all possible 25
445 The result of the rotation is returned (with Slack formatting) in
446 the body of the response so that Slack will provide it as a reply
447 to the user who submitted the slash command."""
449 channel_name = body['channel_name'][0]
450 response_url = body['response_url'][0]
451 channel_id = body['channel_id'][0]
453 result = turbot.rot.rot(args)
455 if (channel_name == "directmessage"):
456 requests.post(response_url,
457 json = {"text": result},
458 headers = {"Content-type": "application/json"})
460 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
464 commands["/rot"] = rot
466 def get_table_item(turb, table_name, key, value):
467 """Get an item from the database 'table_name' with 'key' as 'value'
469 Returns a tuple of (item, table) if found and (None, None) otherwise."""
471 table = turb.db.Table(table_name)
473 response = table.get_item(Key={key: value})
475 if 'Item' in response:
476 return (response['Item'], table)
480 def db_entry_for_channel(turb, channel_id):
481 """Given a channel ID return the database item for this channel
483 If this channel is a registered hunt or puzzle channel, return the
484 corresponding row from the database for this channel. Otherwise,
487 Note: If you need to specifically ensure that the channel is a
488 puzzle or a hunt, please call puzzle_for_channel or
489 hunt_for_channel respectively.
492 response = turb.table.query(
493 IndexName = "channel_id_index",
494 KeyConditionExpression=Key("channel_id").eq(channel_id)
497 if response['Count'] == 0:
500 return response['Items'][0]
503 def puzzle_for_channel(turb, channel_id):
505 """Given a channel ID return the puzzle from the database for this channel
507 If the given channel_id is a puzzle's channel, this function
508 returns a dict filled with the attributes from the puzzle's entry
511 Otherwise, this function returns None.
514 entry = db_entry_for_channel(turb, channel_id)
516 if entry and entry['SK'].startswith('puzzle-'):
521 def hunt_for_channel(turb, channel_id):
523 """Given a channel ID return the hunt from the database for this channel
525 This works whether the original channel is a primary hunt channel,
526 or if it is one of the channels of a puzzle belonging to the hunt.
528 Returns None if channel does not belong to a hunt, otherwise a
529 dictionary with all fields from the hunt's row in the table,
530 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
533 entry = db_entry_for_channel(turb, channel_id)
535 # We're done if this channel doesn't exist in the database at all
539 # Also done if this channel is a hunt channel
540 if entry['SK'].startswith('hunt-'):
543 # Otherwise, (the channel is in the database, but is not a hunt),
544 # we expect this to be a puzzle channel instead
545 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
547 # python3.9 has a built-in removeprefix but AWS only has python3.8
548 def remove_prefix(text, prefix):
549 if text.startswith(prefix):
550 return text[len(prefix):]
553 def hunt_rounds(turb, hunt_id):
554 """Returns array of strings giving rounds that exist in the given hunt"""
556 response = turb.table.query(
557 KeyConditionExpression=(
558 Key('hunt_id').eq(hunt_id) &
559 Key('SK').begins_with('round-')
563 if response['Count'] == 0:
566 return [remove_prefix(option['SK'], 'round-')
567 for option in response['Items']]
569 def puzzle(turb, body, args):
570 """Implementation of the /puzzle command
572 The args string can be a sub-command:
574 /puzzle new: Bring up a dialog to create a new puzzle
576 Or with no argument at all:
578 /puzzle: Print details of the current puzzle (if in a puzzle channel)
582 return new_puzzle(turb, body)
585 return bot_reply("Unknown syntax for `/puzzle` command. " +
586 "Use `/puzzle new` to create a new puzzle.")
588 # For no arguments we print the current puzzle as a reply
589 channel_id = body['channel_id'][0]
590 response_url = body['response_url'][0]
592 puzzle = puzzle_for_channel(turb, channel_id)
595 hunt = hunt_for_channel(turb, channel_id)
598 "This is not a puzzle channel, but is a hunt channel. "
599 + "If you want to create a new puzzle for this hunt, use "
603 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
604 + "channel, so the `/puzzle` command cannot work here.")
606 blocks = puzzle_blocks(puzzle, include_rounds=True)
608 requests.post(response_url,
609 json = {'blocks': blocks},
610 headers = {'Content-type': 'application/json'}
615 commands["/puzzle"] = puzzle
617 def new_puzzle(turb, body):
618 """Implementation of the "/puzzle new" command
620 This brings up a dialog box for creating a new puzzle.
623 channel_id = body['channel_id'][0]
624 trigger_id = body['trigger_id'][0]
626 hunt = hunt_for_channel(turb, channel_id)
629 return bot_reply("Sorry, this channel doesn't appear to "
630 + "be a hunt or puzzle channel")
632 round_options = hunt_rounds(turb, hunt['hunt_id'])
634 if len(round_options):
635 round_options_block = [
636 multi_select_block("Round(s)", "rounds",
637 "Existing round(s) this puzzle belongs to",
641 round_options_block = []
645 "private_metadata": json.dumps({
646 "hunt_id": hunt['hunt_id'],
648 "title": {"type": "plain_text", "text": "New Puzzle"},
649 "submit": { "type": "plain_text", "text": "Create" },
651 section_block(text_block("*For {}*".format(hunt['name']))),
652 input_block("Puzzle name", "name", "Name of the puzzle"),
653 input_block("Puzzle URL", "url", "External URL of puzzle",
655 * round_options_block,
656 input_block("New round(s)", "new_rounds",
657 "New round(s) this puzzle belongs to " +
663 result = turb.slack_client.views_open(trigger_id=trigger_id,
667 submission_handlers[result['view']['id']] = new_puzzle_submission
671 def new_puzzle_submission(turb, payload, metadata):
672 """Handler for the user submitting the new puzzle modal
674 This is the modal view presented to the user by the new_puzzle
678 # First, read all the various data from the request
679 meta = json.loads(metadata)
680 hunt_id = meta['hunt_id']
682 state = payload['view']['state']['values']
683 name = state['name']['name']['value']
684 url = state['url']['url']['value']
685 if 'rounds' in state:
686 rounds = [option['value'] for option in
687 state['rounds']['rounds']['selected_options']]
690 new_rounds = state['new_rounds']['new_rounds']['value']
692 # Before doing anything, reject this puzzle if a puzzle already
693 # exists with the same URL.
695 existing = find_puzzle_for_url(turb, hunt_id, url)
697 return submission_error(
699 "Error: A puzzle with this URL already exists.")
701 # Create a Slack-channel-safe puzzle_id
702 puzzle_id = puzzle_id_from_name(name)
704 # Create a channel for the puzzle
705 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
708 response = turb.slack_client.conversations_create(
709 name=hunt_dash_channel)
710 except SlackApiError as e:
711 return submission_error(
713 "Error creating Slack channel {}: {}"
714 .format(hunt_dash_channel, e.response['error']))
716 channel_id = response['channel']['id']
718 # Add any new rounds to the database
720 for round in new_rounds.split(','):
721 # Drop any leading/trailing spaces from the round name
722 round = round.strip()
723 # Ignore any empty string
730 'SK': 'round-' + round
734 # Insert the newly-created puzzle into the database
737 "SK": "puzzle-{}".format(puzzle_id),
738 "puzzle_id": puzzle_id,
739 "channel_id": channel_id,
741 "status": 'unsolved',
747 item['rounds'] = rounds
748 turb.table.put_item(Item=item)
752 def state(turb, body, args):
753 """Implementation of the /state command
755 The args string should be a brief sentence describing where things
756 stand or what's needed."""
758 channel_id = body['channel_id'][0]
760 old_puzzle = puzzle_for_channel(turb, channel_id)
764 "Sorry, the /state command only works in a puzzle channel")
766 # Make a copy of the puzzle object
767 puzzle = old_puzzle.copy()
769 # Update the puzzle in the database
770 puzzle['state'] = args
771 turb.table.put_item(Item=puzzle)
773 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
777 commands["/state"] = state
779 def solved(turb, body, args):
780 """Implementation of the /solved command
782 The args string should be a confirmed solution."""
784 channel_id = body['channel_id'][0]
785 user_name = body['user_name'][0]
787 old_puzzle = puzzle_for_channel(turb, channel_id)
790 return bot_reply("Sorry, this is not a puzzle channel.")
794 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
796 # Make a copy of the puzzle object
797 puzzle = old_puzzle.copy()
799 # Set the status and solution fields in the database
800 puzzle['status'] = 'solved'
801 puzzle['solution'].append(args)
802 if 'state' in puzzle:
804 turb.table.put_item(Item=puzzle)
806 # Report the solution to the puzzle's channel
808 turb.slack_client, channel_id,
809 "Puzzle mark solved by {}: `{}`".format(user_name, args))
811 # Also report the solution to the hunt channel
812 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
814 turb.slack_client, hunt['channel_id'],
815 "Puzzle <{}|{}> has been solved!".format(
816 puzzle['channel_url'],
820 # And update the puzzle's description
821 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
825 commands["/solved"] = solved
828 def hunt(turb, body, args):
829 """Implementation of the /hunt command
831 The (optional) args string can be used to filter which puzzles to
832 display. The first word can be one of 'all', 'unsolved', or
833 'solved' and can be used to display only puzzles with the given
834 status. Any remaining text in the args string will be interpreted
835 as search terms. These will be split into separate terms on space
836 characters, (though quotation marks can be used to include a space
837 character in a term). All terms must match on a puzzle in order
838 for that puzzle to be included. But a puzzle will be considered to
839 match if any of the puzzle title, round title, puzzle URL, puzzle
840 state, or puzzle solution match. Matching will be performed
841 without regard to case sensitivity and the search terms can
842 include regular expression syntax.
845 channel_id = body['channel_id'][0]
846 response_url = body['response_url'][0]
850 # The first word can be a puzzle status and all remaining word
851 # (if any) are search terms. _But_, if the first word is not a
852 # valid puzzle status ('all', 'unsolved', 'solved'), then all
853 # words are search terms and we default status to 'unsolved'.
854 split_args = args.split(' ', 1)
855 status = split_args[0]
856 if (len(split_args) > 1):
857 terms = split_args[1]
858 if status not in ('unsolved', 'solved', 'all'):
864 # Separate search terms on spaces (but allow for quotation marks
865 # to capture spaces in a search term)
867 terms = shlex.split(terms)
869 hunt = hunt_for_channel(turb, channel_id)
872 return bot_reply("Sorry, this channel doesn't appear to "
873 + "be a hunt or puzzle channel")
875 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
877 requests.post(response_url,
878 json = { 'blocks': blocks },
879 headers = {'Content-type': 'application/json'}
884 commands["/hunt"] = hunt