]> git.cworth.org Git - turbot/blobdiff - turbot/interaction.py
Add a /tag command to add or remove tags from puzzle
[turbot] / turbot / interaction.py
index 3254f24587508953f7bf19d32905b5ea194dd68d..fb11599034fc1f518a1ec7a19a7d54ade43862be 100644 (file)
@@ -2,13 +2,20 @@ from slack.errors import SlackApiError
 from turbot.blocks import (
     input_block, section_block, text_block, multi_select_block, checkbox_block
 )
-from turbot.hunt import find_hunt_for_hunt_id, hunt_blocks
+from turbot.hunt import (
+    find_hunt_for_hunt_id,
+    hunt_blocks,
+    hunt_puzzles_for_hunt_id
+)
 from turbot.puzzle import (
     find_puzzle_for_url,
-    find_puzzle_for_puzzle_id,
+    find_puzzle_for_sort_key,
     puzzle_update_channel_and_sheet,
-    puzzle_id_from_name
+    puzzle_id_from_name,
+    puzzle_blocks,
+    puzzle_sort_key
 )
+from turbot.round import round_quoted_puzzles_titles_answers
 import turbot.rot
 import turbot.sheets
 import turbot.slack
@@ -74,16 +81,48 @@ def multi_static_select(turb, payload):
 
 actions['multi_static_select'] = {"*": multi_static_select}
 
-def edit_puzzle(turb, payload):
+def edit(turb, body, args):
+
+    """Implementation of the `/edit` command
+
+    To edit the puzzle for the current channel.
+
+    This is simply a shortcut for `/puzzle edit`.
+    """
+
+    return edit_puzzle_command(turb, body)
+
+commands["/edit"] = edit
+
+
+def edit_puzzle_command(turb, body):
+    """Implementation of the `/puzzle edit` command
+
+    As dispatched from the puzzle() function.
+    """
+
+    channel_id = body['channel_id'][0]
+    trigger_id = body['trigger_id'][0]
+
+    puzzle = puzzle_for_channel(turb, channel_id)
+
+    if not puzzle:
+        return bot_reply("Sorry, this does not appear to be a puzzle channel.")
+
+    return edit_puzzle(turb, puzzle, trigger_id)
+
+    return lambda_ok
+
+def edit_puzzle_button(turb, payload):
     """Handler for the action of user pressing an edit_puzzle button"""
 
     action_id = payload['actions'][0]['action_id']
     response_url = payload['response_url']
     trigger_id = payload['trigger_id']
 
-    (hunt_id, puzzle_id) = action_id.split('-', 1)
+    (hunt_id, sort_key) = action_id.split('-', 1)
 
-    puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
+    puzzle = find_puzzle_for_sort_key(turb, hunt_id, sort_key)
 
     if not puzzle:
         requests.post(response_url,
@@ -91,7 +130,18 @@ def edit_puzzle(turb, payload):
                       headers = {"Content-type": "application/json"})
         return bot_reply("Error: Puzzle not found.")
 
-    round_options = hunt_rounds(turb, hunt_id)
+    return edit_puzzle(turb, puzzle, trigger_id)
+
+actions['button']['edit_puzzle'] = edit_puzzle_button
+
+def edit_puzzle(turb, puzzle, trigger_id):
+    """Common code for implementing an edit puzzle dialog
+
+    This implementation is common whether the edit operation was invoked
+    by a button (edit_puzzle_button) or a command (edit_puzzle_command).
+    """
+
+    round_options = hunt_rounds(turb, puzzle['hunt_id'])
 
     if len(round_options):
         round_options_block = [
@@ -115,9 +165,9 @@ def edit_puzzle(turb, payload):
     view = {
         "type": "modal",
         "private_metadata": json.dumps({
-            "hunt_id": hunt_id,
+            "hunt_id": puzzle['hunt_id'],
             "SK": puzzle["SK"],
-            "puzzle_id": puzzle_id,
+            "puzzle_id": puzzle['puzzle_id'],
             "channel_id": puzzle["channel_id"],
             "channel_url": puzzle["channel_url"],
             "sheet_url": puzzle["sheet_url"],
@@ -130,6 +180,8 @@ def edit_puzzle(turb, payload):
             input_block("Puzzle URL", "url", "External URL of puzzle",
                         initial_value=puzzle.get("url", None),
                         optional=True),
+            checkbox_block("Is this a meta puzzle?", "Meta", "meta",
+                           checked=(puzzle.get('type', 'plain') == 'meta')),
             * round_options_block,
             input_block("New round(s)", "new_rounds",
                         "New round(s) this puzzle belongs to " +
@@ -157,8 +209,6 @@ def edit_puzzle(turb, payload):
 
     return lambda_ok
 
-actions['button']['edit_puzzle'] = edit_puzzle
-
 def edit_puzzle_submission(turb, payload, metadata):
     """Handler for the user submitting the edit puzzle modal
 
@@ -178,11 +228,16 @@ def edit_puzzle_submission(turb, payload, metadata):
     puzzle['sheet_url'] = meta['sheet_url']
 
     state = payload['view']['state']['values']
+    user_id = payload['user']['id']
 
     puzzle['name'] = state['name']['name']['value']
     url = state['url']['url']['value']
     if url:
         puzzle['url'] = url
+    if state['meta']['meta']['selected_options']:
+        puzzle['type'] = 'meta'
+    else:
+        puzzle['type'] = 'plain'
     rounds = [option['value'] for option in
               state['rounds']['rounds']['selected_options']]
     if rounds:
@@ -230,13 +285,52 @@ def edit_puzzle_submission(turb, payload, metadata):
             )
 
     # Get old puzzle from the database (to determine what's changed)
-    old_puzzle = find_puzzle_for_puzzle_id(turb,
-                                           puzzle['hunt_id'],
-                                           puzzle['puzzle_id'])
+    old_puzzle = find_puzzle_for_sort_key(turb,
+                                          puzzle['hunt_id'],
+                                          puzzle['SK'])
+
+    # If we are changing puzzle type (meta -> plain or plain -> meta)
+    # the the sort key has to change, so compute the new one and delete
+    # the old item from the database.
+    #
+    # XXX: We should really be using a transaction here to combine the
+    # delete_item and the put_item into a single transaction, but
+    # the boto interface is annoying in that transactions are only on
+    # the "Client" object which has a totally different interface than
+    # the "Table" object I've been using so I haven't figured out how
+    # to do that yet.
+
+    if puzzle['type'] != old_puzzle.get('type', 'plain'):
+        puzzle['SK'] = puzzle_sort_key(puzzle)
+        turb.table.delete_item(Key={
+            'hunt_id': old_puzzle['hunt_id'],
+            'SK': old_puzzle['SK']
+        })
 
     # Update the puzzle in the database
     turb.table.put_item(Item=puzzle)
 
+    # Inform the puzzle channel about the edit
+    edit_message = "Puzzle edited by <@{}>".format(user_id)
+    blocks = ([section_block(text_block(edit_message+":\n"))] +
+              puzzle_blocks(puzzle, include_rounds=True))
+    slack_send_message(
+        turb.slack_client, puzzle['channel_id'],
+        edit_message, blocks=blocks)
+
+    # Also inform the hunt if the puzzle's solved status changed
+    if puzzle['status'] != old_puzzle['status']:
+        hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
+        if puzzle['status'] == 'solved':
+            message = "Puzzle <{}|{}> has been solved!".format(
+                puzzle['channel_url'],
+                puzzle['name'])
+        else:
+            message = "Oops. Puzzle <{}|{}> has been marked unsolved!".format(
+                puzzle['channel_url'],
+                puzzle['name'])
+        slack_send_message(turb.slack_client, hunt['channel_id'], message)
+
     # We need to set the channel topic if any of puzzle name, url,
     # state, status, or solution, has changed. Let's just do that
     # unconditionally here.
@@ -546,8 +640,93 @@ def hunt_rounds(turb, hunt_id):
 def puzzle(turb, body, args):
     """Implementation of the /puzzle command
 
-    The args string is currently ignored (this command will bring up
-    a modal dialog for user input instead)."""
+    The args string can be a sub-command:
+
+        /puzzle new: Bring up a dialog to create a new puzzle
+
+        /puzzle edit: Edit the puzzle for the current channel
+
+    Or with no argument at all:
+
+        /puzzle: Print details of the current puzzle (if in a puzzle channel)
+    """
+
+    if args == 'new':
+        return new_puzzle(turb, body)
+
+    if args == 'edit':
+        return edit_puzzle_command(turb, body)
+
+    if len(args):
+        return bot_reply("Unknown syntax for `/puzzle` command. " +
+                         "Valid commands are: `/puzzle`, `/puzzle edit`, " +
+                         "and `/puzzle new` to display, edit, or create " +
+                         "a puzzle.")
+
+    # For no arguments we print the current puzzle as a reply
+    channel_id = body['channel_id'][0]
+    response_url = body['response_url'][0]
+
+    puzzle = puzzle_for_channel(turb, channel_id)
+
+    if not puzzle:
+        hunt = hunt_for_channel(turb, channel_id)
+        if hunt:
+            return bot_reply(
+                "This is not a puzzle channel, but is a hunt channel. "
+                + "If you want to create a new puzzle for this hunt, use "
+                + "`/puzzle new`.")
+        else:
+            return bot_reply(
+                "Sorry, this channel doesn't appear to be a hunt or a puzzle "
+                + "channel, so the `/puzzle` command cannot work here.")
+
+    blocks = puzzle_blocks(puzzle, include_rounds=True)
+
+    # For a meta puzzle, also display the titles and solutions for all
+    # puzzles in the same round.
+    if puzzle['type'] == 'meta':
+        puzzles = hunt_puzzles_for_hunt_id(turb, puzzle['hunt_id'])
+
+        # Drop this puzzle itself from the report
+        puzzles = [p for p in puzzles if p['puzzle_id'] != puzzle['puzzle_id']]
+
+        for round in puzzle.get('rounds', [None]):
+            answers = round_quoted_puzzles_titles_answers(round, puzzles)
+            blocks += [
+                section_block(text_block(
+                    "*Feeder solutions from round {}*".format(
+                        round if round else "<none>"
+                    ))),
+                section_block(text_block(answers))
+            ]
+
+    requests.post(response_url,
+                  json = {'blocks': blocks},
+                  headers = {'Content-type': 'application/json'}
+                  )
+
+    return lambda_ok
+
+commands["/puzzle"] = puzzle
+
+def new(turb, body, args):
+    """Implementation of the `/new` command
+
+    To create a new puzzle.
+
+    This is simply a shortcut for `/puzzle new`.
+    """
+
+    return new_puzzle(turb, body)
+
+commands["/new"] = new
+
+def new_puzzle(turb, body):
+    """Implementation of the "/puzzle new" command
+
+    This brings up a dialog box for creating a new puzzle.
+    """
 
     channel_id = body['channel_id'][0]
     trigger_id = body['trigger_id'][0]
@@ -581,6 +760,7 @@ def puzzle(turb, body, args):
             input_block("Puzzle name", "name", "Name of the puzzle"),
             input_block("Puzzle URL", "url", "External URL of puzzle",
                         optional=True),
+            checkbox_block("Is this a meta puzzle?", "Meta", "meta"),
             * round_options_block,
             input_block("New round(s)", "new_rounds",
                         "New round(s) this puzzle belongs to " +
@@ -593,17 +773,16 @@ def puzzle(turb, body, args):
                                           view=view)
 
     if (result['ok']):
-        submission_handlers[result['view']['id']] = puzzle_submission
+        submission_handlers[result['view']['id']] = new_puzzle_submission
 
     return lambda_ok
 
-commands["/puzzle"] = puzzle
-
-def puzzle_submission(turb, payload, metadata):
+def new_puzzle_submission(turb, payload, metadata):
     """Handler for the user submitting the new puzzle modal
 
-    This is the modal view presented to the user by the puzzle function
-    above."""
+    This is the modal view presented to the user by the new_puzzle
+    function above.
+    """
 
     # First, read all the various data from the request
     meta = json.loads(metadata)
@@ -612,6 +791,10 @@ def puzzle_submission(turb, payload, metadata):
     state = payload['view']['state']['values']
     name = state['name']['name']['value']
     url = state['url']['url']['value']
+    if state['meta']['meta']['selected_options']:
+        puzzle_type = 'meta'
+    else:
+        puzzle_type = 'plain'
     if 'rounds' in state:
         rounds = [option['value'] for option in
                   state['rounds']['rounds']['selected_options']]
@@ -661,21 +844,26 @@ def puzzle_submission(turb, payload, metadata):
                 }
             )
 
-    # Insert the newly-created puzzle into the database
-    item={
+    # Construct a puzzle dict
+    puzzle = {
         "hunt_id": hunt_id,
-        "SK": "puzzle-{}".format(puzzle_id),
         "puzzle_id": puzzle_id,
         "channel_id": channel_id,
         "solution": [],
         "status": 'unsolved',
         "name": name,
+        "type": puzzle_type
     }
     if url:
-        item['url'] = url
+        puzzle['url'] = url
     if rounds:
-        item['rounds'] = rounds
-    turb.table.put_item(Item=item)
+        puzzle['rounds'] = rounds
+
+    # Finally, compute the appropriate sort key
+    puzzle["SK"] = puzzle_sort_key(puzzle)
+
+    # Insert the newly-created puzzle into the database
+    turb.table.put_item(Item=puzzle)
 
     return lambda_ok
 
@@ -706,6 +894,72 @@ def state(turb, body, args):
 
 commands["/state"] = state
 
+def tag(turb, body, args):
+    """Implementation of the `/tag` command.
+
+    Arg is either a tag to add (optionally prefixed with '+'), or if
+    prefixed with '-' is a tag to remove.
+    """
+
+    if not args:
+        return bot_reply("Usage: `/tag [+]TAG_TO_ADD` "
+                         + "or `/tag -TAG_TO_REMOVE`.")
+
+    channel_id = body['channel_id'][0]
+
+    old_puzzle = puzzle_for_channel(turb, channel_id)
+
+    if not old_puzzle:
+        return bot_reply(
+            "Sorry, the /tag command only works in a puzzle channel")
+
+    if args[0] == '-':
+        tag = args[1:]
+        action = 'remove'
+    else:
+        tag = args
+        if tag[0] == '+':
+            tag = tag[1:]
+        action = 'add'
+
+    # Force tag to all uppercase
+    tag = tag.upper()
+
+    # Reject a tag that is not alphabetic or underscore A-Z_
+    if not re.match(r'^[A-Z_]*$', tag):
+        return bot_reply("Sorry, tags can only contain letters "
+                         + "and the underscore character.")
+
+    if action == 'remove':
+        if 'tags' not in old_puzzle or tag not in old_puzzle['tags']:
+            return bot_reply("Nothing to do. This puzzle is not tagged "
+                             + "with the tag: {}".format(tag))
+    else:
+        if 'tags' in old_puzzle and tag in old_puzzle['tags']:
+            return bot_reply("Nothing to do. This puzzle is already tagged "
+                             + "with the tag: {}".format(tag))
+
+    # OK. Error checking is done. Let's get to work
+
+    # Make a copy of the puzzle object
+    puzzle = old_puzzle.copy()
+
+    if action == 'remove':
+        puzzle['tags'] = [t for t in puzzle['tags'] if t != tag]
+    else:
+        if 'tags' not in puzzle:
+            puzzle['tags'] = [tag]
+        else:
+            puzzle['tags'].append(tag)
+
+    turb.table.put_item(Item=puzzle)
+
+    puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
+
+    return lambda_ok
+
+commands["/tag"] = tag
+
 def solved(turb, body, args):
     """Implementation of the /solved command
 
@@ -754,15 +1008,17 @@ def solved(turb, body, args):
 
 commands["/solved"] = solved
 
-
 def hunt(turb, body, args):
     """Implementation of the /hunt command
 
     The (optional) args string can be used to filter which puzzles to
     display. The first word can be one of 'all', 'unsolved', or
     'solved' and can be used to display only puzzles with the given
-    status. Any remaining text in the args string will be interpreted
-    as search terms. These will be split into separate terms on space
+    status. If this first word is missing, this command will display
+    only unsolved puzzles by default.
+
+    Any remaining text in the args string will be interpreted as
+    search terms. These will be split into separate terms on space
     characters, (though quotation marks can be used to include a space
     character in a term). All terms must match on a puzzle in order
     for that puzzle to be included. But a puzzle will be considered to
@@ -812,3 +1068,77 @@ def hunt(turb, body, args):
     return lambda_ok
 
 commands["/hunt"] = hunt
+
+def round(turb, body, args):
+    """Implementation of the /round command
+
+    Displays puzzles in the same round(s) as the puzzle for the
+    current channel.
+
+    The (optional) args string can be used to filter which puzzles to
+    display. The first word can be one of 'all', 'unsolved', or
+    'solved' and can be used to display only puzzles with the given
+    status. If this first word is missing, this command will display
+    all puzzles in the round by default.
+
+    Any remaining text in the args string will be interpreted as
+    search terms. These will be split into separate terms on space
+    characters, (though quotation marks can be used to include a space
+    character in a term). All terms must match on a puzzle in order
+    for that puzzle to be included. But a puzzle will be considered to
+    match if any of the puzzle title, round title, puzzle URL, puzzle
+    state, or puzzle solution match. Matching will be performed
+    without regard to case sensitivity and the search terms can
+    include regular expression syntax.
+    """
+
+    channel_id = body['channel_id'][0]
+    response_url = body['response_url'][0]
+
+    puzzle = puzzle_for_channel(turb, channel_id)
+    hunt = hunt_for_channel(turb, channel_id)
+
+    if not puzzle:
+        if hunt:
+            return bot_reply(
+                "This is not a puzzle channel, but is a hunt channel. "
+                + "Use /hunt if you want to see all rounds for this hunt.")
+        else:
+            return bot_reply(
+                "Sorry, this channel doesn't appear to be a puzzle channel "
+                + "so the `/round` command cannot work here.")
+
+    terms = None
+    if args:
+        # The first word can be a puzzle status and all remaining word
+        # (if any) are search terms. _But_, if the first word is not a
+        # valid puzzle status ('all', 'unsolved', 'solved'), then all
+        # words are search terms and we default status to 'unsolved'.
+        split_args = args.split(' ', 1)
+        status = split_args[0]
+        if (len(split_args) > 1):
+            terms = split_args[1]
+        if status not in ('unsolved', 'solved', 'all'):
+            terms = args
+            status = 'all'
+    else:
+        status = 'all'
+
+    # Separate search terms on spaces (but allow for quotation marks
+    # to capture spaces in a search term)
+    if terms:
+        terms = shlex.split(terms)
+
+    blocks = hunt_blocks(turb, hunt,
+                         puzzle_status=status, search_terms=terms,
+                         limit_to_rounds=puzzle.get('rounds', [])
+                         )
+
+    requests.post(response_url,
+                  json = { 'blocks': blocks },
+                  headers = {'Content-type': 'application/json'}
+                  )
+
+    return lambda_ok
+
+commands["/round"] = round