Slink behaviour (Slink v0.6.0)

Copy Markdown View Source

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 β€” a Plug that 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
end

Or 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)
end

Handlers that do several things chain the helpers with with β€” the return shapes are consistent, so the first {:error, reason} short-circuits. See the Composing helpers guide.

Running 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.

To acquire those tokens as workspaces install your app, use the OAuth install flow in Slink.OAuth β€” the consent URL, code exchange, and a callback plug are done for you. Persisting a token per team is deliberately yours: bring your own store; Slink routes to whatever token you hand it.

Summary

Types

Context passed to handle_event/2. See Slink.Context.

What a handler returns

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).

Whether the bot itself is @-mentioned in the event's text (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

Send a direct message to user, using the bot token from the handler context (imported by use Slink).

Post a message to channel, using the bot token from the handler context.

Show a status line under the assistant thread of the event in context β€” "is thinking…" while the real answer is prepared (imported by use Slink).

Stream a reply into the event's thread, chunk by chunk (imported by use Slink).

Replace the message this interaction came from (imported by use Slink).

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

context()

@type context() :: Slink.Context.t()

Context passed to handle_event/2. See Slink.Context.

result()

@type result() ::
  :ok | {:reply, String.t()} | {:reply, String.t(), keyword()} | {:ack, map()}

What a handler returns:

  • :ok β€” done, no reply.
  • {:reply, text} β€” slink replies with text via reply/3 with the default to: :auto placement (threaded if the event is in a thread, otherwise inline).
  • {:reply, text, opts} β€” same, passing opts to reply/3: to: :thread / to: :channel to force placement, and blocks: [...] / attachments: [...] for rich replies. text is still sent as the notification/fallback Slack shows in previews, so always provide something meaningful.
  • {:ack, map} β€” only for a view_submission (modal submit): map is Slack's response_action reply, e.g. %{response_action: "errors", errors: %{"block" => "…"}} to show validation errors, or update/push to 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

handle_event(t, context)

@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

enabled?(config)

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
    []
  end

config is any keyword list or map (e.g. from Application.get_env/2).

in_thread?(event)

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.

mentions_me?(context)

Whether the bot itself is @-mentioned in the event's text (imported by use Slink).

Unlike matching on :app_mention β€” a whole event type β€” this answers the question for any event carrying text, e.g. a :message in a thread the bot is following. Compares against context.bot_user_id, which is discovered via auth.test shortly after the transport starts; returns false while that's still unknown (and :app_mention events don't need it anyway).

open_modal(context, view)

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(context, text, opts \\ [])

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 πŸ‘")
end

The 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.
  • :ephemeral β€” visible only to the user who triggered the event, and gone on reload. Interactions go through their response_url; plain events (a mention, a message) use chat.postEphemeral.

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.

An interaction with no channel to post into (a button on an ephemeral message, a message in a channel the bot isn't a member of) falls back to its response_url β€” ephemeral to the invoker, or in-channel with to: :channel β€” rather than failing. To replace the message a button lives on instead of posting a new one, see update_original/3.

send_dm(context, user, text, opts \\ [])

Send a direct message to user, using the bot token from the handler context (imported by use Slink).

Opens (or resumes) the DM conversation via conversations.open, then posts through Slink.Rate like send_message/4. opts merges into the request body (blocks: etc.). Returns :ok, or {:error, reason} if the DM couldn't be opened (e.g. the app lacks the im:write scope).

def handle_event(%Slink.Event{type: :team_join} = event, context) do
  send_dm(context, Slink.Event.user(event), "welcome aboard πŸ‘‹")
end

send_message(context, channel, text, opts \\ [])

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

set_status(context, status)

Show a status line under the assistant thread of the event in context β€” "is thinking…" while the real answer is prepared (imported by use Slink).

Wraps Slink.API.set_thread_status/4 with the event's channel and thread. Pass "" to clear; posting or streaming a reply into the thread clears it automatically. Returns :ok | {:error, reason}. Needs the assistant:write scope.

stream_reply(context, enumerable, opts \\ [])

Stream a reply into the event's thread, chunk by chunk (imported by use Slink).

Built for AI apps: pass any enumerable of text chunks β€” an LLM token stream, a Stream, a list β€” and it renders as one live, progressively-updating Slack message via chat.startStream / appendStream / stopStream:

def handle_event(%Slink.Event{type: :message} = event, context) do
  set_status(context, "is thinking…")
  stream_reply(context, MyLLM.stream(Slink.Event.text(event)))
end

Chunks are buffered and appended at most every :flush_ms (default 400ms), so a fast token stream doesn't hammer Slack's limits. Streamed messages are always thread replies: the event's thread, or a new one on the triggering message (like reply/3 with to: :thread). If the surface can't stream (the method errors β€” e.g. the feature isn't enabled for the app), it degrades to a single chat.postMessage with the full text, so the reply still arrives.

Returns {:ok, ts} of the streamed (or fallback) message, or {:error, reason} when both paths failed. The enumerable is fully consumed either way. Raises ArgumentError for an event with no channel or nothing to thread under (a global shortcut, a modal submit). A chat.stopStream failure after a successful stream still returns {:ok, ts} β€” the message exists with everything appended β€” and logs what the failed stop was carrying (a :finish payload, trailing text).

Options:

  • :flush_ms β€” minimum interval between appends (default 400).
  • :start β€” extra chat.startStream params; streaming into a channel (not the app's DM) requires %{recipient_user_id: ..., recipient_team_id: ...}.
  • :finish β€” extra chat.stopStream params (e.g. %{blocks: [...]} β€” blocks are allowed only on the final call).

update_original(context, text, opts \\ [])

Replace the message this interaction came from (imported by use Slink).

The canonical "a button click updates its own message" pattern: posts to the event's response_url with replace_original: true, so it also works on ephemeral messages and in channels the bot isn't a member of β€” places Slink.API.update_message/5 can't reach. opts merges into the body (blocks: etc.).

def handle_event(%Slink.Event{type: :block_actions} = event, context) do
  update_original(context, "deploying #{Event.action_value(event)}…")
end

Returns :ok, or {:error, :no_response_url} when the event carries none (only slash commands and message interactions do β€” not plain events), or the responder's {:error, reason}.

working(context, fun, opts \\ [])

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)
end

Options:

  • :delay_ms β€” how long to wait before showing the indicator (default 3000). Use 0 to 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. One caveat: if the app is shut down mid-work (a deploy, a :brutal_kill), the removal never runs and the reaction can be left on the message.