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 # Add any new rounds to the database
202 if 'rounds' not in puzzle:
203 puzzle['rounds'] = []
204 for round in new_rounds.split(','):
205 # Drop any leading/trailing spaces from the round name
206 round = round.strip()
207 # Ignore any empty string
210 puzzle['rounds'].append(round)
213 'hunt_id': puzzle['hunt_id'],
214 'SK': 'round-' + round
218 # Update the puzzle in the database
219 turb.table.put_item(Item=puzzle)
221 # We need to set the channel topic if any of puzzle name, url,
222 # state, status, or solution, has changed. Let's just do that
223 # unconditionally here.
225 # XXX: What we really want here is a single function that sets the
226 # channel name, the channel topic, and the sheet name. That single
227 # function should be called anywhere there is code changing any of
228 # these things. This function could then also accept an optional
229 # "old_puzzle" argument and avoid changing any of those things
230 # that are unnecessary.
231 set_channel_topic(turb, puzzle)
235 def new_hunt(turb, payload):
236 """Handler for the action of user pressing the new_hunt button"""
240 "private_metadata": json.dumps({}),
241 "title": { "type": "plain_text", "text": "New Hunt" },
242 "submit": { "type": "plain_text", "text": "Create" },
244 input_block("Hunt name", "name", "Name of the hunt"),
245 input_block("Hunt ID", "hunt_id",
246 "Used as puzzle channel prefix "
247 + "(no spaces nor punctuation)"),
248 input_block("Hunt URL", "url", "External URL of hunt",
253 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
256 submission_handlers[result['view']['id']] = new_hunt_submission
260 actions['button']['new_hunt'] = new_hunt
262 def new_hunt_submission(turb, payload, metadata):
263 """Handler for the user submitting the new hunt modal
265 This is the modal view presented to the user by the new_hunt
268 state = payload['view']['state']['values']
269 user_id = payload['user']['id']
270 name = state['name']['name']['value']
271 hunt_id = state['hunt_id']['hunt_id']['value']
272 url = state['url']['url']['value']
274 # Validate that the hunt_id contains no invalid characters
275 if not re.match(valid_id_re, hunt_id):
276 return submission_error("hunt_id",
277 "Hunt ID can only contain lowercase letters, "
278 + "numbers, and underscores")
280 # Check to see if the turbot table exists
282 exists = turb.table.table_status in ("CREATING", "UPDATING",
287 # Create the turbot table if necessary.
289 turb.table = turb.db.create_table(
292 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
293 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
295 AttributeDefinitions=[
296 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
297 {'AttributeName': 'SK', 'AttributeType': 'S'},
298 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
299 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
300 {'AttributeName': 'url', 'AttributeType': 'S'}
302 ProvisionedThroughput={
303 'ReadCapacityUnits': 5,
304 'WriteCapacityUnits': 5
306 GlobalSecondaryIndexes=[
308 'IndexName': 'channel_id_index',
310 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
313 'ProjectionType': 'ALL'
315 'ProvisionedThroughput': {
316 'ReadCapacityUnits': 5,
317 'WriteCapacityUnits': 5
321 'IndexName': 'is_hunt_index',
323 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
326 'ProjectionType': 'ALL'
328 'ProvisionedThroughput': {
329 'ReadCapacityUnits': 5,
330 'WriteCapacityUnits': 5
334 LocalSecondaryIndexes = [
336 'IndexName': 'url_index',
338 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
339 {'AttributeName': 'url', 'KeyType': 'RANGE'},
342 'ProjectionType': 'ALL'
347 return submission_error(
349 "Still bootstrapping turbot table. Try again in a minute, please.")
351 # Create a channel for the hunt
353 response = turb.slack_client.conversations_create(name=hunt_id)
354 except SlackApiError as e:
355 return submission_error("hunt_id",
356 "Error creating Slack channel: {}"
357 .format(e.response['error']))
359 channel_id = response['channel']['id']
361 # Insert the newly-created hunt into the database
362 # (leaving it as non-active for now until the channel-created handler
363 # finishes fixing it up with a sheet and a companion table)
366 "SK": "hunt-{}".format(hunt_id),
368 "channel_id": channel_id,
374 turb.table.put_item(Item=item)
376 # Invite the initiating user to the channel
377 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
381 def view_submission(turb, payload):
382 """Handler for Slack interactive view submission
384 Specifically, those that have a payload type of 'view_submission'"""
386 view_id = payload['view']['id']
387 metadata = payload['view']['private_metadata']
389 if view_id in submission_handlers:
390 return submission_handlers[view_id](turb, payload, metadata)
392 print("Error: Unknown view ID: {}".format(view_id))
397 def rot(turb, body, args):
398 """Implementation of the /rot command
400 The args string should be as follows:
402 [count|*] String to be rotated
404 That is, the first word of the string is an optional number (or
405 the character '*'). If this is a number it indicates an amount to
406 rotate each character in the string. If the count is '*' or is not
407 present, then the string will be rotated through all possible 25
410 The result of the rotation is returned (with Slack formatting) in
411 the body of the response so that Slack will provide it as a reply
412 to the user who submitted the slash command."""
414 channel_name = body['channel_name'][0]
415 response_url = body['response_url'][0]
416 channel_id = body['channel_id'][0]
418 result = turbot.rot.rot(args)
420 if (channel_name == "directmessage"):
421 requests.post(response_url,
422 json = {"text": result},
423 headers = {"Content-type": "application/json"})
425 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
429 commands["/rot"] = rot
431 def get_table_item(turb, table_name, key, value):
432 """Get an item from the database 'table_name' with 'key' as 'value'
434 Returns a tuple of (item, table) if found and (None, None) otherwise."""
436 table = turb.db.Table(table_name)
438 response = table.get_item(Key={key: value})
440 if 'Item' in response:
441 return (response['Item'], table)
445 def db_entry_for_channel(turb, channel_id):
446 """Given a channel ID return the database item for this channel
448 If this channel is a registered hunt or puzzle channel, return the
449 corresponding row from the database for this channel. Otherwise,
452 Note: If you need to specifically ensure that the channel is a
453 puzzle or a hunt, please call puzzle_for_channel or
454 hunt_for_channel respectively.
457 response = turb.table.query(
458 IndexName = "channel_id_index",
459 KeyConditionExpression=Key("channel_id").eq(channel_id)
462 if response['Count'] == 0:
465 return response['Items'][0]
468 def puzzle_for_channel(turb, channel_id):
470 """Given a channel ID return the puzzle from the database for this channel
472 If the given channel_id is a puzzle's channel, this function
473 returns a dict filled with the attributes from the puzzle's entry
476 Otherwise, this function returns None.
479 entry = db_entry_for_channel(turb, channel_id)
481 if entry and entry['SK'].startswith('puzzle-'):
486 def hunt_for_channel(turb, channel_id):
488 """Given a channel ID return the hunt from the database for this channel
490 This works whether the original channel is a primary hunt channel,
491 or if it is one of the channels of a puzzle belonging to the hunt.
493 Returns None if channel does not belong to a hunt, otherwise a
494 dictionary with all fields from the hunt's row in the table,
495 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
498 entry = db_entry_for_channel(turb, channel_id)
500 # We're done if this channel doesn't exist in the database at all
504 # Also done if this channel is a hunt channel
505 if entry['SK'].startswith('hunt-'):
508 # Otherwise, (the channel is in the database, but is not a hunt),
509 # we expect this to be a puzzle channel instead
510 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
512 # python3.9 has a built-in removeprefix but AWS only has python3.8
513 def remove_prefix(text, prefix):
514 if text.startswith(prefix):
515 return text[len(prefix):]
518 def hunt_rounds(turb, hunt_id):
519 """Returns array of strings giving rounds that exist in the given hunt"""
521 response = turb.table.query(
522 KeyConditionExpression=(
523 Key('hunt_id').eq(hunt_id) &
524 Key('SK').begins_with('round-')
528 if response['Count'] == 0:
531 return [remove_prefix(option['SK'], 'round-')
532 for option in response['Items']]
534 def puzzle(turb, body, args):
535 """Implementation of the /puzzle command
537 The args string is currently ignored (this command will bring up
538 a modal dialog for user input instead)."""
540 channel_id = body['channel_id'][0]
541 trigger_id = body['trigger_id'][0]
543 hunt = hunt_for_channel(turb, channel_id)
546 return bot_reply("Sorry, this channel doesn't appear to "
547 + "be a hunt or puzzle channel")
549 round_options = hunt_rounds(turb, hunt['hunt_id'])
551 if len(round_options):
552 round_options_block = [
553 multi_select_block("Round(s)", "rounds",
554 "Existing round(s) this puzzle belongs to",
558 round_options_block = []
562 "private_metadata": json.dumps({
563 "hunt_id": hunt['hunt_id'],
565 "title": {"type": "plain_text", "text": "New Puzzle"},
566 "submit": { "type": "plain_text", "text": "Create" },
568 section_block(text_block("*For {}*".format(hunt['name']))),
569 input_block("Puzzle name", "name", "Name of the puzzle"),
570 input_block("Puzzle URL", "url", "External URL of puzzle",
572 * round_options_block,
573 input_block("New round(s)", "new_rounds",
574 "New round(s) this puzzle belongs to " +
580 result = turb.slack_client.views_open(trigger_id=trigger_id,
584 submission_handlers[result['view']['id']] = puzzle_submission
588 commands["/puzzle"] = puzzle
590 def puzzle_submission(turb, payload, metadata):
591 """Handler for the user submitting the new puzzle modal
593 This is the modal view presented to the user by the puzzle function
596 # First, read all the various data from the request
597 meta = json.loads(metadata)
598 hunt_id = meta['hunt_id']
600 state = payload['view']['state']['values']
601 name = state['name']['name']['value']
602 url = state['url']['url']['value']
603 if 'rounds' in state:
604 rounds = [option['value'] for option in
605 state['rounds']['rounds']['selected_options']]
608 new_rounds = state['new_rounds']['new_rounds']['value']
610 # Before doing anything, reject this puzzle if a puzzle already
611 # exists with the same URL.
613 existing = find_puzzle_for_url(turb, hunt_id, url)
615 return submission_error(
617 "Error: A puzzle with this URL already exists.")
619 # Create a Slack-channel-safe puzzle_id
620 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
622 # Create a channel for the puzzle
623 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
626 response = turb.slack_client.conversations_create(
627 name=hunt_dash_channel)
628 except SlackApiError as e:
629 return submission_error(
631 "Error creating Slack channel {}: {}"
632 .format(hunt_dash_channel, e.response['error']))
634 channel_id = response['channel']['id']
636 # Add any new rounds to the database
638 for round in new_rounds.split(','):
639 # Drop any leading/trailing spaces from the round name
640 round = round.strip()
641 # Ignore any empty string
648 'SK': 'round-' + round
652 # Insert the newly-created puzzle into the database
655 "SK": "puzzle-{}".format(puzzle_id),
656 "puzzle_id": puzzle_id,
657 "channel_id": channel_id,
659 "status": 'unsolved',
665 item['rounds'] = rounds
666 turb.table.put_item(Item=item)
670 # XXX: This duplicates functionality eith events.py:set_channel_description
671 def set_channel_topic(turb, puzzle):
672 channel_id = puzzle['channel_id']
673 name = puzzle['name']
674 url = puzzle.get('url', None)
675 sheet_url = puzzle.get('sheet_url', None)
676 state = puzzle.get('state', None)
677 status = puzzle['status']
681 if status == 'solved':
682 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
688 links.append("<{}|Puzzle>".format(url))
690 links.append("<{}|Sheet>".format(sheet_url))
693 description += "({})".format(', '.join(links))
696 description += " {}".format(state)
698 # Slack only allows 250 characters for a topic
699 if len(description) > 250:
700 description = description[:247] + "..."
702 turb.slack_client.conversations_setTopic(channel=channel_id,
705 def state(turb, body, args):
706 """Implementation of the /state command
708 The args string should be a brief sentence describing where things
709 stand or what's needed."""
711 channel_id = body['channel_id'][0]
713 puzzle = puzzle_for_channel(turb, channel_id)
717 "Sorry, the /state command only works in a puzzle channel")
719 # Set the state field in the database
720 puzzle['state'] = args
721 turb.table.put_item(Item=puzzle)
723 set_channel_topic(turb, puzzle)
727 commands["/state"] = state
729 def solved(turb, body, args):
730 """Implementation of the /solved command
732 The args string should be a confirmed solution."""
734 channel_id = body['channel_id'][0]
735 user_name = body['user_name'][0]
737 puzzle = puzzle_for_channel(turb, channel_id)
740 return bot_reply("Sorry, this is not a puzzle channel.")
744 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
746 # Set the status and solution fields in the database
747 puzzle['status'] = 'solved'
748 puzzle['solution'].append(args)
749 if 'state' in puzzle:
751 turb.table.put_item(Item=puzzle)
753 # Report the solution to the puzzle's channel
755 turb.slack_client, channel_id,
756 "Puzzle mark solved by {}: `{}`".format(user_name, args))
758 # Also report the solution to the hunt channel
759 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
761 turb.slack_client, hunt['channel_id'],
762 "Puzzle <{}|{}> has been solved!".format(
763 puzzle['channel_url'],
767 # And update the puzzle's description
768 set_channel_topic(turb, puzzle)
770 # And rename the sheet to suffix with "-SOLVED"
771 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
772 puzzle['name'] + "-SOLVED")
774 # Finally, rename the Slack channel to add the suffix '-solved'
775 channel_name = "{}-{}-solved".format(
778 turb.slack_client.conversations_rename(
779 channel=puzzle['channel_id'],
784 commands["/solved"] = solved
787 def hunt(turb, body, args):
788 """Implementation of the /hunt command
790 The (optional) args string can be used to filter which puzzles to
791 display. The first word can be one of 'all', 'unsolved', or
792 'solved' and can be used to display only puzzles with the given
793 status. Any remaining text in the args string will be interpreted
794 as search terms. These will be split into separate terms on space
795 characters, (though quotation marks can be used to include a space
796 character in a term). All terms must match on a puzzle in order
797 for that puzzle to be included. But a puzzle will be considered to
798 match if any of the puzzle title, round title, puzzle URL, puzzle
799 state, or puzzle solution match. Matching will be performed
800 without regard to case sensitivity and the search terms can
801 include regular expression syntax.
804 channel_id = body['channel_id'][0]
805 response_url = body['response_url'][0]
809 # The first word can be a puzzle status and all remaining word
810 # (if any) are search terms. _But_, if the first word is not a
811 # valid puzzle status ('all', 'unsolved', 'solved'), then all
812 # words are search terms and we default status to 'unsolved'.
813 split_args = args.split(' ', 1)
814 status = split_args[0]
815 if (len(split_args) > 1):
816 terms = split_args[1]
817 if status not in ('unsolved', 'solved', 'all'):
823 # Separate search terms on spaces (but allow for quotation marks
824 # to capture spaces in a search term)
826 terms = shlex.split(terms)
828 hunt = hunt_for_channel(turb, channel_id)
831 return bot_reply("Sorry, this channel doesn't appear to "
832 + "be a hunt or puzzle channel")
834 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
836 requests.post(response_url,
837 json = { 'blocks': blocks },
838 headers = {'Content-type': 'application/json'}
843 commands["/hunt"] = hunt