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