]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add a "make flake" recipe and clean up some style bugs flake8 noticed
[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 from turbot.slack import slack_send_message
11
12 actions = {}
13 commands = {}
14 submission_handlers = {}
15
16 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
17 valid_id_re = r'^[_a-z0-9]+$'
18
19 lambda_ok = {'statusCode': 200}
20
21 def bot_reply(message):
22     """Construct a return value suitable for a bot reply
23
24     This is suitable as a way to give an error back to the user who
25     initiated a slash command, for example."""
26
27     return {
28         'statusCode': 200,
29         'body': message
30     }
31
32 def submission_error(field, error):
33     """Construct an error suitable for returning for an invalid submission.
34
35     Returning this value will prevent a submission and alert the user that
36     the given field is invalid because of the given error."""
37
38     print("Rejecting invalid modal submission: {}".format(error))
39
40     return {
41         'statusCode': 200,
42         'headers': {
43             "Content-Type": "application/json"
44         },
45         'body': json.dumps({
46             "response_action": "errors",
47             "errors": {
48                 field: error
49             }
50         })
51     }
52
53 def new_hunt(turb, payload):
54     """Handler for the action of user pressing the new_hunt button"""
55
56     view = {
57         "type": "modal",
58         "private_metadata": json.dumps({}),
59         "title": { "type": "plain_text", "text": "New Hunt" },
60         "submit": { "type": "plain_text", "text": "Create" },
61         "blocks": [
62             input_block("Hunt name", "name", "Name of the hunt"),
63             input_block("Hunt ID", "hunt_id",
64                         "Used as puzzle channel prefix "
65                         + "(no spaces nor punctuation)"),
66             input_block("Hunt URL", "url", "External URL of hunt",
67                         optional=True)
68         ],
69     }
70
71     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
72                                           view=view)
73     if (result['ok']):
74         submission_handlers[result['view']['id']] = new_hunt_submission
75
76     return lambda_ok
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 turbot table exists
99     try:
100         exists = turb.table.table_status in ("CREATING", "UPDATING",
101                                              "ACTIVE")
102     except ClientError:
103         exists = False
104
105     # Create the turbot table if necessary.
106     if not exists:
107         turb.table = turb.db.create_table(
108             TableName='turbot',
109             KeySchema=[
110                 {'AttributeName': 'PK', 'KeyType': 'HASH'},
111                 {'AttributeName': 'SK', 'KeyType': 'RANGE'},
112             ],
113             AttributeDefinitions=[
114                 {'AttributeName': 'PK', 'AttributeType': 'S'},
115                 {'AttributeName': 'SK', 'AttributeType': 'S'},
116                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
117             ],
118             ProvisionedThroughput={
119                 'ReadCapacityUnits': 5,
120                 'WriteCapacityUnits': 5
121             },
122             GlobalSecondaryIndexes=[
123                 {
124                     'IndexName': 'channel_id_index',
125                     'KeySchema': [
126                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
127                     ],
128                     'Projection': {
129                         'ProjectionType': 'ALL'
130                     },
131                     'ProvisionedThroughput': {
132                         'ReadCapacityUnits': 5,
133                         'WriteCapacityUnits': 5
134                     }
135                 }
136             ]
137         )
138         return submission_error("hunt_id",
139                                 "Still bootstrapping turbot table. Try again.")
140
141     # Create a channel for the hunt
142     try:
143         response = turb.slack_client.conversations_create(name=hunt_id)
144     except SlackApiError as e:
145         return submission_error("hunt_id",
146                                 "Error creating Slack channel: {}"
147                                 .format(e.response['error']))
148
149     channel_id = response['channel']['id']
150
151     # Insert the newly-created hunt into the database
152     # (leaving it as non-active for now until the channel-created handler
153     #  finishes fixing it up with a sheet and a companion table)
154     turb.table.put_item(
155         Item={
156             "PK": "hunt-{}".format(hunt_id),
157             "SK": "hunt-{}".format(hunt_id),
158             "channel_id": channel_id,
159             "active": False,
160             "name": name,
161             "url": url
162         }
163     )
164
165     # Invite the initiating user to the channel
166     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
167
168     return lambda_ok
169
170 def view_submission(turb, payload):
171     """Handler for Slack interactive view submission
172
173     Specifically, those that have a payload type of 'view_submission'"""
174
175     view_id = payload['view']['id']
176     metadata = payload['view']['private_metadata']
177
178     if view_id in submission_handlers:
179         return submission_handlers[view_id](turb, payload, metadata)
180
181     print("Error: Unknown view ID: {}".format(view_id))
182     return {
183         'statusCode': 400
184     }
185
186 def rot(turb, body, args):
187     """Implementation of the /rot command
188
189     The args string should be as follows:
190
191         [count|*] String to be rotated
192
193     That is, the first word of the string is an optional number (or
194     the character '*'). If this is a number it indicates an amount to
195     rotate each character in the string. If the count is '*' or is not
196     present, then the string will be rotated through all possible 25
197     values.
198
199     The result of the rotation is returned (with Slack formatting) in
200     the body of the response so that Slack will provide it as a reply
201     to the user who submitted the slash command."""
202
203     channel_name = body['channel_name'][0]
204     response_url = body['response_url'][0]
205     channel_id = body['channel_id'][0]
206
207     result = turbot.rot.rot(args)
208
209     if (channel_name == "directmessage"):
210         requests.post(response_url,
211                       json = {"text": result},
212                       headers = {"Content-type": "application/json"})
213     else:
214         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
215
216     return lambda_ok
217
218 commands["/rot"] = rot
219
220 def get_table_item(turb, table_name, key, value):
221     """Get an item from the database 'table_name' with 'key' as 'value'
222
223     Returns a tuple of (item, table) if found and (None, None) otherwise."""
224
225     table = turb.db.Table(table_name)
226
227     response = table.get_item(Key={key: value})
228
229     if 'Item' in response:
230         return (response['Item'], table)
231     else:
232         return (None, None)
233
234 def channel_is_puzzle(turb, channel_id, channel_name):
235     """Given a channel ID/name return the database item for the puzzle
236
237     If this channel is a puzzle, this function returns a tuple:
238
239         (puzzle, table)
240
241     Where puzzle is dict filled with database entries, and table is a
242     database table that can be used to update the puzzle in the
243     database.
244
245     Otherwise, this function returns (None, None)."""
246
247     hunt_id = channel_name.split('-')[0]
248
249     # Not a puzzle channel if there is no hyphen in the name
250     if hunt_id == channel_name:
251         return (None, None)
252
253     return get_table_item(turb, hunt_id, 'channel_id', channel_id)
254
255 def channel_is_hunt(turb, channel_id):
256
257     """Given a channel ID/name return the database item for the hunt
258
259     Returns a dict (filled with database entries) if there is a hunt
260     for this channel, otherwise returns None."""
261
262     return get_table_item(turb, "channel_id_index", 'channel_id', channel_id)
263
264 def find_hunt_for_hunt_id(turb, hunt_id):
265     """Given a hunt ID find the database for for that hunt
266
267     Returns None if hunt ID is not found, otherwise a
268     dictionary with all fields from the hunt's row in the table,
269     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
270
271     """
272     turbot_table = turb.db.Table("turbot")
273
274     response = turbot_table.get_item(Key={'PK': 'hunt-{}'.format(hunt_id)})
275
276     if 'Item' in response:
277         return response['Item']
278     else:
279         return None
280
281 def find_hunt_for_channel(turb, channel_id, channel_name):
282     """Given a channel ID/name find the id/name of the hunt for this channel
283
284     This works whether the original channel is a primary hunt channel,
285     or if it is one of the channels of a puzzle belonging to the hunt.
286
287     Returns None if channel does not belong to a hunt, otherwise a
288     dictionary with all fields from the hunt's row in the table,
289     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
290
291     """
292
293     (hunt, _) = channel_is_hunt(turb, channel_id)
294
295     if hunt:
296         return hunt
297
298     # So we're not a hunt channel, let's look to see if we are a
299     # puzzle channel with a hunt-id prefix.
300     hunt_id = channel_name.split('-')[0]
301
302     return find_hunt_for_hunt_id(turb, hunt_id)
303
304 def puzzle(turb, body, args):
305     """Implementation of the /puzzle command
306
307     The args string is currently ignored (this command will bring up
308     a modal dialog for user input instead)."""
309
310     channel_id = body['channel_id'][0]
311     channel_name = body['channel_name'][0]
312     trigger_id = body['trigger_id'][0]
313
314     hunt = find_hunt_for_channel(turb,
315                                  channel_id,
316                                  channel_name)
317
318     if not hunt:
319         return bot_reply("Sorry, this channel doesn't appear to "
320                          + "be a hunt or puzzle channel")
321
322     view = {
323         "type": "modal",
324         "private_metadata": json.dumps({
325             "hunt_id": hunt['hunt_id'],
326         }),
327         "title": {"type": "plain_text", "text": "New Puzzle"},
328         "submit": { "type": "plain_text", "text": "Create" },
329         "blocks": [
330             section_block(text_block("*For {}*".format(hunt['name']))),
331             input_block("Puzzle name", "name", "Name of the puzzle"),
332             input_block("Puzzle URL", "url", "External URL of puzzle",
333                         optional=True)
334         ]
335     }
336
337     result = turb.slack_client.views_open(trigger_id=trigger_id,
338                                           view=view)
339
340     if (result['ok']):
341         submission_handlers[result['view']['id']] = puzzle_submission
342
343     return lambda_ok
344
345 commands["/puzzle"] = puzzle
346
347 def puzzle_submission(turb, payload, metadata):
348     """Handler for the user submitting the new puzzle modal
349
350     This is the modal view presented to the user by the puzzle function
351     above."""
352
353     meta = json.loads(metadata)
354     hunt_id = meta['hunt_id']
355
356     state = payload['view']['state']['values']
357     name = state['name']['name']['value']
358     url = state['url']['url']['value']
359
360     # Create a Slack-channel-safe puzzle_id
361     puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
362
363     # Create a channel for the puzzle
364     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
365
366     try:
367         response = turb.slack_client.conversations_create(
368             name=hunt_dash_channel)
369     except SlackApiError as e:
370         return submission_error(
371             "name",
372             "Error creating Slack channel {}: {}"
373             .format(hunt_dash_channel, e.response['error']))
374
375     puzzle_channel_id = response['channel']['id']
376
377     # Insert the newly-created puzzle into the database
378     table = turb.db.Table(hunt_id)
379     table.put_item(
380         Item={
381             "channel_id": puzzle_channel_id,
382             "solution": [],
383             "status": 'unsolved',
384             "hunt_id": hunt_id,
385             "name": name,
386             "puzzle_id": puzzle_id,
387             "url": url,
388         }
389     )
390
391     return lambda_ok
392
393 # XXX: This duplicates functionality eith events.py:set_channel_description
394 def set_channel_topic(turb, puzzle):
395     channel_id = puzzle['channel_id']
396     name = puzzle['name']
397     url = puzzle.get('url', None)
398     sheet_url = puzzle.get('sheet_url', None)
399     state = puzzle.get('state', None)
400     status = puzzle['status']
401
402     description = ''
403
404     if status == 'solved':
405         description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
406
407     description += name
408
409     links = []
410     if url:
411         links.append("<{}|Puzzle>".format(url))
412     if sheet_url:
413         links.append("<{}|Sheet>".format(sheet_url))
414
415     if len(links):
416         description += "({})".format(', '.join(links))
417
418     if state:
419         description += " {}".format(state)
420
421     turb.slack_client.conversations_setTopic(channel=channel_id,
422                                              topic=description)
423
424 def state(turb, body, args):
425     """Implementation of the /state command
426
427     The args string should be a brief sentence describing where things
428     stand or what's needed."""
429
430     channel_id = body['channel_id'][0]
431     channel_name = body['channel_name'][0]
432
433     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
434
435     if not puzzle:
436         return bot_reply("Sorry, this is not a puzzle channel.")
437
438     # Set the state field in the database
439     puzzle['state'] = args
440     table.put_item(Item=puzzle)
441
442     set_channel_topic(turb, puzzle)
443
444     return lambda_ok
445
446 commands["/state"] = state
447
448 def solved(turb, body, args):
449     """Implementation of the /solved command
450
451     The args string should be a confirmed solution."""
452
453     channel_id = body['channel_id'][0]
454     channel_name = body['channel_name'][0]
455     user_name = body['user_name'][0]
456
457     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
458
459     if not puzzle:
460         return bot_reply("Sorry, this is not a puzzle channel.")
461
462     # Set the status and solution fields in the database
463     puzzle['status'] = 'solved'
464     puzzle['solution'].append(args)
465     table.put_item(Item=puzzle)
466
467     # Report the solution to the puzzle's channel
468     slack_send_message(
469         turb.slack_client, channel_id,
470         "Puzzle mark solved by {}: `{}`".format(user_name, args))
471
472     # Also report the solution to the hunt channel
473     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
474     slack_send_message(
475         turb.slack_client, hunt['channel_id'],
476         "Puzzle <{}|{}> has been solved!".format(
477             puzzle['channel_url'],
478             puzzle['name'])
479     )
480
481     # And update the puzzle's description
482     set_channel_topic(turb, puzzle)
483
484     # And rename the sheet to prefix with "SOLVED: "
485     turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
486                               'SOLVED: ' + puzzle['name'])
487
488     # Finally, rename the Slack channel to add the suffix '-solved'
489     channel_name = "{}-{}-solved".format(
490         puzzle['hunt_id'],
491         puzzle['puzzle_id'])
492     turb.slack_client.conversations_rename(
493         channel=puzzle['channel_id'],
494         name=channel_name)
495
496     return lambda_ok
497
498 commands["/solved"] = solved