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