All notable changes to this project are documented here. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[0.5.0] - 2026-07-09

Multi-workspace support: one bot module can now serve many workspaces over either transport, routing a per-workspace token per request. Plus fixes from a deep review of the new code.

Added

  • Multi-workspace routing. Slink was already token-per-call throughout; this makes serving several workspaces from one bot straightforward:
    • Slink.Event.team_id/1 returns the workspace (team) id for any payload shape (event callback, interaction, slash command; both transports).
    • Slink.EventsApi.Plug's :bot_token now also accepts a 1-arity function, called with the event's team id — the seam for looking a token up from your own per-team store. String and 0-arity forms are unchanged. The signing secret stays a single value (it's per-app, not per-install).
    • Slink.SocketMode documents running one client per workspace, and now defines child_spec/1 keyed on :name so several clients coexist under one supervisor. Acquiring/storing per-team tokens (the OAuth install flow) remains yours to own — see the roadmap.

Fixed

  • A token/secret resolver that exits no longer 500s the request. A 1-arity :bot_token (or :signing_secret) resolver backed by a token store exits — not raises — on a GenServer.call timeout, which rescue alone didn't catch. Slink.EventsApi.Plug now also traps exits/throws and degrades to unset, as its never-500 contract promises.
  • Slink.SocketMode.child_spec/1 derives its :id from :name, so the documented one-client-per-workspace pattern boots instead of failing with :duplicate_child_name on the default id.

Documentation

  • Corrected open_modal/2: the opened view's id is for update_view/3; push_view/3 takes a fresh trigger_id from a later in-modal interaction.
  • Slink.Dedup documents that dedup is node-local (per-instance).
  • Fixed mix docs warnings (doc references to the hidden Dispatcher module).

0.4.0 - 2026-07-09

A review-hardening release: closes the last token-to-logs path, removes a connection crash-loop, and tightens the helper API for predictability. One small breaking rename.

Security

  • A bot token could reach crash logs via Slink.Context. The context is handed to your handle_event/2, so a raising handler (e.g. a bot with no catch-all clause hitting a FunctionClauseError) had OTP print the context — token included — as a blamed argument. Slink.Context now derives an Inspect that omits :bot_token, closing the one leak the transports' format_status/1 didn't already cover.

Fixed

  • A raising open_connection no longer crash-loops the Socket Mode client. With a missing/nil app token Req raises rather than returning an error; connect/1 runs in handle_continue/handle_info, so the unrescued raise crashed the GenServer before its backoff — escalating into a supervisor restart loop that could take the host app down. connect/1 now contains any raise/exit and schedules a backed-off retry, as its contract always promised.
  • Slink.API.open_connection/1 degrades a malformed ok: true response with no "url" to {:error, {:no_url, body}} instead of raising CaseClauseError.
  • send_message/4 accepts a keyword list or a map for opts, matching reply/3send_message(ctx, ch, "hi", blocks: [...]) previously raised BadMapError.
  • An invalid to: raises a clear ArgumentError naming the allowed values, instead of a cryptic FunctionClauseError (message replies: :auto / :thread / :channel) or silently collapsing to ephemeral (slash replies: :ephemeral / :channel).
  • A signing-secret/bot-token resolver that raises (e.g. System.fetch_env! on an unset var) is treated as unset — the request fails closed (401) instead of 500ing.
  • A view_submission handler that returns {:reply, …} (silently dropped — a modal submit answers only with {:ack, map}) now logs a warning, so the no-op is visible.

Changed

  • Breaking: Event.mention?/1 is renamed to Event.app_mention?/1, to disambiguate it from mentions?/2 ("is this an app_mention event" vs "is a given user mentioned in the text"). Rename any call sites.
  • in_thread?/1 also accepts a context (not just an Event), consistent with the other use Slink helpers.

0.3.2 - 2026-07-09

Documentation

  • Clarified the handle_event/2 return contract. A handler returns :ok (or anything that isn't {:reply, …} / {:ack, …}) when there's nothing to reply — no ceremony. open_modal/2 keeps returning {:ok, response} | {:error, reason} (the standard shape for a call that returns data and can fail; response["view"]["id"] is what you pass to update_view/3 / push_view/3), and a handler can simply end with it — the dispatcher treats its non-{:reply, …} return as "no reply", so no trailing :ok is needed. The docs previously implied one was required.
  • README now shows replying with a button (Block Kit blocks on a reply) and handling the click.

0.3.1 - 2026-07-09

Security

  • A bot token could still reach crash reports via the last-message field. 0.3.0 redacted the rate-limit worker's state, but OTP also prints the last message a process handled, and an {:enqueue, {token, …}} cast carries the token — so a worker crashing mid-cast (e.g. a send_fun that exits, which pump/1's rescue doesn't catch) still logged it. Slink.Rate.Channel's format_status/1 now redacts the :message too.

0.3.0 - 2026-07-09

Deep-review release: connection-lifecycle hardening for Socket Mode, dedup correctness, resource bounds, and Phoenix deployment fixes.

Added

  • Socket Mode liveness watchdog (:idle_timeout_ms, default 2 minutes). A connection that dies without a close — NAT timeout, network partition — previously left the client believing it was connected forever: the bot went permanently deaf until the process was restarted. Slack pings every few seconds, so silence now triggers a reconnect. Pass :infinity to disable.
  • The Events API plug accepts signing_secret/bot_token as 0-arity functions, resolved per request. Phoenix's forward evaluates init options at compile time in production, so a literal System.fetch_env!/1 in the router read the build machine's env (or failed the build). New Mounting in Phoenix docs also cover the Plug.Parsers raw-body pitfall (mounted after parsers, signature verification always 401s).
  • config :slink, :rate_idle_stop_ms, 600_000 — an idle channel rate-limit worker now stops itself (default 10 min). Previously every channel ever posted to kept a worker process alive for the node's lifetime.

Security

  • Bot tokens no longer appear in crash reports. OTP prints a GenServer's full state when it terminates abnormally (and in :sys.get_status); the Socket Mode client's state and every queued rate-limiter request carry the bot token, so any crash wrote tokens to logs. Both now implement format_status/1 and redact them.
  • Signature verification fails closed on a misconfigured signing secret. A secret that resolves to nil, "", or a non-string (e.g. a function reading a missing env var) now rejects with 401 instead of crashing into a 500 — and an empty secret is never accepted, since an empty-key HMAC is computable by anyone.

Fixed

  • Event dedup now covers Slack's real retry schedule. Slack retries a failed delivery immediately, after ~1 minute, and after ~5 minutes; the old 60s default TTL only caught the first retry, so later retries double-fired handlers. Default :dedup_ttl_ms is now 11 minutes.
  • Event dedup is keyed per handler module. Two bots in one VM (two Slack apps receiving the same workspace event, which carries the same event_id) shared one dedup namespace — whichever dispatched first silently swallowed the other's delivery.
  • A duplicate reconnect timer no longer opens a second connection. Slack sends disconnect and then closes the socket; when both arrived in one batch each scheduled a reconnect, and the second :connect opened a parallel connection while leaking the first. A :connect while connected is now ignored.
  • Stale bytes can no longer leak across reconnects. A reconnect firing mid-batch could buffer trailing frame bytes from the old connection and later decode them with the new connection's WebSocket state, corrupting its framing. The buffer is now cleared when a new connection starts.
  • Reconnect backoff resets on Slack's hello, not on the WebSocket handshake — an accept-then-immediately-disconnect loop (e.g. too many connections) now backs off instead of retrying at full speed forever.
  • The url_verification challenge is echoed only when it's a string.

0.2.2 - 2026-07-09

Robustness release: no user action, handler return value, or malformed Slack frame can take a transport down.

Fixed

  • A view_submission handler can no longer take down the transport. When a handler returned an {:ack, map} whose payload wasn't JSON-encodable (e.g. it held a tuple), encoding the ACK frame raised inside the transport process — on Socket Mode this crashed the connection and the supervisor's restart re-read the same envelope, producing a crash-reconnect loop. The payload is now checked for encodability before the ACK frame is built; a non-encodable one is logged and degrades to %{} (closing the modal), like a handler crash already does.
  • A malformed Slack frame can no longer crash the Socket Mode connection. Slink.Event parsing and accessors used get_in/2, which raises when a value Slack normally nests as a map (a channel, view, item, or the envelope payload itself) arrives as a string, list, or null. Since event_id/1 and event construction run in the socket process, that raise dropped the connection. All parsing and accessors are now total — a wrong-shaped payload yields nil/empty defaults instead of raising — and the socket wraps per-message handling as a final backstop. That backstop also no longer reverts a sent ACK's connection state: a dispatch that raised after the envelope was ACKed would otherwise unwind to the pre-ACK state and leave the socket on a stale Mint connection, so the post-ACK dispatch is contained on its own.
  • A bad reply body no longer crashes a channel's rate-limit worker. If a handler's reply carried a body the Web API client couldn't encode, the raise killed the per-channel Slink.Rate.Channel worker and dropped everything else queued for that channel. The send is now wrapped so a raising call is logged and draining continues, and Slink.Rate tolerates a worker that fails to start rather than crashing the caller.
  • The Events API plug no longer 500s on a form body with invalid percent-encodingURI.decode_query/1 raising now degrades to an empty payload.

0.2.1 - 2026-07-09

Fixed

  • reply/3 to a reaction event no longer crashes. reaction_added / reaction_removed nest their target under payload["item"], so Event.channel/1, ts/1 now read it there — previously a reply to a reaction raised ArgumentError (no channel) and working/3 was a no-op.

Changed

  • Event.command/1 unwraps Slack link markup to plain text. A linkified address (<mailto:a@b.com|a@b.com>) now comes through as the bare a@b.com, and <@U1|alice> / <#C1|general> / <https://x|label> reduce to their name/url — so the addressed text reads as a human typed it. A bare <@U1> (e.g. the bot's own mention) still drops out.

0.2.0 - 2026-07-09

Added

  • Slash commands, end to end. Both transports now decode slash-command payloads (application/x-www-form-urlencoded over HTTP). reply/3 routes by event kind — a channel post for messages and interactions, or the response_url for a slash command (to: :ephemeral default, or :channel) — and raises clearly when an event has no channel to reply to (e.g. a view_submission). New Slink.Event accessors: command_name/1, response_url/1, trigger_id/1.
  • Interactivity & modals. Interactions are routed on their inner type (:block_actions, :view_submission, :shortcut, …). A view_submission handler may return {:ack, map} to control the modal (validation errors, update, push) — it runs synchronously so Slack gets the response in time. New helper open_modal/2 (imported by use Slink) and Slink.API.open_view/3, update_view/3, push_view/3, publish_view/3 (App Home). New Slink.Event accessors: actions/1, action_id/1, action_value/1, callback_id/1, view/1, view_values/1.
  • Event deduplication (Slink.Dedup). Slack's retried deliveries (same event_id) dispatch only once. On by default; config :slink, :dedup, false to disable, :dedup_ttl_ms to tune. New Slink.Event.event_id/1, retry?/1, retry_attempt/1.
  • Wider Web API surface (Slink.API): update_message/5, delete_message/3, post_ephemeral/5, get_permalink/3, user_info/2, and respond/2 (post to a response_url). call/3 now retries HTTP 429s, honouring Retry-After.

0.1.0 - 2026-07-08

Initial release.

Added

  • One event-handling contract, two transports. Write a bot once against the Slink behaviour and run it over either transport unchanged.
    • Slink.SocketMode — dials out to Slack over a WebSocket (Mint.WebSocket), no public endpoint required. Opens the connection via apps.connections.open, auto-reconnects, and optionally auto-joins channels on boot (:join).
    • Slink.EventsApi.Plug — a Plug for Slack's HTTP event callbacks. Mount it in a Plug/Phoenix router or run it standalone with Bandit.
  • Request hardening (Events API): HMAC-SHA256 signature verification against the raw body, constant-time comparison, timestamp-freshness (replay) window, request-body size cap, and automatic url_verification handshake.
  • Normalised events. Slink.Event collapses Socket Mode envelopes and HTTP payloads into one shape; both transports acknowledge to Slack before your handler runs and dispatch off-process, so a slow handler never blows Slack's ~3s ACK window.
  • Handler helpers (imported by use Slink): send_message/3,4, reply/2,3, working/2,3, and in_thread?/1.
  • Per-channel outbound rate limiting (Slink.Rate) to stay within Slack's ~1 message/sec/channel limit. Tunable via config :slink, :rate_interval_ms.
  • Web API client (Slink.API) built on Req.
  • Slink.enabled?/1 to conditionally start a bot from config.
  • A shippable app manifest (manifest.json) and a runnable Slink.ExampleBot.