]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Remove code (previously disabled) to invite users to new puzzle channel
[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 channel_is_puzzle(turb, channel_id, channel_name):
253     """Given a channel ID/name return the database item for the puzzle
254
255     If this channel is a puzzle, this function returns a tuple:
256
257         (puzzle, table)
258
259     Where puzzle is dict filled with database entries, and table is a
260     database table that can be used to update the puzzle in the
261     database.
262
263     Otherwise, this function returns (None, None)."""
264
265     hunt_id = channel_name.split('-')[0]
266
267     # Not a puzzle channel if there is no hyphen in the name
268     if hunt_id == channel_name:
269         return (None, None)
270
271     return get_table_item(turb, hunt_id, 'channel_id', channel_id)
272
273 def channel_is_hunt(turb, channel_id):
274
275     """Given a channel ID/name return the database item for the hunt
276
277     Returns a dict (filled with database entries) if there is a hunt
278     for this channel, otherwise returns None."""
279
280     response = turb.table.query(
281         IndexName = "channel_id_index",
282         KeyConditionExpression=Key("channel_id").eq(channel_id)
283     )
284
285     if 'Items' not in response:
286         return None
287
288     return response['Items'][0]
289
290 def find_hunt_for_channel(turb, channel_id, channel_name):
291     """Given a channel ID/name find the id/name of the hunt for this channel
292
293     This works whether the original channel is a primary hunt channel,
294     or if it is one of the channels of a puzzle belonging to the hunt.
295
296     Returns None if channel does not belong to a hunt, otherwise a
297     dictionary with all fields from the hunt's row in the table,
298     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
299
300     """
301
302     hunt = channel_is_hunt(turb, channel_id)
303
304     if hunt:
305         return hunt
306
307     # So we're not a hunt channel, let's look to see if we are a
308     # puzzle channel with a hunt-id prefix.
309     hunt_id = channel_name.split('-')[0]
310
311     return find_hunt_for_hunt_id(turb, hunt_id)
312
313 def puzzle(turb, body, args):
314     """Implementation of the /puzzle command
315
316     The args string is currently ignored (this command will bring up
317     a modal dialog for user input instead)."""
318
319     channel_id = body['channel_id'][0]
320     channel_name = body['channel_name'][0]
321     trigger_id = body['trigger_id'][0]
322
323     hunt = find_hunt_for_channel(turb,
324                                  channel_id,
325                                  channel_name)
326
327     if not hunt:
328         return bot_reply("Sorry, this channel doesn't appear to "
329                          + "be a hunt or puzzle channel")
330
331     view = {
332         "type": "modal",
333         "private_metadata": json.dumps({
334             "hunt_id": hunt['hunt_id'],
335         }),
336         "title": {"type": "plain_text", "text": "New Puzzle"},
337         "submit": { "type": "plain_text", "text": "Create" },
338         "blocks": [
339             section_block(text_block("*For {}*".format(hunt['name']))),
340             input_block("Puzzle name", "name", "Name of the puzzle"),
341             input_block("Puzzle URL", "url", "External URL of puzzle",
342                         optional=True)
343         ]
344     }
345
346     result = turb.slack_client.views_open(trigger_id=trigger_id,
347                                           view=view)
348
349     if (result['ok']):
350         submission_handlers[result['view']['id']] = puzzle_submission
351
352     return lambda_ok
353
354 commands["/puzzle"] = puzzle
355
356 def puzzle_submission(turb, payload, metadata):
357     """Handler for the user submitting the new puzzle modal
358
359     This is the modal view presented to the user by the puzzle function
360     above."""
361
362     meta = json.loads(metadata)
363     hunt_id = meta['hunt_id']
364
365     state = payload['view']['state']['values']
366     name = state['name']['name']['value']
367     url = state['url']['url']['value']
368
369     # Create a Slack-channel-safe puzzle_id
370     puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
371
372     # Create a channel for the puzzle
373     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
374
375     try:
376         response = turb.slack_client.conversations_create(
377             name=hunt_dash_channel)
378     except SlackApiError as e:
379         return submission_error(
380             "name",
381             "Error creating Slack channel {}: {}"
382             .format(hunt_dash_channel, e.response['error']))
383
384     channel_id = response['channel']['id']
385
386     # Insert the newly-created puzzle into the database
387     item={
388         "hunt_id": hunt_id,
389         "SK": "puzzle-{}".format(puzzle_id),
390         "puzzle_id": puzzle_id,
391         "channel_id": channel_id,
392         "solution": [],
393         "status": 'unsolved',
394         "name": name,
395     }
396     if url:
397         item['url'] = url
398     turb.table.put_item(Item=item)
399
400     return lambda_ok
401
402 # XXX: This duplicates functionality eith events.py:set_channel_description
403 def set_channel_topic(turb, puzzle):
404     channel_id = puzzle['channel_id']
405     name = puzzle['name']
406     url = puzzle.get('url', None)
407     sheet_url = puzzle.get('sheet_url', None)
408     state = puzzle.get('state', None)
409     status = puzzle['status']
410
411     description = ''
412
413     if status == 'solved':
414         description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
415
416     description += name
417
418     links = []
419     if url:
420         links.append("<{}|Puzzle>".format(url))
421     if sheet_url:
422         links.append("<{}|Sheet>".format(sheet_url))
423
424     if len(links):
425         description += "({})".format(', '.join(links))
426
427     if state:
428         description += " {}".format(state)
429
430     turb.slack_client.conversations_setTopic(channel=channel_id,
431                                              topic=description)
432
433 def state(turb, body, args):
434     """Implementation of the /state command
435
436     The args string should be a brief sentence describing where things
437     stand or what's needed."""
438
439     channel_id = body['channel_id'][0]
440     channel_name = body['channel_name'][0]
441
442     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
443
444     if not puzzle:
445         return bot_reply("Sorry, this is not a puzzle channel.")
446
447     # Set the state field in the database
448     puzzle['state'] = args
449     table.put_item(Item=puzzle)
450
451     set_channel_topic(turb, puzzle)
452
453     return lambda_ok
454
455 commands["/state"] = state
456
457 def solved(turb, body, args):
458     """Implementation of the /solved command
459
460     The args string should be a confirmed solution."""
461
462     channel_id = body['channel_id'][0]
463     channel_name = body['channel_name'][0]
464     user_name = body['user_name'][0]
465
466     (puzzle, table) = channel_is_puzzle(turb, channel_id, channel_name)
467
468     if not puzzle:
469         return bot_reply("Sorry, this is not a puzzle channel.")
470
471     # Set the status and solution fields in the database
472     puzzle['status'] = 'solved'
473     puzzle['solution'].append(args)
474     table.put_item(Item=puzzle)
475
476     # Report the solution to the puzzle's channel
477     slack_send_message(
478         turb.slack_client, channel_id,
479         "Puzzle mark solved by {}: `{}`".format(user_name, args))
480
481     # Also report the solution to the hunt channel
482     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
483     slack_send_message(
484         turb.slack_client, hunt['channel_id'],
485         "Puzzle <{}|{}> has been solved!".format(
486             puzzle['channel_url'],
487             puzzle['name'])
488     )
489
490     # And update the puzzle's description
491     set_channel_topic(turb, puzzle)
492
493     # And rename the sheet to prefix with "SOLVED: "
494     turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
495                               'SOLVED: ' + puzzle['name'])
496
497     # Finally, rename the Slack channel to add the suffix '-solved'
498     channel_name = "{}-{}-solved".format(
499         puzzle['hunt_id'],
500         puzzle['puzzle_id'])
501     turb.slack_client.conversations_rename(
502         channel=puzzle['channel_id'],
503         name=channel_name)
504
505     return lambda_ok
506
507 commands["/solved"] = solved