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