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