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