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
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 submission_handlers = {}
21 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
22 valid_id_re = r'^[_a-z0-9]+$'
24 lambda_ok = {'statusCode': 200}
26 def bot_reply(message):
27 """Construct a return value suitable for a bot reply
29 This is suitable as a way to give an error back to the user who
30 initiated a slash command, for example."""
37 def submission_error(field, error):
38 """Construct an error suitable for returning for an invalid submission.
40 Returning this value will prevent a submission and alert the user that
41 the given field is invalid because of the given error."""
43 print("Rejecting invalid modal submission: {}".format(error))
48 "Content-Type": "application/json"
51 "response_action": "errors",
58 def multi_static_select(turb, payload):
59 """Handler for the action of user entering a multi-select value"""
63 actions['multi_static_select'] = {"*": multi_static_select}
65 def new_hunt(turb, payload):
66 """Handler for the action of user pressing the new_hunt button"""
70 "private_metadata": json.dumps({}),
71 "title": { "type": "plain_text", "text": "New Hunt" },
72 "submit": { "type": "plain_text", "text": "Create" },
74 input_block("Hunt name", "name", "Name of the hunt"),
75 input_block("Hunt ID", "hunt_id",
76 "Used as puzzle channel prefix "
77 + "(no spaces nor punctuation)"),
78 input_block("Hunt URL", "url", "External URL of hunt",
83 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
86 submission_handlers[result['view']['id']] = new_hunt_submission
90 actions['button'] = {"new_hunt": new_hunt}
92 def new_hunt_submission(turb, payload, metadata):
93 """Handler for the user submitting the new hunt modal
95 This is the modal view presented to the user by the new_hunt
98 state = payload['view']['state']['values']
99 user_id = payload['user']['id']
100 name = state['name']['name']['value']
101 hunt_id = state['hunt_id']['hunt_id']['value']
102 url = state['url']['url']['value']
104 # Validate that the hunt_id contains no invalid characters
105 if not re.match(valid_id_re, hunt_id):
106 return submission_error("hunt_id",
107 "Hunt ID can only contain lowercase letters, "
108 + "numbers, and underscores")
110 # Check to see if the turbot table exists
112 exists = turb.table.table_status in ("CREATING", "UPDATING",
117 # Create the turbot table if necessary.
119 turb.table = turb.db.create_table(
122 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
123 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
125 AttributeDefinitions=[
126 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
127 {'AttributeName': 'SK', 'AttributeType': 'S'},
128 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
129 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
130 {'AttributeName': 'url', 'AttributeType': 'S'}
132 ProvisionedThroughput={
133 'ReadCapacityUnits': 5,
134 'WriteCapacityUnits': 5
136 GlobalSecondaryIndexes=[
138 'IndexName': 'channel_id_index',
140 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
143 'ProjectionType': 'ALL'
145 'ProvisionedThroughput': {
146 'ReadCapacityUnits': 5,
147 'WriteCapacityUnits': 5
151 'IndexName': 'is_hunt_index',
153 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
156 'ProjectionType': 'ALL'
158 'ProvisionedThroughput': {
159 'ReadCapacityUnits': 5,
160 'WriteCapacityUnits': 5
164 LocalSecondaryIndexes = [
166 'IndexName': 'url_index',
168 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
169 {'AttributeName': 'url', 'KeyType': 'RANGE'},
172 'ProjectionType': 'ALL'
177 return submission_error(
179 "Still bootstrapping turbot table. Try again in a minute, please.")
181 # Create a channel for the hunt
183 response = turb.slack_client.conversations_create(name=hunt_id)
184 except SlackApiError as e:
185 return submission_error("hunt_id",
186 "Error creating Slack channel: {}"
187 .format(e.response['error']))
189 channel_id = response['channel']['id']
191 # Insert the newly-created hunt into the database
192 # (leaving it as non-active for now until the channel-created handler
193 # finishes fixing it up with a sheet and a companion table)
196 "SK": "hunt-{}".format(hunt_id),
198 "channel_id": channel_id,
204 turb.table.put_item(Item=item)
206 # Invite the initiating user to the channel
207 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
211 def view_submission(turb, payload):
212 """Handler for Slack interactive view submission
214 Specifically, those that have a payload type of 'view_submission'"""
216 view_id = payload['view']['id']
217 metadata = payload['view']['private_metadata']
219 if view_id in submission_handlers:
220 return submission_handlers[view_id](turb, payload, metadata)
222 print("Error: Unknown view ID: {}".format(view_id))
227 def rot(turb, body, args):
228 """Implementation of the /rot command
230 The args string should be as follows:
232 [count|*] String to be rotated
234 That is, the first word of the string is an optional number (or
235 the character '*'). If this is a number it indicates an amount to
236 rotate each character in the string. If the count is '*' or is not
237 present, then the string will be rotated through all possible 25
240 The result of the rotation is returned (with Slack formatting) in
241 the body of the response so that Slack will provide it as a reply
242 to the user who submitted the slash command."""
244 channel_name = body['channel_name'][0]
245 response_url = body['response_url'][0]
246 channel_id = body['channel_id'][0]
248 result = turbot.rot.rot(args)
250 if (channel_name == "directmessage"):
251 requests.post(response_url,
252 json = {"text": result},
253 headers = {"Content-type": "application/json"})
255 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
259 commands["/rot"] = rot
261 def get_table_item(turb, table_name, key, value):
262 """Get an item from the database 'table_name' with 'key' as 'value'
264 Returns a tuple of (item, table) if found and (None, None) otherwise."""
266 table = turb.db.Table(table_name)
268 response = table.get_item(Key={key: value})
270 if 'Item' in response:
271 return (response['Item'], table)
275 def db_entry_for_channel(turb, channel_id):
276 """Given a channel ID return the database item for this channel
278 If this channel is a registered hunt or puzzle channel, return the
279 corresponding row from the database for this channel. Otherwise,
282 Note: If you need to specifically ensure that the channel is a
283 puzzle or a hunt, please call puzzle_for_channel or
284 hunt_for_channel respectively.
287 response = turb.table.query(
288 IndexName = "channel_id_index",
289 KeyConditionExpression=Key("channel_id").eq(channel_id)
292 if response['Count'] == 0:
295 return response['Items'][0]
298 def puzzle_for_channel(turb, channel_id):
300 """Given a channel ID return the puzzle from the database for this channel
302 If the given channel_id is a puzzle's channel, this function
303 returns a dict filled with the attributes from the puzzle's entry
306 Otherwise, this function returns None.
309 entry = db_entry_for_channel(turb, channel_id)
311 if entry and entry['SK'].startswith('puzzle-'):
316 def hunt_for_channel(turb, channel_id):
318 """Given a channel ID return the hunt from the database for this channel
320 This works whether the original channel is a primary hunt channel,
321 or if it is one of the channels of a puzzle belonging to the hunt.
323 Returns None if channel does not belong to a hunt, otherwise a
324 dictionary with all fields from the hunt's row in the table,
325 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
328 entry = db_entry_for_channel(turb, channel_id)
330 # We're done if this channel doesn't exist in the database at all
334 # Also done if this channel is a hunt channel
335 if entry['SK'].startswith('hunt-'):
338 # Otherwise, (the channel is in the database, but is not a hunt),
339 # we expect this to be a puzzle channel instead
340 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
342 # python3.9 has a built-in removeprefix but AWS only has python3.8
343 def remove_prefix(text, prefix):
344 if text.startswith(prefix):
345 return text[len(prefix):]
348 def hunt_rounds(turb, hunt_id):
349 """Returns array of strings giving rounds that exist in the given hunt"""
351 response = turb.table.query(
352 KeyConditionExpression=(
353 Key('hunt_id').eq(hunt_id) &
354 Key('SK').begins_with('round-')
358 if response['Count'] == 0:
361 return [remove_prefix(option['SK'], 'round-')
362 for option in response['Items']]
364 def puzzle(turb, body, args):
365 """Implementation of the /puzzle command
367 The args string is currently ignored (this command will bring up
368 a modal dialog for user input instead)."""
370 channel_id = body['channel_id'][0]
371 trigger_id = body['trigger_id'][0]
373 hunt = hunt_for_channel(turb, channel_id)
376 return bot_reply("Sorry, this channel doesn't appear to "
377 + "be a hunt or puzzle channel")
379 round_options = hunt_rounds(turb, hunt['hunt_id'])
381 if len(round_options):
382 round_options_block = [
383 multi_select_block("Round(s)", "rounds",
384 "Existing round(s) this puzzle belongs to",
388 round_options_block = []
392 "private_metadata": json.dumps({
393 "hunt_id": hunt['hunt_id'],
395 "title": {"type": "plain_text", "text": "New Puzzle"},
396 "submit": { "type": "plain_text", "text": "Create" },
398 section_block(text_block("*For {}*".format(hunt['name']))),
399 input_block("Puzzle name", "name", "Name of the puzzle"),
400 input_block("Puzzle URL", "url", "External URL of puzzle",
402 * round_options_block,
403 input_block("New round(s)", "new_rounds",
404 "New round(s) this puzzle belongs to " +
410 result = turb.slack_client.views_open(trigger_id=trigger_id,
414 submission_handlers[result['view']['id']] = puzzle_submission
418 commands["/puzzle"] = puzzle
420 def puzzle_submission(turb, payload, metadata):
421 """Handler for the user submitting the new puzzle modal
423 This is the modal view presented to the user by the puzzle function
426 # First, read all the various data from the request
427 meta = json.loads(metadata)
428 hunt_id = meta['hunt_id']
430 state = payload['view']['state']['values']
431 name = state['name']['name']['value']
432 url = state['url']['url']['value']
433 if 'rounds' in state:
434 rounds = [option['value'] for option in
435 state['rounds']['rounds']['selected_options']]
438 new_rounds = state['new_rounds']['new_rounds']['value']
440 # Before doing anything, reject this puzzle if a puzzle already
441 # exists with the same URL.
443 existing = find_puzzle_for_url(turb, hunt_id, url)
445 return submission_error(
447 "Error: A puzzle with this URL already exists.")
449 # Create a Slack-channel-safe puzzle_id
450 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
452 # Create a channel for the puzzle
453 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
456 response = turb.slack_client.conversations_create(
457 name=hunt_dash_channel)
458 except SlackApiError as e:
459 return submission_error(
461 "Error creating Slack channel {}: {}"
462 .format(hunt_dash_channel, e.response['error']))
464 channel_id = response['channel']['id']
466 # Add any new rounds to the database
468 for round in new_rounds.split(','):
473 'SK': 'round-' + round
477 # Insert the newly-created puzzle into the database
480 "SK": "puzzle-{}".format(puzzle_id),
481 "puzzle_id": puzzle_id,
482 "channel_id": channel_id,
484 "status": 'unsolved',
490 item['rounds'] = rounds
491 turb.table.put_item(Item=item)
495 # XXX: This duplicates functionality eith events.py:set_channel_description
496 def set_channel_topic(turb, puzzle):
497 channel_id = puzzle['channel_id']
498 name = puzzle['name']
499 url = puzzle.get('url', None)
500 sheet_url = puzzle.get('sheet_url', None)
501 state = puzzle.get('state', None)
502 status = puzzle['status']
506 if status == 'solved':
507 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
513 links.append("<{}|Puzzle>".format(url))
515 links.append("<{}|Sheet>".format(sheet_url))
518 description += "({})".format(', '.join(links))
521 description += " {}".format(state)
523 # Slack only allows 250 characters for a topic
524 if len(description) > 250:
525 description = description[:247] + "..."
527 turb.slack_client.conversations_setTopic(channel=channel_id,
530 def state(turb, body, args):
531 """Implementation of the /state command
533 The args string should be a brief sentence describing where things
534 stand or what's needed."""
536 channel_id = body['channel_id'][0]
538 puzzle = puzzle_for_channel(turb, channel_id)
542 "Sorry, the /state command only works in a puzzle channel")
544 # Set the state field in the database
545 puzzle['state'] = args
546 turb.table.put_item(Item=puzzle)
548 set_channel_topic(turb, puzzle)
552 commands["/state"] = state
554 def solved(turb, body, args):
555 """Implementation of the /solved command
557 The args string should be a confirmed solution."""
559 channel_id = body['channel_id'][0]
560 user_name = body['user_name'][0]
562 puzzle = puzzle_for_channel(turb, channel_id)
565 return bot_reply("Sorry, this is not a puzzle channel.")
569 "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
571 # Set the status and solution fields in the database
572 puzzle['status'] = 'solved'
573 puzzle['solution'].append(args)
575 turb.table.put_item(Item=puzzle)
577 # Report the solution to the puzzle's channel
579 turb.slack_client, channel_id,
580 "Puzzle mark solved by {}: `{}`".format(user_name, args))
582 # Also report the solution to the hunt channel
583 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
585 turb.slack_client, hunt['channel_id'],
586 "Puzzle <{}|{}> has been solved!".format(
587 puzzle['channel_url'],
591 # And update the puzzle's description
592 set_channel_topic(turb, puzzle)
594 # And rename the sheet to prefix with "SOLVED: "
595 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
596 'SOLVED: ' + puzzle['name'])
598 # Finally, rename the Slack channel to add the suffix '-solved'
599 channel_name = "{}-{}-solved".format(
602 turb.slack_client.conversations_rename(
603 channel=puzzle['channel_id'],
608 commands["/solved"] = solved