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/1returns the workspace (team) id for any payload shape (event callback, interaction, slash command; both transports).Slink.EventsApi.Plug's:bot_tokennow 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.SocketModedocuments running one client per workspace, and now defineschild_spec/1keyed on:nameso 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 storeexits — not raises — on aGenServer.calltimeout, whichrescuealone didn't catch.Slink.EventsApi.Plugnow also traps exits/throws and degrades to unset, as its never-500 contract promises. Slink.SocketMode.child_spec/1derives its:idfrom:name, so the documented one-client-per-workspace pattern boots instead of failing with:duplicate_child_nameon the default id.
Documentation
- Corrected
open_modal/2: the opened view's id is forupdate_view/3;push_view/3takes a freshtrigger_idfrom a later in-modal interaction. Slink.Dedupdocuments that dedup is node-local (per-instance).- Fixed
mix docswarnings (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 yourhandle_event/2, so a raising handler (e.g. a bot with no catch-all clause hitting aFunctionClauseError) had OTP print the context — token included — as a blamed argument.Slink.Contextnow derives anInspectthat omits:bot_token, closing the one leak the transports'format_status/1didn't already cover.
Fixed
- A raising
open_connectionno longer crash-loops the Socket Mode client. With a missing/nilapp tokenReqraises rather than returning an error;connect/1runs inhandle_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/1now contains any raise/exit and schedules a backed-off retry, as its contract always promised. Slink.API.open_connection/1degrades a malformedok: trueresponse with no"url"to{:error, {:no_url, body}}instead of raisingCaseClauseError.send_message/4accepts a keyword list or a map for opts, matchingreply/3—send_message(ctx, ch, "hi", blocks: [...])previously raisedBadMapError.- An invalid
to:raises a clearArgumentErrornaming the allowed values, instead of a crypticFunctionClauseError(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_submissionhandler 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?/1is renamed toEvent.app_mention?/1, to disambiguate it frommentions?/2("is this anapp_mentionevent" vs "is a given user mentioned in the text"). Rename any call sites. in_thread?/1also accepts acontext(not just anEvent), consistent with the otheruse Slinkhelpers.
0.3.2 - 2026-07-09
Documentation
- Clarified the
handle_event/2return contract. A handler returns:ok(or anything that isn't{:reply, …}/{:ack, …}) when there's nothing to reply — no ceremony.open_modal/2keeps returning{:ok, response} | {:error, reason}(the standard shape for a call that returns data and can fail;response["view"]["id"]is what you pass toupdate_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:okis needed. The docs previously implied one was required. - README now shows replying with a button (Block Kit
blockson 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. asend_funthatexits, whichpump/1'srescuedoesn't catch) still logged it.Slink.Rate.Channel'sformat_status/1now redacts the:messagetoo.
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:infinityto disable. - The Events API plug accepts
signing_secret/bot_tokenas 0-arity functions, resolved per request. Phoenix'sforwardevaluates init options at compile time in production, so a literalSystem.fetch_env!/1in the router read the build machine's env (or failed the build). New Mounting in Phoenix docs also cover thePlug.Parsersraw-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 implementformat_status/1and 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_msis 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
disconnectand then closes the socket; when both arrived in one batch each scheduled a reconnect, and the second:connectopened a parallel connection while leaking the first. A:connectwhile 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_verificationchallenge 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_submissionhandler 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.Eventparsing and accessors usedget_in/2, which raises when a value Slack normally nests as a map (achannel,view,item, or the envelopepayloaditself) arrives as a string, list, or null. Sinceevent_id/1and event construction run in the socket process, that raise dropped the connection. All parsing and accessors are now total — a wrong-shaped payload yieldsnil/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.Channelworker and dropped everything else queued for that channel. The send is now wrapped so a raising call is logged and draining continues, andSlink.Ratetolerates 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-encoding —
URI.decode_query/1raising now degrades to an empty payload.
0.2.1 - 2026-07-09
Fixed
reply/3to a reaction event no longer crashes.reaction_added/reaction_removednest their target underpayload["item"], soEvent.channel/1,ts/1now read it there — previously a reply to a reaction raisedArgumentError(no channel) andworking/3was a no-op.
Changed
Event.command/1unwraps Slack link markup to plain text. A linkified address (<mailto:a@b.com|a@b.com>) now comes through as the barea@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-urlencodedover HTTP).reply/3routes by event kind — a channel post for messages and interactions, or theresponse_urlfor a slash command (to: :ephemeraldefault, or:channel) — and raises clearly when an event has no channel to reply to (e.g. aview_submission). NewSlink.Eventaccessors:command_name/1,response_url/1,trigger_id/1. - Interactivity & modals. Interactions are routed on their inner type
(
:block_actions,:view_submission,:shortcut, …). Aview_submissionhandler may return{:ack, map}to control the modal (validation errors, update, push) — it runs synchronously so Slack gets the response in time. New helperopen_modal/2(imported byuse Slink) andSlink.API.open_view/3,update_view/3,push_view/3,publish_view/3(App Home). NewSlink.Eventaccessors:actions/1,action_id/1,action_value/1,callback_id/1,view/1,view_values/1. - Event deduplication (
Slink.Dedup). Slack's retried deliveries (sameevent_id) dispatch only once. On by default;config :slink, :dedup, falseto disable,:dedup_ttl_msto tune. NewSlink.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, andrespond/2(post to aresponse_url).call/3now retries HTTP429s, honouringRetry-After.
0.1.0 - 2026-07-08
Initial release.
Added
- One event-handling contract, two transports. Write a bot once against the
Slinkbehaviour 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 viaapps.connections.open, auto-reconnects, and optionally auto-joins channels on boot (:join).Slink.EventsApi.Plug— aPlugfor 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_verificationhandshake. - Normalised events.
Slink.Eventcollapses 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, andin_thread?/1. - Per-channel outbound rate limiting (
Slink.Rate) to stay within Slack's ~1 message/sec/channel limit. Tunable viaconfig :slink, :rate_interval_ms. - Web API client (
Slink.API) built onReq. Slink.enabled?/1to conditionally start a bot from config.- A shippable app manifest (
manifest.json) and a runnableSlink.ExampleBot.