]> git.cworth.org Git - turbot/blob - turbot/interaction.py
507da76741730f2944962b10306a1de42c2696d6
[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     round_options = hunt_rounds(turb, hunt['hunt_id'])
932
933     if len(round_options):
934         round_options_block = [
935             multi_select_block("Round(s)", "rounds",
936                                "Existing round(s) this puzzle belongs to",
937                                round_options)
938         ]
939     else:
940         round_options_block = []
941
942     view = {
943         "type": "modal",
944         "private_metadata": json.dumps({
945             "hunt_id": hunt['hunt_id'],
946         }),
947         "title": {"type": "plain_text", "text": "New Puzzle"},
948         "submit": { "type": "plain_text", "text": "Create" },
949         "blocks": [
950             section_block(text_block("*For {}*".format(hunt['name']))),
951             input_block("Puzzle name", "name", "Name of the puzzle"),
952             input_block("Puzzle URL", "url", "External URL of puzzle",
953                         optional=True),
954             checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
955             * round_options_block,
956             input_block("New round(s)", "new_rounds",
957                         "New round(s) this puzzle belongs to " +
958                         "(comma separated)",
959                         optional=True)
960         ]
961     }
962
963     result = turb.slack_client.views_open(trigger_id=trigger_id,
964                                           view=view)
965
966     if (result['ok']):
967         submission_handlers[result['view']['id']] = new_puzzle_submission
968
969     return lambda_ok
970
971 def new_puzzle_submission(turb, payload, metadata):
972     """Handler for the user submitting the new puzzle modal
973
974     This is the modal view presented to the user by the new_puzzle
975     function above.
976     """
977
978     # First, read all the various data from the request
979     meta = json.loads(metadata)
980     hunt_id = meta['hunt_id']
981
982     state = payload['view']['state']['values']
983
984     # And start loading data into a puzzle dict
985     puzzle = {}
986     puzzle['hunt_id'] = hunt_id
987     puzzle['name'] = state['name']['name']['value']
988     url = state['url']['url']['value']
989     if url:
990         puzzle['url'] = url
991     if state['meta']['meta']['selected_options']:
992         puzzle['type'] = 'meta'
993     else:
994         puzzle['type'] = 'plain'
995     if 'rounds' in state:
996         rounds = [option['value'] for option in
997                   state['rounds']['rounds']['selected_options']]
998     else:
999         rounds = []
1000     new_rounds = state['new_rounds']['new_rounds']['value']
1001
1002     # Create a Slack-channel-safe puzzle_id
1003     puzzle['puzzle_id'] = puzzle_id_from_name(puzzle['name'])
1004
1005     # Before doing anything, reject this puzzle if a puzzle already
1006     # exists with the same puzzle_id or url
1007     existing = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle['puzzle_id'])
1008     if existing:
1009         return submission_error(
1010             "name",
1011             "Error: This name collides with an existing puzzle.")
1012
1013     if url:
1014         existing = find_puzzle_for_url(turb, hunt_id, url)
1015         if existing:
1016             return submission_error(
1017                 "url",
1018                 "Error: A puzzle with this URL already exists.")
1019
1020     # Add any new rounds to the database
1021     if new_rounds:
1022         for round in new_rounds.split(','):
1023             # Drop any leading/trailing spaces from the round name
1024             round = round.strip()
1025             # Ignore any empty string
1026             if not len(round):
1027                 continue
1028             rounds.append(round)
1029             turb.table.put_item(
1030                 Item={
1031                     'hunt_id': hunt_id,
1032                     'SK': 'round-' + round
1033                 }
1034             )
1035
1036     if rounds:
1037         puzzle['rounds'] = rounds
1038
1039     puzzle['solution'] = []
1040     puzzle['status'] = 'unsolved'
1041
1042     # Create a channel for the puzzle
1043     channel_name = puzzle_channel_name(puzzle)
1044
1045     try:
1046         response = turb.slack_client.conversations_create(
1047             name=channel_name)
1048     except SlackApiError as e:
1049         return submission_error(
1050             "name",
1051             "Error creating Slack channel {}: {}"
1052             .format(channel_name, e.response['error']))
1053
1054     puzzle['channel_id'] = response['channel']['id']
1055
1056     # Finally, compute the appropriate sort key
1057     puzzle["SK"] = puzzle_sort_key(puzzle)
1058
1059     # Insert the newly-created puzzle into the database
1060     turb.table.put_item(Item=puzzle)
1061
1062     return lambda_ok
1063
1064 def state(turb, body, args):
1065     """Implementation of the /state command
1066
1067     The args string should be a brief sentence describing where things
1068     stand or what's needed."""
1069
1070     channel_id = body['channel_id'][0]
1071
1072     old_puzzle = puzzle_for_channel(turb, channel_id)
1073
1074     if not old_puzzle:
1075         return bot_reply(
1076             "Sorry, the /state command only works in a puzzle channel")
1077
1078     # Make a deep copy of the puzzle object
1079     puzzle = puzzle_copy(old_puzzle)
1080
1081     # Update the puzzle in the database
1082     puzzle['state'] = args
1083     turb.table.put_item(Item=puzzle)
1084
1085     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1086
1087     return lambda_ok
1088
1089 commands["/state"] = state
1090
1091 def tag(turb, body, args):
1092     """Implementation of the `/tag` command.
1093
1094     Arg is either a tag to add (optionally prefixed with '+'), or if
1095     prefixed with '-' is a tag to remove.
1096     """
1097
1098     if not args:
1099         return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
1100                          + "or `/tag -TAG_TO_REMOVE`.")
1101
1102     channel_id = body['channel_id'][0]
1103
1104     old_puzzle = puzzle_for_channel(turb, channel_id)
1105
1106     if not old_puzzle:
1107         return bot_reply(
1108             "Sorry, the /tag command only works in a puzzle channel")
1109
1110     if args[0] == '-':
1111         tag = args[1:]
1112         action = 'remove'
1113     else:
1114         tag = args
1115         if tag[0] == '+':
1116             tag = tag[1:]
1117         action = 'add'
1118
1119     # Force tag to all uppercase
1120     tag = tag.upper()
1121
1122     # Reject a tag that is not alphabetic or underscore A-Z_
1123     if not re.match(r'^[A-Z0-9_]*$', tag):
1124         return bot_reply("Sorry, tags can only contain letters, numbers, "
1125                          + "and the underscore character.")
1126
1127     if action == 'remove':
1128         if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
1129             return bot_reply("Nothing to do. This puzzle is not tagged "
1130                              + "with the tag: {}".format(tag))
1131     else:
1132         if 'tags' in old_puzzle and tag in old_puzzle['tags']:
1133             return bot_reply("Nothing to do. This puzzle is already tagged "
1134                              + "with the tag: {}".format(tag))
1135
1136     # OK. Error checking is done. Let's get to work
1137
1138     # Make a deep copy of the puzzle object
1139     puzzle = puzzle_copy(old_puzzle)
1140
1141     if action == 'remove':
1142         puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
1143     else:
1144         if 'tags' not in puzzle:
1145             puzzle['tags'] = [tag]
1146         else:
1147             puzzle['tags'].append(tag)
1148
1149     turb.table.put_item(Item=puzzle)
1150
1151     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1152
1153     return lambda_ok
1154
1155 commands["/tag"] = tag
1156
1157 def solved(turb, body, args):
1158     """Implementation of the /solved command
1159
1160     The args string should be a confirmed solution."""
1161
1162     channel_id = body['channel_id'][0]
1163     user_id = body['user_id'][0]
1164
1165     old_puzzle = puzzle_for_channel(turb, channel_id)
1166
1167     if not old_puzzle:
1168         return bot_reply("Sorry, this is not a puzzle channel.")
1169
1170     if not args:
1171         return bot_reply(
1172             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1173
1174     # Make a deep copy of the puzzle object
1175     puzzle = puzzle_copy(old_puzzle)
1176
1177     # Set the status and solution fields in the database
1178     puzzle['status'] = 'solved'
1179     puzzle['solution'].append(args)
1180     if 'state' in puzzle:
1181         del puzzle['state']
1182     turb.table.put_item(Item=puzzle)
1183
1184     # Report the solution to the puzzle's channel
1185     slack_send_message(
1186         turb.slack_client, channel_id,
1187         "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1188
1189     # Also report the solution to the hunt channel
1190     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1191     slack_send_message(
1192         turb.slack_client, hunt['channel_id'],
1193         "Puzzle <{}|{}> has been solved!".format(
1194             puzzle['channel_url'],
1195             puzzle['name'])
1196     )
1197
1198     # And update the puzzle's description
1199     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1200
1201     return lambda_ok
1202
1203 commands["/solved"] = solved
1204
1205 def hunt(turb, body, args):
1206     """Implementation of the /hunt command
1207
1208     The (optional) args string can be used to filter which puzzles to
1209     display. The first word can be one of 'all', 'unsolved', or
1210     'solved' and can be used to display only puzzles with the given
1211     status. If this first word is missing, this command will display
1212     only unsolved puzzles by default.
1213
1214     Any remaining text in the args string will be interpreted as
1215     search terms. These will be split into separate terms on space
1216     characters, (though quotation marks can be used to include a space
1217     character in a term). All terms must match on a puzzle in order
1218     for that puzzle to be included. But a puzzle will be considered to
1219     match if any of the puzzle title, round title, puzzle URL, puzzle
1220     state, puzzle type, tags, or puzzle solution match. Matching will
1221     be performed without regard to case sensitivity and the search
1222     terms can include regular expression syntax.
1223
1224     """
1225
1226     channel_id = body['channel_id'][0]
1227     response_url = body['response_url'][0]
1228
1229     # First, farm off "/hunt new" and "/hunt edit" a separate commands
1230     if args == "new":
1231         return new_hunt_command(turb, body)
1232
1233     if args == "edit":
1234         return edit_hunt_command(turb, body)
1235
1236     terms = None
1237     if args:
1238         # The first word can be a puzzle status and all remaining word
1239         # (if any) are search terms. _But_, if the first word is not a
1240         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1241         # words are search terms and we default status to 'unsolved'.
1242         split_args = args.split(' ', 1)
1243         status = split_args[0]
1244         if (len(split_args) > 1):
1245             terms = split_args[1]
1246         if status not in ('unsolved', 'solved', 'all'):
1247             terms = args
1248             status = 'unsolved'
1249     else:
1250         status = 'unsolved'
1251
1252     # Separate search terms on spaces (but allow for quotation marks
1253     # to capture spaces in a search term)
1254     if terms:
1255         terms = shlex.split(terms)
1256
1257     hunt = hunt_for_channel(turb, channel_id)
1258
1259     if not hunt:
1260         return bot_reply("Sorry, this channel doesn't appear to "
1261                          + "be a hunt or puzzle channel")
1262
1263     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1264
1265     requests.post(response_url,
1266                   json = { 'blocks': blocks },
1267                   headers = {'Content-type': 'application/json'}
1268                   )
1269
1270     return lambda_ok
1271
1272 commands["/hunt"] = hunt
1273
1274 def round(turb, body, args):
1275     """Implementation of the /round command
1276
1277     Displays puzzles in the same round(s) as the puzzle for the
1278     current channel.
1279
1280     The (optional) args string can be used to filter which puzzles to
1281     display. The first word can be one of 'all', 'unsolved', or
1282     'solved' and can be used to display only puzzles with the given
1283     status. If this first word is missing, this command will display
1284     all puzzles in the round by default.
1285
1286     Any remaining text in the args string will be interpreted as
1287     search terms. These will be split into separate terms on space
1288     characters, (though quotation marks can be used to include a space
1289     character in a term). All terms must match on a puzzle in order
1290     for that puzzle to be included. But a puzzle will be considered to
1291     match if any of the puzzle title, round title, puzzle URL, puzzle
1292     state, or puzzle solution match. Matching will be performed
1293     without regard to case sensitivity and the search terms can
1294     include regular expression syntax.
1295     """
1296
1297     channel_id = body['channel_id'][0]
1298     response_url = body['response_url'][0]
1299
1300     puzzle = puzzle_for_channel(turb, channel_id)
1301     hunt = hunt_for_channel(turb, channel_id)
1302
1303     if not puzzle:
1304         if hunt:
1305             return bot_reply(
1306                 "This is not a puzzle channel, but is a hunt channel. "
1307                 + "Use /hunt if you want to see all rounds for this hunt.")
1308         else:
1309             return bot_reply(
1310                 "Sorry, this channel doesn't appear to be a puzzle channel "
1311                 + "so the `/round` command cannot work here.")
1312
1313     terms = None
1314     if args:
1315         # The first word can be a puzzle status and all remaining word
1316         # (if any) are search terms. _But_, if the first word is not a
1317         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1318         # words are search terms and we default status to 'unsolved'.
1319         split_args = args.split(' ', 1)
1320         status = split_args[0]
1321         if (len(split_args) > 1):
1322             terms = split_args[1]
1323         if status not in ('unsolved', 'solved', 'all'):
1324             terms = args
1325             status = 'all'
1326     else:
1327         status = 'all'
1328
1329     # Separate search terms on spaces (but allow for quotation marks
1330     # to capture spaces in a search term)
1331     if terms:
1332         terms = shlex.split(terms)
1333
1334     blocks = hunt_blocks(turb, hunt,
1335                          puzzle_status=status, search_terms=terms,
1336                          limit_to_rounds=puzzle.get('rounds', [])
1337                          )
1338
1339     requests.post(response_url,
1340                   json = { 'blocks': blocks },
1341                   headers = {'Content-type': 'application/json'}
1342                   )
1343
1344     return lambda_ok
1345
1346 commands["/round"] = round
1347
1348 def help_command(turb, body, args):
1349     """Implementation of the /help command
1350
1351     Displays help on how to use Turbot.
1352     """
1353
1354     channel_id = body['channel_id'][0]
1355     response_url = body['response_url'][0]
1356     user_id = body['user_id'][0]
1357
1358     # Process "/help me" first. It calls out to have_you_tried rather
1359     # than going through our help system.
1360     #
1361     # Also, it reports in the current channel, (where all other help
1362     # output is reported privately to the invoking user).
1363     if args == "me":
1364         to_try = "In response to <@{}> asking `/help me`:\n\n{}\n".format(
1365             user_id, have_you_tried())
1366
1367         # We'll try first to reply directly to the channel (for the benefit
1368         # of anyone else in the same channel that might be stuck too.
1369         #
1370         # But if this doesn't work, (direct message or private channel),
1371         # then we can instead reply with an ephemeral message by using
1372         # the response_url.
1373         try:
1374             turb.slack_client.chat_postMessage(
1375                 channel=channel_id, text=to_try)
1376         except SlackApiError:
1377             requests.post(response_url,
1378                           json = {"text": to_try},
1379                           headers = {"Content-type": "application/json"})
1380         return lambda_ok
1381
1382     help_string = turbot_help(args)
1383
1384     requests.post(response_url,
1385                   json = {"text": help_string},
1386                   headers = {"Content-type": "application/json"})
1387
1388     return lambda_ok
1389
1390 commands["/help"] = help_command