1 from slack.errors import SlackApiError
2 from turbot.blocks import input_block, section_block, text_block
9 from botocore.exceptions import ClientError
10 from boto3.dynamodb.conditions import Key
11 from turbot.slack import slack_send_message
15 submission_handlers = {}
17 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
18 valid_id_re = r'^[_a-z0-9]+$'
20 lambda_ok = {'statusCode': 200}
22 def bot_reply(message):
23 """Construct a return value suitable for a bot reply
25 This is suitable as a way to give an error back to the user who
26 initiated a slash command, for example."""
33 def submission_error(field, error):
34 """Construct an error suitable for returning for an invalid submission.
36 Returning this value will prevent a submission and alert the user that
37 the given field is invalid because of the given error."""
39 print("Rejecting invalid modal submission: {}".format(error))
44 "Content-Type": "application/json"
47 "response_action": "errors",
54 def new_hunt(turb, payload):
55 """Handler for the action of user pressing the new_hunt button"""
59 "private_metadata": json.dumps({}),
60 "title": { "type": "plain_text", "text": "New Hunt" },
61 "submit": { "type": "plain_text", "text": "Create" },
63 input_block("Hunt name", "name", "Name of the hunt"),
64 input_block("Hunt ID", "hunt_id",
65 "Used as puzzle channel prefix "
66 + "(no spaces nor punctuation)"),
67 input_block("Hunt URL", "url", "External URL of hunt",
72 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
75 submission_handlers[result['view']['id']] = new_hunt_submission
79 actions['button'] = {"new_hunt": new_hunt}
81 def new_hunt_submission(turb, payload, metadata):
82 """Handler for the user submitting the new hunt modal
84 This is the modal view presented to the user by the new_hunt
87 state = payload['view']['state']['values']
88 user_id = payload['user']['id']
89 name = state['name']['name']['value']
90 hunt_id = state['hunt_id']['hunt_id']['value']
91 url = state['url']['url']['value']
93 # Validate that the hunt_id contains no invalid characters
94 if not re.match(valid_id_re, hunt_id):
95 return submission_error("hunt_id",
96 "Hunt ID can only contain lowercase letters, "
97 + "numbers, and underscores")
99 # Check to see if the turbot table exists
101 exists = turb.table.table_status in ("CREATING", "UPDATING",
106 # Create the turbot table if necessary.
108 turb.table = turb.db.create_table(
111 {'AttributeName': 'PK', 'KeyType': 'HASH'},
112 {'AttributeName': 'SK', 'KeyType': 'RANGE'},
114 AttributeDefinitions=[
115 {'AttributeName': 'PK', 'AttributeType': 'S'},
116 {'AttributeName': 'SK', 'AttributeType': 'S'},
117 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
118 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
120 ProvisionedThroughput={
121 'ReadCapacityUnits': 5,
122 'WriteCapacityUnits': 5
124 GlobalSecondaryIndexes=[
126 'IndexName': 'channel_id_index',
128 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
131 'ProjectionType': 'ALL'
133 'ProvisionedThroughput': {
134 'ReadCapacityUnits': 5,
135 'WriteCapacityUnits': 5
139 'IndexName': 'hunt_id_index',
141 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'}
144 'ProjectionType': 'ALL'
146 'ProvisionedThroughput': {
147 'ReadCapacityUnits': 5,
148 'WriteCapacityUnits': 5
153 return submission_error(
155 "Still bootstrapping turbot table. Try again in a minute, please.")
157 # Create a channel for the hunt
159 response = turb.slack_client.conversations_create(name=hunt_id)
160 except SlackApiError as e:
161 return submission_error("hunt_id",
162 "Error creating Slack channel: {}"
163 .format(e.response['error']))
165 channel_id = response['channel']['id']
167 # Insert the newly-created hunt into the database
168 # (leaving it as non-active for now until the channel-created handler
169 # finishes fixing it up with a sheet and a companion table)
171 "PK": "hunt-{}".format(hunt_id),
172 "SK": "hunt-{}".format(hunt_id),
174 "channel_id": channel_id,
180 turb.table.put_item(Item=item)
182 # Invite the initiating user to the channel
183 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
187 def view_submission(turb, payload):
188 """Handler for Slack interactive view submission
190 Specifically, those that have a payload type of 'view_submission'"""
192 view_id = payload['view']['id']
193 metadata = payload['view']['private_metadata']
195 if view_id in submission_handlers:
196 return submission_handlers[view_id](turb, payload, metadata)
198 print("Error: Unknown view ID: {}".format(view_id))
203 def rot(turb, body, args):
204 """Implementation of the /rot command
206 The args string should be as follows:
208 [count|*] String to be rotated
210 That is, the first word of the string is an optional number (or
211 the character '*'). If this is a number it indicates an amount to
212 rotate each character in the string. If the count is '*' or is not
213 present, then the string will be rotated through all possible 25
216 The result of the rotation is returned (with Slack formatting) in
217 the body of the response so that Slack will provide it as a reply
218 to the user who submitted the slash command."""
220 channel_name = body['channel_name'][0]
221 response_url = body['response_url'][0]
222 channel_id = body['channel_id'][0]
224 result = turbot.rot.rot(args)
226 if (channel_name == "directmessage"):
227 requests.post(response_url,
228 json = {"text": result},
229 headers = {"Content-type": "application/json"})
231 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
235 commands["/rot"] = rot
237 def get_table_item(turb, table_name, key, value):
238 """Get an item from the database 'table_name' with 'key' as 'value'
240 Returns a tuple of (item, table) if found and (None, None) otherwise."""
242 table = turb.db.Table(table_name)
244 response = table.get_item(Key={key: value})
246 if 'Item' in response:
247 return (response['Item'], table)
251 def channel_is_puzzle(turb, channel_id, channel_name):
252 """Given a channel ID/name return the database item for the puzzle
254 If this channel is a puzzle, this function returns a tuple:
258 Where puzzle is dict filled with database entries, and table is a
259 database table that can be used to update the puzzle in the
262 Otherwise, this function returns (None, None)."""
264 hunt_id = channel_name.split('-')[0]
266 # Not a puzzle channel if there is no hyphen in the name
267 if hunt_id == channel_name:
270 return get_table_item(turb, hunt_id, 'channel_id', channel_id)
272 def channel_is_hunt(turb, channel_id):
274 """Given a channel ID/name return the database item for the hunt
276 Returns a dict (filled with database entries) if there is a hunt
277 for this channel, otherwise returns None."""
279 response = turb.table.query(
280 IndexName = "channel_id_index",
281 KeyConditionExpression=Key("channel_id").eq(channel_id)
284 if 'Items' not in response:
287 return response['Items'][0]
289 def find_hunt_for_hunt_id(turb, hunt_id):
290 """Given a hunt ID find the database for for that hunt
292 Returns None if hunt ID is not found, otherwise a
293 dictionary with all fields from the hunt's row in the table,
294 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
297 turbot_table = turb.db.Table("turbot")
299 response = turbot_table.get_item(Key={'PK': 'hunt-{}'.format(hunt_id)})
301 if 'Item' in response:
302 return response['Item']
306 def find_hunt_for_channel(turb, channel_id, channel_name):
307 """Given a channel ID/name find the id/name of the hunt for this channel
309 This works whether the original channel is a primary hunt channel,
310 or if it is one of the channels of a puzzle belonging to the hunt.
312 Returns None if channel does not belong to a hunt, otherwise a
313 dictionary with all fields from the hunt's row in the table,
314 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
318 hunt = channel_is_hunt(turb, channel_id)
323 # So we're not a hunt channel, let's look to see if we are a
324 # puzzle channel with a hunt-id prefix.
325 hunt_id = channel_name.split('-')[0]
327 return find_hunt_for_hunt_id(turb, hunt_id)
329 def puzzle(turb, body, args):
330 """Implementation of the /puzzle command
332 The args string is currently ignored (this command will bring up
333 a modal dialog for user input instead)."""
335 channel_id = body['channel_id'][0]
336 channel_name = body['channel_name'][0]
337 trigger_id = body['trigger_id'][0]
339 hunt = find_hunt_for_channel(turb,
344 return bot_reply("Sorry, this channel doesn't appear to "
345 + "be a hunt or puzzle channel")
349 "private_metadata": json.dumps({
350 "hunt_id": hunt['hunt_id'],
352 "title": {"type": "plain_text", "text": "New Puzzle"},
353 "submit": { "type": "plain_text", "text": "Create" },
355 section_block(text_block("*For {}*".format(hunt['name']))),
356 input_block("Puzzle name", "name", "Name of the puzzle"),
357 input_block("Puzzle URL", "url", "External URL of puzzle",
362 result = turb.slack_client.views_open(trigger_id=trigger_id,
366 submission_handlers[result['view']['id']] = puzzle_submission
370 commands["/puzzle"] = puzzle
372 def puzzle_submission(turb, payload, metadata):
373 """Handler for the user submitting the new puzzle modal
375 This is the modal view presented to the user by the puzzle function
378 meta = json.loads(metadata)
379 hunt_id = meta['hunt_id']
381 state = payload['view']['state']['values']
382 name = state['name']['name']['value']
383 url = state['url']['url']['value']
385 # Create a Slack-channel-safe puzzle_id
386 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
388 # Create a channel for the puzzle
389 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
392 response = turb.slack_client.conversations_create(
393 name=hunt_dash_channel)
394 except SlackApiError as e:
395 return submission_error(
397 "Error creating Slack channel {}: {}"
398 .format(hunt_dash_channel, e.response['error']))
400 channel_id = response['channel']['id']
402 # Insert the newly-created puzzle into the database
404 "PK": "hunt-{}".format(hunt_id),
405 "SK": "puzzle-{}".format(puzzle_id),
406 "puzzle_id": puzzle_id,
407 "channel_id": channel_id,
409 "status": 'unsolved',
414 turb.table.put_item(Item=item)
418 # XXX: This duplicates functionality eith events.py:set_channel_description
419 def set_channel_topic(turb, puzzle):
420 channel_id = puzzle['channel_id']
421 name = puzzle['name']
422 url = puzzle.get('url', None)
423 sheet_url = puzzle.get('sheet_url', None)
424 state = puzzle.get('state', None)
425 status = puzzle['status']
429 if status == 'solved':
430 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
436 links.append("<{}|Puzzle>".format(url))
438 links.append("<{}|Sheet>".format(sheet_url))
441 description += "({})".format(', '.join(links))
444 description += " {}".format(state)
446 turb.slack_client.conversations_setTopic(channel=channel_id,
449 def state(turb, body, args):
450 """Implementation of the /state command
452 The args string should be a brief sentence describing where things
453 stand or what's needed."""
455 channel_id = body['channel_id'][0]
456 channel_name = body['channel_name'][0]
458 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
461 return bot_reply("Sorry, this is not a puzzle channel.")
463 # Set the state field in the database
464 puzzle['state'] = args
465 table.put_item(Item=puzzle)
467 set_channel_topic(turb, puzzle)
471 commands["/state"] = state
473 def solved(turb, body, args):
474 """Implementation of the /solved command
476 The args string should be a confirmed solution."""
478 channel_id = body['channel_id'][0]
479 channel_name = body['channel_name'][0]
480 user_name = body['user_name'][0]
482 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
485 return bot_reply("Sorry, this is not a puzzle channel.")
487 # Set the status and solution fields in the database
488 puzzle['status'] = 'solved'
489 puzzle['solution'].append(args)
490 table.put_item(Item=puzzle)
492 # Report the solution to the puzzle's channel
494 turb.slack_client, channel_id,
495 "Puzzle mark solved by {}: `{}`".format(user_name, args))
497 # Also report the solution to the hunt channel
498 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
500 turb.slack_client, hunt['channel_id'],
501 "Puzzle <{}|{}> has been solved!".format(
502 puzzle['channel_url'],
506 # And update the puzzle's description
507 set_channel_topic(turb, puzzle)
509 # And rename the sheet to prefix with "SOLVED: "
510 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
511 'SOLVED: ' + puzzle['name'])
513 # Finally, rename the Slack channel to add the suffix '-solved'
514 channel_name = "{}-{}-solved".format(
517 turb.slack_client.conversations_rename(
518 channel=puzzle['channel_id'],
523 commands["/solved"] = solved