]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add tags field to the new_puzzle and edit_puzzle forms
[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     # Also inform the hunt if the puzzle's solved status changed
376     if puzzle['status'] != old_puzzle['status']:
377         hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
378         if puzzle['status'] == 'solved':
379             message = "Puzzle <{}|{}> has been solved!".format(
380                 puzzle['channel_url'],
381                 puzzle['name'])
382         else:
383             message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
384                 puzzle['channel_url'],
385                 puzzle['name'])
386         slack_send_message(turb.slack_client, hunt['channel_id'], message)
387
388     # We need to set the channel topic if any of puzzle name, url,
389     # state, status, or solution, has changed. Let's just do that
390     # unconditionally here.
391     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
392
393     return lambda_ok
394
395 def edit_hunt_command(turb, body):
396     """Implementation of the `/hunt edit` command
397
398     As dispatched from the hunt() function.
399     """
400
401     channel_id = body['channel_id'][0]
402     trigger_id = body['trigger_id'][0]
403
404     hunt = hunt_for_channel(turb, channel_id)
405
406     if not hunt:
407         return bot_reply("Sorry, this does not appear to be a hunt channel.")
408
409     return edit_hunt(turb, hunt, trigger_id)
410
411 def edit_hunt_button(turb, payload):
412     """Handler for the action of user pressing an edit_hunt button"""
413
414     hunt_id = payload['actions'][0]['action_id']
415     trigger_id = payload['trigger_id']
416
417     hunt = find_hunt_for_hunt_id(turb, hunt_id)
418
419     if not hunt:
420         return bot_reply("Error: Hunt not found.")
421
422     return edit_hunt(turb, hunt, trigger_id)
423
424 actions['button']['edit_hunt'] = edit_hunt_button
425
426 def edit_hunt(turb, hunt, trigger_id):
427     """Common code for implementing an edit hunt dialog
428
429     This implementation is common whether the edit operation was invoked
430     by a button (edit_hunt_button) or a command (edit_hunt_command).
431     """
432
433     view = {
434         "type": "modal",
435         "private_metadata": json.dumps({
436             "hunt_id": hunt["hunt_id"],
437             "SK": hunt["SK"],
438             "is_hunt": hunt["is_hunt"],
439             "channel_id": hunt["channel_id"],
440             "sheet_url": hunt["sheet_url"],
441             "folder_id": hunt["folder_id"],
442         }),
443         "title": { "type": "plain_text", "text": "Edit Hunt" },
444         "submit": { "type": "plain_text", "text": "Save" },
445         "blocks": [
446             input_block("Hunt name", "name", "Name of the hunt",
447                         initial_value=hunt["name"]),
448             input_block("Hunt URL", "url", "External URL of hunt",
449                         initial_value=hunt.get("url", None),
450                         optional=True),
451             checkbox_block("Is this hunt active?", "Active", "active",
452                            checked=(hunt.get('active', False)))
453         ]
454     }
455
456     result = turb.slack_client.views_open(trigger_id=trigger_id,
457                                           view=view)
458
459     if result['ok']:
460         submission_handlers[result['view']['id']] = edit_hunt_submission
461
462     return lambda_ok
463
464 def edit_hunt_submission(turb, payload, metadata):
465     """Handler for the user submitting the edit hunt modal
466
467     This is the modal view presented by the edit_hunt function above.
468     """
469
470     hunt={}
471
472     # First, read all the various data from the request
473     meta = json.loads(metadata)
474     hunt['hunt_id'] = meta['hunt_id']
475     hunt['SK'] = meta['SK']
476     hunt['is_hunt'] = meta['is_hunt']
477     hunt['channel_id'] = meta['channel_id']
478     hunt['sheet_url'] = meta['sheet_url']
479     hunt['folder_id'] = meta['folder_id']
480
481     state = payload['view']['state']['values']
482     user_id = payload['user']['id']
483
484     hunt['name'] = state['name']['name']['value']
485     url = state['url']['url']['value']
486     if url:
487         hunt['url'] = url
488
489     if state['active']['active']['selected_options']:
490         hunt['active'] = True
491     else:
492         hunt['active'] = False
493
494     # Update the hunt in the database
495     turb.table.put_item(Item=hunt)
496
497     # Inform the hunt channel about the edit
498     edit_message = "Hunt edited by <@{}>".format(user_id)
499     blocks = [
500         section_block(text_block(edit_message)),
501         section_block(text_block("Hunt name: {}".format(hunt['name']))),
502     ]
503
504     url = hunt.get('url', None)
505     if url:
506         blocks.append(
507             section_block(text_block("Hunt URL: {}".format(hunt['url'])))
508         )
509
510     slack_send_message(
511         turb.slack_client, hunt['channel_id'],
512         edit_message, blocks=blocks)
513
514     return lambda_ok
515
516 def new_hunt_command(turb, body):
517     """Implementation of the '/hunt new' command
518
519     As dispatched from the hunt() function.
520     """
521
522     trigger_id = body['trigger_id'][0]
523
524     return new_hunt(turb, trigger_id)
525
526 def new_hunt_button(turb, payload):
527     """Handler for the action of user pressing the new_hunt button"""
528
529     trigger_id = payload['trigger_id']
530
531     return new_hunt(turb, trigger_id)
532
533 def new_hunt(turb, trigger_id):
534     """Common code for implementing a new hunt dialog
535
536     This implementation is common whether the operations was invoked
537     by a button (new_hunt_button) or a command (new_hunt_command).
538     """
539
540     view = {
541         "type": "modal",
542         "private_metadata": json.dumps({}),
543         "title": { "type": "plain_text", "text": "New Hunt" },
544         "submit": { "type": "plain_text", "text": "Create" },
545         "blocks": [
546             input_block("Hunt name", "name", "Name of the hunt"),
547             input_block("Hunt ID", "hunt_id",
548                         "Used as puzzle channel prefix "
549                         + "(no spaces nor punctuation)"),
550             input_block("Hunt URL", "url", "External URL of hunt",
551                         optional=True)
552         ],
553     }
554
555     result = turb.slack_client.views_open(trigger_id=trigger_id,
556                                           view=view)
557     if (result['ok']):
558         submission_handlers[result['view']['id']] = new_hunt_submission
559
560     return lambda_ok
561
562 actions['button']['new_hunt'] = new_hunt_button
563
564 def new_hunt_submission(turb, payload, metadata):
565     """Handler for the user submitting the new hunt modal
566
567     This is the modal view presented to the user by the new_hunt
568     function above."""
569
570     state = payload['view']['state']['values']
571     user_id = payload['user']['id']
572     name = state['name']['name']['value']
573     hunt_id = state['hunt_id']['hunt_id']['value']
574     url = state['url']['url']['value']
575
576     # Validate that the hunt_id contains no invalid characters
577     if not re.match(valid_id_re, hunt_id):
578         return submission_error("hunt_id",
579                                 "Hunt ID can only contain lowercase letters, "
580                                 + "numbers, and underscores")
581
582     # Check to see if the turbot table exists
583     try:
584         exists = turb.table.table_status in ("CREATING", "UPDATING",
585                                              "ACTIVE")
586     except ClientError:
587         exists = False
588
589     # Create the turbot table if necessary.
590     if not exists:
591         turb.table = turb.db.create_table(
592             TableName='turbot',
593             KeySchema=[
594                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
595                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
596             ],
597             AttributeDefinitions=[
598                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
599                 {'AttributeName': 'SK', 'AttributeType': 'S'},
600                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
601                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
602                 {'AttributeName': 'url', 'AttributeType': 'S'},
603                 {'AttributeName': 'puzzle_id', 'AttributeType': 'S'}
604             ],
605             ProvisionedThroughput={
606                 'ReadCapacityUnits': 5,
607                 'WriteCapacityUnits': 5
608             },
609             GlobalSecondaryIndexes=[
610                 {
611                     'IndexName': 'channel_id_index',
612                     'KeySchema': [
613                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
614                     ],
615                     'Projection': {
616                         'ProjectionType': 'ALL'
617                     },
618                     'ProvisionedThroughput': {
619                         'ReadCapacityUnits': 5,
620                         'WriteCapacityUnits': 5
621                     }
622                 },
623                 {
624                     'IndexName': 'is_hunt_index',
625                     'KeySchema': [
626                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
627                     ],
628                     'Projection': {
629                         'ProjectionType': 'ALL'
630                     },
631                     'ProvisionedThroughput': {
632                         'ReadCapacityUnits': 5,
633                         'WriteCapacityUnits': 5
634                     }
635                 }
636             ],
637             LocalSecondaryIndexes = [
638                 {
639                     'IndexName': 'url_index',
640                     'KeySchema': [
641                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
642                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
643                     ],
644                     'Projection': {
645                         'ProjectionType': 'ALL'
646                     }
647                 },
648                 {
649                     'IndexName': 'puzzle_id_index',
650                     'KeySchema': [
651                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
652                         {'AttributeName': 'puzzle_id', 'KeyType': 'RANGE'},
653                     ],
654                     'Projection': {
655                         'ProjectionType': 'ALL'
656                     }
657                 }
658             ]
659         )
660         return submission_error(
661             "hunt_id",
662             "Still bootstrapping turbot table. Try again in a minute, please.")
663
664     # Create a channel for the hunt
665     try:
666         response = turb.slack_client.conversations_create(name=hunt_id)
667     except SlackApiError as e:
668         return submission_error("hunt_id",
669                                 "Error creating Slack channel: {}"
670                                 .format(e.response['error']))
671
672     channel_id = response['channel']['id']
673
674     # Insert the newly-created hunt into the database
675     # (leaving it as non-active for now until the channel-created handler
676     #  finishes fixing it up with a sheet and a companion table)
677     item={
678         "hunt_id": hunt_id,
679         "SK": "hunt-{}".format(hunt_id),
680         "is_hunt": hunt_id,
681         "channel_id": channel_id,
682         "active": False,
683         "name": name,
684     }
685     if url:
686         item['url'] = url
687     turb.table.put_item(Item=item)
688
689     # Invite the initiating user to the channel
690     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
691
692     return lambda_ok
693
694 def view_submission(turb, payload):
695     """Handler for Slack interactive view submission
696
697     Specifically, those that have a payload type of 'view_submission'"""
698
699     view_id = payload['view']['id']
700     metadata = payload['view']['private_metadata']
701
702     if view_id in submission_handlers:
703         return submission_handlers[view_id](turb, payload, metadata)
704
705     print("Error: Unknown view ID: {}".format(view_id))
706     return {
707         'statusCode': 400
708     }
709
710 def rot(turb, body, args):
711     """Implementation of the /rot command
712
713     The args string should be as follows:
714
715         [count|*] String to be rotated
716
717     That is, the first word of the string is an optional number (or
718     the character '*'). If this is a number it indicates an amount to
719     rotate each character in the string. If the count is '*' or is not
720     present, then the string will be rotated through all possible 25
721     values.
722
723     The result of the rotation is returned (with Slack formatting) in
724     the body of the response so that Slack will provide it as a reply
725     to the user who submitted the slash command."""
726
727     channel_name = body['channel_name'][0]
728     response_url = body['response_url'][0]
729     channel_id = body['channel_id'][0]
730
731     result = turbot.rot.rot(args)
732
733     if (channel_name == "directmessage"):
734         requests.post(response_url,
735                       json = {"text": result},
736                       headers = {"Content-type": "application/json"})
737     else:
738         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
739
740     return lambda_ok
741
742 commands["/rot"] = rot
743
744 def get_table_item(turb, table_name, key, value):
745     """Get an item from the database 'table_name' with 'key' as 'value'
746
747     Returns a tuple of (item, table) if found and (None, None) otherwise."""
748
749     table = turb.db.Table(table_name)
750
751     response = table.get_item(Key={key: value})
752
753     if 'Item' in response:
754         return (response['Item'], table)
755     else:
756         return (None, None)
757
758 def db_entry_for_channel(turb, channel_id):
759     """Given a channel ID return the database item for this channel
760
761     If this channel is a registered hunt or puzzle channel, return the
762     corresponding row from the database for this channel. Otherwise,
763     return None.
764
765     Note: If you need to specifically ensure that the channel is a
766     puzzle or a hunt, please call puzzle_for_channel or
767     hunt_for_channel respectively.
768     """
769
770     response = turb.table.query(
771         IndexName = "channel_id_index",
772         KeyConditionExpression=Key("channel_id").eq(channel_id)
773     )
774
775     if response['Count'] == 0:
776         return None
777
778     return response['Items'][0]
779
780
781 def puzzle_for_channel(turb, channel_id):
782
783     """Given a channel ID return the puzzle from the database for this channel
784
785     If the given channel_id is a puzzle's channel, this function
786     returns a dict filled with the attributes from the puzzle's entry
787     in the database.
788
789     Otherwise, this function returns None.
790     """
791
792     entry = db_entry_for_channel(turb, channel_id)
793
794     if entry and entry['SK'].startswith('puzzle-'):
795         return entry
796     else:
797         return None
798
799 def hunt_for_channel(turb, channel_id):
800
801     """Given a channel ID return the hunt from the database for this channel
802
803     This works whether the original channel is a primary hunt channel,
804     or if it is one of the channels of a puzzle belonging to the hunt.
805
806     Returns None if channel does not belong to a hunt, otherwise a
807     dictionary with all fields from the hunt's row in the table,
808     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
809     """
810
811     entry = db_entry_for_channel(turb, channel_id)
812
813     # We're done if this channel doesn't exist in the database at all
814     if not entry:
815         return None
816
817     # Also done if this channel is a hunt channel
818     if entry['SK'].startswith('hunt-'):
819         return entry
820
821     # Otherwise, (the channel is in the database, but is not a hunt),
822     # we expect this to be a puzzle channel instead
823     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
824
825 # python3.9 has a built-in removeprefix but AWS only has python3.8
826 def remove_prefix(text, prefix):
827     if text.startswith(prefix):
828         return text[len(prefix):]
829     return text
830
831 def hunt_rounds(turb, hunt_id):
832     """Returns array of strings giving rounds that exist in the given hunt"""
833
834     response = turb.table.query(
835         KeyConditionExpression=(
836             Key('hunt_id').eq(hunt_id) &
837             Key('SK').begins_with('round-')
838         )
839     )
840
841     if response['Count'] == 0:
842         return []
843
844     return [remove_prefix(option['SK'], 'round-')
845             for option in response['Items']]
846
847 def puzzle(turb, body, args):
848     """Implementation of the /puzzle command
849
850     The args string can be a sub-command:
851
852         /puzzle new: Bring up a dialog to create a new puzzle
853
854         /puzzle edit: Edit the puzzle for the current channel
855
856     Or with no argument at all:
857
858         /puzzle: Print details of the current puzzle (if in a puzzle channel)
859     """
860
861     if args == 'new':
862         return new_puzzle(turb, body)
863
864     if args == 'edit':
865         return edit_puzzle_command(turb, body)
866
867     if len(args):
868         return bot_reply("Unknown syntax for `/puzzle` command. " +
869                          "Valid commands are: `/puzzle`, `/puzzle edit`, " +
870                          "and `/puzzle new` to display, edit, or create " +
871                          "a puzzle.")
872
873     # For no arguments we print the current puzzle as a reply
874     channel_id = body['channel_id'][0]
875     response_url = body['response_url'][0]
876
877     puzzle = puzzle_for_channel(turb, channel_id)
878
879     if not puzzle:
880         hunt = hunt_for_channel(turb, channel_id)
881         if hunt:
882             return bot_reply(
883                 "This is not a puzzle channel, but is a hunt channel. "
884                 + "If you want to create a new puzzle for this hunt, use "
885                 + "`/puzzle new`.")
886         else:
887             return bot_reply(
888                 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
889                 + "channel, so the `/puzzle` command cannot work here.")
890
891     blocks = puzzle_blocks(puzzle, include_rounds=True)
892
893     # For a meta puzzle, also display the titles and solutions for all
894     # puzzles in the same round.
895     if puzzle.get('type', 'plain') == 'meta':
896         puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
897
898         # Drop this puzzle itself from the report
899         puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
900
901         for round in puzzle.get('rounds', [None]):
902             answers = round_quoted_puzzles_titles_answers(round, puzzles)
903             blocks += [
904                 section_block(text_block(
905                     "*Feeder solutions from round {}*".format(
906                         round if round else "<none>"
907                     ))),
908                 section_block(text_block(answers))
909             ]
910
911     requests.post(response_url,
912                   json = {'blocks': blocks},
913                   headers = {'Content-type': 'application/json'}
914                   )
915
916     return lambda_ok
917
918 commands["/puzzle"] = puzzle
919
920 def new(turb, body, args):
921     """Implementation of the `/new` command
922
923     This can be used to create a new hunt ("/new hunt") or a new
924     puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
925     default behavior (as it is much more common).
926
927     This operations are identical to the existing "/hunt new" and
928     "/puzzle new". I don't know that that redundancy is actually
929     helpful in the interface. But at least having both allows us to
930     experiment and decide which is more natural and should be kept
931     around long-term.
932     """
933
934     if args == 'hunt':
935         return new_hunt_command(turb, body)
936
937     return new_puzzle(turb, body)
938
939 commands["/new"] = new
940
941 def new_puzzle(turb, body):
942     """Implementation of the "/puzzle new" command
943
944     This brings up a dialog box for creating a new puzzle.
945     """
946
947     channel_id = body['channel_id'][0]
948     trigger_id = body['trigger_id'][0]
949
950     hunt = hunt_for_channel(turb, channel_id)
951
952     if not hunt:
953         return bot_reply("Sorry, this channel doesn't appear to "
954                          + "be a hunt or puzzle channel")
955
956     # We used puzzle (if available) to select the initial round(s)
957     puzzle = puzzle_for_channel(turb, channel_id)
958     initial_rounds = None
959     if puzzle:
960         initial_rounds=puzzle.get("rounds", None)
961
962     round_options = hunt_rounds(turb, hunt['hunt_id'])
963
964     if len(round_options):
965         round_options_block = [
966             multi_select_block("Round(s)", "rounds",
967                                "Existing round(s) this puzzle belongs to",
968                                round_options,
969                                initial_options=initial_rounds)
970         ]
971     else:
972         round_options_block = []
973
974     view = {
975         "type": "modal",
976         "private_metadata": json.dumps({
977             "hunt_id": hunt['hunt_id'],
978         }),
979         "title": {"type": "plain_text", "text": "New Puzzle"},
980         "submit": { "type": "plain_text", "text": "Create" },
981         "blocks": [
982             section_block(text_block("*For {}*".format(hunt['name']))),
983             input_block("Puzzle name", "name", "Name of the puzzle"),
984             input_block("Puzzle URL", "url", "External URL of puzzle",
985                         optional=True),
986             checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
987             * round_options_block,
988             input_block("New round(s)", "new_rounds",
989                         "New round(s) this puzzle belongs to " +
990                         "(comma separated)",
991                         optional=True),
992             input_block("Tag(s)", "tags",
993                         "Tags for this puzzle (comma separated)",
994                         optional=True),
995         ]
996     }
997
998     result = turb.slack_client.views_open(trigger_id=trigger_id,
999                                           view=view)
1000
1001     if (result['ok']):
1002         submission_handlers[result['view']['id']] = new_puzzle_submission
1003
1004     return lambda_ok
1005
1006 def new_puzzle_submission(turb, payload, metadata):
1007     """Handler for the user submitting the new puzzle modal
1008
1009     This is the modal view presented to the user by the new_puzzle
1010     function above.
1011     """
1012
1013     # First, read all the various data from the request
1014     meta = json.loads(metadata)
1015     hunt_id = meta['hunt_id']
1016
1017     state = payload['view']['state']['values']
1018
1019     # And start loading data into a puzzle dict
1020     puzzle = {}
1021     puzzle['hunt_id'] = hunt_id
1022     puzzle['name'] = state['name']['name']['value']
1023     url = state['url']['url']['value']
1024     if url:
1025         puzzle['url'] = url
1026     if state['meta']['meta']['selected_options']:
1027         puzzle['type'] = 'meta'
1028     else:
1029         puzzle['type'] = 'plain'
1030     if 'rounds' in state:
1031         rounds = [option['value'] for option in
1032                   state['rounds']['rounds']['selected_options']]
1033     else:
1034         rounds = []
1035     new_rounds = state['new_rounds']['new_rounds']['value']
1036     tags = state['tags']['tags']['value']
1037
1038     # Create a Slack-channel-safe puzzle_id
1039     puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1040
1041     # Before doing anything, reject this puzzle if a puzzle already
1042     # exists with the same puzzle_id or url
1043     existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1044     if existing:
1045         return submission_error(
1046             "name",
1047             "Error: This name collides with an existing puzzle.")
1048
1049     if url:
1050         existing = find_puzzle_for_url(turb, hunt_id, url)
1051         if existing:
1052             return submission_error(
1053                 "url",
1054                 "Error: A puzzle with this URL already exists.")
1055
1056     # Add any new rounds to the database
1057     if new_rounds:
1058         for round in new_rounds.split(','):
1059             # Drop any leading/trailing spaces from the round name
1060             round = round.strip()
1061             # Ignore any empty string
1062             if not len(round):
1063                 continue
1064             rounds.append(round)
1065             turb.table.put_item(
1066                 Item={
1067                     'hunt_id': hunt_id,
1068                     'SK': 'round-' + round
1069                 }
1070             )
1071
1072     # Process any tags
1073     puzzle['tags'] = []
1074     if tags:
1075         for tag in tags.split(','):
1076             # Drop any leading/trailing spaces from the tag
1077             tag = tag.strip().upper()
1078             # Ignore any empty string
1079             if not len(tag):
1080                 continue
1081             # Reject a tag that is not alphabetic or underscore A-Z_
1082             if not re.match(r'^[A-Z0-9_]*$', tag):
1083                 return submission_error(
1084                     "tags",
1085                     "Error: Tags can only contain letters, numbers, "
1086                     + "and the underscore character."
1087                 )
1088             puzzle['tags'].append(tag)
1089
1090     if rounds:
1091         puzzle['rounds'] = rounds
1092
1093     puzzle['solution'] = []
1094     puzzle['status'] = 'unsolved'
1095
1096     # Create a channel for the puzzle
1097     channel_name = puzzle_channel_name(puzzle)
1098
1099     try:
1100         response = turb.slack_client.conversations_create(
1101             name=channel_name)
1102     except SlackApiError as e:
1103         return submission_error(
1104             "name",
1105             "Error creating Slack channel {}: {}"
1106             .format(channel_name, e.response['error']))
1107
1108     puzzle['channel_id'] = response['channel']['id']
1109
1110     # Finally, compute the appropriate sort key
1111     puzzle["SK"] = puzzle_sort_key(puzzle)
1112
1113     # Insert the newly-created puzzle into the database
1114     turb.table.put_item(Item=puzzle)
1115
1116     return lambda_ok
1117
1118 def state(turb, body, args):
1119     """Implementation of the /state command
1120
1121     The args string should be a brief sentence describing where things
1122     stand or what's needed."""
1123
1124     channel_id = body['channel_id'][0]
1125
1126     old_puzzle = puzzle_for_channel(turb, channel_id)
1127
1128     if not old_puzzle:
1129         return bot_reply(
1130             "Sorry, the /state command only works in a puzzle channel")
1131
1132     # Make a deep copy of the puzzle object
1133     puzzle = puzzle_copy(old_puzzle)
1134
1135     # Update the puzzle in the database
1136     puzzle['state'] = args
1137     turb.table.put_item(Item=puzzle)
1138
1139     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1140
1141     return lambda_ok
1142
1143 commands["/state"] = state
1144
1145 def tag(turb, body, args):
1146     """Implementation of the `/tag` command.
1147
1148     Arg is either a tag to add (optionally prefixed with '+'), or if
1149     prefixed with '-' is a tag to remove.
1150     """
1151
1152     if not args:
1153         return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1154                          + "or `/tag -TAG_TO_REMOVE`.")
1155
1156     channel_id = body['channel_id'][0]
1157
1158     old_puzzle = puzzle_for_channel(turb, channel_id)
1159
1160     if not old_puzzle:
1161         return bot_reply(
1162             "Sorry, the /tag command only works in a puzzle channel")
1163
1164     if args[0] == '-':
1165         tag = args[1:]
1166         action = 'remove'
1167     else:
1168         tag = args
1169         if tag[0] == '+':
1170             tag = tag[1:]
1171         action = 'add'
1172
1173     # Force tag to all uppercase
1174     tag = tag.upper()
1175
1176     # Reject a tag that is not alphabetic or underscore A-Z_
1177     if not re.match(r'^[A-Z0-9_]*$', tag):
1178         return bot_reply("Sorry, tags can only contain letters, numbers, "
1179                          + "and the underscore character.")
1180
1181     if action == 'remove':
1182         if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1183             return bot_reply("Nothing to do. This puzzle is not tagged "
1184                              + "with the tag: {}".format(tag))
1185     else:
1186         if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1187             return bot_reply("Nothing to do. This puzzle is already tagged "
1188                              + "with the tag: {}".format(tag))
1189
1190     # OK. Error checking is done. Let's get to work
1191
1192     # Make a deep copy of the puzzle object
1193     puzzle = puzzle_copy(old_puzzle)
1194
1195     if action == 'remove':
1196         puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1197     else:
1198         if 'tags' not in puzzle:
1199             puzzle['tags'] = [tag]
1200         else:
1201             puzzle['tags'].append(tag)
1202
1203     turb.table.put_item(Item=puzzle)
1204
1205     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1206
1207     return lambda_ok
1208
1209 commands["/tag"] = tag
1210
1211 def solved(turb, body, args):
1212     """Implementation of the /solved command
1213
1214     The args string should be a confirmed solution."""
1215
1216     channel_id = body['channel_id'][0]
1217     user_id = body['user_id'][0]
1218
1219     old_puzzle = puzzle_for_channel(turb, channel_id)
1220
1221     if not old_puzzle:
1222         return bot_reply("Sorry, this is not a puzzle channel.")
1223
1224     if not args:
1225         return bot_reply(
1226             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1227
1228     # Make a deep copy of the puzzle object
1229     puzzle = puzzle_copy(old_puzzle)
1230
1231     # Set the status and solution fields in the database
1232     puzzle['status'] = 'solved'
1233
1234     # Don't append a duplicate solution
1235     if args not in puzzle['solution']:
1236         puzzle['solution'].append(args)
1237     if 'state' in puzzle:
1238         del puzzle['state']
1239     turb.table.put_item(Item=puzzle)
1240
1241     # Report the solution to the puzzle's channel
1242     slack_send_message(
1243         turb.slack_client, channel_id,
1244         "Puzzle marked solved by <@{}>: `{}`".format(user_id, args))
1245
1246     # Also report the solution to the hunt channel
1247     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1248     slack_send_message(
1249         turb.slack_client, hunt['channel_id'],
1250         "Puzzle <{}|{}> has been solved!".format(
1251             puzzle['channel_url'],
1252             puzzle['name'])
1253     )
1254
1255     # And update the puzzle's description
1256     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1257
1258     return lambda_ok
1259
1260 commands["/solved"] = solved
1261
1262 def delete(turb, body, args):
1263     """Implementation of the /delete command
1264
1265     The argument to this command is the ID of a hunt.
1266
1267     The command will report an error if the specified hunt is active.
1268
1269     If the hunt is inactive, this command will archive all channels
1270     from the hunt.
1271     """
1272
1273     if not args:
1274         return bot_reply("Error, no hunt provided. Usage: `/delete HUNT_ID`")
1275
1276     hunt_id = args
1277     hunt = find_hunt_for_hunt_id(turb, hunt_id)
1278
1279     if not hunt:
1280         return bot_reply("Error, no hunt named \"{}\" exists.".format(hunt_id))
1281
1282     if hunt['active']:
1283         return bot_reply(
1284             "Error, refusing to delete active hunt \"{}\".".format(hunt_id)
1285         )
1286
1287     if hunt['hunt_id'] != hunt_id:
1288         return bot_reply(
1289             "Error, expected hunt ID of \"{}\" but found \"{}\".".format(
1290                 hunt_id, hunt['hunt_id']
1291             )
1292         )
1293
1294     puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
1295
1296     for puzzle in puzzles:
1297         channel_id = puzzle['channel_id']
1298         turb.slack_client.conversations_archive(channel=channel_id)
1299
1300     turb.slack_client.conversations_archive(channel=hunt['channel_id'])
1301
1302     return lambda_ok
1303
1304 commands["/delete"] = delete
1305
1306 def hunt(turb, body, args):
1307     """Implementation of the /hunt command
1308
1309     The (optional) args string can be used to filter which puzzles to
1310     display. The first word can be one of 'all', 'unsolved', or
1311     'solved' and can be used to display only puzzles with the given
1312     status. If this first word is missing, this command will display
1313     only unsolved puzzles by default.
1314
1315     Any remaining text in the args string will be interpreted as
1316     search terms. These will be split into separate terms on space
1317     characters, (though quotation marks can be used to include a space
1318     character in a term). All terms must match on a puzzle in order
1319     for that puzzle to be included. But a puzzle will be considered to
1320     match if any of the puzzle title, round title, puzzle URL, puzzle
1321     state, puzzle type, tags, or puzzle solution match. Matching will
1322     be performed without regard to case sensitivity and the search
1323     terms can include regular expression syntax.
1324
1325     """
1326
1327     channel_id = body['channel_id'][0]
1328     response_url = body['response_url'][0]
1329
1330     # First, farm off "/hunt new" and "/hunt edit" a separate commands
1331     if args == "new":
1332         return new_hunt_command(turb, body)
1333
1334     if args == "edit":
1335         return edit_hunt_command(turb, body)
1336
1337     terms = None
1338     if args:
1339         # The first word can be a puzzle status and all remaining word
1340         # (if any) are search terms. _But_, if the first word is not a
1341         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1342         # words are search terms and we default status to 'unsolved'.
1343         split_args = args.split(' ', 1)
1344         status = split_args[0]
1345         if (len(split_args) > 1):
1346             terms = split_args[1]
1347         if status not in ('unsolved', 'solved', 'all'):
1348             terms = args
1349             status = 'unsolved'
1350     else:
1351         status = 'unsolved'
1352
1353     # Separate search terms on spaces (but allow for quotation marks
1354     # to capture spaces in a search term)
1355     if terms:
1356         terms = shlex.split(terms)
1357
1358     hunt = hunt_for_channel(turb, channel_id)
1359
1360     if not hunt:
1361         return bot_reply("Sorry, this channel doesn't appear to "
1362                          + "be a hunt or puzzle channel")
1363
1364     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1365
1366     for block in blocks:
1367         if len(block) > 100:
1368             block = block[:100]
1369         requests.post(response_url,
1370                       json = { 'blocks': block },
1371                       headers = {'Content-type': 'application/json'}
1372                       )
1373
1374     return lambda_ok
1375
1376 commands["/hunt"] = hunt
1377
1378 def round(turb, body, args):
1379     """Implementation of the /round command
1380
1381     Displays puzzles in the same round(s) as the puzzle for the
1382     current channel.
1383
1384     The (optional) args string can be used to filter which puzzles to
1385     display. The first word can be one of 'all', 'unsolved', or
1386     'solved' and can be used to display only puzzles with the given
1387     status. If this first word is missing, this command will display
1388     all puzzles in the round by default.
1389
1390     Any remaining text in the args string will be interpreted as
1391     search terms. These will be split into separate terms on space
1392     characters, (though quotation marks can be used to include a space
1393     character in a term). All terms must match on a puzzle in order
1394     for that puzzle to be included. But a puzzle will be considered to
1395     match if any of the puzzle title, round title, puzzle URL, puzzle
1396     state, or puzzle solution match. Matching will be performed
1397     without regard to case sensitivity and the search terms can
1398     include regular expression syntax.
1399     """
1400
1401     channel_id = body['channel_id'][0]
1402     response_url = body['response_url'][0]
1403
1404     puzzle = puzzle_for_channel(turb, channel_id)
1405     hunt = hunt_for_channel(turb, channel_id)
1406
1407     if not puzzle:
1408         if hunt:
1409             return bot_reply(
1410                 "This is not a puzzle channel, but is a hunt channel. "
1411                 + "Use /hunt if you want to see all rounds for this hunt.")
1412         else:
1413             return bot_reply(
1414                 "Sorry, this channel doesn't appear to be a puzzle channel "
1415                 + "so the `/round` command cannot work here.")
1416
1417     terms = None
1418     if args:
1419         # The first word can be a puzzle status and all remaining word
1420         # (if any) are search terms. _But_, if the first word is not a
1421         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1422         # words are search terms and we default status to 'unsolved'.
1423         split_args = args.split(' ', 1)
1424         status = split_args[0]
1425         if (len(split_args) > 1):
1426             terms = split_args[1]
1427         if status not in ('unsolved', 'solved', 'all'):
1428             terms = args
1429             status = 'all'
1430     else:
1431         status = 'all'
1432
1433     # Separate search terms on spaces (but allow for quotation marks
1434     # to capture spaces in a search term)
1435     if terms:
1436         terms = shlex.split(terms)
1437
1438     blocks = hunt_blocks(turb, hunt,
1439                          puzzle_status=status, search_terms=terms,
1440                          limit_to_rounds=puzzle.get('rounds', [])
1441                          )
1442
1443     for block in blocks:
1444         if len(block) > 100:
1445             block = block[:100]
1446         requests.post(response_url,
1447                       json = { 'blocks': block },
1448                       headers = {'Content-type': 'application/json'}
1449                       )
1450
1451     return lambda_ok
1452
1453 commands["/round"] = round
1454
1455 def help_command(turb, body, args):
1456     """Implementation of the /help command
1457
1458     Displays help on how to use Turbot.
1459     """
1460
1461     channel_id = body['channel_id'][0]
1462     response_url = body['response_url'][0]
1463     user_id = body['user_id'][0]
1464
1465     # Process "/help me" first. It calls out to have_you_tried rather
1466     # than going through our help system.
1467     #
1468     # Also, it reports in the current channel, (where all other help
1469     # output is reported privately to the invoking user).
1470     if args == "me":
1471         to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1472             user_id, have_you_tried())
1473
1474         # We'll try first to reply directly to the channel (for the benefit
1475         # of anyone else in the same channel that might be stuck too.
1476         #
1477         # But if this doesn't work, (direct message or private channel),
1478         # then we can instead reply with an ephemeral message by using
1479         # the response_url.
1480         try:
1481             turb.slack_client.chat_postMessage(
1482                 channel=channel_id, text=to_try)
1483         except SlackApiError:
1484             requests.post(response_url,
1485                           json = {"text": to_try},
1486                           headers = {"Content-type": "application/json"})
1487         return lambda_ok
1488
1489     help_string = turbot_help(args)
1490
1491     requests.post(response_url,
1492                   json = {"text": help_string},
1493                   headers = {"Content-type": "application/json"})
1494
1495     return lambda_ok
1496
1497 commands["/help"] = help_command