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