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