]> git.cworth.org Git - turbot/blob - turbot/interaction.py
53f45a701a9fb9bae68d41316af01fe29f518a14
[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 (
6     find_hunt_for_hunt_id,
7     hunt_blocks,
8     hunt_puzzles_for_hunt_id
9 )
10 from turbot.puzzle import (
11     find_puzzle_for_url,
12     find_puzzle_for_sort_key,
13     puzzle_update_channel_and_sheet,
14     puzzle_id_from_name,
15     puzzle_blocks,
16     puzzle_sort_key,
17     puzzle_copy
18 )
19 from turbot.round import round_quoted_puzzles_titles_answers
20 from turbot.help import turbot_help
21 import turbot.rot
22 import turbot.sheets
23 import turbot.slack
24 import json
25 import re
26 import requests
27 from botocore.exceptions import ClientError
28 from boto3.dynamodb.conditions import Key
29 from turbot.slack import slack_send_message
30 import shlex
31
32 actions = {}
33 actions['button'] = {}
34 commands = {}
35 submission_handlers = {}
36
37 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
38 #
39 # Note: This restriction not only allows for hunt and puzzle ID values to
40 # be used as Slack channel names, but it also allows for '-' as a valid
41 # separator between a hunt and a puzzle ID (for example in the puzzle
42 # edit dialog where a single attribute must capture both values).
43 valid_id_re = r'^[_a-z0-9]+$'
44
45 lambda_ok = {'statusCode': 200}
46
47 def bot_reply(message):
48     """Construct a return value suitable for a bot reply
49
50     This is suitable as a way to give an error back to the user who
51     initiated a slash command, for example."""
52
53     return {
54         'statusCode': 200,
55         'body': message
56     }
57
58 def submission_error(field, error):
59     """Construct an error suitable for returning for an invalid submission.
60
61     Returning this value will prevent a submission and alert the user that
62     the given field is invalid because of the given error."""
63
64     print("Rejecting invalid modal submission: {}".format(error))
65
66     return {
67         'statusCode': 200,
68         'headers': {
69             "Content-Type": "application/json"
70         },
71         'body': json.dumps({
72             "response_action": "errors",
73             "errors": {
74                 field: error
75             }
76         })
77     }
78
79 def multi_static_select(turb, payload):
80     """Handler for the action of user entering a multi-select value"""
81
82     return lambda_ok
83
84 actions['multi_static_select'] = {"*": multi_static_select}
85
86 def edit(turb, body, args):
87
88     """Implementation of the `/edit` command
89
90     This can be used as `/edit` (with no arguments) in either a hunt
91     or a puzzle channel to edit that hunt or puzzle. It can also be
92     called explicitly as `/edit hunt` to edit a hunt even from a
93     puzzle channel.
94
95     In any case, the operation is identical to `/hunt edit` or
96     `/puzzle edit`.
97     """
98
99     # If we have an explicit argument, do what it says to do
100     if args == "hunt":
101         return edit_hunt_command(turb, body)
102
103     if args == "puzzle":
104         return edit_puzzle_command(turb, body)
105
106     # Any other argument string is an error
107     if args:
108         return bot_reply("Error: Unexpected argument: {}\n".format(args) +
109                          "Usage: `/edit puzzle`, `/edit hunt`, or " +
110                          "`/edit` (to choose based on channel)"
111                          )
112
113     # No explicit argument, so select what to edit based on the current channel
114     channel_id = body['channel_id'][0]
115     trigger_id = body['trigger_id'][0]
116
117     puzzle = puzzle_for_channel(turb, channel_id)
118     if puzzle:
119         return edit_puzzle(turb, puzzle, trigger_id)
120
121     hunt = hunt_for_channel(turb, channel_id)
122     if hunt:
123         return edit_hunt(turb, hunt, trigger_id)
124
125     return bot_reply("Sorry, `/edit` only works in a hunt or puzzle channel.")
126
127 commands["/edit"] = edit
128
129
130 def edit_puzzle_command(turb, body):
131     """Implementation of the `/puzzle edit` command
132
133     As dispatched from the puzzle() function.
134     """
135
136     channel_id = body['channel_id'][0]
137     trigger_id = body['trigger_id'][0]
138
139     puzzle = puzzle_for_channel(turb, channel_id)
140
141     if not puzzle:
142         return bot_reply("Sorry, this does not appear to be a puzzle channel.")
143
144     return edit_puzzle(turb, puzzle, trigger_id)
145
146 def edit_puzzle_button(turb, payload):
147     """Handler for the action of user pressing an edit_puzzle button"""
148
149     action_id = payload['actions'][0]['action_id']
150     trigger_id = payload['trigger_id']
151
152     (hunt_id, sort_key) = action_id.split('-', 1)
153
154     puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
155
156     if not puzzle:
157         return bot_reply("Error: Puzzle not found.")
158
159     return edit_puzzle(turb, puzzle, trigger_id)
160
161 actions['button']['edit_puzzle'] = edit_puzzle_button
162
163 def edit_puzzle(turb, puzzle, trigger_id):
164     """Common code for implementing an edit puzzle dialog
165
166     This implementation is common whether the edit operation was invoked
167     by a button (edit_puzzle_button) or a command (edit_puzzle_command).
168     """
169
170     round_options = hunt_rounds(turb, puzzle['hunt_id'])
171
172     if len(round_options):
173         round_options_block = [
174             multi_select_block("Round(s)", "rounds",
175                                "Existing round(s) this puzzle belongs to",
176                                round_options,
177                                initial_options=puzzle.get("rounds", None)),
178         ]
179     else:
180         round_options_block = []
181
182     solved = False
183     if puzzle.get("status", "unsolved") == solved:
184         solved = True
185
186     solution_str = None
187     solution_list = puzzle.get("solution", [])
188     if solution_list:
189         solution_str = ", ".join(solution_list)
190
191     view = {
192         "type": "modal",
193         "private_metadata": json.dumps({
194             "hunt_id": puzzle['hunt_id'],
195             "SK": puzzle["SK"],
196             "puzzle_id": puzzle['puzzle_id'],
197             "channel_id": puzzle["channel_id"],
198             "channel_url": puzzle["channel_url"],
199             "sheet_url": puzzle["sheet_url"],
200         }),
201         "title": {"type": "plain_text", "text": "Edit Puzzle"},
202         "submit": { "type": "plain_text", "text": "Save" },
203         "blocks": [
204             input_block("Puzzle name", "name", "Name of the puzzle",
205                         initial_value=puzzle["name"]),
206             input_block("Puzzle URL", "url", "External URL of puzzle",
207                         initial_value=puzzle.get("url", None),
208                         optional=True),
209             checkbox_block("Is this a meta puzzle?", "Meta", "meta",
210                            checked=(puzzle.get('type', 'plain') == 'meta')),
211             * round_options_block,
212             input_block("New round(s)", "new_rounds",
213                         "New round(s) this puzzle belongs to " +
214                         "(comma separated)",
215                         optional=True),
216             input_block("State", "state",
217                         "State of this puzzle (partial progress, next steps)",
218                         initial_value=puzzle.get("state", None),
219                         optional=True),
220             checkbox_block(
221                 "Puzzle status", "Solved", "solved",
222                 checked=(puzzle.get('status', 'unsolved') == 'solved')),
223             input_block("Solution", "solution",
224                         "Solution(s) (comma-separated if multiple)",
225                         initial_value=solution_str,
226                         optional=True),
227         ]
228     }
229
230     result = turb.slack_client.views_open(trigger_id=trigger_id,
231                                           view=view)
232
233     if (result['ok']):
234         submission_handlers[result['view']['id']] = edit_puzzle_submission
235
236     return lambda_ok
237
238 def edit_puzzle_submission(turb, payload, metadata):
239     """Handler for the user submitting the edit puzzle modal
240
241     This is the modal view presented to the user by the edit_puzzle
242     function above.
243     """
244
245     puzzle={}
246
247     # First, read all the various data from the request
248     meta = json.loads(metadata)
249     puzzle['hunt_id'] = meta['hunt_id']
250     puzzle['SK'] = meta['SK']
251     puzzle['puzzle_id'] = meta['puzzle_id']
252     puzzle['channel_id'] = meta['channel_id']
253     puzzle['channel_url'] = meta['channel_url']
254     puzzle['sheet_url'] = meta['sheet_url']
255
256     state = payload['view']['state']['values']
257     user_id = payload['user']['id']
258
259     puzzle['name'] = state['name']['name']['value']
260     url = state['url']['url']['value']
261     if url:
262         puzzle['url'] = url
263     if state['meta']['meta']['selected_options']:
264         puzzle['type'] = 'meta'
265     else:
266         puzzle['type'] = 'plain'
267     rounds = [option['value'] for option in
268               state['rounds']['rounds']['selected_options']]
269     if rounds:
270         puzzle['rounds'] = rounds
271     new_rounds = state['new_rounds']['new_rounds']['value']
272     puzzle_state = state['state']['state']['value']
273     if puzzle_state:
274         puzzle['state'] = puzzle_state
275     if state['solved']['solved']['selected_options']:
276         puzzle['status'] = 'solved'
277     else:
278         puzzle['status'] = 'unsolved'
279     puzzle['solution'] = []
280     solution = state['solution']['solution']['value']
281     if solution:
282         puzzle['solution'] = [
283             sol.strip() for sol in solution.split(',')
284         ]
285
286     # Verify that there's a solution if the puzzle is mark solved
287     if puzzle['status'] == 'solved' and not puzzle['solution']:
288         return submission_error("solution",
289                                 "A solved puzzle requires a solution.")
290
291     if puzzle['status'] == 'unsolved' and puzzle['solution']:
292         return submission_error("solution",
293                                 "An unsolved puzzle should have no solution.")
294
295     # Add any new rounds to the database
296     if new_rounds:
297         if 'rounds' not in puzzle:
298             puzzle['rounds'] = []
299         for round in new_rounds.split(','):
300             # Drop any leading/trailing spaces from the round name
301             round = round.strip()
302             # Ignore any empty string
303             if not len(round):
304                 continue
305             puzzle['rounds'].append(round)
306             turb.table.put_item(
307                 Item={
308                     'hunt_id': puzzle['hunt_id'],
309                     'SK': 'round-' + round
310                 }
311             )
312
313     # Get old puzzle from the database (to determine what's changed)
314     old_puzzle = find_puzzle_for_sort_key(turb,
315                                           puzzle['hunt_id'],
316                                           puzzle['SK'])
317
318     # If we are changing puzzle type (meta -> plain or plain -> meta)
319     # the the sort key has to change, so compute the new one and delete
320     # the old item from the database.
321     #
322     # XXX: We should really be using a transaction here to combine the
323     # delete_item and the put_item into a single transaction, but
324     # the boto interface is annoying in that transactions are only on
325     # the "Client" object which has a totally different interface than
326     # the "Table" object I've been using so I haven't figured out how
327     # to do that yet.
328
329     if puzzle['type'] != old_puzzle.get('type', 'plain'):
330         puzzle['SK'] = puzzle_sort_key(puzzle)
331         turb.table.delete_item(Key={
332             'hunt_id': old_puzzle['hunt_id'],
333             'SK': old_puzzle['SK']
334         })
335
336     # Update the puzzle in the database
337     turb.table.put_item(Item=puzzle)
338
339     # Inform the puzzle channel about the edit
340     edit_message = "Puzzle edited by <@{}>".format(user_id)
341     blocks = ([section_block(text_block(edit_message+":\n"))] +
342               puzzle_blocks(puzzle, include_rounds=True))
343     slack_send_message(
344         turb.slack_client, puzzle['channel_id'],
345         edit_message, blocks=blocks)
346
347     # Also inform the hunt if the puzzle's solved status changed
348     if puzzle['status'] != old_puzzle['status']:
349         hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
350         if puzzle['status'] == 'solved':
351             message = "Puzzle <{}|{}> has been solved!".format(
352                 puzzle['channel_url'],
353                 puzzle['name'])
354         else:
355             message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
356                 puzzle['channel_url'],
357                 puzzle['name'])
358         slack_send_message(turb.slack_client, hunt['channel_id'], message)
359
360     # We need to set the channel topic if any of puzzle name, url,
361     # state, status, or solution, has changed. Let's just do that
362     # unconditionally here.
363     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
364
365     return lambda_ok
366
367 def edit_hunt_command(turb, body):
368     """Implementation of the `/hunt edit` command
369
370     As dispatched from the hunt() function.
371     """
372
373     channel_id = body['channel_id'][0]
374     trigger_id = body['trigger_id'][0]
375
376     hunt = hunt_for_channel(turb, channel_id)
377
378     if not hunt:
379         return bot_reply("Sorry, this does not appear to be a hunt channel.")
380
381     return edit_hunt(turb, hunt, trigger_id)
382
383 def edit_hunt_button(turb, payload):
384     """Handler for the action of user pressing an edit_hunt button"""
385
386     hunt_id = payload['actions'][0]['action_id']
387     trigger_id = payload['trigger_id']
388
389     hunt = find_hunt_for_hunt_id(turb, hunt_id)
390
391     if not hunt:
392         return bot_reply("Error: Hunt not found.")
393
394     return edit_hunt(turb, hunt, trigger_id)
395
396 actions['button']['edit_hunt'] = edit_hunt_button
397
398 def edit_hunt(turb, hunt, trigger_id):
399     """Common code for implementing an edit hunt dialog
400
401     This implementation is common whether the edit operation was invoked
402     by a button (edit_hunt_button) or a command (edit_hunt_command).
403     """
404
405     view = {
406         "type": "modal",
407         "private_metadata": json.dumps({
408             "hunt_id": hunt["hunt_id"],
409             "SK": hunt["SK"],
410             "is_hunt": hunt["is_hunt"],
411             "channel_id": hunt["channel_id"],
412             "sheet_url": hunt["sheet_url"],
413             "folder_id": hunt["folder_id"],
414         }),
415         "title": { "type": "plain_text", "text": "Edit Hunt" },
416         "submit": { "type": "plain_text", "text": "Save" },
417         "blocks": [
418             input_block("Hunt name", "name", "Name of the hunt",
419                         initial_value=hunt["name"]),
420             input_block("Hunt URL", "url", "External URL of hunt",
421                         initial_value=hunt.get("url", None),
422                         optional=True),
423             checkbox_block("Is this hunt active?", "Active", "active",
424                            checked=(hunt.get('active', False)))
425         ]
426     }
427
428     result = turb.slack_client.views_open(trigger_id=trigger_id,
429                                           view=view)
430
431     if result['ok']:
432         submission_handlers[result['view']['id']] = edit_hunt_submission
433
434     return lambda_ok
435
436 def edit_hunt_submission(turb, payload, metadata):
437     """Handler for the user submitting the edit hunt modal
438
439     This is the modal view presented by the edit_hunt function above.
440     """
441
442     hunt={}
443
444     # First, read all the various data from the request
445     meta = json.loads(metadata)
446     hunt['hunt_id'] = meta['hunt_id']
447     hunt['SK'] = meta['SK']
448     hunt['is_hunt'] = meta['is_hunt']
449     hunt['channel_id'] = meta['channel_id']
450     hunt['sheet_url'] = meta['sheet_url']
451     hunt['folder_id'] = meta['folder_id']
452
453     state = payload['view']['state']['values']
454     user_id = payload['user']['id']
455
456     hunt['name'] = state['name']['name']['value']
457     url = state['url']['url']['value']
458     if url:
459         hunt['url'] = url
460
461     if state['active']['active']['selected_options']:
462         hunt['active'] = True
463     else:
464         hunt['active'] = False
465
466     # Update the hunt in the database
467     turb.table.put_item(Item=hunt)
468
469     # Inform the hunt channel about the edit
470     edit_message = "Hunt edited by <@{}>".format(user_id)
471     blocks = [
472         section_block(text_block(edit_message)),
473         section_block(text_block("Hunt name: {}".format(hunt['name']))),
474     ]
475
476     url = hunt.get('url', None)
477     if url:
478         blocks.append(
479             section_block(text_block("Hunt URL: {}".format(hunt['url'])))
480         )
481
482     slack_send_message(
483         turb.slack_client, hunt['channel_id'],
484         edit_message, blocks=blocks)
485
486     return lambda_ok
487
488 def new_hunt_command(turb, body):
489     """Implementation of the '/hunt new' command
490
491     As dispatched from the hunt() function.
492     """
493
494     trigger_id = body['trigger_id'][0]
495
496     return new_hunt(turb, trigger_id)
497
498 def new_hunt_button(turb, payload):
499     """Handler for the action of user pressing the new_hunt button"""
500
501     trigger_id = payload['trigger_id']
502
503     return new_hunt(turb, trigger_id)
504
505 def new_hunt(turb, trigger_id):
506     """Common code for implementing a new hunt dialog
507
508     This implementation is common whether the operations was invoked
509     by a button (new_hunt_button) or a command (new_hunt_command).
510     """
511
512     view = {
513         "type": "modal",
514         "private_metadata": json.dumps({}),
515         "title": { "type": "plain_text", "text": "New Hunt" },
516         "submit": { "type": "plain_text", "text": "Create" },
517         "blocks": [
518             input_block("Hunt name", "name", "Name of the hunt"),
519             input_block("Hunt ID", "hunt_id",
520                         "Used as puzzle channel prefix "
521                         + "(no spaces nor punctuation)"),
522             input_block("Hunt URL", "url", "External URL of hunt",
523                         optional=True)
524         ],
525     }
526
527     result = turb.slack_client.views_open(trigger_id=trigger_id,
528                                           view=view)
529     if (result['ok']):
530         submission_handlers[result['view']['id']] = new_hunt_submission
531
532     return lambda_ok
533
534 actions['button']['new_hunt'] = new_hunt
535
536 def new_hunt_submission(turb, payload, metadata):
537     """Handler for the user submitting the new hunt modal
538
539     This is the modal view presented to the user by the new_hunt
540     function above."""
541
542     state = payload['view']['state']['values']
543     user_id = payload['user']['id']
544     name = state['name']['name']['value']
545     hunt_id = state['hunt_id']['hunt_id']['value']
546     url = state['url']['url']['value']
547
548     # Validate that the hunt_id contains no invalid characters
549     if not re.match(valid_id_re, hunt_id):
550         return submission_error("hunt_id",
551                                 "Hunt ID can only contain lowercase letters, "
552                                 + "numbers, and underscores")
553
554     # Check to see if the turbot table exists
555     try:
556         exists = turb.table.table_status in ("CREATING", "UPDATING",
557                                              "ACTIVE")
558     except ClientError:
559         exists = False
560
561     # Create the turbot table if necessary.
562     if not exists:
563         turb.table = turb.db.create_table(
564             TableName='turbot',
565             KeySchema=[
566                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
567                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
568             ],
569             AttributeDefinitions=[
570                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
571                 {'AttributeName': 'SK', 'AttributeType': 'S'},
572                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
573                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
574                 {'AttributeName': 'url', 'AttributeType': 'S'}
575             ],
576             ProvisionedThroughput={
577                 'ReadCapacityUnits': 5,
578                 'WriteCapacityUnits': 5
579             },
580             GlobalSecondaryIndexes=[
581                 {
582                     'IndexName': 'channel_id_index',
583                     'KeySchema': [
584                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
585                     ],
586                     'Projection': {
587                         'ProjectionType': 'ALL'
588                     },
589                     'ProvisionedThroughput': {
590                         'ReadCapacityUnits': 5,
591                         'WriteCapacityUnits': 5
592                     }
593                 },
594                 {
595                     'IndexName': 'is_hunt_index',
596                     'KeySchema': [
597                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
598                     ],
599                     'Projection': {
600                         'ProjectionType': 'ALL'
601                     },
602                     'ProvisionedThroughput': {
603                         'ReadCapacityUnits': 5,
604                         'WriteCapacityUnits': 5
605                     }
606                 }
607             ],
608             LocalSecondaryIndexes = [
609                 {
610                     'IndexName': 'url_index',
611                     'KeySchema': [
612                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
613                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
614                     ],
615                     'Projection': {
616                         'ProjectionType': 'ALL'
617                     }
618                 }
619             ]
620         )
621         return submission_error(
622             "hunt_id",
623             "Still bootstrapping turbot table. Try again in a minute, please.")
624
625     # Create a channel for the hunt
626     try:
627         response = turb.slack_client.conversations_create(name=hunt_id)
628     except SlackApiError as e:
629         return submission_error("hunt_id",
630                                 "Error creating Slack channel: {}"
631                                 .format(e.response['error']))
632
633     channel_id = response['channel']['id']
634
635     # Insert the newly-created hunt into the database
636     # (leaving it as non-active for now until the channel-created handler
637     #  finishes fixing it up with a sheet and a companion table)
638     item={
639         "hunt_id": hunt_id,
640         "SK": "hunt-{}".format(hunt_id),
641         "is_hunt": hunt_id,
642         "channel_id": channel_id,
643         "active": False,
644         "name": name,
645     }
646     if url:
647         item['url'] = url
648     turb.table.put_item(Item=item)
649
650     # Invite the initiating user to the channel
651     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
652
653     return lambda_ok
654
655 def view_submission(turb, payload):
656     """Handler for Slack interactive view submission
657
658     Specifically, those that have a payload type of 'view_submission'"""
659
660     view_id = payload['view']['id']
661     metadata = payload['view']['private_metadata']
662
663     if view_id in submission_handlers:
664         return submission_handlers[view_id](turb, payload, metadata)
665
666     print("Error: Unknown view ID: {}".format(view_id))
667     return {
668         'statusCode': 400
669     }
670
671 def rot(turb, body, args):
672     """Implementation of the /rot command
673
674     The args string should be as follows:
675
676         [count|*] String to be rotated
677
678     That is, the first word of the string is an optional number (or
679     the character '*'). If this is a number it indicates an amount to
680     rotate each character in the string. If the count is '*' or is not
681     present, then the string will be rotated through all possible 25
682     values.
683
684     The result of the rotation is returned (with Slack formatting) in
685     the body of the response so that Slack will provide it as a reply
686     to the user who submitted the slash command."""
687
688     channel_name = body['channel_name'][0]
689     response_url = body['response_url'][0]
690     channel_id = body['channel_id'][0]
691
692     result = turbot.rot.rot(args)
693
694     if (channel_name == "directmessage"):
695         requests.post(response_url,
696                       json = {"text": result},
697                       headers = {"Content-type": "application/json"})
698     else:
699         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
700
701     return lambda_ok
702
703 commands["/rot"] = rot
704
705 def get_table_item(turb, table_name, key, value):
706     """Get an item from the database 'table_name' with 'key' as 'value'
707
708     Returns a tuple of (item, table) if found and (None, None) otherwise."""
709
710     table = turb.db.Table(table_name)
711
712     response = table.get_item(Key={key: value})
713
714     if 'Item' in response:
715         return (response['Item'], table)
716     else:
717         return (None, None)
718
719 def db_entry_for_channel(turb, channel_id):
720     """Given a channel ID return the database item for this channel
721
722     If this channel is a registered hunt or puzzle channel, return the
723     corresponding row from the database for this channel. Otherwise,
724     return None.
725
726     Note: If you need to specifically ensure that the channel is a
727     puzzle or a hunt, please call puzzle_for_channel or
728     hunt_for_channel respectively.
729     """
730
731     response = turb.table.query(
732         IndexName = "channel_id_index",
733         KeyConditionExpression=Key("channel_id").eq(channel_id)
734     )
735
736     if response['Count'] == 0:
737         return None
738
739     return response['Items'][0]
740
741
742 def puzzle_for_channel(turb, channel_id):
743
744     """Given a channel ID return the puzzle from the database for this channel
745
746     If the given channel_id is a puzzle's channel, this function
747     returns a dict filled with the attributes from the puzzle's entry
748     in the database.
749
750     Otherwise, this function returns None.
751     """
752
753     entry = db_entry_for_channel(turb, channel_id)
754
755     if entry and entry['SK'].startswith('puzzle-'):
756         return entry
757     else:
758         return None
759
760 def hunt_for_channel(turb, channel_id):
761
762     """Given a channel ID return the hunt from the database for this channel
763
764     This works whether the original channel is a primary hunt channel,
765     or if it is one of the channels of a puzzle belonging to the hunt.
766
767     Returns None if channel does not belong to a hunt, otherwise a
768     dictionary with all fields from the hunt's row in the table,
769     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
770     """
771
772     entry = db_entry_for_channel(turb, channel_id)
773
774     # We're done if this channel doesn't exist in the database at all
775     if not entry:
776         return None
777
778     # Also done if this channel is a hunt channel
779     if entry['SK'].startswith('hunt-'):
780         return entry
781
782     # Otherwise, (the channel is in the database, but is not a hunt),
783     # we expect this to be a puzzle channel instead
784     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
785
786 # python3.9 has a built-in removeprefix but AWS only has python3.8
787 def remove_prefix(text, prefix):
788     if text.startswith(prefix):
789         return text[len(prefix):]
790     return text
791
792 def hunt_rounds(turb, hunt_id):
793     """Returns array of strings giving rounds that exist in the given hunt"""
794
795     response = turb.table.query(
796         KeyConditionExpression=(
797             Key('hunt_id').eq(hunt_id) &
798             Key('SK').begins_with('round-')
799         )
800     )
801
802     if response['Count'] == 0:
803         return []
804
805     return [remove_prefix(option['SK'], 'round-')
806             for option in response['Items']]
807
808 def puzzle(turb, body, args):
809     """Implementation of the /puzzle command
810
811     The args string can be a sub-command:
812
813         /puzzle new: Bring up a dialog to create a new puzzle
814
815         /puzzle edit: Edit the puzzle for the current channel
816
817     Or with no argument at all:
818
819         /puzzle: Print details of the current puzzle (if in a puzzle channel)
820     """
821
822     if args == 'new':
823         return new_puzzle(turb, body)
824
825     if args == 'edit':
826         return edit_puzzle_command(turb, body)
827
828     if len(args):
829         return bot_reply("Unknown syntax for `/puzzle` command. " +
830                          "Valid commands are: `/puzzle`, `/puzzle edit`, " +
831                          "and `/puzzle new` to display, edit, or create " +
832                          "a puzzle.")
833
834     # For no arguments we print the current puzzle as a reply
835     channel_id = body['channel_id'][0]
836     response_url = body['response_url'][0]
837
838     puzzle = puzzle_for_channel(turb, channel_id)
839
840     if not puzzle:
841         hunt = hunt_for_channel(turb, channel_id)
842         if hunt:
843             return bot_reply(
844                 "This is not a puzzle channel, but is a hunt channel. "
845                 + "If you want to create a new puzzle for this hunt, use "
846                 + "`/puzzle new`.")
847         else:
848             return bot_reply(
849                 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
850                 + "channel, so the `/puzzle` command cannot work here.")
851
852     blocks = puzzle_blocks(puzzle, include_rounds=True)
853
854     # For a meta puzzle, also display the titles and solutions for all
855     # puzzles in the same round.
856     if puzzle.get('type', 'plain') == 'meta':
857         puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
858
859         # Drop this puzzle itself from the report
860         puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
861
862         for round in puzzle.get('rounds', [None]):
863             answers = round_quoted_puzzles_titles_answers(round, puzzles)
864             blocks += [
865                 section_block(text_block(
866                     "*Feeder solutions from round {}*".format(
867                         round if round else "<none>"
868                     ))),
869                 section_block(text_block(answers))
870             ]
871
872     requests.post(response_url,
873                   json = {'blocks': blocks},
874                   headers = {'Content-type': 'application/json'}
875                   )
876
877     return lambda_ok
878
879 commands["/puzzle"] = puzzle
880
881 def new(turb, body, args):
882     """Implementation of the `/new` command
883
884     This can be used to create a new hunt ("/new hunt") or a new
885     puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
886     default behavior (as it is much more common).
887
888     This operations are identical to the existing "/hunt new" and
889     "/puzzle new". I don't know that that redundancy is actually
890     helpful in the interface. But at least having both allows us to
891     experiment and decide which is more natural and should be kept
892     around long-term.
893     """
894
895     if args == 'hunt':
896         return new_hunt_command(turb, body)
897
898     return new_puzzle(turb, body)
899
900 commands["/new"] = new
901
902 def new_puzzle(turb, body):
903     """Implementation of the "/puzzle new" command
904
905     This brings up a dialog box for creating a new puzzle.
906     """
907
908     channel_id = body['channel_id'][0]
909     trigger_id = body['trigger_id'][0]
910
911     hunt = hunt_for_channel(turb, channel_id)
912
913     if not hunt:
914         return bot_reply("Sorry, this channel doesn't appear to "
915                          + "be a hunt or puzzle channel")
916
917     round_options = hunt_rounds(turb, hunt['hunt_id'])
918
919     if len(round_options):
920         round_options_block = [
921             multi_select_block("Round(s)", "rounds",
922                                "Existing round(s) this puzzle belongs to",
923                                round_options)
924         ]
925     else:
926         round_options_block = []
927
928     view = {
929         "type": "modal",
930         "private_metadata": json.dumps({
931             "hunt_id": hunt['hunt_id'],
932         }),
933         "title": {"type": "plain_text", "text": "New Puzzle"},
934         "submit": { "type": "plain_text", "text": "Create" },
935         "blocks": [
936             section_block(text_block("*For {}*".format(hunt['name']))),
937             input_block("Puzzle name", "name", "Name of the puzzle"),
938             input_block("Puzzle URL", "url", "External URL of puzzle",
939                         optional=True),
940             checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
941             * round_options_block,
942             input_block("New round(s)", "new_rounds",
943                         "New round(s) this puzzle belongs to " +
944                         "(comma separated)",
945                         optional=True)
946         ]
947     }
948
949     result = turb.slack_client.views_open(trigger_id=trigger_id,
950                                           view=view)
951
952     if (result['ok']):
953         submission_handlers[result['view']['id']] = new_puzzle_submission
954
955     return lambda_ok
956
957 def new_puzzle_submission(turb, payload, metadata):
958     """Handler for the user submitting the new puzzle modal
959
960     This is the modal view presented to the user by the new_puzzle
961     function above.
962     """
963
964     # First, read all the various data from the request
965     meta = json.loads(metadata)
966     hunt_id = meta['hunt_id']
967
968     state = payload['view']['state']['values']
969     name = state['name']['name']['value']
970     url = state['url']['url']['value']
971     if state['meta']['meta']['selected_options']:
972         puzzle_type = 'meta'
973     else:
974         puzzle_type = 'plain'
975     if 'rounds' in state:
976         rounds = [option['value'] for option in
977                   state['rounds']['rounds']['selected_options']]
978     else:
979         rounds = []
980     new_rounds = state['new_rounds']['new_rounds']['value']
981
982     # Before doing anything, reject this puzzle if a puzzle already
983     # exists with the same URL.
984     if url:
985         existing = find_puzzle_for_url(turb, hunt_id, url)
986         if existing:
987             return submission_error(
988                 "url",
989                 "Error: A puzzle with this URL already exists.")
990
991     # Create a Slack-channel-safe puzzle_id
992     puzzle_id = puzzle_id_from_name(name)
993
994     # Create a channel for the puzzle
995     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
996
997     try:
998         response = turb.slack_client.conversations_create(
999             name=hunt_dash_channel)
1000     except SlackApiError as e:
1001         return submission_error(
1002             "name",
1003             "Error creating Slack channel {}: {}"
1004             .format(hunt_dash_channel, e.response['error']))
1005
1006     channel_id = response['channel']['id']
1007
1008     # Add any new rounds to the database
1009     if new_rounds:
1010         for round in new_rounds.split(','):
1011             # Drop any leading/trailing spaces from the round name
1012             round = round.strip()
1013             # Ignore any empty string
1014             if not len(round):
1015                 continue
1016             rounds.append(round)
1017             turb.table.put_item(
1018                 Item={
1019                     'hunt_id': hunt_id,
1020                     'SK': 'round-' + round
1021                 }
1022             )
1023
1024     # Construct a puzzle dict
1025     puzzle = {
1026         "hunt_id": hunt_id,
1027         "puzzle_id": puzzle_id,
1028         "channel_id": channel_id,
1029         "solution": [],
1030         "status": 'unsolved',
1031         "name": name,
1032         "type": puzzle_type
1033     }
1034     if url:
1035         puzzle['url'] = url
1036     if rounds:
1037         puzzle['rounds'] = rounds
1038
1039     # Finally, compute the appropriate sort key
1040     puzzle["SK"] = puzzle_sort_key(puzzle)
1041
1042     # Insert the newly-created puzzle into the database
1043     turb.table.put_item(Item=puzzle)
1044
1045     return lambda_ok
1046
1047 def state(turb, body, args):
1048     """Implementation of the /state command
1049
1050     The args string should be a brief sentence describing where things
1051     stand or what's needed."""
1052
1053     channel_id = body['channel_id'][0]
1054
1055     old_puzzle = puzzle_for_channel(turb, channel_id)
1056
1057     if not old_puzzle:
1058         return bot_reply(
1059             "Sorry, the /state command only works in a puzzle channel")
1060
1061     # Make a deep copy of the puzzle object
1062     puzzle = puzzle_copy(old_puzzle)
1063
1064     # Update the puzzle in the database
1065     puzzle['state'] = args
1066     turb.table.put_item(Item=puzzle)
1067
1068     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1069
1070     return lambda_ok
1071
1072 commands["/state"] = state
1073
1074 def tag(turb, body, args):
1075     """Implementation of the `/tag` command.
1076
1077     Arg is either a tag to add (optionally prefixed with '+'), or if
1078     prefixed with '-' is a tag to remove.
1079     """
1080
1081     if not args:
1082         return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1083                          + "or `/tag -TAG_TO_REMOVE`.")
1084
1085     channel_id = body['channel_id'][0]
1086
1087     old_puzzle = puzzle_for_channel(turb, channel_id)
1088
1089     if not old_puzzle:
1090         return bot_reply(
1091             "Sorry, the /tag command only works in a puzzle channel")
1092
1093     if args[0] == '-':
1094         tag = args[1:]
1095         action = 'remove'
1096     else:
1097         tag = args
1098         if tag[0] == '+':
1099             tag = tag[1:]
1100         action = 'add'
1101
1102     # Force tag to all uppercase
1103     tag = tag.upper()
1104
1105     # Reject a tag that is not alphabetic or underscore A-Z_
1106     if not re.match(r'^[A-Z0-9_]*$', tag):
1107         return bot_reply("Sorry, tags can only contain letters, numbers, "
1108                          + "and the underscore character.")
1109
1110     if action == 'remove':
1111         if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1112             return bot_reply("Nothing to do. This puzzle is not tagged "
1113                              + "with the tag: {}".format(tag))
1114     else:
1115         if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1116             return bot_reply("Nothing to do. This puzzle is already tagged "
1117                              + "with the tag: {}".format(tag))
1118
1119     # OK. Error checking is done. Let's get to work
1120
1121     # Make a deep copy of the puzzle object
1122     puzzle = puzzle_copy(old_puzzle)
1123
1124     if action == 'remove':
1125         puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1126     else:
1127         if 'tags' not in puzzle:
1128             puzzle['tags'] = [tag]
1129         else:
1130             puzzle['tags'].append(tag)
1131
1132     turb.table.put_item(Item=puzzle)
1133
1134     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1135
1136     return lambda_ok
1137
1138 commands["/tag"] = tag
1139
1140 def solved(turb, body, args):
1141     """Implementation of the /solved command
1142
1143     The args string should be a confirmed solution."""
1144
1145     channel_id = body['channel_id'][0]
1146     user_id = body['user_id'][0]
1147
1148     old_puzzle = puzzle_for_channel(turb, channel_id)
1149
1150     if not old_puzzle:
1151         return bot_reply("Sorry, this is not a puzzle channel.")
1152
1153     if not args:
1154         return bot_reply(
1155             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1156
1157     # Make a deep copy of the puzzle object
1158     puzzle = puzzle_copy(old_puzzle)
1159
1160     # Set the status and solution fields in the database
1161     puzzle['status'] = 'solved'
1162     puzzle['solution'].append(args)
1163     if 'state' in puzzle:
1164         del puzzle['state']
1165     turb.table.put_item(Item=puzzle)
1166
1167     # Report the solution to the puzzle's channel
1168     slack_send_message(
1169         turb.slack_client, channel_id,
1170         "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1171
1172     # Also report the solution to the hunt channel
1173     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1174     slack_send_message(
1175         turb.slack_client, hunt['channel_id'],
1176         "Puzzle <{}|{}> has been solved!".format(
1177             puzzle['channel_url'],
1178             puzzle['name'])
1179     )
1180
1181     # And update the puzzle's description
1182     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1183
1184     return lambda_ok
1185
1186 commands["/solved"] = solved
1187
1188 def hunt(turb, body, args):
1189     """Implementation of the /hunt command
1190
1191     The (optional) args string can be used to filter which puzzles to
1192     display. The first word can be one of 'all', 'unsolved', or
1193     'solved' and can be used to display only puzzles with the given
1194     status. If this first word is missing, this command will display
1195     only unsolved puzzles by default.
1196
1197     Any remaining text in the args string will be interpreted as
1198     search terms. These will be split into separate terms on space
1199     characters, (though quotation marks can be used to include a space
1200     character in a term). All terms must match on a puzzle in order
1201     for that puzzle to be included. But a puzzle will be considered to
1202     match if any of the puzzle title, round title, puzzle URL, puzzle
1203     state, puzzle type, tags, or puzzle solution match. Matching will
1204     be performed without regard to case sensitivity and the search
1205     terms can include regular expression syntax.
1206
1207     """
1208
1209     channel_id = body['channel_id'][0]
1210     response_url = body['response_url'][0]
1211
1212     # First, farm off "/hunt new" and "/hunt edit" a separate commands
1213     if args == "new":
1214         return new_hunt_command(turb, body)
1215
1216     if args == "edit":
1217         return edit_hunt_command(turb, body)
1218
1219     terms = None
1220     if args:
1221         # The first word can be a puzzle status and all remaining word
1222         # (if any) are search terms. _But_, if the first word is not a
1223         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1224         # words are search terms and we default status to 'unsolved'.
1225         split_args = args.split(' ', 1)
1226         status = split_args[0]
1227         if (len(split_args) > 1):
1228             terms = split_args[1]
1229         if status not in ('unsolved', 'solved', 'all'):
1230             terms = args
1231             status = 'unsolved'
1232     else:
1233         status = 'unsolved'
1234
1235     # Separate search terms on spaces (but allow for quotation marks
1236     # to capture spaces in a search term)
1237     if terms:
1238         terms = shlex.split(terms)
1239
1240     hunt = hunt_for_channel(turb, channel_id)
1241
1242     if not hunt:
1243         return bot_reply("Sorry, this channel doesn't appear to "
1244                          + "be a hunt or puzzle channel")
1245
1246     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1247
1248     requests.post(response_url,
1249                   json = { 'blocks': blocks },
1250                   headers = {'Content-type': 'application/json'}
1251                   )
1252
1253     return lambda_ok
1254
1255 commands["/hunt"] = hunt
1256
1257 def round(turb, body, args):
1258     """Implementation of the /round command
1259
1260     Displays puzzles in the same round(s) as the puzzle for the
1261     current channel.
1262
1263     The (optional) args string can be used to filter which puzzles to
1264     display. The first word can be one of 'all', 'unsolved', or
1265     'solved' and can be used to display only puzzles with the given
1266     status. If this first word is missing, this command will display
1267     all puzzles in the round by default.
1268
1269     Any remaining text in the args string will be interpreted as
1270     search terms. These will be split into separate terms on space
1271     characters, (though quotation marks can be used to include a space
1272     character in a term). All terms must match on a puzzle in order
1273     for that puzzle to be included. But a puzzle will be considered to
1274     match if any of the puzzle title, round title, puzzle URL, puzzle
1275     state, or puzzle solution match. Matching will be performed
1276     without regard to case sensitivity and the search terms can
1277     include regular expression syntax.
1278     """
1279
1280     channel_id = body['channel_id'][0]
1281     response_url = body['response_url'][0]
1282
1283     puzzle = puzzle_for_channel(turb, channel_id)
1284     hunt = hunt_for_channel(turb, channel_id)
1285
1286     if not puzzle:
1287         if hunt:
1288             return bot_reply(
1289                 "This is not a puzzle channel, but is a hunt channel. "
1290                 + "Use /hunt if you want to see all rounds for this hunt.")
1291         else:
1292             return bot_reply(
1293                 "Sorry, this channel doesn't appear to be a puzzle channel "
1294                 + "so the `/round` command cannot work here.")
1295
1296     terms = None
1297     if args:
1298         # The first word can be a puzzle status and all remaining word
1299         # (if any) are search terms. _But_, if the first word is not a
1300         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1301         # words are search terms and we default status to 'unsolved'.
1302         split_args = args.split(' ', 1)
1303         status = split_args[0]
1304         if (len(split_args) > 1):
1305             terms = split_args[1]
1306         if status not in ('unsolved', 'solved', 'all'):
1307             terms = args
1308             status = 'all'
1309     else:
1310         status = 'all'
1311
1312     # Separate search terms on spaces (but allow for quotation marks
1313     # to capture spaces in a search term)
1314     if terms:
1315         terms = shlex.split(terms)
1316
1317     blocks = hunt_blocks(turb, hunt,
1318                          puzzle_status=status, search_terms=terms,
1319                          limit_to_rounds=puzzle.get('rounds', [])
1320                          )
1321
1322     requests.post(response_url,
1323                   json = { 'blocks': blocks },
1324                   headers = {'Content-type': 'application/json'}
1325                   )
1326
1327     return lambda_ok
1328
1329 commands["/round"] = round
1330
1331 def help_command(turb, body, args):
1332     """Implementation of the /help command
1333
1334     Displays help on how to use Turbot.
1335     """
1336
1337     channel_id = body['channel_id'][0]
1338     response_url = body['response_url'][0]
1339
1340     help_string = turbot_help(args)
1341
1342     # The "/help me" command is special in that it reports in the
1343     # current channel, (where all other commands report privately to
1344     # the invoking user).
1345     if args == "me":
1346         turb.slack_client.chat_postMessage(
1347             channel=channel_id, text=help_string)
1348     else:
1349         requests.post(response_url,
1350                       json = {"text": help_string},
1351                       headers = {"Content-type": "application/json"})
1352
1353     return lambda_ok
1354
1355 commands["/help"] = help_command