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