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