SlackBlockBuilder

SlackBlockBuilder will format a given Slack block into a format readable by the SlackV3 integration. The script will also send the block to the given destination. Make sure to mark **Trust any certificate** and fill the **XSOAR API Key integration** parameters if you want to get a response to the incident context.

python · Slack

Source

import traceback
import urllib.parse
from typing import Any

import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401

DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
""" STANDALONE FUNCTION """


class Commands:
    SEND_NOTIFICATION = "send-notification"
    ADD_ENTITLEMENT = "addEntitlement"
    GET_LIST = "getList"


class DefaultValues:
    RESPONSE = "Thank you for your reply."
    ONE_DAY = "1 day"


class ErrorMessages:
    MALFORMED_LIST = (
        "The list that was provided is not a dictionary. Please ensure the entire payload has been copied"
        " from the Block Kit Builder"
    )
    COMMAND_ERROR = "An error has occurred while executing the send-notification command"
    MISSING_DESTINATION = "Either a user or a channel must be provided."
    NOT_FOUND = "Item not found (8)"
    MISSING_URL_OR_LIST = "blocks_url or a list was not specified"
    INVALID_BLOCKS_URL = "An error has occurred while parsing the blocks_url argument."


class BlockCarrier:
    def __init__(
        self,
        url: str = "",
        list_name: Optional[str] = None,
        task: Optional[str] = None,
        persistent: Optional[str] = None,
        reply_entries_tag: Optional[str] = None,
        lifetime: str = DefaultValues.ONE_DAY,
        reply: Optional[str] = None,
        default_response: str = DefaultValues.RESPONSE,
    ):
        """Used to format blocks for the send-notification command

        BlockCarrier will handle several formatting steps for the Slack blocks.
        - First, the investigation_id is retrieved. If none is found, the default is 00. This will give a failsafe for
        entitlements which enter the context and an incident cannot be found.
        - Second, the entitlement string is built. This requires a server call to generate the GUID. From there, we
        append information needed by SlackV3 in order to process any given reply.
        - Depending on the inputs, if a list name is given, then _retrieve_blocks_from_list is called and will make a
        server call to retrieve the stored blocks. If a URL is given, then _parse_url_blocks is called, and we will
        extract the blocks out of the URL given by the Slack Block Builder tool.

        Args:
            url (str): The Slack Builder URL. Not required if list_name is provided.
            list_name (str): The Name of the XSOAR list. Not required if url is provided.
            task (str): The ID for the task which will be closed when a response is received.
            persistent (str): Indicates if the entitlement should remain available after a reply is received.
            reply_entries_tag (str): The tag which should be added to entries containing the reply.
            lifetime (str): The TTL for the entitlement. This gets converted to an expiry time.
            reply (str): The message a user should receive when a response has been submitted.
            default_response (str): If the entitlement expires, this will be the response submitted to the incident.
        Raises:
            ValueError: If neither the url nor list_name arg is not provided, will raise an exception.
        """
        self.entitlement_string = ""
        self.default_response = default_response
        self.blocks_ready_for_args: dict[str, Any] = {}
        self.url = url
        self.list_name = list_name
        self.persistent = persistent
        self.reply_entries_tag = reply_entries_tag
        self.task = task
        self.blocks_dict: list[dict] = list({})
        self.investigation_id = demisto.investigation().get("id", "00")
        self.reply = reply
        self._build_entitlement()

        if self.list_name:
            self._retrieve_blocks_from_list()
        elif self.url:
            self._parse_url_blocks()
        else:
            raise ValueError(ErrorMessages.MISSING_URL_OR_LIST)

        parsed_date = dateparser.parse("in " + lifetime, settings={"TIMEZONE": "UTC"})
        assert parsed_date is not None, f"could not parse in {lifetime}"
        self.expiry = datetime.strftime(parsed_date, DATE_FORMAT)

    def _build_entitlement(self):
        """Builds the entitlement string

        Entitlement strings are essential in order to know which incident the response belongs to. This will get fed to
        the send-notification command and stored into the SlackV3 integration context.
        """
        res = demisto.executeCommand(
            command=Commands.ADD_ENTITLEMENT, args={"persistent": self.persistent, "replyEntriesTag": self.reply_entries_tag}
        )
        if isError(res[0]):
            raise DemistoException(message=res)
        entitlement = demisto.get(obj=res[0], field="Contents")
        self.entitlement_string = entitlement + "@" + self.investigation_id
        if self.task:
            self.entitlement_string += "|" + self.task

    def _add_block_ids(self):
        """Populates block_ids into a given array of blocks

        Slack will give pre-populate the block_id fields if we do not specify them. This function will iterate over the
        elements of a block array and set the block_id field based on the label (in the case of input blocks) or
        action_ids in the case of everything else. We also increment an integer in order to keep each key as unique
        since this will inevitably be placed in the context of the incident.
        """
        action_id_int: int = 0
        for block in self.blocks_dict:
            if block.get("type") == "input":
                action_id: str = block.get("element", {}).get("type", "")
                block["block_id"] = action_id + "_" + str(action_id_int)
                action_id_int += 1
            elif block.get("type") == "actions":
                if "elements" in block:
                    actions_block_id: str = block.get("elements", [{}])[0].get("type", "")
                    block["block_id"] = actions_block_id + "_" + str(action_id_int)
                    action_id_int += 1
            elif block.get("type") == "section" and "accessory" in block:
                sec_action_id: str = block.get("accessory", {}).get("type", "")
                if sec_action_id != "image":
                    block["block_id"] = sec_action_id + "_" + str(action_id_int)
                    block["accessory"]["action_id"] = sec_action_id + str(action_id_int)
                    action_id_int += 1

    def _add_submit_button(self):
        """Adds a submit button with a known action_id

        We need to ensure that there will always be a button with a known action_id. SlackV3 will listen for blocks
        containing this action_id and then pull the state of the inputs.
        """
        value = json.dumps({"entitlement": self.entitlement_string, "reply": self.reply})
        self.blocks_dict.append(
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "Submit", "emoji": True},
                        "value": value,
                        "action_id": "xsoar-button-submit",
                    }
                ],
            }
        )

    def _parse_url_blocks(self):
        """Parses the Slack Block Builder URL

        Slack provides a tool located at https://app.slack.com/block-kit-builder. When you are done, you can copy and
        paste the URL into the blocks_url argument for this automation. The URL is then decoded to provide the blocks.

        Regex is used to extract the URL-encoded blocks from the provided URL. The URL is expected to have a format
        where the blocks are appended after the first '#' symbol.

        Raises:
            DemistoException: If no blocks are found in the URL or if there is an error while parsing the blocks.

        """
        try:
            if match_url_encoded_blocks := re.search(r"#(.+)", self.url):
                url_encoded_blocks = match_url_encoded_blocks[1]
                url_decoded_blocks = urllib.parse.unquote(url_encoded_blocks)
                parsed_blocks = json.loads(url_decoded_blocks)
                self.blocks_dict = parsed_blocks.get("blocks", [{}])
                demisto.debug(f"Parsed blocks: {self.blocks_dict}")
            else:
                raise DemistoException("No blocks found in URL")
        except Exception as e:
            demisto.debug(f"Failed to parse blocks from URL, {e!s}")
            raise DemistoException(ErrorMessages.INVALID_BLOCKS_URL)

    def _retrieve_blocks_from_list(self):
        """Retrieves the blocks when given an XSOAR list name.

        It may be useful to store the blocks which are used frequently as an item in the XSOAR lists. This function will
        retrieve the blocks from the list and set them in the BlockCarrier object.
        Raises:
            ValueError: If the list was not located, or the list contains an invalid JSON.
        """
        res = demisto.executeCommand(command=Commands.GET_LIST, args={"listName": self.list_name})
        if (
            not isinstance(res, list)
            or "Contents" not in res[0]
            or not isinstance(res[0]["Contents"], str)
            or res[0]["Contents"] == ErrorMessages.NOT_FOUND
        ):
            raise ValueError(
                f"Cannot retrieve list {self.list_name}. Please verify the name is correct. If you have"
                f" not created a list before, please refer to https://xsoar.pan.dev/docs/incidents/"
                f"incident-lists for more information."
            )
        data: str = res[0]["Contents"]
        if data and len(data) > 0:
            try:
                parsed_blocks: Any = json.loads(data)
                try:
                    assert isinstance(parsed_blocks, dict)
                except AssertionError:
                    raise DemistoException(ErrorMessages.MALFORMED_LIST)
                self.blocks_dict = parsed_blocks.get("blocks", [{}])

            except json.decoder.JSONDecodeError as e:
                raise ValueError(f"List does not contain valid JSON data: {e}")

    def _blocks_formatted_for_command(self):
        """Formats the Blocks for the send-notification command.

        Inevitably, the blocks will need to be converted to a json string and then fed into the send-notification command.
        This handles that process.
        """
        self.blocks_ready_for_args = {
            "blocks": json.dumps(self.blocks_dict),
            "entitlement": self.entitlement_string,
            "reply": self.reply,
            "expiry": self.expiry,
            "default_response": self.default_response,
        }

    def format_blocks(self):
        """Finalizes the blocks for the send-notification command.

        Before calling the send-notification command, we finalize the blocks by adding the submit button and the
        block_ids. Lastly, we dump the results as a string in the blocks_as_json_str field.
        """
        self._add_submit_button()
        self._add_block_ids()
        self._blocks_formatted_for_command()


class SendNotification:
    def __init__(
        self,
        blocks_carrier: BlockCarrier,
        slack_instance: Optional[str] = None,
        to: Optional[str] = None,
        channel_id: Optional[str] = None,
        channel: Optional[str] = None,
        threadID: Optional[str] = None,
    ):
        self.blocks_carrier: BlockCarrier = blocks_carrier
        self.send_response: list = []
        self.command_args: dict = {
            "ignoreAddURL": "true",
            "using-brand": "SlackV3",
            "blocks": json.dumps(self.blocks_carrier.blocks_ready_for_args),
        }
        if slack_instance:
            self.command_args["using"] = slack_instance
        # Set if the thread_id argument was passed in by the end user
        if threadID:
            self.command_args["threadID"] = threadID
        # Determine Destination, Raise error if not given
        if to:
            self.command_args["to"] = to
        elif channel_id:
            self.command_args["channel_id"] = channel_id
        elif channel:
            self.command_args["channel"] = channel
        else:
            raise DemistoException(message=ErrorMessages.MISSING_DESTINATION)

    def send(self):
        """Executes the send-notification command.

        Sends the blocks to the given destination. Will then store the response from the command.
        Raises:
            ValueError: If an error occurs while sending the notification.
        """
        try:
            self.send_response = demisto.executeCommand(Commands.SEND_NOTIFICATION, self.command_args)
        except ValueError as e:
            raise DemistoException(message=ErrorMessages.COMMAND_ERROR, exception=e)


""" COMMAND FUNCTION """


def slack_block_builder_command(args: dict[str, Any]):
    """Executes the block_builder command.

    Args:
        args (Dict[str, Any]): The demisto.args() object.

    Returns:
        CommandResults: Will contain the response from the send-notification command.
    """
    blocks_url: str = demisto.get(obj=args, field="blocks_url", defaultParam=None)
    list_name: str = demisto.get(obj=args, field="list_name", defaultParam=None)
    slack_instance = demisto.get(obj=args, field="slackInstance")
    lifetime = demisto.get(obj=args, field="lifetime", defaultParam="1 day")
    reply = demisto.get(obj=args, field="reply")
    task = demisto.get(obj=args, field="task")
    persistent = demisto.get(obj=args, field="persistent")
    reply_entries_tag = demisto.get(obj=args, field="reply_entries_tag")
    default_response = demisto.get(obj=args, field="defaultResponse")
    to = demisto.get(obj=args, field="user")
    channel = demisto.get(obj=args, field="channel")
    channel_id = demisto.get(obj=args, field="channel_id")
    thread_id = demisto.get(obj=args, field="thread_id")

    block_carrier = BlockCarrier(
        url=blocks_url,
        list_name=list_name,
        task=task,
        persistent=persistent,
        reply_entries_tag=reply_entries_tag,
        lifetime=lifetime,
        reply=reply,
        default_response=default_response,
    )
    block_carrier.format_blocks()
    notification = SendNotification(
        blocks_carrier=block_carrier,
        slack_instance=slack_instance,
        to=to,
        channel_id=channel_id,
        channel=channel,
        threadID=thread_id,
    )
    notification.send()
    send_response = notification.send_response[0]
    human_readable = send_response["HumanReadable"]
    if isinstance(send_response.get("Contents"), str):
        raise DemistoException(f"{send_response.get('Contents')}")

    # Dict object returned from sending the message; contains Slack metadata
    context_output = {
        "ThreadID": send_response.get("Contents", {}).get("ts"),
        "Channel": send_response.get("Contents", {}).get("channel"),
        "Text": send_response.get("Contents", {}).get("message", {}).get("text"),
        "BotID": send_response.get("Contents", {}).get("message", {}).get("bot_id"),
        "Username": send_response.get("Contents", {}).get("message", {}).get("username"),
        "AppID": send_response.get("Contents", {}).get("message", {}).get("app_id"),
    }
    return CommandResults(readable_output=human_readable, outputs_prefix="SlackBlockBuilder", outputs=context_output)


""" MAIN FUNCTION """


def main():  # pragma: no cover
    try:
        return_results(slack_block_builder_command(demisto.args()))
    except Exception as excep:
        demisto.error(traceback.format_exc())  # print the traceback
        return_error(f"Failed to execute SlackBlockBuilder. Error: {excep!s}")


""" ENTRY POINT """

if __name__ in ("__main__", "__builtin__", "builtins"):  # pragma: no cover
    main()

README

SlackBlockBuilder will format a given Slack block into a format readable by the SlackV3 integration. The script will also send the block to the given destination. Make sure to mark Trust any certificate and fill the XSOAR API Key integration parameters if you want to get a response to the incident context.

The Slack Block Kit Builder can be found here.

Script Data


Name Description
Script Type python3
Tags slack
Cortex XSOAR Version 6.2.0

Use Case


This automation allows you to send a survey to users (including external to Cortex XSOAR) via Slack, and have them respond and
reflect the answer back to Cortex XSOAR. Within the survey, you can use all the different input types that are available
via the Slack Block Kit, including buttons, text fields, datepickers, radio buttons, multi select, and more.

See this blog post for more detailed use case information and ideas.

Dependencies


Requires an instance of the Slack v3 integration.

This script uses the following commands and scripts.

  • send-notification

Setup Requirements

  1. In order to use SlackBlockBuilder, you must enter an API key created by a default admin user (username admin by default) into the Slack v3 integration instance settings. The API key must be generated by default admin, not just any user.
    API_Key

  2. Ensure the “Long running instance” checkbox is checked in the Slack v3 integration instance settings.
    Long_running

Inputs


Argument Name Description
blocks_url The URL copied from Slack’s Block Builder.
list_name The name of the Cortex XSOAR list to use as the block’s input.
user The Slack user to which to send the message. Can be either an email address or a Slack user name.
channel The Slack channel to send the message to.
channel_id The Slack channel ID to send the message to.
task The task to close with the reply. If empty, then no playbook tasks will be closed.
replyEntriesTag Tag to add to email reply entries.
persistent Indicates whether to use one-time entitlement or persistent entitlement.
reply The reply to send to the user. Use the templates {user} and {response} to incorporate these in the reply. (i.e., “Thank you {user}. You have answered {response}.”)
lifetime Time until the question expires. For example - 1 day. When it expires, a default response is sent.
defaultResponse Default response in case the question expires.
slackInstance The instance of SlackV3 this script should use.
thread_id The ID of the thread to which to reply. Can be retrieved from a previous send-notification command.

Outputs


Path Description Type
SlackBlockState The State of the response from the user will be stored under this context path. unknown

Note
Make sure to configure the “XSOAR API Key” instance parameter in the “Slack v3” integration.

To get the API Key:

  1. Go to Settings -> API keys.
  2. Click Get Your Key.
  3. Type a name for the key and click Generate key.
  4. Copy and paste the key in the instance parameter.

Command Example using blocks_url


!SlackBlockBuilder blocks_url=https://app.slack.com/block-kit-builder/T0DAYMVCM#%7B%22blocks%22:%5B%7B%22type%22:%22section%22
channel=random
task=4
replyEntriesTag=slackResponse
persistent=yes

Human Readable Output using blocks_url


Message sent to Slack successfully.
Thread ID is: 1660645689.649679

Command Example using list_name


!SlackBlockBuilder list_name=MySlackBlocksList channel=random task=4 replyEntriesTag=slackResponse persistent=yes

Human Readable Output using list_name


Message sent to Slack successfully.
Thread ID is: 1660645689.649679

Troubleshooting


Issue: The survey message is not sent to Slack.

Troubleshooting: Test the send-notification command on its own to verify it is working correctly. This will provide a more specific error message if there is an issue. Note that the Slack API bot you set up for the Slack v3 integration needs to be added to any channels you want to send SlackBlockBuilder surveys to.

Verify your Slack blocks payload is valid. Try simplifying the payload. Test with a simple dummy payload like the following:

{
 "blocks": [
  {
   "type": "section",
   "text": {
    "type": "plain_text",
    "text": "hello world",
    "emoji": true
   }
  }
 ]
}

Issue: The survey is sent to Slack and submitted successfully, but the response does not show up in context data in Cortex XSOAR.

Troubleshooting: The most likely cause is that there is no API key entered into the Slack v3 integration instance settings, or the API key was not created by default admin. Ensure an API key created by a default admin user is entered into the Slack v3 integration instance settings. Also, make sure to mark the Trust any certificate (not secure) integration parameter.


Issue: The survey is sent to Slack successfully, but clicking the Submit button in Slack does nothing. No response is returned to Cortex XSOAR. There may be a ⚠️ icon next to the Submit button in Slack.

Troubleshooting: Ensure the “Long running instance” checkbox is checked in the Slack v3 integration instance settings.


Issue: Migrating to pack version 3.3.0.

Troubleshooting: # Implementation Guide for Changes in SlackV3 for XSOAR (Version 3.3.0)

1. Review and Identify Affected Playbooks

  • Identify all playbooks currently utilizing the SlackBlockBuilder script or its derivatives.

2. Implement Conditional Tasks

  • Introduce a conditional task in your playbooks that waits for the expected response.
  • Configure the task to proceed once the response is received.

3. Integrate the GetSlackBlockBuilderResponse Script

  • After receiving the response and closing the conditional task, initiate a new task that runs the ParseSlackResponse script.

Issue: Running the script using the playbook debugger results with an error: “invalid_blocks_format”.

Troubleshooting: SlackBlockBuilder will not work when run in the playbook debugger. This is because the debugger does not generate entitlements, since they must be tied to an investigation. Entitlements are needed to track the response. The workaround for this is running the playbook from an existing incident.