]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Don't allow capital letters in a hunt or puzzle ID
[turbot] / turbot / interaction.py
1 from slack.errors import SlackApiError
2 from turbot.blocks import input_block, section_block, text_block
3 import turbot.rot
4 import turbot.sheets
5 import turbot.slack
6 import json
7 import re
8 import requests
9 from botocore.exceptions import ClientError
10
11 actions = {}
12 commands = {}
13 submission_handlers = {}
14
15 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
16 valid_id_re = r'^[_a-z0-9]+$'
17
18 def bot_reply(message):
19     """Construct a return value suitable for a bot reply
20
21     This is suitable as a way to give an error back to the user who
22     initiated a slash command, for example."""
23
24     return {
25         'statusCode': 200,
26         'body': message
27     }
28
29 def submission_error(field, error):
30     """Construct an error suitable for returning for an invalid submission.
31
32     Returning this value will prevent a submission and alert the user that
33     the given field is invalid because of the given error."""
34
35     print("Rejecting invalid modal submission: {}".format(error))
36
37     return {
38         'statusCode': 200,
39         'headers': {
40             "Content-Type": "application/json"
41         },
42         'body': json.dumps({
43             "response_action": "errors",
44             "errors": {
45                 field: error
46             }
47         })
48     }
49
50 def new_hunt(turb, payload):
51     """Handler for the action of user pressing the new_hunt button"""
52
53     view = {
54         "type": "modal",
55         "private_metadata": json.dumps({}),
56         "title": { "type": "plain_text", "text": "New Hunt" },
57         "submit": { "type": "plain_text", "text": "Create" },
58         "blocks": [
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",
64                         optional=True)
65         ],
66     }
67
68     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
69                                           view=view)
70     if (result['ok']):
71         submission_handlers[result['view']['id']] = new_hunt_submission
72
73     return {
74         'statusCode': 200,
75         'body': 'OK'
76     }
77
78 actions['button'] = {"new_hunt": new_hunt}
79
80 def new_hunt_submission(turb, payload, metadata):
81     """Handler for the user submitting the new hunt modal
82
83     This is the modal view presented to the user by the new_hunt
84     function above."""
85
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']
91
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")
97
98     # Check to see if the hunts table exists
99     hunts_table = turb.db.Table("hunts")
100
101     try:
102         exists = hunts_table.table_status in ("CREATING", "UPDATING",
103                                               "ACTIVE")
104     except ClientError:
105         exists = False
106
107     # Create the hunts table if necessary.
108     if not exists:
109         hunts_table = turb.db.create_table(
110             TableName='hunts',
111             KeySchema=[
112                 {'AttributeName': 'channel_id', 'KeyType': 'HASH'},
113             ],
114             AttributeDefinitions=[
115                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
116             ],
117             ProvisionedThroughput={
118                 'ReadCapacityUnits': 5,
119                 'WriteCapacityUnits': 5
120             }
121         )
122         return submission_error("hunt_id",
123                                 "Still bootstrapping hunts table. Try again.")
124
125     # Create a channel for the hunt
126     try:
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']))
132
133     channel_id = response['channel']['id']
134
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(
139         Item={
140             'channel_id': channel_id,
141             "active": False,
142             "name": name,
143             "hunt_id": hunt_id,
144             "url": url
145         }
146     )
147
148     # Invite the initiating user to the channel
149     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
150
151     return {
152         'statusCode': 200,
153     }
154
155 def view_submission(turb, payload):
156     """Handler for Slack interactive view submission
157
158     Specifically, those that have a payload type of 'view_submission'"""
159
160     view_id = payload['view']['id']
161     metadata = payload['view']['private_metadata']
162
163     if view_id in submission_handlers:
164         return submission_handlers[view_id](turb, payload, metadata)
165
166     print("Error: Unknown view ID: {}".format(view_id))
167     return {
168         'statusCode': 400
169     }
170
171 def rot(turb, body, args):
172     """Implementation of the /rot command
173
174     The args string should be as follows:
175
176         [count|*] String to be rotated
177
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
182     values.
183
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."""
187
188     channel_name = body['channel_name'][0]
189     response_url = body['response_url'][0]
190     channel_id = body['channel_id'][0]
191
192     result = turbot.rot.rot(args)
193
194     if (channel_name == "directmessage"):
195         requests.post(response_url,
196                       json = {"text": result},
197                       headers = {"Content-type": "application/json"})
198     else:
199         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
200
201     return {
202         'statusCode': 200,
203         'body': ""
204     }
205
206 commands["/rot"] = rot
207
208 def get_table_item(turb, table_name, key, value):
209     """Get an item from the database 'table_name' with 'key' as 'value'
210
211     Returns a tuple of (item, table) if found and (None, None) otherwise."""
212
213     table = turb.db.Table(table_name)
214
215     response = table.get_item(Key={key: value})
216
217     if 'Item' in response:
218         return (response['Item'], table)
219     else:
220         return None
221
222 def channel_is_puzzle(turb, channel_id, channel_name):
223     """Given a channel ID/name return the database item for the puzzle
224
225     If this channel is a puzzle, this function returns a tuple:
226
227         (puzzle, table)
228
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
231     database.
232
233     Otherwise, this function returns (None, None)."""
234
235     hunt_id = channel_name.split('-')[0]
236
237     # Not a puzzle channel if there is no hyphen in the name
238     if hunt_id == channel_name:
239         return (None, None)
240
241     return get_table_item(turb, hunt_id, 'channel_id', channel_id)
242
243 def channel_is_hunt(turb, channel_id):
244
245     """Given a channel ID/name return the database item for the hunt
246
247     Returns a dict (filled with database entries) if there is a hunt
248     for this channel, otherwise returns None."""
249
250     return get_table_item(turb, "hunts", 'channel_id', channel_id)
251
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
254
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.
257
258     Returns a tuple of (hunt_name, hunt_id) or (None, None)."""
259
260     (hunt, hunts_table) = channel_is_hunt(turb, channel_id)
261
262     if hunt:
263         return (hunt['hunt_id'], hunt['name'])
264
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]
268
269     response = hunts_table.scan(
270         FilterExpression='hunt_id = :hunt_id',
271         ExpressionAttributeValues={':hunt_id': hunt_id}
272     )
273
274     if 'Items' in response:
275         item = response['Items'][0]
276         return (item['hunt_id'], item['name'])
277
278     return (None, None)
279
280 def puzzle(turb, body, args):
281     """Implementation of the /puzzle command
282
283     The args string is currently ignored (this command will bring up
284     a modal dialog for user input instead)."""
285
286     channel_id = body['channel_id'][0]
287     channel_name = body['channel_name'][0]
288     trigger_id = body['trigger_id'][0]
289
290     (hunt_id, hunt_name) = find_hunt_for_channel(turb,
291                                                  channel_id,
292                                                  channel_name)
293
294     if not hunt_id:
295         return bot_reply("Sorry, this channel doesn't appear to "
296                          + "be a hunt or puzzle channel")
297
298     view = {
299         "type": "modal",
300         "private_metadata": json.dumps({
301             "hunt_id": hunt_id,
302         }),
303         "title": {"type": "plain_text", "text": "New Puzzle"},
304         "submit": { "type": "plain_text", "text": "Create" },
305         "blocks": [
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",
312                         optional=True)
313         ]
314     }
315
316     result = turb.slack_client.views_open(trigger_id=trigger_id,
317                                           view=view)
318
319     if (result['ok']):
320         submission_handlers[result['view']['id']] = puzzle_submission
321
322     return {
323         'statusCode': 200
324     }
325
326 commands["/puzzle"] = puzzle
327
328 def puzzle_submission(turb, payload, metadata):
329     """Handler for the user submitting the new puzzle modal
330
331     This is the modal view presented to the user by the puzzle function
332     above."""
333
334     meta = json.loads(metadata)
335     hunt_id = meta['hunt_id']
336
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']
341
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")
347
348     # Create a channel for the puzzle
349     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
350
351     try:
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']))
358
359     puzzle_channel_id = response['channel']['id']
360
361     # Insert the newly-created puzzle into the database
362     table = turb.db.Table(hunt_id)
363     table.put_item(
364         Item={
365             "channel_id": puzzle_channel_id,
366             "solution": [],
367             "status": 'unsolved',
368             "name": name,
369             "puzzle_id": puzzle_id,
370             "url": url,
371         }
372     )
373
374     return {
375         'statusCode': 200
376     }
377
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)
385
386     links = []
387     if url:
388         links.append("<{}|Puzzle>".format(url))
389     if sheet_url:
390         links.append("<{}|Sheet>".format(sheet_url))
391
392     if len(links):
393         description += "({})".format(', '.join(links))
394
395     if state:
396         description += " {}".format(state)
397
398     turb.slack_client.conversations_setTopic(channel=channel_id,
399                                              topic=description)
400
401 def state(turb, body, args):
402     """Implementation of the /state command
403
404     The args string should be a brief sentence describing where things
405     stand or what's needed."""
406
407     channel_id = body['channel_id'][0]
408     channel_name = body['channel_name'][0]
409
410     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
411
412     if not puzzle:
413         return bot_reply("Sorry, this is not a puzzle channel.")
414
415     # Set the state field in the database
416     puzzle['state'] = args
417     table.put_item(Item=puzzle)
418
419     set_channel_topic(turb, puzzle)