A little Slack notifier in Elixir
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 ...
oklo 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 ...
okand 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
endImplementing this is quite pleasant, using two attributes of Elixir:
- 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,
 is equivalent to[answer: 42, question: nil][{:answer, 42}, {:question, nil}]
- 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
      }
    }
  endTo 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}
endAfter 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
endWhat 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)
  endWith 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)
  endWe 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.RepoThe 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
endAnd in our original SlackNotifier, this helper schedules a message:
  defp schedule_message(message) do
    Dreng.SlackWorker.new(%{message: message})
    |> Oban.insert()
  endAnd 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()
  endOban 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)
  endFor 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
endNow go forth and absolutely hammer those Salesforce servers.
Footnotes
- 
The /1after the function name means that the function accepts one argument. The number of arguments accepted by a function is known as its arity. ↩
- 
You did know Salesforce acquired Slack in 2020? ↩