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