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 ...
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:
- 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
}
}
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
-
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. ↩ -
You did know Salesforce acquired Slack in 2020? ↩