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
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 print("DEBUG: In edit_puzzle with payload: {}".format(str(payload)))
79 actions['button']['edit_puzzle'] = edit_puzzle
81 def new_hunt(turb, payload):
82 """Handler for the action of user pressing the new_hunt button"""
86 "private_metadata": json.dumps({}),
87 "title": { "type": "plain_text", "text": "New Hunt" },
88 "submit": { "type": "plain_text", "text": "Create" },
90 input_block("Hunt name", "name", "Name of the hunt"),
91 input_block("Hunt ID", "hunt_id",
92 "Used as puzzle channel prefix "
93 + "(no spaces nor punctuation)"),
94 input_block("Hunt URL", "url", "External URL of hunt",
99 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
102 submission_handlers[result['view']['id']] = new_hunt_submission
106 actions['button']['new_hunt'] = new_hunt
108 def new_hunt_submission(turb, payload, metadata):
109 """Handler for the user submitting the new hunt modal
111 This is the modal view presented to the user by the new_hunt
114 state = payload['view']['state']['values']
115 user_id = payload['user']['id']
116 name = state['name']['name']['value']
117 hunt_id = state['hunt_id']['hunt_id']['value']
118 url = state['url']['url']['value']
120 # Validate that the hunt_id contains no invalid characters
121 if not re.match(valid_id_re, hunt_id):
122 return submission_error("hunt_id",
123 "Hunt ID can only contain lowercase letters, "
124 + "numbers, and underscores")
126 # Check to see if the turbot table exists
128 exists = turb.table.table_status in ("CREATING", "UPDATING",
133 # Create the turbot table if necessary.
135 turb.table = turb.db.create_table(
138 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
139 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
141 AttributeDefinitions=[
142 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
143 {'AttributeName': 'SK', 'AttributeType': 'S'},
144 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
145 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
146 {'AttributeName': 'url', 'AttributeType': 'S'}
148 ProvisionedThroughput={
149 'ReadCapacityUnits': 5,
150 'WriteCapacityUnits': 5
152 GlobalSecondaryIndexes=[
154 'IndexName': 'channel_id_index',
156 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
159 'ProjectionType': 'ALL'
161 'ProvisionedThroughput': {
162 'ReadCapacityUnits': 5,
163 'WriteCapacityUnits': 5
167 'IndexName': 'is_hunt_index',
169 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
172 'ProjectionType': 'ALL'
174 'ProvisionedThroughput': {
175 'ReadCapacityUnits': 5,
176 'WriteCapacityUnits': 5
180 LocalSecondaryIndexes = [
182 'IndexName': 'url_index',
184 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
185 {'AttributeName': 'url', 'KeyType': 'RANGE'},
188 'ProjectionType': 'ALL'
193 return submission_error(
195 "Still bootstrapping turbot table. Try again in a minute, please.")
197 # Create a channel for the hunt
199 response = turb.slack_client.conversations_create(name=hunt_id)
200 except SlackApiError as e:
201 return submission_error("hunt_id",
202 "Error creating Slack channel: {}"
203 .format(e.response['error']))
205 channel_id = response['channel']['id']
207 # Insert the newly-created hunt into the database
208 # (leaving it as non-active for now until the channel-created handler
209 # finishes fixing it up with a sheet and a companion table)
212 "SK": "hunt-{}".format(hunt_id),
214 "channel_id": channel_id,
220 turb.table.put_item(Item=item)
222 # Invite the initiating user to the channel
223 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
227 def view_submission(turb, payload):
228 """Handler for Slack interactive view submission
230 Specifically, those that have a payload type of 'view_submission'"""
232 view_id = payload['view']['id']
233 metadata = payload['view']['private_metadata']
235 if view_id in submission_handlers:
236 return submission_handlers[view_id](turb, payload, metadata)
238 print("Error: Unknown view ID: {}".format(view_id))
243 def rot(turb, body, args):
244 """Implementation of the /rot command
246 The args string should be as follows:
248 [count|*] String to be rotated
250 That is, the first word of the string is an optional number (or
251 the character '*'). If this is a number it indicates an amount to
252 rotate each character in the string. If the count is '*' or is not
253 present, then the string will be rotated through all possible 25
256 The result of the rotation is returned (with Slack formatting) in
257 the body of the response so that Slack will provide it as a reply
258 to the user who submitted the slash command."""
260 channel_name = body['channel_name'][0]
261 response_url = body['response_url'][0]
262 channel_id = body['channel_id'][0]
264 result = turbot.rot.rot(args)
266 if (channel_name == "directmessage"):
267 requests.post(response_url,
268 json = {"text": result},
269 headers = {"Content-type": "application/json"})
271 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
275 commands["/rot"] = rot
277 def get_table_item(turb, table_name, key, value):
278 """Get an item from the database 'table_name' with 'key' as 'value'
280 Returns a tuple of (item, table) if found and (None, None) otherwise."""
282 table = turb.db.Table(table_name)
284 response = table.get_item(Key={key: value})
286 if 'Item' in response:
287 return (response['Item'], table)
291 def db_entry_for_channel(turb, channel_id):
292 """Given a channel ID return the database item for this channel
294 If this channel is a registered hunt or puzzle channel, return the
295 corresponding row from the database for this channel. Otherwise,
298 Note: If you need to specifically ensure that the channel is a
299 puzzle or a hunt, please call puzzle_for_channel or
300 hunt_for_channel respectively.
303 response = turb.table.query(
304 IndexName = "channel_id_index",
305 KeyConditionExpression=Key("channel_id").eq(channel_id)
308 if response['Count'] == 0:
311 return response['Items'][0]
314 def puzzle_for_channel(turb, channel_id):
316 """Given a channel ID return the puzzle from the database for this channel
318 If the given channel_id is a puzzle's channel, this function
319 returns a dict filled with the attributes from the puzzle's entry
322 Otherwise, this function returns None.
325 entry = db_entry_for_channel(turb, channel_id)
327 if entry and entry['SK'].startswith('puzzle-'):
332 def hunt_for_channel(turb, channel_id):
334 """Given a channel ID return the hunt from the database for this channel
336 This works whether the original channel is a primary hunt channel,
337 or if it is one of the channels of a puzzle belonging to the hunt.
339 Returns None if channel does not belong to a hunt, otherwise a
340 dictionary with all fields from the hunt's row in the table,
341 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
344 entry = db_entry_for_channel(turb, channel_id)
346 # We're done if this channel doesn't exist in the database at all
350 # Also done if this channel is a hunt channel
351 if entry['SK'].startswith('hunt-'):
354 # Otherwise, (the channel is in the database, but is not a hunt),
355 # we expect this to be a puzzle channel instead
356 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
358 # python3.9 has a built-in removeprefix but AWS only has python3.8
359 def remove_prefix(text, prefix):
360 if text.startswith(prefix):
361 return text[len(prefix):]
364 def hunt_rounds(turb, hunt_id):
365 """Returns array of strings giving rounds that exist in the given hunt"""
367 response = turb.table.query(
368 KeyConditionExpression=(
369 Key('hunt_id').eq(hunt_id) &
370 Key('SK').begins_with('round-')
374 if response['Count'] == 0:
377 return [remove_prefix(option['SK'], 'round-')
378 for option in response['Items']]
380 def puzzle(turb, body, args):
381 """Implementation of the /puzzle command
383 The args string is currently ignored (this command will bring up
384 a modal dialog for user input instead)."""
386 channel_id = body['channel_id'][0]
387 trigger_id = body['trigger_id'][0]
389 hunt = hunt_for_channel(turb, channel_id)
392 return bot_reply("Sorry, this channel doesn't appear to "
393 + "be a hunt or puzzle channel")
395 round_options = hunt_rounds(turb, hunt['hunt_id'])
397 if len(round_options):
398 round_options_block = [
399 multi_select_block("Round(s)", "rounds",
400 "Existing round(s) this puzzle belongs to",
404 round_options_block = []
408 "private_metadata": json.dumps({
409 "hunt_id": hunt['hunt_id'],
411 "title": {"type": "plain_text", "text": "New Puzzle"},
412 "submit": { "type": "plain_text", "text": "Create" },
414 section_block(text_block("*For {}*".format(hunt['name']))),
415 input_block("Puzzle name", "name", "Name of the puzzle"),
416 input_block("Puzzle URL", "url", "External URL of puzzle",
418 * round_options_block,
419 input_block("New round(s)", "new_rounds",
420 "New round(s) this puzzle belongs to " +
426 result = turb.slack_client.views_open(trigger_id=trigger_id,
430 submission_handlers[result['view']['id']] = puzzle_submission
434 commands["/puzzle"] = puzzle
436 def puzzle_submission(turb, payload, metadata):
437 """Handler for the user submitting the new puzzle modal
439 This is the modal view presented to the user by the puzzle function
442 # First, read all the various data from the request
443 meta = json.loads(metadata)
444 hunt_id = meta['hunt_id']
446 state = payload['view']['state']['values']
447 name = state['name']['name']['value']
448 url = state['url']['url']['value']
449 if 'rounds' in state:
450 rounds = [option['value'] for option in
451 state['rounds']['rounds']['selected_options']]
454 new_rounds = state['new_rounds']['new_rounds']['value']
456 # Before doing anything, reject this puzzle if a puzzle already
457 # exists with the same URL.
459 existing = find_puzzle_for_url(turb, hunt_id, url)
461 return submission_error(
463 "Error: A puzzle with this URL already exists.")
465 # Create a Slack-channel-safe puzzle_id
466 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
468 # Create a channel for the puzzle
469 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
472 response = turb.slack_client.conversations_create(
473 name=hunt_dash_channel)
474 except SlackApiError as e:
475 return submission_error(
477 "Error creating Slack channel {}: {}"
478 .format(hunt_dash_channel, e.response['error']))
480 channel_id = response['channel']['id']
482 # Add any new rounds to the database
484 for round in new_rounds.split(','):
485 # Drop any leading/trailing spaces from the round name
486 round = round.strip()
487 # Ignore any empty string
494 'SK': 'round-' + round
498 # Insert the newly-created puzzle into the database
501 "SK": "puzzle-{}".format(puzzle_id),
502 "puzzle_id": puzzle_id,
503 "channel_id": channel_id,
505 "status": 'unsolved',
511 item['rounds'] = rounds
512 turb.table.put_item(Item=item)
516 # XXX: This duplicates functionality eith events.py:set_channel_description
517 def set_channel_topic(turb, puzzle):
518 channel_id = puzzle['channel_id']
519 name = puzzle['name']
520 url = puzzle.get('url', None)
521 sheet_url = puzzle.get('sheet_url', None)
522 state = puzzle.get('state', None)
523 status = puzzle['status']
527 if status == 'solved':
528 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
534 links.append("<{}|Puzzle>".format(url))
536 links.append("<{}|Sheet>".format(sheet_url))
539 description += "({})".format(', '.join(links))
542 description += " {}".format(state)
544 # Slack only allows 250 characters for a topic
545 if len(description) > 250:
546 description = description[:247] + "..."
548 turb.slack_client.conversations_setTopic(channel=channel_id,
551 def state(turb, body, args):
552 """Implementation of the /state command
554 The args string should be a brief sentence describing where things
555 stand or what's needed."""
557 channel_id = body['channel_id'][0]
559 puzzle = puzzle_for_channel(turb, channel_id)
563 "Sorry, the /state command only works in a puzzle channel")
565 # Set the state field in the database
566 puzzle['state'] = args
567 turb.table.put_item(Item=puzzle)
569 set_channel_topic(turb, puzzle)
573 commands["/state"] = state
575 def solved(turb, body, args):
576 """Implementation of the /solved command
578 The args string should be a confirmed solution."""
580 channel_id = body['channel_id'][0]
581 user_name = body['user_name'][0]
583 puzzle = puzzle_for_channel(turb, channel_id)
586 return bot_reply("Sorry, this is not a puzzle channel.")
590 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
592 # Set the status and solution fields in the database
593 puzzle['status'] = 'solved'
594 puzzle['solution'].append(args)
595 if 'state' in puzzle:
597 turb.table.put_item(Item=puzzle)
599 # Report the solution to the puzzle's channel
601 turb.slack_client, channel_id,
602 "Puzzle mark solved by {}: `{}`".format(user_name, args))
604 # Also report the solution to the hunt channel
605 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
607 turb.slack_client, hunt['channel_id'],
608 "Puzzle <{}|{}> has been solved!".format(
609 puzzle['channel_url'],
613 # And update the puzzle's description
614 set_channel_topic(turb, puzzle)
616 # And rename the sheet to suffix with "-SOLVED"
617 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
618 puzzle['name'] + "-SOLVED")
620 # Finally, rename the Slack channel to add the suffix '-solved'
621 channel_name = "{}-{}-solved".format(
624 turb.slack_client.conversations_rename(
625 channel=puzzle['channel_id'],
630 commands["/solved"] = solved
633 def hunt(turb, body, args):
634 """Implementation of the /hunt command
636 The (optional) args string can be used to filter which puzzles to
637 display. The first word can be one of 'all', 'unsolved', or
638 'solved' and can be used to display only puzzles with the given
639 status. Any remaining text in the args string will be interpreted
640 as search terms. These will be split into separate terms on space
641 characters, (though quotation marks can be used to include a space
642 character in a term). All terms must match on a puzzle in order
643 for that puzzle to be included. But a puzzle will be considered to
644 match if any of the puzzle title, round title, puzzle URL, puzzle
645 state, or puzzle solution match. Matching will be performed
646 without regard to case sensitivity and the search terms can
647 include regular expression syntax.
650 channel_id = body['channel_id'][0]
651 response_url = body['response_url'][0]
655 # The first word can be a puzzle status and all remaining word
656 # (if any) are search terms. _But_, if the first word is not a
657 # valid puzzle status ('all', 'unsolved', 'solved'), then all
658 # words are search terms and we default status to 'unsolved'.
659 split_args = args.split(' ', 1)
660 status = split_args[0]
661 if (len(split_args) > 1):
662 terms = split_args[1]
663 if status not in ('unsolved', 'solved', 'all'):
669 # Separate search terms on spaces (but allow for quotation marks
670 # to capture spaces in a search term)
672 terms = shlex.split(terms)
674 hunt = hunt_for_channel(turb, channel_id)
677 return bot_reply("Sorry, this channel doesn't appear to "
678 + "be a hunt or puzzle channel")
680 blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
682 requests.post(response_url,
683 json = { 'blocks': blocks },
684 headers = {'Content-type': 'application/json'}
689 commands["/hunt"] = hunt