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.
Summary
Callbacks
Invoked for every event Slack delivers, from either transport.
Functions
Whether a bot should start, given its config.
Whether event happened inside a thread (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.
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).
@spec in_thread?(Slink.Event.t()) :: boolean()
Whether event happened inside a thread (imported by use Slink).
Delegates to Slink.Event.in_thread?/1.
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. 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)
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 is merged into the request body (e.g. blocks,
thread_ts). 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.