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 # Update the puzzle in the database
233 turb.table.put_item(Item=puzzle)
235 # We need to set the channel topic if any of puzzle name, url,
236 # state, status, or solution, has changed. Let's just do that
237 # unconditionally here.
238 puzzle_update_channel_and_sheet(turb, puzzle)
242 def new_hunt(turb, payload):
243 """Handler for the action of user pressing the new_hunt button"""
247 "private_metadata": json.dumps({}),
248 "title": { "type": "plain_text", "text": "New Hunt" },
249 "submit": { "type": "plain_text", "text": "Create" },
251 input_block("Hunt name", "name", "Name of the hunt"),
252 input_block("Hunt ID", "hunt_id",
253 "Used as puzzle channel prefix "
254 + "(no spaces nor punctuation)"),
255 input_block("Hunt URL", "url", "External URL of hunt",
260 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
263 submission_handlers[result['view']['id']] = new_hunt_submission
267 actions['button']['new_hunt'] = new_hunt
269 def new_hunt_submission(turb, payload, metadata):
270 """Handler for the user submitting the new hunt modal
272 This is the modal view presented to the user by the new_hunt
275 state = payload['view']['state']['values']
276 user_id = payload['user']['id']
277 name = state['name']['name']['value']
278 hunt_id = state['hunt_id']['hunt_id']['value']
279 url = state['url']['url']['value']
281 # Validate that the hunt_id contains no invalid characters
282 if not re.match(valid_id_re, hunt_id):
283 return submission_error("hunt_id",
284 "Hunt ID can only contain lowercase letters, "
285 + "numbers, and underscores")
287 # Check to see if the turbot table exists
289 exists = turb.table.table_status in ("CREATING", "UPDATING",
294 # Create the turbot table if necessary.
296 turb.table = turb.db.create_table(
299 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
300 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
302 AttributeDefinitions=[
303 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
304 {'AttributeName': 'SK', 'AttributeType': 'S'},
305 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
306 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
307 {'AttributeName': 'url', 'AttributeType': 'S'}
309 ProvisionedThroughput={
310 'ReadCapacityUnits': 5,
311 'WriteCapacityUnits': 5
313 GlobalSecondaryIndexes=[
315 'IndexName': 'channel_id_index',
317 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
320 'ProjectionType': 'ALL'
322 'ProvisionedThroughput': {
323 'ReadCapacityUnits': 5,
324 'WriteCapacityUnits': 5
328 'IndexName': 'is_hunt_index',
330 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
333 'ProjectionType': 'ALL'
335 'ProvisionedThroughput': {
336 'ReadCapacityUnits': 5,
337 'WriteCapacityUnits': 5
341 LocalSecondaryIndexes = [
343 'IndexName': 'url_index',
345 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
346 {'AttributeName': 'url', 'KeyType': 'RANGE'},
349 'ProjectionType': 'ALL'
354 return submission_error(
356 "Still bootstrapping turbot table. Try again in a minute, please.")
358 # Create a channel for the hunt
360 response = turb.slack_client.conversations_create(name=hunt_id)
361 except SlackApiError as e:
362 return submission_error("hunt_id",
363 "Error creating Slack channel: {}"
364 .format(e.response['error']))
366 channel_id = response['channel']['id']
368 # Insert the newly-created hunt into the database
369 # (leaving it as non-active for now until the channel-created handler
370 # finishes fixing it up with a sheet and a companion table)
373 "SK": "hunt-{}".format(hunt_id),
375 "channel_id": channel_id,
381 turb.table.put_item(Item=item)
383 # Invite the initiating user to the channel
384 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
388 def view_submission(turb, payload):
389 """Handler for Slack interactive view submission
391 Specifically, those that have a payload type of 'view_submission'"""
393 view_id = payload['view']['id']
394 metadata = payload['view']['private_metadata']
396 if view_id in submission_handlers:
397 return submission_handlers[view_id](turb, payload, metadata)
399 print("Error: Unknown view ID: {}".format(view_id))
404 def rot(turb, body, args):
405 """Implementation of the /rot command
407 The args string should be as follows:
409 [count|*] String to be rotated
411 That is, the first word of the string is an optional number (or
412 the character '*'). If this is a number it indicates an amount to
413 rotate each character in the string. If the count is '*' or is not
414 present, then the string will be rotated through all possible 25
417 The result of the rotation is returned (with Slack formatting) in
418 the body of the response so that Slack will provide it as a reply
419 to the user who submitted the slash command."""
421 channel_name = body['channel_name'][0]
422 response_url = body['response_url'][0]
423 channel_id = body['channel_id'][0]
425 result = turbot.rot.rot(args)
427 if (channel_name == "directmessage"):
428 requests.post(response_url,
429 json = {"text": result},
430 headers = {"Content-type": "application/json"})
432 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
436 commands["/rot"] = rot
438 def get_table_item(turb, table_name, key, value):
439 """Get an item from the database 'table_name' with 'key' as 'value'
441 Returns a tuple of (item, table) if found and (None, None) otherwise."""
443 table = turb.db.Table(table_name)
445 response = table.get_item(Key={key: value})
447 if 'Item' in response:
448 return (response['Item'], table)
452 def db_entry_for_channel(turb, channel_id):
453 """Given a channel ID return the database item for this channel
455 If this channel is a registered hunt or puzzle channel, return the
456 corresponding row from the database for this channel. Otherwise,
459 Note: If you need to specifically ensure that the channel is a
460 puzzle or a hunt, please call puzzle_for_channel or
461 hunt_for_channel respectively.
464 response = turb.table.query(
465 IndexName = "channel_id_index",
466 KeyConditionExpression=Key("channel_id").eq(channel_id)
469 if response['Count'] == 0:
472 return response['Items'][0]
475 def puzzle_for_channel(turb, channel_id):
477 """Given a channel ID return the puzzle from the database for this channel
479 If the given channel_id is a puzzle's channel, this function
480 returns a dict filled with the attributes from the puzzle's entry
483 Otherwise, this function returns None.
486 entry = db_entry_for_channel(turb, channel_id)
488 if entry and entry['SK'].startswith('puzzle-'):
493 def hunt_for_channel(turb, channel_id):
495 """Given a channel ID return the hunt from the database for this channel
497 This works whether the original channel is a primary hunt channel,
498 or if it is one of the channels of a puzzle belonging to the hunt.
500 Returns None if channel does not belong to a hunt, otherwise a
501 dictionary with all fields from the hunt's row in the table,
502 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
505 entry = db_entry_for_channel(turb, channel_id)
507 # We're done if this channel doesn't exist in the database at all
511 # Also done if this channel is a hunt channel
512 if entry['SK'].startswith('hunt-'):
515 # Otherwise, (the channel is in the database, but is not a hunt),
516 # we expect this to be a puzzle channel instead
517 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
519 # python3.9 has a built-in removeprefix but AWS only has python3.8
520 def remove_prefix(text, prefix):
521 if text.startswith(prefix):
522 return text[len(prefix):]
525 def hunt_rounds(turb, hunt_id):
526 """Returns array of strings giving rounds that exist in the given hunt"""
528 response = turb.table.query(
529 KeyConditionExpression=(
530 Key('hunt_id').eq(hunt_id) &
531 Key('SK').begins_with('round-')
535 if response['Count'] == 0:
538 return [remove_prefix(option['SK'], 'round-')
539 for option in response['Items']]
541 def puzzle(turb, body, args):
542 """Implementation of the /puzzle command
544 The args string is currently ignored (this command will bring up
545 a modal dialog for user input instead)."""
547 channel_id = body['channel_id'][0]
548 trigger_id = body['trigger_id'][0]
550 hunt = hunt_for_channel(turb, channel_id)
553 return bot_reply("Sorry, this channel doesn't appear to "
554 + "be a hunt or puzzle channel")
556 round_options = hunt_rounds(turb, hunt['hunt_id'])
558 if len(round_options):
559 round_options_block = [
560 multi_select_block("Round(s)", "rounds",
561 "Existing round(s) this puzzle belongs to",
565 round_options_block = []
569 "private_metadata": json.dumps({
570 "hunt_id": hunt['hunt_id'],
572 "title": {"type": "plain_text", "text": "New Puzzle"},
573 "submit": { "type": "plain_text", "text": "Create" },
575 section_block(text_block("*For {}*".format(hunt['name']))),
576 input_block("Puzzle name", "name", "Name of the puzzle"),
577 input_block("Puzzle URL", "url", "External URL of puzzle",
579 * round_options_block,
580 input_block("New round(s)", "new_rounds",
581 "New round(s) this puzzle belongs to " +
587 result = turb.slack_client.views_open(trigger_id=trigger_id,
591 submission_handlers[result['view']['id']] = puzzle_submission
595 commands["/puzzle"] = puzzle
597 def puzzle_submission(turb, payload, metadata):
598 """Handler for the user submitting the new puzzle modal
600 This is the modal view presented to the user by the puzzle function
603 # First, read all the various data from the request
604 meta = json.loads(metadata)
605 hunt_id = meta['hunt_id']
607 state = payload['view']['state']['values']
608 name = state['name']['name']['value']
609 url = state['url']['url']['value']
610 if 'rounds' in state:
611 rounds = [option['value'] for option in
612 state['rounds']['rounds']['selected_options']]
615 new_rounds = state['new_rounds']['new_rounds']['value']
617 # Before doing anything, reject this puzzle if a puzzle already
618 # exists with the same URL.
620 existing = find_puzzle_for_url(turb, hunt_id, url)
622 return submission_error(
624 "Error: A puzzle with this URL already exists.")
626 # Create a Slack-channel-safe puzzle_id
627 puzzle_id = puzzle_id_from_name(name)
629 # Create a channel for the puzzle
630 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
633 response = turb.slack_client.conversations_create(
634 name=hunt_dash_channel)
635 except SlackApiError as e:
636 return submission_error(
638 "Error creating Slack channel {}: {}"
639 .format(hunt_dash_channel, e.response['error']))
641 channel_id = response['channel']['id']
643 # Add any new rounds to the database
645 for round in new_rounds.split(','):
646 # Drop any leading/trailing spaces from the round name
647 round = round.strip()
648 # Ignore any empty string
655 'SK': 'round-' + round
659 # Insert the newly-created puzzle into the database
662 "SK": "puzzle-{}".format(puzzle_id),
663 "puzzle_id": puzzle_id,
664 "channel_id": channel_id,
666 "status": 'unsolved',
672 item['rounds'] = rounds
673 turb.table.put_item(Item=item)
677 def state(turb, body, args):
678 """Implementation of the /state command
680 The args string should be a brief sentence describing where things
681 stand or what's needed."""
683 channel_id = body['channel_id'][0]
685 puzzle = puzzle_for_channel(turb, channel_id)
689 "Sorry, the /state command only works in a puzzle channel")
691 # Set the state field in the database
692 puzzle['state'] = args
693 turb.table.put_item(Item=puzzle)
695 puzzle_update_channel_and_sheet(turb, puzzle)
699 commands["/state"] = state
701 def solved(turb, body, args):
702 """Implementation of the /solved command
704 The args string should be a confirmed solution."""
706 channel_id = body['channel_id'][0]
707 user_name = body['user_name'][0]
709 puzzle = puzzle_for_channel(turb, channel_id)
712 return bot_reply("Sorry, this is not a puzzle channel.")
716 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
718 # Set the status and solution fields in the database
719 puzzle['status'] = 'solved'
720 puzzle['solution'].append(args)
721 if 'state' in puzzle:
723 turb.table.put_item(Item=puzzle)
725 # Report the solution to the puzzle's channel
727 turb.slack_client, channel_id,
728 "Puzzle mark solved by {}: `{}`".format(user_name, args))
730 # Also report the solution to the hunt channel
731 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
733 turb.slack_client, hunt['channel_id'],
734 "Puzzle <{}|{}> has been solved!".format(
735 puzzle['channel_url'],
739 # And update the puzzle's description
740 puzzle_update_channel_and_sheet(turb, puzzle)
744 commands["/solved"] = solved
747 def hunt(turb, body, args):
748 """Implementation of the /hunt command
750 The (optional) args string can be used to filter which puzzles to
751 display. The first word can be one of 'all', 'unsolved', or
752 'solved' and can be used to display only puzzles with the given
753 status. Any remaining text in the args string will be interpreted
754 as search terms. These will be split into separate terms on space
755 characters, (though quotation marks can be used to include a space
756 character in a term). All terms must match on a puzzle in order
757 for that puzzle to be included. But a puzzle will be considered to
758 match if any of the puzzle title, round title, puzzle URL, puzzle
759 state, or puzzle solution match. Matching will be performed
760 without regard to case sensitivity and the search terms can
761 include regular expression syntax.
764 channel_id = body['channel_id'][0]
765 response_url = body['response_url'][0]
769 # The first word can be a puzzle status and all remaining word
770 # (if any) are search terms. _But_, if the first word is not a
771 # valid puzzle status ('all', 'unsolved', 'solved'), then all
772 # words are search terms and we default status to 'unsolved'.
773 split_args = args.split(' ', 1)
774 status = split_args[0]
775 if (len(split_args) > 1):
776 terms = split_args[1]
777 if status not in ('unsolved', 'solved', 'all'):
783 # Separate search terms on spaces (but allow for quotation marks
784 # to capture spaces in a search term)
786 terms = shlex.split(terms)
788 hunt = hunt_for_channel(turb, channel_id)
791 return bot_reply("Sorry, this channel doesn't appear to "
792 + "be a hunt or puzzle channel")
794 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
796 requests.post(response_url,
797 json = { 'blocks': blocks },
798 headers = {'Content-type': 'application/json'}
803 commands["/hunt"] = hunt