1 from slack.errors import SlackApiError
2 from turbot.blocks import input_block, section_block, text_block
9 from botocore.exceptions import ClientError
13 submission_handlers = {}
15 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
16 valid_id_re = r'^[_a-z0-9]+$'
18 lambda_ok = {'statusCode': 200}
20 def bot_reply(message):
21 """Construct a return value suitable for a bot reply
23 This is suitable as a way to give an error back to the user who
24 initiated a slash command, for example."""
31 def submission_error(field, error):
32 """Construct an error suitable for returning for an invalid submission.
34 Returning this value will prevent a submission and alert the user that
35 the given field is invalid because of the given error."""
37 print("Rejecting invalid modal submission: {}".format(error))
42 "Content-Type": "application/json"
45 "response_action": "errors",
52 def new_hunt(turb, payload):
53 """Handler for the action of user pressing the new_hunt button"""
57 "private_metadata": json.dumps({}),
58 "title": { "type": "plain_text", "text": "New Hunt" },
59 "submit": { "type": "plain_text", "text": "Create" },
61 input_block("Hunt name", "name", "Name of the hunt"),
62 input_block("Hunt ID", "hunt_id",
63 "Used as puzzle channel prefix "
64 + "(no spaces nor punctuation)"),
65 input_block("Hunt URL", "url", "External URL of hunt",
70 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
73 submission_handlers[result['view']['id']] = new_hunt_submission
77 actions['button'] = {"new_hunt": new_hunt}
79 def new_hunt_submission(turb, payload, metadata):
80 """Handler for the user submitting the new hunt modal
82 This is the modal view presented to the user by the new_hunt
85 state = payload['view']['state']['values']
86 user_id = payload['user']['id']
87 name = state['name']['name']['value']
88 hunt_id = state['hunt_id']['hunt_id']['value']
89 url = state['url']['url']['value']
91 # Validate that the hunt_id contains no invalid characters
92 if not re.match(valid_id_re, hunt_id):
93 return submission_error("hunt_id",
94 "Hunt ID can only contain lowercase letters, "
95 + "numbers, and underscores")
97 # Check to see if the hunts table exists
98 hunts_table = turb.db.Table("hunts")
101 exists = hunts_table.table_status in ("CREATING", "UPDATING",
106 # Create the hunts table if necessary.
108 hunts_table = turb.db.create_table(
111 {'AttributeName': 'channel_id', 'KeyType': 'HASH'},
113 AttributeDefinitions=[
114 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
116 ProvisionedThroughput={
117 'ReadCapacityUnits': 5,
118 'WriteCapacityUnits': 5
121 return submission_error("hunt_id",
122 "Still bootstrapping hunts table. Try again.")
124 # Create a channel for the hunt
126 response = turb.slack_client.conversations_create(name=hunt_id)
127 except SlackApiError as e:
128 return submission_error("hunt_id",
129 "Error creating Slack channel: {}"
130 .format(e.response['error']))
132 channel_id = response['channel']['id']
134 # Insert the newly-created hunt into the database
135 # (leaving it as non-active for now until the channel-created handler
136 # finishes fixing it up with a sheet and a companion table)
137 hunts_table.put_item(
139 'channel_id': channel_id,
147 # Invite the initiating user to the channel
148 turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
152 def view_submission(turb, payload):
153 """Handler for Slack interactive view submission
155 Specifically, those that have a payload type of 'view_submission'"""
157 view_id = payload['view']['id']
158 metadata = payload['view']['private_metadata']
160 if view_id in submission_handlers:
161 return submission_handlers[view_id](turb, payload, metadata)
163 print("Error: Unknown view ID: {}".format(view_id))
168 def rot(turb, body, args):
169 """Implementation of the /rot command
171 The args string should be as follows:
173 [count|*] String to be rotated
175 That is, the first word of the string is an optional number (or
176 the character '*'). If this is a number it indicates an amount to
177 rotate each character in the string. If the count is '*' or is not
178 present, then the string will be rotated through all possible 25
181 The result of the rotation is returned (with Slack formatting) in
182 the body of the response so that Slack will provide it as a reply
183 to the user who submitted the slash command."""
185 channel_name = body['channel_name'][0]
186 response_url = body['response_url'][0]
187 channel_id = body['channel_id'][0]
189 result = turbot.rot.rot(args)
191 if (channel_name == "directmessage"):
192 requests.post(response_url,
193 json = {"text": result},
194 headers = {"Content-type": "application/json"})
196 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
200 commands["/rot"] = rot
202 def get_table_item(turb, table_name, key, value):
203 """Get an item from the database 'table_name' with 'key' as 'value'
205 Returns a tuple of (item, table) if found and (None, None) otherwise."""
207 table = turb.db.Table(table_name)
209 response = table.get_item(Key={key: value})
211 if 'Item' in response:
212 return (response['Item'], table)
216 def channel_is_puzzle(turb, channel_id, channel_name):
217 """Given a channel ID/name return the database item for the puzzle
219 If this channel is a puzzle, this function returns a tuple:
223 Where puzzle is dict filled with database entries, and table is a
224 database table that can be used to update the puzzle in the
227 Otherwise, this function returns (None, None)."""
229 hunt_id = channel_name.split('-')[0]
231 # Not a puzzle channel if there is no hyphen in the name
232 if hunt_id == channel_name:
235 return get_table_item(turb, hunt_id, 'channel_id', channel_id)
237 def channel_is_hunt(turb, channel_id):
239 """Given a channel ID/name return the database item for the hunt
241 Returns a dict (filled with database entries) if there is a hunt
242 for this channel, otherwise returns None."""
244 return get_table_item(turb, "hunts", 'channel_id', channel_id)
246 def find_hunt_for_channel(turb, channel_id, channel_name):
247 """Given a channel ID/name find the id/name of the hunt for this channel
249 This works whether the original channel is a primary hunt channel,
250 or if it is one of the channels of a puzzle belonging to the hunt.
252 Returns a tuple of (hunt_name, hunt_id) or (None, None)."""
254 (hunt, _) = channel_is_hunt(turb, channel_id)
257 return (hunt['hunt_id'], hunt['name'])
259 # So we're not a hunt channel, let's look to see if we are a
260 # puzzle channel with a hunt-id prefix.
261 hunt_id = channel_name.split('-')[0]
263 hunts_table = turb.db.Table("hunts")
265 response = hunts_table.scan(
266 FilterExpression='hunt_id = :hunt_id',
267 ExpressionAttributeValues={':hunt_id': hunt_id}
270 if 'Items' in response and len(response['Items']):
271 item = response['Items'][0]
272 return (item['hunt_id'], item['name'])
276 def puzzle(turb, body, args):
277 """Implementation of the /puzzle command
279 The args string is currently ignored (this command will bring up
280 a modal dialog for user input instead)."""
282 channel_id = body['channel_id'][0]
283 channel_name = body['channel_name'][0]
284 trigger_id = body['trigger_id'][0]
286 (hunt_id, hunt_name) = find_hunt_for_channel(turb,
291 return bot_reply("Sorry, this channel doesn't appear to "
292 + "be a hunt or puzzle channel")
296 "private_metadata": json.dumps({
299 "title": {"type": "plain_text", "text": "New Puzzle"},
300 "submit": { "type": "plain_text", "text": "Create" },
302 section_block(text_block("*For {}*".format(hunt_name))),
303 input_block("Puzzle name", "name", "Name of the puzzle"),
304 input_block("Puzzle ID", "puzzle_id",
305 "Used as part of channel name "
306 + "(no spaces nor punctuation)"),
307 input_block("Puzzle URL", "url", "External URL of puzzle",
312 result = turb.slack_client.views_open(trigger_id=trigger_id,
316 submission_handlers[result['view']['id']] = puzzle_submission
320 commands["/puzzle"] = puzzle
322 def puzzle_submission(turb, payload, metadata):
323 """Handler for the user submitting the new puzzle modal
325 This is the modal view presented to the user by the puzzle function
328 meta = json.loads(metadata)
329 hunt_id = meta['hunt_id']
331 state = payload['view']['state']['values']
332 name = state['name']['name']['value']
333 puzzle_id = state['puzzle_id']['puzzle_id']['value']
334 url = state['url']['url']['value']
336 # Validate that the puzzle_id contains no invalid characters
337 if not re.match(valid_id_re, puzzle_id):
338 return submission_error("puzzle_id",
339 "Puzzle ID can only contain lowercase letters,"
340 + " numbers, and underscores")
342 # Create a channel for the puzzle
343 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
346 response = turb.slack_client.conversations_create(
347 name=hunt_dash_channel)
348 except SlackApiError as e:
349 return submission_error("puzzle_id",
350 "Error creating Slack channel: {}"
351 .format(e.response['error']))
353 puzzle_channel_id = response['channel']['id']
355 # Insert the newly-created puzzle into the database
356 table = turb.db.Table(hunt_id)
359 "channel_id": puzzle_channel_id,
361 "status": 'unsolved',
363 "puzzle_id": puzzle_id,
370 # XXX: This duplicates functionality eith events.py:set_channel_description
371 def set_channel_topic(turb, puzzle):
372 channel_id = puzzle['channel_id']
373 description = puzzle['name']
374 url = puzzle.get('url', None)
375 sheet_url = puzzle.get('sheet_url', None)
376 state = puzzle.get('state', None)
380 links.append("<{}|Puzzle>".format(url))
382 links.append("<{}|Sheet>".format(sheet_url))
385 description += "({})".format(', '.join(links))
388 description += " {}".format(state)
390 turb.slack_client.conversations_setTopic(channel=channel_id,
393 def state(turb, body, args):
394 """Implementation of the /state command
396 The args string should be a brief sentence describing where things
397 stand or what's needed."""
399 channel_id = body['channel_id'][0]
400 channel_name = body['channel_name'][0]
402 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
405 return bot_reply("Sorry, this is not a puzzle channel.")
407 # Set the state field in the database
408 puzzle['state'] = args
409 table.put_item(Item=puzzle)
411 set_channel_topic(turb, puzzle)
415 commands["/state"] = state
417 def solved(turb, body, args):
418 """Implementation of the /solved command
420 The args string should be a confirmed solution."""
422 channel_id = body['channel_id'][0]
423 channel_name = body['channel_name'][0]
425 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
428 return bot_reply("Sorry, this is not a puzzle channel.")
430 # Set the status and solution fields in the database
431 puzzle['status'] = 'solved'
432 puzzle['solution'].append(args)
433 table.put_item(Item=puzzle)
437 commands["/solved"] = solved