defmodule Slink do @moduledoc """ 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 ## 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. """ @typedoc "Context passed to `c:handle_event/2`. See `Slink.Context`." @type context :: Slink.Context.t() @doc """ 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`). """ def enabled?(config) do !!(config[:enabled] && config[:app_token] && config[:bot_token]) end @typedoc """ 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). """ @type result :: :ok | {:reply, String.t()} | {:reply, String.t(), keyword()} | {:ack, map()} @doc """ 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. """ @callback handle_event(Slink.Event.t(), context()) :: result() @doc """ 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 """ def send_message(%Slink.Context{bot_token: token}, channel, text, opts \\ %{}) do Slink.Rate.post_message(token, channel, text, opts) end @doc """ Whether `event` happened inside a thread (imported by `use Slink`). Delegates to `Slink.Event.in_thread?/1`. """ defdelegate in_thread?(event), to: Slink.Event @doc """ 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. 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`. """ def reply(context, text, opts \\ []) def reply( %Slink.Context{event: %Slink.Event{kind: :slash_commands} = event} = context, text, opts ) do # Slash commands reply through their response_url; `to:` picks visibility β€” # `:ephemeral` (default, only the invoker) or `:channel`. On the off chance # there's no response_url, fall back to a plain channel post. case Slink.Event.response_url(event) do url when is_binary(url) -> {to, body} = Keyword.pop(opts, :to, :ephemeral) params = body |> Map.new() |> Map.merge(%{text: text, response_type: slash_type(to)}) _ = Slink.API.respond(url, params) :ok _ -> {_to, body} = Keyword.pop(opts, :to) send_message(context, Slink.Event.channel(event), text, Map.new(body)) end end def reply(%Slink.Context{event: %Slink.Event{} = event} = context, text, opts) do case Slink.Event.channel(event) do channel when is_binary(channel) -> {to, body} = Keyword.pop(opts, :to, :auto) send_message(context, channel, text, thread(body, to, event)) _ -> raise ArgumentError, "reply/3 has no channel for a #{inspect(event.type)} event; return " <> "{:ack, map} from a view_submission, or post via Slink.API directly" end end def reply(%Slink.Context{event: nil}, _text, _opts) do raise ArgumentError, "reply/3 requires context.event; call it from a handler (the dispatcher sets the event) " <> "or use send_message/4 for an arbitrary channel" end # Add thread_ts only when the placement is threaded and we actually have a # timestamp to thread under β€” never send a nil thread_ts. defp thread(body, to, event) do body = Map.new(body) with true <- threaded?(to, event), ts when is_binary(ts) <- Slink.Event.reply_thread(event) do Map.put_new(body, :thread_ts, ts) else _ -> body end end defp threaded?(:thread, _event), do: true defp threaded?(:channel, _event), do: false defp threaded?(:auto, event), do: in_thread?(event) defp slash_type(:channel), do: "in_channel" defp slash_type(_ephemeral), do: "ephemeral" @doc """ Open a modal in response to the interaction or slash command in `context` (imported by `use Slink`). Uses the event's `trigger_id`, which Slack honours for only ~3 seconds, so open promptly. `view` is a Block Kit view map. Returns `Slink.API.open_view/3`'s result. For follow-ups use `Slink.API.update_view/3` and `Slink.API.push_view/3`. def handle_event(%Slink.Event{type: :shortcut} = _event, context) do open_modal(context, my_view()) :ok end """ def open_modal(%Slink.Context{bot_token: token, event: %Slink.Event{} = event}, view) do Slink.API.open_view(token, Slink.Event.trigger_id(event), view) end @working_emoji "hourglass_flowing_sand" @working_delay_ms 3_000 @doc """ 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 `#{@working_emoji}` ⏳). 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 #{@working_delay_ms}ms); 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 `#{@working_delay_ms}`). Use `0` to show it immediately. * `:emoji` β€” reaction name without colons (default `"#{@working_emoji}"`). 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. """ def working(context, fun, opts \\ []) def working(%Slink.Context{event: %Slink.Event{} = event} = context, fun, opts) when is_function(fun, 0) do channel = Slink.Event.channel(event) ts = Slink.Event.ts(event) if is_binary(channel) and is_binary(ts) do emoji = Keyword.get(opts, :emoji, @working_emoji) delay = Keyword.get(opts, :delay_ms, @working_delay_ms) with_indicator(context, fun, channel, ts, emoji, delay) else fun.() end end # Run fun in a task; add the reaction only if it hasn't finished within `delay`, # and always remove it once fun completes. A task exit is re-propagated so a # crashing handler still crashes as it would run inline. defp with_indicator(context, fun, channel, ts, emoji, delay) do task = Task.Supervisor.async_nolink(Slink.TaskSupervisor, fun) case Task.yield(task, delay) do {:ok, result} -> result {:exit, reason} -> exit(reason) nil -> _ = Slink.API.add_reaction(context.bot_token, channel, ts, emoji) try do case Task.yield(task, :infinity) do {:ok, result} -> result {:exit, reason} -> exit(reason) end after _ = Slink.API.remove_reaction(context.bot_token, channel, ts, emoji) end end end defmacro __using__(_opts) do quote do @behaviour Slink import Slink, only: [ send_message: 3, send_message: 4, reply: 2, reply: 3, working: 2, working: 3, open_modal: 2, in_thread?: 1 ] end end end