ADR-0040: Slack adapter with channel:ts message refs, configurable conversation scopes and an in-band challenge callback
| Status | Accepted |
| Date | 2026-10-11 |
| Issue | #39 |
| Pull request | #131 |
| Related | ADR-0009, ADR-0015, ADR-0016, ADR-0019, ADR-0038 |
Context and problem statement
Issue #39 adds a slack channel: Events API callbacks
in, Web API calls out, verified with the app's signing secret. Slack differs from the WhatsApp providers
the adapter behaviour was shaped by in four ways that need a decision:
- Message identity. A Slack message is identified by its
ts, which is unique only within a Slack channel. Edits (chat.update), deletes (chat.delete), thread replies (thread_ts) and reactions (item.ts) all name a message by channel andts. The same message can also arrive twice: as amessageevent and as anapp_mentionevent, with differentevent_ids, and Slack retries any callback that is not answered with2xxwithin 3 seconds. - What a conversation is. One Slack app serves many channels and threads, and people talk in
threads. Converger finds the conversation of an inbound message through the participant's
external_id(ADR-0016), one active conversation per participant and channel. The issue asks for "conversation per Slack thread or per channel (configurable)" and a participant id built from channel and user. - The handshake. Slack verifies a Request URL with a signed
POST{"type": "url_verification", "challenge": "..."}to the same URL as the events, and expects the challenge back. The only handshake hook,verify_subscription/2, answers aGET(Meta'shub.challenge). - Files.
files.uploadis retired; the replacement (files.uploadV2in Slack's SDKs) is three calls: get an upload URL, upload the bytes, complete the upload, which shares the files. The completion does not return thetsof the message it posts.
Decision drivers
- No message is lost and none is duplicated by Slack's retries or by the
message/app_mentionpair. - Replies, edits and deletes written by any client of a conversation must reach the right Slack thread, without the client knowing Slack ids.
- Reuse the participant model and the existing inbound path; no Slack-specific code outside the adapter other than generic behaviour callbacks.
- Signature checks run before anything else answers, including the handshake.
Considered options
Message identity
"<channel>:<ts>"as the idempotency key of inbound messages and the provider message id of outbound deliveries - one string resolves references in both directions.- Slack
event_idas the idempotency key - unique per event, but theapp_mentioncopy of a message gets a different id (duplicate activity), and edits / reactions namets, not the event. tsalone - collides across channels.
Conversations
- Encode the scope in the participant
external_id:"<channel>:<thread root ts>"(thread),"<channel>"(channel) or"<channel>:<user>"(user), chosen by the channel config keyconversation_scope; the Slack user is the activitysender. - Participant = channel + user always, thread in metadata - every thread of a user would land in one conversation, and a thread with several people in several conversations.
- A Slack-specific conversation lookup - a second resolution path next to ADR-0016.
Handshake
- A new optional callback
inbound_challenge/2, called by the inbound controller after the signature check and before parsing;{:ok, body}is returned as JSON. - Answer from
parse_inbound/2- parsing returns messages, not responses; the controller would have to special-case a return value. - A separate Slack route - a second inbound endpoint and signature path for one provider.
Outbound files
- Stage, post, share: fetch and upload every file (invisible in Slack), post the text with
chat.postMessage(which returns thetsthe delivery is keyed by), then complete the upload into the same channel and thread. - Text as the upload's
initial_comment- one visible step, but nots, so the message could never be edited, deleted or replied to, and Block Kit is not available there.
Decision
- Message identity: option 1. The inbound idempotency key and the outbound
provider_message_idare both"<channel>:<ts>". A retried callback and theapp_mentionof amessageare duplicates of one activity (ADR-0015); a thread reply, a reaction, an edit or a delete names its target asreply_to_provider_id = "<channel>:<ts>", which resolves to an inbound activity or to an outbound one through its delivery. Edits are keyed by"<channel>:<ts>:edit:<edited.ts>"and deletes by"<channel>:<ts>:delete", reactions by theirevent_id. - Conversations: option 1,
conversation_scopethread(default),channeloruser. Outbound delivery reads the participant back: the part after:is a threadtswhen it looks like one (\d+.\d+), else a user id. Precedence for the target is activity metadata (slack_channel,slack_thread_ts), the participant, the thread of the activity replied to, the config's defaultchannel. A reaction names only channel andts, not the thread, so in the thread scope it joins the conversation of the reacted-to activity when Converger knows it. - Handshake: option 1.
inbound_challenge/2is optional; without it nothing changes. - Files: option 1. A failure while staging leaves nothing in Slack and is retried cleanly. A failure after the post (completion fails) is retried as a whole and can post the text twice; we accept a possible duplicate over a lost file.
- Bots are ignored inbound (
bot_idor subtypebot_message), which drops Converger's own replies and avoids loops between bots. Unknown subtypes and event types are acknowledged and dropped. - Errors. Slack answers most failures with
200 {"ok": false}.ratelimited,internal_error,fatal_error,service_unavailableandrequest_timeoutare retryable; other error codes are permanent. HTTP429honoursRetry-After(ADR-0019). The adapter declares:provider_ack, so handled requests always get200and Slack does not retry, or disable the subscription for, permanently rejected messages.
Consequences
Positive
- One reference format serves idempotency, threading, reactions, edits and deletes.
- Thread-per-conversation fits how Slack is used, and needs no change to conversation resolution.
- Any adapter with an in-band handshake (Slack today; Discord interactions or Teams later) can use
inbound_challenge/2.
Negative and trade-offs
- Changing
conversation_scopeon a live channel starts new conversations: existing participants keep their old external ids. - In the thread scope, a reaction to a message Converger never saw starts a conversation for that
message's
ts, which may be a reply inside a thread (the event does not name the root). - Inbound files are references by default.
download_files: truestores them as Converger uploads inside the webhook request, which counts against Slack's 3 second budget; a slow download makes Slack retry, which idempotency absorbs. - Messages from other bots are dropped too; integrations between bots through Converger need a config switch later.
- Slack does not let a bot edit or delete a user's message, so
messageUpdate/messageDeleteof an activity that did not come from this channel's deliveries fail permanently.
Follow-ups
- Asynchronous rehosting of inbound files, outside the webhook request.
- Display names through
users.info(with caching); today the participant has none. - Outbound reactions (
reactions.add), which need a mapping from emoji to Slack names.
Implementation
lib/converger/channels/adapters/slack.ex: the adapter (config schema, outbound, signature, challenge, health probe).slack/events.ex: Events API andblock_actionsparsing, conversation scopes.slack/api.ex: Web API calls and error classification.slack/blocks.ex: card attachments as Block Kit.slack/files.ex: the three-step upload and inbound rehosting.Converger.Channels.Adapter.inbound_challenge/2and its call inConvergerWeb.InboundController.create/2.- Tests:
test/converger/channels/adapters/slack_test.exs(parsing, signatures, Block Kit, errors) andtest/converger_web/controllers/slack_inbound_test.exs(signed requests end to end, replay rejection, a WebSocket-side reply delivered into the Slack thread, edit and delete, files).