# `Slink`
[🔗](https://github.com/wkirschbaum/slink/blob/v0.6.0/lib/slink.ex#L1)

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 `c: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](composing.html) 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.

# `context`

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

Context passed to `c:handle_event/2`. See `Slink.Context`.

# `result`

```elixir
@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).

# `handle_event`

```elixir
@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 `t: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.

# `enabled?`

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?`

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?`

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`

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 `t: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`

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`

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`

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`

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`

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`

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`

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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
