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