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