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 "Solutions (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 def new_hunt(turb, payload):
167 """Handler for the action of user pressing the new_hunt button"""
171 "private_metadata": json.dumps({}),
172 "title": { "type": "plain_text", "text": "New Hunt" },
173 "submit": { "type": "plain_text", "text": "Create" },
175 input_block("Hunt name", "name", "Name of the hunt"),
176 input_block("Hunt ID", "hunt_id",
177 "Used as puzzle channel prefix "
178 + "(no spaces nor punctuation)"),
179 input_block("Hunt URL", "url", "External URL of hunt",
184 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
187 submission_handlers[result['view']['id']] = new_hunt_submission
191 actions['button']['new_hunt'] = new_hunt
193 def new_hunt_submission(turb, payload, metadata):
194 """Handler for the user submitting the new hunt modal
196 This is the modal view presented to the user by the new_hunt
199 state = payload['view']['state']['values']
200 user_id = payload['user']['id']
201 name = state['name']['name']['value']
202 hunt_id = state['hunt_id']['hunt_id']['value']
203 url = state['url']['url']['value']
205 # Validate that the hunt_id contains no invalid characters
206 if not re.match(valid_id_re, hunt_id):
207 return submission_error("hunt_id",
208 "Hunt ID can only contain lowercase letters, "
209 + "numbers, and underscores")
211 # Check to see if the turbot table exists
213 exists = turb.table.table_status in ("CREATING", "UPDATING",
218 # Create the turbot table if necessary.
220 turb.table = turb.db.create_table(
223 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
224 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
226 AttributeDefinitions=[
227 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
228 {'AttributeName': 'SK', 'AttributeType': 'S'},
229 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
230 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
231 {'AttributeName': 'url', 'AttributeType': 'S'}
233 ProvisionedThroughput={
234 'ReadCapacityUnits': 5,
235 'WriteCapacityUnits': 5
237 GlobalSecondaryIndexes=[
239 'IndexName': 'channel_id_index',
241 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
244 'ProjectionType': 'ALL'
246 'ProvisionedThroughput': {
247 'ReadCapacityUnits': 5,
248 'WriteCapacityUnits': 5
252 'IndexName': 'is_hunt_index',
254 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
257 'ProjectionType': 'ALL'
259 'ProvisionedThroughput': {
260 'ReadCapacityUnits': 5,
261 'WriteCapacityUnits': 5
265 LocalSecondaryIndexes = [
267 'IndexName': 'url_index',
269 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
270 {'AttributeName': 'url', 'KeyType': 'RANGE'},
273 'ProjectionType': 'ALL'
278 return submission_error(
280 "Still bootstrapping turbot table. Try again in a minute, please.")
282 # Create a channel for the hunt
284 response = turb.slack_client.conversations_create(name=hunt_id)
285 except SlackApiError as e:
286 return submission_error("hunt_id",
287 "Error creating Slack channel: {}"
288 .format(e.response['error']))
290 channel_id = response['channel']['id']
292 # Insert the newly-created hunt into the database
293 # (leaving it as non-active for now until the channel-created handler
294 # finishes fixing it up with a sheet and a companion table)
297 "SK": "hunt-{}".format(hunt_id),
299 "channel_id": channel_id,
305 turb.table.put_item(Item=item)
307 # Invite the initiating user to the channel
308 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
312 def view_submission(turb, payload):
313 """Handler for Slack interactive view submission
315 Specifically, those that have a payload type of 'view_submission'"""
317 view_id = payload['view']['id']
318 metadata = payload['view']['private_metadata']
320 if view_id in submission_handlers:
321 return submission_handlers[view_id](turb, payload, metadata)
323 print("Error: Unknown view ID: {}".format(view_id))
328 def rot(turb, body, args):
329 """Implementation of the /rot command
331 The args string should be as follows:
333 [count|*] String to be rotated
335 That is, the first word of the string is an optional number (or
336 the character '*'). If this is a number it indicates an amount to
337 rotate each character in the string. If the count is '*' or is not
338 present, then the string will be rotated through all possible 25
341 The result of the rotation is returned (with Slack formatting) in
342 the body of the response so that Slack will provide it as a reply
343 to the user who submitted the slash command."""
345 channel_name = body['channel_name'][0]
346 response_url = body['response_url'][0]
347 channel_id = body['channel_id'][0]
349 result = turbot.rot.rot(args)
351 if (channel_name == "directmessage"):
352 requests.post(response_url,
353 json = {"text": result},
354 headers = {"Content-type": "application/json"})
356 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
360 commands["/rot"] = rot
362 def get_table_item(turb, table_name, key, value):
363 """Get an item from the database 'table_name' with 'key' as 'value'
365 Returns a tuple of (item, table) if found and (None, None) otherwise."""
367 table = turb.db.Table(table_name)
369 response = table.get_item(Key={key: value})
371 if 'Item' in response:
372 return (response['Item'], table)
376 def db_entry_for_channel(turb, channel_id):
377 """Given a channel ID return the database item for this channel
379 If this channel is a registered hunt or puzzle channel, return the
380 corresponding row from the database for this channel. Otherwise,
383 Note: If you need to specifically ensure that the channel is a
384 puzzle or a hunt, please call puzzle_for_channel or
385 hunt_for_channel respectively.
388 response = turb.table.query(
389 IndexName = "channel_id_index",
390 KeyConditionExpression=Key("channel_id").eq(channel_id)
393 if response['Count'] == 0:
396 return response['Items'][0]
399 def puzzle_for_channel(turb, channel_id):
401 """Given a channel ID return the puzzle from the database for this channel
403 If the given channel_id is a puzzle's channel, this function
404 returns a dict filled with the attributes from the puzzle's entry
407 Otherwise, this function returns None.
410 entry = db_entry_for_channel(turb, channel_id)
412 if entry and entry['SK'].startswith('puzzle-'):
417 def hunt_for_channel(turb, channel_id):
419 """Given a channel ID return the hunt from the database for this channel
421 This works whether the original channel is a primary hunt channel,
422 or if it is one of the channels of a puzzle belonging to the hunt.
424 Returns None if channel does not belong to a hunt, otherwise a
425 dictionary with all fields from the hunt's row in the table,
426 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
429 entry = db_entry_for_channel(turb, channel_id)
431 # We're done if this channel doesn't exist in the database at all
435 # Also done if this channel is a hunt channel
436 if entry['SK'].startswith('hunt-'):
439 # Otherwise, (the channel is in the database, but is not a hunt),
440 # we expect this to be a puzzle channel instead
441 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
443 # python3.9 has a built-in removeprefix but AWS only has python3.8
444 def remove_prefix(text, prefix):
445 if text.startswith(prefix):
446 return text[len(prefix):]
449 def hunt_rounds(turb, hunt_id):
450 """Returns array of strings giving rounds that exist in the given hunt"""
452 response = turb.table.query(
453 KeyConditionExpression=(
454 Key('hunt_id').eq(hunt_id) &
455 Key('SK').begins_with('round-')
459 if response['Count'] == 0:
462 return [remove_prefix(option['SK'], 'round-')
463 for option in response['Items']]
465 def puzzle(turb, body, args):
466 """Implementation of the /puzzle command
468 The args string is currently ignored (this command will bring up
469 a modal dialog for user input instead)."""
471 channel_id = body['channel_id'][0]
472 trigger_id = body['trigger_id'][0]
474 hunt = hunt_for_channel(turb, channel_id)
477 return bot_reply("Sorry, this channel doesn't appear to "
478 + "be a hunt or puzzle channel")
480 round_options = hunt_rounds(turb, hunt['hunt_id'])
482 if len(round_options):
483 round_options_block = [
484 multi_select_block("Round(s)", "rounds",
485 "Existing round(s) this puzzle belongs to",
489 round_options_block = []
493 "private_metadata": json.dumps({
494 "hunt_id": hunt['hunt_id'],
496 "title": {"type": "plain_text", "text": "New Puzzle"},
497 "submit": { "type": "plain_text", "text": "Create" },
499 section_block(text_block("*For {}*".format(hunt['name']))),
500 input_block("Puzzle name", "name", "Name of the puzzle"),
501 input_block("Puzzle URL", "url", "External URL of puzzle",
503 * round_options_block,
504 input_block("New round(s)", "new_rounds",
505 "New round(s) this puzzle belongs to " +
511 result = turb.slack_client.views_open(trigger_id=trigger_id,
515 submission_handlers[result['view']['id']] = puzzle_submission
519 commands["/puzzle"] = puzzle
521 def puzzle_submission(turb, payload, metadata):
522 """Handler for the user submitting the new puzzle modal
524 This is the modal view presented to the user by the puzzle function
527 # First, read all the various data from the request
528 meta = json.loads(metadata)
529 hunt_id = meta['hunt_id']
531 state = payload['view']['state']['values']
532 name = state['name']['name']['value']
533 url = state['url']['url']['value']
534 if 'rounds' in state:
535 rounds = [option['value'] for option in
536 state['rounds']['rounds']['selected_options']]
539 new_rounds = state['new_rounds']['new_rounds']['value']
541 # Before doing anything, reject this puzzle if a puzzle already
542 # exists with the same URL.
544 existing = find_puzzle_for_url(turb, hunt_id, url)
546 return submission_error(
548 "Error: A puzzle with this URL already exists.")
550 # Create a Slack-channel-safe puzzle_id
551 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
553 # Create a channel for the puzzle
554 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
557 response = turb.slack_client.conversations_create(
558 name=hunt_dash_channel)
559 except SlackApiError as e:
560 return submission_error(
562 "Error creating Slack channel {}: {}"
563 .format(hunt_dash_channel, e.response['error']))
565 channel_id = response['channel']['id']
567 # Add any new rounds to the database
569 for round in new_rounds.split(','):
570 # Drop any leading/trailing spaces from the round name
571 round = round.strip()
572 # Ignore any empty string
579 'SK': 'round-' + round
583 # Insert the newly-created puzzle into the database
586 "SK": "puzzle-{}".format(puzzle_id),
587 "puzzle_id": puzzle_id,
588 "channel_id": channel_id,
590 "status": 'unsolved',
596 item['rounds'] = rounds
597 turb.table.put_item(Item=item)
601 # XXX: This duplicates functionality eith events.py:set_channel_description
602 def set_channel_topic(turb, puzzle):
603 channel_id = puzzle['channel_id']
604 name = puzzle['name']
605 url = puzzle.get('url', None)
606 sheet_url = puzzle.get('sheet_url', None)
607 state = puzzle.get('state', None)
608 status = puzzle['status']
612 if status == 'solved':
613 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
619 links.append("<{}|Puzzle>".format(url))
621 links.append("<{}|Sheet>".format(sheet_url))
624 description += "({})".format(', '.join(links))
627 description += " {}".format(state)
629 # Slack only allows 250 characters for a topic
630 if len(description) > 250:
631 description = description[:247] + "..."
633 turb.slack_client.conversations_setTopic(channel=channel_id,
636 def state(turb, body, args):
637 """Implementation of the /state command
639 The args string should be a brief sentence describing where things
640 stand or what's needed."""
642 channel_id = body['channel_id'][0]
644 puzzle = puzzle_for_channel(turb, channel_id)
648 "Sorry, the /state command only works in a puzzle channel")
650 # Set the state field in the database
651 puzzle['state'] = args
652 turb.table.put_item(Item=puzzle)
654 set_channel_topic(turb, puzzle)
658 commands["/state"] = state
660 def solved(turb, body, args):
661 """Implementation of the /solved command
663 The args string should be a confirmed solution."""
665 channel_id = body['channel_id'][0]
666 user_name = body['user_name'][0]
668 puzzle = puzzle_for_channel(turb, channel_id)
671 return bot_reply("Sorry, this is not a puzzle channel.")
675 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
677 # Set the status and solution fields in the database
678 puzzle['status'] = 'solved'
679 puzzle['solution'].append(args)
680 if 'state' in puzzle:
682 turb.table.put_item(Item=puzzle)
684 # Report the solution to the puzzle's channel
686 turb.slack_client, channel_id,
687 "Puzzle mark solved by {}: `{}`".format(user_name, args))
689 # Also report the solution to the hunt channel
690 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
692 turb.slack_client, hunt['channel_id'],
693 "Puzzle <{}|{}> has been solved!".format(
694 puzzle['channel_url'],
698 # And update the puzzle's description
699 set_channel_topic(turb, puzzle)
701 # And rename the sheet to suffix with "-SOLVED"
702 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
703 puzzle['name'] + "-SOLVED")
705 # Finally, rename the Slack channel to add the suffix '-solved'
706 channel_name = "{}-{}-solved".format(
709 turb.slack_client.conversations_rename(
710 channel=puzzle['channel_id'],
715 commands["/solved"] = solved
718 def hunt(turb, body, args):
719 """Implementation of the /hunt command
721 The (optional) args string can be used to filter which puzzles to
722 display. The first word can be one of 'all', 'unsolved', or
723 'solved' and can be used to display only puzzles with the given
724 status. Any remaining text in the args string will be interpreted
725 as search terms. These will be split into separate terms on space
726 characters, (though quotation marks can be used to include a space
727 character in a term). All terms must match on a puzzle in order
728 for that puzzle to be included. But a puzzle will be considered to
729 match if any of the puzzle title, round title, puzzle URL, puzzle
730 state, or puzzle solution match. Matching will be performed
731 without regard to case sensitivity and the search terms can
732 include regular expression syntax.
735 channel_id = body['channel_id'][0]
736 response_url = body['response_url'][0]
740 # The first word can be a puzzle status and all remaining word
741 # (if any) are search terms. _But_, if the first word is not a
742 # valid puzzle status ('all', 'unsolved', 'solved'), then all
743 # words are search terms and we default status to 'unsolved'.
744 split_args = args.split(' ', 1)
745 status = split_args[0]
746 if (len(split_args) > 1):
747 terms = split_args[1]
748 if status not in ('unsolved', 'solved', 'all'):
754 # Separate search terms on spaces (but allow for quotation marks
755 # to capture spaces in a search term)
757 terms = shlex.split(terms)
759 hunt = hunt_for_channel(turb, channel_id)
762 return bot_reply("Sorry, this channel doesn't appear to "
763 + "be a hunt or puzzle channel")
765 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
767 requests.post(response_url,
768 json = { 'blocks': blocks },
769 headers = {'Content-type': 'application/json'}
774 commands["/hunt"] = hunt