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