to all posts

A little Slack notifier in Elixir

Published

I recently made a Slack bot that notifies us whenever someone fills out our lead form:

Let me show you how it works.

First you need a Slack app

Well, first you need a Slack account and a workspace, but I’ll assume you got that far already.

Rather than me fumbling through how to create a Slack app here I’ll just give you this link to the docs where they do a better job explaining it anyway.

For this simple use case I’m ignoring most of their API, and the only feature we’ll be using are incoming webhooks—that is, we ask Slack for an endpoint, where each endpoint will correspond to a different channel in our Workspace. When we make a POST to that endpoint with our message as the payload, the message will be, err, posted in the channel belonging to that endpoint.

The webhook URL is the only thing needed to post whatever in your internal Slack, so Keep It Secret, Keep It Safe.

Okay, I have a webhook, how do I send a message

The simplest possible message payload is something like this:

{
  "text": "message goes here"
}

Using the excellent HTTPie CLI we can send a message like this:

❯ http POST \
      # I know I just said to keep the URL secret,
      # but by the time you read this I'll have deleted this one:
      https://hooks.slack.com/services/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 \
      text="what's up nerds"
HTTP/1.1 200 OK
access-control-allow-origin: *
content-length: 2
content-type: text/html
# a bunch more response headers ...
ok

lo and behold:

Booo!

Got it, we want fancy messages. This is where blocks come in. In our payload each block is a JSON object. For instance, this is a heading:

{
  "type": "header",
  "text": {
    "type": "plain_text",
    "text": "This is important"
  }
}

To send that in a message, we’d need to specify a list under the blocks attribute of our payload, which is also how we use multiple blocks:

{
  "blocks": [
    {
      "type": "header",
      "text": {
        "type": "plain_text",
        "text": "This is important"
      }
    },
    {
      "type": "section",
      "text": {
        "type": "plain_text",
        "text": "Has anybody seen my boat"
      }
    }
  ]
}

Saving the above JSON to a file called message.json we can POST it to our webhook like so:

❯ http POST \
      https://hooks.slack.com/services/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 \
      @message.json
HTTP/1.1 200 OK
access-control-allow-origin: *
content-length: 2
content-type: text/html
# more response headers ...

ok

and now we’re getting somewhere:

I don’t want to type all that out

Nobody does, which is why we have been gifted the block kit builder: Slack’s official way of using a GUI to compose messages of blocks, giving you the JSON you need.

Now, we could use the builder to structure our message the way we want it, copy the JSON and paste it into a file on our server, and use string interpolation to insert dynamic values where we need them.

But that’s gross, so we’re not going to do that.

Enter Elixir

I’ll call my module SlackNotifier, and I’ll make a function called format_message/1.1 I want to be able to do

SlackNotifier.format_message(header: "This is important")

and it should handle all of the boring stuff.

This is the full test suite it should pass:

defmodule Dreng.SlackNotifierTest do
  use ExUnit.Case, async: true

  alias Dreng.SlackNotifier

  describe "format_message" do
    test "header tag is correctly formatted" do
      result = SlackNotifier.format_message(header: "What a nice header")

      assert result == %{
               blocks: [
                 %{
                   type: "header",
                   text: %{type: "plain_text", text: "What a nice header", emoji: true}
                 }
               ]
             }
    end

    test "mrkdwn tag is correctly formatted" do
      result =
        SlackNotifier.format_message(mrkdwn: "Lots of goodies in here\nMultiple lines, even!")

      assert result == %{
               blocks: [
                 %{
                   type: "mrkdwn",
                   text: "Lots of goodies in here\nMultiple lines, even!"
                 }
               ]
             }
    end

    test "section with nested fields is correctly formatted" do
      result =
        SlackNotifier.format_message(
          section: [mrkdwn: "*This* is a field", mrkdwn: "And this is another field!"]
        )

      assert result == %{
               blocks: [
                 %{
                   type: "section",
                   fields: [
                     %{
                       type: "mrkdwn",
                       text: "*This* is a field"
                     },
                     %{
                       type: "mrkdwn",
                       text: "And this is another field!"
                     }
                   ]
                 }
               ]
             }
    end

    test "a more complicated message with several types is correctly formatted" do
      result =
        SlackNotifier.format_message(
          header: "Very important message!",
          section: [
            mrkdwn: "*Calling all test runners*",
            mrkdwn: "Uh, nevermind."
          ]
        )

      assert result == %{
               blocks: [
                 %{
                   type: "header",
                   text: %{
                     type: "plain_text",
                     text: "Very important message!",
                     emoji: true
                   }
                 },
                 %{
                   type: "section",
                   fields: [
                     %{
                       type: "mrkdwn",
                       text: "*Calling all test runners*"
                     },
                     %{
                       type: "mrkdwn",
                       text: "Uh, nevermind."
                     }
                   ]
                 }
               ]
             }
    end
  end
end

Implementing this is quite pleasant, using two attributes of Elixir:

  1. A keyword list, which is what we’re passing as the argument to format_message/1, is syntax sugar for a list of 2-tuples where the first element is an atom and the second element is the value associated with that keyword. That is,
    [answer: 42, question: nil]
    is equivalent to
    [{:answer, 42}, {:question, nil}]
  2. A function can have multiple clauses, where each clause pattern matches on the shape of its arguments.

This means we can use the familiar Elixir pattern of writing lots of small functions, each focusing on a narrow set of implementation details. For instance, this is the function that formats a header block:

  defp format_block({:header, content}) when is_binary(content) do
    %{
      type: "header",
      text: %{
        type: "plain_text",
        text: content,
        emoji: true
      }
    }
  end

To format a whole message, we take our keyword lists (which, remember, is just a list of 2-tuples) and map over it to create our blocks:

@spec format_message(Keyword.t()) :: map()
def format_message(blocks) do
  formatted_blocks = Enum.map(blocks, &format_block/1)
  %{blocks: formatted_blocks}
end

After the dust has settled, this is our message formatting code:

defmodule Dreng.SlackNotifier do
  @spec format_message(Keyword.t()) :: map()
  def format_message(blocks) do
    formatted_blocks = Enum.map(blocks, &format_block/1)
    %{blocks: formatted_blocks}
  end

  defp format_block({:header, content}) when is_binary(content) do
    %{
      type: "header",
      text: %{
        type: "plain_text",
        text: content,
        emoji: true
      }
    }
  end

  defp format_block({:mrkdwn, content}) when is_binary(content) do
    %{
      type: "mrkdwn",
      text: content
    }
  end

  defp format_block({:section, fields}) when is_list(fields) do
    formatted_fields = Enum.map(fields, &format_block/1)

    %{
      type: "section",
      fields: formatted_fields
    }
  end
end

What about sending it?

All that remains is issuing a POST to our webhook endpoint, and we are done. Since our URL is sensitive, we’ll use an environment variable to get it. In the appropriate config file we add this line:

# e.g. config/runtime.exs
config :dreng, Dreng.SlackNotifier, webhook_url: System.get_env("SLACK_WEBHOOK_URL")

and to get it in our module we make a helper:

  defp webhook_url() do
    Application.fetch_env!(:dreng, __MODULE__) |> Keyword.fetch!(:webhook_url)
  end

With the excellent req HTTP client, sending the message is as simple as

  @spec send_message(map()) :: {:ok, Req.Response.t()} | {:error, Exception.t()}
  def send_message(message) do
    webhook_url()
    |> Req.post(json: message)
  end

We could call it a day here, but HTTP requests are fragile and bound to fail sooner or later. It would be a bummer if somebody wants to try our product, but we don’t notice because Salesforce is having an outage.2 Let’s go for the extra credit.

Extra credit: Background jobs

The definitive background job library for Elixir is Oban. Again, I’ll simply defer to the installation instructions instead of re-iterating them here. My only recommendation is to check out the Igniter installation option if you haven’t already—it really makes it painless.

In config/config.exs, I created a separate queue for our Slack worker:

config :dreng, Oban,
  engine: Oban.Engines.Basic,
  queues: [default: 10, slack: 5],
  repo: Dreng.Repo

The worker module is dead simple:

defmodule Dreng.SlackWorker do
  use Oban.Worker, queue: :slack

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"message" => message}}) do
    Dreng.SlackNotifier.send_message(message)
  end
end

And in our original SlackNotifier, this helper schedules a message:

  defp schedule_message(message) do
    Dreng.SlackWorker.new(%{message: message})
    |> Oban.insert()
  end

And that’s all we need!

When someone fills out the lead form, we create an InterestedOrganization struct. In our slack notifier, this function is responsible for creating and scheduling the message:

  def schedule_new_interested_organization_message(
        %InterestedOrganization{} = interested_organization
      ) do
    [
      header: "#{interested_organization.name} ønsker å teste Dreng :tada:",
      section: [
        mrkdwn: "*Kontaktperson*\n#{interested_organization.contact_person}",
        mrkdwn: "*E-post*\n#{interested_organization.email || "Ikke oppgitt"}",
        mrkdwn: "*Telefon*\n#{interested_organization.phone || "Ikke oppgitt"}"
      ]
    ]
    |> format_message()
    |> schedule_message()
  end

Oban also provides lovely testing tools, so we can make sure we have lined up the job when we want to, and also that we don’t send any notifications by mistake if something else failed:

  test "create_interested_organization/1 enqueues a slack notification when successful" do
    {:ok, _interested_organization} =
      Organizations.create_interested_organization(%{
        name: Faker.Company.name(),
        contact_person: Faker.Person.name(),
        phone: "40000123"
      })

    assert_enqueued(worker: Dreng.SlackWorker, queue: :slack)
  end

  test "a slack notification is not enqueued when create_interested_organization/1 fails" do
    {:error, _reason} =
      Organizations.create_interested_organization(%{})

    refute_enqueued(worker: Dreng.SlackWorker)
  end

For completeness, this is the Slack notifier in its entirety:

defmodule Dreng.SlackNotifier do
  alias Dreng.Organizations.InterestedOrganization

  def schedule_new_interested_organization_message(
        %InterestedOrganization{} = interested_organization
      ) do
    [
      header: "#{interested_organization.name} ønsker å teste Dreng :tada:",
      section: [
        mrkdwn: "*Kontaktperson*\n#{interested_organization.contact_person}",
        mrkdwn: "*E-post*\n#{interested_organization.email || "Ikke oppgitt"}",
        mrkdwn: "*Telefon*\n#{interested_organization.phone || "Ikke oppgitt"}"
      ]
    ]
    |> format_message()
    |> schedule_message()
  end

  @spec format_message(Keyword.t()) :: map()
  def format_message(blocks) do
    formatted_blocks = Enum.map(blocks, &format_block/1)
    %{blocks: formatted_blocks}
  end

  defp format_block({:header, content}) when is_binary(content) do
    %{
      type: "header",
      text: %{
        type: "plain_text",
        text: content,
        emoji: true
      }
    }
  end

  defp format_block({:mrkdwn, content}) when is_binary(content) do
    %{
      type: "mrkdwn",
      text: content
    }
  end

  defp format_block({:section, fields}) when is_list(fields) do
    formatted_fields = Enum.map(fields, &format_block/1)

    %{
      type: "section",
      fields: formatted_fields
    }
  end

  defp schedule_message(message) do
    Dreng.SlackWorker.new(%{message: message})
    |> Oban.insert()
  end

  @spec send_message(map()) :: {:ok, Req.Response.t()} | {:error, Exception.t()}
  def send_message(message) do
    webhook_url()
    |> Req.post(json: message)
  end

  defp webhook_url() do
    Application.fetch_env!(:dreng, __MODULE__) |> Keyword.fetch!(:webhook_url)
  end
end

Now go forth and absolutely hammer those Salesforce servers.


Footnotes

  1. The /1 after the function name means that the function accepts one argument. The number of arguments accepted by a function is known as its arity.

  2. You did know Salesforce acquired Slack in 2020?