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