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.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 inSlink.Dispatcher.ack_result/3; 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.