A lightweight Slack bot toolkit.
Slink gives you one event-handling contract and two interchangeable
transports:
Slink.SocketModeβ dials out to Slack over a WebSocket. No public endpoint required. Best for development and internal/behind-firewall apps.Slink.EventsApi.Plugβ aPlugthat receives Slack's HTTP event callbacks. Best for production and distributed apps.
Both transports normalise Slack payloads into a Slink.Event and dispatch it
to your module's handle_event/2. Write your bot once; pick the transport
per environment (Socket Mode in dev, HTTP in prod β which is exactly what
Slack recommends).
Defining a bot
defmodule MyBot do
use Slink
alias Slink.Event
@impl true
def handle_event(%Slink.Event{type: :app_mention} = event, _context) do
# Return a reply and slink sends it (placement: to: :auto by default).
{:reply, "hi <@#{Event.user(event)}> π"}
end
def handle_event(_event, _context), do: :ok
endOr reply imperatively β reply/3 returns :ok, so it can be the last
expression, and to: picks where it lands (:auto, :thread, :channel):
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
reply(context, Event.command(event), to: :channel)
endRunning it (Socket Mode)
children = [
{Slink.SocketMode,
module: MyBot,
app_token: System.fetch_env!("SLACK_APP_TOKEN"),
bot_token: System.fetch_env!("SLACK_BOT_TOKEN")}
]
Supervisor.start_link(children, strategy: :one_for_one)See the module docs for Slink.EventsApi.Plug to run the HTTP transport.
Multiple workspaces
Slink is token-per-request throughout: every Slink.API call takes a token,
the handler context carries the :bot_token, and both transports let you pick
it per workspace. Over Socket Mode you run one client per workspace (see
Slink.SocketMode); over HTTP you pass a :bot_token resolver that receives
the event's team id (see Slink.EventsApi.Plug). So one bot module can serve
many workspaces.
What Slink deliberately leaves to you is the OAuth install flow β
oauth.v2.access and persisting a token per team as workspaces install your
app. Bring your own token store; Slink routes to whatever token you hand it.
Summary
Callbacks
Invoked for every event Slack delivers, from either transport.
Functions
Whether a bot should start, given its config.
Whether the event happened inside a thread (imported by use Slink).
Open a modal in response to the interaction or slash command in context
(imported by use Slink).
Reply to the event in context (imported by use Slink). Returns :ok, so a
handler can end with it β no trailing :ok needed
Post a message to channel, using the bot token from the handler context.
Show a "working on it" indicator on the triggering message only if the work
is slow, then clear it (imported by use Slink). Returns whatever fun
returns.
Types
@type context() :: Slink.Context.t()
Context passed to handle_event/2. See Slink.Context.
What a handler returns:
:okβ done, no reply.{:reply, text}β slink replies withtextviareply/3with the defaultto: :autoplacement (threaded if the event is in a thread, otherwise inline).{:reply, text, opts}β same, passingoptstoreply/3:to: :thread/to: :channelto force placement, andblocks: [...]/attachments: [...]for rich replies.textis still sent as the notification/fallback Slack shows in previews, so always provide something meaningful.{:ack, map}β only for aview_submission(modal submit):mapis Slack'sresponse_actionreply, e.g.%{response_action: "errors", errors: %{"block" => "β¦"}}to show validation errors, orupdate/pushto swap the modal. This event type runs synchronously, so return promptly. Any other return closes the modal.
Any other value is treated as :ok (no reply).
Callbacks
@callback handle_event(Slink.Event.t(), context()) :: result()
Invoked for every event Slack delivers, from either transport.
Return :ok to do nothing, or {:reply, text} to reply (see result/0).
The transport has already acknowledged the event to Slack before this runs,
so slow work here never risks Slack's 3-second ACK window.
Functions
Whether a bot should start, given its config.
Returns true only when :enabled is truthy and both :app_token and
:bot_token are present. Use it to conditionally add Slink.SocketMode to a
supervision tree, so an app without credentials (or with the bot switched off)
simply doesn't connect:
children =
if Slink.enabled?(config) do
[{Slink.SocketMode, [module: MyBot] ++ config}]
else
[]
endconfig is any keyword list or map (e.g. from Application.get_env/2).
Whether the event happened inside a thread (imported by use Slink).
Accepts either a context (like the other imported helpers) or a bare
Slink.Event. Delegates to Slink.Event.in_thread?/1.
Open a modal in response to the interaction or slash command in context
(imported by use Slink).
Returns {:ok, response} | {:error, reason} β the standard shape for a call
that returns data and can fail. response is Slack's views.open payload, so
response["view"]["id"] is the id you pass to update_view/3 later.
(push_view/3 instead takes a fresh trigger_id from a later interaction
inside the modal, not the opened view's id.) You can also just end a handler
with it: the dispatcher treats a
non-{:reply, β¦}/{:ack, β¦} return as "no reply" (see result/0), so a
bare open_modal(context, view) is fine and no trailing :ok is needed.
Uses the event's trigger_id, which Slack honours for only ~3 seconds, so open
promptly. view is a Block Kit view map.
def handle_event(%Slink.Event{type: :shortcut} = _event, context) do
open_modal(context, my_view())
end
Reply to the event in context (imported by use Slink). Returns :ok, so a
handler can end with it β no trailing :ok needed:
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
reply(context, "on it π")
endThe channel and thread come from context.event (set by the dispatcher), so
no event argument is needed. This works the same for a block_actions
interaction (a button click): the reply lands on the message the button is on.
Where the reply lands is controlled by opts[:to]:
:auto(default) β dynamic: in the thread if the event is in one, otherwise inline in the channel.:threadβ always in a thread: the event's existing thread, or a new one started on the triggering message.:channelβ always inline in the channel timeline, even if the event was inside a thread.
Every other key in opts is merged into the Slack request body, for rich
replies: blocks: [...] (Block Kit), attachments: [...], an explicit
thread_ts:, etc.
reply(context, "deployed β
", to: :channel, blocks: blocks)For a slash command the reply goes to the command's response_url instead,
with to: :ephemeral (default, only the invoker) or to: :channel.
Post a message to channel, using the bot token from the handler context.
Goes through Slink.Rate so sends are rate-limited per channel (Slack allows
~1/sec/channel). opts β a keyword list or map β is merged into the request
body (e.g. blocks:, thread_ts:), matching reply/3's keyword opts. Returns
:ok. use Slink imports this, so handlers can call it unqualified:
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
send_message(context, Slink.Event.channel(event), "hi")
end
Show a "working on it" indicator on the triggering message only if the work
is slow, then clear it (imported by use Slink). Returns whatever fun
returns.
Slack has no bot "typingβ¦" indicator for channels, so this reacts to the
event's message with an emoji (default hourglass_flowing_sand β³). To avoid a
pointless flicker on fast replies, fun runs in a task and the reaction is
added only if it's still running after :delay_ms (default 3000ms);
it's always removed once fun finishes β even if it raises. Fast work shows
nothing. So it's safe to wrap any handler:
def handle_event(%Slink.Event{type: :app_mention} = event, context) do
working(context, fn -> reply(context, answer(event)) end)
endOptions:
:delay_msβ how long to wait before showing the indicator (default3000). Use0to show it immediately.:emojiβ reaction name without colons (default"hourglass_flowing_sand").
Best-effort: reaction API errors are ignored so they never break the handler,
and if the event has no message to react to, fun just runs inline.