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 hunts table exists
99 hunts_table = turb.db.Table("hunts")
102 exists = hunts_table.table_status in ("CREATING", "UPDATING",
107 # Create the hunts table if necessary.
109 hunts_table = turb.db.create_table(
112 {'AttributeName': 'channel_id', 'KeyType': 'HASH'},
114 AttributeDefinitions=[
115 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
117 ProvisionedThroughput={
118 'ReadCapacityUnits': 5,
119 'WriteCapacityUnits': 5
122 return submission_error("hunt_id",
123 "Still bootstrapping hunts table. Try again.")
125 # Create a channel for the hunt
127 response = turb.slack_client.conversations_create(name=hunt_id)
128 except SlackApiError as e:
129 return submission_error("hunt_id",
130 "Error creating Slack channel: {}"
131 .format(e.response['error']))
133 channel_id = response['channel']['id']
135 # Insert the newly-created hunt into the database
136 # (leaving it as non-active for now until the channel-created handler
137 # finishes fixing it up with a sheet and a companion table)
138 hunts_table.put_item(
140 'channel_id': channel_id,
148 # Invite the initiating user to the channel
149 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
153 def view_submission(turb, payload):
154 """Handler for Slack interactive view submission
156 Specifically, those that have a payload type of 'view_submission'"""
158 view_id = payload['view']['id']
159 metadata = payload['view']['private_metadata']
161 if view_id in submission_handlers:
162 return submission_handlers[view_id](turb, payload, metadata)
164 print("Error: Unknown view ID: {}".format(view_id))
169 def rot(turb, body, args):
170 """Implementation of the /rot command
172 The args string should be as follows:
174 [count|*] String to be rotated
176 That is, the first word of the string is an optional number (or
177 the character '*'). If this is a number it indicates an amount to
178 rotate each character in the string. If the count is '*' or is not
179 present, then the string will be rotated through all possible 25
182 The result of the rotation is returned (with Slack formatting) in
183 the body of the response so that Slack will provide it as a reply
184 to the user who submitted the slash command."""
186 channel_name = body['channel_name'][0]
187 response_url = body['response_url'][0]
188 channel_id = body['channel_id'][0]
190 result = turbot.rot.rot(args)
192 if (channel_name == "directmessage"):
193 requests.post(response_url,
194 json = {"text": result},
195 headers = {"Content-type": "application/json"})
197 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
201 commands["/rot"] = rot
203 def get_table_item(turb, table_name, key, value):
204 """Get an item from the database 'table_name' with 'key' as 'value'
206 Returns a tuple of (item, table) if found and (None, None) otherwise."""
208 table = turb.db.Table(table_name)
210 response = table.get_item(Key={key: value})
212 if 'Item' in response:
213 return (response['Item'], table)
217 def channel_is_puzzle(turb, channel_id, channel_name):
218 """Given a channel ID/name return the database item for the puzzle
220 If this channel is a puzzle, this function returns a tuple:
224 Where puzzle is dict filled with database entries, and table is a
225 database table that can be used to update the puzzle in the
228 Otherwise, this function returns (None, None)."""
230 hunt_id = channel_name.split('-')[0]
232 # Not a puzzle channel if there is no hyphen in the name
233 if hunt_id == channel_name:
236 return get_table_item(turb, hunt_id, 'channel_id', channel_id)
238 def channel_is_hunt(turb, channel_id):
240 """Given a channel ID/name return the database item for the hunt
242 Returns a dict (filled with database entries) if there is a hunt
243 for this channel, otherwise returns None."""
245 return get_table_item(turb, "hunts", 'channel_id', channel_id)
247 def find_hunt_for_hunt_id(turb, hunt_id):
248 """Given a hunt ID find the database for for that hunt
250 Returns None if hunt ID is not found, otherwise a
251 dictionary with all fields from the hunt's row in the table,
252 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
255 hunts_table = turb.db.Table("hunts")
257 response = hunts_table.scan(
258 FilterExpression='hunt_id = :hunt_id',
259 ExpressionAttributeValues={':hunt_id': hunt_id}
262 if 'Items' in response and len(response['Items']):
263 item = response['Items'][0]
268 def find_hunt_for_channel(turb, channel_id, channel_name):
269 """Given a channel ID/name find the id/name of the hunt for this channel
271 This works whether the original channel is a primary hunt channel,
272 or if it is one of the channels of a puzzle belonging to the hunt.
274 Returns None if channel does not belong to a hunt, otherwise a
275 dictionary with all fields from the hunt's row in the table,
276 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
280 (hunt, _) = channel_is_hunt(turb, channel_id)
285 # So we're not a hunt channel, let's look to see if we are a
286 # puzzle channel with a hunt-id prefix.
287 hunt_id = channel_name.split('-')[0]
289 return find_hunt_for_hunt_id(turb, hunt_id)
291 def puzzle(turb, body, args):
292 """Implementation of the /puzzle command
294 The args string is currently ignored (this command will bring up
295 a modal dialog for user input instead)."""
297 channel_id = body['channel_id'][0]
298 channel_name = body['channel_name'][0]
299 trigger_id = body['trigger_id'][0]
301 hunt = find_hunt_for_channel(turb,
306 return bot_reply("Sorry, this channel doesn't appear to "
307 + "be a hunt or puzzle channel")
311 "private_metadata": json.dumps({
312 "hunt_id": hunt['hunt_id'],
314 "title": {"type": "plain_text", "text": "New Puzzle"},
315 "submit": { "type": "plain_text", "text": "Create" },
317 section_block(text_block("*For {}*".format(hunt['name']))),
318 input_block("Puzzle name", "name", "Name of the puzzle"),
319 input_block("Puzzle URL", "url", "External URL of puzzle",
324 result = turb.slack_client.views_open(trigger_id=trigger_id,
328 submission_handlers[result['view']['id']] = puzzle_submission
332 commands["/puzzle"] = puzzle
334 def puzzle_submission(turb, payload, metadata):
335 """Handler for the user submitting the new puzzle modal
337 This is the modal view presented to the user by the puzzle function
340 meta = json.loads(metadata)
341 hunt_id = meta['hunt_id']
343 state = payload['view']['state']['values']
344 name = state['name']['name']['value']
345 url = state['url']['url']['value']
347 # Create a Slack-channel-safe puzzle_id
348 puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
350 # Create a channel for the puzzle
351 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
354 response = turb.slack_client.conversations_create(
355 name=hunt_dash_channel)
356 except SlackApiError as e:
357 return submission_error(
359 "Error creating Slack channel {}: {}"
360 .format(hunt_dash_channel, e.response['error']))
362 puzzle_channel_id = response['channel']['id']
364 # Insert the newly-created puzzle into the database
365 table = turb.db.Table(hunt_id)
368 "channel_id": puzzle_channel_id,
370 "status": 'unsolved',
373 "puzzle_id": puzzle_id,
380 # XXX: This duplicates functionality eith events.py:set_channel_description
381 def set_channel_topic(turb, puzzle):
382 channel_id = puzzle['channel_id']
383 name = puzzle['name']
384 url = puzzle.get('url', None)
385 sheet_url = puzzle.get('sheet_url', None)
386 state = puzzle.get('state', None)
387 status = puzzle['status']
391 if status == 'solved':
392 description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
398 links.append("<{}|Puzzle>".format(url))
400 links.append("<{}|Sheet>".format(sheet_url))
403 description += "({})".format(', '.join(links))
406 description += " {}".format(state)
408 turb.slack_client.conversations_setTopic(channel=channel_id,
411 def state(turb, body, args):
412 """Implementation of the /state command
414 The args string should be a brief sentence describing where things
415 stand or what's needed."""
417 channel_id = body['channel_id'][0]
418 channel_name = body['channel_name'][0]
420 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
423 return bot_reply("Sorry, this is not a puzzle channel.")
425 # Set the state field in the database
426 puzzle['state'] = args
427 table.put_item(Item=puzzle)
429 set_channel_topic(turb, puzzle)
433 commands["/state"] = state
435 def solved(turb, body, args):
436 """Implementation of the /solved command
438 The args string should be a confirmed solution."""
440 channel_id = body['channel_id'][0]
441 channel_name = body['channel_name'][0]
442 user_name = body['user_name'][0]
444 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
447 return bot_reply("Sorry, this is not a puzzle channel.")
449 # Set the status and solution fields in the database
450 puzzle['status'] = 'solved'
451 puzzle['solution'].append(args)
452 table.put_item(Item=puzzle)
454 # Report the solution to the puzzle's channel
456 turb.slack_client, channel_id,
457 "Puzzle mark solved by {}: `{}`".format(user_name, args))
459 # Also report the solution to the hunt channel
460 hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
462 turb.slack_client, hunt['channel_id'],
463 "Puzzle <{}|{}> has been solved!".format(
464 puzzle['channel_url'],
468 # And update the puzzle's description
469 set_channel_topic(turb, puzzle)
471 # And rename the sheet to prefix with "SOLVED: "
472 turbot.sheets.renameSheet(turb, puzzle['sheet_url'], 'SOLVED: ' + puzzle['name'])
474 # Finally, rename the Slack channel to add the suffix '-solved'
475 channel_name = "{}-{}-solved".format(
478 turb.slack_client.conversations_rename(
479 channel=puzzle['channel_id'],
484 commands["/solved"] = solved