]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add display of round(s) in /puzzle output
[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 find_hunt_for_hunt_id, hunt_blocks
6 from turbot.puzzle import (
7     find_puzzle_for_url,
8     find_puzzle_for_puzzle_id,
9     puzzle_update_channel_and_sheet,
10     puzzle_id_from_name,
11     puzzle_blocks
12 )
13 import turbot.rot
14 import turbot.sheets
15 import turbot.slack
16 import json
17 import re
18 import requests
19 from botocore.exceptions import ClientError
20 from boto3.dynamodb.conditions import Key
21 from turbot.slack import slack_send_message
22 import shlex
23
24 actions = {}
25 actions['button'] = {}
26 commands = {}
27 submission_handlers = {}
28
29 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
30 #
31 # Note: This restriction not only allows for hunt and puzzle ID values to
32 # be used as Slack channel names, but it also allows for '-' as a valid
33 # separator between a hunt and a puzzle ID (for example in the puzzle
34 # edit dialog where a single attribute must capture both values).
35 valid_id_re = r'^[_a-z0-9]+$'
36
37 lambda_ok = {'statusCode': 200}
38
39 def bot_reply(message):
40     """Construct a return value suitable for a bot reply
41
42     This is suitable as a way to give an error back to the user who
43     initiated a slash command, for example."""
44
45     return {
46         'statusCode': 200,
47         'body': message
48     }
49
50 def submission_error(field, error):
51     """Construct an error suitable for returning for an invalid submission.
52
53     Returning this value will prevent a submission and alert the user that
54     the given field is invalid because of the given error."""
55
56     print("Rejecting invalid modal submission: {}".format(error))
57
58     return {
59         'statusCode': 200,
60         'headers': {
61             "Content-Type": "application/json"
62         },
63         'body': json.dumps({
64             "response_action": "errors",
65             "errors": {
66                 field: error
67             }
68         })
69     }
70
71 def multi_static_select(turb, payload):
72     """Handler for the action of user entering a multi-select value"""
73
74     return lambda_ok
75
76 actions['multi_static_select'] = {"*": multi_static_select}
77
78 def edit_puzzle(turb, payload):
79     """Handler for the action of user pressing an edit_puzzle button"""
80
81     action_id = payload['actions'][0]['action_id']
82     response_url = payload['response_url']
83     trigger_id = payload['trigger_id']
84
85     (hunt_id, puzzle_id) = action_id.split('-', 1)
86
87     puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
88
89     if not puzzle:
90         requests.post(response_url,
91                       json = {"text": "Error: Puzzle not found!"},
92                       headers = {"Content-type": "application/json"})
93         return bot_reply("Error: Puzzle not found.")
94
95     round_options = hunt_rounds(turb, hunt_id)
96
97     if len(round_options):
98         round_options_block = [
99             multi_select_block("Round(s)", "rounds",
100                                "Existing round(s) this puzzle belongs to",
101                                round_options,
102                                initial_options=puzzle.get("rounds", None)),
103         ]
104     else:
105         round_options_block = []
106
107     solved = False
108     if puzzle.get("status", "unsolved") == solved:
109         solved = True
110
111     solution_str = None
112     solution_list = puzzle.get("solution", [])
113     if solution_list:
114         solution_str = ", ".join(solution_list)
115
116     view = {
117         "type": "modal",
118         "private_metadata": json.dumps({
119             "hunt_id": hunt_id,
120             "SK": puzzle["SK"],
121             "puzzle_id": puzzle_id,
122             "channel_id": puzzle["channel_id"],
123             "channel_url": puzzle["channel_url"],
124             "sheet_url": puzzle["sheet_url"],
125         }),
126         "title": {"type": "plain_text", "text": "Edit Puzzle"},
127         "submit": { "type": "plain_text", "text": "Save" },
128         "blocks": [
129             input_block("Puzzle name", "name", "Name of the puzzle",
130                         initial_value=puzzle["name"]),
131             input_block("Puzzle URL", "url", "External URL of puzzle",
132                         initial_value=puzzle.get("url", None),
133                         optional=True),
134             * round_options_block,
135             input_block("New round(s)", "new_rounds",
136                         "New round(s) this puzzle belongs to " +
137                         "(comma separated)",
138                         optional=True),
139             input_block("State", "state",
140                         "State of this puzzle (partial progress, next steps)",
141                         initial_value=puzzle.get("state", None),
142                         optional=True),
143             checkbox_block(
144                 "Puzzle status", "Solved", "solved",
145                 checked=(puzzle.get('status', 'unsolved') == 'solved')),
146             input_block("Solution", "solution",
147                         "Solution(s) (comma-separated if multiple)",
148                         initial_value=solution_str,
149                         optional=True),
150         ]
151     }
152
153     result = turb.slack_client.views_open(trigger_id=trigger_id,
154                                           view=view)
155
156     if (result['ok']):
157         submission_handlers[result['view']['id']] = edit_puzzle_submission
158
159     return lambda_ok
160
161 actions['button']['edit_puzzle'] = edit_puzzle
162
163 def edit_puzzle_submission(turb, payload, metadata):
164     """Handler for the user submitting the edit puzzle modal
165
166     This is the modal view presented to the user by the edit_puzzle
167     function above.
168     """
169
170     puzzle={}
171
172     # First, read all the various data from the request
173     meta = json.loads(metadata)
174     puzzle['hunt_id'] = meta['hunt_id']
175     puzzle['SK'] = meta['SK']
176     puzzle['puzzle_id'] = meta['puzzle_id']
177     puzzle['channel_id'] = meta['channel_id']
178     puzzle['channel_url'] = meta['channel_url']
179     puzzle['sheet_url'] = meta['sheet_url']
180
181     state = payload['view']['state']['values']
182
183     puzzle['name'] = state['name']['name']['value']
184     url = state['url']['url']['value']
185     if url:
186         puzzle['url'] = url
187     rounds = [option['value'] for option in
188               state['rounds']['rounds']['selected_options']]
189     if rounds:
190         puzzle['rounds'] = rounds
191     new_rounds = state['new_rounds']['new_rounds']['value']
192     puzzle_state = state['state']['state']['value']
193     if puzzle_state:
194         puzzle['state'] = puzzle_state
195     if state['solved']['solved']['selected_options']:
196         puzzle['status'] = 'solved'
197     else:
198         puzzle['status'] = 'unsolved'
199     puzzle['solution'] = []
200     solution = state['solution']['solution']['value']
201     if solution:
202         puzzle['solution'] = [
203             sol.strip() for sol in solution.split(',')
204         ]
205
206     # Verify that there's a solution if the puzzle is mark solved
207     if puzzle['status'] == 'solved' and not puzzle['solution']:
208         return submission_error("solution",
209                                 "A solved puzzle requires a solution.")
210
211     if puzzle['status'] == 'unsolved' and puzzle['solution']:
212         return submission_error("solution",
213                                 "An unsolved puzzle should have no solution.")
214
215     # Add any new rounds to the database
216     if new_rounds:
217         if 'rounds' not in puzzle:
218             puzzle['rounds'] = []
219         for round in new_rounds.split(','):
220             # Drop any leading/trailing spaces from the round name
221             round = round.strip()
222             # Ignore any empty string
223             if not len(round):
224                 continue
225             puzzle['rounds'].append(round)
226             turb.table.put_item(
227                 Item={
228                     'hunt_id': puzzle['hunt_id'],
229                     'SK': 'round-' + round
230                 }
231             )
232
233     # Get old puzzle from the database (to determine what's changed)
234     old_puzzle = find_puzzle_for_puzzle_id(turb,
235                                            puzzle['hunt_id'],
236                                            puzzle['puzzle_id'])
237
238     # Update the puzzle in the database
239     turb.table.put_item(Item=puzzle)
240
241     # We need to set the channel topic if any of puzzle name, url,
242     # state, status, or solution, has changed. Let's just do that
243     # unconditionally here.
244     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
245
246     return lambda_ok
247
248 def new_hunt(turb, payload):
249     """Handler for the action of user pressing the new_hunt button"""
250
251     view = {
252         "type": "modal",
253         "private_metadata": json.dumps({}),
254         "title": { "type": "plain_text", "text": "New Hunt" },
255         "submit": { "type": "plain_text", "text": "Create" },
256         "blocks": [
257             input_block("Hunt name", "name", "Name of the hunt"),
258             input_block("Hunt ID", "hunt_id",
259                         "Used as puzzle channel prefix "
260                         + "(no spaces nor punctuation)"),
261             input_block("Hunt URL", "url", "External URL of hunt",
262                         optional=True)
263         ],
264     }
265
266     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
267                                           view=view)
268     if (result['ok']):
269         submission_handlers[result['view']['id']] = new_hunt_submission
270
271     return lambda_ok
272
273 actions['button']['new_hunt'] = new_hunt
274
275 def new_hunt_submission(turb, payload, metadata):
276     """Handler for the user submitting the new hunt modal
277
278     This is the modal view presented to the user by the new_hunt
279     function above."""
280
281     state = payload['view']['state']['values']
282     user_id = payload['user']['id']
283     name = state['name']['name']['value']
284     hunt_id = state['hunt_id']['hunt_id']['value']
285     url = state['url']['url']['value']
286
287     # Validate that the hunt_id contains no invalid characters
288     if not re.match(valid_id_re, hunt_id):
289         return submission_error("hunt_id",
290                                 "Hunt ID can only contain lowercase letters, "
291                                 + "numbers, and underscores")
292
293     # Check to see if the turbot table exists
294     try:
295         exists = turb.table.table_status in ("CREATING", "UPDATING",
296                                              "ACTIVE")
297     except ClientError:
298         exists = False
299
300     # Create the turbot table if necessary.
301     if not exists:
302         turb.table = turb.db.create_table(
303             TableName='turbot',
304             KeySchema=[
305                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
306                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
307             ],
308             AttributeDefinitions=[
309                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
310                 {'AttributeName': 'SK', 'AttributeType': 'S'},
311                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
312                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
313                 {'AttributeName': 'url', 'AttributeType': 'S'}
314             ],
315             ProvisionedThroughput={
316                 'ReadCapacityUnits': 5,
317                 'WriteCapacityUnits': 5
318             },
319             GlobalSecondaryIndexes=[
320                 {
321                     'IndexName': 'channel_id_index',
322                     'KeySchema': [
323                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
324                     ],
325                     'Projection': {
326                         'ProjectionType': 'ALL'
327                     },
328                     'ProvisionedThroughput': {
329                         'ReadCapacityUnits': 5,
330                         'WriteCapacityUnits': 5
331                     }
332                 },
333                 {
334                     'IndexName': 'is_hunt_index',
335                     'KeySchema': [
336                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
337                     ],
338                     'Projection': {
339                         'ProjectionType': 'ALL'
340                     },
341                     'ProvisionedThroughput': {
342                         'ReadCapacityUnits': 5,
343                         'WriteCapacityUnits': 5
344                     }
345                 }
346             ],
347             LocalSecondaryIndexes = [
348                 {
349                     'IndexName': 'url_index',
350                     'KeySchema': [
351                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
352                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
353                     ],
354                     'Projection': {
355                         'ProjectionType': 'ALL'
356                     }
357                 }
358             ]
359         )
360         return submission_error(
361             "hunt_id",
362             "Still bootstrapping turbot table. Try again in a minute, please.")
363
364     # Create a channel for the hunt
365     try:
366         response = turb.slack_client.conversations_create(name=hunt_id)
367     except SlackApiError as e:
368         return submission_error("hunt_id",
369                                 "Error creating Slack channel: {}"
370                                 .format(e.response['error']))
371
372     channel_id = response['channel']['id']
373
374     # Insert the newly-created hunt into the database
375     # (leaving it as non-active for now until the channel-created handler
376     #  finishes fixing it up with a sheet and a companion table)
377     item={
378         "hunt_id": hunt_id,
379         "SK": "hunt-{}".format(hunt_id),
380         "is_hunt": hunt_id,
381         "channel_id": channel_id,
382         "active": False,
383         "name": name,
384     }
385     if url:
386         item['url'] = url
387     turb.table.put_item(Item=item)
388
389     # Invite the initiating user to the channel
390     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
391
392     return lambda_ok
393
394 def view_submission(turb, payload):
395     """Handler for Slack interactive view submission
396
397     Specifically, those that have a payload type of 'view_submission'"""
398
399     view_id = payload['view']['id']
400     metadata = payload['view']['private_metadata']
401
402     if view_id in submission_handlers:
403         return submission_handlers[view_id](turb, payload, metadata)
404
405     print("Error: Unknown view ID: {}".format(view_id))
406     return {
407         'statusCode': 400
408     }
409
410 def rot(turb, body, args):
411     """Implementation of the /rot command
412
413     The args string should be as follows:
414
415         [count|*] String to be rotated
416
417     That is, the first word of the string is an optional number (or
418     the character '*'). If this is a number it indicates an amount to
419     rotate each character in the string. If the count is '*' or is not
420     present, then the string will be rotated through all possible 25
421     values.
422
423     The result of the rotation is returned (with Slack formatting) in
424     the body of the response so that Slack will provide it as a reply
425     to the user who submitted the slash command."""
426
427     channel_name = body['channel_name'][0]
428     response_url = body['response_url'][0]
429     channel_id = body['channel_id'][0]
430
431     result = turbot.rot.rot(args)
432
433     if (channel_name == "directmessage"):
434         requests.post(response_url,
435                       json = {"text": result},
436                       headers = {"Content-type": "application/json"})
437     else:
438         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
439
440     return lambda_ok
441
442 commands["/rot"] = rot
443
444 def get_table_item(turb, table_name, key, value):
445     """Get an item from the database 'table_name' with 'key' as 'value'
446
447     Returns a tuple of (item, table) if found and (None, None) otherwise."""
448
449     table = turb.db.Table(table_name)
450
451     response = table.get_item(Key={key: value})
452
453     if 'Item' in response:
454         return (response['Item'], table)
455     else:
456         return (None, None)
457
458 def db_entry_for_channel(turb, channel_id):
459     """Given a channel ID return the database item for this channel
460
461     If this channel is a registered hunt or puzzle channel, return the
462     corresponding row from the database for this channel. Otherwise,
463     return None.
464
465     Note: If you need to specifically ensure that the channel is a
466     puzzle or a hunt, please call puzzle_for_channel or
467     hunt_for_channel respectively.
468     """
469
470     response = turb.table.query(
471         IndexName = "channel_id_index",
472         KeyConditionExpression=Key("channel_id").eq(channel_id)
473     )
474
475     if response['Count'] == 0:
476         return None
477
478     return response['Items'][0]
479
480
481 def puzzle_for_channel(turb, channel_id):
482
483     """Given a channel ID return the puzzle from the database for this channel
484
485     If the given channel_id is a puzzle's channel, this function
486     returns a dict filled with the attributes from the puzzle's entry
487     in the database.
488
489     Otherwise, this function returns None.
490     """
491
492     entry = db_entry_for_channel(turb, channel_id)
493
494     if entry and entry['SK'].startswith('puzzle-'):
495         return entry
496     else:
497         return None
498
499 def hunt_for_channel(turb, channel_id):
500
501     """Given a channel ID return the hunt from the database for this channel
502
503     This works whether the original channel is a primary hunt channel,
504     or if it is one of the channels of a puzzle belonging to the hunt.
505
506     Returns None if channel does not belong to a hunt, otherwise a
507     dictionary with all fields from the hunt's row in the table,
508     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
509     """
510
511     entry = db_entry_for_channel(turb, channel_id)
512
513     # We're done if this channel doesn't exist in the database at all
514     if not entry:
515         return None
516
517     # Also done if this channel is a hunt channel
518     if entry['SK'].startswith('hunt-'):
519         return entry
520
521     # Otherwise, (the channel is in the database, but is not a hunt),
522     # we expect this to be a puzzle channel instead
523     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
524
525 # python3.9 has a built-in removeprefix but AWS only has python3.8
526 def remove_prefix(text, prefix):
527     if text.startswith(prefix):
528         return text[len(prefix):]
529     return text
530
531 def hunt_rounds(turb, hunt_id):
532     """Returns array of strings giving rounds that exist in the given hunt"""
533
534     response = turb.table.query(
535         KeyConditionExpression=(
536             Key('hunt_id').eq(hunt_id) &
537             Key('SK').begins_with('round-')
538         )
539     )
540
541     if response['Count'] == 0:
542         return []
543
544     return [remove_prefix(option['SK'], 'round-')
545             for option in response['Items']]
546
547 def puzzle(turb, body, args):
548     """Implementation of the /puzzle command
549
550     The args string can be a sub-command:
551
552         /puzzle new: Bring up a dialog to create a new puzzle
553
554     Or with no argument at all:
555
556         /puzzle: Print details of the current puzzle (if in a puzzle channel)
557     """
558
559     if args == 'new':
560         return new_puzzle(turb, body)
561
562     if len(args):
563         return bot_reply("Unknown syntax for `/puzzle` command. " +
564                          "Use `/puzzle new` to create a new puzzle.")
565
566     # For no arguments we print the current puzzle as a reply
567     channel_id = body['channel_id'][0]
568     response_url = body['response_url'][0]
569
570     puzzle = puzzle_for_channel(turb, channel_id)
571
572     if not puzzle:
573         hunt = hunt_for_channel(turb, channel_id)
574         if hunt:
575             return bot_reply(
576                 "This is not a puzzle channel, but is a hunt channel. "
577                 + "If you want to create a new puzzle for this hunt, use "
578                 + "`/puzzle new`.")
579         else:
580             return bot_reply(
581                 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
582                 + "channel, so the `/puzzle` command cannot work here.")
583
584     blocks = puzzle_blocks(puzzle, include_rounds=True)
585
586     requests.post(response_url,
587                   json = {'blocks': blocks},
588                   headers = {'Content-type': 'application/json'}
589                   )
590
591     return lambda_ok
592
593 commands["/puzzle"] = puzzle
594
595 def new_puzzle(turb, body):
596     """Implementation of the "/puzzle new" command
597
598     This brings up a dialog box for creating a new puzzle.
599     """
600
601     channel_id = body['channel_id'][0]
602     trigger_id = body['trigger_id'][0]
603
604     hunt = hunt_for_channel(turb, channel_id)
605
606     if not hunt:
607         return bot_reply("Sorry, this channel doesn't appear to "
608                          + "be a hunt or puzzle channel")
609
610     round_options = hunt_rounds(turb, hunt['hunt_id'])
611
612     if len(round_options):
613         round_options_block = [
614             multi_select_block("Round(s)", "rounds",
615                                "Existing round(s) this puzzle belongs to",
616                                round_options)
617         ]
618     else:
619         round_options_block = []
620
621     view = {
622         "type": "modal",
623         "private_metadata": json.dumps({
624             "hunt_id": hunt['hunt_id'],
625         }),
626         "title": {"type": "plain_text", "text": "New Puzzle"},
627         "submit": { "type": "plain_text", "text": "Create" },
628         "blocks": [
629             section_block(text_block("*For {}*".format(hunt['name']))),
630             input_block("Puzzle name", "name", "Name of the puzzle"),
631             input_block("Puzzle URL", "url", "External URL of puzzle",
632                         optional=True),
633             * round_options_block,
634             input_block("New round(s)", "new_rounds",
635                         "New round(s) this puzzle belongs to " +
636                         "(comma separated)",
637                         optional=True)
638         ]
639     }
640
641     result = turb.slack_client.views_open(trigger_id=trigger_id,
642                                           view=view)
643
644     if (result['ok']):
645         submission_handlers[result['view']['id']] = new_puzzle_submission
646
647     return lambda_ok
648
649 def new_puzzle_submission(turb, payload, metadata):
650     """Handler for the user submitting the new puzzle modal
651
652     This is the modal view presented to the user by the new_puzzle
653     function above.
654     """
655
656     # First, read all the various data from the request
657     meta = json.loads(metadata)
658     hunt_id = meta['hunt_id']
659
660     state = payload['view']['state']['values']
661     name = state['name']['name']['value']
662     url = state['url']['url']['value']
663     if 'rounds' in state:
664         rounds = [option['value'] for option in
665                   state['rounds']['rounds']['selected_options']]
666     else:
667         rounds = []
668     new_rounds = state['new_rounds']['new_rounds']['value']
669
670     # Before doing anything, reject this puzzle if a puzzle already
671     # exists with the same URL.
672     if url:
673         existing = find_puzzle_for_url(turb, hunt_id, url)
674         if existing:
675             return submission_error(
676                 "url",
677                 "Error: A puzzle with this URL already exists.")
678
679     # Create a Slack-channel-safe puzzle_id
680     puzzle_id = puzzle_id_from_name(name)
681
682     # Create a channel for the puzzle
683     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
684
685     try:
686         response = turb.slack_client.conversations_create(
687             name=hunt_dash_channel)
688     except SlackApiError as e:
689         return submission_error(
690             "name",
691             "Error creating Slack channel {}: {}"
692             .format(hunt_dash_channel, e.response['error']))
693
694     channel_id = response['channel']['id']
695
696     # Add any new rounds to the database
697     if new_rounds:
698         for round in new_rounds.split(','):
699             # Drop any leading/trailing spaces from the round name
700             round = round.strip()
701             # Ignore any empty string
702             if not len(round):
703                 continue
704             rounds.append(round)
705             turb.table.put_item(
706                 Item={
707                     'hunt_id': hunt_id,
708                     'SK': 'round-' + round
709                 }
710             )
711
712     # Insert the newly-created puzzle into the database
713     item={
714         "hunt_id": hunt_id,
715         "SK": "puzzle-{}".format(puzzle_id),
716         "puzzle_id": puzzle_id,
717         "channel_id": channel_id,
718         "solution": [],
719         "status": 'unsolved',
720         "name": name,
721     }
722     if url:
723         item['url'] = url
724     if rounds:
725         item['rounds'] = rounds
726     turb.table.put_item(Item=item)
727
728     return lambda_ok
729
730 def state(turb, body, args):
731     """Implementation of the /state command
732
733     The args string should be a brief sentence describing where things
734     stand or what's needed."""
735
736     channel_id = body['channel_id'][0]
737
738     old_puzzle = puzzle_for_channel(turb, channel_id)
739
740     if not old_puzzle:
741         return bot_reply(
742             "Sorry, the /state command only works in a puzzle channel")
743
744     # Make a copy of the puzzle object
745     puzzle = old_puzzle.copy()
746
747     # Update the puzzle in the database
748     puzzle['state'] = args
749     turb.table.put_item(Item=puzzle)
750
751     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
752
753     return lambda_ok
754
755 commands["/state"] = state
756
757 def solved(turb, body, args):
758     """Implementation of the /solved command
759
760     The args string should be a confirmed solution."""
761
762     channel_id = body['channel_id'][0]
763     user_name = body['user_name'][0]
764
765     old_puzzle = puzzle_for_channel(turb, channel_id)
766
767     if not old_puzzle:
768         return bot_reply("Sorry, this is not a puzzle channel.")
769
770     if not args:
771         return bot_reply(
772             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
773
774     # Make a copy of the puzzle object
775     puzzle = old_puzzle.copy()
776
777     # Set the status and solution fields in the database
778     puzzle['status'] = 'solved'
779     puzzle['solution'].append(args)
780     if 'state' in puzzle:
781         del puzzle['state']
782     turb.table.put_item(Item=puzzle)
783
784     # Report the solution to the puzzle's channel
785     slack_send_message(
786         turb.slack_client, channel_id,
787         "Puzzle mark solved by {}: `{}`".format(user_name, args))
788
789     # Also report the solution to the hunt channel
790     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
791     slack_send_message(
792         turb.slack_client, hunt['channel_id'],
793         "Puzzle <{}|{}> has been solved!".format(
794             puzzle['channel_url'],
795             puzzle['name'])
796     )
797
798     # And update the puzzle's description
799     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
800
801     return lambda_ok
802
803 commands["/solved"] = solved
804
805
806 def hunt(turb, body, args):
807     """Implementation of the /hunt command
808
809     The (optional) args string can be used to filter which puzzles to
810     display. The first word can be one of 'all', 'unsolved', or
811     'solved' and can be used to display only puzzles with the given
812     status. Any remaining text in the args string will be interpreted
813     as search terms. These will be split into separate terms on space
814     characters, (though quotation marks can be used to include a space
815     character in a term). All terms must match on a puzzle in order
816     for that puzzle to be included. But a puzzle will be considered to
817     match if any of the puzzle title, round title, puzzle URL, puzzle
818     state, or puzzle solution match. Matching will be performed
819     without regard to case sensitivity and the search terms can
820     include regular expression syntax.
821     """
822
823     channel_id = body['channel_id'][0]
824     response_url = body['response_url'][0]
825
826     terms = None
827     if args:
828         # The first word can be a puzzle status and all remaining word
829         # (if any) are search terms. _But_, if the first word is not a
830         # valid puzzle status ('all', 'unsolved', 'solved'), then all
831         # words are search terms and we default status to 'unsolved'.
832         split_args = args.split(' ', 1)
833         status = split_args[0]
834         if (len(split_args) > 1):
835             terms = split_args[1]
836         if status not in ('unsolved', 'solved', 'all'):
837             terms = args
838             status = 'unsolved'
839     else:
840         status = 'unsolved'
841
842     # Separate search terms on spaces (but allow for quotation marks
843     # to capture spaces in a search term)
844     if terms:
845         terms = shlex.split(terms)
846
847     hunt = hunt_for_channel(turb, channel_id)
848
849     if not hunt:
850         return bot_reply("Sorry, this channel doesn't appear to "
851                          + "be a hunt or puzzle channel")
852
853     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
854
855     requests.post(response_url,
856                   json = { 'blocks': blocks },
857                   headers = {'Content-type': 'application/json'}
858                   )
859
860     return lambda_ok
861
862 commands["/hunt"] = hunt