]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Don't re-set channel and sheet name or channel topic to the same value as before
[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 is currently ignored (this command will bring up
550     a modal dialog for user input instead)."""
551
552     channel_id = body['channel_id'][0]
553     trigger_id = body['trigger_id'][0]
554
555     hunt = hunt_for_channel(turb, channel_id)
556
557     if not hunt:
558         return bot_reply("Sorry, this channel doesn't appear to "
559                          + "be a hunt or puzzle channel")
560
561     round_options = hunt_rounds(turb, hunt['hunt_id'])
562
563     if len(round_options):
564         round_options_block = [
565             multi_select_block("Round(s)", "rounds",
566                                "Existing round(s) this puzzle belongs to",
567                                round_options)
568         ]
569     else:
570         round_options_block = []
571
572     view = {
573         "type": "modal",
574         "private_metadata": json.dumps({
575             "hunt_id": hunt['hunt_id'],
576         }),
577         "title": {"type": "plain_text", "text": "New Puzzle"},
578         "submit": { "type": "plain_text", "text": "Create" },
579         "blocks": [
580             section_block(text_block("*For {}*".format(hunt['name']))),
581             input_block("Puzzle name", "name", "Name of the puzzle"),
582             input_block("Puzzle URL", "url", "External URL of puzzle",
583                         optional=True),
584             * round_options_block,
585             input_block("New round(s)", "new_rounds",
586                         "New round(s) this puzzle belongs to " +
587                         "(comma separated)",
588                         optional=True)
589         ]
590     }
591
592     result = turb.slack_client.views_open(trigger_id=trigger_id,
593                                           view=view)
594
595     if (result['ok']):
596         submission_handlers[result['view']['id']] = puzzle_submission
597
598     return lambda_ok
599
600 commands["/puzzle"] = puzzle
601
602 def puzzle_submission(turb, payload, metadata):
603     """Handler for the user submitting the new puzzle modal
604
605     This is the modal view presented to the user by the puzzle function
606     above."""
607
608     # First, read all the various data from the request
609     meta = json.loads(metadata)
610     hunt_id = meta['hunt_id']
611
612     state = payload['view']['state']['values']
613     name = state['name']['name']['value']
614     url = state['url']['url']['value']
615     if 'rounds' in state:
616         rounds = [option['value'] for option in
617                   state['rounds']['rounds']['selected_options']]
618     else:
619         rounds = []
620     new_rounds = state['new_rounds']['new_rounds']['value']
621
622     # Before doing anything, reject this puzzle if a puzzle already
623     # exists with the same URL.
624     if url:
625         existing = find_puzzle_for_url(turb, hunt_id, url)
626         if existing:
627             return submission_error(
628                 "url",
629                 "Error: A puzzle with this URL already exists.")
630
631     # Create a Slack-channel-safe puzzle_id
632     puzzle_id = puzzle_id_from_name(name)
633
634     # Create a channel for the puzzle
635     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
636
637     try:
638         response = turb.slack_client.conversations_create(
639             name=hunt_dash_channel)
640     except SlackApiError as e:
641         return submission_error(
642             "name",
643             "Error creating Slack channel {}: {}"
644             .format(hunt_dash_channel, e.response['error']))
645
646     channel_id = response['channel']['id']
647
648     # Add any new rounds to the database
649     if new_rounds:
650         for round in new_rounds.split(','):
651             # Drop any leading/trailing spaces from the round name
652             round = round.strip()
653             # Ignore any empty string
654             if not len(round):
655                 continue
656             rounds.append(round)
657             turb.table.put_item(
658                 Item={
659                     'hunt_id': hunt_id,
660                     'SK': 'round-' + round
661                 }
662             )
663
664     # Insert the newly-created puzzle into the database
665     item={
666         "hunt_id": hunt_id,
667         "SK": "puzzle-{}".format(puzzle_id),
668         "puzzle_id": puzzle_id,
669         "channel_id": channel_id,
670         "solution": [],
671         "status": 'unsolved',
672         "name": name,
673     }
674     if url:
675         item['url'] = url
676     if rounds:
677         item['rounds'] = rounds
678     turb.table.put_item(Item=item)
679
680     return lambda_ok
681
682 def state(turb, body, args):
683     """Implementation of the /state command
684
685     The args string should be a brief sentence describing where things
686     stand or what's needed."""
687
688     channel_id = body['channel_id'][0]
689
690     old_puzzle = puzzle_for_channel(turb, channel_id)
691
692     if not old_puzzle:
693         return bot_reply(
694             "Sorry, the /state command only works in a puzzle channel")
695
696     # Make a copy of the puzzle object
697     puzzle = old_puzzle.copy()
698
699     # Update the puzzle in the database
700     puzzle['state'] = args
701     turb.table.put_item(Item=puzzle)
702
703     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
704
705     return lambda_ok
706
707 commands["/state"] = state
708
709 def solved(turb, body, args):
710     """Implementation of the /solved command
711
712     The args string should be a confirmed solution."""
713
714     channel_id = body['channel_id'][0]
715     user_name = body['user_name'][0]
716
717     old_puzzle = puzzle_for_channel(turb, channel_id)
718
719     if not old_puzzle:
720         return bot_reply("Sorry, this is not a puzzle channel.")
721
722     if not args:
723         return bot_reply(
724             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
725
726     # Make a copy of the puzzle object
727     puzzle = old_puzzle.copy()
728
729     # Set the status and solution fields in the database
730     puzzle['status'] = 'solved'
731     puzzle['solution'].append(args)
732     if 'state' in puzzle:
733         del puzzle['state']
734     turb.table.put_item(Item=puzzle)
735
736     # Report the solution to the puzzle's channel
737     slack_send_message(
738         turb.slack_client, channel_id,
739         "Puzzle mark solved by {}: `{}`".format(user_name, args))
740
741     # Also report the solution to the hunt channel
742     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
743     slack_send_message(
744         turb.slack_client, hunt['channel_id'],
745         "Puzzle <{}|{}> has been solved!".format(
746             puzzle['channel_url'],
747             puzzle['name'])
748     )
749
750     # And update the puzzle's description
751     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
752
753     return lambda_ok
754
755 commands["/solved"] = solved
756
757
758 def hunt(turb, body, args):
759     """Implementation of the /hunt command
760
761     The (optional) args string can be used to filter which puzzles to
762     display. The first word can be one of 'all', 'unsolved', or
763     'solved' and can be used to display only puzzles with the given
764     status. Any remaining text in the args string will be interpreted
765     as search terms. These will be split into separate terms on space
766     characters, (though quotation marks can be used to include a space
767     character in a term). All terms must match on a puzzle in order
768     for that puzzle to be included. But a puzzle will be considered to
769     match if any of the puzzle title, round title, puzzle URL, puzzle
770     state, or puzzle solution match. Matching will be performed
771     without regard to case sensitivity and the search terms can
772     include regular expression syntax.
773     """
774
775     channel_id = body['channel_id'][0]
776     response_url = body['response_url'][0]
777
778     terms = None
779     if args:
780         # The first word can be a puzzle status and all remaining word
781         # (if any) are search terms. _But_, if the first word is not a
782         # valid puzzle status ('all', 'unsolved', 'solved'), then all
783         # words are search terms and we default status to 'unsolved'.
784         split_args = args.split(' ', 1)
785         status = split_args[0]
786         if (len(split_args) > 1):
787             terms = split_args[1]
788         if status not in ('unsolved', 'solved', 'all'):
789             terms = args
790             status = 'unsolved'
791     else:
792         status = 'unsolved'
793
794     # Separate search terms on spaces (but allow for quotation marks
795     # to capture spaces in a search term)
796     if terms:
797         terms = shlex.split(terms)
798
799     hunt = hunt_for_channel(turb, channel_id)
800
801     if not hunt:
802         return bot_reply("Sorry, this channel doesn't appear to "
803                          + "be a hunt or puzzle channel")
804
805     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
806
807     requests.post(response_url,
808                   json = { 'blocks': blocks },
809                   headers = {'Content-type': 'application/json'}
810                   )
811
812     return lambda_ok
813
814 commands["/hunt"] = hunt