]> git.cworth.org Git - turbot/blobdiff - turbot/interaction.py
Report change in a puzzle's solved status to the main hunt channel
[turbot] / turbot / interaction.py
index b50a6588129e9fbf04e117d7ec2a33ddefb70661..bfb759ada53f9395ac0f8c629ec0fd0991a4b55f 100644 (file)
@@ -1,9 +1,15 @@
 from slack.errors import SlackApiError
 from turbot.blocks import (
-    input_block, section_block, text_block, multi_select_block
+    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
+from turbot.puzzle import (
+    find_puzzle_for_url,
+    find_puzzle_for_puzzle_id,
+    puzzle_update_channel_and_sheet,
+    puzzle_id_from_name,
+    puzzle_blocks
+)
 import turbot.rot
 import turbot.sheets
 import turbot.slack
@@ -16,10 +22,16 @@ from turbot.slack import slack_send_message
 import shlex
 
 actions = {}
+actions['button'] = {}
 commands = {}
 submission_handlers = {}
 
 # Hunt/Puzzle IDs are restricted to lowercase letters, numbers, and underscores
+#
+# Note: This restriction not only allows for hunt and puzzle ID values to
+# be used as Slack channel names, but it also allows for '-' as a valid
+# separator between a hunt and a puzzle ID (for example in the puzzle
+# edit dialog where a single attribute must capture both values).
 valid_id_re = r'^[_a-z0-9]+$'
 
 lambda_ok = {'statusCode': 200}
@@ -63,6 +75,198 @@ def multi_static_select(turb, payload):
 
 actions['multi_static_select'] = {"*": multi_static_select}
 
+def edit_puzzle(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)
+
+    puzzle = find_puzzle_for_puzzle_id(turb, hunt_id, puzzle_id)
+
+    if not puzzle:
+        requests.post(response_url,
+                      json = {"text": "Error: Puzzle not found!"},
+                      headers = {"Content-type": "application/json"})
+        return bot_reply("Error: Puzzle not found.")
+
+    round_options = hunt_rounds(turb, hunt_id)
+
+    if len(round_options):
+        round_options_block = [
+            multi_select_block("Round(s)", "rounds",
+                               "Existing round(s) this puzzle belongs to",
+                               round_options,
+                               initial_options=puzzle.get("rounds", None)),
+        ]
+    else:
+        round_options_block = []
+
+    solved = False
+    if puzzle.get("status", "unsolved") == solved:
+        solved = True
+
+    solution_str = None
+    solution_list = puzzle.get("solution", [])
+    if solution_list:
+        solution_str = ", ".join(solution_list)
+
+    view = {
+        "type": "modal",
+        "private_metadata": json.dumps({
+            "hunt_id": hunt_id,
+            "SK": puzzle["SK"],
+            "puzzle_id": puzzle_id,
+            "channel_id": puzzle["channel_id"],
+            "channel_url": puzzle["channel_url"],
+            "sheet_url": puzzle["sheet_url"],
+        }),
+        "title": {"type": "plain_text", "text": "Edit Puzzle"},
+        "submit": { "type": "plain_text", "text": "Save" },
+        "blocks": [
+            input_block("Puzzle name", "name", "Name of the puzzle",
+                        initial_value=puzzle["name"]),
+            input_block("Puzzle URL", "url", "External URL of puzzle",
+                        initial_value=puzzle.get("url", None),
+                        optional=True),
+            * round_options_block,
+            input_block("New round(s)", "new_rounds",
+                        "New round(s) this puzzle belongs to " +
+                        "(comma separated)",
+                        optional=True),
+            input_block("State", "state",
+                        "State of this puzzle (partial progress, next steps)",
+                        initial_value=puzzle.get("state", None),
+                        optional=True),
+            checkbox_block(
+                "Puzzle status", "Solved", "solved",
+                checked=(puzzle.get('status', 'unsolved') == 'solved')),
+            input_block("Solution", "solution",
+                        "Solution(s) (comma-separated if multiple)",
+                        initial_value=solution_str,
+                        optional=True),
+        ]
+    }
+
+    result = turb.slack_client.views_open(trigger_id=trigger_id,
+                                          view=view)
+
+    if (result['ok']):
+        submission_handlers[result['view']['id']] = edit_puzzle_submission
+
+    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
+
+    This is the modal view presented to the user by the edit_puzzle
+    function above.
+    """
+
+    puzzle={}
+
+    # First, read all the various data from the request
+    meta = json.loads(metadata)
+    puzzle['hunt_id'] = meta['hunt_id']
+    puzzle['SK'] = meta['SK']
+    puzzle['puzzle_id'] = meta['puzzle_id']
+    puzzle['channel_id'] = meta['channel_id']
+    puzzle['channel_url'] = meta['channel_url']
+    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
+    rounds = [option['value'] for option in
+              state['rounds']['rounds']['selected_options']]
+    if rounds:
+        puzzle['rounds'] = rounds
+    new_rounds = state['new_rounds']['new_rounds']['value']
+    puzzle_state = state['state']['state']['value']
+    if puzzle_state:
+        puzzle['state'] = puzzle_state
+    if state['solved']['solved']['selected_options']:
+        puzzle['status'] = 'solved'
+    else:
+        puzzle['status'] = 'unsolved'
+    puzzle['solution'] = []
+    solution = state['solution']['solution']['value']
+    if solution:
+        puzzle['solution'] = [
+            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:
+            puzzle['rounds'] = []
+        for round in new_rounds.split(','):
+            # Drop any leading/trailing spaces from the round name
+            round = round.strip()
+            # Ignore any empty string
+            if not len(round):
+                continue
+            puzzle['rounds'].append(round)
+            turb.table.put_item(
+                Item={
+                    'hunt_id': puzzle['hunt_id'],
+                    'SK': 'round-' + round
+                }
+            )
+
+    # 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'])
+
+    # 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
+
 def new_hunt(turb, payload):
     """Handler for the action of user pressing the new_hunt button"""
 
@@ -88,7 +292,7 @@ def new_hunt(turb, payload):
 
     return lambda_ok
 
-actions['button'] = {"new_hunt": new_hunt}
+actions['button']['new_hunt'] = new_hunt
 
 def new_hunt_submission(turb, payload, metadata):
     """Handler for the user submitting the new hunt modal
@@ -365,8 +569,56 @@ 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
+
+    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 len(args):
+        return bot_reply("Unknown syntax for `/puzzle` command. " +
+                         "Use `/puzzle new` to create a new 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)
+
+    requests.post(response_url,
+                  json = {'blocks': blocks},
+                  headers = {'Content-type': 'application/json'}
+                  )
+
+    return lambda_ok
+
+commands["/puzzle"] = puzzle
+
+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]
@@ -412,17 +664,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)
@@ -448,7 +699,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)
@@ -498,41 +749,6 @@ def puzzle_submission(turb, payload, metadata):
 
     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']
-
-    description = ''
-
-    if status == 'solved':
-        description += "SOLVED: `{}` ".format('`, `'.join(puzzle['solution']))
-
-    description += name
-
-    links = []
-    if url:
-        links.append("<{}|Puzzle>".format(url))
-    if sheet_url:
-        links.append("<{}|Sheet>".format(sheet_url))
-
-    if len(links):
-        description += "({})".format(', '.join(links))
-
-    if state:
-        description += " {}".format(state)
-
-    # Slack only allows 250 characters for a topic
-    if len(description) > 250:
-        description = description[:247] + "..."
-
-    turb.slack_client.conversations_setTopic(channel=channel_id,
-                                             topic=description)
-
 def state(turb, body, args):
     """Implementation of the /state command
 
@@ -541,17 +757,20 @@ def state(turb, body, args):
 
     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")
 
-    # Set the state field in the database
+    # Make a copy of the puzzle object
+    puzzle = old_puzzle.copy()
+
+    # Update the puzzle 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
 
@@ -565,15 +784,18 @@ def solved(turb, body, args):
     channel_id = body['channel_id'][0]
     user_name = body['user_name'][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 copy of the puzzle object
+    puzzle = old_puzzle.copy()
+
     # Set the status and solution fields in the database
     puzzle['status'] = 'solved'
     puzzle['solution'].append(args)
@@ -596,19 +818,7 @@ 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