]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Set hunt channel topic when initially creating the hunt
[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     # Update channel topic and description
712     hunt_update_topic(turb, item)
713
714     # Invite the initiating user to the channel
715     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
716
717     return lambda_ok
718
719 def view_submission(turb, payload):
720     """Handler for Slack interactive view submission
721
722     Specifically, those that have a payload type of 'view_submission'"""
723
724     view_id = payload['view']['id']
725     metadata = payload['view']['private_metadata']
726
727     if view_id in submission_handlers:
728         return submission_handlers[view_id](turb, payload, metadata)
729
730     print("Error: Unknown view ID: {}".format(view_id))
731     return {
732         'statusCode': 400
733     }
734
735 def rot(turb, body, args):
736     """Implementation of the /rot command
737
738     The args string should be as follows:
739
740         [count|*] String to be rotated
741
742     That is, the first word of the string is an optional number (or
743     the character '*'). If this is a number it indicates an amount to
744     rotate each character in the string. If the count is '*' or is not
745     present, then the string will be rotated through all possible 25
746     values.
747
748     The result of the rotation is returned (with Slack formatting) in
749     the body of the response so that Slack will provide it as a reply
750     to the user who submitted the slash command."""
751
752     channel_name = body['channel_name'][0]
753     response_url = body['response_url'][0]
754     channel_id = body['channel_id'][0]
755
756     result = turbot.rot.rot(args)
757
758     if (channel_name == "directmessage"):
759         requests.post(response_url,
760                       json = {"text": result},
761                       headers = {"Content-type": "application/json"})
762     else:
763         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
764
765     return lambda_ok
766
767 commands["/rot"] = rot
768
769 def get_table_item(turb, table_name, key, value):
770     """Get an item from the database 'table_name' with 'key' as 'value'
771
772     Returns a tuple of (item, table) if found and (None, None) otherwise."""
773
774     table = turb.db.Table(table_name)
775
776     response = table.get_item(Key={key: value})
777
778     if 'Item' in response:
779         return (response['Item'], table)
780     else:
781         return (None, None)
782
783 def db_entry_for_channel(turb, channel_id):
784     """Given a channel ID return the database item for this channel
785
786     If this channel is a registered hunt or puzzle channel, return the
787     corresponding row from the database for this channel. Otherwise,
788     return None.
789
790     Note: If you need to specifically ensure that the channel is a
791     puzzle or a hunt, please call puzzle_for_channel or
792     hunt_for_channel respectively.
793     """
794
795     response = turb.table.query(
796         IndexName = "channel_id_index",
797         KeyConditionExpression=Key("channel_id").eq(channel_id)
798     )
799
800     if response['Count'] == 0:
801         return None
802
803     return response['Items'][0]
804
805
806 def puzzle_for_channel(turb, channel_id):
807
808     """Given a channel ID return the puzzle from the database for this channel
809
810     If the given channel_id is a puzzle's channel, this function
811     returns a dict filled with the attributes from the puzzle's entry
812     in the database.
813
814     Otherwise, this function returns None.
815     """
816
817     entry = db_entry_for_channel(turb, channel_id)
818
819     if entry and entry['SK'].startswith('puzzle-'):
820         return entry
821     else:
822         return None
823
824 def hunt_for_channel(turb, channel_id):
825
826     """Given a channel ID return the hunt from the database for this channel
827
828     This works whether the original channel is a primary hunt channel,
829     or if it is one of the channels of a puzzle belonging to the hunt.
830
831     Returns None if channel does not belong to a hunt, otherwise a
832     dictionary with all fields from the hunt's row in the table,
833     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
834     """
835
836     entry = db_entry_for_channel(turb, channel_id)
837
838     # We're done if this channel doesn't exist in the database at all
839     if not entry:
840         return None
841
842     # Also done if this channel is a hunt channel
843     if entry['SK'].startswith('hunt-'):
844         return entry
845
846     # Otherwise, (the channel is in the database, but is not a hunt),
847     # we expect this to be a puzzle channel instead
848     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
849
850 # python3.9 has a built-in removeprefix but AWS only has python3.8
851 def remove_prefix(text, prefix):
852     if text.startswith(prefix):
853         return text[len(prefix):]
854     return text
855
856 def hunt_rounds(turb, hunt_id):
857     """Returns array of strings giving rounds that exist in the given hunt"""
858
859     response = turb.table.query(
860         KeyConditionExpression=(
861             Key('hunt_id').eq(hunt_id) &
862             Key('SK').begins_with('round-')
863         )
864     )
865
866     if response['Count'] == 0:
867         return []
868
869     return [remove_prefix(option['SK'], 'round-')
870             for option in response['Items']]
871
872 def puzzle(turb, body, args):
873     """Implementation of the /puzzle command
874
875     The args string can be a sub-command:
876
877         /puzzle new: Bring up a dialog to create a new puzzle
878
879         /puzzle edit: Edit the puzzle for the current channel
880
881     Or with no argument at all:
882
883         /puzzle: Print details of the current puzzle (if in a puzzle channel)
884     """
885
886     if args == 'new':
887         return new_puzzle(turb, body)
888
889     if args == 'edit':
890         return edit_puzzle_command(turb, body)
891
892     if len(args):
893         return bot_reply("Unknown syntax for `/puzzle` command. " +
894                          "Valid commands are: `/puzzle`, `/puzzle edit`, " +
895                          "and `/puzzle new` to display, edit, or create " +
896                          "a puzzle.")
897
898     # For no arguments we print the current puzzle as a reply
899     channel_id = body['channel_id'][0]
900     response_url = body['response_url'][0]
901
902     puzzle = puzzle_for_channel(turb, channel_id)
903
904     if not puzzle:
905         hunt = hunt_for_channel(turb, channel_id)
906         if hunt:
907             return bot_reply(
908                 "This is not a puzzle channel, but is a hunt channel. "
909                 + "If you want to create a new puzzle for this hunt, use "
910                 + "`/puzzle new`.")
911         else:
912             return bot_reply(
913                 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
914                 + "channel, so the `/puzzle` command cannot work here.")
915
916     blocks = puzzle_blocks(puzzle, include_rounds=True)
917
918     # For a meta puzzle, also display the titles and solutions for all
919     # puzzles in the same round.
920     if puzzle.get('type', 'plain') == 'meta':
921         puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
922
923         # Drop this puzzle itself from the report
924         puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
925
926         for round in puzzle.get('rounds', [None]):
927             answers = round_quoted_puzzles_titles_answers(round, puzzles)
928             blocks += [
929                 section_block(text_block(
930                     "*Feeder solutions from round {}*".format(
931                         round if round else "<none>"
932                     ))),
933                 section_block(text_block(answers))
934             ]
935
936     requests.post(response_url,
937                   json = {'blocks': blocks},
938                   headers = {'Content-type': 'application/json'}
939                   )
940
941     return lambda_ok
942
943 commands["/puzzle"] = puzzle
944
945 def new(turb, body, args):
946     """Implementation of the `/new` command
947
948     This can be used to create a new hunt ("/new hunt") or a new
949     puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
950     default behavior (as it is much more common).
951
952     This operations are identical to the existing "/hunt new" and
953     "/puzzle new". I don't know that that redundancy is actually
954     helpful in the interface. But at least having both allows us to
955     experiment and decide which is more natural and should be kept
956     around long-term.
957     """
958
959     if args == 'hunt':
960         return new_hunt_command(turb, body)
961
962     return new_puzzle(turb, body)
963
964 commands["/new"] = new
965
966 def new_puzzle(turb, body):
967     """Implementation of the "/puzzle new" command
968
969     This brings up a dialog box for creating a new puzzle.
970     """
971
972     channel_id = body['channel_id'][0]
973     trigger_id = body['trigger_id'][0]
974
975     hunt = hunt_for_channel(turb, channel_id)
976
977     if not hunt:
978         return bot_reply("Sorry, this channel doesn't appear to "
979                          + "be a hunt or puzzle channel")
980
981     # We used puzzle (if available) to select the initial round(s)
982     puzzle = puzzle_for_channel(turb, channel_id)
983     initial_rounds = None
984     if puzzle:
985         initial_rounds=puzzle.get("rounds", None)
986
987     round_options = hunt_rounds(turb, hunt['hunt_id'])
988
989     if len(round_options):
990         round_options_block = [
991             multi_select_block("Round(s)", "rounds",
992                                "Existing round(s) this puzzle belongs to",
993                                round_options,
994                                initial_options=initial_rounds)
995         ]
996     else:
997         round_options_block = []
998
999     view = {
1000         "type": "modal",
1001         "private_metadata": json.dumps({
1002             "hunt_id": hunt['hunt_id'],
1003         }),
1004         "title": {"type": "plain_text", "text": "New Puzzle"},
1005         "submit": { "type": "plain_text", "text": "Create" },
1006         "blocks": [
1007             section_block(text_block("*For {}*".format(hunt['name']))),
1008             input_block("Puzzle name", "name", "Name of the puzzle"),
1009             input_block("Puzzle URL", "url", "External URL of puzzle",
1010                         optional=True),
1011             checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
1012             * round_options_block,
1013             input_block("New round(s)", "new_rounds",
1014                         "New round(s) this puzzle belongs to " +
1015                         "(comma separated)",
1016                         optional=True),
1017             input_block("Tag(s)", "tags",
1018                         "Tags for this puzzle (comma separated)",
1019                         optional=True),
1020         ]
1021     }
1022
1023     result = turb.slack_client.views_open(trigger_id=trigger_id,
1024                                           view=view)
1025
1026     if (result['ok']):
1027         submission_handlers[result['view']['id']] = new_puzzle_submission
1028
1029     return lambda_ok
1030
1031 def new_puzzle_submission(turb, payload, metadata):
1032     """Handler for the user submitting the new puzzle modal
1033
1034     This is the modal view presented to the user by the new_puzzle
1035     function above.
1036     """
1037
1038     # First, read all the various data from the request
1039     meta = json.loads(metadata)
1040     hunt_id = meta['hunt_id']
1041
1042     state = payload['view']['state']['values']
1043
1044     # And start loading data into a puzzle dict
1045     puzzle = {}
1046     puzzle['hunt_id'] = hunt_id
1047     puzzle['name'] = state['name']['name']['value']
1048     url = state['url']['url']['value']
1049     if url:
1050         puzzle['url'] = url
1051     if state['meta']['meta']['selected_options']:
1052         puzzle['type'] = 'meta'
1053     else:
1054         puzzle['type'] = 'plain'
1055     if 'rounds' in state:
1056         rounds = [option['value'] for option in
1057                   state['rounds']['rounds']['selected_options']]
1058     else:
1059         rounds = []
1060     new_rounds = state['new_rounds']['new_rounds']['value']
1061     tags = state['tags']['tags']['value']
1062
1063     # Create a Slack-channel-safe puzzle_id
1064     puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1065
1066     # Before doing anything, reject this puzzle if a puzzle already
1067     # exists with the same puzzle_id or url
1068     existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1069     if existing:
1070         return submission_error(
1071             "name",
1072             "Error: This name collides with an existing puzzle.")
1073
1074     if url:
1075         existing = find_puzzle_for_url(turb, hunt_id, url)
1076         if existing:
1077             return submission_error(
1078                 "url",
1079                 "Error: A puzzle with this URL already exists.")
1080
1081     # Add any new rounds to the database
1082     if new_rounds:
1083         for round in new_rounds.split(','):
1084             # Drop any leading/trailing spaces from the round name
1085             round = round.strip()
1086             # Ignore any empty string
1087             if not len(round):
1088                 continue
1089             rounds.append(round)
1090             turb.table.put_item(
1091                 Item={
1092                     'hunt_id': hunt_id,
1093                     'SK': 'round-' + round
1094                 }
1095             )
1096
1097     # Process any tags
1098     puzzle['tags'] = []
1099     if tags:
1100         for tag in tags.split(','):
1101             # Drop any leading/trailing spaces from the tag
1102             tag = tag.strip().upper()
1103             # Ignore any empty string
1104             if not len(tag):
1105                 continue
1106             # Reject a tag that is not alphabetic or underscore A-Z_
1107             if not re.match(r'^[A-Z0-9_]*$', tag):
1108                 return submission_error(
1109                     "tags",
1110                     "Error: Tags can only contain letters, numbers, "
1111                     + "and the underscore character."
1112                 )
1113             puzzle['tags'].append(tag)
1114
1115     if rounds:
1116         puzzle['rounds'] = rounds
1117
1118     puzzle['solution'] = []
1119     puzzle['status'] = 'unsolved'
1120
1121     # Create a channel for the puzzle
1122     channel_name = puzzle_channel_name(puzzle)
1123
1124     try:
1125         response = turb.slack_client.conversations_create(
1126             name=channel_name)
1127     except SlackApiError as e:
1128         return submission_error(
1129             "name",
1130             "Error creating Slack channel {}: {}"
1131             .format(channel_name, e.response['error']))
1132
1133     puzzle['channel_id'] = response['channel']['id']
1134
1135     # Finally, compute the appropriate sort key
1136     puzzle["SK"] = puzzle_sort_key(puzzle)
1137
1138     # Insert the newly-created puzzle into the database
1139     turb.table.put_item(Item=puzzle)
1140
1141     return lambda_ok
1142
1143 def state(turb, body, args):
1144     """Implementation of the /state command
1145
1146     The args string should be a brief sentence describing where things
1147     stand or what's needed."""
1148
1149     channel_id = body['channel_id'][0]
1150
1151     old_puzzle = puzzle_for_channel(turb, channel_id)
1152
1153     if not old_puzzle:
1154         return bot_reply(
1155             "Sorry, the /state command only works in a puzzle channel")
1156
1157     # Make a deep copy of the puzzle object
1158     puzzle = puzzle_copy(old_puzzle)
1159
1160     # Update the puzzle in the database
1161     puzzle['state'] = args
1162     turb.table.put_item(Item=puzzle)
1163
1164     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1165
1166     return lambda_ok
1167
1168 commands["/state"] = state
1169
1170 def tag(turb, body, args):
1171     """Implementation of the `/tag` command.
1172
1173     Arg is either a tag to add (optionally prefixed with '+'), or if
1174     prefixed with '-' is a tag to remove.
1175     """
1176
1177     if not args:
1178         return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1179                          + "or `/tag -TAG_TO_REMOVE`.")
1180
1181     channel_id = body['channel_id'][0]
1182
1183     old_puzzle = puzzle_for_channel(turb, channel_id)
1184
1185     if not old_puzzle:
1186         return bot_reply(
1187             "Sorry, the /tag command only works in a puzzle channel")
1188
1189     if args[0] == '-':
1190         tag = args[1:]
1191         action = 'remove'
1192     else:
1193         tag = args
1194         if tag[0] == '+':
1195             tag = tag[1:]
1196         action = 'add'
1197
1198     # Force tag to all uppercase
1199     tag = tag.upper()
1200
1201     # Reject a tag that is not alphabetic or underscore A-Z_
1202     if not re.match(r'^[A-Z0-9_]*$', tag):
1203         return bot_reply("Sorry, tags can only contain letters, numbers, "
1204                          + "and the underscore character.")
1205
1206     if action == 'remove':
1207         if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1208             return bot_reply("Nothing to do. This puzzle is not tagged "
1209                              + "with the tag: {}".format(tag))
1210     else:
1211         if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1212             return bot_reply("Nothing to do. This puzzle is already tagged "
1213                              + "with the tag: {}".format(tag))
1214
1215     # OK. Error checking is done. Let's get to work
1216
1217     # Make a deep copy of the puzzle object
1218     puzzle = puzzle_copy(old_puzzle)
1219
1220     if action == 'remove':
1221         puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1222     else:
1223         if 'tags' not in puzzle:
1224             puzzle['tags'] = [tag]
1225         else:
1226             puzzle['tags'].append(tag)
1227
1228     turb.table.put_item(Item=puzzle)
1229
1230     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1231
1232     # Advertize any tag additions to the hunt
1233     new_tags = set(puzzle['tags']) - set(old_puzzle['tags'])
1234     if new_tags:
1235         hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1236         message = "Puzzle <{}|{}> has been tagged: {}".format(
1237             puzzle['channel_url'],
1238             puzzle['name'],
1239             ", ".join(['`{}`'.format(t) for t in new_tags])
1240         )
1241         slack_send_message(turb.slack_client, hunt['channel_id'], message)
1242
1243     return lambda_ok
1244
1245 commands["/tag"] = tag
1246
1247 def solved(turb, body, args):
1248     """Implementation of the /solved command
1249
1250     The args string should be a confirmed solution."""
1251
1252     channel_id = body['channel_id'][0]
1253     user_id = body['user_id'][0]
1254
1255     old_puzzle = puzzle_for_channel(turb, channel_id)
1256
1257     if not old_puzzle:
1258         return bot_reply("Sorry, this is not a puzzle channel.")
1259
1260     if not args:
1261         return bot_reply(
1262             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1263
1264     # Make a deep copy of the puzzle object
1265     puzzle = puzzle_copy(old_puzzle)
1266
1267     # Set the status and solution fields in the database
1268     puzzle['status'] = 'solved'
1269
1270     # Don't append a duplicate solution
1271     if args not in puzzle['solution']:
1272         puzzle['solution'].append(args)
1273     if 'state' in puzzle:
1274         del puzzle['state']
1275     turb.table.put_item(Item=puzzle)
1276
1277     # Report the solution to the puzzle's channel
1278     slack_send_message(
1279         turb.slack_client, channel_id,
1280         "Puzzle marked solved by <@{}>: `{}`".format(user_id, args))
1281
1282     # Also report the solution to the hunt channel
1283     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1284     slack_send_message(
1285         turb.slack_client, hunt['channel_id'],
1286         "Puzzle <{}|{}> has been solved!".format(
1287             puzzle['channel_url'],
1288             puzzle['name'])
1289     )
1290
1291     # And update the puzzle's description
1292     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1293
1294     return lambda_ok
1295
1296 commands["/solved"] = solved
1297
1298 def delete(turb, body, args):
1299     """Implementation of the /delete command
1300
1301     The argument to this command is the ID of a hunt.
1302
1303     The command will report an error if the specified hunt is active.
1304
1305     If the hunt is inactive, this command will archive all channels
1306     from the hunt.
1307     """
1308
1309     if not args:
1310         return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`")
1311
1312     hunt_id = args
1313     hunt = find_hunt_for_hunt_id(turb, hunt_id)
1314
1315     if not hunt:
1316         return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id))
1317
1318     if hunt['active']:
1319         return bot_reply(
1320             "Error, refusing to delete active hunt \"{}\".".format(hunt_id)
1321         )
1322
1323     if hunt['hunt_id'] != hunt_id:
1324         return bot_reply(
1325             "Error, expected hunt ID of \"{}\" but found \"{}\".".format(
1326                 hunt_id, hunt['hunt_id']
1327             )
1328         )
1329
1330     puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
1331
1332     for puzzle in puzzles:
1333         channel_id = puzzle['channel_id']
1334         turb.slack_client.conversations_archive(channel=channel_id)
1335
1336     turb.slack_client.conversations_archive(channel=hunt['channel_id'])
1337
1338     return lambda_ok
1339
1340 commands["/delete"] = delete
1341
1342 def hunt(turb, body, args):
1343     """Implementation of the /hunt command
1344
1345     The (optional) args string can be used to filter which puzzles to
1346     display. The first word can be one of 'all', 'unsolved', or
1347     'solved' and can be used to display only puzzles with the given
1348     status. If this first word is missing, this command will display
1349     only unsolved puzzles by default.
1350
1351     Any remaining text in the args string will be interpreted as
1352     search terms. These will be split into separate terms on space
1353     characters, (though quotation marks can be used to include a space
1354     character in a term). All terms must match on a puzzle in order
1355     for that puzzle to be included. But a puzzle will be considered to
1356     match if any of the puzzle title, round title, puzzle URL, puzzle
1357     state, puzzle type, tags, or puzzle solution match. Matching will
1358     be performed without regard to case sensitivity and the search
1359     terms can include regular expression syntax.
1360
1361     """
1362
1363     channel_id = body['channel_id'][0]
1364     response_url = body['response_url'][0]
1365
1366     # First, farm off "/hunt new" and "/hunt edit" a separate commands
1367     if args == "new":
1368         return new_hunt_command(turb, body)
1369
1370     if args == "edit":
1371         return edit_hunt_command(turb, body)
1372
1373     terms = None
1374     if args:
1375         # The first word can be a puzzle status and all remaining word
1376         # (if any) are search terms. _But_, if the first word is not a
1377         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1378         # words are search terms and we default status to 'unsolved'.
1379         split_args = args.split(' ', 1)
1380         status = split_args[0]
1381         if (len(split_args) > 1):
1382             terms = split_args[1]
1383         if status not in ('unsolved', 'solved', 'all'):
1384             terms = args
1385             status = 'unsolved'
1386     else:
1387         status = 'unsolved'
1388
1389     # Separate search terms on spaces (but allow for quotation marks
1390     # to capture spaces in a search term)
1391     if terms:
1392         terms = shlex.split(terms)
1393
1394     hunt = hunt_for_channel(turb, channel_id)
1395
1396     if not hunt:
1397         return bot_reply("Sorry, this channel doesn't appear to "
1398                          + "be a hunt or puzzle channel")
1399
1400     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1401
1402     for block in blocks:
1403         if len(block) > 100:
1404             block = block[:100]
1405         requests.post(response_url,
1406                       json = { 'blocks': block },
1407                       headers = {'Content-type': 'application/json'}
1408                       )
1409
1410     return lambda_ok
1411
1412 commands["/hunt"] = hunt
1413
1414 def round(turb, body, args):
1415     """Implementation of the /round command
1416
1417     Displays puzzles in the same round(s) as the puzzle for the
1418     current channel.
1419
1420     The (optional) args string can be used to filter which puzzles to
1421     display. The first word can be one of 'all', 'unsolved', or
1422     'solved' and can be used to display only puzzles with the given
1423     status. If this first word is missing, this command will display
1424     all puzzles in the round by default.
1425
1426     Any remaining text in the args string will be interpreted as
1427     search terms. These will be split into separate terms on space
1428     characters, (though quotation marks can be used to include a space
1429     character in a term). All terms must match on a puzzle in order
1430     for that puzzle to be included. But a puzzle will be considered to
1431     match if any of the puzzle title, round title, puzzle URL, puzzle
1432     state, or puzzle solution match. Matching will be performed
1433     without regard to case sensitivity and the search terms can
1434     include regular expression syntax.
1435     """
1436
1437     channel_id = body['channel_id'][0]
1438     response_url = body['response_url'][0]
1439
1440     puzzle = puzzle_for_channel(turb, channel_id)
1441     hunt = hunt_for_channel(turb, channel_id)
1442
1443     if not puzzle:
1444         if hunt:
1445             return bot_reply(
1446                 "This is not a puzzle channel, but is a hunt channel. "
1447                 + "Use /hunt if you want to see all rounds for this hunt.")
1448         else:
1449             return bot_reply(
1450                 "Sorry, this channel doesn't appear to be a puzzle channel "
1451                 + "so the `/round` command cannot work here.")
1452
1453     terms = None
1454     if args:
1455         # The first word can be a puzzle status and all remaining word
1456         # (if any) are search terms. _But_, if the first word is not a
1457         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1458         # words are search terms and we default status to 'unsolved'.
1459         split_args = args.split(' ', 1)
1460         status = split_args[0]
1461         if (len(split_args) > 1):
1462             terms = split_args[1]
1463         if status not in ('unsolved', 'solved', 'all'):
1464             terms = args
1465             status = 'all'
1466     else:
1467         status = 'all'
1468
1469     # Separate search terms on spaces (but allow for quotation marks
1470     # to capture spaces in a search term)
1471     if terms:
1472         terms = shlex.split(terms)
1473
1474     blocks = hunt_blocks(turb, hunt,
1475                          puzzle_status=status, search_terms=terms,
1476                          limit_to_rounds=puzzle.get('rounds', [])
1477                          )
1478
1479     for block in blocks:
1480         if len(block) > 100:
1481             block = block[:100]
1482         requests.post(response_url,
1483                       json = { 'blocks': block },
1484                       headers = {'Content-type': 'application/json'}
1485                       )
1486
1487     return lambda_ok
1488
1489 commands["/round"] = round
1490
1491 def help_command(turb, body, args):
1492     """Implementation of the /help command
1493
1494     Displays help on how to use Turbot.
1495     """
1496
1497     channel_id = body['channel_id'][0]
1498     response_url = body['response_url'][0]
1499     user_id = body['user_id'][0]
1500
1501     # Process "/help me" first. It calls out to have_you_tried rather
1502     # than going through our help system.
1503     #
1504     # Also, it reports in the current channel, (where all other help
1505     # output is reported privately to the invoking user).
1506     if args == "me":
1507         to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1508             user_id, have_you_tried())
1509
1510         # We'll try first to reply directly to the channel (for the benefit
1511         # of anyone else in the same channel that might be stuck too.
1512         #
1513         # But if this doesn't work, (direct message or private channel),
1514         # then we can instead reply with an ephemeral message by using
1515         # the response_url.
1516         try:
1517             turb.slack_client.chat_postMessage(
1518                 channel=channel_id, text=to_try)
1519         except SlackApiError:
1520             requests.post(response_url,
1521                           json = {"text": to_try},
1522                           headers = {"Content-type": "application/json"})
1523         return lambda_ok
1524
1525     help_string = turbot_help(args)
1526
1527     requests.post(response_url,
1528                   json = {"text": help_string},
1529                   headers = {"Content-type": "application/json"})
1530
1531     return lambda_ok
1532
1533 commands["/help"] = help_command