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(
154 "Still bootstrapping turbot table. Try again in a minute, please.")
156 # Create a channel for the hunt
158 response = turb.slack_client.conversations_create(name=hunt_id)
159 except SlackApiError as e:
160 return submission_error("hunt_id",
161 "Error creating Slack channel: {}"
162 .format(e.response['error']))
164 channel_id = response['channel']['id']
166 # Insert the newly-created hunt into the database
167 # (leaving it as non-active for now until the channel-created handler
168 # 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,
181 # Invite the initiating user to the channel
182 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
186 def view_submission(turb, payload):
187 """Handler for Slack interactive view submission
189 Specifically, those that have a payload type of 'view_submission'"""
191 view_id = payload['view']['id']
192 metadata = payload['view']['private_metadata']
194 if view_id in submission_handlers:
195 return submission_handlers[view_id](turb, payload, metadata)
197 print("Error: Unknown view ID: {}".format(view_id))
202 def rot(turb, body, args):
203 """Implementation of the /rot command
205 The args string should be as follows:
207 [count|*] String to be rotated
209 That is, the first word of the string is an optional number (or
210 the character '*'). If this is a number it indicates an amount to
211 rotate each character in the string. If the count is '*' or is not
212 present, then the string will be rotated through all possible 25
215 The result of the rotation is returned (with Slack formatting) in
216 the body of the response so that Slack will provide it as a reply
217 to the user who submitted the slash command."""
219 channel_name = body['channel_name'][0]
220 response_url = body['response_url'][0]
221 channel_id = body['channel_id'][0]
223 result = turbot.rot.rot(args)
225 if (channel_name == "directmessage"):
226 requests.post(response_url,
227 json = {"text": result},
228 headers = {"Content-type": "application/json"})
230 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
234 commands["/rot"] = rot
236 def get_table_item(turb, table_name, key, value):
237 """Get an item from the database 'table_name' with 'key' as 'value'
239 Returns a tuple of (item, table) if found and (None, None) otherwise."""
241 table = turb.db.Table(table_name)
243 response = table.get_item(Key={key: value})
245 if 'Item' in response:
246 return (response['Item'], table)
250 def channel_is_puzzle(turb, channel_id, channel_name):
251 """Given a channel ID/name return the database item for the puzzle
253 If this channel is a puzzle, this function returns a tuple:
257 Where puzzle is dict filled with database entries, and table is a
258 database table that can be used to update the puzzle in the
261 Otherwise, this function returns (None, None)."""
263 hunt_id = channel_name.split('-')[0]
265 # Not a puzzle channel if there is no hyphen in the name
266 if hunt_id == channel_name:
269 return get_table_item(turb, hunt_id, 'channel_id', channel_id)
271 def channel_is_hunt(turb, channel_id):
273 """Given a channel ID/name return the database item for the hunt
275 Returns a dict (filled with database entries) if there is a hunt
276 for this channel, otherwise returns None."""
278 return get_table_item(turb, "channel_id_index", 'channel_id', channel_id)
280 def find_hunt_for_hunt_id(turb, hunt_id):
281 """Given a hunt ID find the database for for that hunt
283 Returns None if hunt ID is not found, otherwise a
284 dictionary with all fields from the hunt's row in the table,
285 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
288 turbot_table = turb.db.Table("turbot")
290 response = turbot_table.get_item(Key={'PK': 'hunt-{}'.format(hunt_id)})
292 if 'Item' in response:
293 return response['Item']
297 def find_hunt_for_channel(turb, channel_id, channel_name):
298 """Given a channel ID/name find the id/name of the hunt for this channel
300 This works whether the original channel is a primary hunt channel,
301 or if it is one of the channels of a puzzle belonging to the hunt.
303 Returns None if channel does not belong to a hunt, otherwise a
304 dictionary with all fields from the hunt's row in the table,
305 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
309 (hunt, _) = channel_is_hunt(turb, channel_id)
314 # So we're not a hunt channel, let's look to see if we are a
315 # puzzle channel with a hunt-id prefix.
316 hunt_id = channel_name.split('-')[0]
318 return find_hunt_for_hunt_id(turb, hunt_id)
320 def puzzle(turb, body, args):
321 """Implementation of the /puzzle command
323 The args string is currently ignored (this command will bring up
324 a modal dialog for user input instead)."""
326 channel_id = body['channel_id'][0]
327 channel_name = body['channel_name'][0]
328 trigger_id = body['trigger_id'][0]
330 hunt = find_hunt_for_channel(turb,
335 return bot_reply("Sorry, this channel doesn't appear to "
336 + "be a hunt or puzzle channel")
340 "private_metadata": json.dumps({
341 "hunt_id": hunt['hunt_id'],
343 "title": {"type": "plain_text", "text": "New Puzzle"},
344 "submit": { "type": "plain_text", "text": "Create" },
346 section_block(text_block("*For {}*".format(hunt['name']))),
347 input_block("Puzzle name", "name", "Name of the puzzle"),
348 input_block("Puzzle URL", "url", "External URL of puzzle",
353 result = turb.slack_client.views_open(trigger_id=trigger_id,
357 submission_handlers[result['view']['id']] = puzzle_submission
361 commands["/puzzle"] = puzzle
363 def puzzle_submission(turb, payload, metadata):
364 """Handler for the user submitting the new puzzle modal
366 This is the modal view presented to the user by the puzzle function
369 meta = json.loads(metadata)
370 hunt_id = meta['hunt_id']
372 state = payload['view']['state']['values']
373 name = state['name']['name']['value']
374 url = state['url']['url']['value']
376 # Create a Slack-channel-safe puzzle_id
377 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
379 # Create a channel for the puzzle
380 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
383 response = turb.slack_client.conversations_create(
384 name=hunt_dash_channel)
385 except SlackApiError as e:
386 return submission_error(
388 "Error creating Slack channel {}: {}"
389 .format(hunt_dash_channel, e.response['error']))
391 puzzle_channel_id = response['channel']['id']
393 # Insert the newly-created puzzle into the database
394 table = turb.db.Table(hunt_id)
397 "channel_id": puzzle_channel_id,
399 "status": 'unsolved',
402 "puzzle_id": puzzle_id,
409 # XXX: This duplicates functionality eith events.py:set_channel_description
410 def set_channel_topic(turb, puzzle):
411 channel_id = puzzle['channel_id']
412 name = puzzle['name']
413 url = puzzle.get('url', None)
414 sheet_url = puzzle.get('sheet_url', None)
415 state = puzzle.get('state', None)
416 status = puzzle['status']
420 if status == 'solved':
421 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
427 links.append("<{}|Puzzle>".format(url))
429 links.append("<{}|Sheet>".format(sheet_url))
432 description += "({})".format(', '.join(links))
435 description += " {}".format(state)
437 turb.slack_client.conversations_setTopic(channel=channel_id,
440 def state(turb, body, args):
441 """Implementation of the /state command
443 The args string should be a brief sentence describing where things
444 stand or what's needed."""
446 channel_id = body['channel_id'][0]
447 channel_name = body['channel_name'][0]
449 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
452 return bot_reply("Sorry, this is not a puzzle channel.")
454 # Set the state field in the database
455 puzzle['state'] = args
456 table.put_item(Item=puzzle)
458 set_channel_topic(turb, puzzle)
462 commands["/state"] = state
464 def solved(turb, body, args):
465 """Implementation of the /solved command
467 The args string should be a confirmed solution."""
469 channel_id = body['channel_id'][0]
470 channel_name = body['channel_name'][0]
471 user_name = body['user_name'][0]
473 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
476 return bot_reply("Sorry, this is not a puzzle channel.")
478 # Set the status and solution fields in the database
479 puzzle['status'] = 'solved'
480 puzzle['solution'].append(args)
481 table.put_item(Item=puzzle)
483 # Report the solution to the puzzle's channel
485 turb.slack_client, channel_id,
486 "Puzzle mark solved by {}: `{}`".format(user_name, args))
488 # Also report the solution to the hunt channel
489 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
491 turb.slack_client, hunt['channel_id'],
492 "Puzzle <{}|{}> has been solved!".format(
493 puzzle['channel_url'],
497 # And update the puzzle's description
498 set_channel_topic(turb, puzzle)
500 # And rename the sheet to prefix with "SOLVED: "
501 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
502 'SOLVED: ' + puzzle['name'])
504 # Finally, rename the Slack channel to add the suffix '-solved'
505 channel_name = "{}-{}-solved".format(
508 turb.slack_client.conversations_rename(
509 channel=puzzle['channel_id'],
514 commands["/solved"] = solved