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, hunts_table) = 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 response = hunts_table.scan(
264 FilterExpression='hunt_id = :hunt_id',
265 ExpressionAttributeValues={':hunt_id': hunt_id}
268 if 'Items' in response:
269 item = response['Items'][0]
270 return (item['hunt_id'], item['name'])
274 def puzzle(turb, body, args):
275 """Implementation of the /puzzle command
277 The args string is currently ignored (this command will bring up
278 a modal dialog for user input instead)."""
280 channel_id = body['channel_id'][0]
281 channel_name = body['channel_name'][0]
282 trigger_id = body['trigger_id'][0]
284 (hunt_id, hunt_name) = find_hunt_for_channel(turb,
289 return bot_reply("Sorry, this channel doesn't appear to "
290 + "be a hunt or puzzle channel")
294 "private_metadata": json.dumps({
297 "title": {"type": "plain_text", "text": "New Puzzle"},
298 "submit": { "type": "plain_text", "text": "Create" },
300 section_block(text_block("*For {}*".format(hunt_name))),
301 input_block("Puzzle name", "name", "Name of the puzzle"),
302 input_block("Puzzle ID", "puzzle_id",
303 "Used as part of channel name "
304 + "(no spaces nor punctuation)"),
305 input_block("Puzzle URL", "url", "External URL of puzzle",
310 result = turb.slack_client.views_open(trigger_id=trigger_id,
314 submission_handlers[result['view']['id']] = puzzle_submission
318 commands["/puzzle"] = puzzle
320 def puzzle_submission(turb, payload, metadata):
321 """Handler for the user submitting the new puzzle modal
323 This is the modal view presented to the user by the puzzle function
326 meta = json.loads(metadata)
327 hunt_id = meta['hunt_id']
329 state = payload['view']['state']['values']
330 name = state['name']['name']['value']
331 puzzle_id = state['puzzle_id']['puzzle_id']['value']
332 url = state['url']['url']['value']
334 # Validate that the puzzle_id contains no invalid characters
335 if not re.match(valid_id_re, puzzle_id):
336 return submission_error("puzzle_id",
337 "Puzzle ID can only contain lowercase letters,"
338 + " numbers, and underscores")
340 # Create a channel for the puzzle
341 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
344 response = turb.slack_client.conversations_create(
345 name=hunt_dash_channel)
346 except SlackApiError as e:
347 return submission_error("puzzle_id",
348 "Error creating Slack channel: {}"
349 .format(e.response['error']))
351 puzzle_channel_id = response['channel']['id']
353 # Insert the newly-created puzzle into the database
354 table = turb.db.Table(hunt_id)
357 "channel_id": puzzle_channel_id,
359 "status": 'unsolved',
361 "puzzle_id": puzzle_id,
368 # XXX: This duplicates functionality eith events.py:set_channel_description
369 def set_channel_topic(turb, puzzle):
370 channel_id = puzzle['channel_id']
371 description = puzzle['name']
372 url = puzzle.get('url', None)
373 sheet_url = puzzle.get('sheet_url', None)
374 state = puzzle.get('state', None)
378 links.append("<{}|Puzzle>".format(url))
380 links.append("<{}|Sheet>".format(sheet_url))
383 description += "({})".format(', '.join(links))
386 description += " {}".format(state)
388 turb.slack_client.conversations_setTopic(channel=channel_id,
391 def state(turb, body, args):
392 """Implementation of the /state command
394 The args string should be a brief sentence describing where things
395 stand or what's needed."""
397 channel_id = body['channel_id'][0]
398 channel_name = body['channel_name'][0]
400 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
403 return bot_reply("Sorry, this is not a puzzle channel.")
405 # Set the state field in the database
406 puzzle['state'] = args
407 table.put_item(Item=puzzle)
409 set_channel_topic(turb, puzzle)
413 commands["/state"] = state