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