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