Basic Operations
The SlackGateway[F] trait provides basic messaging operations. All methods return values wrapped in your effect type F.
Sending Messages
msgId <- slack.send(channel, "Deployment started")
send returns a MessageId (channel + timestamp) that you can use to reply, update, or delete the message later.
Replying in Threads
_ <- slack.reply(msgId, "Step 1 complete")
reply posts a message as a thread reply under the message identified by MessageId.
Updating Messages
_ <- slack.update(msgId, "Deployment finished")
update replaces the text (and optionally the buttons) of an existing message. This is commonly used to replace a message's buttons with a status after a user clicks one.
Deleting Messages
_ <- slack.delete(msgId)
Reactions
_ <- slack.addReaction(msgId, "hourglass_flowing_sand")
_ <- slack.removeReaction(msgId, "hourglass_flowing_sand")
_ <- slack.addReaction(msgId, "white_check_mark")
Reactions are a lightweight way to show status on a message without updating its text.
Ephemeral Messages
slack.sendEphemeral(channel, userId, "Only you can see this")
Ephemeral messages are visible only to the specified user. They're useful for command acknowledgments or error messages that shouldn't clutter the channel.
Buttons on Messages
All message-sending methods (send, reply, update) accept an optional buttons parameter:
slack.send(
channel,
"Approve deployment?",
Seq(
approveBtn.render("Approve", "v1.2.3"),
rejectBtn.render("Reject", "v1.2.3"),
),
)
See Buttons for how to register button handlers.
User Info Cache
getUserInfo fetches user profile data from Slack and caches it in memory (15-minute TTL, 1000 entries by default):
info <- slack.getUserInfo(userId)
// info.profile.flatMap(_.email) — user's email
// info.profile.flatMap(_.real_name) — display name
You can provide a custom cache implementation:
customCache <- UserInfoCache.inMemory[IO](ttl = Duration.ofMinutes(30), maxEntries = 5000)
_ <- slack.withUserInfoCache(customCache)
Or disable caching entirely with UserInfoCache.noCache.
Idempotent Sending
send and reply accept an optional idempotencyKey parameter. When provided, the gateway checks for a recently sent message with the same key before posting a new one. If a match is found, the existing MessageId is returned and no duplicate message is sent.
msgId <- slack.send(channel, "Deployment started", idempotencyKey = Some(IdempotencyKey("deploy-v1.2.3")))
This also works for thread replies:
_ <- slack.reply(msgId, "Step 1 complete", idempotencyKey = Some(IdempotencyKey("step-1")))
The key is embedded in the message's metadata field and is used for duplicate detection.
How it works
By default, the gateway uses a Slack scan strategy: before posting, it calls conversations.history (or conversations.replies for threads) and looks for a message whose metadata contains the matching key. This works across restarts since the key is persisted on the message itself.
Customizing the check
You can swap the idempotency strategy using withIdempotencyCheck:
check <- IdempotencyCheck.inMemory[IO](ttl = Duration.ofMinutes(30), maxEntries = 5000)
_ <- slack.withIdempotencyCheck(check)
Available implementations:
IdempotencyCheck.slackScan(default) — scans recent Slack messages via the API. Survives restarts and works across multiple app instances, but makes an API call per send.IdempotencyCheck.inMemory— fast in-memory cache with configurable TTL and max entries. No extra API calls, but state is lost on restart and not shared across multiple app instances — each instance maintains its own cache, so duplicates can still occur if the same key is sent from different instances.IdempotencyCheck.noCheck— disables idempotency checks entirely. Messages are always sent.
The IdempotencyCheck trait is simple to implement, so you can provide your own backed by Redis, a database, or any shared store if you need cross-instance deduplication without the per-send Slack API cost.
Caveats
- Race condition: If two processes send with the same key simultaneously, both may send before either's message appears in the history scan. The
inMemorycheck has the same limitation across instances. - Rate limiting: The default
slackScanmakes aconversations.history/conversations.repliescall for each send with a key. For high-volume use, preferinMemoryor a custom implementation. updateanddeleteare not affected — they are already naturally idempotent byMessageId.
Error Handling
By default, exceptions thrown in interaction handlers (buttons, commands, forms) are logged and swallowed so the WebSocket connection stays alive. You can replace the default handler with onError:
slack.onError { error =>
IO.println(s"Handler error: ${error.getMessage}")
}
This is useful for sending errors to an alerting system or logging them in a structured format. The handler replaces any previously set handler.