]> git.cworth.org Git - turbot/blob - turbot/interaction.py
d14c3d4ad07195aeefc916b9e0649ca664b9e98b
[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, checkbox_block
4 )
5 from turbot.hunt import find_hunt_for_hunt_id, hunt_blocks
6 from turbot.puzzle import find_puzzle_for_url, find_puzzle_for_puzzle_id
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 import shlex
17
18 actions = {}
19 actions['button'] = {}
20 commands = {}
21 submission_handlers = {}
22
23 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
24 #
25 # Note: This restriction not only allows for hunt and puzzle ID values to
26 # be used as Slack channel names, but it also allows for '-' as a valid
27 # separator between a hunt and a puzzle ID (for example in the puzzle
28 # edit dialog where a single attribute must capture both values).
29 valid_id_re = r'^[_a-z0-9]+$'
30
31 lambda_ok = {'statusCode': 200}
32
33 def bot_reply(message):
34     """Construct a return value suitable for a bot reply
35
36     This is suitable as a way to give an error back to the user who
37     initiated a slash command, for example."""
38
39     return {
40         'statusCode': 200,
41         'body': message
42     }
43
44 def submission_error(field, error):
45     """Construct an error suitable for returning for an invalid submission.
46
47     Returning this value will prevent a submission and alert the user that
48     the given field is invalid because of the given error."""
49
50     print("Rejecting invalid modal submission: {}".format(error))
51
52     return {
53         'statusCode': 200,
54         'headers': {
55             "Content-Type": "application/json"
56         },
57         'body': json.dumps({
58             "response_action": "errors",
59             "errors": {
60                 field: error
61             }
62         })
63     }
64
65 def multi_static_select(turb, payload):
66     """Handler for the action of user entering a multi-select value"""
67
68     return lambda_ok
69
70 actions['multi_static_select'] = {"*": multi_static_select}
71
72 def edit_puzzle(turb, payload):
73     """Handler for the action of user pressing an edit_puzzle button"""
74
75     action_id = payload['actions'][0]['action_id']
76     response_url = payload['response_url']
77     trigger_id = payload['trigger_id']
78
79     (hunt_id, puzzle_id) = action_id.split('-', 1)
80
81     puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
82
83     if not puzzle:
84         requests.post(response_url,
85                       json = {"text": "Error: Puzzle not found!"},
86                       headers = {"Content-type": "application/json"})
87         return bot_reply("Error: Puzzle not found.")
88
89     round_options = hunt_rounds(turb, hunt_id)
90
91     if len(round_options):
92         round_options_block = [
93             multi_select_block("Round(s)", "rounds",
94                                "Existing round(s) this puzzle belongs to",
95                                round_options,
96                                initial_options=puzzle.get("rounds", None)),
97         ]
98     else:
99         round_options_block = []
100
101     solved = False
102     if puzzle.get("status", "unsolved") == solved:
103         solved = True
104
105     solution_str = None
106     solution_list = puzzle.get("solution", [])
107     if solution_list:
108         solution_str = ", ".join(solution_list)
109
110     view = {
111         "type": "modal",
112         "private_metadata": json.dumps({
113             "hunt_id": hunt_id,
114             "SK": puzzle["SK"],
115             "puzzle_id": puzzle_id,
116             "channel_id": puzzle["channel_id"],
117             "channel_url": puzzle["channel_url"],
118             "sheet_url": puzzle["sheet_url"],
119         }),
120         "title": {"type": "plain_text", "text": "Edit Puzzle"},
121         "submit": { "type": "plain_text", "text": "Save" },
122         "blocks": [
123             input_block("Puzzle name", "name", "Name of the puzzle",
124                         initial_value=puzzle["name"]),
125             input_block("Puzzle URL", "url", "External URL of puzzle",
126                         initial_value=puzzle.get("url", None),
127                         optional=True),
128             * round_options_block,
129             input_block("New round(s)", "new_rounds",
130                         "New round(s) this puzzle belongs to " +
131                         "(comma separated)",
132                         optional=True),
133             input_block("State", "state",
134                         "State of this puzzle (partial progress, next steps)",
135                         initial_value=puzzle.get("state", None),
136                         optional=True),
137             checkbox_block(
138                 "Puzzle status", "Solved", "solved",
139                 checked=(puzzle.get('status', 'unsolved') == 'solved')),
140             input_block("Solution", "solution",
141                         "Solutions (comma-separated if multiple",
142                         initial_value=solution_str,
143                         optional=True),
144         ]
145     }
146
147     result = turb.slack_client.views_open(trigger_id=trigger_id,
148                                           view=view)
149
150     if (result['ok']):
151         submission_handlers[result['view']['id']] = edit_puzzle_submission
152
153     return lambda_ok
154
155 actions['button']['edit_puzzle'] = edit_puzzle
156
157 def edit_puzzle_submission(turb, payload, metadata):
158     """Handler for the user submitting the edit puzzle modal
159
160     This is the modal view presented to the user by the edit_puzzle
161     function above.
162     """
163
164     return lambda_ok
165
166 def new_hunt(turb, payload):
167     """Handler for the action of user pressing the new_hunt button"""
168
169     view = {
170         "type": "modal",
171         "private_metadata": json.dumps({}),
172         "title": { "type": "plain_text", "text": "New Hunt" },
173         "submit": { "type": "plain_text", "text": "Create" },
174         "blocks": [
175             input_block("Hunt name", "name", "Name of the hunt"),
176             input_block("Hunt ID", "hunt_id",
177                         "Used as puzzle channel prefix "
178                         + "(no spaces nor punctuation)"),
179             input_block("Hunt URL", "url", "External URL of hunt",
180                         optional=True)
181         ],
182     }
183
184     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
185                                           view=view)
186     if (result['ok']):
187         submission_handlers[result['view']['id']] = new_hunt_submission
188
189     return lambda_ok
190
191 actions['button']['new_hunt'] = new_hunt
192
193 def new_hunt_submission(turb, payload, metadata):
194     """Handler for the user submitting the new hunt modal
195
196     This is the modal view presented to the user by the new_hunt
197     function above."""
198
199     state = payload['view']['state']['values']
200     user_id = payload['user']['id']
201     name = state['name']['name']['value']
202     hunt_id = state['hunt_id']['hunt_id']['value']
203     url = state['url']['url']['value']
204
205     # Validate that the hunt_id contains no invalid characters
206     if not re.match(valid_id_re, hunt_id):
207         return submission_error("hunt_id",
208                                 "Hunt ID can only contain lowercase letters, "
209                                 + "numbers, and underscores")
210
211     # Check to see if the turbot table exists
212     try:
213         exists = turb.table.table_status in ("CREATING", "UPDATING",
214                                              "ACTIVE")
215     except ClientError:
216         exists = False
217
218     # Create the turbot table if necessary.
219     if not exists:
220         turb.table = turb.db.create_table(
221             TableName='turbot',
222             KeySchema=[
223                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
224                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
225             ],
226             AttributeDefinitions=[
227                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
228                 {'AttributeName': 'SK', 'AttributeType': 'S'},
229                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
230                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
231                 {'AttributeName': 'url', 'AttributeType': 'S'}
232             ],
233             ProvisionedThroughput={
234                 'ReadCapacityUnits': 5,
235                 'WriteCapacityUnits': 5
236             },
237             GlobalSecondaryIndexes=[
238                 {
239                     'IndexName': 'channel_id_index',
240                     'KeySchema': [
241                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
242                     ],
243                     'Projection': {
244                         'ProjectionType': 'ALL'
245                     },
246                     'ProvisionedThroughput': {
247                         'ReadCapacityUnits': 5,
248                         'WriteCapacityUnits': 5
249                     }
250                 },
251                 {
252                     'IndexName': 'is_hunt_index',
253                     'KeySchema': [
254                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
255                     ],
256                     'Projection': {
257                         'ProjectionType': 'ALL'
258                     },
259                     'ProvisionedThroughput': {
260                         'ReadCapacityUnits': 5,
261                         'WriteCapacityUnits': 5
262                     }
263                 }
264             ],
265             LocalSecondaryIndexes = [
266                 {
267                     'IndexName': 'url_index',
268                     'KeySchema': [
269                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
270                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
271                     ],
272                     'Projection': {
273                         'ProjectionType': 'ALL'
274                     }
275                 }
276             ]
277         )
278         return submission_error(
279             "hunt_id",
280             "Still bootstrapping turbot table. Try again in a minute, please.")
281
282     # Create a channel for the hunt
283     try:
284         response = turb.slack_client.conversations_create(name=hunt_id)
285     except SlackApiError as e:
286         return submission_error("hunt_id",
287                                 "Error creating Slack channel: {}"
288                                 .format(e.response['error']))
289
290     channel_id = response['channel']['id']
291
292     # Insert the newly-created hunt into the database
293     # (leaving it as non-active for now until the channel-created handler
294     #  finishes fixing it up with a sheet and a companion table)
295     item={
296         "hunt_id": hunt_id,
297         "SK": "hunt-{}".format(hunt_id),
298         "is_hunt": hunt_id,
299         "channel_id": channel_id,
300         "active": False,
301         "name": name,
302     }
303     if url:
304         item['url'] = url
305     turb.table.put_item(Item=item)
306
307     # Invite the initiating user to the channel
308     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
309
310     return lambda_ok
311
312 def view_submission(turb, payload):
313     """Handler for Slack interactive view submission
314
315     Specifically, those that have a payload type of 'view_submission'"""
316
317     view_id = payload['view']['id']
318     metadata = payload['view']['private_metadata']
319
320     if view_id in submission_handlers:
321         return submission_handlers[view_id](turb, payload, metadata)
322
323     print("Error: Unknown view ID: {}".format(view_id))
324     return {
325         'statusCode': 400
326     }
327
328 def rot(turb, body, args):
329     """Implementation of the /rot command
330
331     The args string should be as follows:
332
333         [count|*] String to be rotated
334
335     That is, the first word of the string is an optional number (or
336     the character '*'). If this is a number it indicates an amount to
337     rotate each character in the string. If the count is '*' or is not
338     present, then the string will be rotated through all possible 25
339     values.
340
341     The result of the rotation is returned (with Slack formatting) in
342     the body of the response so that Slack will provide it as a reply
343     to the user who submitted the slash command."""
344
345     channel_name = body['channel_name'][0]
346     response_url = body['response_url'][0]
347     channel_id = body['channel_id'][0]
348
349     result = turbot.rot.rot(args)
350
351     if (channel_name == "directmessage"):
352         requests.post(response_url,
353                       json = {"text": result},
354                       headers = {"Content-type": "application/json"})
355     else:
356         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
357
358     return lambda_ok
359
360 commands["/rot"] = rot
361
362 def get_table_item(turb, table_name, key, value):
363     """Get an item from the database 'table_name' with 'key' as 'value'
364
365     Returns a tuple of (item, table) if found and (None, None) otherwise."""
366
367     table = turb.db.Table(table_name)
368
369     response = table.get_item(Key={key: value})
370
371     if 'Item' in response:
372         return (response['Item'], table)
373     else:
374         return (None, None)
375
376 def db_entry_for_channel(turb, channel_id):
377     """Given a channel ID return the database item for this channel
378
379     If this channel is a registered hunt or puzzle channel, return the
380     corresponding row from the database for this channel. Otherwise,
381     return None.
382
383     Note: If you need to specifically ensure that the channel is a
384     puzzle or a hunt, please call puzzle_for_channel or
385     hunt_for_channel respectively.
386     """
387
388     response = turb.table.query(
389         IndexName = "channel_id_index",
390         KeyConditionExpression=Key("channel_id").eq(channel_id)
391     )
392
393     if response['Count'] == 0:
394         return None
395
396     return response['Items'][0]
397
398
399 def puzzle_for_channel(turb, channel_id):
400
401     """Given a channel ID return the puzzle from the database for this channel
402
403     If the given channel_id is a puzzle's channel, this function
404     returns a dict filled with the attributes from the puzzle's entry
405     in the database.
406
407     Otherwise, this function returns None.
408     """
409
410     entry = db_entry_for_channel(turb, channel_id)
411
412     if entry and entry['SK'].startswith('puzzle-'):
413         return entry
414     else:
415         return None
416
417 def hunt_for_channel(turb, channel_id):
418
419     """Given a channel ID return the hunt from the database for this channel
420
421     This works whether the original channel is a primary hunt channel,
422     or if it is one of the channels of a puzzle belonging to the hunt.
423
424     Returns None if channel does not belong to a hunt, otherwise a
425     dictionary with all fields from the hunt's row in the table,
426     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
427     """
428
429     entry = db_entry_for_channel(turb, channel_id)
430
431     # We're done if this channel doesn't exist in the database at all
432     if not entry:
433         return None
434
435     # Also done if this channel is a hunt channel
436     if entry['SK'].startswith('hunt-'):
437         return entry
438
439     # Otherwise, (the channel is in the database, but is not a hunt),
440     # we expect this to be a puzzle channel instead
441     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
442
443 # python3.9 has a built-in removeprefix but AWS only has python3.8
444 def remove_prefix(text, prefix):
445     if text.startswith(prefix):
446         return text[len(prefix):]
447     return text
448
449 def hunt_rounds(turb, hunt_id):
450     """Returns array of strings giving rounds that exist in the given hunt"""
451
452     response = turb.table.query(
453         KeyConditionExpression=(
454             Key('hunt_id').eq(hunt_id) &
455             Key('SK').begins_with('round-')
456         )
457     )
458
459     if response['Count'] == 0:
460         return []
461
462     return [remove_prefix(option['SK'], 'round-')
463             for option in response['Items']]
464
465 def puzzle(turb, body, args):
466     """Implementation of the /puzzle command
467
468     The args string is currently ignored (this command will bring up
469     a modal dialog for user input instead)."""
470
471     channel_id = body['channel_id'][0]
472     trigger_id = body['trigger_id'][0]
473
474     hunt = hunt_for_channel(turb, channel_id)
475
476     if not hunt:
477         return bot_reply("Sorry, this channel doesn't appear to "
478                          + "be a hunt or puzzle channel")
479
480     round_options = hunt_rounds(turb, hunt['hunt_id'])
481
482     if len(round_options):
483         round_options_block = [
484             multi_select_block("Round(s)", "rounds",
485                                "Existing round(s) this puzzle belongs to",
486                                round_options)
487         ]
488     else:
489         round_options_block = []
490
491     view = {
492         "type": "modal",
493         "private_metadata": json.dumps({
494             "hunt_id": hunt['hunt_id'],
495         }),
496         "title": {"type": "plain_text", "text": "New Puzzle"},
497         "submit": { "type": "plain_text", "text": "Create" },
498         "blocks": [
499             section_block(text_block("*For {}*".format(hunt['name']))),
500             input_block("Puzzle name", "name", "Name of the puzzle"),
501             input_block("Puzzle URL", "url", "External URL of puzzle",
502                         optional=True),
503             * round_options_block,
504             input_block("New round(s)", "new_rounds",
505                         "New round(s) this puzzle belongs to " +
506                         "(comma separated)",
507                         optional=True)
508         ]
509     }
510
511     result = turb.slack_client.views_open(trigger_id=trigger_id,
512                                           view=view)
513
514     if (result['ok']):
515         submission_handlers[result['view']['id']] = puzzle_submission
516
517     return lambda_ok
518
519 commands["/puzzle"] = puzzle
520
521 def puzzle_submission(turb, payload, metadata):
522     """Handler for the user submitting the new puzzle modal
523
524     This is the modal view presented to the user by the puzzle function
525     above."""
526
527     # First, read all the various data from the request
528     meta = json.loads(metadata)
529     hunt_id = meta['hunt_id']
530
531     state = payload['view']['state']['values']
532     name = state['name']['name']['value']
533     url = state['url']['url']['value']
534     if 'rounds' in state:
535         rounds = [option['value'] for option in
536                   state['rounds']['rounds']['selected_options']]
537     else:
538         rounds = []
539     new_rounds = state['new_rounds']['new_rounds']['value']
540
541     # Before doing anything, reject this puzzle if a puzzle already
542     # exists with the same URL.
543     if url:
544         existing = find_puzzle_for_url(turb, hunt_id, url)
545         if existing:
546             return submission_error(
547                 "url",
548                 "Error: A puzzle with this URL already exists.")
549
550     # Create a Slack-channel-safe puzzle_id
551     puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
552
553     # Create a channel for the puzzle
554     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
555
556     try:
557         response = turb.slack_client.conversations_create(
558             name=hunt_dash_channel)
559     except SlackApiError as e:
560         return submission_error(
561             "name",
562             "Error creating Slack channel {}: {}"
563             .format(hunt_dash_channel, e.response['error']))
564
565     channel_id = response['channel']['id']
566
567     # Add any new rounds to the database
568     if new_rounds:
569         for round in new_rounds.split(','):
570             # Drop any leading/trailing spaces from the round name
571             round = round.strip()
572             # Ignore any empty string
573             if not len(round):
574                 continue
575             rounds.append(round)
576             turb.table.put_item(
577                 Item={
578                     'hunt_id': hunt_id,
579                     'SK': 'round-' + round
580                 }
581             )
582
583     # Insert the newly-created puzzle into the database
584     item={
585         "hunt_id": hunt_id,
586         "SK": "puzzle-{}".format(puzzle_id),
587         "puzzle_id": puzzle_id,
588         "channel_id": channel_id,
589         "solution": [],
590         "status": 'unsolved',
591         "name": name,
592     }
593     if url:
594         item['url'] = url
595     if rounds:
596         item['rounds'] = rounds
597     turb.table.put_item(Item=item)
598
599     return lambda_ok
600
601 # XXX: This duplicates functionality eith events.py:set_channel_description
602 def set_channel_topic(turb, puzzle):
603     channel_id = puzzle['channel_id']
604     name = puzzle['name']
605     url = puzzle.get('url', None)
606     sheet_url = puzzle.get('sheet_url', None)
607     state = puzzle.get('state', None)
608     status = puzzle['status']
609
610     description = ''
611
612     if status == 'solved':
613         description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
614
615     description += name
616
617     links = []
618     if url:
619         links.append("<{}|Puzzle>".format(url))
620     if sheet_url:
621         links.append("<{}|Sheet>".format(sheet_url))
622
623     if len(links):
624         description += "({})".format(', '.join(links))
625
626     if state:
627         description += " {}".format(state)
628
629     # Slack only allows 250 characters for a topic
630     if len(description) > 250:
631         description = description[:247] + "..."
632
633     turb.slack_client.conversations_setTopic(channel=channel_id,
634                                              topic=description)
635
636 def state(turb, body, args):
637     """Implementation of the /state command
638
639     The args string should be a brief sentence describing where things
640     stand or what's needed."""
641
642     channel_id = body['channel_id'][0]
643
644     puzzle = puzzle_for_channel(turb, channel_id)
645
646     if not puzzle:
647         return bot_reply(
648             "Sorry, the /state command only works in a puzzle channel")
649
650     # Set the state field in the database
651     puzzle['state'] = args
652     turb.table.put_item(Item=puzzle)
653
654     set_channel_topic(turb, puzzle)
655
656     return lambda_ok
657
658 commands["/state"] = state
659
660 def solved(turb, body, args):
661     """Implementation of the /solved command
662
663     The args string should be a confirmed solution."""
664
665     channel_id = body['channel_id'][0]
666     user_name = body['user_name'][0]
667
668     puzzle = puzzle_for_channel(turb, channel_id)
669
670     if not puzzle:
671         return bot_reply("Sorry, this is not a puzzle channel.")
672
673     if not args:
674         return bot_reply(
675             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
676
677     # Set the status and solution fields in the database
678     puzzle['status'] = 'solved'
679     puzzle['solution'].append(args)
680     if 'state' in puzzle:
681         del puzzle['state']
682     turb.table.put_item(Item=puzzle)
683
684     # Report the solution to the puzzle's channel
685     slack_send_message(
686         turb.slack_client, channel_id,
687         "Puzzle mark solved by {}: `{}`".format(user_name, args))
688
689     # Also report the solution to the hunt channel
690     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
691     slack_send_message(
692         turb.slack_client, hunt['channel_id'],
693         "Puzzle <{}|{}> has been solved!".format(
694             puzzle['channel_url'],
695             puzzle['name'])
696     )
697
698     # And update the puzzle's description
699     set_channel_topic(turb, puzzle)
700
701     # And rename the sheet to suffix with "-SOLVED"
702     turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
703                               puzzle['name'] + "-SOLVED")
704
705     # Finally, rename the Slack channel to add the suffix '-solved'
706     channel_name = "{}-{}-solved".format(
707         puzzle['hunt_id'],
708         puzzle['puzzle_id'])
709     turb.slack_client.conversations_rename(
710         channel=puzzle['channel_id'],
711         name=channel_name)
712
713     return lambda_ok
714
715 commands["/solved"] = solved
716
717
718 def hunt(turb, body, args):
719     """Implementation of the /hunt command
720
721     The (optional) args string can be used to filter which puzzles to
722     display. The first word can be one of 'all', 'unsolved', or
723     'solved' and can be used to display only puzzles with the given
724     status. Any remaining text in the args string will be interpreted
725     as search terms. These will be split into separate terms on space
726     characters, (though quotation marks can be used to include a space
727     character in a term). All terms must match on a puzzle in order
728     for that puzzle to be included. But a puzzle will be considered to
729     match if any of the puzzle title, round title, puzzle URL, puzzle
730     state, or puzzle solution match. Matching will be performed
731     without regard to case sensitivity and the search terms can
732     include regular expression syntax.
733     """
734
735     channel_id = body['channel_id'][0]
736     response_url = body['response_url'][0]
737
738     terms = None
739     if args:
740         # The first word can be a puzzle status and all remaining word
741         # (if any) are search terms. _But_, if the first word is not a
742         # valid puzzle status ('all', 'unsolved', 'solved'), then all
743         # words are search terms and we default status to 'unsolved'.
744         split_args = args.split(' ', 1)
745         status = split_args[0]
746         if (len(split_args) > 1):
747             terms = split_args[1]
748         if status not in ('unsolved', 'solved', 'all'):
749             terms = args
750             status = 'unsolved'
751     else:
752         status = 'unsolved'
753
754     # Separate search terms on spaces (but allow for quotation marks
755     # to capture spaces in a search term)
756     if terms:
757         terms = shlex.split(terms)
758
759     hunt = hunt_for_channel(turb, channel_id)
760
761     if not hunt:
762         return bot_reply("Sorry, this channel doesn't appear to "
763                          + "be a hunt or puzzle channel")
764
765     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
766
767     requests.post(response_url,
768                   json = { 'blocks': blocks },
769                   headers = {'Content-type': 'application/json'}
770                   )
771
772     return lambda_ok
773
774 commands["/hunt"] = hunt