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
12 from botocore.exceptions import ClientError
13 from boto3.dynamodb.conditions import Key
14 from turbot.slack import slack_send_message
18 submission_handlers = {}
20 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
21 valid_id_re = r'^[_a-z0-9]+$'
23 lambda_ok = {'statusCode': 200}
25 def bot_reply(message):
26 """Construct a return value suitable for a bot reply
28 This is suitable as a way to give an error back to the user who
29 initiated a slash command, for example."""
36 def submission_error(field, error):
37 """Construct an error suitable for returning for an invalid submission.
39 Returning this value will prevent a submission and alert the user that
40 the given field is invalid because of the given error."""
42 print("Rejecting invalid modal submission: {}".format(error))
47 "Content-Type": "application/json"
50 "response_action": "errors",
57 def multi_static_select(turb, payload):
58 """Handler for the action of user entering a multi-select value"""
62 actions['multi_static_select'] = {"*": multi_static_select}
64 def new_hunt(turb, payload):
65 """Handler for the action of user pressing the new_hunt button"""
69 "private_metadata": json.dumps({}),
70 "title": { "type": "plain_text", "text": "New Hunt" },
71 "submit": { "type": "plain_text", "text": "Create" },
73 input_block("Hunt name", "name", "Name of the hunt"),
74 input_block("Hunt ID", "hunt_id",
75 "Used as puzzle channel prefix "
76 + "(no spaces nor punctuation)"),
77 input_block("Hunt URL", "url", "External URL of hunt",
82 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
85 submission_handlers[result['view']['id']] = new_hunt_submission
89 actions['button'] = {"new_hunt": new_hunt}
91 def new_hunt_submission(turb, payload, metadata):
92 """Handler for the user submitting the new hunt modal
94 This is the modal view presented to the user by the new_hunt
97 state = payload['view']['state']['values']
98 user_id = payload['user']['id']
99 name = state['name']['name']['value']
100 hunt_id = state['hunt_id']['hunt_id']['value']
101 url = state['url']['url']['value']
103 # Validate that the hunt_id contains no invalid characters
104 if not re.match(valid_id_re, hunt_id):
105 return submission_error("hunt_id",
106 "Hunt ID can only contain lowercase letters, "
107 + "numbers, and underscores")
109 # Check to see if the turbot table exists
111 exists = turb.table.table_status in ("CREATING", "UPDATING",
116 # Create the turbot table if necessary.
118 turb.table = turb.db.create_table(
121 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
122 {'AttributeName': 'SK', 'KeyType': 'RANGE'},
124 AttributeDefinitions=[
125 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
126 {'AttributeName': 'SK', 'AttributeType': 'S'},
127 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
128 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
130 ProvisionedThroughput={
131 'ReadCapacityUnits': 5,
132 'WriteCapacityUnits': 5
134 GlobalSecondaryIndexes=[
136 'IndexName': 'channel_id_index',
138 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
141 'ProjectionType': 'ALL'
143 'ProvisionedThroughput': {
144 'ReadCapacityUnits': 5,
145 'WriteCapacityUnits': 5
149 'IndexName': 'is_hunt_index',
151 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
154 'ProjectionType': 'ALL'
156 'ProvisionedThroughput': {
157 'ReadCapacityUnits': 5,
158 'WriteCapacityUnits': 5
163 return submission_error(
165 "Still bootstrapping turbot table. Try again in a minute, please.")
167 # Create a channel for the hunt
169 response = turb.slack_client.conversations_create(name=hunt_id)
170 except SlackApiError as e:
171 return submission_error("hunt_id",
172 "Error creating Slack channel: {}"
173 .format(e.response['error']))
175 channel_id = response['channel']['id']
177 # Insert the newly-created hunt into the database
178 # (leaving it as non-active for now until the channel-created handler
179 # finishes fixing it up with a sheet and a companion table)
182 "SK": "hunt-{}".format(hunt_id),
184 "channel_id": channel_id,
190 turb.table.put_item(Item=item)
192 # Invite the initiating user to the channel
193 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
197 def view_submission(turb, payload):
198 """Handler for Slack interactive view submission
200 Specifically, those that have a payload type of 'view_submission'"""
202 view_id = payload['view']['id']
203 metadata = payload['view']['private_metadata']
205 if view_id in submission_handlers:
206 return submission_handlers[view_id](turb, payload, metadata)
208 print("Error: Unknown view ID: {}".format(view_id))
213 def rot(turb, body, args):
214 """Implementation of the /rot command
216 The args string should be as follows:
218 [count|*] String to be rotated
220 That is, the first word of the string is an optional number (or
221 the character '*'). If this is a number it indicates an amount to
222 rotate each character in the string. If the count is '*' or is not
223 present, then the string will be rotated through all possible 25
226 The result of the rotation is returned (with Slack formatting) in
227 the body of the response so that Slack will provide it as a reply
228 to the user who submitted the slash command."""
230 channel_name = body['channel_name'][0]
231 response_url = body['response_url'][0]
232 channel_id = body['channel_id'][0]
234 result = turbot.rot.rot(args)
236 if (channel_name == "directmessage"):
237 requests.post(response_url,
238 json = {"text": result},
239 headers = {"Content-type": "application/json"})
241 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
245 commands["/rot"] = rot
247 def get_table_item(turb, table_name, key, value):
248 """Get an item from the database 'table_name' with 'key' as 'value'
250 Returns a tuple of (item, table) if found and (None, None) otherwise."""
252 table = turb.db.Table(table_name)
254 response = table.get_item(Key={key: value})
256 if 'Item' in response:
257 return (response['Item'], table)
261 def db_entry_for_channel(turb, channel_id):
262 """Given a channel ID return the database item for this channel
264 If this channel is a registered hunt or puzzle channel, return the
265 corresponding row from the database for this channel. Otherwise,
268 Note: If you need to specifically ensure that the channel is a
269 puzzle or a hunt, please call puzzle_for_channel or
270 hunt_for_channel respectively.
273 response = turb.table.query(
274 IndexName = "channel_id_index",
275 KeyConditionExpression=Key("channel_id").eq(channel_id)
278 if response['Count'] == 0:
281 return response['Items'][0]
284 def puzzle_for_channel(turb, channel_id):
286 """Given a channel ID return the puzzle from the database for this channel
288 If the given channel_id is a puzzle's channel, this function
289 returns a dict filled with the attributes from the puzzle's entry
292 Otherwise, this function returns None.
295 entry = db_entry_for_channel(turb, channel_id)
297 if entry and entry['SK'].startswith('puzzle-'):
302 def hunt_for_channel(turb, channel_id):
304 """Given a channel ID return the hunt from the database for this channel
306 This works whether the original channel is a primary hunt channel,
307 or if it is one of the channels of a puzzle belonging to the hunt.
309 Returns None if channel does not belong to a hunt, otherwise a
310 dictionary with all fields from the hunt's row in the table,
311 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
314 entry = db_entry_for_channel(turb, channel_id)
316 # We're done if this channel doesn't exist in the database at all
320 # Also done if this channel is a hunt channel
321 if entry['SK'].startswith('hunt-'):
324 # Otherwise, (the channel is in the database, but is not a hunt),
325 # we expect this to be a puzzle channel instead
326 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
328 # python3.9 has a built-in removeprefix but AWS only has python3.8
329 def remove_prefix(text, prefix):
330 if text.startswith(prefix):
331 return text[len(prefix):]
334 def hunt_rounds(turb, hunt_id):
335 """Returns array of strings giving rounds that exist in the given hunt"""
337 response = turb.table.query(
338 KeyConditionExpression=(
339 Key('hunt_id').eq(hunt_id) &
340 Key('SK').begins_with('round-')
344 if response['Count'] == 0:
347 return [remove_prefix(option['SK'], 'round-')
348 for option in response['Items']]
350 def puzzle(turb, body, args):
351 """Implementation of the /puzzle command
353 The args string is currently ignored (this command will bring up
354 a modal dialog for user input instead)."""
356 channel_id = body['channel_id'][0]
357 trigger_id = body['trigger_id'][0]
359 hunt = hunt_for_channel(turb, channel_id)
362 return bot_reply("Sorry, this channel doesn't appear to "
363 + "be a hunt or puzzle channel")
365 round_options = hunt_rounds(turb, hunt['hunt_id'])
367 if len(round_options):
368 round_options_block = [
369 multi_select_block("Round(s)", "rounds",
370 "Existing round(s) this puzzle belongs to",
374 round_options_block = []
378 "private_metadata": json.dumps({
379 "hunt_id": hunt['hunt_id'],
381 "title": {"type": "plain_text", "text": "New Puzzle"},
382 "submit": { "type": "plain_text", "text": "Create" },
384 section_block(text_block("*For {}*".format(hunt['name']))),
385 input_block("Puzzle name", "name", "Name of the puzzle"),
386 input_block("Puzzle URL", "url", "External URL of puzzle",
388 * round_options_block,
389 input_block("New round(s)", "new_rounds",
390 "New round(s) this puzzle belongs to " +
396 result = turb.slack_client.views_open(trigger_id=trigger_id,
400 submission_handlers[result['view']['id']] = puzzle_submission
404 commands["/puzzle"] = puzzle
406 def puzzle_submission(turb, payload, metadata):
407 """Handler for the user submitting the new puzzle modal
409 This is the modal view presented to the user by the puzzle function
412 meta = json.loads(metadata)
413 hunt_id = meta['hunt_id']
415 state = payload['view']['state']['values']
416 name = state['name']['name']['value']
417 url = state['url']['url']['value']
418 if 'rounds' in state:
419 rounds = [option['value'] for option in
420 state['rounds']['rounds']['selected_options']]
423 new_rounds = state['new_rounds']['new_rounds']['value']
425 # Create a Slack-channel-safe puzzle_id
426 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
428 # Create a channel for the puzzle
429 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
432 response = turb.slack_client.conversations_create(
433 name=hunt_dash_channel)
434 except SlackApiError as e:
435 return submission_error(
437 "Error creating Slack channel {}: {}"
438 .format(hunt_dash_channel, e.response['error']))
440 channel_id = response['channel']['id']
442 # Add any new rounds to the database
444 for round in new_rounds.split(','):
449 'SK': 'round-' + round
453 # Insert the newly-created puzzle into the database
456 "SK": "puzzle-{}".format(puzzle_id),
457 "puzzle_id": puzzle_id,
458 "channel_id": channel_id,
460 "status": 'unsolved',
466 item['rounds'] = rounds
467 turb.table.put_item(Item=item)
471 # XXX: This duplicates functionality eith events.py:set_channel_description
472 def set_channel_topic(turb, puzzle):
473 channel_id = puzzle['channel_id']
474 name = puzzle['name']
475 url = puzzle.get('url', None)
476 sheet_url = puzzle.get('sheet_url', None)
477 state = puzzle.get('state', None)
478 status = puzzle['status']
482 if status == 'solved':
483 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
489 links.append("<{}|Puzzle>".format(url))
491 links.append("<{}|Sheet>".format(sheet_url))
494 description += "({})".format(', '.join(links))
497 description += " {}".format(state)
499 turb.slack_client.conversations_setTopic(channel=channel_id,
502 def state(turb, body, args):
503 """Implementation of the /state command
505 The args string should be a brief sentence describing where things
506 stand or what's needed."""
508 channel_id = body['channel_id'][0]
510 puzzle = puzzle_for_channel(turb, channel_id)
514 "Sorry, the /state command only works in a puzzle channel")
516 # Set the state field in the database
517 puzzle['state'] = args
518 turb.table.put_item(Item=puzzle)
520 set_channel_topic(turb, puzzle)
524 commands["/state"] = state
526 def solved(turb, body, args):
527 """Implementation of the /solved command
529 The args string should be a confirmed solution."""
531 channel_id = body['channel_id'][0]
532 user_name = body['user_name'][0]
534 puzzle = puzzle_for_channel(turb, channel_id)
537 return bot_reply("Sorry, this is not a puzzle channel.")
539 # Set the status and solution fields in the database
540 puzzle['status'] = 'solved'
541 puzzle['solution'].append(args)
542 turb.table.put_item(Item=puzzle)
544 # Report the solution to the puzzle's channel
546 turb.slack_client, channel_id,
547 "Puzzle mark solved by {}: `{}`".format(user_name, args))
549 # Also report the solution to the hunt channel
550 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
552 turb.slack_client, hunt['channel_id'],
553 "Puzzle <{}|{}> has been solved!".format(
554 puzzle['channel_url'],
558 # And update the puzzle's description
559 set_channel_topic(turb, puzzle)
561 # And rename the sheet to prefix with "SOLVED: "
562 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
563 'SOLVED: ' + puzzle['name'])
565 # Finally, rename the Slack channel to add the suffix '-solved'
566 channel_name = "{}-{}-solved".format(
569 turb.slack_client.conversations_rename(
570 channel=puzzle['channel_id'],
575 commands["/solved"] = solved