]> git.cworth.org Git - turbot/blob - turbot/interaction.py
0b2f30eec9324c9ba8689a266d9f3ad683e2fd78
[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     # Update the puzzle in the database
233     turb.table.put_item(Item=puzzle)
234
235     # We need to set the channel topic if any of puzzle name, url,
236     # state, status, or solution, has changed. Let's just do that
237     # unconditionally here.
238     puzzle_update_channel_and_sheet(turb, puzzle)
239
240     return lambda_ok
241
242 def new_hunt(turb, payload):
243     """Handler for the action of user pressing the new_hunt button"""
244
245     view = {
246         "type": "modal",
247         "private_metadata": json.dumps({}),
248         "title": { "type": "plain_text", "text": "New Hunt" },
249         "submit": { "type": "plain_text", "text": "Create" },
250         "blocks": [
251             input_block("Hunt name", "name", "Name of the hunt"),
252             input_block("Hunt ID", "hunt_id",
253                         "Used as puzzle channel prefix "
254                         + "(no spaces nor punctuation)"),
255             input_block("Hunt URL", "url", "External URL of hunt",
256                         optional=True)
257         ],
258     }
259
260     result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
261                                           view=view)
262     if (result['ok']):
263         submission_handlers[result['view']['id']] = new_hunt_submission
264
265     return lambda_ok
266
267 actions['button']['new_hunt'] = new_hunt
268
269 def new_hunt_submission(turb, payload, metadata):
270     """Handler for the user submitting the new hunt modal
271
272     This is the modal view presented to the user by the new_hunt
273     function above."""
274
275     state = payload['view']['state']['values']
276     user_id = payload['user']['id']
277     name = state['name']['name']['value']
278     hunt_id = state['hunt_id']['hunt_id']['value']
279     url = state['url']['url']['value']
280
281     # Validate that the hunt_id contains no invalid characters
282     if not re.match(valid_id_re, hunt_id):
283         return submission_error("hunt_id",
284                                 "Hunt ID can only contain lowercase letters, "
285                                 + "numbers, and underscores")
286
287     # Check to see if the turbot table exists
288     try:
289         exists = turb.table.table_status in ("CREATING", "UPDATING",
290                                              "ACTIVE")
291     except ClientError:
292         exists = False
293
294     # Create the turbot table if necessary.
295     if not exists:
296         turb.table = turb.db.create_table(
297             TableName='turbot',
298             KeySchema=[
299                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
300                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
301             ],
302             AttributeDefinitions=[
303                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
304                 {'AttributeName': 'SK', 'AttributeType': 'S'},
305                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
306                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
307                 {'AttributeName': 'url', 'AttributeType': 'S'}
308             ],
309             ProvisionedThroughput={
310                 'ReadCapacityUnits': 5,
311                 'WriteCapacityUnits': 5
312             },
313             GlobalSecondaryIndexes=[
314                 {
315                     'IndexName': 'channel_id_index',
316                     'KeySchema': [
317                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
318                     ],
319                     'Projection': {
320                         'ProjectionType': 'ALL'
321                     },
322                     'ProvisionedThroughput': {
323                         'ReadCapacityUnits': 5,
324                         'WriteCapacityUnits': 5
325                     }
326                 },
327                 {
328                     'IndexName': 'is_hunt_index',
329                     'KeySchema': [
330                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
331                     ],
332                     'Projection': {
333                         'ProjectionType': 'ALL'
334                     },
335                     'ProvisionedThroughput': {
336                         'ReadCapacityUnits': 5,
337                         'WriteCapacityUnits': 5
338                     }
339                 }
340             ],
341             LocalSecondaryIndexes = [
342                 {
343                     'IndexName': 'url_index',
344                     'KeySchema': [
345                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
346                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
347                     ],
348                     'Projection': {
349                         'ProjectionType': 'ALL'
350                     }
351                 }
352             ]
353         )
354         return submission_error(
355             "hunt_id",
356             "Still bootstrapping turbot table. Try again in a minute, please.")
357
358     # Create a channel for the hunt
359     try:
360         response = turb.slack_client.conversations_create(name=hunt_id)
361     except SlackApiError as e:
362         return submission_error("hunt_id",
363                                 "Error creating Slack channel: {}"
364                                 .format(e.response['error']))
365
366     channel_id = response['channel']['id']
367
368     # Insert the newly-created hunt into the database
369     # (leaving it as non-active for now until the channel-created handler
370     #  finishes fixing it up with a sheet and a companion table)
371     item={
372         "hunt_id": hunt_id,
373         "SK": "hunt-{}".format(hunt_id),
374         "is_hunt": hunt_id,
375         "channel_id": channel_id,
376         "active": False,
377         "name": name,
378     }
379     if url:
380         item['url'] = url
381     turb.table.put_item(Item=item)
382
383     # Invite the initiating user to the channel
384     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
385
386     return lambda_ok
387
388 def view_submission(turb, payload):
389     """Handler for Slack interactive view submission
390
391     Specifically, those that have a payload type of 'view_submission'"""
392
393     view_id = payload['view']['id']
394     metadata = payload['view']['private_metadata']
395
396     if view_id in submission_handlers:
397         return submission_handlers[view_id](turb, payload, metadata)
398
399     print("Error: Unknown view ID: {}".format(view_id))
400     return {
401         'statusCode': 400
402     }
403
404 def rot(turb, body, args):
405     """Implementation of the /rot command
406
407     The args string should be as follows:
408
409         [count|*] String to be rotated
410
411     That is, the first word of the string is an optional number (or
412     the character '*'). If this is a number it indicates an amount to
413     rotate each character in the string. If the count is '*' or is not
414     present, then the string will be rotated through all possible 25
415     values.
416
417     The result of the rotation is returned (with Slack formatting) in
418     the body of the response so that Slack will provide it as a reply
419     to the user who submitted the slash command."""
420
421     channel_name = body['channel_name'][0]
422     response_url = body['response_url'][0]
423     channel_id = body['channel_id'][0]
424
425     result = turbot.rot.rot(args)
426
427     if (channel_name == "directmessage"):
428         requests.post(response_url,
429                       json = {"text": result},
430                       headers = {"Content-type": "application/json"})
431     else:
432         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
433
434     return lambda_ok
435
436 commands["/rot"] = rot
437
438 def get_table_item(turb, table_name, key, value):
439     """Get an item from the database 'table_name' with 'key' as 'value'
440
441     Returns a tuple of (item, table) if found and (None, None) otherwise."""
442
443     table = turb.db.Table(table_name)
444
445     response = table.get_item(Key={key: value})
446
447     if 'Item' in response:
448         return (response['Item'], table)
449     else:
450         return (None, None)
451
452 def db_entry_for_channel(turb, channel_id):
453     """Given a channel ID return the database item for this channel
454
455     If this channel is a registered hunt or puzzle channel, return the
456     corresponding row from the database for this channel. Otherwise,
457     return None.
458
459     Note: If you need to specifically ensure that the channel is a
460     puzzle or a hunt, please call puzzle_for_channel or
461     hunt_for_channel respectively.
462     """
463
464     response = turb.table.query(
465         IndexName = "channel_id_index",
466         KeyConditionExpression=Key("channel_id").eq(channel_id)
467     )
468
469     if response['Count'] == 0:
470         return None
471
472     return response['Items'][0]
473
474
475 def puzzle_for_channel(turb, channel_id):
476
477     """Given a channel ID return the puzzle from the database for this channel
478
479     If the given channel_id is a puzzle's channel, this function
480     returns a dict filled with the attributes from the puzzle's entry
481     in the database.
482
483     Otherwise, this function returns None.
484     """
485
486     entry = db_entry_for_channel(turb, channel_id)
487
488     if entry and entry['SK'].startswith('puzzle-'):
489         return entry
490     else:
491         return None
492
493 def hunt_for_channel(turb, channel_id):
494
495     """Given a channel ID return the hunt from the database for this channel
496
497     This works whether the original channel is a primary hunt channel,
498     or if it is one of the channels of a puzzle belonging to the hunt.
499
500     Returns None if channel does not belong to a hunt, otherwise a
501     dictionary with all fields from the hunt's row in the table,
502     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
503     """
504
505     entry = db_entry_for_channel(turb, channel_id)
506
507     # We're done if this channel doesn't exist in the database at all
508     if not entry:
509         return None
510
511     # Also done if this channel is a hunt channel
512     if entry['SK'].startswith('hunt-'):
513         return entry
514
515     # Otherwise, (the channel is in the database, but is not a hunt),
516     # we expect this to be a puzzle channel instead
517     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
518
519 # python3.9 has a built-in removeprefix but AWS only has python3.8
520 def remove_prefix(text, prefix):
521     if text.startswith(prefix):
522         return text[len(prefix):]
523     return text
524
525 def hunt_rounds(turb, hunt_id):
526     """Returns array of strings giving rounds that exist in the given hunt"""
527
528     response = turb.table.query(
529         KeyConditionExpression=(
530             Key('hunt_id').eq(hunt_id) &
531             Key('SK').begins_with('round-')
532         )
533     )
534
535     if response['Count'] == 0:
536         return []
537
538     return [remove_prefix(option['SK'], 'round-')
539             for option in response['Items']]
540
541 def puzzle(turb, body, args):
542     """Implementation of the /puzzle command
543
544     The args string is currently ignored (this command will bring up
545     a modal dialog for user input instead)."""
546
547     channel_id = body['channel_id'][0]
548     trigger_id = body['trigger_id'][0]
549
550     hunt = hunt_for_channel(turb, channel_id)
551
552     if not hunt:
553         return bot_reply("Sorry, this channel doesn't appear to "
554                          + "be a hunt or puzzle channel")
555
556     round_options = hunt_rounds(turb, hunt['hunt_id'])
557
558     if len(round_options):
559         round_options_block = [
560             multi_select_block("Round(s)", "rounds",
561                                "Existing round(s) this puzzle belongs to",
562                                round_options)
563         ]
564     else:
565         round_options_block = []
566
567     view = {
568         "type": "modal",
569         "private_metadata": json.dumps({
570             "hunt_id": hunt['hunt_id'],
571         }),
572         "title": {"type": "plain_text", "text": "New Puzzle"},
573         "submit": { "type": "plain_text", "text": "Create" },
574         "blocks": [
575             section_block(text_block("*For {}*".format(hunt['name']))),
576             input_block("Puzzle name", "name", "Name of the puzzle"),
577             input_block("Puzzle URL", "url", "External URL of puzzle",
578                         optional=True),
579             * round_options_block,
580             input_block("New round(s)", "new_rounds",
581                         "New round(s) this puzzle belongs to " +
582                         "(comma separated)",
583                         optional=True)
584         ]
585     }
586
587     result = turb.slack_client.views_open(trigger_id=trigger_id,
588                                           view=view)
589
590     if (result['ok']):
591         submission_handlers[result['view']['id']] = puzzle_submission
592
593     return lambda_ok
594
595 commands["/puzzle"] = puzzle
596
597 def puzzle_submission(turb, payload, metadata):
598     """Handler for the user submitting the new puzzle modal
599
600     This is the modal view presented to the user by the puzzle function
601     above."""
602
603     # First, read all the various data from the request
604     meta = json.loads(metadata)
605     hunt_id = meta['hunt_id']
606
607     state = payload['view']['state']['values']
608     name = state['name']['name']['value']
609     url = state['url']['url']['value']
610     if 'rounds' in state:
611         rounds = [option['value'] for option in
612                   state['rounds']['rounds']['selected_options']]
613     else:
614         rounds = []
615     new_rounds = state['new_rounds']['new_rounds']['value']
616
617     # Before doing anything, reject this puzzle if a puzzle already
618     # exists with the same URL.
619     if url:
620         existing = find_puzzle_for_url(turb, hunt_id, url)
621         if existing:
622             return submission_error(
623                 "url",
624                 "Error: A puzzle with this URL already exists.")
625
626     # Create a Slack-channel-safe puzzle_id
627     puzzle_id = puzzle_id_from_name(name)
628
629     # Create a channel for the puzzle
630     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
631
632     try:
633         response = turb.slack_client.conversations_create(
634             name=hunt_dash_channel)
635     except SlackApiError as e:
636         return submission_error(
637             "name",
638             "Error creating Slack channel {}: {}"
639             .format(hunt_dash_channel, e.response['error']))
640
641     channel_id = response['channel']['id']
642
643     # Add any new rounds to the database
644     if new_rounds:
645         for round in new_rounds.split(','):
646             # Drop any leading/trailing spaces from the round name
647             round = round.strip()
648             # Ignore any empty string
649             if not len(round):
650                 continue
651             rounds.append(round)
652             turb.table.put_item(
653                 Item={
654                     'hunt_id': hunt_id,
655                     'SK': 'round-' + round
656                 }
657             )
658
659     # Insert the newly-created puzzle into the database
660     item={
661         "hunt_id": hunt_id,
662         "SK": "puzzle-{}".format(puzzle_id),
663         "puzzle_id": puzzle_id,
664         "channel_id": channel_id,
665         "solution": [],
666         "status": 'unsolved',
667         "name": name,
668     }
669     if url:
670         item['url'] = url
671     if rounds:
672         item['rounds'] = rounds
673     turb.table.put_item(Item=item)
674
675     return lambda_ok
676
677 def state(turb, body, args):
678     """Implementation of the /state command
679
680     The args string should be a brief sentence describing where things
681     stand or what's needed."""
682
683     channel_id = body['channel_id'][0]
684
685     puzzle = puzzle_for_channel(turb, channel_id)
686
687     if not puzzle:
688         return bot_reply(
689             "Sorry, the /state command only works in a puzzle channel")
690
691     # Set the state field in the database
692     puzzle['state'] = args
693     turb.table.put_item(Item=puzzle)
694
695     puzzle_update_channel_and_sheet(turb, puzzle)
696
697     return lambda_ok
698
699 commands["/state"] = state
700
701 def solved(turb, body, args):
702     """Implementation of the /solved command
703
704     The args string should be a confirmed solution."""
705
706     channel_id = body['channel_id'][0]
707     user_name = body['user_name'][0]
708
709     puzzle = puzzle_for_channel(turb, channel_id)
710
711     if not puzzle:
712         return bot_reply("Sorry, this is not a puzzle channel.")
713
714     if not args:
715         return bot_reply(
716             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
717
718     # Set the status and solution fields in the database
719     puzzle['status'] = 'solved'
720     puzzle['solution'].append(args)
721     if 'state' in puzzle:
722         del puzzle['state']
723     turb.table.put_item(Item=puzzle)
724
725     # Report the solution to the puzzle's channel
726     slack_send_message(
727         turb.slack_client, channel_id,
728         "Puzzle mark solved by {}: `{}`".format(user_name, args))
729
730     # Also report the solution to the hunt channel
731     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
732     slack_send_message(
733         turb.slack_client, hunt['channel_id'],
734         "Puzzle <{}|{}> has been solved!".format(
735             puzzle['channel_url'],
736             puzzle['name'])
737     )
738
739     # And update the puzzle's description
740     puzzle_update_channel_and_sheet(turb, puzzle)
741
742     return lambda_ok
743
744 commands["/solved"] = solved
745
746
747 def hunt(turb, body, args):
748     """Implementation of the /hunt command
749
750     The (optional) args string can be used to filter which puzzles to
751     display. The first word can be one of 'all', 'unsolved', or
752     'solved' and can be used to display only puzzles with the given
753     status. Any remaining text in the args string will be interpreted
754     as search terms. These will be split into separate terms on space
755     characters, (though quotation marks can be used to include a space
756     character in a term). All terms must match on a puzzle in order
757     for that puzzle to be included. But a puzzle will be considered to
758     match if any of the puzzle title, round title, puzzle URL, puzzle
759     state, or puzzle solution match. Matching will be performed
760     without regard to case sensitivity and the search terms can
761     include regular expression syntax.
762     """
763
764     channel_id = body['channel_id'][0]
765     response_url = body['response_url'][0]
766
767     terms = None
768     if args:
769         # The first word can be a puzzle status and all remaining word
770         # (if any) are search terms. _But_, if the first word is not a
771         # valid puzzle status ('all', 'unsolved', 'solved'), then all
772         # words are search terms and we default status to 'unsolved'.
773         split_args = args.split(' ', 1)
774         status = split_args[0]
775         if (len(split_args) > 1):
776             terms = split_args[1]
777         if status not in ('unsolved', 'solved', 'all'):
778             terms = args
779             status = 'unsolved'
780     else:
781         status = 'unsolved'
782
783     # Separate search terms on spaces (but allow for quotation marks
784     # to capture spaces in a search term)
785     if terms:
786         terms = shlex.split(terms)
787
788     hunt = hunt_for_channel(turb, channel_id)
789
790     if not hunt:
791         return bot_reply("Sorry, this channel doesn't appear to "
792                          + "be a hunt or puzzle channel")
793
794     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
795
796     requests.post(response_url,
797                   json = { 'blocks': blocks },
798                   headers = {'Content-type': 'application/json'}
799                   )
800
801     return lambda_ok
802
803 commands["/hunt"] = hunt