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']
183 puzzle['name'] = state['name']['name']['value']
184 url = state['url']['url']['value']
187 rounds = [option['value'] for option in
188 state['rounds']['rounds']['selected_options']]
190 puzzle['rounds'] = rounds
191 new_rounds = state['new_rounds']['new_rounds']['value']
192 puzzle_state = state['state']['state']['value']
194 puzzle['state'] = puzzle_state
195 if state['solved']['solved']['selected_options']:
196 puzzle['status'] = 'solved'
198 puzzle['status'] = 'unsolved'
199 puzzle['solution'] = []
200 solution = state['solution']['solution']['value']
202 puzzle['solution'] = [
203 sol.strip() for sol in solution.split(',')
206 # Verify that there's a solution if the puzzle is mark solved
207 if puzzle['status'] == 'solved' and not puzzle['solution']:
208 return submission_error("solution",
209 "A solved puzzle requires a solution.")
211 if puzzle['status'] == 'unsolved' and puzzle['solution']:
212 return submission_error("solution",
213 "An unsolved puzzle should have no solution.")
215 # Add any new rounds to the database
217 if 'rounds' not in puzzle:
218 puzzle['rounds'] = []
219 for round in new_rounds.split(','):
220 # Drop any leading/trailing spaces from the round name
221 round = round.strip()
222 # Ignore any empty string
225 puzzle['rounds'].append(round)
228 'hunt_id': puzzle['hunt_id'],
229 'SK': 'round-' + round
233 # Get old puzzle from the database (to determine what's changed)
234 old_puzzle = find_puzzle_for_puzzle_id(turb,
238 # Update the puzzle in the database
239 turb.table.put_item(Item=puzzle)
241 # We need to set the channel topic if any of puzzle name, url,
242 # state, status, or solution, has changed. Let's just do that
243 # unconditionally here.
244 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
248 def new_hunt(turb, payload):
249 """Handler for the action of user pressing the new_hunt button"""
253 "private_metadata": json.dumps({}),
254 "title": { "type": "plain_text", "text": "New Hunt" },
255 "submit": { "type": "plain_text", "text": "Create" },
257 input_block("Hunt name", "name", "Name of the hunt"),
258 input_block("Hunt ID", "hunt_id",
259 "Used as puzzle channel prefix "
260 + "(no spaces nor punctuation)"),
261 input_block("Hunt URL", "url", "External URL of hunt",
266 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
269 submission_handlers[result['view']['id']] = new_hunt_submission
273 actions['button']['new_hunt'] = new_hunt
275 def new_hunt_submission(turb, payload, metadata):
276 """Handler for the user submitting the new hunt modal
278 This is the modal view presented to the user by the new_hunt
281 state = payload['view']['state']['values']
282 user_id = payload['user']['id']
283 name = state['name']['name']['value']
284 hunt_id = state['hunt_id']['hunt_id']['value']
285 url = state['url']['url']['value']
287 # Validate that the hunt_id contains no invalid characters
288 if not re.match(valid_id_re, hunt_id):
289 return submission_error("hunt_id",
290 "Hunt ID can only contain lowercase letters, "
291 + "numbers, and underscores")
293 # Check to see if the turbot table exists
295 exists = turb.table.table_status in ("CREATING", "UPDATING",
300 # Create the turbot table if necessary.
302 turb.table = turb.db.create_table(
305 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
306 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
308 AttributeDefinitions=[
309 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
310 {'AttributeName': 'SK', 'AttributeType': 'S'},
311 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
312 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
313 {'AttributeName': 'url', 'AttributeType': 'S'}
315 ProvisionedThroughput={
316 'ReadCapacityUnits': 5,
317 'WriteCapacityUnits': 5
319 GlobalSecondaryIndexes=[
321 'IndexName': 'channel_id_index',
323 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
326 'ProjectionType': 'ALL'
328 'ProvisionedThroughput': {
329 'ReadCapacityUnits': 5,
330 'WriteCapacityUnits': 5
334 'IndexName': 'is_hunt_index',
336 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
339 'ProjectionType': 'ALL'
341 'ProvisionedThroughput': {
342 'ReadCapacityUnits': 5,
343 'WriteCapacityUnits': 5
347 LocalSecondaryIndexes = [
349 'IndexName': 'url_index',
351 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
352 {'AttributeName': 'url', 'KeyType': 'RANGE'},
355 'ProjectionType': 'ALL'
360 return submission_error(
362 "Still bootstrapping turbot table. Try again in a minute, please.")
364 # Create a channel for the hunt
366 response = turb.slack_client.conversations_create(name=hunt_id)
367 except SlackApiError as e:
368 return submission_error("hunt_id",
369 "Error creating Slack channel: {}"
370 .format(e.response['error']))
372 channel_id = response['channel']['id']
374 # Insert the newly-created hunt into the database
375 # (leaving it as non-active for now until the channel-created handler
376 # finishes fixing it up with a sheet and a companion table)
379 "SK": "hunt-{}".format(hunt_id),
381 "channel_id": channel_id,
387 turb.table.put_item(Item=item)
389 # Invite the initiating user to the channel
390 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
394 def view_submission(turb, payload):
395 """Handler for Slack interactive view submission
397 Specifically, those that have a payload type of 'view_submission'"""
399 view_id = payload['view']['id']
400 metadata = payload['view']['private_metadata']
402 if view_id in submission_handlers:
403 return submission_handlers[view_id](turb, payload, metadata)
405 print("Error: Unknown view ID: {}".format(view_id))
410 def rot(turb, body, args):
411 """Implementation of the /rot command
413 The args string should be as follows:
415 [count|*] String to be rotated
417 That is, the first word of the string is an optional number (or
418 the character '*'). If this is a number it indicates an amount to
419 rotate each character in the string. If the count is '*' or is not
420 present, then the string will be rotated through all possible 25
423 The result of the rotation is returned (with Slack formatting) in
424 the body of the response so that Slack will provide it as a reply
425 to the user who submitted the slash command."""
427 channel_name = body['channel_name'][0]
428 response_url = body['response_url'][0]
429 channel_id = body['channel_id'][0]
431 result = turbot.rot.rot(args)
433 if (channel_name == "directmessage"):
434 requests.post(response_url,
435 json = {"text": result},
436 headers = {"Content-type": "application/json"})
438 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
442 commands["/rot"] = rot
444 def get_table_item(turb, table_name, key, value):
445 """Get an item from the database 'table_name' with 'key' as 'value'
447 Returns a tuple of (item, table) if found and (None, None) otherwise."""
449 table = turb.db.Table(table_name)
451 response = table.get_item(Key={key: value})
453 if 'Item' in response:
454 return (response['Item'], table)
458 def db_entry_for_channel(turb, channel_id):
459 """Given a channel ID return the database item for this channel
461 If this channel is a registered hunt or puzzle channel, return the
462 corresponding row from the database for this channel. Otherwise,
465 Note: If you need to specifically ensure that the channel is a
466 puzzle or a hunt, please call puzzle_for_channel or
467 hunt_for_channel respectively.
470 response = turb.table.query(
471 IndexName = "channel_id_index",
472 KeyConditionExpression=Key("channel_id").eq(channel_id)
475 if response['Count'] == 0:
478 return response['Items'][0]
481 def puzzle_for_channel(turb, channel_id):
483 """Given a channel ID return the puzzle from the database for this channel
485 If the given channel_id is a puzzle's channel, this function
486 returns a dict filled with the attributes from the puzzle's entry
489 Otherwise, this function returns None.
492 entry = db_entry_for_channel(turb, channel_id)
494 if entry and entry['SK'].startswith('puzzle-'):
499 def hunt_for_channel(turb, channel_id):
501 """Given a channel ID return the hunt from the database for this channel
503 This works whether the original channel is a primary hunt channel,
504 or if it is one of the channels of a puzzle belonging to the hunt.
506 Returns None if channel does not belong to a hunt, otherwise a
507 dictionary with all fields from the hunt's row in the table,
508 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
511 entry = db_entry_for_channel(turb, channel_id)
513 # We're done if this channel doesn't exist in the database at all
517 # Also done if this channel is a hunt channel
518 if entry['SK'].startswith('hunt-'):
521 # Otherwise, (the channel is in the database, but is not a hunt),
522 # we expect this to be a puzzle channel instead
523 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
525 # python3.9 has a built-in removeprefix but AWS only has python3.8
526 def remove_prefix(text, prefix):
527 if text.startswith(prefix):
528 return text[len(prefix):]
531 def hunt_rounds(turb, hunt_id):
532 """Returns array of strings giving rounds that exist in the given hunt"""
534 response = turb.table.query(
535 KeyConditionExpression=(
536 Key('hunt_id').eq(hunt_id) &
537 Key('SK').begins_with('round-')
541 if response['Count'] == 0:
544 return [remove_prefix(option['SK'], 'round-')
545 for option in response['Items']]
547 def puzzle(turb, body, args):
548 """Implementation of the /puzzle command
550 The args string can be a sub-command:
552 /puzzle new: Bring up a dialog to create a new puzzle
554 Or with no argument at all:
556 /puzzle: Print details of the current puzzle (if in a puzzle channel)
560 return new_puzzle(turb, body)
563 return bot_reply("Unknown syntax for `/puzzle` command. " +
564 "Use `/puzzle new` to create a new puzzle.")
566 # For no arguments we print the current puzzle as a reply
567 channel_id = body['channel_id'][0]
568 response_url = body['response_url'][0]
570 puzzle = puzzle_for_channel(turb, channel_id)
573 hunt = hunt_for_channel(turb, channel_id)
576 "This is not a puzzle channel, but is a hunt channel. "
577 + "If you want to create a new puzzle for this hunt, use "
581 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
582 + "channel, so the `/puzzle` command cannot work here.")
584 blocks = puzzle_blocks(puzzle)
586 requests.post(response_url,
587 json = {'blocks': blocks},
588 headers = {'Content-type': 'application/json'}
593 commands["/puzzle"] = puzzle
595 def new_puzzle(turb, body):
596 """Implementation of the "/puzzle new" command
598 This brings up a dialog box for creating a new puzzle.
601 channel_id = body['channel_id'][0]
602 trigger_id = body['trigger_id'][0]
604 hunt = hunt_for_channel(turb, channel_id)
607 return bot_reply("Sorry, this channel doesn't appear to "
608 + "be a hunt or puzzle channel")
610 round_options = hunt_rounds(turb, hunt['hunt_id'])
612 if len(round_options):
613 round_options_block = [
614 multi_select_block("Round(s)", "rounds",
615 "Existing round(s) this puzzle belongs to",
619 round_options_block = []
623 "private_metadata": json.dumps({
624 "hunt_id": hunt['hunt_id'],
626 "title": {"type": "plain_text", "text": "New Puzzle"},
627 "submit": { "type": "plain_text", "text": "Create" },
629 section_block(text_block("*For {}*".format(hunt['name']))),
630 input_block("Puzzle name", "name", "Name of the puzzle"),
631 input_block("Puzzle URL", "url", "External URL of puzzle",
633 * round_options_block,
634 input_block("New round(s)", "new_rounds",
635 "New round(s) this puzzle belongs to " +
641 result = turb.slack_client.views_open(trigger_id=trigger_id,
645 submission_handlers[result['view']['id']] = new_puzzle_submission
649 def new_puzzle_submission(turb, payload, metadata):
650 """Handler for the user submitting the new puzzle modal
652 This is the modal view presented to the user by the new_puzzle
656 # First, read all the various data from the request
657 meta = json.loads(metadata)
658 hunt_id = meta['hunt_id']
660 state = payload['view']['state']['values']
661 name = state['name']['name']['value']
662 url = state['url']['url']['value']
663 if 'rounds' in state:
664 rounds = [option['value'] for option in
665 state['rounds']['rounds']['selected_options']]
668 new_rounds = state['new_rounds']['new_rounds']['value']
670 # Before doing anything, reject this puzzle if a puzzle already
671 # exists with the same URL.
673 existing = find_puzzle_for_url(turb, hunt_id, url)
675 return submission_error(
677 "Error: A puzzle with this URL already exists.")
679 # Create a Slack-channel-safe puzzle_id
680 puzzle_id = puzzle_id_from_name(name)
682 # Create a channel for the puzzle
683 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
686 response = turb.slack_client.conversations_create(
687 name=hunt_dash_channel)
688 except SlackApiError as e:
689 return submission_error(
691 "Error creating Slack channel {}: {}"
692 .format(hunt_dash_channel, e.response['error']))
694 channel_id = response['channel']['id']
696 # Add any new rounds to the database
698 for round in new_rounds.split(','):
699 # Drop any leading/trailing spaces from the round name
700 round = round.strip()
701 # Ignore any empty string
708 'SK': 'round-' + round
712 # Insert the newly-created puzzle into the database
715 "SK": "puzzle-{}".format(puzzle_id),
716 "puzzle_id": puzzle_id,
717 "channel_id": channel_id,
719 "status": 'unsolved',
725 item['rounds'] = rounds
726 turb.table.put_item(Item=item)
730 def state(turb, body, args):
731 """Implementation of the /state command
733 The args string should be a brief sentence describing where things
734 stand or what's needed."""
736 channel_id = body['channel_id'][0]
738 old_puzzle = puzzle_for_channel(turb, channel_id)
742 "Sorry, the /state command only works in a puzzle channel")
744 # Make a copy of the puzzle object
745 puzzle = old_puzzle.copy()
747 # Update the puzzle in the database
748 puzzle['state'] = args
749 turb.table.put_item(Item=puzzle)
751 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
755 commands["/state"] = state
757 def solved(turb, body, args):
758 """Implementation of the /solved command
760 The args string should be a confirmed solution."""
762 channel_id = body['channel_id'][0]
763 user_name = body['user_name'][0]
765 old_puzzle = puzzle_for_channel(turb, channel_id)
768 return bot_reply("Sorry, this is not a puzzle channel.")
772 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
774 # Make a copy of the puzzle object
775 puzzle = old_puzzle.copy()
777 # Set the status and solution fields in the database
778 puzzle['status'] = 'solved'
779 puzzle['solution'].append(args)
780 if 'state' in puzzle:
782 turb.table.put_item(Item=puzzle)
784 # Report the solution to the puzzle's channel
786 turb.slack_client, channel_id,
787 "Puzzle mark solved by {}: `{}`".format(user_name, args))
789 # Also report the solution to the hunt channel
790 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
792 turb.slack_client, hunt['channel_id'],
793 "Puzzle <{}|{}> has been solved!".format(
794 puzzle['channel_url'],
798 # And update the puzzle's description
799 puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
803 commands["/solved"] = solved
806 def hunt(turb, body, args):
807 """Implementation of the /hunt command
809 The (optional) args string can be used to filter which puzzles to
810 display. The first word can be one of 'all', 'unsolved', or
811 'solved' and can be used to display only puzzles with the given
812 status. Any remaining text in the args string will be interpreted
813 as search terms. These will be split into separate terms on space
814 characters, (though quotation marks can be used to include a space
815 character in a term). All terms must match on a puzzle in order
816 for that puzzle to be included. But a puzzle will be considered to
817 match if any of the puzzle title, round title, puzzle URL, puzzle
818 state, or puzzle solution match. Matching will be performed
819 without regard to case sensitivity and the search terms can
820 include regular expression syntax.
823 channel_id = body['channel_id'][0]
824 response_url = body['response_url'][0]
828 # The first word can be a puzzle status and all remaining word
829 # (if any) are search terms. _But_, if the first word is not a
830 # valid puzzle status ('all', 'unsolved', 'solved'), then all
831 # words are search terms and we default status to 'unsolved'.
832 split_args = args.split(' ', 1)
833 status = split_args[0]
834 if (len(split_args) > 1):
835 terms = split_args[1]
836 if status not in ('unsolved', 'solved', 'all'):
842 # Separate search terms on spaces (but allow for quotation marks
843 # to capture spaces in a search term)
845 terms = shlex.split(terms)
847 hunt = hunt_for_channel(turb, channel_id)
850 return bot_reply("Sorry, this channel doesn't appear to "
851 + "be a hunt or puzzle channel")
853 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
855 requests.post(response_url,
856 json = { 'blocks': blocks },
857 headers = {'Content-type': 'application/json'}
862 commands["/hunt"] = hunt