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