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 turbot.slack import slack_send_message
14 submission_handlers = {}
16 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
17 valid_id_re = r'^[_a-z0-9]+$'
19 lambda_ok = {'statusCode': 200}
21 def bot_reply(message):
22 """Construct a return value suitable for a bot reply
24 This is suitable as a way to give an error back to the user who
25 initiated a slash command, for example."""
32 def submission_error(field, error):
33 """Construct an error suitable for returning for an invalid submission.
35 Returning this value will prevent a submission and alert the user that
36 the given field is invalid because of the given error."""
38 print("Rejecting invalid modal submission: {}".format(error))
43 "Content-Type": "application/json"
46 "response_action": "errors",
53 def new_hunt(turb, payload):
54 """Handler for the action of user pressing the new_hunt button"""
58 "private_metadata": json.dumps({}),
59 "title": { "type": "plain_text", "text": "New Hunt" },
60 "submit": { "type": "plain_text", "text": "Create" },
62 input_block("Hunt name", "name", "Name of the hunt"),
63 input_block("Hunt ID", "hunt_id",
64 "Used as puzzle channel prefix "
65 + "(no spaces nor punctuation)"),
66 input_block("Hunt URL", "url", "External URL of hunt",
71 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
74 submission_handlers[result['view']['id']] = new_hunt_submission
78 actions['button'] = {"new_hunt": new_hunt}
80 def new_hunt_submission(turb, payload, metadata):
81 """Handler for the user submitting the new hunt modal
83 This is the modal view presented to the user by the new_hunt
86 state = payload['view']['state']['values']
87 user_id = payload['user']['id']
88 name = state['name']['name']['value']
89 hunt_id = state['hunt_id']['hunt_id']['value']
90 url = state['url']['url']['value']
92 # Validate that the hunt_id contains no invalid characters
93 if not re.match(valid_id_re, hunt_id):
94 return submission_error("hunt_id",
95 "Hunt ID can only contain lowercase letters, "
96 + "numbers, and underscores")
98 # Check to see if the turbot table exists
100 exists = turb.table.table_status in ("CREATING", "UPDATING",
105 # Create the turbot table if necessary.
107 turb.table = turb.db.create_table(
110 {'AttributeName': 'PK', 'KeyType': 'HASH'},
111 {'AttributeName': 'SK', 'KeyType': 'RANGE'},
113 AttributeDefinitions=[
114 {'AttributeName': 'PK', 'AttributeType': 'S'},
115 {'AttributeName': 'SK', 'AttributeType': 'S'},
116 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
117 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
119 ProvisionedThroughput={
120 'ReadCapacityUnits': 5,
121 'WriteCapacityUnits': 5
123 GlobalSecondaryIndexes=[
125 'IndexName': 'channel_id_index',
127 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
130 'ProjectionType': 'ALL'
132 'ProvisionedThroughput': {
133 'ReadCapacityUnits': 5,
134 'WriteCapacityUnits': 5
138 'IndexName': 'hunt_id_index',
140 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'}
143 'ProjectionType': 'ALL'
145 'ProvisionedThroughput': {
146 'ReadCapacityUnits': 5,
147 'WriteCapacityUnits': 5
152 return submission_error("hunt_id",
153 "Still bootstrapping turbot table. Try again.")
155 # Create a channel for the hunt
157 response = turb.slack_client.conversations_create(name=hunt_id)
158 except SlackApiError as e:
159 return submission_error("hunt_id",
160 "Error creating Slack channel: {}"
161 .format(e.response['error']))
163 channel_id = response['channel']['id']
165 # Insert the newly-created hunt into the database
166 # (leaving it as non-active for now until the channel-created handler
167 # finishes fixing it up with a sheet and a companion table)
170 "PK": "hunt-{}".format(hunt_id),
171 "SK": "hunt-{}".format(hunt_id),
173 "channel_id": channel_id,
180 # Invite the initiating user to the channel
181 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
185 def view_submission(turb, payload):
186 """Handler for Slack interactive view submission
188 Specifically, those that have a payload type of 'view_submission'"""
190 view_id = payload['view']['id']
191 metadata = payload['view']['private_metadata']
193 if view_id in submission_handlers:
194 return submission_handlers[view_id](turb, payload, metadata)
196 print("Error: Unknown view ID: {}".format(view_id))
201 def rot(turb, body, args):
202 """Implementation of the /rot command
204 The args string should be as follows:
206 [count|*] String to be rotated
208 That is, the first word of the string is an optional number (or
209 the character '*'). If this is a number it indicates an amount to
210 rotate each character in the string. If the count is '*' or is not
211 present, then the string will be rotated through all possible 25
214 The result of the rotation is returned (with Slack formatting) in
215 the body of the response so that Slack will provide it as a reply
216 to the user who submitted the slash command."""
218 channel_name = body['channel_name'][0]
219 response_url = body['response_url'][0]
220 channel_id = body['channel_id'][0]
222 result = turbot.rot.rot(args)
224 if (channel_name == "directmessage"):
225 requests.post(response_url,
226 json = {"text": result},
227 headers = {"Content-type": "application/json"})
229 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
233 commands["/rot"] = rot
235 def get_table_item(turb, table_name, key, value):
236 """Get an item from the database 'table_name' with 'key' as 'value'
238 Returns a tuple of (item, table) if found and (None, None) otherwise."""
240 table = turb.db.Table(table_name)
242 response = table.get_item(Key={key: value})
244 if 'Item' in response:
245 return (response['Item'], table)
249 def channel_is_puzzle(turb, channel_id, channel_name):
250 """Given a channel ID/name return the database item for the puzzle
252 If this channel is a puzzle, this function returns a tuple:
256 Where puzzle is dict filled with database entries, and table is a
257 database table that can be used to update the puzzle in the
260 Otherwise, this function returns (None, None)."""
262 hunt_id = channel_name.split('-')[0]
264 # Not a puzzle channel if there is no hyphen in the name
265 if hunt_id == channel_name:
268 return get_table_item(turb, hunt_id, 'channel_id', channel_id)
270 def channel_is_hunt(turb, channel_id):
272 """Given a channel ID/name return the database item for the hunt
274 Returns a dict (filled with database entries) if there is a hunt
275 for this channel, otherwise returns None."""
277 return get_table_item(turb, "channel_id_index", 'channel_id', channel_id)
279 def find_hunt_for_hunt_id(turb, hunt_id):
280 """Given a hunt ID find the database for for that hunt
282 Returns None if hunt ID is not found, otherwise a
283 dictionary with all fields from the hunt's row in the table,
284 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
287 turbot_table = turb.db.Table("turbot")
289 response = turbot_table.get_item(Key={'PK': 'hunt-{}'.format(hunt_id)})
291 if 'Item' in response:
292 return response['Item']
296 def find_hunt_for_channel(turb, channel_id, channel_name):
297 """Given a channel ID/name find the id/name of the hunt for this channel
299 This works whether the original channel is a primary hunt channel,
300 or if it is one of the channels of a puzzle belonging to the hunt.
302 Returns None if channel does not belong to a hunt, otherwise a
303 dictionary with all fields from the hunt's row in the table,
304 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
308 (hunt, _) = channel_is_hunt(turb, channel_id)
313 # So we're not a hunt channel, let's look to see if we are a
314 # puzzle channel with a hunt-id prefix.
315 hunt_id = channel_name.split('-')[0]
317 return find_hunt_for_hunt_id(turb, hunt_id)
319 def puzzle(turb, body, args):
320 """Implementation of the /puzzle command
322 The args string is currently ignored (this command will bring up
323 a modal dialog for user input instead)."""
325 channel_id = body['channel_id'][0]
326 channel_name = body['channel_name'][0]
327 trigger_id = body['trigger_id'][0]
329 hunt = find_hunt_for_channel(turb,
334 return bot_reply("Sorry, this channel doesn't appear to "
335 + "be a hunt or puzzle channel")
339 "private_metadata": json.dumps({
340 "hunt_id": hunt['hunt_id'],
342 "title": {"type": "plain_text", "text": "New Puzzle"},
343 "submit": { "type": "plain_text", "text": "Create" },
345 section_block(text_block("*For {}*".format(hunt['name']))),
346 input_block("Puzzle name", "name", "Name of the puzzle"),
347 input_block("Puzzle URL", "url", "External URL of puzzle",
352 result = turb.slack_client.views_open(trigger_id=trigger_id,
356 submission_handlers[result['view']['id']] = puzzle_submission
360 commands["/puzzle"] = puzzle
362 def puzzle_submission(turb, payload, metadata):
363 """Handler for the user submitting the new puzzle modal
365 This is the modal view presented to the user by the puzzle function
368 meta = json.loads(metadata)
369 hunt_id = meta['hunt_id']
371 state = payload['view']['state']['values']
372 name = state['name']['name']['value']
373 url = state['url']['url']['value']
375 # Create a Slack-channel-safe puzzle_id
376 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
378 # Create a channel for the puzzle
379 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
382 response = turb.slack_client.conversations_create(
383 name=hunt_dash_channel)
384 except SlackApiError as e:
385 return submission_error(
387 "Error creating Slack channel {}: {}"
388 .format(hunt_dash_channel, e.response['error']))
390 puzzle_channel_id = response['channel']['id']
392 # Insert the newly-created puzzle into the database
393 table = turb.db.Table(hunt_id)
396 "channel_id": puzzle_channel_id,
398 "status": 'unsolved',
401 "puzzle_id": puzzle_id,
408 # XXX: This duplicates functionality eith events.py:set_channel_description
409 def set_channel_topic(turb, puzzle):
410 channel_id = puzzle['channel_id']
411 name = puzzle['name']
412 url = puzzle.get('url', None)
413 sheet_url = puzzle.get('sheet_url', None)
414 state = puzzle.get('state', None)
415 status = puzzle['status']
419 if status == 'solved':
420 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
426 links.append("<{}|Puzzle>".format(url))
428 links.append("<{}|Sheet>".format(sheet_url))
431 description += "({})".format(', '.join(links))
434 description += " {}".format(state)
436 turb.slack_client.conversations_setTopic(channel=channel_id,
439 def state(turb, body, args):
440 """Implementation of the /state command
442 The args string should be a brief sentence describing where things
443 stand or what's needed."""
445 channel_id = body['channel_id'][0]
446 channel_name = body['channel_name'][0]
448 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
451 return bot_reply("Sorry, this is not a puzzle channel.")
453 # Set the state field in the database
454 puzzle['state'] = args
455 table.put_item(Item=puzzle)
457 set_channel_topic(turb, puzzle)
461 commands["/state"] = state
463 def solved(turb, body, args):
464 """Implementation of the /solved command
466 The args string should be a confirmed solution."""
468 channel_id = body['channel_id'][0]
469 channel_name = body['channel_name'][0]
470 user_name = body['user_name'][0]
472 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
475 return bot_reply("Sorry, this is not a puzzle channel.")
477 # Set the status and solution fields in the database
478 puzzle['status'] = 'solved'
479 puzzle['solution'].append(args)
480 table.put_item(Item=puzzle)
482 # Report the solution to the puzzle's channel
484 turb.slack_client, channel_id,
485 "Puzzle mark solved by {}: `{}`".format(user_name, args))
487 # Also report the solution to the hunt channel
488 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
490 turb.slack_client, hunt['channel_id'],
491 "Puzzle <{}|{}> has been solved!".format(
492 puzzle['channel_url'],
496 # And update the puzzle's description
497 set_channel_topic(turb, puzzle)
499 # And rename the sheet to prefix with "SOLVED: "
500 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
501 'SOLVED: ' + puzzle['name'])
503 # Finally, rename the Slack channel to add the suffix '-solved'
504 channel_name = "{}-{}-solved".format(
507 turb.slack_client.conversations_rename(
508 channel=puzzle['channel_id'],
513 commands["/solved"] = solved