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