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