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