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'},
118 ProvisionedThroughput={
119 'ReadCapacityUnits': 5,
120 'WriteCapacityUnits': 5
122 GlobalSecondaryIndexes=[
124 'IndexName': 'channel_id_index',
126 {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
129 'ProjectionType': 'ALL'
131 'ProvisionedThroughput': {
132 'ReadCapacityUnits': 5,
133 'WriteCapacityUnits': 5
138 return submission_error("hunt_id",
139 "Still bootstrapping turbot table. Try again.")
141 # Create a channel for the hunt
143 response = turb.slack_client.conversations_create(name=hunt_id)
144 except SlackApiError as e:
145 return submission_error("hunt_id",
146 "Error creating Slack channel: {}"
147 .format(e.response['error']))
149 channel_id = response['channel']['id']
151 # Insert the newly-created hunt into the database
152 # (leaving it as non-active for now until the channel-created handler
153 # finishes fixing it up with a sheet and a companion table)
156 "PK": "hunt-{}".format(hunt_id),
157 "SK": "hunt-{}".format(hunt_id),
158 "channel_id": channel_id,
165 # Invite the initiating user to the channel
166 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
170 def view_submission(turb, payload):
171 """Handler for Slack interactive view submission
173 Specifically, those that have a payload type of 'view_submission'"""
175 view_id = payload['view']['id']
176 metadata = payload['view']['private_metadata']
178 if view_id in submission_handlers:
179 return submission_handlers[view_id](turb, payload, metadata)
181 print("Error: Unknown view ID: {}".format(view_id))
186 def rot(turb, body, args):
187 """Implementation of the /rot command
189 The args string should be as follows:
191 [count|*] String to be rotated
193 That is, the first word of the string is an optional number (or
194 the character '*'). If this is a number it indicates an amount to
195 rotate each character in the string. If the count is '*' or is not
196 present, then the string will be rotated through all possible 25
199 The result of the rotation is returned (with Slack formatting) in
200 the body of the response so that Slack will provide it as a reply
201 to the user who submitted the slash command."""
203 channel_name = body['channel_name'][0]
204 response_url = body['response_url'][0]
205 channel_id = body['channel_id'][0]
207 result = turbot.rot.rot(args)
209 if (channel_name == "directmessage"):
210 requests.post(response_url,
211 json = {"text": result},
212 headers = {"Content-type": "application/json"})
214 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
218 commands["/rot"] = rot
220 def get_table_item(turb, table_name, key, value):
221 """Get an item from the database 'table_name' with 'key' as 'value'
223 Returns a tuple of (item, table) if found and (None, None) otherwise."""
225 table = turb.db.Table(table_name)
227 response = table.get_item(Key={key: value})
229 if 'Item' in response:
230 return (response['Item'], table)
234 def channel_is_puzzle(turb, channel_id, channel_name):
235 """Given a channel ID/name return the database item for the puzzle
237 If this channel is a puzzle, this function returns a tuple:
241 Where puzzle is dict filled with database entries, and table is a
242 database table that can be used to update the puzzle in the
245 Otherwise, this function returns (None, None)."""
247 hunt_id = channel_name.split('-')[0]
249 # Not a puzzle channel if there is no hyphen in the name
250 if hunt_id == channel_name:
253 return get_table_item(turb, hunt_id, 'channel_id', channel_id)
255 def channel_is_hunt(turb, channel_id):
257 """Given a channel ID/name return the database item for the hunt
259 Returns a dict (filled with database entries) if there is a hunt
260 for this channel, otherwise returns None."""
262 return get_table_item(turb, "channel_id_index", 'channel_id', channel_id)
264 def find_hunt_for_hunt_id(turb, hunt_id):
265 """Given a hunt ID find the database for for that hunt
267 Returns None if hunt ID is not found, otherwise a
268 dictionary with all fields from the hunt's row in the table,
269 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
272 turbot_table = turb.db.Table("turbot")
274 response = turbot_table.get_item(Key={'PK': 'hunt-{}'.format(hunt_id)})
276 if 'Item' in response:
277 return response['Item']
281 def find_hunt_for_channel(turb, channel_id, channel_name):
282 """Given a channel ID/name find the id/name of the hunt for this channel
284 This works whether the original channel is a primary hunt channel,
285 or if it is one of the channels of a puzzle belonging to the hunt.
287 Returns None if channel does not belong to a hunt, otherwise a
288 dictionary with all fields from the hunt's row in the table,
289 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
293 (hunt, _) = channel_is_hunt(turb, channel_id)
298 # So we're not a hunt channel, let's look to see if we are a
299 # puzzle channel with a hunt-id prefix.
300 hunt_id = channel_name.split('-')[0]
302 return find_hunt_for_hunt_id(turb, hunt_id)
304 def puzzle(turb, body, args):
305 """Implementation of the /puzzle command
307 The args string is currently ignored (this command will bring up
308 a modal dialog for user input instead)."""
310 channel_id = body['channel_id'][0]
311 channel_name = body['channel_name'][0]
312 trigger_id = body['trigger_id'][0]
314 hunt = find_hunt_for_channel(turb,
319 return bot_reply("Sorry, this channel doesn't appear to "
320 + "be a hunt or puzzle channel")
324 "private_metadata": json.dumps({
325 "hunt_id": hunt['hunt_id'],
327 "title": {"type": "plain_text", "text": "New Puzzle"},
328 "submit": { "type": "plain_text", "text": "Create" },
330 section_block(text_block("*For {}*".format(hunt['name']))),
331 input_block("Puzzle name", "name", "Name of the puzzle"),
332 input_block("Puzzle URL", "url", "External URL of puzzle",
337 result = turb.slack_client.views_open(trigger_id=trigger_id,
341 submission_handlers[result['view']['id']] = puzzle_submission
345 commands["/puzzle"] = puzzle
347 def puzzle_submission(turb, payload, metadata):
348 """Handler for the user submitting the new puzzle modal
350 This is the modal view presented to the user by the puzzle function
353 meta = json.loads(metadata)
354 hunt_id = meta['hunt_id']
356 state = payload['view']['state']['values']
357 name = state['name']['name']['value']
358 url = state['url']['url']['value']
360 # Create a Slack-channel-safe puzzle_id
361 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
363 # Create a channel for the puzzle
364 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
367 response = turb.slack_client.conversations_create(
368 name=hunt_dash_channel)
369 except SlackApiError as e:
370 return submission_error(
372 "Error creating Slack channel {}: {}"
373 .format(hunt_dash_channel, e.response['error']))
375 puzzle_channel_id = response['channel']['id']
377 # Insert the newly-created puzzle into the database
378 table = turb.db.Table(hunt_id)
381 "channel_id": puzzle_channel_id,
383 "status": 'unsolved',
386 "puzzle_id": puzzle_id,
393 # XXX: This duplicates functionality eith events.py:set_channel_description
394 def set_channel_topic(turb, puzzle):
395 channel_id = puzzle['channel_id']
396 name = puzzle['name']
397 url = puzzle.get('url', None)
398 sheet_url = puzzle.get('sheet_url', None)
399 state = puzzle.get('state', None)
400 status = puzzle['status']
404 if status == 'solved':
405 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
411 links.append("<{}|Puzzle>".format(url))
413 links.append("<{}|Sheet>".format(sheet_url))
416 description += "({})".format(', '.join(links))
419 description += " {}".format(state)
421 turb.slack_client.conversations_setTopic(channel=channel_id,
424 def state(turb, body, args):
425 """Implementation of the /state command
427 The args string should be a brief sentence describing where things
428 stand or what's needed."""
430 channel_id = body['channel_id'][0]
431 channel_name = body['channel_name'][0]
433 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
436 return bot_reply("Sorry, this is not a puzzle channel.")
438 # Set the state field in the database
439 puzzle['state'] = args
440 table.put_item(Item=puzzle)
442 set_channel_topic(turb, puzzle)
446 commands["/state"] = state
448 def solved(turb, body, args):
449 """Implementation of the /solved command
451 The args string should be a confirmed solution."""
453 channel_id = body['channel_id'][0]
454 channel_name = body['channel_name'][0]
455 user_name = body['user_name'][0]
457 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
460 return bot_reply("Sorry, this is not a puzzle channel.")
462 # Set the status and solution fields in the database
463 puzzle['status'] = 'solved'
464 puzzle['solution'].append(args)
465 table.put_item(Item=puzzle)
467 # Report the solution to the puzzle's channel
469 turb.slack_client, channel_id,
470 "Puzzle mark solved by {}: `{}`".format(user_name, args))
472 # Also report the solution to the hunt channel
473 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
475 turb.slack_client, hunt['channel_id'],
476 "Puzzle <{}|{}> has been solved!".format(
477 puzzle['channel_url'],
481 # And update the puzzle's description
482 set_channel_topic(turb, puzzle)
484 # And rename the sheet to prefix with "SOLVED: "
485 turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
486 'SOLVED: ' + puzzle['name'])
488 # Finally, rename the Slack channel to add the suffix '-solved'
489 channel_name = "{}-{}-solved".format(
492 turb.slack_client.conversations_rename(
493 channel=puzzle['channel_id'],
498 commands["/solved"] = solved