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