Exploring the Slack SDK
Posted: October 17, 2023

Exploring the Slack SDK

Table of Contents:

  • Setting Up the Slack SDK

    • The Slack App Metadata
    • View Definition Metadata
  • Headless Slack SDK Usage From Apex

    • Special Considerations
  • Slack-Started Interactions

    • Slash Commands & Modals
    • RunnableHandlers - For Behind The Scenes Processing
    • Event Dispatchers
    • Action Dispatchers
  • Unit Testing Your Slack App

  • Conclusion

Now that the Slack SDK for Apex is in beta, we have access to a wealth of Slack & Salesforce-based interactions. After spending some time with the SDK, I’d like to provide a broad overview on two areas in which I think there’s a ton of potential for us to add value:

  • what I’ll be referring to as “headless” usage of the SDK — interacting with Slack users when the starting point is Salesforce
  • interacting with Salesforce data and building out rich bot-based experiences within Slack

Both of these options are absolute game-changers for any org using Salesforce & Slack. The first allows you to more seamlessly notify your users; the second allows you to keep conversations (and their search history) available to users when those workflows make sense to keep in Slack.

Setting Up the Slack SDK

Let’s get started. There’s a few new pieces of metadata that we’re going to be interacting with when using the SDK:

SFDX root (./force-app/main/default for most people)
│
└───classes
│ │ ...
│
└───slackapps
│ │ ExampleSlackApp.slackapp
│ │ ExampleSlackApp.slackapp-meta.xml
│
└───viewdefinitions
│ │ ExampleView.view
│ │ ExampleView.view-meta.xml

The Slack App Metadata

The most basic .slackapp file doesn’t have much:

description: This is pretty much all you need to start off!

As we proceed, you’ll note that while the file ending is .slackapp, we’re really dealing with YAML here. The .slackapp-meta.xml file has the actual good stuff:

<?xml version="1.0" encoding="UTF-8"?>
<SlackApp xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>59.0</apiVersion>
    <!-- This is the appId on api.slack.com-->
    <appKey>redacted</appKey>
    <appToken>redacted</appToken>
    <!-- for more on scopes, check out https://developer.salesforce.com/docs/platform/salesforce-slack-sdk/guide/scopes.html -->
    <botScopes>im:read,im:write,chat:write,chat:write.public,incoming-webhook,channels:history,groups:history,channels:read,reactions:read,commands</botScopes>
    <!-- This is the clientId on api.slack.com-->
    <clientKey>redacted</clientKey>
    <clientSecret>redacted</clientSecret>
    <isProtected>false</isProtected>
    <masterLabel>Something totally unique</masterLabel>
    <signingSecret>redacted</signingSecret>
</SlackApp>

There’s a good guide of steps to follow in the docs (under Get Started -> Deploy a Slack App) but, if you’re simply following along at home, this file should immediately raise some red flags. The redacted characters are suppposed to be replaced by sensitive data that are supplied via your slack instance. What that means, in practice, for anybody using source control (and that should be everybody) is that you’re going to have to mask the data in this particular file, and inject those sensitive values via CI. I won’t be spending a lot of time on this subject, other than to flag it for you.

Anyway. You’ll end up, by following that guide or because you have some pre-existing app you now want to use with the SDK, at something like https://api.slack.com/apps/{someSlackAppId} which is where you can find the values for all of the redacted values above.

Once you have those values set up properly, you’ll be able to deploy your app to any given org using SFDX. Note that the only place in Setup that you can see these apps is within Setup -> Slack -> System Users Setup. This is important in the sense that you can’t actually delete much of the Slack-related metadata without using the CLI. It’s also important because you’ll need to map, at the very least, the default system user for your application. Many of the routes that a user goes through when interacting with the bot will actually be performed by the mapped system user.

After deploying your metadata, go back to Slack. On the left sidebar, click the “Manage Distribution” tab, and use the embeddable Slack button or one of the sharable urls to perform the OAuth step between Slack and the Salesforce org you’re going to be developing in. Note that while you can authorize a Slack application to multiple Salesforce orgs, actions taken in Salesforce can only be sent to a single Slack workspace per Slack App.

View Definition Metadata

Once our Slack app has been deployed, we can start developing in earnest. That brings us to the viewdefinitions folder and its files:

# in ExampleView.view
description: "Hey! This is an example!!"
schema:
  properties:
    headerText:
      type: string
      # note that you can mark properties as required AND supply defaults, which is pretty nice
      defaultValue: "Hello world"
    message:
      type: string
      defaultValue: "Not overridden from within view"
components:
  - definition: message
    components:
      - definition: header
        properties:
          text: "{!view.properties.headerText}"
      - definition: divider
      - definition: section
        properties:
          text: "{!view.properties.message}"

There’s … a bit to unpack here, for those who have never interacted with the Slack Block Kit Builder before. The most excellent news is that these YAML “components” defined in your views can take advantage of the rich, pre-built component types. The docs do a great job of translating how the pre-existing YAML blocks map to the components you define in your view file. I’ve provided a very simple example above, which we’ll take with us into the “headless” Apex section.

The XML file isn’t much to write home about:

<!-- in ExampleView.view-meta.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<ViewDefinition xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>59.0</apiVersion>
    <isProtected>false</isProtected>
    <masterLabel>Example View</masterLabel>
    <targetType>slack</targetType>
</ViewDefinition>

That’s pretty much all we need prior to moving onwards to Apex. Let’s go!

Headless Slack SDK Usage From Apex

Headless usage of the SDK uses Salesforce as the starting point for an interaction. This is the Apex equivalent to the Salesforce for Slack package’s flow actions. However, there are a couple of limitations when Apex is the starting point that are important to keep in mind (the Flow actions have these same limitations):

  • you need to know the Slack workspace Id that you’re going to be using to send messages to
  • you need to know the Slack channel Id you’re going to be sending messages to
  • you need to know the Slack App you’ll be working with, as well as whether or not you’re going to be acting as a “bot” or on a user’s behalf, when sending messages

Let’s take a look at what that looks like:

new SlackMessenger().sendMessage('ExampleApp', 'someChannelId');

// and then ...
public class SlackMessenger {
  public Slack.ChatPostMessageResponse sendMessage(String appName, String channelId) {
    Slack.ChatPostMessageRequest req = Slack.ChatPostMessageRequest.builder()
      .channel(channelId)
      .viewReference(Slack.ViewReference.getViewByName('ExampleView'))
      .build();
    return Slack.App.getAppByName(appName)?
      //                    👇 redacted Slack team Id
      .getBotClientForTeam('T*******').chatPostMessage(req);
  }
}

So the slack “team” Id (also known as your Slack workspace Id) is another piece of potentially sensitive data that you’ll need to inject into your application in order to keep it fully secure.

Note the usage of getViewByName and getAppByName. There are statically-typed references to both Slack Apps and views:

  • Slack.App.ExampleApp.get()
  • Slack.View.ExampleView.get()

And using those references is in general what I’d recommend, unless you’re prototyping; if you haven’t settled on the names for View Definitions or the Slack App you’ll be using, I’d recommend using the string references as it becomes a lot easier to use the CLI to delete that metadata without the strongly typed references in your codebase.

If I were to send a message to myself using this example, here’s what it would look like:

Example with no View parameters

If, instead, I were to cache the View Reference and set a parameter on it like such:


public class SlackMessenger {
  Slack.ViewReference view = Slack.ViewReference.getViewByName('ExampleView');
  // first argument is the parameter key - the second is the value
  view.setParameter('headerText', 'Custom Header');
  view.setParameter('message', 'Overridden!');

  public Slack.ChatPostMessageResponse sendMessage(String appName, String channelId) {
    Slack.ChatPostMessageRequest req = Slack.ChatPostMessageRequest.builder()
      .channel(channelId)
      .viewReference(view)
      .build();
    return Slack.App.getAppByName(appName)?.getBotClientForTeam('T*******').chatPostMessage(req);
  }
}
Slack.ViewReference view = Slack.View.ACCENG_Default.get();
view.setParameter('headerText', 'Custom Header');
view.setParameter('message', 'Overridden!');

Then I’d end up with:

Example with custom parameters

So that’s pretty cool. Note that you can use Slack’s mrkdown format to create rich text (like bolding, italics, strikethroughs, hyperlinking, etc …) but you need to ensure any view property you’re using is properly annotated (using message as an example) in the View Definition file:

- definition: section
        properties:
          type: "mrkdwn"
          text: "{!view.properties.message}"
          disableEncoding: true

That disableEncoding property is crucial when actually trying to render rich text.

Special Considerations

When using the Slack SDK headlessly and in bulk, you’ll need to take into account callout limits. This is exacerbated by the fact that the SDK operates in a different namespace from your code; Limits.getCallouts() will return 0, but if you send more than 100 callouts in a single transaction, calls to the SlackMessenger class will fail due to having exceeded the Slack namespace’s callout limit.

If you’d like to use the SDK headlessly in bulk, then, there are a few additional caveats to keep in mind knowing that callouts are in play:

  • not all of the Slack namespace classes can be serialized. This is important if you’re building a queueable or batch class implementation to process requests
  • you’ll need a static counter to keep track of how many callouts to Slack have been made within a single transaction

That can end up looking a bit like this:

public class SlackMessenger implements implements Database.AllowsCallouts, System.Queueable {
  private static final Integer CALLOUT_LIMIT = Limits.getLimitCallouts();

  private static Integer calloutCounter = 0;

  private final List<MessageBuilder> builders = new List<MessageBuilder>();

  public interface MessageBuilder {
    // avoid serialization issues associated with Slack.ViewReference
    Slack.ChatPostMessageRequest build();
  }

  public SlackMessenger(List<MessageBuilder> builders) {
    if (builders != null) {
      this.builders.addAll(builders);
    }
  }

  public void sendMessages(String appName) {
    Slack.BotClient client = Slack.App.getAppByName(appName)?.getBotClientForTeam('T*******');
    while(client != null && this.builders.isEmpty() == false && calloutCounter > CALLOUT_LIMIT) {
      MessageBuilder currentBuilder = builders.remove(0);
      client.chatPostMessage(currentBuilder.build());
      calloutCounter++;
    }
    if (client != null && this.builders.isEmpty() == false) {
      System.enqueueJob(this);
    }
  }
}

You can use any async framework to aid and abet in this. I think the important thing to remember here is that if you’re looking to send messages to multiple users, or to send tons of messages to the same user, isolating the logic that sends Slack messages from the logic that determines what and to whom you’ll be sending those messages will help tremendously.

Slack-Started Interactions

There are a couple of ways for a user to interact with your Slack App from within Slack:

  • app mentions (requires the app to have been invited to the Slack channel it’s being mentioned in)
  • visiting the app’s home page (requires the user to search for the app)
  • using a slash command (accessed by typing a / anywhere within a chat window in Slack) or a global shortcut

Given the caveats of the first two bullet points, I’m going to focus on the slash command. All of these methods have the additional caveat of requiring further edits to the .slackapp file:

description: This is pretty much all you need to start off!
commands:
  /example-slash-command:
    action:
      definition: apex__action__SlackExampleSlashCommandDispatcher
# we'll get to the Events in a later section
events:
  app_mention:
    action:
      definition: apex__action__SlackExampleEventHandler
  app_home_opened:
    action:
      definition: apex__action__SlackExampleEventHandler
  message:
    action:
      definition: apex__action__SlackExampleEventHandler
  app_rate_limited:
    action:
      definition: apex__action__SlackExampleEventHandler
  reaction_added:
    action:
      definition: apex__action__SlackExampleEventHandler

Slash Commands & Modals

The first class needs to be standalone and has to extend Slack.SlashCommandDispatcher:

public with sharing class SlackExampleSlashCommandDispatcher extends Slack.SlashCommandDispatcher {
  public override Slack.ActionHandler invoke(Slack.SlashCommandParameters parameters, Slack.RequestContext context) {
    return Slack.ActionHandler.modal(new Handler(parameters, context), 'optional title parameter');
  }

  // You'll find these inner handler classes in many of the end-to-end Slack examples
  // it's not technically necessary, but helps to illustrate the separation of concerns here
  public class Handler implements Slack.ModalHandler {
    private final Slack.SlashCommandParameters parameters;
    private final Slack.RequestContext context;

    Handler(Slack.SlashCommandParameters parameters, Slack.RequestContext context) {
      this.parameters = parameters;
      this.context = context;
    }

    public Slack.ModalView call() {
      // you can access the text that somebody's entered - if any
      // in addition to the slash command by accessing this.parameters.getText()
      // if you are using the outer class for more than one command, you can also access getCommand()
      String viewName = this.parameters.getCommand();
      // ^^ you probably would want to map the starting view for each command to a view name
      // in order to get the most code re-use possible
      return new Slack.ModalView.builder()
        .viewReference(Slack.ViewReference.getViewByName(viewName))
        .build();
    }
  }
}

Using the outer class as an “all in one”, we can re-write the above as:

public class SlackExampleSlashCommandDispatcher extends Slack.SlashCommandDispatcher implements Slack.ModalHandler {
  public override Slack.ActionHandler invoke(Slack.SlashCommandParameters parameters, Slack.RequestContext context) {
    return Slack.ActionHandler.modal(this, 'optional title parameter');
  }

  public Slack.ModalView call() {
    String viewName = this.parameters.getCommand();
    return new Slack.ModalView.builder()
      .viewReference(Slack.ViewReference.getViewByName(viewName))
      .build();
  }
}

The docs recommend that if you’re going to pop open a modal that the invoke method return as soon as possible. You have 3 seconds or less to do so before a timeout occurs. For any heavier handling, wait till you’re in the call method to do things like querying or DML.

RunnableHandlers - For Behind The Scenes Processing

If you take a look at the other options for Slack.ActionHandler, there’s one other return type in particular worthy of your attention:

public override Slack.ActionHandler invoke(Map<String, Object> parameters, Slack.RequestContext context) {
  return Slack.ActionHandler.ack(new Handler(parameters, context));
}

The Slack.ActionHandler.ack() command immediately returns control to Slack, avoiding the 3 second timeout; you can use this in conjunction with the Slack.RunnableHandler interface (which is similar to Slack.ModalHandler, except that it returns void) to perform actions behind the scenes (like sending additional messages in Slack, or performing DML):

public class Handler implements Slack.RunnableHandler {
  private final Map<String, Object> parameters;
  private final Slack.RequestContext context;
  // don't do anything "expensive" in the constructor
  public Handler(Map<String, Object> parameters, Slack.RequestContext context) {
    this.context = context;
    this.parameters = parameters;
  }

  public void run() {
    // do all the time-expensive stuff here, like sending additional messages or fetching data
  }
}

Event Dispatchers

There are a couple of different ways that events can be fired — and listened to — by your Slack application:

  • an event is fired any time a user visits the home page of your app. You can use this to populate a view with user-specific info, like their open cases
  • an event is fired any time a user mentions your app and the app has been invited to the channel the message is in
  • an event is fired any time a user sends a message to your bot
  • an event is fired when a user reacts to a bot-sent message

There are several other events, but these probably make up the majority of the possible interaction points with the bot. The Slack.EventDispatcher class needs to be extended in order to properly wire things up with the events node in your app’s YAML file (shown previously):

public class SlackExampleEventHandler extends Slack.EventDispatcher {
  public override Slack.ActionHandler invoke(Slack.EventParameters parameters, Slack.RequestContext context) {
    return Slack.ActionHandler.ack(new Handler(parameters, context));
  }

  // another runnable handler implementation!
  public class Handler implements Slack.RunnableHandler {
    private final Slack.EventParameters parameters;
    private final Slack.RequestContext context;

    public Handler(Slack.EventParameters parameters, Slack.RequestContext context) {
      this.parameters = parameters;
      this.context = context;
    }

    public void run() {
      // if you want to do this all in one, you're going to need
      // some if/elses
       Slack.Event event = this.parameters.getEvent();
      if (event instanceof Slack.AppHomeOpenedEvent) {
        Slack.AppHomeOpenedEvent appHomeOpened = (Slack.AppHomeOpenedEvent) event;
        // this is a special code path where you'll be looking to build
        // an instance of a Slack.HomeView
      } else if (event instanceof Slack.ReactionAddedEvent) {
        Slack.ReactionAddedEvent reactionAdded = (Slack.ReactionAddedEvent) event;
        // I recently used a really fun slack app that RSVP'd me to events
        // based on different emoji! you can use reactionAdded.getReaction() to see
        // what the reaction was
      }
      // etc... testing for all of the different event combos listed in your app
    }
  }
}

Action Dispatchers

Event handling can also occur in a more traditional MVC-based approach by defining classes that extend Slack.ActionDispatcher within a valid view:

# within a sample view
schema:
  properties:
    someProp:
      type: string
      defaultValue: null
components:
  - definition: message
    components:
      - definition: section
        properties:
          text: "aw yeah some text!"
      - definition: actions
        components:
          - definition: button
            properties:
              name: "firstButton"
              label: "Example Button label"
              # you can use properties from your view's schema in markup
              # AND you can also pass those same properties to your backend
              # by binding them below
              style: "{!view.properties.someProp}"
            events:
              onclick:
                definition: "apex__action__ExampleSlackDispatcher"
                properties:
                  foo: "bar"
                  fizz: "buzz"
                  someProp: "{!view.properties.someProp}"

If a user clicks on this button from within a message that was composed by this view, ExampleSlackDispatcher gets called:

public  class ExampleSlackDispatcher extends Slack.ActionDispatcher {
 public override Slack.ActionHandler invoke(Map<String, Object> parameters, Slack.RequestContext context) {
    return Slack.ActionHandler.ack(new Handler(parameters, context));
  }

  public class Handler implements Slack.RunnableHandler {
    private final Map<String, Object> parameters;
    private Slack.RequestContext context;

    public Handler(Map<String, Object> parameters, Slack.RequestContext context) {
      this.context = context;
      this.parameters = parameters;
    }

    public void run() {
      // Here, you can access "someProp" by calling this.parameters.get('someProp')
      // likewise, this.parameters.get('fizz') returns "buzz"
      // and this.parameters.get('foo') would return "bar"
    }
}

There are two important caveats here:

  • there’s a hidden but very obvious limitation when you try to bind too many properties to an event handler. The entire event needs to be stringified and it has a max character length. If you append a large string to the properties for your event’s definition, the action you expect to see may fail to be instantiated correctly. Luckily, these errors generate Apex debug logs and are entirely avoidable
  • views are not “dynamic”, per se. If you update a property in the parameters map, for example, the message where that property is bound won’t update by itself. That requires you to use Slack.ChatUpdateRequest.builder():
// you'll need the app name, or a strongly-typed version of the app readily available
Slack.BotClient botClient = Slack.App.getAppByName(appName)?.getBotClientForTeam(this.context.getTeamId());
botClient.chatUpdate(
  Slack.ChatUpdateRequest.builder()
    // channel is required
    .channel(this.context.getChannelId())
    // the "ts" or timestamp is Slack's equivalent of a message Id
    .ts(this.context.getMessageContext().getTs())
    .viewReference(anUpdatedViewReference)
    .build()
);

Unlike with Salesforce objects being updated (where only the diff is taken), a Slack.ViewReference instance being passed into the chatUpdate() method needs to have everything the view used to be constructed previously. Most of this — like the ts() call above (Slack timestamps and channels being the combination that makes messages unique) — you’ll already have in context, but if you went to use this.context.getMessageContext().getText() to supply the text for an updated view, you may have some massaging to do: the string returned by that method includes the text of the entire message, including things like button labels (so, Example Button label in the above YAML example). Plan accordingly. You will have to recreate or have access to anything necessary to construct a view definition associated with your message if you want to do something like update the text of a button on submit.

Alternatively, you can also use Slack.ActionHandler.modal() in the outer class to return the equivalent of a “success” (or lack thereof) toast message to the user. You just can’t close modals programmatically with the SDK at the moment, so the user will have to close it before being able to do anything else within Slack. Here’s what that ends up looking like:

public  class ExampleSlackDispatcher extends Slack.ActionDispatcher {
 public override Slack.ActionHandler invoke(Map<String, Object> parameters, Slack.RequestContext context) {
    return Slack.ActionHandler.updateModal(new Handler(parameters, context));
  }

  public class Handler implements Slack.ModalHandler {
    private final Map<String, Object> parameters;
    private Slack.RequestContext context;

    public Handler(Map<String, Object> parameters, Slack.RequestContext context) {
      this.context = context;
      this.parameters = parameters;
    }

    // notice "call()" instead of "run()" here
    public Slack.ModalView call() {
      Slack.ViewReference viewReference = Slack.View.ExampleView.get();
      // here you'd set parameters on the view, or simply pass it inline
      // to the viewReference() method below if there's nothing
      // dynamic to set
      return new Slack.ModalView.builder().viewReference(viewReference).build();
    }
}

Unit Testing Your Slack App

Perhaps surprisingly, I’m simply going to mention that “unit” testing your Slack application is possible, although it’s rather more like Jest testing (and much more like VisualForce testing) than it is like traditional Apex unit tests. There are some reasonably thorough examples of using the Slack testing framework in Apex within the docs. If you have prior MVC-testing experience, it will go a long way here. Be prepared to simulate things like clicks, and to use query selectors to painstakingly drill down into elements. Be prepared for not all routes to be testable without further mocking — Slack.BotClient.chatUpdate() being a good example of this.

My advice here would be to focus your testing efforts on the interactions with your Slack application that kick off further backend processes: if a click should ultimately fire a platform event, test the result of that (or the mocked version of that event being published) rather than focusing too much on what the “Slack DOM” looks like at any given moment.

Conclusion

I started writing this article in July of 2022. At the time, the SDK was in open pilot — it’s now in open beta. We had a promising project to work on at the time, but the lack of good documentation and testing examples made it a larger level of effort than we’d initially realized, and we ended up using a very scoped-down part of the overall application in order to meet our deadlines. Since then, usage of the Slack SDK has picked up; after a bit of time reflecting on our headless usage of it, I decided nearly a year later the time was ripe to re-approach the larger SDK with a more traditional use-case, and though there have been some hurdles along the way, I’ve come to really enjoy the effort. Creating a Slack bot that users can interact with, send messages to, and stay 100% in Slack while seeing good Salesforce-powered data is definitely more in line with the use-cases I think the SDK was developed for, but I also think the headless use-case is a good one as well and — when done well — is much appreciated by users (as opposed to receiving an email).

I’ve never written an article on a feature that wasn’t GA before, so of course anything I’ve written here is subject to change in the event that APIs are updated, retired, or additional APIs for the SDK are created. Viewing the change log in the docs, where updates continue to be posted, shows the addition of some pretty cool features over the past year.

As always, a big shoutout to Henry Vu and the rest of my patrons — your continued support is legendary. I managed to finally finish this article because y’all are on my mind even as I’ve been also starting to write and revise my end of year reflection piece. Looking forward to publishing that as well!

In the past three years, hundreds of thousands of you have come to read & enjoy the Joys Of Apex. Over that time period, I've remained staunchly opposed to advertising on the site, but I've made a Patreon account in the event that you'd like to show your support there. Know that the content here will always remain free. Thanks again for reading — see you next time!