]> git.cworth.org Git - turbot/blobdiff - turbot/interaction.py
Wire up "/edit hunt" to be the same as "/hunt edit"
[turbot] / turbot / interaction.py
index 8ae4a53556d45542ec19dca22471e46b89f8f702..05bbbb88d3c09f5eade01318604df40d330484e1 100644 (file)
@@ -2,8 +2,21 @@ 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.puzzle import find_puzzle_for_url, find_puzzle_for_puzzle_id
+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_sort_key,
+    puzzle_update_channel_and_sheet,
+    puzzle_id_from_name,
+    puzzle_blocks,
+    puzzle_sort_key,
+    puzzle_copy
+)
+from turbot.round import round_quoted_puzzles_titles_answers
 import turbot.rot
 import turbot.sheets
 import turbot.slack
@@ -69,16 +82,50 @@ 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
+
+    This can be used as `/edit hunt` or `/edit puzzle`, (and if issued as
+    just `/edit` will default to editing the current puzzle.
+
+    These are simply shortcuts for `/hunt edit` and `/puzzle edit`.
+    """
+
+    if args == "hunt":
+        return edit_hunt_command(turb, body)
+
+    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)
+
+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,
@@ -86,7 +133,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 = [
@@ -110,9 +168,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"],
@@ -125,6 +183,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 " +
@@ -152,8 +212,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
 
@@ -173,11 +231,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:
@@ -197,6 +260,15 @@ def edit_puzzle_submission(turb, payload, metadata):
             sol.strip() for sol in solution.split(',')
         ]
 
+    # Verify that there's a solution if the puzzle is mark solved
+    if puzzle['status'] == 'solved' and not puzzle['solution']:
+        return submission_error("solution",
+                                "A solved puzzle requires a solution.")
+
+    if puzzle['status'] == 'unsolved' and puzzle['solution']:
+        return submission_error("solution",
+                                "An unsolved puzzle should have no solution.")
+
     # Add any new rounds to the database
     if new_rounds:
         if 'rounds' not in puzzle:
@@ -215,26 +287,209 @@ def edit_puzzle_submission(turb, payload, metadata):
                 }
             )
 
+    # Get old puzzle from the database (to determine what's changed)
+    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.
+    puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
+
+    return lambda_ok
 
-    # XXX: What we really want here is a single function that sets the
-    # channel name, the channel topic, and the sheet name. That single
-    # function should be called anywhere there is code changing any of
-    # these things. This function could then also accept an optional
-    # "old_puzzle" argument and avoid changing any of those things
-    # that are unnecessary.
-    set_channel_topic(turb, puzzle)
+def edit_hunt_command(turb, body):
+    """Implementation of the `/hunt edit` command
+
+    As dispatched from the hunt() function.
+    """
+
+    channel_id = body['channel_id'][0]
+    trigger_id = body['trigger_id'][0]
+
+    hunt = hunt_for_channel(turb, channel_id)
+
+    if not hunt:
+        return bot_reply("Sorry, this does not appear to be a hunt channel.")
+
+    return edit_hunt(turb, hunt, trigger_id)
+
+def edit_hunt_button(turb, payload):
+    """Handler for the action of user pressing an edit_hunt button"""
+
+    hunt_id = payload['actions'][0]['action_id']
+    response_url = payload['response_url']
+    trigger_id = payload['trigger_id']
+
+    hunt = find_hunt_for_hunt_id(hunt_id)
+
+    if not hunt:
+        requests.post(response_url,
+                      json = {"text": "Error: Hunt not found!"},
+                      headers = {"Content-type": "application/json"})
+        return bot_reply("Error: Hunt not found.")
+
+    return edit_hunt(turb, hunt, trigger_id)
+
+actions['button']['edit_hunt'] = edit_hunt_button
+
+def edit_hunt(turb, hunt, trigger_id):
+    """Common code for implementing an edit hunt dialog
+
+    This implementation is common whether the edit operation was invoked
+    by a button (edit_hunt_button) or a command (edit_hunt_command).
+    """
+
+    view = {
+        "type": "modal",
+        "private_metadata": json.dumps({
+            "hunt_id": hunt["hunt_id"],
+            "SK": hunt["SK"],
+            "is_hunt": hunt["is_hunt"],
+            "channel_id": hunt["channel_id"],
+            "sheet_url": hunt["sheet_url"],
+            "folder_id": hunt["folder_id"],
+        }),
+        "title": { "type": "plain_text", "text": "Edit Hunt" },
+        "submit": { "type": "plain_text", "text": "Save" },
+        "blocks": [
+            input_block("Hunt name", "name", "Name of the hunt",
+                        initial_value=hunt["name"]),
+            input_block("Hunt URL", "url", "External URL of hunt",
+                        initial_value=hunt.get("url", None),
+                        optional=True),
+            checkbox_block("Is this hunt active?", "Active", "active",
+                           checked=(hunt.get('active', False)))
+        ]
+    }
+
+    result = turb.slack_client.views_open(trigger_id=trigger_id,
+                                          view=view)
+
+    if result['ok']:
+        submission_handlers[result['view']['id']] = edit_hunt_submission
 
     return lambda_ok
 
-def new_hunt(turb, payload):
+def edit_hunt_submission(turb, payload, metadata):
+    """Handler for the user submitting the edit hunt modal
+
+    This is the modal view presented by the edit_hunt function above.
+    """
+
+    hunt={}
+
+    # First, read all the various data from the request
+    meta = json.loads(metadata)
+    hunt['hunt_id'] = meta['hunt_id']
+    hunt['SK'] = meta['SK']
+    hunt['is_hunt'] = meta['is_hunt']
+    hunt['channel_id'] = meta['channel_id']
+    hunt['sheet_url'] = meta['sheet_url']
+    hunt['folder_id'] = meta['folder_id']
+
+    state = payload['view']['state']['values']
+    user_id = payload['user']['id']
+
+    hunt['name'] = state['name']['name']['value']
+    url = state['url']['url']['value']
+    if url:
+        hunt['url'] = url
+
+    if state['active']['active']['selected_options']:
+        hunt['active'] = True
+    else:
+        hunt['active'] = False
+
+    # Update the hunt in the database
+    turb.table.put_item(Item=hunt)
+
+    # Inform the hunt channel about the edit
+    edit_message = "Hunt edited by <@{}>".format(user_id)
+    blocks = [
+        section_block(text_block(edit_message)),
+        section_block(text_block("Hunt name: {}".format(hunt['name']))),
+    ]
+
+    url = hunt.get('url', None)
+    if url:
+        blocks.append(
+            section_block(text_block("Hunt URL: {}".format(hunt['url'])))
+        )
+
+    slack_send_message(
+        turb.slack_client, hunt['channel_id'],
+        edit_message, blocks=blocks)
+
+    return lambda_ok
+
+def new_hunt_command(turb, body):
+    """Implementation of the '/hunt new' command
+
+    As dispatched from the hunt() function.
+    """
+
+    trigger_id = body['trigger_id'][0]
+
+    return new_hunt(turb, trigger_id)
+
+def new_hunt_button(turb, payload):
     """Handler for the action of user pressing the new_hunt button"""
 
+    trigger_id = payload['trigger_id']
+
+    return new_hunt(turb, trigger_id)
+
+def new_hunt(turb, trigger_id):
+    """Common code for implementing a new hunt dialog
+
+    This implementation is common whether the operations was invoked
+    by a button (new_hunt_button) or a command (new_hunt_command).
+    """
+
     view = {
         "type": "modal",
         "private_metadata": json.dumps({}),
@@ -250,7 +505,7 @@ def new_hunt(turb, payload):
         ],
     }
 
-    result = turb.slack_client.views_open(trigger_id=payload['trigger_id'],
+    result = turb.slack_client.views_open(trigger_id=trigger_id,
                                           view=view)
     if (result['ok']):
         submission_handlers[result['view']['id']] = new_hunt_submission
@@ -534,8 +789,102 @@ 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.get('type', 'plain') == '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
+
+    This can be used to create a new hunt ("/new hunt") or a new
+    puzzle ("/new puzzle" or simply "/new"). So puzzle creation is the
+    default behavior (as it is much more common).
+
+    This operations are identical to the existing "/hunt new" and
+    "/puzzle new". I don't know that that redundancy is actually
+    helpful in the interface. But at least having both allows us to
+    experiment and decide which is more natural and should be kept
+    around long-term.
+    """
+
+    if args == 'hunt':
+        return new_hunt_command(turb, body)
+
+    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]
@@ -569,6 +918,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 " +
@@ -581,17 +931,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)
@@ -600,6 +949,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']]
@@ -617,7 +970,7 @@ def puzzle_submission(turb, payload, metadata):
                 "Error: A puzzle with this URL already exists.")
 
     # Create a Slack-channel-safe puzzle_id
-    puzzle_id = re.sub(r'[^a-zA-Z0-9_]', '', name).lower()
+    puzzle_id = puzzle_id_from_name(name)
 
     # Create a channel for the puzzle
     hunt_dash_channel = "{}-{}".format(hunt_id, puzzle_id)
@@ -649,82 +1002,121 @@ 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
 
-# XXX: This duplicates functionality eith events.py:set_channel_description
-def set_channel_topic(turb, puzzle):
-    channel_id = puzzle['channel_id']
-    name = puzzle['name']
-    url = puzzle.get('url', None)
-    sheet_url = puzzle.get('sheet_url', None)
-    state = puzzle.get('state', None)
-    status = puzzle['status']
+def state(turb, body, args):
+    """Implementation of the /state command
 
-    description = ''
+    The args string should be a brief sentence describing where things
+    stand or what's needed."""
 
-    if status == 'solved':
-        description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
+    channel_id = body['channel_id'][0]
 
-    description += name
+    old_puzzle = puzzle_for_channel(turb, channel_id)
 
-    links = []
-    if url:
-        links.append("<{}|Puzzle>".format(url))
-    if sheet_url:
-        links.append("<{}|Sheet>".format(sheet_url))
+    if not old_puzzle:
+        return bot_reply(
+            "Sorry, the /state command only works in a puzzle channel")
 
-    if len(links):
-        description += "({})".format(', '.join(links))
+    # Make a deep copy of the puzzle object
+    puzzle = puzzle_copy(old_puzzle)
 
-    if state:
-        description += " {}".format(state)
+    # Update the puzzle in the database
+    puzzle['state'] = args
+    turb.table.put_item(Item=puzzle)
 
-    # Slack only allows 250 characters for a topic
-    if len(description) > 250:
-        description = description[:247] + "..."
+    puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
 
-    turb.slack_client.conversations_setTopic(channel=channel_id,
-                                             topic=description)
+    return lambda_ok
 
-def state(turb, body, args):
-    """Implementation of the /state command
+commands["/state"] = state
 
-    The args string should be a brief sentence describing where things
-    stand or what's needed."""
+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]
 
-    puzzle = puzzle_for_channel(turb, channel_id)
+    old_puzzle = puzzle_for_channel(turb, channel_id)
 
-    if not puzzle:
+    if not old_puzzle:
         return bot_reply(
-            "Sorry, the /state command only works in a puzzle channel")
+            "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-Z0-9_]*$', tag):
+        return bot_reply("Sorry, tags can only contain letters, numbers, "
+                         + "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 deep copy of the puzzle object
+    puzzle = puzzle_copy(old_puzzle)
+
+    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)
 
-    # Set the state field in the database
-    puzzle['state'] = args
     turb.table.put_item(Item=puzzle)
 
-    set_channel_topic(turb, puzzle)
+    puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
 
     return lambda_ok
 
-commands["/state"] = state
+commands["/tag"] = tag
 
 def solved(turb, body, args):
     """Implementation of the /solved command
@@ -732,17 +1124,20 @@ def solved(turb, body, args):
     The args string should be a confirmed solution."""
 
     channel_id = body['channel_id'][0]
-    user_name = body['user_name'][0]
+    user_id = body['user_id'][0]
 
-    puzzle = puzzle_for_channel(turb, channel_id)
+    old_puzzle = puzzle_for_channel(turb, channel_id)
 
-    if not puzzle:
+    if not old_puzzle:
         return bot_reply("Sorry, this is not a puzzle channel.")
 
     if not args:
         return bot_reply(
             "Error, no solution provided. Usage: `/solved SOLUTION HERE`")
 
+    # Make a deep copy of the puzzle object
+    puzzle = puzzle_copy(old_puzzle)
+
     # Set the status and solution fields in the database
     puzzle['status'] = 'solved'
     puzzle['solution'].append(args)
@@ -753,7 +1148,7 @@ def solved(turb, body, args):
     # Report the solution to the puzzle's channel
     slack_send_message(
         turb.slack_client, channel_id,
-        "Puzzle mark solved by {}: `{}`".format(user_name, args))
+        "Puzzle mark solved by <@{}>: `{}`".format(user_id, args))
 
     # Also report the solution to the hunt channel
     hunt = find_hunt_for_hunt_id(turb, puzzle['hunt_id'])
@@ -765,45 +1160,43 @@ def solved(turb, body, args):
     )
 
     # And update the puzzle's description
-    set_channel_topic(turb, puzzle)
-
-    # And rename the sheet to suffix with "-SOLVED"
-    turbot.sheets.renameSheet(turb, puzzle['sheet_url'],
-                              puzzle['name'] + "-SOLVED")
-
-    # Finally, rename the Slack channel to add the suffix '-solved'
-    channel_name = "{}-{}-solved".format(
-        puzzle['hunt_id'],
-        puzzle['puzzle_id'])
-    turb.slack_client.conversations_rename(
-        channel=puzzle['channel_id'],
-        name=channel_name)
+    puzzle_update_channel_and_sheet(turb, puzzle, old_puzzle=old_puzzle)
 
     return lambda_ok
 
 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
     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.
+    state, puzzle type, tags, 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]
 
+    # First, farm off "/hunt new" and "/hunt edit" a separate commands
+    if args == "new":
+        return new_hunt_command(turb, body)
+
+    if args == "edit":
+        return edit_hunt_command(turb, body)
+
     terms = None
     if args:
         # The first word can be a puzzle status and all remaining word
@@ -841,3 +1234,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