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 new_hunt(turb, payload):
56 """Handler for the action of user pressing the new_hunt button"""
60 "private_metadata": json.dumps({}),
61 "title": { "type": "plain_text", "text": "New Hunt" },
62 "submit": { "type": "plain_text", "text": "Create" },
64 input_block("Hunt name", "name", "Name of the hunt"),
65 input_block("Hunt ID", "hunt_id",
66 "Used as puzzle channel prefix "
67 + "(no spaces nor punctuation)"),
68 input_block("Hunt URL", "url", "External URL of hunt",
73 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
76 submission_handlers[result['view']['id']] = new_hunt_submission
80 actions['button'] = {"new_hunt": new_hunt}
82 def new_hunt_submission(turb, payload, metadata):
83 """Handler for the user submitting the new hunt modal
85 This is the modal view presented to the user by the new_hunt
88 state = payload['view']['state']['values']
89 user_id = payload['user']['id']
90 name = state['name']['name']['value']
91 hunt_id = state['hunt_id']['hunt_id']['value']
92 url = state['url']['url']['value']
94 # Validate that the hunt_id contains no invalid characters
95 if not re.match(valid_id_re, hunt_id):
96 return submission_error("hunt_id",
97 "Hunt ID can only contain lowercase letters, "
98 + "numbers, and underscores")
100 # Check to see if the turbot table exists
102 exists = turb.table.table_status in ("CREATING", "UPDATING",
107 # Create the turbot table if necessary.
109 turb.table = turb.db.create_table(
112 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
113 {'AttributeName': 'SK', 'KeyType': 'RANGE'},
115 AttributeDefinitions=[
116 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
117 {'AttributeName': 'SK', 'AttributeType': 'S'},
118 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
119 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
121 ProvisionedThroughput={
122 'ReadCapacityUnits': 5,
123 'WriteCapacityUnits': 5
125 GlobalSecondaryIndexes=[
127 'IndexName': 'channel_id_index',
129 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
132 'ProjectionType': 'ALL'
134 'ProvisionedThroughput': {
135 'ReadCapacityUnits': 5,
136 'WriteCapacityUnits': 5
140 'IndexName': 'is_hunt_index',
142 {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
145 'ProjectionType': 'ALL'
147 'ProvisionedThroughput': {
148 'ReadCapacityUnits': 5,
149 'WriteCapacityUnits': 5
154 return submission_error(
156 "Still bootstrapping turbot table. Try again in a minute, please.")
158 # Create a channel for the hunt
160 response = turb.slack_client.conversations_create(name=hunt_id)
161 except SlackApiError as e:
162 return submission_error("hunt_id",
163 "Error creating Slack channel: {}"
164 .format(e.response['error']))
166 channel_id = response['channel']['id']
168 # Insert the newly-created hunt into the database
169 # (leaving it as non-active for now until the channel-created handler
170 # finishes fixing it up with a sheet and a companion table)
173 "SK": "hunt-{}".format(hunt_id),
175 "channel_id": channel_id,
181 turb.table.put_item(Item=item)
183 # Invite the initiating user to the channel
184 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
188 def view_submission(turb, payload):
189 """Handler for Slack interactive view submission
191 Specifically, those that have a payload type of 'view_submission'"""
193 view_id = payload['view']['id']
194 metadata = payload['view']['private_metadata']
196 if view_id in submission_handlers:
197 return submission_handlers[view_id](turb, payload, metadata)
199 print("Error: Unknown view ID: {}".format(view_id))
204 def rot(turb, body, args):
205 """Implementation of the /rot command
207 The args string should be as follows:
209 [count|*] String to be rotated
211 That is, the first word of the string is an optional number (or
212 the character '*'). If this is a number it indicates an amount to
213 rotate each character in the string. If the count is '*' or is not
214 present, then the string will be rotated through all possible 25
217 The result of the rotation is returned (with Slack formatting) in
218 the body of the response so that Slack will provide it as a reply
219 to the user who submitted the slash command."""
221 channel_name = body['channel_name'][0]
222 response_url = body['response_url'][0]
223 channel_id = body['channel_id'][0]
225 result = turbot.rot.rot(args)
227 if (channel_name == "directmessage"):
228 requests.post(response_url,
229 json = {"text": result},
230 headers = {"Content-type": "application/json"})
232 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
236 commands["/rot"] = rot
238 def get_table_item(turb, table_name, key, value):
239 """Get an item from the database 'table_name' with 'key' as 'value'
241 Returns a tuple of (item, table) if found and (None, None) otherwise."""
243 table = turb.db.Table(table_name)
245 response = table.get_item(Key={key: value})
247 if 'Item' in response:
248 return (response['Item'], table)
252 def channel_is_puzzle(turb, channel_id, channel_name):
253 """Given a channel ID/name return the database item for the puzzle
255 If this channel is a puzzle, this function returns a tuple:
259 Where puzzle is dict filled with database entries, and table is a
260 database table that can be used to update the puzzle in the
263 Otherwise, this function returns (None, None)."""
265 hunt_id = channel_name.split('-')[0]
267 # Not a puzzle channel if there is no hyphen in the name
268 if hunt_id == channel_name:
271 return get_table_item(turb, hunt_id, 'channel_id', channel_id)
273 def channel_is_hunt(turb, channel_id):
275 """Given a channel ID/name return the database item for the hunt
277 Returns a dict (filled with database entries) if there is a hunt
278 for this channel, otherwise returns None."""
280 response = turb.table.query(
281 IndexName = "channel_id_index",
282 KeyConditionExpression=Key("channel_id").eq(channel_id)
285 if 'Items' not in response:
288 return response['Items'][0]
290 def find_hunt_for_channel(turb, channel_id, channel_name):
291 """Given a channel ID/name find the id/name of the hunt for this channel
293 This works whether the original channel is a primary hunt channel,
294 or if it is one of the channels of a puzzle belonging to the hunt.
296 Returns None if channel does not belong to a hunt, otherwise a
297 dictionary with all fields from the hunt's row in the table,
298 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
302 hunt = channel_is_hunt(turb, channel_id)
307 # So we're not a hunt channel, let's look to see if we are a
308 # puzzle channel with a hunt-id prefix.
309 hunt_id = channel_name.split('-')[0]
311 return find_hunt_for_hunt_id(turb, hunt_id)
313 def puzzle(turb, body, args):
314 """Implementation of the /puzzle command
316 The args string is currently ignored (this command will bring up
317 a modal dialog for user input instead)."""
319 channel_id = body['channel_id'][0]
320 channel_name = body['channel_name'][0]
321 trigger_id = body['trigger_id'][0]
323 hunt = find_hunt_for_channel(turb,
328 return bot_reply("Sorry, this channel doesn't appear to "
329 + "be a hunt or puzzle channel")
333 "private_metadata": json.dumps({
334 "hunt_id": hunt['hunt_id'],
336 "title": {"type": "plain_text", "text": "New Puzzle"},
337 "submit": { "type": "plain_text", "text": "Create" },
339 section_block(text_block("*For {}*".format(hunt['name']))),
340 input_block("Puzzle name", "name", "Name of the puzzle"),
341 input_block("Puzzle URL", "url", "External URL of puzzle",
346 result = turb.slack_client.views_open(trigger_id=trigger_id,
350 submission_handlers[result['view']['id']] = puzzle_submission
354 commands["/puzzle"] = puzzle
356 def puzzle_submission(turb, payload, metadata):
357 """Handler for the user submitting the new puzzle modal
359 This is the modal view presented to the user by the puzzle function
362 meta = json.loads(metadata)
363 hunt_id = meta['hunt_id']
365 state = payload['view']['state']['values']
366 name = state['name']['name']['value']
367 url = state['url']['url']['value']
369 # Create a Slack-channel-safe puzzle_id
370 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
372 # Create a channel for the puzzle
373 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
376 response = turb.slack_client.conversations_create(
377 name=hunt_dash_channel)
378 except SlackApiError as e:
379 return submission_error(
381 "Error creating Slack channel {}: {}"
382 .format(hunt_dash_channel, e.response['error']))
384 channel_id = response['channel']['id']
386 # Insert the newly-created puzzle into the database
389 "SK": "puzzle-{}".format(puzzle_id),
390 "puzzle_id": puzzle_id,
391 "channel_id": channel_id,
393 "status": 'unsolved',
398 turb.table.put_item(Item=item)
402 # XXX: This duplicates functionality eith events.py:set_channel_description
403 def set_channel_topic(turb, puzzle):
404 channel_id = puzzle['channel_id']
405 name = puzzle['name']
406 url = puzzle.get('url', None)
407 sheet_url = puzzle.get('sheet_url', None)
408 state = puzzle.get('state', None)
409 status = puzzle['status']
413 if status == 'solved':
414 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
420 links.append("<{}|Puzzle>".format(url))
422 links.append("<{}|Sheet>".format(sheet_url))
425 description += "({})".format(', '.join(links))
428 description += " {}".format(state)
430 turb.slack_client.conversations_setTopic(channel=channel_id,
433 def state(turb, body, args):
434 """Implementation of the /state command
436 The args string should be a brief sentence describing where things
437 stand or what's needed."""
439 channel_id = body['channel_id'][0]
440 channel_name = body['channel_name'][0]
442 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
445 return bot_reply("Sorry, this is not a puzzle channel.")
447 # Set the state field in the database
448 puzzle['state'] = args
449 table.put_item(Item=puzzle)
451 set_channel_topic(turb, puzzle)
455 commands["/state"] = state
457 def solved(turb, body, args):
458 """Implementation of the /solved command
460 The args string should be a confirmed solution."""
462 channel_id = body['channel_id'][0]
463 channel_name = body['channel_name'][0]
464 user_name = body['user_name'][0]
466 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
469 return bot_reply("Sorry, this is not a puzzle channel.")
471 # Set the status and solution fields in the database
472 puzzle['status'] = 'solved'
473 puzzle['solution'].append(args)
474 table.put_item(Item=puzzle)
476 # Report the solution to the puzzle's channel
478 turb.slack_client, channel_id,
479 "Puzzle mark solved by {}: `{}`".format(user_name, args))
481 # Also report the solution to the hunt channel
482 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
484 turb.slack_client, hunt['channel_id'],
485 "Puzzle <{}|{}> has been solved!".format(
486 puzzle['channel_url'],
490 # And update the puzzle's description
491 set_channel_topic(turb, puzzle)
493 # And rename the sheet to prefix with "SOLVED: "
494 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
495 'SOLVED: ' + puzzle['name'])
497 # Finally, rename the Slack channel to add the suffix '-solved'
498 channel_name = "{}-{}-solved".format(
501 turb.slack_client.conversations_rename(
502 channel=puzzle['channel_id'],
507 commands["/solved"] = solved