]> git.cworth.org Git - turbot/blob - turbot/interaction.py
Add "/new hunt" as a new sub-command of "/new"
[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_command(turb, body):
341     """Implementation of the '/hunt new' command
342
343     As dispatched from the hunt() function.
344     """
345
346     trigger_id = body['trigger_id'][0]
347
348     return new_hunt(turb, trigger_id)
349
350 def new_hunt_button(turb, payload):
351     """Handler for the action of user pressing the new_hunt button"""
352
353     trigger_id = payload['trigger_id']
354
355     return new_hunt(turb, trigger_id)
356
357 def new_hunt(turb, trigger_id):
358     """Common code for implementing a new hunt dialog
359
360     This implementation is common whether the operations was invoked
361     by a button (new_hunt_button) or a command (new_hunt_command).
362     """
363
364     view = {
365         "type": "modal",
366         "private_metadata": json.dumps({}),
367         "title": { "type": "plain_text", "text": "New Hunt" },
368         "submit": { "type": "plain_text", "text": "Create" },
369         "blocks": [
370             input_block("Hunt name", "name", "Name of the hunt"),
371             input_block("Hunt ID", "hunt_id",
372                         "Used as puzzle channel prefix "
373                         + "(no spaces nor punctuation)"),
374             input_block("Hunt URL", "url", "External URL of hunt",
375                         optional=True)
376         ],
377     }
378
379     result = turb.slack_client.views_open(trigger_id=trigger_id,
380                                           view=view)
381     if (result['ok']):
382         submission_handlers[result['view']['id']] = new_hunt_submission
383
384     return lambda_ok
385
386 actions['button']['new_hunt'] = new_hunt
387
388 def new_hunt_submission(turb, payload, metadata):
389     """Handler for the user submitting the new hunt modal
390
391     This is the modal view presented to the user by the new_hunt
392     function above."""
393
394     state = payload['view']['state']['values']
395     user_id = payload['user']['id']
396     name = state['name']['name']['value']
397     hunt_id = state['hunt_id']['hunt_id']['value']
398     url = state['url']['url']['value']
399
400     # Validate that the hunt_id contains no invalid characters
401     if not re.match(valid_id_re, hunt_id):
402         return submission_error("hunt_id",
403                                 "Hunt ID can only contain lowercase letters, "
404                                 + "numbers, and underscores")
405
406     # Check to see if the turbot table exists
407     try:
408         exists = turb.table.table_status in ("CREATING", "UPDATING",
409                                              "ACTIVE")
410     except ClientError:
411         exists = False
412
413     # Create the turbot table if necessary.
414     if not exists:
415         turb.table = turb.db.create_table(
416             TableName='turbot',
417             KeySchema=[
418                 {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
419                 {'AttributeName': 'SK', 'KeyType': 'RANGE'}
420             ],
421             AttributeDefinitions=[
422                 {'AttributeName': 'hunt_id', 'AttributeType': 'S'},
423                 {'AttributeName': 'SK', 'AttributeType': 'S'},
424                 {'AttributeName': 'channel_id', 'AttributeType': 'S'},
425                 {'AttributeName': 'is_hunt', 'AttributeType': 'S'},
426                 {'AttributeName': 'url', 'AttributeType': 'S'}
427             ],
428             ProvisionedThroughput={
429                 'ReadCapacityUnits': 5,
430                 'WriteCapacityUnits': 5
431             },
432             GlobalSecondaryIndexes=[
433                 {
434                     'IndexName': 'channel_id_index',
435                     'KeySchema': [
436                         {'AttributeName': 'channel_id', 'KeyType': 'HASH'}
437                     ],
438                     'Projection': {
439                         'ProjectionType': 'ALL'
440                     },
441                     'ProvisionedThroughput': {
442                         'ReadCapacityUnits': 5,
443                         'WriteCapacityUnits': 5
444                     }
445                 },
446                 {
447                     'IndexName': 'is_hunt_index',
448                     'KeySchema': [
449                         {'AttributeName': 'is_hunt', 'KeyType': 'HASH'}
450                     ],
451                     'Projection': {
452                         'ProjectionType': 'ALL'
453                     },
454                     'ProvisionedThroughput': {
455                         'ReadCapacityUnits': 5,
456                         'WriteCapacityUnits': 5
457                     }
458                 }
459             ],
460             LocalSecondaryIndexes = [
461                 {
462                     'IndexName': 'url_index',
463                     'KeySchema': [
464                         {'AttributeName': 'hunt_id', 'KeyType': 'HASH'},
465                         {'AttributeName': 'url', 'KeyType': 'RANGE'},
466                     ],
467                     'Projection': {
468                         'ProjectionType': 'ALL'
469                     }
470                 }
471             ]
472         )
473         return submission_error(
474             "hunt_id",
475             "Still bootstrapping turbot table. Try again in a minute, please.")
476
477     # Create a channel for the hunt
478     try:
479         response = turb.slack_client.conversations_create(name=hunt_id)
480     except SlackApiError as e:
481         return submission_error("hunt_id",
482                                 "Error creating Slack channel: {}"
483                                 .format(e.response['error']))
484
485     channel_id = response['channel']['id']
486
487     # Insert the newly-created hunt into the database
488     # (leaving it as non-active for now until the channel-created handler
489     #  finishes fixing it up with a sheet and a companion table)
490     item={
491         "hunt_id": hunt_id,
492         "SK": "hunt-{}".format(hunt_id),
493         "is_hunt": hunt_id,
494         "channel_id": channel_id,
495         "active": False,
496         "name": name,
497     }
498     if url:
499         item['url'] = url
500     turb.table.put_item(Item=item)
501
502     # Invite the initiating user to the channel
503     turb.slack_client.conversations_invite(channel=channel_id, users=user_id)
504
505     return lambda_ok
506
507 def view_submission(turb, payload):
508     """Handler for Slack interactive view submission
509
510     Specifically, those that have a payload type of 'view_submission'"""
511
512     view_id = payload['view']['id']
513     metadata = payload['view']['private_metadata']
514
515     if view_id in submission_handlers:
516         return submission_handlers[view_id](turb, payload, metadata)
517
518     print("Error: Unknown view ID: {}".format(view_id))
519     return {
520         'statusCode': 400
521     }
522
523 def rot(turb, body, args):
524     """Implementation of the /rot command
525
526     The args string should be as follows:
527
528         [count|*] String to be rotated
529
530     That is, the first word of the string is an optional number (or
531     the character '*'). If this is a number it indicates an amount to
532     rotate each character in the string. If the count is '*' or is not
533     present, then the string will be rotated through all possible 25
534     values.
535
536     The result of the rotation is returned (with Slack formatting) in
537     the body of the response so that Slack will provide it as a reply
538     to the user who submitted the slash command."""
539
540     channel_name = body['channel_name'][0]
541     response_url = body['response_url'][0]
542     channel_id = body['channel_id'][0]
543
544     result = turbot.rot.rot(args)
545
546     if (channel_name == "directmessage"):
547         requests.post(response_url,
548                       json = {"text": result},
549                       headers = {"Content-type": "application/json"})
550     else:
551         turb.slack_client.chat_postMessage(channel=channel_id, text=result)
552
553     return lambda_ok
554
555 commands["/rot"] = rot
556
557 def get_table_item(turb, table_name, key, value):
558     """Get an item from the database 'table_name' with 'key' as 'value'
559
560     Returns a tuple of (item, table) if found and (None, None) otherwise."""
561
562     table = turb.db.Table(table_name)
563
564     response = table.get_item(Key={key: value})
565
566     if 'Item' in response:
567         return (response['Item'], table)
568     else:
569         return (None, None)
570
571 def db_entry_for_channel(turb, channel_id):
572     """Given a channel ID return the database item for this channel
573
574     If this channel is a registered hunt or puzzle channel, return the
575     corresponding row from the database for this channel. Otherwise,
576     return None.
577
578     Note: If you need to specifically ensure that the channel is a
579     puzzle or a hunt, please call puzzle_for_channel or
580     hunt_for_channel respectively.
581     """
582
583     response = turb.table.query(
584         IndexName = "channel_id_index",
585         KeyConditionExpression=Key("channel_id").eq(channel_id)
586     )
587
588     if response['Count'] == 0:
589         return None
590
591     return response['Items'][0]
592
593
594 def puzzle_for_channel(turb, channel_id):
595
596     """Given a channel ID return the puzzle from the database for this channel
597
598     If the given channel_id is a puzzle's channel, this function
599     returns a dict filled with the attributes from the puzzle's entry
600     in the database.
601
602     Otherwise, this function returns None.
603     """
604
605     entry = db_entry_for_channel(turb, channel_id)
606
607     if entry and entry['SK'].startswith('puzzle-'):
608         return entry
609     else:
610         return None
611
612 def hunt_for_channel(turb, channel_id):
613
614     """Given a channel ID return the hunt from the database for this channel
615
616     This works whether the original channel is a primary hunt channel,
617     or if it is one of the channels of a puzzle belonging to the hunt.
618
619     Returns None if channel does not belong to a hunt, otherwise a
620     dictionary with all fields from the hunt's row in the table,
621     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
622     """
623
624     entry = db_entry_for_channel(turb, channel_id)
625
626     # We're done if this channel doesn't exist in the database at all
627     if not entry:
628         return None
629
630     # Also done if this channel is a hunt channel
631     if entry['SK'].startswith('hunt-'):
632         return entry
633
634     # Otherwise, (the channel is in the database, but is not a hunt),
635     # we expect this to be a puzzle channel instead
636     return find_hunt_for_hunt_id(turb, entry['hunt_id'])
637
638 # python3.9 has a built-in removeprefix but AWS only has python3.8
639 def remove_prefix(text, prefix):
640     if text.startswith(prefix):
641         return text[len(prefix):]
642     return text
643
644 def hunt_rounds(turb, hunt_id):
645     """Returns array of strings giving rounds that exist in the given hunt"""
646
647     response = turb.table.query(
648         KeyConditionExpression=(
649             Key('hunt_id').eq(hunt_id) &
650             Key('SK').begins_with('round-')
651         )
652     )
653
654     if response['Count'] == 0:
655         return []
656
657     return [remove_prefix(option['SK'], 'round-')
658             for option in response['Items']]
659
660 def puzzle(turb, body, args):
661     """Implementation of the /puzzle command
662
663     The args string can be a sub-command:
664
665         /puzzle new: Bring up a dialog to create a new puzzle
666
667         /puzzle edit: Edit the puzzle for the current channel
668
669     Or with no argument at all:
670
671         /puzzle: Print details of the current puzzle (if in a puzzle channel)
672     """
673
674     if args == 'new':
675         return new_puzzle(turb, body)
676
677     if args == 'edit':
678         return edit_puzzle_command(turb, body)
679
680     if len(args):
681         return bot_reply("Unknown syntax for `/puzzle` command. " +
682                          "Valid commands are: `/puzzle`, `/puzzle edit`, " +
683                          "and `/puzzle new` to display, edit, or create " +
684                          "a puzzle.")
685
686     # For no arguments we print the current puzzle as a reply
687     channel_id = body['channel_id'][0]
688     response_url = body['response_url'][0]
689
690     puzzle = puzzle_for_channel(turb, channel_id)
691
692     if not puzzle:
693         hunt = hunt_for_channel(turb, channel_id)
694         if hunt:
695             return bot_reply(
696                 "This is not a puzzle channel, but is a hunt channel. "
697                 + "If you want to create a new puzzle for this hunt, use "
698                 + "`/puzzle new`.")
699         else:
700             return bot_reply(
701                 "Sorry, this channel doesn't appear to be a hunt or a puzzle "
702                 + "channel, so the `/puzzle` command cannot work here.")
703
704     blocks = puzzle_blocks(puzzle, include_rounds=True)
705
706     # For a meta puzzle, also display the titles and solutions for all
707     # puzzles in the same round.
708     if puzzle.get('type', 'plain') == 'meta':
709         puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
710
711         # Drop this puzzle itself from the report
712         puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
713
714         for round in puzzle.get('rounds', [None]):
715             answers = round_quoted_puzzles_titles_answers(round, puzzles)
716             blocks += [
717                 section_block(text_block(
718                     "*Feeder solutions from round {}*".format(
719                         round if round else "<none>"
720                     ))),
721                 section_block(text_block(answers))
722             ]
723
724     requests.post(response_url,
725                   json = {'blocks': blocks},
726                   headers = {'Content-type': 'application/json'}
727                   )
728
729     return lambda_ok
730
731 commands["/puzzle"] = puzzle
732
733 def new(turb, body, args):
734     """Implementation of the `/new` command
735
736     This can be used to create a new hunt ("/new hunt") or a new
737     puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
738     default behavior (as it is much more common).
739
740     This operations are identical to the existing "/hunt new" and
741     "/puzzle new". I don't know that that redundancy is actually
742     helpful in the interface. But at least having both allows us to
743     experiment and decide which is more natural and should be kept
744     around long-term.
745     """
746
747     if args == 'hunt':
748         return new_hunt_command(turb, body)
749
750     return new_puzzle(turb, body)
751
752 commands["/new"] = new
753
754 def new_puzzle(turb, body):
755     """Implementation of the "/puzzle new" command
756
757     This brings up a dialog box for creating a new puzzle.
758     """
759
760     channel_id = body['channel_id'][0]
761     trigger_id = body['trigger_id'][0]
762
763     hunt = hunt_for_channel(turb, channel_id)
764
765     if not hunt:
766         return bot_reply("Sorry, this channel doesn't appear to "
767                          + "be a hunt or puzzle channel")
768
769     round_options = hunt_rounds(turb, hunt['hunt_id'])
770
771     if len(round_options):
772         round_options_block = [
773             multi_select_block("Round(s)", "rounds",
774                                "Existing round(s) this puzzle belongs to",
775                                round_options)
776         ]
777     else:
778         round_options_block = []
779
780     view = {
781         "type": "modal",
782         "private_metadata": json.dumps({
783             "hunt_id": hunt['hunt_id'],
784         }),
785         "title": {"type": "plain_text", "text": "New Puzzle"},
786         "submit": { "type": "plain_text", "text": "Create" },
787         "blocks": [
788             section_block(text_block("*For {}*".format(hunt['name']))),
789             input_block("Puzzle name", "name", "Name of the puzzle"),
790             input_block("Puzzle URL", "url", "External URL of puzzle",
791                         optional=True),
792             checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
793             * round_options_block,
794             input_block("New round(s)", "new_rounds",
795                         "New round(s) this puzzle belongs to " +
796                         "(comma separated)",
797                         optional=True)
798         ]
799     }
800
801     result = turb.slack_client.views_open(trigger_id=trigger_id,
802                                           view=view)
803
804     if (result['ok']):
805         submission_handlers[result['view']['id']] = new_puzzle_submission
806
807     return lambda_ok
808
809 def new_puzzle_submission(turb, payload, metadata):
810     """Handler for the user submitting the new puzzle modal
811
812     This is the modal view presented to the user by the new_puzzle
813     function above.
814     """
815
816     # First, read all the various data from the request
817     meta = json.loads(metadata)
818     hunt_id = meta['hunt_id']
819
820     state = payload['view']['state']['values']
821     name = state['name']['name']['value']
822     url = state['url']['url']['value']
823     if state['meta']['meta']['selected_options']:
824         puzzle_type = 'meta'
825     else:
826         puzzle_type = 'plain'
827     if 'rounds' in state:
828         rounds = [option['value'] for option in
829                   state['rounds']['rounds']['selected_options']]
830     else:
831         rounds = []
832     new_rounds = state['new_rounds']['new_rounds']['value']
833
834     # Before doing anything, reject this puzzle if a puzzle already
835     # exists with the same URL.
836     if url:
837         existing = find_puzzle_for_url(turb, hunt_id, url)
838         if existing:
839             return submission_error(
840                 "url",
841                 "Error: A puzzle with this URL already exists.")
842
843     # Create a Slack-channel-safe puzzle_id
844     puzzle_id = puzzle_id_from_name(name)
845
846     # Create a channel for the puzzle
847     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
848
849     try:
850         response = turb.slack_client.conversations_create(
851             name=hunt_dash_channel)
852     except SlackApiError as e:
853         return submission_error(
854             "name",
855             "Error creating Slack channel {}: {}"
856             .format(hunt_dash_channel, e.response['error']))
857
858     channel_id = response['channel']['id']
859
860     # Add any new rounds to the database
861     if new_rounds:
862         for round in new_rounds.split(','):
863             # Drop any leading/trailing spaces from the round name
864             round = round.strip()
865             # Ignore any empty string
866             if not len(round):
867                 continue
868             rounds.append(round)
869             turb.table.put_item(
870                 Item={
871                     'hunt_id': hunt_id,
872                     'SK': 'round-' + round
873                 }
874             )
875
876     # Construct a puzzle dict
877     puzzle = {
878         "hunt_id": hunt_id,
879         "puzzle_id": puzzle_id,
880         "channel_id": channel_id,
881         "solution": [],
882         "status": 'unsolved',
883         "name": name,
884         "type": puzzle_type
885     }
886     if url:
887         puzzle['url'] = url
888     if rounds:
889         puzzle['rounds'] = rounds
890
891     # Finally, compute the appropriate sort key
892     puzzle["SK"] = puzzle_sort_key(puzzle)
893
894     # Insert the newly-created puzzle into the database
895     turb.table.put_item(Item=puzzle)
896
897     return lambda_ok
898
899 def state(turb, body, args):
900     """Implementation of the /state command
901
902     The args string should be a brief sentence describing where things
903     stand or what's needed."""
904
905     channel_id = body['channel_id'][0]
906
907     old_puzzle = puzzle_for_channel(turb, channel_id)
908
909     if not old_puzzle:
910         return bot_reply(
911             "Sorry, the /state command only works in a puzzle channel")
912
913     # Make a deep copy of the puzzle object
914     puzzle = puzzle_copy(old_puzzle)
915
916     # Update the puzzle in the database
917     puzzle['state'] = args
918     turb.table.put_item(Item=puzzle)
919
920     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
921
922     return lambda_ok
923
924 commands["/state"] = state
925
926 def tag(turb, body, args):
927     """Implementation of the `/tag` command.
928
929     Arg is either a tag to add (optionally prefixed with '+'), or if
930     prefixed with '-' is a tag to remove.
931     """
932
933     if not args:
934         return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
935                          + "or `/tag -TAG_TO_REMOVE`.")
936
937     channel_id = body['channel_id'][0]
938
939     old_puzzle = puzzle_for_channel(turb, channel_id)
940
941     if not old_puzzle:
942         return bot_reply(
943             "Sorry, the /tag command only works in a puzzle channel")
944
945     if args[0] == '-':
946         tag = args[1:]
947         action = 'remove'
948     else:
949         tag = args
950         if tag[0] == '+':
951             tag = tag[1:]
952         action = 'add'
953
954     # Force tag to all uppercase
955     tag = tag.upper()
956
957     # Reject a tag that is not alphabetic or underscore A-Z_
958     if not re.match(r'^[A-Z0-9_]*$', tag):
959         return bot_reply("Sorry, tags can only contain letters, numbers, "
960                          + "and the underscore character.")
961
962     if action == 'remove':
963         if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
964             return bot_reply("Nothing to do. This puzzle is not tagged "
965                              + "with the tag: {}".format(tag))
966     else:
967         if 'tags' in old_puzzle and tag in old_puzzle['tags']:
968             return bot_reply("Nothing to do. This puzzle is already tagged "
969                              + "with the tag: {}".format(tag))
970
971     # OK. Error checking is done. Let's get to work
972
973     # Make a deep copy of the puzzle object
974     puzzle = puzzle_copy(old_puzzle)
975
976     if action == 'remove':
977         puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
978     else:
979         if 'tags' not in puzzle:
980             puzzle['tags'] = [tag]
981         else:
982             puzzle['tags'].append(tag)
983
984     turb.table.put_item(Item=puzzle)
985
986     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
987
988     return lambda_ok
989
990 commands["/tag"] = tag
991
992 def solved(turb, body, args):
993     """Implementation of the /solved command
994
995     The args string should be a confirmed solution."""
996
997     channel_id = body['channel_id'][0]
998     user_id = body['user_id'][0]
999
1000     old_puzzle = puzzle_for_channel(turb, channel_id)
1001
1002     if not old_puzzle:
1003         return bot_reply("Sorry, this is not a puzzle channel.")
1004
1005     if not args:
1006         return bot_reply(
1007             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
1008
1009     # Make a deep copy of the puzzle object
1010     puzzle = puzzle_copy(old_puzzle)
1011
1012     # Set the status and solution fields in the database
1013     puzzle['status'] = 'solved'
1014     puzzle['solution'].append(args)
1015     if 'state' in puzzle:
1016         del puzzle['state']
1017     turb.table.put_item(Item=puzzle)
1018
1019     # Report the solution to the puzzle's channel
1020     slack_send_message(
1021         turb.slack_client, channel_id,
1022         "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
1023
1024     # Also report the solution to the hunt channel
1025     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
1026     slack_send_message(
1027         turb.slack_client, hunt['channel_id'],
1028         "Puzzle <{}|{}> has been solved!".format(
1029             puzzle['channel_url'],
1030             puzzle['name'])
1031     )
1032
1033     # And update the puzzle's description
1034     puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
1035
1036     return lambda_ok
1037
1038 commands["/solved"] = solved
1039
1040 def hunt(turb, body, args):
1041     """Implementation of the /hunt command
1042
1043     The (optional) args string can be used to filter which puzzles to
1044     display. The first word can be one of 'all', 'unsolved', or
1045     'solved' and can be used to display only puzzles with the given
1046     status. If this first word is missing, this command will display
1047     only unsolved puzzles by default.
1048
1049     Any remaining text in the args string will be interpreted as
1050     search terms. These will be split into separate terms on space
1051     characters, (though quotation marks can be used to include a space
1052     character in a term). All terms must match on a puzzle in order
1053     for that puzzle to be included. But a puzzle will be considered to
1054     match if any of the puzzle title, round title, puzzle URL, puzzle
1055     state, puzzle type, tags, or puzzle solution match. Matching will
1056     be performed without regard to case sensitivity and the search
1057     terms can include regular expression syntax.
1058
1059     """
1060
1061     channel_id = body['channel_id'][0]
1062     response_url = body['response_url'][0]
1063
1064     # First, farm off "/hunt new" as a separate command
1065     if args == "new":
1066         return new_hunt_command(turb, body)
1067
1068     terms = None
1069     if args:
1070         # The first word can be a puzzle status and all remaining word
1071         # (if any) are search terms. _But_, if the first word is not a
1072         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1073         # words are search terms and we default status to 'unsolved'.
1074         split_args = args.split(' ', 1)
1075         status = split_args[0]
1076         if (len(split_args) > 1):
1077             terms = split_args[1]
1078         if status not in ('unsolved', 'solved', 'all'):
1079             terms = args
1080             status = 'unsolved'
1081     else:
1082         status = 'unsolved'
1083
1084     # Separate search terms on spaces (but allow for quotation marks
1085     # to capture spaces in a search term)
1086     if terms:
1087         terms = shlex.split(terms)
1088
1089     hunt = hunt_for_channel(turb, channel_id)
1090
1091     if not hunt:
1092         return bot_reply("Sorry, this channel doesn't appear to "
1093                          + "be a hunt or puzzle channel")
1094
1095     blocks = hunt_blocks(turb, hunt, puzzle_status=status, search_terms=terms)
1096
1097     requests.post(response_url,
1098                   json = { 'blocks': blocks },
1099                   headers = {'Content-type': 'application/json'}
1100                   )
1101
1102     return lambda_ok
1103
1104 commands["/hunt"] = hunt
1105
1106 def round(turb, body, args):
1107     """Implementation of the /round command
1108
1109     Displays puzzles in the same round(s) as the puzzle for the
1110     current channel.
1111
1112     The (optional) args string can be used to filter which puzzles to
1113     display. The first word can be one of 'all', 'unsolved', or
1114     'solved' and can be used to display only puzzles with the given
1115     status. If this first word is missing, this command will display
1116     all puzzles in the round by default.
1117
1118     Any remaining text in the args string will be interpreted as
1119     search terms. These will be split into separate terms on space
1120     characters, (though quotation marks can be used to include a space
1121     character in a term). All terms must match on a puzzle in order
1122     for that puzzle to be included. But a puzzle will be considered to
1123     match if any of the puzzle title, round title, puzzle URL, puzzle
1124     state, or puzzle solution match. Matching will be performed
1125     without regard to case sensitivity and the search terms can
1126     include regular expression syntax.
1127     """
1128
1129     channel_id = body['channel_id'][0]
1130     response_url = body['response_url'][0]
1131
1132     puzzle = puzzle_for_channel(turb, channel_id)
1133     hunt = hunt_for_channel(turb, channel_id)
1134
1135     if not puzzle:
1136         if hunt:
1137             return bot_reply(
1138                 "This is not a puzzle channel, but is a hunt channel. "
1139                 + "Use /hunt if you want to see all rounds for this hunt.")
1140         else:
1141             return bot_reply(
1142                 "Sorry, this channel doesn't appear to be a puzzle channel "
1143                 + "so the `/round` command cannot work here.")
1144
1145     terms = None
1146     if args:
1147         # The first word can be a puzzle status and all remaining word
1148         # (if any) are search terms. _But_, if the first word is not a
1149         # valid puzzle status ('all', 'unsolved', 'solved'), then all
1150         # words are search terms and we default status to 'unsolved'.
1151         split_args = args.split(' ', 1)
1152         status = split_args[0]
1153         if (len(split_args) > 1):
1154             terms = split_args[1]
1155         if status not in ('unsolved', 'solved', 'all'):
1156             terms = args
1157             status = 'all'
1158     else:
1159         status = 'all'
1160
1161     # Separate search terms on spaces (but allow for quotation marks
1162     # to capture spaces in a search term)
1163     if terms:
1164         terms = shlex.split(terms)
1165
1166     blocks = hunt_blocks(turb, hunt,
1167                          puzzle_status=status, search_terms=terms,
1168                          limit_to_rounds=puzzle.get('rounds', [])
1169                          )
1170
1171     requests.post(response_url,
1172                   json = { 'blocks': blocks },
1173                   headers = {'Content-type': 'application/json'}
1174                   )
1175
1176     return lambda_ok
1177
1178 commands["/round"] = round