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 find_puzzle_for_url, find_puzzle_for_puzzle_id
13 from botocore.exceptions import ClientError
14 from boto3.dynamodb.conditions import Key
15 from turbot.slack import slack_send_message
19 actions['button'] = {}
21 submission_handlers = {}
23 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
25 # Note: This restriction not only allows for hunt and puzzle ID values to
26 # be used as Slack channel names, but it also allows for '-' as a valid
27 # separator between a hunt and a puzzle ID (for example in the puzzle
28 # edit dialog where a single attribute must capture both values).
29 valid_id_re = r'^[_a-z0-9]+$'
31 lambda_ok = {'statusCode': 200}
33 def bot_reply(message):
34 """Construct a return value suitable for a bot reply
36 This is suitable as a way to give an error back to the user who
37 initiated a slash command, for example."""
44 def submission_error(field, error):
45 """Construct an error suitable for returning for an invalid submission.
47 Returning this value will prevent a submission and alert the user that
48 the given field is invalid because of the given error."""
50 print("Rejecting invalid modal submission: {}".format(error))
55 "Content-Type": "application/json"
58 "response_action": "errors",
65 def multi_static_select(turb, payload):
66 """Handler for the action of user entering a multi-select value"""
70 actions['multi_static_select'] = {"*": multi_static_select}
72 def edit_puzzle(turb, payload):
73 """Handler for the action of user pressing an edit_puzzle button"""
75 action_id = payload['actions'][0]['action_id']
76 response_url = payload['response_url']
77 trigger_id = payload['trigger_id']
79 (hunt_id, puzzle_id) = action_id.split('-', 1)
81 puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
84 requests.post(response_url,
85 json = {"text": "Error: Puzzle not found!"},
86 headers = {"Content-type": "application/json"})
87 return bot_reply("Error: Puzzle not found.")
89 round_options = hunt_rounds(turb, hunt_id)
91 if len(round_options):
92 round_options_block = [
93 multi_select_block("Round(s)", "rounds",
94 "Existing round(s) this puzzle belongs to",
96 initial_options=puzzle.get("rounds", None)),
99 round_options_block = []
102 if puzzle.get("status", "unsolved") == solved:
106 solution_list = puzzle.get("solution", [])
108 solution_str = ", ".join(solution_list)
112 "private_metadata": json.dumps({
115 "puzzle_id": puzzle_id,
116 "channel_id": puzzle["channel_id"],
117 "channel_url": puzzle["channel_url"],
118 "sheet_url": puzzle["sheet_url"],
120 "title": {"type": "plain_text", "text": "Edit Puzzle"},
121 "submit": { "type": "plain_text", "text": "Save" },
123 input_block("Puzzle name", "name", "Name of the puzzle",
124 initial_value=puzzle["name"]),
125 input_block("Puzzle URL", "url", "External URL of puzzle",
126 initial_value=puzzle.get("url", None),
128 * round_options_block,
129 input_block("New round(s)", "new_rounds",
130 "New round(s) this puzzle belongs to " +
133 input_block("State", "state",
134 "State of this puzzle (partial progress, next steps)",
135 initial_value=puzzle.get("state", None),
138 "Puzzle status", "Solved", "solved",
139 checked=(puzzle.get('status', 'unsolved') == 'solved')),
140 input_block("Solution", "solution",
141 "Solution(s) (comma-separated if multiple)",
142 initial_value=solution_str,
147 result = turb.slack_client.views_open(trigger_id=trigger_id,
151 submission_handlers[result['view']['id']] = edit_puzzle_submission
155 actions['button']['edit_puzzle'] = edit_puzzle
157 def edit_puzzle_submission(turb, payload, metadata):
158 """Handler for the user submitting the edit puzzle modal
160 This is the modal view presented to the user by the edit_puzzle
166 # First, read all the various data from the request
167 meta = json.loads(metadata)
168 puzzle['hunt_id'] = meta['hunt_id']
169 puzzle['SK'] = meta['SK']
170 puzzle['puzzle_id'] = meta['puzzle_id']
171 puzzle['channel_id'] = meta['channel_id']
172 puzzle['channel_url'] = meta['channel_url']
173 puzzle['sheet_url'] = meta['sheet_url']
175 state = payload['view']['state']['values']
177 puzzle['name'] = state['name']['name']['value']
178 url = state['url']['url']['value']
181 rounds = [option['value'] for option in
182 state['rounds']['rounds']['selected_options']]
184 puzzle['rounds'] = rounds
185 new_rounds = state['new_rounds']['new_rounds']['value']
186 puzzle_state = state['state']['state']['value']
188 puzzle['state'] = puzzle_state
189 if state['solved']['solved']['selected_options']:
190 puzzle['status'] = 'solved'
192 puzzle['status'] = 'unsolved'
193 puzzle['solution'] = []
194 solution = state['solution']['solution']['value']
196 puzzle['solution'] = [
197 sol.strip() for sol in solution.split(',')
200 # Verify that there's a solution if the puzzle is mark solved
201 if puzzle['status'] == 'solved' and not puzzle['solution']:
202 return submission_error("solution",
203 "A solved puzzle requires a solution.")
205 if puzzle['status'] == 'unsolved' and puzzle['solution']:
206 return submission_error("solution",
207 "An unsolved puzzle should have no solution.")
209 # Add any new rounds to the database
211 if 'rounds' not in puzzle:
212 puzzle['rounds'] = []
213 for round in new_rounds.split(','):
214 # Drop any leading/trailing spaces from the round name
215 round = round.strip()
216 # Ignore any empty string
219 puzzle['rounds'].append(round)
222 'hunt_id': puzzle['hunt_id'],
223 'SK': 'round-' + round
227 # Update the puzzle in the database
228 turb.table.put_item(Item=puzzle)
230 # We need to set the channel topic if any of puzzle name, url,
231 # state, status, or solution, has changed. Let's just do that
232 # unconditionally here.
234 # XXX: What we really want here is a single function that sets the
235 # channel name, the channel topic, and the sheet name. That single
236 # function should be called anywhere there is code changing any of
237 # these things. This function could then also accept an optional
238 # "old_puzzle" argument and avoid changing any of those things
239 # that are unnecessary.
240 set_channel_topic(turb, puzzle)
244 def new_hunt(turb, payload):
245 """Handler for the action of user pressing the new_hunt button"""
249 "private_metadata": json.dumps({}),
250 "title": { "type": "plain_text", "text": "New Hunt" },
251 "submit": { "type": "plain_text", "text": "Create" },
253 input_block("Hunt name", "name", "Name of the hunt"),
254 input_block("Hunt ID", "hunt_id",
255 "Used as puzzle channel prefix "
256 + "(no spaces nor punctuation)"),
257 input_block("Hunt URL", "url", "External URL of hunt",
262 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
265 submission_handlers[result['view']['id']] = new_hunt_submission
269 actions['button']['new_hunt'] = new_hunt
271 def new_hunt_submission(turb, payload, metadata):
272 """Handler for the user submitting the new hunt modal
274 This is the modal view presented to the user by the new_hunt
277 state = payload['view']['state']['values']
278 user_id = payload['user']['id']
279 name = state['name']['name']['value']
280 hunt_id = state['hunt_id']['hunt_id']['value']
281 url = state['url']['url']['value']
283 # Validate that the hunt_id contains no invalid characters
284 if not re.match(valid_id_re, hunt_id):
285 return submission_error("hunt_id",
286 "Hunt ID can only contain lowercase letters, "
287 + "numbers, and underscores")
289 # Check to see if the turbot table exists
291 exists = turb.table.table_status in ("CREATING", "UPDATING",
296 # Create the turbot table if necessary.
298 turb.table = turb.db.create_table(
301 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
302 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
304 AttributeDefinitions=[
305 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
306 {'AttributeName': 'SK', 'AttributeType': 'S'},
307 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
308 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
309 {'AttributeName': 'url', 'AttributeType': 'S'}
311 ProvisionedThroughput={
312 'ReadCapacityUnits': 5,
313 'WriteCapacityUnits': 5
315 GlobalSecondaryIndexes=[
317 'IndexName': 'channel_id_index',
319 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
322 'ProjectionType': 'ALL'
324 'ProvisionedThroughput': {
325 'ReadCapacityUnits': 5,
326 'WriteCapacityUnits': 5
330 'IndexName': 'is_hunt_index',
332 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
335 'ProjectionType': 'ALL'
337 'ProvisionedThroughput': {
338 'ReadCapacityUnits': 5,
339 'WriteCapacityUnits': 5
343 LocalSecondaryIndexes = [
345 'IndexName': 'url_index',
347 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
348 {'AttributeName': 'url', 'KeyType': 'RANGE'},
351 'ProjectionType': 'ALL'
356 return submission_error(
358 "Still bootstrapping turbot table. Try again in a minute, please.")
360 # Create a channel for the hunt
362 response = turb.slack_client.conversations_create(name=hunt_id)
363 except SlackApiError as e:
364 return submission_error("hunt_id",
365 "Error creating Slack channel: {}"
366 .format(e.response['error']))
368 channel_id = response['channel']['id']
370 # Insert the newly-created hunt into the database
371 # (leaving it as non-active for now until the channel-created handler
372 # finishes fixing it up with a sheet and a companion table)
375 "SK": "hunt-{}".format(hunt_id),
377 "channel_id": channel_id,
383 turb.table.put_item(Item=item)
385 # Invite the initiating user to the channel
386 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
390 def view_submission(turb, payload):
391 """Handler for Slack interactive view submission
393 Specifically, those that have a payload type of 'view_submission'"""
395 view_id = payload['view']['id']
396 metadata = payload['view']['private_metadata']
398 if view_id in submission_handlers:
399 return submission_handlers[view_id](turb, payload, metadata)
401 print("Error: Unknown view ID: {}".format(view_id))
406 def rot(turb, body, args):
407 """Implementation of the /rot command
409 The args string should be as follows:
411 [count|*] String to be rotated
413 That is, the first word of the string is an optional number (or
414 the character '*'). If this is a number it indicates an amount to
415 rotate each character in the string. If the count is '*' or is not
416 present, then the string will be rotated through all possible 25
419 The result of the rotation is returned (with Slack formatting) in
420 the body of the response so that Slack will provide it as a reply
421 to the user who submitted the slash command."""
423 channel_name = body['channel_name'][0]
424 response_url = body['response_url'][0]
425 channel_id = body['channel_id'][0]
427 result = turbot.rot.rot(args)
429 if (channel_name == "directmessage"):
430 requests.post(response_url,
431 json = {"text": result},
432 headers = {"Content-type": "application/json"})
434 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
438 commands["/rot"] = rot
440 def get_table_item(turb, table_name, key, value):
441 """Get an item from the database 'table_name' with 'key' as 'value'
443 Returns a tuple of (item, table) if found and (None, None) otherwise."""
445 table = turb.db.Table(table_name)
447 response = table.get_item(Key={key: value})
449 if 'Item' in response:
450 return (response['Item'], table)
454 def db_entry_for_channel(turb, channel_id):
455 """Given a channel ID return the database item for this channel
457 If this channel is a registered hunt or puzzle channel, return the
458 corresponding row from the database for this channel. Otherwise,
461 Note: If you need to specifically ensure that the channel is a
462 puzzle or a hunt, please call puzzle_for_channel or
463 hunt_for_channel respectively.
466 response = turb.table.query(
467 IndexName = "channel_id_index",
468 KeyConditionExpression=Key("channel_id").eq(channel_id)
471 if response['Count'] == 0:
474 return response['Items'][0]
477 def puzzle_for_channel(turb, channel_id):
479 """Given a channel ID return the puzzle from the database for this channel
481 If the given channel_id is a puzzle's channel, this function
482 returns a dict filled with the attributes from the puzzle's entry
485 Otherwise, this function returns None.
488 entry = db_entry_for_channel(turb, channel_id)
490 if entry and entry['SK'].startswith('puzzle-'):
495 def hunt_for_channel(turb, channel_id):
497 """Given a channel ID return the hunt from the database for this channel
499 This works whether the original channel is a primary hunt channel,
500 or if it is one of the channels of a puzzle belonging to the hunt.
502 Returns None if channel does not belong to a hunt, otherwise a
503 dictionary with all fields from the hunt's row in the table,
504 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
507 entry = db_entry_for_channel(turb, channel_id)
509 # We're done if this channel doesn't exist in the database at all
513 # Also done if this channel is a hunt channel
514 if entry['SK'].startswith('hunt-'):
517 # Otherwise, (the channel is in the database, but is not a hunt),
518 # we expect this to be a puzzle channel instead
519 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
521 # python3.9 has a built-in removeprefix but AWS only has python3.8
522 def remove_prefix(text, prefix):
523 if text.startswith(prefix):
524 return text[len(prefix):]
527 def hunt_rounds(turb, hunt_id):
528 """Returns array of strings giving rounds that exist in the given hunt"""
530 response = turb.table.query(
531 KeyConditionExpression=(
532 Key('hunt_id').eq(hunt_id) &
533 Key('SK').begins_with('round-')
537 if response['Count'] == 0:
540 return [remove_prefix(option['SK'], 'round-')
541 for option in response['Items']]
543 def puzzle(turb, body, args):
544 """Implementation of the /puzzle command
546 The args string is currently ignored (this command will bring up
547 a modal dialog for user input instead)."""
549 channel_id = body['channel_id'][0]
550 trigger_id = body['trigger_id'][0]
552 hunt = hunt_for_channel(turb, channel_id)
555 return bot_reply("Sorry, this channel doesn't appear to "
556 + "be a hunt or puzzle channel")
558 round_options = hunt_rounds(turb, hunt['hunt_id'])
560 if len(round_options):
561 round_options_block = [
562 multi_select_block("Round(s)", "rounds",
563 "Existing round(s) this puzzle belongs to",
567 round_options_block = []
571 "private_metadata": json.dumps({
572 "hunt_id": hunt['hunt_id'],
574 "title": {"type": "plain_text", "text": "New Puzzle"},
575 "submit": { "type": "plain_text", "text": "Create" },
577 section_block(text_block("*For {}*".format(hunt['name']))),
578 input_block("Puzzle name", "name", "Name of the puzzle"),
579 input_block("Puzzle URL", "url", "External URL of puzzle",
581 * round_options_block,
582 input_block("New round(s)", "new_rounds",
583 "New round(s) this puzzle belongs to " +
589 result = turb.slack_client.views_open(trigger_id=trigger_id,
593 submission_handlers[result['view']['id']] = puzzle_submission
597 commands["/puzzle"] = puzzle
599 def puzzle_submission(turb, payload, metadata):
600 """Handler for the user submitting the new puzzle modal
602 This is the modal view presented to the user by the puzzle function
605 # First, read all the various data from the request
606 meta = json.loads(metadata)
607 hunt_id = meta['hunt_id']
609 state = payload['view']['state']['values']
610 name = state['name']['name']['value']
611 url = state['url']['url']['value']
612 if 'rounds' in state:
613 rounds = [option['value'] for option in
614 state['rounds']['rounds']['selected_options']]
617 new_rounds = state['new_rounds']['new_rounds']['value']
619 # Before doing anything, reject this puzzle if a puzzle already
620 # exists with the same URL.
622 existing = find_puzzle_for_url(turb, hunt_id, url)
624 return submission_error(
626 "Error: A puzzle with this URL already exists.")
628 # Create a Slack-channel-safe puzzle_id
629 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
631 # Create a channel for the puzzle
632 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
635 response = turb.slack_client.conversations_create(
636 name=hunt_dash_channel)
637 except SlackApiError as e:
638 return submission_error(
640 "Error creating Slack channel {}: {}"
641 .format(hunt_dash_channel, e.response['error']))
643 channel_id = response['channel']['id']
645 # Add any new rounds to the database
647 for round in new_rounds.split(','):
648 # Drop any leading/trailing spaces from the round name
649 round = round.strip()
650 # Ignore any empty string
657 'SK': 'round-' + round
661 # Insert the newly-created puzzle into the database
664 "SK": "puzzle-{}".format(puzzle_id),
665 "puzzle_id": puzzle_id,
666 "channel_id": channel_id,
668 "status": 'unsolved',
674 item['rounds'] = rounds
675 turb.table.put_item(Item=item)
679 # XXX: This duplicates functionality eith events.py:set_channel_description
680 def set_channel_topic(turb, puzzle):
681 channel_id = puzzle['channel_id']
682 name = puzzle['name']
683 url = puzzle.get('url', None)
684 sheet_url = puzzle.get('sheet_url', None)
685 state = puzzle.get('state', None)
686 status = puzzle['status']
690 if status == 'solved':
691 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
697 links.append("<{}|Puzzle>".format(url))
699 links.append("<{}|Sheet>".format(sheet_url))
702 description += "({})".format(', '.join(links))
705 description += " {}".format(state)
707 # Slack only allows 250 characters for a topic
708 if len(description) > 250:
709 description = description[:247] + "..."
711 turb.slack_client.conversations_setTopic(channel=channel_id,
714 def state(turb, body, args):
715 """Implementation of the /state command
717 The args string should be a brief sentence describing where things
718 stand or what's needed."""
720 channel_id = body['channel_id'][0]
722 puzzle = puzzle_for_channel(turb, channel_id)
726 "Sorry, the /state command only works in a puzzle channel")
728 # Set the state field in the database
729 puzzle['state'] = args
730 turb.table.put_item(Item=puzzle)
732 set_channel_topic(turb, puzzle)
736 commands["/state"] = state
738 def solved(turb, body, args):
739 """Implementation of the /solved command
741 The args string should be a confirmed solution."""
743 channel_id = body['channel_id'][0]
744 user_name = body['user_name'][0]
746 puzzle = puzzle_for_channel(turb, channel_id)
749 return bot_reply("Sorry, this is not a puzzle channel.")
753 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
755 # Set the status and solution fields in the database
756 puzzle['status'] = 'solved'
757 puzzle['solution'].append(args)
758 if 'state' in puzzle:
760 turb.table.put_item(Item=puzzle)
762 # Report the solution to the puzzle's channel
764 turb.slack_client, channel_id,
765 "Puzzle mark solved by {}: `{}`".format(user_name, args))
767 # Also report the solution to the hunt channel
768 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
770 turb.slack_client, hunt['channel_id'],
771 "Puzzle <{}|{}> has been solved!".format(
772 puzzle['channel_url'],
776 # And update the puzzle's description
777 set_channel_topic(turb, puzzle)
779 # And rename the sheet to suffix with "-SOLVED"
780 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
781 puzzle['name'] + "-SOLVED")
783 # Finally, rename the Slack channel to add the suffix '-solved'
784 channel_name = "{}-{}-solved".format(
787 turb.slack_client.conversations_rename(
788 channel=puzzle['channel_id'],
793 commands["/solved"] = solved
796 def hunt(turb, body, args):
797 """Implementation of the /hunt command
799 The (optional) args string can be used to filter which puzzles to
800 display. The first word can be one of 'all', 'unsolved', or
801 'solved' and can be used to display only puzzles with the given
802 status. Any remaining text in the args string will be interpreted
803 as search terms. These will be split into separate terms on space
804 characters, (though quotation marks can be used to include a space
805 character in a term). All terms must match on a puzzle in order
806 for that puzzle to be included. But a puzzle will be considered to
807 match if any of the puzzle title, round title, puzzle URL, puzzle
808 state, or puzzle solution match. Matching will be performed
809 without regard to case sensitivity and the search terms can
810 include regular expression syntax.
813 channel_id = body['channel_id'][0]
814 response_url = body['response_url'][0]
818 # The first word can be a puzzle status and all remaining word
819 # (if any) are search terms. _But_, if the first word is not a
820 # valid puzzle status ('all', 'unsolved', 'solved'), then all
821 # words are search terms and we default status to 'unsolved'.
822 split_args = args.split(' ', 1)
823 status = split_args[0]
824 if (len(split_args) > 1):
825 terms = split_args[1]
826 if status not in ('unsolved', 'solved', 'all'):
832 # Separate search terms on spaces (but allow for quotation marks
833 # to capture spaces in a search term)
835 terms = shlex.split(terms)
837 hunt = hunt_for_channel(turb, channel_id)
840 return bot_reply("Sorry, this channel doesn't appear to "
841 + "be a hunt or puzzle channel")
843 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
845 requests.post(response_url,
846 json = { 'blocks': blocks },
847 headers = {'Content-type': 'application/json'}
852 commands["/hunt"] = hunt