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.6.0 - 2026-07-17
The whole roadmap in one release: quality-of-life helpers (DMs, ephemeral replies, updating a clicked message, bot self-identity), installing into many workspaces, Socket Mode that survives a dropped connection, a public testing story, one-call file uploads, Web API pagination, Block Kit builders, and AI-app support (assistant threads + streamed replies).
Added
mix slink.smoke— a live workspace smoke test,auth.testfor everything Slink uses: identity, posting, reactions, and whether the AI streaming surface is enabled for the app (informational —stream_reply/3falls back to plain messages without it). Point it at a private testing channel:SLACK_BOT_TOKEN=xoxb-... mix slink.smoke C012345. The offline half of the story isSlink.Testing.AI-app (assistant) support.
:assistant_thread_started/:assistant_thread_context_changednormalise like any other event (channel, user and thread resolve fromassistant_thread). Handlers getset_status/2("is thinking…") andstream_reply/3— feed it any enumerable of text chunks (an LLM token stream) and it renders as one live, progressively-updating message viachat.startStream/appendStream/stopStream, batched under Slack's limits, degrading to a singlechat.postMessagewhen the surface can't stream.Slink.APIgainsset_thread_status/4,set_thread_title/4,set_suggested_prompts/5,start_stream/4,append_stream/4,stop_stream/4.Slink.BlockKit— plain builder functions for Block Kit maps (no DSL, no macros):section,header,divider,context,image,actions,input,button,static_select,option,plain_text_input,mrkdwn/plain_text, andmodalforopen_modal/2. They return exactly the maps Slack expects, so they mix freely with hand-written ones.OAuth install flow.
Slink.OAuth.authorize_url/1builds the "Add to Slack" consent URL;Slink.OAuth.exchange/2(over the newSlink.API.oauth_access/4) turns the redirect's code into a normalisedSlink.OAuth.Install— team id/name, bot token (redacted from inspects), bot user id, enterprise id, with Slack's full response inraw. MountSlink.OAuth.Plugat the app's Redirect URL and hand the install to your own store via its:installcallback; it handles the cancelled-consent redirect, an optional:verify_stateCSRF check, a:redirect_tosuccess redirect, and contains callback crashes without leaking tokens into logs. Persistence stays yours, as promised by the roadmap.Socket Mode high availability.
{Slink.SocketMode, connections: 2, ...}holds N WebSocket connections open under one supervisor (Slack recommends 2+ in production, allows up to 10), so a dropped socket doesn't lose events while it reconnects. Fleet child specs are typedtype: :supervisor. Default stays1— exactly the previous behaviour.Slink.Testing— unit-test handlers without Slack.event/2builds realistic fixtures for mentions, messages, reactions, slash commands, block actions, modal submits, shortcuts, App Home and assistant threads (assembled through the production Socket Mode normaliser, so shapes can't drift);run/3executes a handler with every Web API call andresponse_urlpost captured into aSlink.Testing.Run— synchronously and offline. Failure paths are scriptable via:api. Requiresasync: false(it swaps Slink's process-global test seams).Slink.API.upload_file/3— one call for Slack's external upload flow (files.getUploadURLExternal→ upload the bytes →files.completeUploadExternal), with:channel,:initial_comment,:thread_ts,:title,:alt_textand:snippet_typeoptions.Slink.API.stream/3— lazily stream every page of a cursor-paginated method, followingresponse_metadata.next_cursorwithlimit: 200by default and 429s waited out. RaisesSlink.API.Erroron a failed page (a lazy stream can't return an error tuple).send_dm/4(imported byuse Slink) — DM a user in one call:conversations.open+ a rate-limited post. AlsoSlink.API.open_dm/2.reply(context, text, to: :ephemeral)— an only-the-invoker reply for any event kind: interactions and slash commands go through theirresponse_url; plain events (a mention, a message) usechat.postEphemeral.update_original/3(imported) — replace the message an interaction came from via itsresponse_url(replace_original: true): the canonical "button click updates its own message" pattern, and it works on ephemeral messages and in channels the bot isn't a member of.Interactions without a channel no longer make
reply/3raise — a click on an ephemeral message, or in a channel the bot isn't in, falls back to the interaction'sresponse_url(ephemeral by default,to: :channelfor everyone) instead of failing.Bot self-identity.
context.bot_user_idcarries the bot's own user id, discovered viaauth.testand cached per token by the newSlink.Identity(async prewarm — never blocks a transport;niluntil the one-off lookup lands). Powers the importedmentions_me?/1— "am I mentioned in this message?" for events that aren't an:app_mention.New
Slink.APIwrappers:schedule_message/5(chat.scheduleMessage),join_channel/2(conversations.join),history/3(conversations.history, single page),auth_test/1,open_dm/2.config :slink, :max_handler_tasks, N— an opt-in backpressure valve: caps concurrently-running handler tasks (default:infinity, the previous behaviour); past the cap the dispatcher sheds events with an error log rather than letting an event flood spawn without bound.
Fixed
- Slash commands and interactions now dedup on their Socket Mode
envelope_id(they carry noevent_id, so previously a redelivered envelope — after a dropped ACK on one socket, or across a fleet's connections — ran the handler twice).view_submissiondeliberately stays un-deduped: its synchronous ack must answer every delivery. - Concurrent
view_submissionsubmits no longer serialize. Socket Mode ran each synchronous modal-submit handler inside the transport GenServer, so a slow submit could push a concurrent one past Slack's ~3s window. Handlers now run off-process and the ACK frame is sent on completion — a fast submit ACKs immediately even while a slow one is still running. message_changed/message_deletedare fully normalised.text/1,user/1,ts/1andthread_ts/1now read the nested message (the edited / deleted content — the wrapper's owntsis the edit event's, not postable), soreply_thread/1threads correctly under the real message, andfrom_bot?/1also recognises deleted bot messages.
Documentation
- A guide per feature area on hexdocs: Slash commands, buttons & modals,
Composing helpers (the return-shape conventions and
withchaining), AI apps, Serving many workspaces (routing + OAuth), Testing your bot, and Going to production (deployment, operational knobs, telemetry, secrets hygiene). The README slims down to teasers linking into them.
0.5.1 - 2026-07-17
Bug-hunt pass over the whole library (three independent review lenses: OTP/concurrency, HTTP/security, Slack protocol semantics), plus routine dependency updates.
Security
- A failing token/secret resolver no longer
inspects its exception into the log. The documented multi-workspace pattern (Map.fetch!(tokens, team_id)) raises aKeyErrorwhosetermis the whole token map on an unknown team — logging the inspected exception printed every workspace's bot token. Only the failure's kind (e.g.KeyError) is logged now.
Fixed
Slink.Event.from_bot?/1recognises the bot's ownmessage_changedevents. Slack nests the real message (and itsbot_id) under"message"for that subtype, so an edit or link unfurl of the bot's own post slipped past the auto-reply loop protection.- Slack's
ssl_checkprobe no longer dispatches a phantom slash command. The form-encodedssl_check=1health probe of a slash-command URL is answered200directly instead of reaching handlers as a:slash_commandsevent with anilcommand. - Exits are contained like raises on the crash-safety paths. The Socket Mode message path and the rate worker's send loop rescued exceptions but not exits; a Finch pool-checkout timeout (a slow Slack — exactly when the queue is longest) exits, which crashed the rate worker and dropped its whole queue, and a task-supervisor blip could take down the transport.
Slink.SocketModewithname: nilgets a unique child id, so several unregistered clients coexist under one static supervisor, as the docs promise.
Added
tokens_revokedandapp_uninstalledare known event types (atoms), since a multi-workspace app typically handles them to prune its token store.
Documentation
- Telemetry events (
[:slink, :event, :received],[:slink, :socket, :connected],[:slink, :socket, :disconnected]) are now documented in the README. - README install snippet tracks the current version (
~> 0.5). reply/3's no-channel error explains global shortcuts, not just modals; theworking/3docs note the reaction can be left behind on a mid-work shutdown.
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.