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, [answer: 42, question: nil] is equivalent to [{: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.
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.