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': 'is_hunt', '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': 'is_hunt_index',
141 {'AttributeName': 'is_hunt', '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),
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_hunt_id(turb, hunt_id):
291 """Given a hunt ID find the database for for that hunt
293 Returns None if hunt ID is not found, otherwise a
294 dictionary with all fields from the hunt's row in the table,
295 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
298 turbot_table = turb.db.Table("turbot")
300 response = turbot_table.get_item(Key={'PK': 'hunt-{}'.format(hunt_id)})
302 if 'Item' in response:
303 return response['Item']
307 def find_hunt_for_channel(turb, channel_id, channel_name):
308 """Given a channel ID/name find the id/name of the hunt for this channel
310 This works whether the original channel is a primary hunt channel,
311 or if it is one of the channels of a puzzle belonging to the hunt.
313 Returns None if channel does not belong to a hunt, otherwise a
314 dictionary with all fields from the hunt's row in the table,
315 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
319 hunt = channel_is_hunt(turb, channel_id)
324 # So we're not a hunt channel, let's look to see if we are a
325 # puzzle channel with a hunt-id prefix.
326 hunt_id = channel_name.split('-')[0]
328 return find_hunt_for_hunt_id(turb, hunt_id)
330 def puzzle(turb, body, args):
331 """Implementation of the /puzzle command
333 The args string is currently ignored (this command will bring up
334 a modal dialog for user input instead)."""
336 channel_id = body['channel_id'][0]
337 channel_name = body['channel_name'][0]
338 trigger_id = body['trigger_id'][0]
340 hunt = find_hunt_for_channel(turb,
345 return bot_reply("Sorry, this channel doesn't appear to "
346 + "be a hunt or puzzle channel")
350 "private_metadata": json.dumps({
351 "hunt_id": hunt['hunt_id'],
353 "title": {"type": "plain_text", "text": "New Puzzle"},
354 "submit": { "type": "plain_text", "text": "Create" },
356 section_block(text_block("*For {}*".format(hunt['name']))),
357 input_block("Puzzle name", "name", "Name of the puzzle"),
358 input_block("Puzzle URL", "url", "External URL of puzzle",
363 result = turb.slack_client.views_open(trigger_id=trigger_id,
367 submission_handlers[result['view']['id']] = puzzle_submission
371 commands["/puzzle"] = puzzle
373 def puzzle_submission(turb, payload, metadata):
374 """Handler for the user submitting the new puzzle modal
376 This is the modal view presented to the user by the puzzle function
379 meta = json.loads(metadata)
380 hunt_id = meta['hunt_id']
382 state = payload['view']['state']['values']
383 name = state['name']['name']['value']
384 url = state['url']['url']['value']
386 # Create a Slack-channel-safe puzzle_id
387 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
389 # Create a channel for the puzzle
390 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
393 response = turb.slack_client.conversations_create(
394 name=hunt_dash_channel)
395 except SlackApiError as e:
396 return submission_error(
398 "Error creating Slack channel {}: {}"
399 .format(hunt_dash_channel, e.response['error']))
401 channel_id = response['channel']['id']
403 # Insert the newly-created puzzle into the database
405 "PK": "hunt-{}".format(hunt_id),
406 "SK": "puzzle-{}".format(puzzle_id),
407 "puzzle_id": puzzle_id,
408 "channel_id": channel_id,
410 "status": 'unsolved',
415 turb.table.put_item(Item=item)
419 # XXX: This duplicates functionality eith events.py:set_channel_description
420 def set_channel_topic(turb, puzzle):
421 channel_id = puzzle['channel_id']
422 name = puzzle['name']
423 url = puzzle.get('url', None)
424 sheet_url = puzzle.get('sheet_url', None)
425 state = puzzle.get('state', None)
426 status = puzzle['status']
430 if status == 'solved':
431 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
437 links.append("<{}|Puzzle>".format(url))
439 links.append("<{}|Sheet>".format(sheet_url))
442 description += "({})".format(', '.join(links))
445 description += " {}".format(state)
447 turb.slack_client.conversations_setTopic(channel=channel_id,
450 def state(turb, body, args):
451 """Implementation of the /state command
453 The args string should be a brief sentence describing where things
454 stand or what's needed."""
456 channel_id = body['channel_id'][0]
457 channel_name = body['channel_name'][0]
459 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
462 return bot_reply("Sorry, this is not a puzzle channel.")
464 # Set the state field in the database
465 puzzle['state'] = args
466 table.put_item(Item=puzzle)
468 set_channel_topic(turb, puzzle)
472 commands["/state"] = state
474 def solved(turb, body, args):
475 """Implementation of the /solved command
477 The args string should be a confirmed solution."""
479 channel_id = body['channel_id'][0]
480 channel_name = body['channel_name'][0]
481 user_name = body['user_name'][0]
483 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
486 return bot_reply("Sorry, this is not a puzzle channel.")
488 # Set the status and solution fields in the database
489 puzzle['status'] = 'solved'
490 puzzle['solution'].append(args)
491 table.put_item(Item=puzzle)
493 # Report the solution to the puzzle's channel
495 turb.slack_client, channel_id,
496 "Puzzle mark solved by {}: `{}`".format(user_name, args))
498 # Also report the solution to the hunt channel
499 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
501 turb.slack_client, hunt['channel_id'],
502 "Puzzle <{}|{}> has been solved!".format(
503 puzzle['channel_url'],
507 # And update the puzzle's description
508 set_channel_topic(turb, puzzle)
510 # And rename the sheet to prefix with "SOLVED: "
511 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
512 'SOLVED: ' + puzzle['name'])
514 # Finally, rename the Slack channel to add the suffix '-solved'
515 channel_name = "{}-{}-solved".format(
518 turb.slack_client.conversations_rename(
519 channel=puzzle['channel_id'],
524 commands["/solved"] = solved