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 db_entry_for_channel(turb, channel_id):
253 """Given a channel ID return the database item for this channel
255 If this channel is a registered hunt or puzzle channel, return the
256 corresponding row from the database for this channel. Otherwise,
259 Note: If you need to specifically ensure that the channel is a
260 puzzle or a hunt, please call puzzle_for_channel or
261 hunt_for_channel respectively.
264 response = turb.table.query(
265 IndexName = "channel_id_index",
266 KeyConditionExpression=Key("channel_id").eq(channel_id)
269 if response['Count'] == 0:
272 return response['Items'][0]
275 def puzzle_for_channel(turb, channel_id):
277 """Given a channel ID return the puzzle from the database for this channel
279 If the given channel_id is a puzzle's channel, this function
280 returns a dict filled with the attributes from the puzzle's entry
283 Otherwise, this function returns None.
286 entry = db_entry_for_channel(turb, channel_id)
288 if entry and entry['SK'].startswith('puzzle-'):
293 def hunt_for_channel(turb, channel_id):
295 """Given a channel ID return the hunt from the database for this channel
297 This works whether the original channel is a primary hunt channel,
298 or if it is one of the channels of a puzzle belonging to the hunt.
300 Returns None if channel does not belong to a hunt, otherwise a
301 dictionary with all fields from the hunt's row in the table,
302 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
305 entry = db_entry_for_channel(turb, channel_id)
307 # We're done if this channel doesn't exist in the database at all
311 # Also done if this channel is a hunt channel
312 if entry['SK'].startswith('hunt-'):
315 # Otherwise, (the channel is in the database, but is not a hunt),
316 # we expect this to be a puzzle channel instead
317 return find_hunt_for_hunt_id(turb, entry['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 trigger_id = body['trigger_id'][0]
328 hunt = hunt_for_channel(turb, channel_id)
331 return bot_reply("Sorry, this channel doesn't appear to "
332 + "be a hunt or puzzle channel")
336 "private_metadata": json.dumps({
337 "hunt_id": hunt['hunt_id'],
339 "title": {"type": "plain_text", "text": "New Puzzle"},
340 "submit": { "type": "plain_text", "text": "Create" },
342 section_block(text_block("*For {}*".format(hunt['name']))),
343 input_block("Puzzle name", "name", "Name of the puzzle"),
344 input_block("Puzzle URL", "url", "External URL of puzzle",
349 result = turb.slack_client.views_open(trigger_id=trigger_id,
353 submission_handlers[result['view']['id']] = puzzle_submission
357 commands["/puzzle"] = puzzle
359 def puzzle_submission(turb, payload, metadata):
360 """Handler for the user submitting the new puzzle modal
362 This is the modal view presented to the user by the puzzle function
365 meta = json.loads(metadata)
366 hunt_id = meta['hunt_id']
368 state = payload['view']['state']['values']
369 name = state['name']['name']['value']
370 url = state['url']['url']['value']
372 # Create a Slack-channel-safe puzzle_id
373 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
375 # Create a channel for the puzzle
376 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
379 response = turb.slack_client.conversations_create(
380 name=hunt_dash_channel)
381 except SlackApiError as e:
382 return submission_error(
384 "Error creating Slack channel {}: {}"
385 .format(hunt_dash_channel, e.response['error']))
387 channel_id = response['channel']['id']
389 # Insert the newly-created puzzle into the database
392 "SK": "puzzle-{}".format(puzzle_id),
393 "puzzle_id": puzzle_id,
394 "channel_id": channel_id,
396 "status": 'unsolved',
401 turb.table.put_item(Item=item)
405 # XXX: This duplicates functionality eith events.py:set_channel_description
406 def set_channel_topic(turb, puzzle):
407 channel_id = puzzle['channel_id']
408 name = puzzle['name']
409 url = puzzle.get('url', None)
410 sheet_url = puzzle.get('sheet_url', None)
411 state = puzzle.get('state', None)
412 status = puzzle['status']
416 if status == 'solved':
417 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
423 links.append("<{}|Puzzle>".format(url))
425 links.append("<{}|Sheet>".format(sheet_url))
428 description += "({})".format(', '.join(links))
431 description += " {}".format(state)
433 turb.slack_client.conversations_setTopic(channel=channel_id,
436 def state(turb, body, args):
437 """Implementation of the /state command
439 The args string should be a brief sentence describing where things
440 stand or what's needed."""
442 channel_id = body['channel_id'][0]
444 puzzle = puzzle_for_channel(turb, channel_id)
448 "Sorry, the /state command only works in a puzzle channel")
450 # Set the state field in the database
451 puzzle['state'] = args
452 turb.table.put_item(Item=puzzle)
454 set_channel_topic(turb, puzzle)
458 commands["/state"] = state
460 def solved(turb, body, args):
461 """Implementation of the /solved command
463 The args string should be a confirmed solution."""
465 channel_id = body['channel_id'][0]
466 user_name = body['user_name'][0]
468 puzzle = puzzle_for_channel(turb, channel_id)
471 return bot_reply("Sorry, this is not a puzzle channel.")
473 # Set the status and solution fields in the database
474 puzzle['status'] = 'solved'
475 puzzle['solution'].append(args)
476 turb.table.put_item(Item=puzzle)
478 # Report the solution to the puzzle's channel
480 turb.slack_client, channel_id,
481 "Puzzle mark solved by {}: `{}`".format(user_name, args))
483 # Also report the solution to the hunt channel
484 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
486 turb.slack_client, hunt['channel_id'],
487 "Puzzle <{}|{}> has been solved!".format(
488 puzzle['channel_url'],
492 # And update the puzzle's description
493 set_channel_topic(turb, puzzle)
495 # And rename the sheet to prefix with "SOLVED: "
496 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
497 'SOLVED: ' + puzzle['name'])
499 # Finally, rename the Slack channel to add the suffix '-solved'
500 channel_name = "{}-{}-solved".format(
503 turb.slack_client.conversations_rename(
504 channel=puzzle['channel_id'],
509 commands["/solved"] = solved