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