1 from slack.errors import SlackApiError
2 from turbot.blocks import input_block, section_block, text_block
3 from turbot.hunt import find_hunt_for_hunt_id
10 from botocore.exceptions import ClientError
11 from boto3.dynamodb.conditions import Key
12 from turbot.slack import slack_send_message
16 submission_handlers = {}
18 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
19 valid_id_re = r'^[_a-z0-9]+$'
21 lambda_ok = {'statusCode': 200}
23 def bot_reply(message):
24 """Construct a return value suitable for a bot reply
26 This is suitable as a way to give an error back to the user who
27 initiated a slash command, for example."""
34 def submission_error(field, error):
35 """Construct an error suitable for returning for an invalid submission.
37 Returning this value will prevent a submission and alert the user that
38 the given field is invalid because of the given error."""
40 print("Rejecting invalid modal submission: {}".format(error))
45 "Content-Type": "application/json"
48 "response_action": "errors",
55 def multi_static_select(turb, payload):
56 """Handler for the action of user entering a multi-select value"""
60 actions['multi_static_select'] = {"*": multi_static_select}
62 def new_hunt(turb, payload):
63 """Handler for the action of user pressing the new_hunt button"""
67 "private_metadata": json.dumps({}),
68 "title": { "type": "plain_text", "text": "New Hunt" },
69 "submit": { "type": "plain_text", "text": "Create" },
71 input_block("Hunt name", "name", "Name of the hunt"),
72 input_block("Hunt ID", "hunt_id",
73 "Used as puzzle channel prefix "
74 + "(no spaces nor punctuation)"),
75 input_block("Hunt URL", "url", "External URL of hunt",
80 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
83 submission_handlers[result['view']['id']] = new_hunt_submission
87 actions['button'] = {"new_hunt": new_hunt}
89 def new_hunt_submission(turb, payload, metadata):
90 """Handler for the user submitting the new hunt modal
92 This is the modal view presented to the user by the new_hunt
95 state = payload['view']['state']['values']
96 user_id = payload['user']['id']
97 name = state['name']['name']['value']
98 hunt_id = state['hunt_id']['hunt_id']['value']
99 url = state['url']['url']['value']
101 # Validate that the hunt_id contains no invalid characters
102 if not re.match(valid_id_re, hunt_id):
103 return submission_error("hunt_id",
104 "Hunt ID can only contain lowercase letters, "
105 + "numbers, and underscores")
107 # Check to see if the turbot table exists
109 exists = turb.table.table_status in ("CREATING", "UPDATING",
114 # Create the turbot table if necessary.
116 turb.table = turb.db.create_table(
119 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
120 {'AttributeName': 'SK', 'KeyType': 'RANGE'},
122 AttributeDefinitions=[
123 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
124 {'AttributeName': 'SK', 'AttributeType': 'S'},
125 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
126 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
128 ProvisionedThroughput={
129 'ReadCapacityUnits': 5,
130 'WriteCapacityUnits': 5
132 GlobalSecondaryIndexes=[
134 'IndexName': 'channel_id_index',
136 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
139 'ProjectionType': 'ALL'
141 'ProvisionedThroughput': {
142 'ReadCapacityUnits': 5,
143 'WriteCapacityUnits': 5
147 'IndexName': 'is_hunt_index',
149 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
152 'ProjectionType': 'ALL'
154 'ProvisionedThroughput': {
155 'ReadCapacityUnits': 5,
156 'WriteCapacityUnits': 5
161 return submission_error(
163 "Still bootstrapping turbot table. Try again in a minute, please.")
165 # Create a channel for the hunt
167 response = turb.slack_client.conversations_create(name=hunt_id)
168 except SlackApiError as e:
169 return submission_error("hunt_id",
170 "Error creating Slack channel: {}"
171 .format(e.response['error']))
173 channel_id = response['channel']['id']
175 # Insert the newly-created hunt into the database
176 # (leaving it as non-active for now until the channel-created handler
177 # finishes fixing it up with a sheet and a companion table)
180 "SK": "hunt-{}".format(hunt_id),
182 "channel_id": channel_id,
188 turb.table.put_item(Item=item)
190 # Invite the initiating user to the channel
191 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
195 def view_submission(turb, payload):
196 """Handler for Slack interactive view submission
198 Specifically, those that have a payload type of 'view_submission'"""
200 view_id = payload['view']['id']
201 metadata = payload['view']['private_metadata']
203 if view_id in submission_handlers:
204 return submission_handlers[view_id](turb, payload, metadata)
206 print("Error: Unknown view ID: {}".format(view_id))
211 def rot(turb, body, args):
212 """Implementation of the /rot command
214 The args string should be as follows:
216 [count|*] String to be rotated
218 That is, the first word of the string is an optional number (or
219 the character '*'). If this is a number it indicates an amount to
220 rotate each character in the string. If the count is '*' or is not
221 present, then the string will be rotated through all possible 25
224 The result of the rotation is returned (with Slack formatting) in
225 the body of the response so that Slack will provide it as a reply
226 to the user who submitted the slash command."""
228 channel_name = body['channel_name'][0]
229 response_url = body['response_url'][0]
230 channel_id = body['channel_id'][0]
232 result = turbot.rot.rot(args)
234 if (channel_name == "directmessage"):
235 requests.post(response_url,
236 json = {"text": result},
237 headers = {"Content-type": "application/json"})
239 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
243 commands["/rot"] = rot
245 def get_table_item(turb, table_name, key, value):
246 """Get an item from the database 'table_name' with 'key' as 'value'
248 Returns a tuple of (item, table) if found and (None, None) otherwise."""
250 table = turb.db.Table(table_name)
252 response = table.get_item(Key={key: value})
254 if 'Item' in response:
255 return (response['Item'], table)
259 def db_entry_for_channel(turb, channel_id):
260 """Given a channel ID return the database item for this channel
262 If this channel is a registered hunt or puzzle channel, return the
263 corresponding row from the database for this channel. Otherwise,
266 Note: If you need to specifically ensure that the channel is a
267 puzzle or a hunt, please call puzzle_for_channel or
268 hunt_for_channel respectively.
271 response = turb.table.query(
272 IndexName = "channel_id_index",
273 KeyConditionExpression=Key("channel_id").eq(channel_id)
276 if response['Count'] == 0:
279 return response['Items'][0]
282 def puzzle_for_channel(turb, channel_id):
284 """Given a channel ID return the puzzle from the database for this channel
286 If the given channel_id is a puzzle's channel, this function
287 returns a dict filled with the attributes from the puzzle's entry
290 Otherwise, this function returns None.
293 entry = db_entry_for_channel(turb, channel_id)
295 if entry and entry['SK'].startswith('puzzle-'):
300 def hunt_for_channel(turb, channel_id):
302 """Given a channel ID return the hunt from the database for this channel
304 This works whether the original channel is a primary hunt channel,
305 or if it is one of the channels of a puzzle belonging to the hunt.
307 Returns None if channel does not belong to a hunt, otherwise a
308 dictionary with all fields from the hunt's row in the table,
309 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
312 entry = db_entry_for_channel(turb, channel_id)
314 # We're done if this channel doesn't exist in the database at all
318 # Also done if this channel is a hunt channel
319 if entry['SK'].startswith('hunt-'):
322 # Otherwise, (the channel is in the database, but is not a hunt),
323 # we expect this to be a puzzle channel instead
324 return find_hunt_for_hunt_id(turb, entry['hunt_id'])
326 def puzzle(turb, body, args):
327 """Implementation of the /puzzle command
329 The args string is currently ignored (this command will bring up
330 a modal dialog for user input instead)."""
332 channel_id = body['channel_id'][0]
333 trigger_id = body['trigger_id'][0]
335 hunt = hunt_for_channel(turb, channel_id)
338 return bot_reply("Sorry, this channel doesn't appear to "
339 + "be a hunt or puzzle channel")
343 "private_metadata": json.dumps({
344 "hunt_id": hunt['hunt_id'],
346 "title": {"type": "plain_text", "text": "New Puzzle"},
347 "submit": { "type": "plain_text", "text": "Create" },
349 section_block(text_block("*For {}*".format(hunt['name']))),
350 input_block("Puzzle name", "name", "Name of the puzzle"),
351 input_block("Puzzle URL", "url", "External URL of puzzle",
356 result = turb.slack_client.views_open(trigger_id=trigger_id,
360 submission_handlers[result['view']['id']] = puzzle_submission
364 commands["/puzzle"] = puzzle
366 def puzzle_submission(turb, payload, metadata):
367 """Handler for the user submitting the new puzzle modal
369 This is the modal view presented to the user by the puzzle function
372 meta = json.loads(metadata)
373 hunt_id = meta['hunt_id']
375 state = payload['view']['state']['values']
376 name = state['name']['name']['value']
377 url = state['url']['url']['value']
379 # Create a Slack-channel-safe puzzle_id
380 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
382 # Create a channel for the puzzle
383 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
386 response = turb.slack_client.conversations_create(
387 name=hunt_dash_channel)
388 except SlackApiError as e:
389 return submission_error(
391 "Error creating Slack channel {}: {}"
392 .format(hunt_dash_channel, e.response['error']))
394 channel_id = response['channel']['id']
396 # Insert the newly-created puzzle into the database
399 "SK": "puzzle-{}".format(puzzle_id),
400 "puzzle_id": puzzle_id,
401 "channel_id": channel_id,
403 "status": 'unsolved',
408 turb.table.put_item(Item=item)
412 # XXX: This duplicates functionality eith events.py:set_channel_description
413 def set_channel_topic(turb, puzzle):
414 channel_id = puzzle['channel_id']
415 name = puzzle['name']
416 url = puzzle.get('url', None)
417 sheet_url = puzzle.get('sheet_url', None)
418 state = puzzle.get('state', None)
419 status = puzzle['status']
423 if status == 'solved':
424 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
430 links.append("<{}|Puzzle>".format(url))
432 links.append("<{}|Sheet>".format(sheet_url))
435 description += "({})".format(', '.join(links))
438 description += " {}".format(state)
440 turb.slack_client.conversations_setTopic(channel=channel_id,
443 def state(turb, body, args):
444 """Implementation of the /state command
446 The args string should be a brief sentence describing where things
447 stand or what's needed."""
449 channel_id = body['channel_id'][0]
451 puzzle = puzzle_for_channel(turb, channel_id)
455 "Sorry, the /state command only works in a puzzle channel")
457 # Set the state field in the database
458 puzzle['state'] = args
459 turb.table.put_item(Item=puzzle)
461 set_channel_topic(turb, puzzle)
465 commands["/state"] = state
467 def solved(turb, body, args):
468 """Implementation of the /solved command
470 The args string should be a confirmed solution."""
472 channel_id = body['channel_id'][0]
473 user_name = body['user_name'][0]
475 puzzle = puzzle_for_channel(turb, channel_id)
478 return bot_reply("Sorry, this is not a puzzle channel.")
480 # Set the status and solution fields in the database
481 puzzle['status'] = 'solved'
482 puzzle['solution'].append(args)
483 turb.table.put_item(Item=puzzle)
485 # Report the solution to the puzzle's channel
487 turb.slack_client, channel_id,
488 "Puzzle mark solved by {}: `{}`".format(user_name, args))
490 # Also report the solution to the hunt channel
491 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
493 turb.slack_client, hunt['channel_id'],
494 "Puzzle <{}|{}> has been solved!".format(
495 puzzle['channel_url'],
499 # And update the puzzle's description
500 set_channel_topic(turb, puzzle)
502 # And rename the sheet to prefix with "SOLVED: "
503 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
504 'SOLVED: ' + puzzle['name'])
506 # Finally, rename the Slack channel to add the suffix '-solved'
507 channel_name = "{}-{}-solved".format(
510 turb.slack_client.conversations_rename(
511 channel=puzzle['channel_id'],
516 commands["/solved"] = solved