]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add notes on how to update the Google sheets credentials
[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 (
6     find_hunt_for_hunt_id,
7     hunt_blocks,
8     hunt_puzzles_for_hunt_id,
9     hunt_update_topic
10 )
11 from turbot.puzzle import (
12     find_puzzle_for_url,
13     find_puzzle_for_sort_key,
14     find_puzzle_for_puzzle_id,
15     puzzle_update_channel_and_sheet,
16     puzzle_channel_name,
17     puzzle_id_from_name,
18     puzzle_blocks,
19     puzzle_sort_key,
20     puzzle_copy
21 )
22 from turbot.round import round_quoted_puzzles_titles_answers
23 from turbot.help import turbot_help
24 from turbot.have_you_tried import have_you_tried
25 import turbot.rot
26 import turbot.sheets
27 import turbot.slack
28 import json
29 import re
30 import requests
31 from botocore.exceptions import ClientError
32 from boto3.dynamodb.conditions import Key
33 from turbot.slack import slack_send_message
34 import shlex
35
36 actions = {}
37 actions['button'] = {}
38 commands = {}
39 submission_handlers = {}
40
41 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
42 #
43 # Note: This restriction not only allows for hunt and puzzle ID values to
44 # be used as Slack channel names, but it also allows for '-' as a valid
45 # separator between a hunt and a puzzle ID (for example in the puzzle
46 # edit dialog where a single attribute must capture both values).
47 valid_id_re = r'^[_a-z0-9]+$'
48
49 lambda_ok = {'statusCode': 200}
50
51 def bot_reply(message):
52     """Construct a return value suitable for a bot reply
53
54     This is suitable as a way to give an error back to the user who
55     initiated a slash command, for example."""
56
57     return {
58         'statusCode': 200,
59         'body': message
60     }
61
62 def submission_error(field, error):
63     """Construct an error suitable for returning for an invalid submission.
64
65     Returning this value will prevent a submission and alert the user that
66     the given field is invalid because of the given error."""
67
68     print("Rejecting invalid modal submission: {}".format(error))
69
70     return {
71         'statusCode': 200,
72         'headers': {
73             "Content-Type": "application/json"
74         },
75         'body': json.dumps({
76             "response_action": "errors",
77             "errors": {
78                 field: error
79             }
80         })
81     }
82
83 def multi_static_select(turb, payload):
84     """Handler for the action of user entering a multi-select value"""
85
86     return lambda_ok
87
88 actions['multi_static_select'] = {"*": multi_static_select}
89
90 def edit(turb, body, args):
91
92     """Implementation of the `/edit` command
93
94     This can be used as `/edit` (with no arguments) in either a hunt
95     or a puzzle channel to edit that hunt or puzzle. It can also be
96     called explicitly as `/edit hunt` to edit a hunt even from a
97     puzzle channel.
98
99     In any case, the operation is identical to `/hunt edit` or
100     `/puzzle edit`.
101     """
102
103     # If we have an explicit argument, do what it says to do
104     if args == "hunt":
105         return edit_hunt_command(turb, body)
106
107     if args == "puzzle":
108         return edit_puzzle_command(turb, body)
109
110     # Any other argument string is an error
111     if args:
112         return bot_reply("Error: Unexpected argument: {}\n".format(args) +
113                          "Usage: `/edit puzzle`, `/edit hunt`, or " +
114                          "`/edit` (to choose based on channel)"
115                          )
116
117     # No explicit argument, so select what to edit based on the current channel
118     channel_id = body['channel_id'][0]
119     trigger_id = body['trigger_id'][0]
120
121     puzzle = puzzle_for_channel(turb, channel_id)
122     if puzzle:
123         return edit_puzzle(turb, puzzle, trigger_id)
124
125     hunt = hunt_for_channel(turb, channel_id)
126     if hunt:
127         return edit_hunt(turb, hunt, trigger_id)
128
129     return bot_reply("Sorry, `/edit` only works in a hunt or puzzle channel.")
130
131 commands["/edit"] = edit
132
133
134 def edit_puzzle_command(turb, body):
135     """Implementation of the `/puzzle edit` command
136
137     As dispatched from the puzzle() function.
138     """
139
140     channel_id = body['channel_id'][0]
141     trigger_id = body['trigger_id'][0]
142
143     puzzle = puzzle_for_channel(turb, channel_id)
144
145     if not puzzle:
146         return bot_reply("Sorry, this does not appear to be a puzzle channel.")
147
148     return edit_puzzle(turb, puzzle, trigger_id)
149
150 def edit_puzzle_button(turb, payload):
151     """Handler for the action of user pressing an edit_puzzle button"""
152
153     action_id = payload['actions'][0]['action_id']
154     trigger_id = payload['trigger_id']
155
156     (hunt_id, sort_key) = action_id.split('-', 1)
157
158     puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
159
160     if not puzzle:
161         return bot_reply("Error: Puzzle not found.")
162
163     return edit_puzzle(turb, puzzle, trigger_id)
164
165 actions['button']['edit_puzzle'] = edit_puzzle_button
166
167 def edit_puzzle(turb, puzzle, trigger_id):
168     """Common code for implementing an edit puzzle dialog
169
170     This implementation is common whether the edit operation was invoked
171     by a button (edit_puzzle_button) or a command (edit_puzzle_command).
172     """
173
174     round_options = hunt_rounds(turb, puzzle['hunt_id'])
175
176     if len(round_options):
177         round_options_block = [
178             multi_select_block("Round(s)", "rounds",
179                                "Existing round(s) this puzzle belongs to",
180                                round_options,
181                                initial_options=puzzle.get("rounds", None)),
182         ]
183     else:
184         round_options_block = []
185
186     solved = False
187     if puzzle.get("status", "unsolved") == solved:
188         solved = True
189
190     solution_str = None
191     solution_list = puzzle.get("solution", [])
192     if solution_list:
193         solution_str = ", ".join(solution_list)
194
195     view = {
196         "type": "modal",
197         "private_metadata": json.dumps({
198             "hunt_id": puzzle['hunt_id'],
199             "SK": puzzle["SK"],
200             "puzzle_id": puzzle['puzzle_id'],
201             "channel_id": puzzle["channel_id"],
202             "channel_url": puzzle["channel_url"],
203             "sheet_url": puzzle["sheet_url"],
204         }),
205         "title": {"type": "plain_text", "text": "Edit Puzzle"},
206         "submit": { "type": "plain_text", "text": "Save" },
207         "blocks": [
208             input_block("Puzzle name", "name", "Name of the puzzle",
209                         initial_value=puzzle["name"]),
210             input_block("Puzzle URL", "url", "External URL of puzzle",
211                         initial_value=puzzle.get("url", None),
212                         optional=True),
213             checkbox_block("Is this a meta puzzle?", "Meta", "meta",
214                            checked=(puzzle.get('type', 'plain') == 'meta')),
215             * round_options_block,
216             input_block("New round(s)", "new_rounds",
217                         "New round(s) this puzzle belongs to " +
218                         "(comma separated)",
219                         optional=True),
220             input_block("Tag(s)", "tags",
221                         "Tags for this puzzle (comma separated)",
222                         initial_value=", ".join(puzzle.get("tags", [])),
223                         optional=True),
224             input_block("State", "state",
225                         "State of this puzzle (partial progress, next steps)",
226                         initial_value=puzzle.get("state", None),
227                         optional=True),
228             checkbox_block(
229                 "Puzzle status", "Solved", "solved",
230                 checked=(puzzle.get('status', 'unsolved') == 'solved')),
231             input_block("Solution", "solution",
232                         "Solution(s) (comma-separated if multiple)",
233                         initial_value=solution_str,
234                         optional=True),
235         ]
236     }
237
238     result = turb.slack_client.views_open(trigger_id=trigger_id,
239                                           view=view)
240
241     if (result['ok']):
242         submission_handlers[result['view']['id']] = edit_puzzle_submission
243
244     return lambda_ok
245
246 def edit_puzzle_submission(turb, payload, metadata):
247     """Handler for the user submitting the edit puzzle modal
248
249     This is the modal view presented to the user by the edit_puzzle
250     function above.
251     """
252
253     puzzle={}
254
255     # First, read all the various data from the request
256     meta = json.loads(metadata)
257     puzzle['hunt_id'] = meta['hunt_id']
258     puzzle['SK'] = meta['SK']
259     puzzle['puzzle_id'] = meta['puzzle_id']
260     puzzle['channel_id'] = meta['channel_id']
261     puzzle['channel_url'] = meta['channel_url']
262     puzzle['sheet_url'] = meta['sheet_url']
263
264     state = payload['view']['state']['values']
265     user_id = payload['user']['id']
266
267     puzzle['name'] = state['name']['name']['value']
268     url = state['url']['url']['value']
269     if url:
270         puzzle['url'] = url
271     if state['meta']['meta']['selected_options']:
272         puzzle['type'] = 'meta'
273     else:
274         puzzle['type'] = 'plain'
275     if 'rounds' in state:
276         rounds = [option['value'] for option in
277                   state['rounds']['rounds']['selected_options']]
278         if rounds:
279             puzzle['rounds'] = rounds
280     new_rounds = state['new_rounds']['new_rounds']['value']
281     tags = state['tags']['tags']['value']
282     puzzle_state = state['state']['state']['value']
283     if puzzle_state:
284         puzzle['state'] = puzzle_state
285     if state['solved']['solved']['selected_options']:
286         puzzle['status'] = 'solved'
287     else:
288         puzzle['status'] = 'unsolved'
289     puzzle['solution'] = []
290     solution = state['solution']['solution']['value']
291     if solution:
292         # Construct a list from a set to avoid any duplicates
293         puzzle['solution'] = list({
294             sol.strip() for sol in solution.split(',')
295         })
296
297     # Verify that there's a solution if the puzzle is mark solved
298     if puzzle['status'] == 'solved' and not puzzle['solution']:
299         return submission_error("solution",
300                                 "A solved puzzle requires a solution.")
301
302     if puzzle['status'] == 'unsolved' and puzzle['solution']:
303         return submission_error("solution",
304                                 "An unsolved puzzle should have no solution.")
305
306     # Add any new rounds to the database
307     if new_rounds:
308         if 'rounds' not in puzzle:
309             puzzle['rounds'] = []
310         for round in new_rounds.split(','):
311             # Drop any leading/trailing spaces from the round name
312             round = round.strip()
313             # Ignore any empty string
314             if not len(round):
315                 continue
316             puzzle['rounds'].append(round)
317             turb.table.put_item(
318                 Item={
319                     'hunt_id': puzzle['hunt_id'],
320                     'SK': 'round-' + round
321                 }
322             )
323
324     # Process any tags
325     puzzle['tags'] = []
326     if tags:
327         for tag in tags.split(','):
328             # Drop any leading/trailing spaces from the tag
329             tag = tag.strip().upper()
330             # Ignore any empty string
331             if not len(tag):
332                 continue
333             # Reject a tag that is not alphabetic or underscore A-Z_
334             if not re.match(r'^[A-Z0-9_]*$', tag):
335                 return submission_error(
336                     "tags",
337                     "Error: Tags can only contain letters, numbers, "
338                     + "and the underscore character."
339                 )
340             puzzle['tags'].append(tag)
341
342     # Get old puzzle from the database (to determine what's changed)
343     old_puzzle = find_puzzle_for_sort_key(turb,
344                                           puzzle['hunt_id'],
345                                           puzzle['SK'])
346
347     # If we are changing puzzle type (meta -> plain or plain -> meta)
348     # then the sort key has to change, so compute the new one and delete
349     # the old item from the database.
350     #
351     # XXX: We should really be using a transaction here to combine the
352     # delete_item and the put_item into a single transaction, but
353     # the boto interface is annoying in that transactions are only on
354     # the "Client" object which has a totally different interface than
355     # the "Table" object I've been using so I haven't figured out how
356     # to do that yet.
357
358     if puzzle['type'] != old_puzzle.get('type', 'plain'):
359         puzzle['SK'] = puzzle_sort_key(puzzle)
360         turb.table.delete_item(Key={
361             'hunt_id': old_puzzle['hunt_id'],
362             'SK': old_puzzle['SK']
363         })
364
365     # Update the puzzle in the database
366     turb.table.put_item(Item=puzzle)
367
368     # Inform the puzzle channel about the edit
369     edit_message = "Puzzle edited by <@{}>".format(user_id)
370     blocks = ([section_block(text_block(edit_message+":\n"))] +
371               puzzle_blocks(puzzle, include_rounds=True))
372     slack_send_message(
373         turb.slack_client, puzzle['channel_id'],
374         edit_message, blocks=blocks)
375
376     # Advertize any tag additions to the hunt
377     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
378
379     new_tags = set(puzzle['tags']) - set(old_puzzle['tags'])
380     if new_tags:
381         message = "Puzzle <{}|{}> has been tagged: {}".format(
382             puzzle['channel_url'],
383             puzzle['name'],
384             ", ".join(['`{}`'.format(t) for t in new_tags])
385         )
386         slack_send_message(turb.slack_client, hunt['channel_id'], message)
387
388     # Also inform the hunt if the puzzle's solved status changed
389     if puzzle['status'] != old_puzzle['status']:
390         if puzzle['status'] == 'solved':
391             message = "Puzzle <{}|{}> has been solved!".format(
392                 puzzle['channel_url'],
393                 puzzle['name'])
394         else:
395             message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
396                 puzzle['channel_url'],
397                 puzzle['name'])
398         slack_send_message(turb.slack_client, hunt['channel_id'], message)
399
400     # We need to set the channel topic if any of puzzle name, url,
401     # state, status, or solution, has changed. Let's just do that
402     # unconditionally here.
403     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
404
405     return lambda_ok
406
407 def edit_hunt_command(turb, body):
408     """Implementation of the `/hunt edit` command
409
410     As dispatched from the hunt() function.
411     """
412
413     channel_id = body['channel_id'][0]
414     trigger_id = body['trigger_id'][0]
415
416     hunt = hunt_for_channel(turb, channel_id)
417
418     if not hunt:
419         return bot_reply("Sorry, this does not appear to be a hunt channel.")
420
421     return edit_hunt(turb, hunt, trigger_id)
422
423 def edit_hunt_button(turb, payload):
424     """Handler for the action of user pressing an edit_hunt button"""
425
426     hunt_id = payload['actions'][0]['action_id']
427     trigger_id = payload['trigger_id']
428
429     hunt = find_hunt_for_hunt_id(turb, hunt_id)
430
431     if not hunt:
432         return bot_reply("Error: Hunt not found.")
433
434     return edit_hunt(turb, hunt, trigger_id)
435
436 actions['button']['edit_hunt'] = edit_hunt_button
437
438 def edit_hunt(turb, hunt, trigger_id):
439     """Common code for implementing an edit hunt dialog
440
441     This implementation is common whether the edit operation was invoked
442     by a button (edit_hunt_button) or a command (edit_hunt_command).
443     """
444
445     view = {
446         "type": "modal",
447         "private_metadata": json.dumps({
448             "hunt_id": hunt["hunt_id"],
449             "SK": hunt["SK"],
450             "is_hunt": hunt["is_hunt"],
451             "channel_id": hunt["channel_id"],
452             "sheet_url": hunt["sheet_url"],
453             "folder_id": hunt["folder_id"],
454         }),
455         "title": { "type": "plain_text", "text": "Edit Hunt" },
456         "submit": { "type": "plain_text", "text": "Save" },
457         "blocks": [
458             input_block("Hunt name", "name", "Name of the hunt",
459                         initial_value=hunt["name"]),
460             input_block("Hunt URL", "url", "External URL of hunt",
461                         initial_value=hunt.get("url", None),
462                         optional=True),
463             input_block("State", "state",
464                         "State of the hunt (goals, upcoming meetings, etc.)",
465                         initial_value=hunt.get("state", None),
466                         optional=True),
467             checkbox_block("Is this hunt active?", "Active", "active",
468                            checked=(hunt.get('active', False)))
469         ]
470     }
471
472     result = turb.slack_client.views_open(trigger_id=trigger_id,
473                                           view=view)
474
475     if result['ok']:
476         submission_handlers[result['view']['id']] = edit_hunt_submission
477
478     return lambda_ok
479
480 def edit_hunt_submission(turb, payload, metadata):
481     """Handler for the user submitting the edit hunt modal
482
483     This is the modal view presented by the edit_hunt function above.
484     """
485
486     hunt={}
487
488     # First, read all the various data from the request
489     meta = json.loads(metadata)
490     hunt['hunt_id'] = meta['hunt_id']
491     hunt['SK'] = meta['SK']
492     hunt['is_hunt'] = meta['is_hunt']
493     hunt['channel_id'] = meta['channel_id']
494     hunt['sheet_url'] = meta['sheet_url']
495     hunt['folder_id'] = meta['folder_id']
496
497     state = payload['view']['state']['values']
498     user_id = payload['user']['id']
499
500     hunt['name'] = state['name']['name']['value']
501     url = state['url']['url']['value']
502     if url:
503         hunt['url'] = url
504
505     hunt_state = state['state']['state']['value']
506     if hunt_state:
507         hunt['state'] = hunt_state
508     if state['active']['active']['selected_options']:
509         hunt['active'] = True
510     else:
511         hunt['active'] = False
512
513     # Update the hunt in the database
514     turb.table.put_item(Item=hunt)
515
516     # Inform the hunt channel about the edit
517     edit_message = "Hunt edited by <@{}>".format(user_id)
518     blocks = [
519         section_block(text_block(edit_message)),
520         section_block(text_block("Hunt name: {}".format(hunt['name']))),
521     ]
522
523     url = hunt.get('url', None)
524     if url:
525         blocks.append(
526             section_block(text_block("Hunt URL: {}".format(hunt['url'])))
527         )
528
529     slack_send_message(
530         turb.slack_client, hunt['channel_id'],
531         edit_message, blocks=blocks)
532
533     # Update channel topic and description
534     hunt_update_topic(turb, hunt)
535
536     return lambda_ok
537
538 def new_hunt_command(turb, body):
539     """Implementation of the '/hunt new' command
540
541     As dispatched from the hunt() function.
542     """
543
544     trigger_id = body['trigger_id'][0]
545
546     return new_hunt(turb, trigger_id)
547
548 def new_hunt_button(turb, payload):
549     """Handler for the action of user pressing the new_hunt button"""
550
551     trigger_id = payload['trigger_id']
552
553     return new_hunt(turb, trigger_id)
554
555 def new_hunt(turb, trigger_id):
556     """Common code for implementing a new hunt dialog
557
558     This implementation is common whether the operations was invoked
559     by a button (new_hunt_button) or a command (new_hunt_command).
560     """
561
562     view = {
563         "type": "modal",
564         "private_metadata": json.dumps({}),
565         "title": { "type": "plain_text", "text": "New Hunt" },
566         "submit": { "type": "plain_text", "text": "Create" },
567         "blocks": [
568             input_block("Hunt name", "name", "Name of the hunt"),
569             input_block("Hunt ID", "hunt_id",
570                         "Used as puzzle channel prefix "
571                         + "(no spaces nor punctuation)"),
572             input_block("Hunt URL", "url", "External URL of hunt",
573                         optional=True)
574         ],
575     }
576
577     result = turb.slack_client.views_open(trigger_id=trigger_id,
578                                           view=view)
579     if (result['ok']):
580         submission_handlers[result['view']['id']] = new_hunt_submission
581
582     return lambda_ok
583
584 actions['button']['new_hunt'] = new_hunt_button
585
586 def new_hunt_submission(turb, payload, metadata):
587     """Handler for the user submitting the new hunt modal
588
589     This is the modal view presented to the user by the new_hunt
590     function above."""
591
592     state = payload['view']['state']['values']
593     user_id = payload['user']['id']
594     name = state['name']['name']['value']
595     hunt_id = state['hunt_id']['hunt_id']['value']
596     url = state['url']['url']['value']
597
598     # Validate that the hunt_id contains no invalid characters
599     if not re.match(valid_id_re, hunt_id):
600         return submission_error("hunt_id",
601                                 "Hunt ID can only contain lowercase letters, "
602                                 + "numbers, and underscores")
603
604     # Check to see if the turbot table exists
605     try:
606         exists = turb.table.table_status in ("CREATING", "UPDATING",
607                                              "ACTIVE")
608     except ClientError:
609         exists = False
610
611     # Create the turbot table if necessary.
612     if not exists:
613         turb.table = turb.db.create_table(
614             TableName='turbot',
615             KeySchema=[
616                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
617                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
618             ],
619             AttributeDefinitions=[
620                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
621                 {'AttributeName': 'SK', 'AttributeType': 'S'},
622                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
623                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
624                 {'AttributeName': 'url', 'AttributeType': 'S'},
625                 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
626             ],
627             ProvisionedThroughput={
628                 'ReadCapacityUnits': 5,
629                 'WriteCapacityUnits': 5
630             },
631             GlobalSecondaryIndexes=[
632                 {
633                     'IndexName': 'channel_id_index',
634                     'KeySchema': [
635                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
636                     ],
637                     'Projection': {
638                         'ProjectionType': 'ALL'
639                     },
640                     'ProvisionedThroughput': {
641                         'ReadCapacityUnits': 5,
642                         'WriteCapacityUnits': 5
643                     }
644                 },
645                 {
646                     'IndexName': 'is_hunt_index',
647                     'KeySchema': [
648                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
649                     ],
650                     'Projection': {
651                         'ProjectionType': 'ALL'
652                     },
653                     'ProvisionedThroughput': {
654                         'ReadCapacityUnits': 5,
655                         'WriteCapacityUnits': 5
656                     }
657                 }
658             ],
659             LocalSecondaryIndexes = [
660                 {
661                     'IndexName': 'url_index',
662                     'KeySchema': [
663                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
664                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
665                     ],
666                     'Projection': {
667                         'ProjectionType': 'ALL'
668                     }
669                 },
670                 {
671                     'IndexName': 'puzzle_id_index',
672                     'KeySchema': [
673                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
674                         {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
675                     ],
676                     'Projection': {
677                         'ProjectionType': 'ALL'
678                     }
679                 }
680             ]
681         )
682         return submission_error(
683             "hunt_id",
684             "Still bootstrapping turbot table. Try again in a minute, please.")
685
686     # Create a channel for the hunt
687     try:
688         response = turb.slack_client.conversations_create(name=hunt_id)
689     except SlackApiError as e:
690         return submission_error("hunt_id",
691                                 "Error creating Slack channel: {}"
692                                 .format(e.response['error']))
693
694     channel_id = response['channel']['id']
695
696     # Insert the newly-created hunt into the database
697     # (leaving it as non-active for now until the channel-created handler
698     #  finishes fixing it up with a sheet and a companion table)
699     item={
700         "hunt_id": hunt_id,
701         "SK": "hunt-{}".format(hunt_id),
702         "is_hunt": hunt_id,
703         "channel_id": channel_id,
704         "active": False,
705         "name": name,
706     }
707     if url:
708         item['url'] = url
709     turb.table.put_item(Item=item)
710
711     # Invite the initiating user to the channel
712     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
713
714     return lambda_ok
715
716 def view_submission(turb, payload):
717     """Handler for Slack interactive view submission
718
719     Specifically, those that have a payload type of 'view_submission'"""
720
721     view_id = payload['view']['id']
722     metadata = payload['view']['private_metadata']
723
724     if view_id in submission_handlers:
725         return submission_handlers[view_id](turb, payload, metadata)
726
727     print("Error: Unknown view ID: {}".format(view_id))
728     return {
729         'statusCode': 400
730     }
731
732 def rot(turb, body, args):
733     """Implementation of the /rot command
734
735     The args string should be as follows:
736
737         [count|*] String to be rotated
738
739     That is, the first word of the string is an optional number (or
740     the character '*'). If this is a number it indicates an amount to
741     rotate each character in the string. If the count is '*' or is not
742     present, then the string will be rotated through all possible 25
743     values.
744
745     The result of the rotation is returned (with Slack formatting) in
746     the body of the response so that Slack will provide it as a reply
747     to the user who submitted the slash command."""
748
749     channel_name = body['channel_name'][0]
750     response_url = body['response_url'][0]
751     channel_id = body['channel_id'][0]
752
753     result = turbot.rot.rot(args)
754
755     if (channel_name == "directmessage"):
756         requests.post(response_url,
757                       json = {"text": result},
758                       headers = {"Content-type": "application/json"})
759     else:
760         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
761
762     return lambda_ok
763
764 commands["/rot"] = rot
765
766 def get_table_item(turb, table_name, key, value):
767     """Get an item from the database 'table_name' with 'key' as 'value'
768
769     Returns a tuple of (item, table) if found and (None, None) otherwise."""
770
771     table = turb.db.Table(table_name)
772
773     response = table.get_item(Key={key: value})
774
775     if 'Item' in response:
776         return (response['Item'], table)
777     else:
778         return (None, None)
779
780 def db_entry_for_channel(turb, channel_id):
781     """Given a channel ID return the database item for this channel
782
783     If this channel is a registered hunt or puzzle channel, return the
784     corresponding row from the database for this channel. Otherwise,
785     return None.
786
787     Note: If you need to specifically ensure that the channel is a
788     puzzle or a hunt, please call puzzle_for_channel or
789     hunt_for_channel respectively.
790     """
791
792     response = turb.table.query(
793         IndexName = "channel_id_index",
794         KeyConditionExpression=Key("channel_id").eq(channel_id)
795     )
796
797     if response['Count'] == 0:
798         return None
799
800     return response['Items'][0]
801
802
803 def puzzle_for_channel(turb, channel_id):
804
805     """Given a channel ID return the puzzle from the database for this channel
806
807     If the given channel_id is a puzzle's channel, this function
808     returns a dict filled with the attributes from the puzzle's entry
809     in the database.
810
811     Otherwise, this function returns None.
812     """
813
814     entry = db_entry_for_channel(turb, channel_id)
815
816     if entry and entry['SK'].startswith('puzzle-'):
817         return entry
818     else:
819         return None
820
821 def hunt_for_channel(turb, channel_id):
822
823     """Given a channel ID return the hunt from the database for this channel
824
825     This works whether the original channel is a primary hunt channel,
826     or if it is one of the channels of a puzzle belonging to the hunt.
827
828     Returns None if channel does not belong to a hunt, otherwise a
829     dictionary with all fields from the hunt's row in the table,
830     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
831     """
832
833     entry = db_entry_for_channel(turb, channel_id)
834
835     # We're done if this channel doesn't exist in the database at all
836     if not entry:
837         return None
838
839     # Also done if this channel is a hunt channel
840     if entry['SK'].startswith('hunt-'):
841         return entry
842
843     # Otherwise, (the channel is in the database, but is not a hunt),
844     # we expect this to be a puzzle channel instead
845     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
846
847 # python3.9 has a built-in removeprefix but AWS only has python3.8
848 def remove_prefix(text, prefix):
849     if text.startswith(prefix):
850         return text[len(prefix):]
851     return text
852
853 def hunt_rounds(turb, hunt_id):
854     """Returns array of strings giving rounds that exist in the given hunt"""
855
856     response = turb.table.query(
857         KeyConditionExpression=(
858             Key('hunt_id').eq(hunt_id) &
859             Key('SK').begins_with('round-')
860         )
861     )
862
863     if response['Count'] == 0:
864         return []
865
866     return [remove_prefix(option['SK'], 'round-')
867             for option in response['Items']]
868
869 def puzzle(turb, body, args):
870     """Implementation of the /puzzle command
871
872     The args string can be a sub-command:
873
874         /puzzle new: Bring up a dialog to create a new puzzle
875
876         /puzzle edit: Edit the puzzle for the current channel
877
878     Or with no argument at all:
879
880         /puzzle: Print details of the current puzzle (if in a puzzle channel)
881     """
882
883     if args == 'new':
884         return new_puzzle(turb, body)
885
886     if args == 'edit':
887         return edit_puzzle_command(turb, body)
888
889     if len(args):
890         return bot_reply("Unknown syntax for `/puzzle` command. " +
891                          "Valid commands are: `/puzzle`, `/puzzle edit`, " +
892                          "and `/puzzle new` to display, edit, or create " +
893                          "a puzzle.")
894
895     # For no arguments we print the current puzzle as a reply
896     channel_id = body['channel_id'][0]
897     response_url = body['response_url'][0]
898
899     puzzle = puzzle_for_channel(turb, channel_id)
900
901     if not puzzle:
902         hunt = hunt_for_channel(turb, channel_id)
903         if hunt:
904             return bot_reply(
905                 "This is not a puzzle channel, but is a hunt channel. "
906                 + "If you want to create a new puzzle for this hunt, use "
907                 + "`/puzzle new`.")
908         else:
909             return bot_reply(
910                 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
911                 + "channel, so the `/puzzle` command cannot work here.")
912
913     blocks = puzzle_blocks(puzzle, include_rounds=True)
914
915     # For a meta puzzle, also display the titles and solutions for all
916     # puzzles in the same round.
917     if puzzle.get('type', 'plain') == 'meta':
918         puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
919
920         # Drop this puzzle itself from the report
921         puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
922
923         for round in puzzle.get('rounds', [None]):
924             answers = round_quoted_puzzles_titles_answers(round, puzzles)
925             blocks += [
926                 section_block(text_block(
927                     "*Feeder solutions from round {}*".format(
928                         round if round else "<none>"
929                     ))),
930                 section_block(text_block(answers))
931             ]
932
933     requests.post(response_url,
934                   json = {'blocks': blocks},
935                   headers = {'Content-type': 'application/json'}
936                   )
937
938     return lambda_ok
939
940 commands["/puzzle"] = puzzle
941
942 def new(turb, body, args):
943     """Implementation of the `/new` command
944
945     This can be used to create a new hunt ("/new hunt") or a new
946     puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
947     default behavior (as it is much more common).
948
949     This operations are identical to the existing "/hunt new" and
950     "/puzzle new". I don't know that that redundancy is actually
951     helpful in the interface. But at least having both allows us to
952     experiment and decide which is more natural and should be kept
953     around long-term.
954     """
955
956     if args == 'hunt':
957         return new_hunt_command(turb, body)
958
959     return new_puzzle(turb, body)
960
961 commands["/new"] = new
962
963 def new_puzzle(turb, body):
964     """Implementation of the "/puzzle new" command
965
966     This brings up a dialog box for creating a new puzzle.
967     """
968
969     channel_id = body['channel_id'][0]
970     trigger_id = body['trigger_id'][0]
971
972     hunt = hunt_for_channel(turb, channel_id)
973
974     if not hunt:
975         return bot_reply("Sorry, this channel doesn't appear to "
976                          + "be a hunt or puzzle channel")
977
978     # We used puzzle (if available) to select the initial round(s)
979     puzzle = puzzle_for_channel(turb, channel_id)
980     initial_rounds = None
981     if puzzle:
982         initial_rounds=puzzle.get("rounds", None)
983
984     round_options = hunt_rounds(turb, hunt['hunt_id'])
985
986     if len(round_options):
987         round_options_block = [
988             multi_select_block("Round(s)", "rounds",
989                                "Existing round(s) this puzzle belongs to",
990                                round_options,
991                                initial_options=initial_rounds)
992         ]
993     else:
994         round_options_block = []
995
996     view = {
997         "type": "modal",
998         "private_metadata": json.dumps({
999             "hunt_id": hunt['hunt_id'],
1000         }),
1001         "title": {"type": "plain_text", "text": "New Puzzle"},
1002         "submit": { "type": "plain_text", "text": "Create" },
1003         "blocks": [
1004             section_block(text_block("*For {}*".format(hunt['name']))),
1005             input_block("Puzzle name", "name", "Name of the puzzle"),
1006             input_block("Puzzle URL", "url", "External URL of puzzle",
1007                         optional=True),
1008             checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
1009             * round_options_block,
1010             input_block("New round(s)", "new_rounds",
1011                         "New round(s) this puzzle belongs to " +
1012                         "(comma separated)",
1013                         optional=True),
1014             input_block("Tag(s)", "tags",
1015                         "Tags for this puzzle (comma separated)",
1016                         optional=True),
1017         ]
1018     }
1019
1020     result = turb.slack_client.views_open(trigger_id=trigger_id,
1021                                           view=view)
1022
1023     if (result['ok']):
1024         submission_handlers[result['view']['id']] = new_puzzle_submission
1025
1026     return lambda_ok
1027
1028 def new_puzzle_submission(turb, payload, metadata):
1029     """Handler for the user submitting the new puzzle modal
1030
1031     This is the modal view presented to the user by the new_puzzle
1032     function above.
1033     """
1034
1035     # First, read all the various data from the request
1036     meta = json.loads(metadata)
1037     hunt_id = meta['hunt_id']
1038
1039     state = payload['view']['state']['values']
1040
1041     # And start loading data into a puzzle dict
1042     puzzle = {}
1043     puzzle['hunt_id'] = hunt_id
1044     puzzle['name'] = state['name']['name']['value']
1045     url = state['url']['url']['value']
1046     if url:
1047         puzzle['url'] = url
1048     if state['meta']['meta']['selected_options']:
1049         puzzle['type'] = 'meta'
1050     else:
1051         puzzle['type'] = 'plain'
1052     if 'rounds' in state:
1053         rounds = [option['value'] for option in
1054                   state['rounds']['rounds']['selected_options']]
1055     else:
1056         rounds = []
1057     new_rounds = state['new_rounds']['new_rounds']['value']
1058     tags = state['tags']['tags']['value']
1059
1060     # Create a Slack-channel-safe puzzle_id
1061     puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1062
1063     # Before doing anything, reject this puzzle if a puzzle already
1064     # exists with the same puzzle_id or url
1065     existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1066     if existing:
1067         return submission_error(
1068             "name",
1069             "Error: This name collides with an existing puzzle.")
1070
1071     if url:
1072         existing = find_puzzle_for_url(turb, hunt_id, url)
1073         if existing:
1074             return submission_error(
1075                 "url",
1076                 "Error: A puzzle with this URL already exists.")
1077
1078     # Add any new rounds to the database
1079     if new_rounds:
1080         for round in new_rounds.split(','):
1081             # Drop any leading/trailing spaces from the round name
1082             round = round.strip()
1083             # Ignore any empty string
1084             if not len(round):
1085                 continue
1086             rounds.append(round)
1087             turb.table.put_item(
1088                 Item={
1089                     'hunt_id': hunt_id,
1090                     'SK': 'round-' + round
1091                 }
1092             )
1093
1094     # Process any tags
1095     puzzle['tags'] = []
1096     if tags:
1097         for tag in tags.split(','):
1098             # Drop any leading/trailing spaces from the tag
1099             tag = tag.strip().upper()
1100             # Ignore any empty string
1101             if not len(tag):
1102                 continue
1103             # Reject a tag that is not alphabetic or underscore A-Z_
1104             if not re.match(r'^[A-Z0-9_]*$', tag):
1105                 return submission_error(
1106                     "tags",
1107                     "Error: Tags can only contain letters, numbers, "
1108                     + "and the underscore character."
1109                 )
1110             puzzle['tags'].append(tag)
1111
1112     if rounds:
1113         puzzle['rounds'] = rounds
1114
1115     puzzle['solution'] = []
1116     puzzle['status'] = 'unsolved'
1117
1118     # Create a channel for the puzzle
1119     channel_name = puzzle_channel_name(puzzle)
1120
1121     try:
1122         response = turb.slack_client.conversations_create(
1123             name=channel_name)
1124     except SlackApiError as e:
1125         return submission_error(
1126             "name",
1127             "Error creating Slack channel {}: {}"
1128             .format(channel_name, e.response['error']))
1129
1130     puzzle['channel_id'] = response['channel']['id']
1131
1132     # Finally, compute the appropriate sort key
1133     puzzle["SK"] = puzzle_sort_key(puzzle)
1134
1135     # Insert the newly-created puzzle into the database
1136     turb.table.put_item(Item=puzzle)
1137
1138     return lambda_ok
1139
1140 def state(turb, body, args):
1141     """Implementation of the /state command
1142
1143     The args string should be a brief sentence describing where things
1144     stand or what's needed."""
1145
1146     channel_id = body['channel_id'][0]
1147
1148     old_puzzle = puzzle_for_channel(turb, channel_id)
1149
1150     if not old_puzzle:
1151         return bot_reply(
1152             "Sorry, the /state command only works in a puzzle channel")
1153
1154     # Make a deep copy of the puzzle object
1155     puzzle = puzzle_copy(old_puzzle)
1156
1157     # Update the puzzle in the database
1158     puzzle['state'] = args
1159     turb.table.put_item(Item=puzzle)
1160
1161     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1162
1163     return lambda_ok
1164
1165 commands["/state"] = state
1166
1167 def tag(turb, body, args):
1168     """Implementation of the `/tag` command.
1169
1170     Arg is either a tag to add (optionally prefixed with '+'), or if
1171     prefixed with '-' is a tag to remove.
1172     """
1173
1174     if not args:
1175         return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1176                          + "or `/tag -TAG_TO_REMOVE`.")
1177
1178     channel_id = body['channel_id'][0]
1179
1180     old_puzzle = puzzle_for_channel(turb, channel_id)
1181
1182     if not old_puzzle:
1183         return bot_reply(
1184             "Sorry, the /tag command only works in a puzzle channel")
1185
1186     if args[0] == '-':
1187         tag = args[1:]
1188         action = 'remove'
1189     else:
1190         tag = args
1191         if tag[0] == '+':
1192             tag = tag[1:]
1193         action = 'add'
1194
1195     # Force tag to all uppercase
1196     tag = tag.upper()
1197
1198     # Reject a tag that is not alphabetic or underscore A-Z_
1199     if not re.match(r'^[A-Z0-9_]*$', tag):
1200         return bot_reply("Sorry, tags can only contain letters, numbers, "
1201                          + "and the underscore character.")
1202
1203     if action == 'remove':
1204         if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1205             return bot_reply("Nothing to do. This puzzle is not tagged "
1206                              + "with the tag: {}".format(tag))
1207     else:
1208         if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1209             return bot_reply("Nothing to do. This puzzle is already tagged "
1210                              + "with the tag: {}".format(tag))
1211
1212     # OK. Error checking is done. Let's get to work
1213
1214     # Make a deep copy of the puzzle object
1215     puzzle = puzzle_copy(old_puzzle)
1216
1217     if action == 'remove':
1218         puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1219     else:
1220         if 'tags' not in puzzle:
1221             puzzle['tags'] = [tag]
1222         else:
1223             puzzle['tags'].append(tag)
1224
1225     turb.table.put_item(Item=puzzle)
1226
1227     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1228
1229     # Advertize any tag additions to the hunt
1230     new_tags = set(puzzle['tags']) - set(old_puzzle['tags'])
1231     if new_tags:
1232         hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1233         message = "Puzzle <{}|{}> has been tagged: {}".format(
1234             puzzle['channel_url'],
1235             puzzle['name'],
1236             ", ".join(['`{}`'.format(t) for t in new_tags])
1237         )
1238         slack_send_message(turb.slack_client, hunt['channel_id'], message)
1239
1240     return lambda_ok
1241
1242 commands["/tag"] = tag
1243
1244 def solved(turb, body, args):
1245     """Implementation of the /solved command
1246
1247     The args string should be a confirmed solution."""
1248
1249     channel_id = body['channel_id'][0]
1250     user_id = body['user_id'][0]
1251
1252     old_puzzle = puzzle_for_channel(turb, channel_id)
1253
1254     if not old_puzzle:
1255         return bot_reply("Sorry, this is not a puzzle channel.")
1256
1257     if not args:
1258         return bot_reply(
1259             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1260
1261     # Make a deep copy of the puzzle object
1262     puzzle = puzzle_copy(old_puzzle)
1263
1264     # Set the status and solution fields in the database
1265     puzzle['status'] = 'solved'
1266
1267     # Don't append a duplicate solution
1268     if args not in puzzle['solution']:
1269         puzzle['solution'].append(args)
1270     if 'state' in puzzle:
1271         del puzzle['state']
1272     turb.table.put_item(Item=puzzle)
1273
1274     # Report the solution to the puzzle's channel
1275     slack_send_message(
1276         turb.slack_client, channel_id,
1277         "Puzzle marked solved by <@{}>: `{}`".format(user_id, args))
1278
1279     # Also report the solution to the hunt channel
1280     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1281     slack_send_message(
1282         turb.slack_client, hunt['channel_id'],
1283         "Puzzle <{}|{}> has been solved!".format(
1284             puzzle['channel_url'],
1285             puzzle['name'])
1286     )
1287
1288     # And update the puzzle's description
1289     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1290
1291     return lambda_ok
1292
1293 commands["/solved"] = solved
1294
1295 def delete(turb, body, args):
1296     """Implementation of the /delete command
1297
1298     The argument to this command is the ID of a hunt.
1299
1300     The command will report an error if the specified hunt is active.
1301
1302     If the hunt is inactive, this command will archive all channels
1303     from the hunt.
1304     """
1305
1306     if not args:
1307         return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`")
1308
1309     hunt_id = args
1310     hunt = find_hunt_for_hunt_id(turb, hunt_id)
1311
1312     if not hunt:
1313         return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id))
1314
1315     if hunt['active']:
1316         return bot_reply(
1317             "Error, refusing to delete active hunt \"{}\".".format(hunt_id)
1318         )
1319
1320     if hunt['hunt_id'] != hunt_id:
1321         return bot_reply(
1322             "Error, expected hunt ID of \"{}\" but found \"{}\".".format(
1323                 hunt_id, hunt['hunt_id']
1324             )
1325         )
1326
1327     puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
1328
1329     for puzzle in puzzles:
1330         channel_id = puzzle['channel_id']
1331         turb.slack_client.conversations_archive(channel=channel_id)
1332
1333     turb.slack_client.conversations_archive(channel=hunt['channel_id'])
1334
1335     return lambda_ok
1336
1337 commands["/delete"] = delete
1338
1339 def hunt(turb, body, args):
1340     """Implementation of the /hunt command
1341
1342     The (optional) args string can be used to filter which puzzles to
1343     display. The first word can be one of 'all', 'unsolved', or
1344     'solved' and can be used to display only puzzles with the given
1345     status. If this first word is missing, this command will display
1346     only unsolved puzzles by default.
1347
1348     Any remaining text in the args string will be interpreted as
1349     search terms. These will be split into separate terms on space
1350     characters, (though quotation marks can be used to include a space
1351     character in a term). All terms must match on a puzzle in order
1352     for that puzzle to be included. But a puzzle will be considered to
1353     match if any of the puzzle title, round title, puzzle URL, puzzle
1354     state, puzzle type, tags, or puzzle solution match. Matching will
1355     be performed without regard to case sensitivity and the search
1356     terms can include regular expression syntax.
1357
1358     """
1359
1360     channel_id = body['channel_id'][0]
1361     response_url = body['response_url'][0]
1362
1363     # First, farm off "/hunt new" and "/hunt edit" a separate commands
1364     if args == "new":
1365         return new_hunt_command(turb, body)
1366
1367     if args == "edit":
1368         return edit_hunt_command(turb, body)
1369
1370     terms = None
1371     if args:
1372         # The first word can be a puzzle status and all remaining word
1373         # (if any) are search terms. _But_, if the first word is not a
1374         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1375         # words are search terms and we default status to 'unsolved'.
1376         split_args = args.split(' ', 1)
1377         status = split_args[0]
1378         if (len(split_args) > 1):
1379             terms = split_args[1]
1380         if status not in ('unsolved', 'solved', 'all'):
1381             terms = args
1382             status = 'unsolved'
1383     else:
1384         status = 'unsolved'
1385
1386     # Separate search terms on spaces (but allow for quotation marks
1387     # to capture spaces in a search term)
1388     if terms:
1389         terms = shlex.split(terms)
1390
1391     hunt = hunt_for_channel(turb, channel_id)
1392
1393     if not hunt:
1394         return bot_reply("Sorry, this channel doesn't appear to "
1395                          + "be a hunt or puzzle channel")
1396
1397     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1398
1399     for block in blocks:
1400         if len(block) > 100:
1401             block = block[:100]
1402         requests.post(response_url,
1403                       json = { 'blocks': block },
1404                       headers = {'Content-type': 'application/json'}
1405                       )
1406
1407     return lambda_ok
1408
1409 commands["/hunt"] = hunt
1410
1411 def round(turb, body, args):
1412     """Implementation of the /round command
1413
1414     Displays puzzles in the same round(s) as the puzzle for the
1415     current channel.
1416
1417     The (optional) args string can be used to filter which puzzles to
1418     display. The first word can be one of 'all', 'unsolved', or
1419     'solved' and can be used to display only puzzles with the given
1420     status. If this first word is missing, this command will display
1421     all puzzles in the round by default.
1422
1423     Any remaining text in the args string will be interpreted as
1424     search terms. These will be split into separate terms on space
1425     characters, (though quotation marks can be used to include a space
1426     character in a term). All terms must match on a puzzle in order
1427     for that puzzle to be included. But a puzzle will be considered to
1428     match if any of the puzzle title, round title, puzzle URL, puzzle
1429     state, or puzzle solution match. Matching will be performed
1430     without regard to case sensitivity and the search terms can
1431     include regular expression syntax.
1432     """
1433
1434     channel_id = body['channel_id'][0]
1435     response_url = body['response_url'][0]
1436
1437     puzzle = puzzle_for_channel(turb, channel_id)
1438     hunt = hunt_for_channel(turb, channel_id)
1439
1440     if not puzzle:
1441         if hunt:
1442             return bot_reply(
1443                 "This is not a puzzle channel, but is a hunt channel. "
1444                 + "Use /hunt if you want to see all rounds for this hunt.")
1445         else:
1446             return bot_reply(
1447                 "Sorry, this channel doesn't appear to be a puzzle channel "
1448                 + "so the `/round` command cannot work here.")
1449
1450     terms = None
1451     if args:
1452         # The first word can be a puzzle status and all remaining word
1453         # (if any) are search terms. _But_, if the first word is not a
1454         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1455         # words are search terms and we default status to 'unsolved'.
1456         split_args = args.split(' ', 1)
1457         status = split_args[0]
1458         if (len(split_args) > 1):
1459             terms = split_args[1]
1460         if status not in ('unsolved', 'solved', 'all'):
1461             terms = args
1462             status = 'all'
1463     else:
1464         status = 'all'
1465
1466     # Separate search terms on spaces (but allow for quotation marks
1467     # to capture spaces in a search term)
1468     if terms:
1469         terms = shlex.split(terms)
1470
1471     blocks = hunt_blocks(turb, hunt,
1472                          puzzle_status=status, search_terms=terms,
1473                          limit_to_rounds=puzzle.get('rounds', [])
1474                          )
1475
1476     for block in blocks:
1477         if len(block) > 100:
1478             block = block[:100]
1479         requests.post(response_url,
1480                       json = { 'blocks': block },
1481                       headers = {'Content-type': 'application/json'}
1482                       )
1483
1484     return lambda_ok
1485
1486 commands["/round"] = round
1487
1488 def help_command(turb, body, args):
1489     """Implementation of the /help command
1490
1491     Displays help on how to use Turbot.
1492     """
1493
1494     channel_id = body['channel_id'][0]
1495     response_url = body['response_url'][0]
1496     user_id = body['user_id'][0]
1497
1498     # Process "/help me" first. It calls out to have_you_tried rather
1499     # than going through our help system.
1500     #
1501     # Also, it reports in the current channel, (where all other help
1502     # output is reported privately to the invoking user).
1503     if args == "me":
1504         to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1505             user_id, have_you_tried())
1506
1507         # We'll try first to reply directly to the channel (for the benefit
1508         # of anyone else in the same channel that might be stuck too.
1509         #
1510         # But if this doesn't work, (direct message or private channel),
1511         # then we can instead reply with an ephemeral message by using
1512         # the response_url.
1513         try:
1514             turb.slack_client.chat_postMessage(
1515                 channel=channel_id, text=to_try)
1516         except SlackApiError:
1517             requests.post(response_url,
1518                           json = {"text": to_try},
1519                           headers = {"Content-type": "application/json"})
1520         return lambda_ok
1521
1522     help_string = turbot_help(args)
1523
1524     requests.post(response_url,
1525                   json = {"text": help_string},
1526                   headers = {"Content-type": "application/json"})
1527
1528     return lambda_ok
1529
1530 commands["/help"] = help_command