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,
18 from botocore.exceptions import ClientError
19 from boto3.dynamodb.conditions import Key
20 from turbot.slack import slack_send_message
24 actions['button'] = {}
26 submission_handlers = {}
28 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
30 # Note: This restriction not only allows for hunt and puzzle ID values to
31 # be used as Slack channel names, but it also allows for '-' as a valid
32 # separator between a hunt and a puzzle ID (for example in the puzzle
33 # edit dialog where a single attribute must capture both values).
34 valid_id_re = r'^[_a-z0-9]+$'
36 lambda_ok = {'statusCode': 200}
38 def bot_reply(message):
39 """Construct a return value suitable for a bot reply
41 This is suitable as a way to give an error back to the user who
42 initiated a slash command, for example."""
49 def submission_error(field, error):
50 """Construct an error suitable for returning for an invalid submission.
52 Returning this value will prevent a submission and alert the user that
53 the given field is invalid because of the given error."""
55 print("Rejecting invalid modal submission: {}".format(error))
60 "Content-Type": "application/json"
63 "response_action": "errors",
70 def multi_static_select(turb, payload):
71 """Handler for the action of user entering a multi-select value"""
75 actions['multi_static_select'] = {"*": multi_static_select}
77 def edit_puzzle(turb, payload):
78 """Handler for the action of user pressing an edit_puzzle button"""
80 action_id = payload['actions'][0]['action_id']
81 response_url = payload['response_url']
82 trigger_id = payload['trigger_id']
84 (hunt_id, puzzle_id) = action_id.split('-', 1)
86 puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
89 requests.post(response_url,
90 json = {"text": "Error: Puzzle not found!"},
91 headers = {"Content-type": "application/json"})
92 return bot_reply("Error: Puzzle not found.")
94 round_options = hunt_rounds(turb, hunt_id)
96 if len(round_options):
97 round_options_block = [
98 multi_select_block("Round(s)", "rounds",
99 "Existing round(s) this puzzle belongs to",
101 initial_options=puzzle.get("rounds", None)),
104 round_options_block = []
107 if puzzle.get("status", "unsolved") == solved:
111 solution_list = puzzle.get("solution", [])
113 solution_str = ", ".join(solution_list)
117 "private_metadata": json.dumps({
120 "puzzle_id": puzzle_id,
121 "channel_id": puzzle["channel_id"],
122 "channel_url": puzzle["channel_url"],
123 "sheet_url": puzzle["sheet_url"],
125 "title": {"type": "plain_text", "text": "Edit Puzzle"},
126 "submit": { "type": "plain_text", "text": "Save" },
128 input_block("Puzzle name", "name", "Name of the puzzle",
129 initial_value=puzzle["name"]),
130 input_block("Puzzle URL", "url", "External URL of puzzle",
131 initial_value=puzzle.get("url", None),
133 * round_options_block,
134 input_block("New round(s)", "new_rounds",
135 "New round(s) this puzzle belongs to " +
138 input_block("State", "state",
139 "State of this puzzle (partial progress, next steps)",
140 initial_value=puzzle.get("state", None),
143 "Puzzle status", "Solved", "solved",
144 checked=(puzzle.get('status', 'unsolved') == 'solved')),
145 input_block("Solution", "solution",
146 "Solution(s) (comma-separated if multiple)",
147 initial_value=solution_str,
152 result = turb.slack_client.views_open(trigger_id=trigger_id,
156 submission_handlers[result['view']['id']] = edit_puzzle_submission
160 actions['button']['edit_puzzle'] = edit_puzzle
162 def edit_puzzle_submission(turb, payload, metadata):
163 """Handler for the user submitting the edit puzzle modal
165 This is the modal view presented to the user by the edit_puzzle
171 # First, read all the various data from the request
172 meta = json.loads(metadata)
173 puzzle['hunt_id'] = meta['hunt_id']
174 puzzle['SK'] = meta['SK']
175 puzzle['puzzle_id'] = meta['puzzle_id']
176 puzzle['channel_id'] = meta['channel_id']
177 puzzle['channel_url'] = meta['channel_url']
178 puzzle['sheet_url'] = meta['sheet_url']
180 state = payload['view']['state']['values']
182 puzzle['name'] = state['name']['name']['value']
183 url = state['url']['url']['value']
186 rounds = [option['value'] for option in
187 state['rounds']['rounds']['selected_options']]
189 puzzle['rounds'] = rounds
190 new_rounds = state['new_rounds']['new_rounds']['value']
191 puzzle_state = state['state']['state']['value']
193 puzzle['state'] = puzzle_state
194 if state['solved']['solved']['selected_options']:
195 puzzle['status'] = 'solved'
197 puzzle['status'] = 'unsolved'
198 puzzle['solution'] = []
199 solution = state['solution']['solution']['value']
201 puzzle['solution'] = [
202 sol.strip() for sol in solution.split(',')
205 # Verify that there's a solution if the puzzle is mark solved
206 if puzzle['status'] == 'solved' and not puzzle['solution']:
207 return submission_error("solution",
208 "A solved puzzle requires a solution.")
210 if puzzle['status'] == 'unsolved' and puzzle['solution']:
211 return submission_error("solution",
212 "An unsolved puzzle should have no solution.")
214 # Add any new rounds to the database
216 if 'rounds' not in puzzle:
217 puzzle['rounds'] = []
218 for round in new_rounds.split(','):
219 # Drop any leading/trailing spaces from the round name
220 round = round.strip()
221 # Ignore any empty string
224 puzzle['rounds'].append(round)
227 'hunt_id': puzzle['hunt_id'],
228 'SK': 'round-' + round
232 # Get old puzzle from the database (to determine what's changed)
233 old_puzzle = find_puzzle_for_puzzle_id(turb,
237 # Update the puzzle in the database
238 turb.table.put_item(Item=puzzle)
240 # We need to set the channel topic if any of puzzle name, url,
241 # state, status, or solution, has changed. Let's just do that
242 # unconditionally here.
243 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
247 def new_hunt(turb, payload):
248 """Handler for the action of user pressing the new_hunt button"""
252 "private_metadata": json.dumps({}),
253 "title": { "type": "plain_text", "text": "New Hunt" },
254 "submit": { "type": "plain_text", "text": "Create" },
256 input_block("Hunt name", "name", "Name of the hunt"),
257 input_block("Hunt ID", "hunt_id",
258 "Used as puzzle channel prefix "
259 + "(no spaces nor punctuation)"),
260 input_block("Hunt URL", "url", "External URL of hunt",
265 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
268 submission_handlers[result['view']['id']] = new_hunt_submission
272 actions['button']['new_hunt'] = new_hunt
274 def new_hunt_submission(turb, payload, metadata):
275 """Handler for the user submitting the new hunt modal
277 This is the modal view presented to the user by the new_hunt
280 state = payload['view']['state']['values']
281 user_id = payload['user']['id']
282 name = state['name']['name']['value']
283 hunt_id = state['hunt_id']['hunt_id']['value']
284 url = state['url']['url']['value']
286 # Validate that the hunt_id contains no invalid characters
287 if not re.match(valid_id_re, hunt_id):
288 return submission_error("hunt_id",
289 "Hunt ID can only contain lowercase letters, "
290 + "numbers, and underscores")
292 # Check to see if the turbot table exists
294 exists = turb.table.table_status in ("CREATING", "UPDATING",
299 # Create the turbot table if necessary.
301 turb.table = turb.db.create_table(
304 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
305 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
307 AttributeDefinitions=[
308 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
309 {'AttributeName': 'SK', 'AttributeType': 'S'},
310 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
311 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
312 {'AttributeName': 'url', 'AttributeType': 'S'}
314 ProvisionedThroughput={
315 'ReadCapacityUnits': 5,
316 'WriteCapacityUnits': 5
318 GlobalSecondaryIndexes=[
320 'IndexName': 'channel_id_index',
322 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
325 'ProjectionType': 'ALL'
327 'ProvisionedThroughput': {
328 'ReadCapacityUnits': 5,
329 'WriteCapacityUnits': 5
333 'IndexName': 'is_hunt_index',
335 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
338 'ProjectionType': 'ALL'
340 'ProvisionedThroughput': {
341 'ReadCapacityUnits': 5,
342 'WriteCapacityUnits': 5
346 LocalSecondaryIndexes = [
348 'IndexName': 'url_index',
350 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
351 {'AttributeName': 'url', 'KeyType': 'RANGE'},
354 'ProjectionType': 'ALL'
359 return submission_error(
361 "Still bootstrapping turbot table. Try again in a minute, please.")
363 # Create a channel for the hunt
365 response = turb.slack_client.conversations_create(name=hunt_id)
366 except SlackApiError as e:
367 return submission_error("hunt_id",
368 "Error creating Slack channel: {}"
369 .format(e.response['error']))
371 channel_id = response['channel']['id']
373 # Insert the newly-created hunt into the database
374 # (leaving it as non-active for now until the channel-created handler
375 # finishes fixing it up with a sheet and a companion table)
378 "SK": "hunt-{}".format(hunt_id),
380 "channel_id": channel_id,
386 turb.table.put_item(Item=item)
388 # Invite the initiating user to the channel
389 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
393 def view_submission(turb, payload):
394 """Handler for Slack interactive view submission
396 Specifically, those that have a payload type of 'view_submission'"""
398 view_id = payload['view']['id']
399 metadata = payload['view']['private_metadata']
401 if view_id in submission_handlers:
402 return submission_handlers[view_id](turb, payload, metadata)
404 print("Error: Unknown view ID: {}".format(view_id))
409 def rot(turb, body, args):
410 """Implementation of the /rot command
412 The args string should be as follows:
414 [count|*] String to be rotated
416 That is, the first word of the string is an optional number (or
417 the character '*'). If this is a number it indicates an amount to
418 rotate each character in the string. If the count is '*' or is not
419 present, then the string will be rotated through all possible 25
422 The result of the rotation is returned (with Slack formatting) in
423 the body of the response so that Slack will provide it as a reply
424 to the user who submitted the slash command."""
426 channel_name = body['channel_name'][0]
427 response_url = body['response_url'][0]
428 channel_id = body['channel_id'][0]
430 result = turbot.rot.rot(args)
432 if (channel_name == "directmessage"):
433 requests.post(response_url,
434 json = {"text": result},
435 headers = {"Content-type": "application/json"})
437 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
441 commands["/rot"] = rot
443 def get_table_item(turb, table_name, key, value):
444 """Get an item from the database 'table_name' with 'key' as 'value'
446 Returns a tuple of (item, table) if found and (None, None) otherwise."""
448 table = turb.db.Table(table_name)
450 response = table.get_item(Key={key: value})
452 if 'Item' in response:
453 return (response['Item'], table)
457 def db_entry_for_channel(turb, channel_id):
458 """Given a channel ID return the database item for this channel
460 If this channel is a registered hunt or puzzle channel, return the
461 corresponding row from the database for this channel. Otherwise,
464 Note: If you need to specifically ensure that the channel is a
465 puzzle or a hunt, please call puzzle_for_channel or
466 hunt_for_channel respectively.
469 response = turb.table.query(
470 IndexName = "channel_id_index",
471 KeyConditionExpression=Key("channel_id").eq(channel_id)
474 if response['Count'] == 0:
477 return response['Items'][0]
480 def puzzle_for_channel(turb, channel_id):
482 """Given a channel ID return the puzzle from the database for this channel
484 If the given channel_id is a puzzle's channel, this function
485 returns a dict filled with the attributes from the puzzle's entry
488 Otherwise, this function returns None.
491 entry = db_entry_for_channel(turb, channel_id)
493 if entry and entry['SK'].startswith('puzzle-'):
498 def hunt_for_channel(turb, channel_id):
500 """Given a channel ID return the hunt from the database for this channel
502 This works whether the original channel is a primary hunt channel,
503 or if it is one of the channels of a puzzle belonging to the hunt.
505 Returns None if channel does not belong to a hunt, otherwise a
506 dictionary with all fields from the hunt's row in the table,
507 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
510 entry = db_entry_for_channel(turb, channel_id)
512 # We're done if this channel doesn't exist in the database at all
516 # Also done if this channel is a hunt channel
517 if entry['SK'].startswith('hunt-'):
520 # Otherwise, (the channel is in the database, but is not a hunt),
521 # we expect this to be a puzzle channel instead
522 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
524 # python3.9 has a built-in removeprefix but AWS only has python3.8
525 def remove_prefix(text, prefix):
526 if text.startswith(prefix):
527 return text[len(prefix):]
530 def hunt_rounds(turb, hunt_id):
531 """Returns array of strings giving rounds that exist in the given hunt"""
533 response = turb.table.query(
534 KeyConditionExpression=(
535 Key('hunt_id').eq(hunt_id) &
536 Key('SK').begins_with('round-')
540 if response['Count'] == 0:
543 return [remove_prefix(option['SK'], 'round-')
544 for option in response['Items']]
546 def puzzle(turb, body, args):
547 """Implementation of the /puzzle command
549 The args string can be a sub-command:
551 /puzzle new: Bring up a dialog to create a new puzzle
555 return new_puzzle(turb, body)
557 return bot_reply("Unknown syntax for `/puzzle` command. " +
558 "Use `/puzzle new` to create a new puzzle.")
560 commands["/puzzle"] = puzzle
562 def new_puzzle(turb, body):
563 """Implementation of the "/puzzle new" command
565 This brings up a dialog box for creating a new puzzle.
568 channel_id = body['channel_id'][0]
569 trigger_id = body['trigger_id'][0]
571 hunt = hunt_for_channel(turb, channel_id)
574 return bot_reply("Sorry, this channel doesn't appear to "
575 + "be a hunt or puzzle channel")
577 round_options = hunt_rounds(turb, hunt['hunt_id'])
579 if len(round_options):
580 round_options_block = [
581 multi_select_block("Round(s)", "rounds",
582 "Existing round(s) this puzzle belongs to",
586 round_options_block = []
590 "private_metadata": json.dumps({
591 "hunt_id": hunt['hunt_id'],
593 "title": {"type": "plain_text", "text": "New Puzzle"},
594 "submit": { "type": "plain_text", "text": "Create" },
596 section_block(text_block("*For {}*".format(hunt['name']))),
597 input_block("Puzzle name", "name", "Name of the puzzle"),
598 input_block("Puzzle URL", "url", "External URL of puzzle",
600 * round_options_block,
601 input_block("New round(s)", "new_rounds",
602 "New round(s) this puzzle belongs to " +
608 result = turb.slack_client.views_open(trigger_id=trigger_id,
612 submission_handlers[result['view']['id']] = new_puzzle_submission
616 def new_puzzle_submission(turb, payload, metadata):
617 """Handler for the user submitting the new puzzle modal
619 This is the modal view presented to the user by the new_puzzle
623 # First, read all the various data from the request
624 meta = json.loads(metadata)
625 hunt_id = meta['hunt_id']
627 state = payload['view']['state']['values']
628 name = state['name']['name']['value']
629 url = state['url']['url']['value']
630 if 'rounds' in state:
631 rounds = [option['value'] for option in
632 state['rounds']['rounds']['selected_options']]
635 new_rounds = state['new_rounds']['new_rounds']['value']
637 # Before doing anything, reject this puzzle if a puzzle already
638 # exists with the same URL.
640 existing = find_puzzle_for_url(turb, hunt_id, url)
642 return submission_error(
644 "Error: A puzzle with this URL already exists.")
646 # Create a Slack-channel-safe puzzle_id
647 puzzle_id = puzzle_id_from_name(name)
649 # Create a channel for the puzzle
650 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
653 response = turb.slack_client.conversations_create(
654 name=hunt_dash_channel)
655 except SlackApiError as e:
656 return submission_error(
658 "Error creating Slack channel {}: {}"
659 .format(hunt_dash_channel, e.response['error']))
661 channel_id = response['channel']['id']
663 # Add any new rounds to the database
665 for round in new_rounds.split(','):
666 # Drop any leading/trailing spaces from the round name
667 round = round.strip()
668 # Ignore any empty string
675 'SK': 'round-' + round
679 # Insert the newly-created puzzle into the database
682 "SK": "puzzle-{}".format(puzzle_id),
683 "puzzle_id": puzzle_id,
684 "channel_id": channel_id,
686 "status": 'unsolved',
692 item['rounds'] = rounds
693 turb.table.put_item(Item=item)
697 def state(turb, body, args):
698 """Implementation of the /state command
700 The args string should be a brief sentence describing where things
701 stand or what's needed."""
703 channel_id = body['channel_id'][0]
705 old_puzzle = puzzle_for_channel(turb, channel_id)
709 "Sorry, the /state command only works in a puzzle channel")
711 # Make a copy of the puzzle object
712 puzzle = old_puzzle.copy()
714 # Update the puzzle in the database
715 puzzle['state'] = args
716 turb.table.put_item(Item=puzzle)
718 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
722 commands["/state"] = state
724 def solved(turb, body, args):
725 """Implementation of the /solved command
727 The args string should be a confirmed solution."""
729 channel_id = body['channel_id'][0]
730 user_name = body['user_name'][0]
732 old_puzzle = puzzle_for_channel(turb, channel_id)
735 return bot_reply("Sorry, this is not a puzzle channel.")
739 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
741 # Make a copy of the puzzle object
742 puzzle = old_puzzle.copy()
744 # Set the status and solution fields in the database
745 puzzle['status'] = 'solved'
746 puzzle['solution'].append(args)
747 if 'state' in puzzle:
749 turb.table.put_item(Item=puzzle)
751 # Report the solution to the puzzle's channel
753 turb.slack_client, channel_id,
754 "Puzzle mark solved by {}: `{}`".format(user_name, args))
756 # Also report the solution to the hunt channel
757 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
759 turb.slack_client, hunt['channel_id'],
760 "Puzzle <{}|{}> has been solved!".format(
761 puzzle['channel_url'],
765 # And update the puzzle's description
766 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
770 commands["/solved"] = solved
773 def hunt(turb, body, args):
774 """Implementation of the /hunt command
776 The (optional) args string can be used to filter which puzzles to
777 display. The first word can be one of 'all', 'unsolved', or
778 'solved' and can be used to display only puzzles with the given
779 status. Any remaining text in the args string will be interpreted
780 as search terms. These will be split into separate terms on space
781 characters, (though quotation marks can be used to include a space
782 character in a term). All terms must match on a puzzle in order
783 for that puzzle to be included. But a puzzle will be considered to
784 match if any of the puzzle title, round title, puzzle URL, puzzle
785 state, or puzzle solution match. Matching will be performed
786 without regard to case sensitivity and the search terms can
787 include regular expression syntax.
790 channel_id = body['channel_id'][0]
791 response_url = body['response_url'][0]
795 # The first word can be a puzzle status and all remaining word
796 # (if any) are search terms. _But_, if the first word is not a
797 # valid puzzle status ('all', 'unsolved', 'solved'), then all
798 # words are search terms and we default status to 'unsolved'.
799 split_args = args.split(' ', 1)
800 status = split_args[0]
801 if (len(split_args) > 1):
802 terms = split_args[1]
803 if status not in ('unsolved', 'solved', 'all'):
809 # Separate search terms on spaces (but allow for quotation marks
810 # to capture spaces in a search term)
812 terms = shlex.split(terms)
814 hunt = hunt_for_channel(turb, channel_id)
817 return bot_reply("Sorry, this channel doesn't appear to "
818 + "be a hunt or puzzle channel")
820 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
822 requests.post(response_url,
823 json = { 'blocks': blocks },
824 headers = {'Content-type': 'application/json'}
829 commands["/hunt"] = hunt