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 def bot_reply(message):
19 """Construct a return value suitable for a bot reply
21 This is suitable as a way to give an error back to the user who
22 initiated a slash command, for example."""
29 def submission_error(field, error):
30 """Construct an error suitable for returning for an invalid submission.
32 Returning this value will prevent a submission and alert the user that
33 the given field is invalid because of the given error."""
35 print("Rejecting invalid modal submission: {}".format(error))
40 "Content-Type": "application/json"
43 "response_action": "errors",
50 def new_hunt(turb, payload):
51 """Handler for the action of user pressing the new_hunt button"""
55 "private_metadata": json.dumps({}),
56 "title": { "type": "plain_text", "text": "New Hunt" },
57 "submit": { "type": "plain_text", "text": "Create" },
59 input_block("Hunt name", "name", "Name of the hunt"),
60 input_block("Hunt ID", "hunt_id",
61 "Used as puzzle channel prefix "
62 + "(no spaces nor punctuation)"),
63 input_block("Hunt URL", "url", "External URL of hunt",
68 result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
71 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)
155 def view_submission(turb, payload):
156 """Handler for Slack interactive view submission
158 Specifically, those that have a payload type of 'view_submission'"""
160 view_id = payload['view']['id']
161 metadata = payload['view']['private_metadata']
163 if view_id in submission_handlers:
164 return submission_handlers[view_id](turb, payload, metadata)
166 print("Error: Unknown view ID: {}".format(view_id))
171 def rot(turb, body, args):
172 """Implementation of the /rot command
174 The args string should be as follows:
176 [count|*] String to be rotated
178 That is, the first word of the string is an optional number (or
179 the character '*'). If this is a number it indicates an amount to
180 rotate each character in the string. If the count is '*' or is not
181 present, then the string will be rotated through all possible 25
184 The result of the rotation is returned (with Slack formatting) in
185 the body of the response so that Slack will provide it as a reply
186 to the user who submitted the slash command."""
188 channel_name = body['channel_name'][0]
189 response_url = body['response_url'][0]
190 channel_id = body['channel_id'][0]
192 result = turbot.rot.rot(args)
194 if (channel_name == "directmessage"):
195 requests.post(response_url,
196 json = {"text": result},
197 headers = {"Content-type": "application/json"})
199 turb.slack_client.chat_postMessage(channel=channel_id, text=result)
206 commands["/rot"] = rot
208 def get_table_item(turb, table_name, key, value):
209 """Get an item from the database 'table_name' with 'key' as 'value'
211 Returns a tuple of (item, table) if found and (None, None) otherwise."""
213 table = turb.db.Table(table_name)
215 response = table.get_item(Key={key: value})
217 if 'Item' in response:
218 return (response['Item'], table)
222 def channel_is_puzzle(turb, channel_id, channel_name):
223 """Given a channel ID/name return the database item for the puzzle
225 If this channel is a puzzle, this function returns a tuple:
229 Where puzzle is dict filled with database entries, and table is a
230 database table that can be used to update the puzzle in the
233 Otherwise, this function returns (None, None)."""
235 hunt_id = channel_name.split('-')[0]
237 # Not a puzzle channel if there is no hyphen in the name
238 if hunt_id == channel_name:
241 return get_table_item(turb, hunt_id, 'channel_id', channel_id)
243 def channel_is_hunt(turb, channel_id):
245 """Given a channel ID/name return the database item for the hunt
247 Returns a dict (filled with database entries) if there is a hunt
248 for this channel, otherwise returns None."""
250 return get_table_item(turb, "hunts", 'channel_id', channel_id)
252 def find_hunt_for_channel(turb, channel_id, channel_name):
253 """Given a channel ID/name find the id/name of the hunt for this channel
255 This works whether the original channel is a primary hunt channel,
256 or if it is one of the channels of a puzzle belonging to the hunt.
258 Returns a tuple of (hunt_name, hunt_id) or (None, None)."""
260 (hunt, hunts_table) = channel_is_hunt(turb, channel_id)
263 return (hunt['hunt_id'], hunt['name'])
265 # So we're not a hunt channel, let's look to see if we are a
266 # puzzle channel with a hunt-id prefix.
267 hunt_id = channel_name.split('-')[0]
269 response = hunts_table.scan(
270 FilterExpression='hunt_id = :hunt_id',
271 ExpressionAttributeValues={':hunt_id': hunt_id}
274 if 'Items' in response:
275 item = response['Items'][0]
276 return (item['hunt_id'], item['name'])
280 def puzzle(turb, body, args):
281 """Implementation of the /puzzle command
283 The args string is currently ignored (this command will bring up
284 a modal dialog for user input instead)."""
286 channel_id = body['channel_id'][0]
287 channel_name = body['channel_name'][0]
288 trigger_id = body['trigger_id'][0]
290 (hunt_id, hunt_name) = find_hunt_for_channel(turb,
295 return bot_reply("Sorry, this channel doesn't appear to "
296 + "be a hunt or puzzle channel")
300 "private_metadata": json.dumps({
303 "title": {"type": "plain_text", "text": "New Puzzle"},
304 "submit": { "type": "plain_text", "text": "Create" },
306 section_block(text_block("*For {}*".format(hunt_name))),
307 input_block("Puzzle name", "name", "Name of the puzzle"),
308 input_block("Puzzle ID", "puzzle_id",
309 "Used as part of channel name "
310 + "(no spaces nor punctuation)"),
311 input_block("Puzzle URL", "url", "External URL of puzzle",
316 result = turb.slack_client.views_open(trigger_id=trigger_id,
320 submission_handlers[result['view']['id']] = puzzle_submission
326 commands["/puzzle"] = puzzle
328 def puzzle_submission(turb, payload, metadata):
329 """Handler for the user submitting the new puzzle modal
331 This is the modal view presented to the user by the puzzle function
334 meta = json.loads(metadata)
335 hunt_id = meta['hunt_id']
337 state = payload['view']['state']['values']
338 name = state['name']['name']['value']
339 puzzle_id = state['puzzle_id']['puzzle_id']['value']
340 url = state['url']['url']['value']
342 # Validate that the puzzle_id contains no invalid characters
343 if not re.match(valid_id_re, puzzle_id):
344 return submission_error("puzzle_id",
345 "Puzzle ID can only contain lowercase letters, "
346 + "numbers, and underscores")
348 # Create a channel for the puzzle
349 hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
352 response = turb.slack_client.conversations_create(
353 name=hunt_dash_channel)
354 except SlackApiError as e:
355 return submission_error("puzzle_id",
356 "Error creating Slack channel: {}"
357 .format(e.response['error']))
359 puzzle_channel_id = response['channel']['id']
361 # Insert the newly-created puzzle into the database
362 table = turb.db.Table(hunt_id)
365 "channel_id": puzzle_channel_id,
367 "status": 'unsolved',
369 "puzzle_id": puzzle_id,
378 # XXX: This duplicates functionality eith events.py:set_channel_description
379 def set_channel_topic(turb, puzzle):
380 channel_id = puzzle['channel_id']
381 description = puzzle['name']
382 url = puzzle.get('url', None)
383 sheet_url = puzzle.get('sheet_url', None)
384 state = puzzle.get('state', None)
388 links.append("<{}|Puzzle>".format(url))
390 links.append("<{}|Sheet>".format(sheet_url))
393 description += "({})".format(', '.join(links))
396 description += " {}".format(state)
398 turb.slack_client.conversations_setTopic(channel=channel_id,
401 def state(turb, body, args):
402 """Implementation of the /state command
404 The args string should be a brief sentence describing where things
405 stand or what's needed."""
407 channel_id = body['channel_id'][0]
408 channel_name = body['channel_name'][0]
410 (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
413 return bot_reply("Sorry, this is not a puzzle channel.")
415 # Set the state field in the database
416 puzzle['state'] = args
417 table.put_item(Item=puzzle)
419 set_channel_topic(turb, puzzle)
421 commands["/state"] = state