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