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