1 from slack.errors import SlackApiError
2 from turbot.blocks import (
3 input_block, section_block, text_block, multi_select_block
5 from turbot.hunt import find_hunt_for_hunt_id, hunt_blocks
6 from turbot.puzzle import find_puzzle_for_url
13 from botocore.exceptions import ClientError
14 from boto3.dynamodb.conditions import Key
15 from turbot.slack import slack_send_message
20 submission_handlers = {}
22 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
23 valid_id_re = r'^[_a-z0-9]+$'
25 lambda_ok = {'statusCode': 200}
27 def bot_reply(message):
28 """Construct a return value suitable for a bot reply
30 This is suitable as a way to give an error back to the user who
31 initiated a slash command, for example."""
38 def submission_error(field, error):
39 """Construct an error suitable for returning for an invalid submission.
41 Returning this value will prevent a submission and alert the user that
42 the given field is invalid because of the given error."""
44 print("Rejecting invalid modal submission: {}".format(error))
49 "Content-Type": "application/json"
52 "response_action": "errors",
59 def multi_static_select(turb, payload):
60 """Handler for the action of user entering a multi-select value"""
64 actions['multi_static_select'] = {"*": multi_static_select}
66 def new_hunt(turb, payload):
67 """Handler for the action of user pressing the new_hunt button"""
71 "private_metadata": json.dumps({}),
72 "title": { "type": "plain_text", "text": "New Hunt" },
73 "submit": { "type": "plain_text", "text": "Create" },
75 input_block("Hunt name", "name", "Name of the hunt"),
76 input_block("Hunt ID", "hunt_id",
77 "Used as puzzle channel prefix "
78 + "(no spaces nor punctuation)"),
79 input_block("Hunt URL", "url", "External URL of hunt",
84 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
87 submission_handlers[result['view']['id']] = new_hunt_submission
91 actions['button'] = {"new_hunt": new_hunt}
93 def new_hunt_submission(turb, payload, metadata):
94 """Handler for the user submitting the new hunt modal
96 This is the modal view presented to the user by the new_hunt
99 state = payload['view']['state']['values']
100 user_id = payload['user']['id']
101 name = state['name']['name']['value']
102 hunt_id = state['hunt_id']['hunt_id']['value']
103 url = state['url']['url']['value']
105 # Validate that the hunt_id contains no invalid characters
106 if not re.match(valid_id_re, hunt_id):
107 return submission_error("hunt_id",
108 "Hunt ID can only contain lowercase letters, "
109 + "numbers, and underscores")
111 # Check to see if the turbot table exists
113 exists = turb.table.table_status in ("CREATING", "UPDATING",
118 # Create the turbot table if necessary.
120 turb.table = turb.db.create_table(
123 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
124 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
126 AttributeDefinitions=[
127 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
128 {'AttributeName': 'SK', 'AttributeType': 'S'},
129 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
130 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
131 {'AttributeName': 'url', 'AttributeType': 'S'}
133 ProvisionedThroughput={
134 'ReadCapacityUnits': 5,
135 'WriteCapacityUnits': 5
137 GlobalSecondaryIndexes=[
139 'IndexName': 'channel_id_index',
141 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
144 'ProjectionType': 'ALL'
146 'ProvisionedThroughput': {
147 'ReadCapacityUnits': 5,
148 'WriteCapacityUnits': 5
152 'IndexName': 'is_hunt_index',
154 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
157 'ProjectionType': 'ALL'
159 'ProvisionedThroughput': {
160 'ReadCapacityUnits': 5,
161 'WriteCapacityUnits': 5
165 LocalSecondaryIndexes = [
167 'IndexName': 'url_index',
169 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
170 {'AttributeName': 'url', 'KeyType': 'RANGE'},
173 'ProjectionType': 'ALL'
178 return submission_error(
180 "Still bootstrapping turbot table. Try again in a minute, please.")
182 # Create a channel for the hunt
184 response = turb.slack_client.conversations_create(name=hunt_id)
185 except SlackApiError as e:
186 return submission_error("hunt_id",
187 "Error creating Slack channel: {}"
188 .format(e.response['error']))
190 channel_id = response['channel']['id']
192 # Insert the newly-created hunt into the database
193 # (leaving it as non-active for now until the channel-created handler
194 # finishes fixing it up with a sheet and a companion table)
197 "SK": "hunt-{}".format(hunt_id),
199 "channel_id": channel_id,
205 turb.table.put_item(Item=item)
207 # Invite the initiating user to the channel
208 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
212 def view_submission(turb, payload):
213 """Handler for Slack interactive view submission
215 Specifically, those that have a payload type of 'view_submission'"""
217 view_id = payload['view']['id']
218 metadata = payload['view']['private_metadata']
220 if view_id in submission_handlers:
221 return submission_handlers[view_id](turb, payload, metadata)
223 print("Error: Unknown view ID: {}".format(view_id))
228 def rot(turb, body, args):
229 """Implementation of the /rot command
231 The args string should be as follows:
233 [count|*] String to be rotated
235 That is, the first word of the string is an optional number (or
236 the character '*'). If this is a number it indicates an amount to
237 rotate each character in the string. If the count is '*' or is not
238 present, then the string will be rotated through all possible 25
241 The result of the rotation is returned (with Slack formatting) in
242 the body of the response so that Slack will provide it as a reply
243 to the user who submitted the slash command."""
245 channel_name = body['channel_name'][0]
246 response_url = body['response_url'][0]
247 channel_id = body['channel_id'][0]
249 result = turbot.rot.rot(args)
251 if (channel_name == "directmessage"):
252 requests.post(response_url,
253 json = {"text": result},
254 headers = {"Content-type": "application/json"})
256 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
260 commands["/rot"] = rot
262 def get_table_item(turb, table_name, key, value):
263 """Get an item from the database 'table_name' with 'key' as 'value'
265 Returns a tuple of (item, table) if found and (None, None) otherwise."""
267 table = turb.db.Table(table_name)
269 response = table.get_item(Key={key: value})
271 if 'Item' in response:
272 return (response['Item'], table)
276 def db_entry_for_channel(turb, channel_id):
277 """Given a channel ID return the database item for this channel
279 If this channel is a registered hunt or puzzle channel, return the
280 corresponding row from the database for this channel. Otherwise,
283 Note: If you need to specifically ensure that the channel is a
284 puzzle or a hunt, please call puzzle_for_channel or
285 hunt_for_channel respectively.
288 response = turb.table.query(
289 IndexName = "channel_id_index",
290 KeyConditionExpression=Key("channel_id").eq(channel_id)
293 if response['Count'] == 0:
296 return response['Items'][0]
299 def puzzle_for_channel(turb, channel_id):
301 """Given a channel ID return the puzzle from the database for this channel
303 If the given channel_id is a puzzle's channel, this function
304 returns a dict filled with the attributes from the puzzle's entry
307 Otherwise, this function returns None.
310 entry = db_entry_for_channel(turb, channel_id)
312 if entry and entry['SK'].startswith('puzzle-'):
317 def hunt_for_channel(turb, channel_id):
319 """Given a channel ID return the hunt from the database for this channel
321 This works whether the original channel is a primary hunt channel,
322 or if it is one of the channels of a puzzle belonging to the hunt.
324 Returns None if channel does not belong to a hunt, otherwise a
325 dictionary with all fields from the hunt's row in the table,
326 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
329 entry = db_entry_for_channel(turb, channel_id)
331 # We're done if this channel doesn't exist in the database at all
335 # Also done if this channel is a hunt channel
336 if entry['SK'].startswith('hunt-'):
339 # Otherwise, (the channel is in the database, but is not a hunt),
340 # we expect this to be a puzzle channel instead
341 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
343 # python3.9 has a built-in removeprefix but AWS only has python3.8
344 def remove_prefix(text, prefix):
345 if text.startswith(prefix):
346 return text[len(prefix):]
349 def hunt_rounds(turb, hunt_id):
350 """Returns array of strings giving rounds that exist in the given hunt"""
352 response = turb.table.query(
353 KeyConditionExpression=(
354 Key('hunt_id').eq(hunt_id) &
355 Key('SK').begins_with('round-')
359 if response['Count'] == 0:
362 return [remove_prefix(option['SK'], 'round-')
363 for option in response['Items']]
365 def puzzle(turb, body, args):
366 """Implementation of the /puzzle command
368 The args string is currently ignored (this command will bring up
369 a modal dialog for user input instead)."""
371 channel_id = body['channel_id'][0]
372 trigger_id = body['trigger_id'][0]
374 hunt = hunt_for_channel(turb, channel_id)
377 return bot_reply("Sorry, this channel doesn't appear to "
378 + "be a hunt or puzzle channel")
380 round_options = hunt_rounds(turb, hunt['hunt_id'])
382 if len(round_options):
383 round_options_block = [
384 multi_select_block("Round(s)", "rounds",
385 "Existing round(s) this puzzle belongs to",
389 round_options_block = []
393 "private_metadata": json.dumps({
394 "hunt_id": hunt['hunt_id'],
396 "title": {"type": "plain_text", "text": "New Puzzle"},
397 "submit": { "type": "plain_text", "text": "Create" },
399 section_block(text_block("*For {}*".format(hunt['name']))),
400 input_block("Puzzle name", "name", "Name of the puzzle"),
401 input_block("Puzzle URL", "url", "External URL of puzzle",
403 * round_options_block,
404 input_block("New round(s)", "new_rounds",
405 "New round(s) this puzzle belongs to " +
411 result = turb.slack_client.views_open(trigger_id=trigger_id,
415 submission_handlers[result['view']['id']] = puzzle_submission
419 commands["/puzzle"] = puzzle
421 def puzzle_submission(turb, payload, metadata):
422 """Handler for the user submitting the new puzzle modal
424 This is the modal view presented to the user by the puzzle function
427 # First, read all the various data from the request
428 meta = json.loads(metadata)
429 hunt_id = meta['hunt_id']
431 state = payload['view']['state']['values']
432 name = state['name']['name']['value']
433 url = state['url']['url']['value']
434 if 'rounds' in state:
435 rounds = [option['value'] for option in
436 state['rounds']['rounds']['selected_options']]
439 new_rounds = state['new_rounds']['new_rounds']['value']
441 # Before doing anything, reject this puzzle if a puzzle already
442 # exists with the same URL.
444 existing = find_puzzle_for_url(turb, hunt_id, url)
446 return submission_error(
448 "Error: A puzzle with this URL already exists.")
450 # Create a Slack-channel-safe puzzle_id
451 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
453 # Create a channel for the puzzle
454 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
457 response = turb.slack_client.conversations_create(
458 name=hunt_dash_channel)
459 except SlackApiError as e:
460 return submission_error(
462 "Error creating Slack channel {}: {}"
463 .format(hunt_dash_channel, e.response['error']))
465 channel_id = response['channel']['id']
467 # Add any new rounds to the database
469 for round in new_rounds.split(','):
470 # Drop any leading/trailing spaces from the round name
471 round = round.strip()
472 # Ignore any empty string
479 'SK': 'round-' + round
483 # Insert the newly-created puzzle into the database
486 "SK": "puzzle-{}".format(puzzle_id),
487 "puzzle_id": puzzle_id,
488 "channel_id": channel_id,
490 "status": 'unsolved',
496 item['rounds'] = rounds
497 turb.table.put_item(Item=item)
501 # XXX: This duplicates functionality eith events.py:set_channel_description
502 def set_channel_topic(turb, puzzle):
503 channel_id = puzzle['channel_id']
504 name = puzzle['name']
505 url = puzzle.get('url', None)
506 sheet_url = puzzle.get('sheet_url', None)
507 state = puzzle.get('state', None)
508 status = puzzle['status']
512 if status == 'solved':
513 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
519 links.append("<{}|Puzzle>".format(url))
521 links.append("<{}|Sheet>".format(sheet_url))
524 description += "({})".format(', '.join(links))
527 description += " {}".format(state)
529 # Slack only allows 250 characters for a topic
530 if len(description) > 250:
531 description = description[:247] + "..."
533 turb.slack_client.conversations_setTopic(channel=channel_id,
536 def state(turb, body, args):
537 """Implementation of the /state command
539 The args string should be a brief sentence describing where things
540 stand or what's needed."""
542 channel_id = body['channel_id'][0]
544 puzzle = puzzle_for_channel(turb, channel_id)
548 "Sorry, the /state command only works in a puzzle channel")
550 # Set the state field in the database
551 puzzle['state'] = args
552 turb.table.put_item(Item=puzzle)
554 set_channel_topic(turb, puzzle)
558 commands["/state"] = state
560 def solved(turb, body, args):
561 """Implementation of the /solved command
563 The args string should be a confirmed solution."""
565 channel_id = body['channel_id'][0]
566 user_name = body['user_name'][0]
568 puzzle = puzzle_for_channel(turb, channel_id)
571 return bot_reply("Sorry, this is not a puzzle channel.")
575 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
577 # Set the status and solution fields in the database
578 puzzle['status'] = 'solved'
579 puzzle['solution'].append(args)
580 if 'state' in puzzle:
582 turb.table.put_item(Item=puzzle)
584 # Report the solution to the puzzle's channel
586 turb.slack_client, channel_id,
587 "Puzzle mark solved by {}: `{}`".format(user_name, args))
589 # Also report the solution to the hunt channel
590 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
592 turb.slack_client, hunt['channel_id'],
593 "Puzzle <{}|{}> has been solved!".format(
594 puzzle['channel_url'],
598 # And update the puzzle's description
599 set_channel_topic(turb, puzzle)
601 # And rename the sheet to suffix with "-SOLVED"
602 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
603 puzzle['name'] + "-SOLVED")
605 # Finally, rename the Slack channel to add the suffix '-solved'
606 channel_name = "{}-{}-solved".format(
609 turb.slack_client.conversations_rename(
610 channel=puzzle['channel_id'],
615 commands["/solved"] = solved
618 def hunt(turb, body, args):
619 """Implementation of the /hunt command
621 The (optional) args string can be used to filter which puzzles to
622 display. The first word can be one of 'all', 'unsolved', or
623 'solved' and can be used to display only puzzles with the given
624 status. Any remaining text in the args string will be interpreted
625 as search terms. These will be split into separate terms on space
626 characters, (though quotation marks can be used to include a space
627 character in a term). All terms must match on a puzzle in order
628 for that puzzle to be included. But a puzzle will be considered to
629 match if any of the puzzle title, round title, puzzle URL, puzzle
630 state, or puzzle solution match. Matching will be performed
631 without regard to case sensitivity and the search terms can
632 include regular expression syntax.
635 channel_id = body['channel_id'][0]
636 response_url = body['response_url'][0]
640 # The first word can be a puzzle status and all remaining word
641 # (if any) are search terms. _But_, if the first word is not a
642 # valid puzzle status ('all', 'unsolved', 'solved'), then all
643 # words are search terms and we default status to 'unsolved'.
644 split_args = args.split(' ', 1)
645 status = split_args[0]
646 if (len(split_args) > 1):
647 terms = split_args[1]
648 if status not in ('unsolved', 'solved', 'all'):
654 # Separate search terms on spaces (but allow for quotation marks
655 # to capture spaces in a search term)
657 terms = shlex.split(terms)
659 hunt = hunt_for_channel(turb, channel_id)
662 return bot_reply("Sorry, this channel doesn't appear to "
663 + "be a hunt or puzzle channel")
665 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
667 requests.post(response_url,
668 json = { 'blocks': blocks },
669 headers = {'Content-type': 'application/json'}
674 commands["/hunt"] = hunt