fix(slack): preserve thread_ts on block_actions interactive payloads#15162
fix(slack): preserve thread_ts on block_actions interactive payloads#15162erichugy wants to merge 6 commits intomasterbotpress/botpress:masterfrom eh/fix/slack-block-actions-thread-contextbotpress/botpress:eh/fix/slack-block-actions-thread-contextCopy head branch name to clipboard
Conversation
When Slack POSTs a block_actions payload (a Block Kit button click) inside a thread, the parent thread is in payload.container.thread_ts (and mirrored in payload.message.thread_ts). Master ignores both — handleInteractiveRequest only passed slackChannelId to getBotpressConversationFromSlackThread, so the integration created a brand-new (channel) conversation per click instead of routing the synthesized message into the thread's existing conversation. Bots' replies then landed in the channel root instead of the thread, even with replyBehaviour.location: 'thread' configured. Cloud-log evidence (production bot 'bot-auditor-adk', UTC): 17:53:26.588 Slack POSTs block_actions payload (URL-encoded body starts with payload=%7B%22type%22%3A%22block_actions%22... container.thread_ts present) 17:53:30.034 Bot starts running in conv_01KQWMBZBQ9X8Y3YA6NPVBJ24N (NEW conversation). The original 'hello' thread used conv_01KQWM1N0CVNDP4R2KFYH2V1X5. 17:53:48.464 Bot reply lands in channel root, not the thread. Fix: extract thread_ts via a new getInteractiveThreadTs helper that reads container.thread_ts ?? message.thread_ts, then pass it to getBotpressConversationFromSlackThread. The helper covers block_actions today and the same shape applies to view_submission / message_action payloads. getBotpressConversationFromSlackThread now passes discriminateByTags: ['id', 'thread'] when upserting the threaded conversation, matching the existing pattern in message-received.ts so distinct threads in the same channel don't collide. Side-effect cleanup: type 'InteractiveBody' relaxed to mark response_url, actions, channel, and message as optional (Slack genuinely omits these for some interactive shapes), with explicit RuntimeError throws when a required field is actually missing. Reordered the 'unsupported interaction type' guard before _respondInteractive so view_submission / message_action / shortcut payloads short-circuit cleanly without sending an empty response_url POST. Test plan: vitest case 'routes block_actions messages to the Slack thread conversation when container.thread_ts is present' confirms the new path; 'keeps top-level block_actions messages on the existing channel conversation path' verifies no regression for non-threaded clicks; 'getInteractiveThreadTs prefers container.thread_ts over message.thread_ts' and 'extracts message.thread_ts for view_submission and message_action style payloads without a container thread' lock down the helper. Verified: with the fix all 13 tests in the slack package pass; without the fix (production code reverted) 3 of the new 4 tests fail with the exact error patterns operators saw in production. eslint clean. Pre-existing typecheck errors in slack-manifest-client.ts and message-received.ts are present on master too and are unrelated to this fix.
|
| Filename | Overview |
|---|---|
| integrations/slack/src/webhook-events/handlers/interactive-request.ts | Core handler refactored to extract thread_ts, use getReplyDispatch, add defensive channel/message-ts helpers, and discriminate messages by userId — all correct and well-tested. |
| integrations/slack/src/misc/utils.ts | Added discriminateByTags to getBotpressConversationFromSlackThread and extracted getReplyDispatch shared helper; safeParseBody + helpers are correct and tested. |
| integrations/slack/src/webhook-events/handlers/message-received.ts | Migrated to shared getReplyDispatch helper with no functional change to existing thread/channel routing logic. |
| integrations/slack/src/webhook-events/handlers/interactive-request.test.ts | New test file with 13 cases covering thread routing, top-level fallback, multi-user dedup, replyBehaviour variations, error paths, and body-parsing edge cases. |
| integrations/slack/integration.definition.ts | Patch version bump from 5.0.2 to 5.0.3 — correct for a bug-fix release. |
Reviews (6): Last reviewed commit: "fix(slack): key block_actions messages b..." | Re-trigger Greptile
Addresses Greptile review feedback on #15162. 1. _parseInteractiveBody used `String.replace('payload=', '')` which strips the first occurrence of that substring anywhere in the body. Slack's URL-encoded callbacks always carry the prefix, but the same handler is reachable via the raw-JSON `/interactive` path; a JSON value containing the literal text `payload=` would have been silently corrupted there. Replaced with anchored-prefix slicing via a small _stripPayloadPrefixIfPresent helper whose name encodes the invariant. 2. Added six negative-path tests: short-circuit on non-block_actions interaction types, RuntimeError throws when actions / response_url / channel id / message ts are absent, and a regression test that a raw JSON body containing 'redirect=payload=baz' inside an action value parses correctly without stripping the embedded payload= substring. All 19 tests in the slack package now pass; pnpm --filter @botpresshub/slack run check:lint clean.
|
Addressed both pieces of @greptile-apps feedback in 1. P2 — const PAYLOAD_PREFIX = 'payload='
const _stripPayloadPrefixIfPresent = (decoded: string): string =>
decoded.startsWith(PAYLOAD_PREFIX) ? decoded.slice(PAYLOAD_PREFIX.length) : decodedThe helper's name documents the invariant (only strips when actually a prefix), so a future contributor can't quietly simplify back to 2. Error-path test coverage gap: added six negative-path cases to
Also added a Verification
|
|
Both items addressed cleanly — this is in better shape than my original suggestion. P2 — Error-path tests: The six new cases are well-scoped. A few notes on what makes them particularly good:
19/19 passing, lint clean. LGTM — no further concerns. Tip: You can customize Greptile's behavior for this repo with |
…l path Self-audit: the previous `actions: []` case already threw on master via `!body.actions.length` — testing it here didn't add unique coverage. Switched to `actions: undefined`, which only became reachable after this PR relaxed the type to `actions?: [...]` and added optional chaining to the guard. Same error message asserted; now the test actually exercises a behavior change.
…o 5.0.3 Two follow-ups on top of the initial block_actions fix: 1. The interactive handler ignored ctx.configuration.replyBehaviour, so a top-level (non-threaded) click would always land in the channel conversation even when the integration was configured to reply in a thread or in both channel and thread. message-received.ts has the canonical dispatch logic for that — replicate it: compute isClickInChannel from getInteractiveThreadTs(body), read replyLocation from configuration, then dispatch to the channel conversation, the thread conversation, or both. Truth table now matches message-received exactly: in-thread click + replyLocation=any => thread only top-level click + replyLocation=channel => channel only top-level click + replyLocation=thread => fork into thread (threadTs = message_ts) top-level click + replyLocation=channelAndThread => both, distinguished by forkedToThread tag Tag set extended with forkedToThread to match the message-received tag shape; discriminateByTags follows the same pattern (['ts','channelId'] for channel, ['ts','channelId','forkedToThread'] for thread / forked). 2. CI: bump integration version 5.0.2 -> 5.0.3 so check-integration-versions accepts the change. Patch bump per semver — pure bug fix, no API additions. Tests: existing 'thread-click' and 'top-level click' cases keep their original assertions plus forkedToThread; three new cases lock the previously-broken paths — top-level + replyLocation=thread, top-level + replyLocation=channelAndThread (asserts both upserts in order), thread-click + replyLocation=channel (proves the in-thread context is sticky regardless of configuration). 22/22 in the slack package; pnpm --filter @botpresshub/slack run check:lint clean.
|
Addressed both follow-ups in 1. CI:
|
| Click location | replyBehaviour.location |
Channel dispatch | Thread dispatch |
|---|---|---|---|
| In thread | any | — | ✓ (the natural thread is sticky regardless of config) |
| Top-level | 'channel' (default) |
✓ | — |
| Top-level | 'thread' |
— | ✓ (forked from message_ts, forkedToThread: 'true') |
| Top-level | 'channelAndThread' |
✓ | ✓ (both upserts; forkedToThread discriminates) |
Tag shape extended with forkedToThread to match message-received.ts. discriminateByTags is ['ts', 'channelId'] for the channel dispatch and ['ts', 'channelId', 'forkedToThread'] for the thread / forked dispatch — same pattern as the message-received handler.
Tests
3 new cases locking the previously-broken paths:
forks a top-level click into a thread when replyBehaviour.location is "thread"dispatches a top-level click to BOTH the channel and the thread when replyBehaviour.location is "channelAndThread"— explicitly asserts the order and the per-dispatchforkedToThreadvaluekeeps thread-click responses in the thread when replyBehaviour.location is "channel"— proves in-thread context is sticky and not overridable by the channel-only config
Existing tests kept their original conversation-routing assertions plus the new forkedToThread: 'false' tag.
Verification: pnpm --filter @botpresshub/slack run test → 22/22 pass (was 19; 3 new). check:lint clean.
…ractive handlers Per review: the previous commit replicated message-received.ts's dispatch logic inline in interactive-request.ts. That's duplication — both handlers now call a single getReplyDispatch helper in misc/utils.ts that owns the (slackThreadTs, slackMessageTs, replyLocation) -> (isSentInChannel, shouldRespondInChannel, shouldRespondInThread, threadTsForReply) computation. Same idea for body parsing. _parseInteractiveBody used to roll its own anchored prefix-strip (PAYLOAD_PREFIX + _stripPayloadPrefixIfPresent) — but utils.ts already had safeParseBody doing the same thing. Switched the interactive handler to delegate to safeParseBody so there's exactly one place to keep correct. No behavior change. interactive-request 13/13 tests still green; signature-validator 4/4; replace-mentions 5/5; total 22/22. message-received has no tests in this package, but its refactored block is mechanically equivalent: the helper produces the exact same booleans + threadTs the inline code computed.
|
Per Eric's note ("mimic, not reinvent"): pulled the dispatch decision into a single Also stopped reinventing the parse path:
|
… don't collapse
Greptile P1: discriminateByTags ['ts','channelId'] used the button-message timestamp as the identity key. Unlike a normal Slack message — where each ts maps to one user message — a block_actions ts is the *button* message's ts, which multiple users can click. The runtime would find the existing record on user-B's click and silently drop it.
Fix: discriminator is now ['ts','channelId','userId','forkedToThread'] for every interactive dispatch. userId makes each user's click its own Botpress message; forkedToThread keeps the channelAndThread dual-dispatch path distinct (channel branch and thread branch differ on that tag). Always-include is also more robust against ordering — earlier code branched on (forkedToThread === 'false' && isSentInChannel) which would have returned the existing thread-message record if the thread branch ran first under channelAndThread.
Test added: 'discriminates inbound messages by userId so distinct users clicking the same button do not collapse onto one Botpress message' — locks both the userId tag and the discriminator array shape, which is the actual contract the runtime keys off.
Also: the previous CI run-checks failure ('Delete \`\u23ce\u00b7\u00b7\u00b7\`') was a prettier complaint about a multi-line shouldRespondInThread expression in misc/utils.ts. pnpm fix:format collapsed it to one line (under the 120-char printWidth).
23/23 slack tests pass; oxlint and eslint both clean.
Linear: ENG-3882
Summary
When Slack POSTs a
block_actionsinteractive callback (button click) inside a thread, the parent thread'sthread_tsis inpayload.container.thread_tsand mirrored inpayload.message.thread_ts. Master ignores both —handleInteractiveRequestonly passedslackChannelIdtogetBotpressConversationFromSlackThread, so the integration created a brand-new(channel)conversation per click instead of routing the synthesized message into the thread's existing conversation. Bots' replies then landed in the channel root instead of the thread, even withreplyBehaviour.location: 'thread'configured.Cloud-log evidence (production bot
bot-auditor-adk, UTC)What changed
integrations/slack/src/webhook-events/handlers/interactive-request.tsgetInteractiveThreadTs(body)helper that readsbody.container?.thread_ts ?? body.message?.thread_ts. Same shape applies toview_submission/message_action/shortcutpayloads — the helper handles all of them.handleInteractiveRequestnow passesslackThreadId: getInteractiveThreadTs(body)togetBotpressConversationFromSlackThreadso a click inside a thread routes to the thread's existing conversation. A click on a top-level message keeps the existing channel-conversation path (no regression).body.type !== 'block_actions'guard before_respondInteractive, so unsupported interaction types short-circuit cleanly instead of POSTing a (possibly missing)response_urlfirst.InteractiveBodyto markresponse_url,actions,channel, andmessageas optional — Slack genuinely omits some of these on certain payload shapes — with explicitRuntimeErrorthrows when a required field is actually missing for the path being taken._getInteractiveSlackChannelIdand_getInteractiveMessageTsthat fall back frombody.channel/body.messagetobody.container.integrations/slack/src/misc/utils.tsgetBotpressConversationFromSlackThreadnow passesdiscriminateByTags: ['id', 'thread']togetOrCreateConversationwhen upserting the threaded conversation. Without this, two distinct threads in the same channel collapse onto whichever conversation Botpress first created in that channel. Matches the existing pattern inmessage-received.ts:97for inbound threaded messages.Tests
New file:
integrations/slack/src/webhook-events/handlers/interactive-request.test.ts(vitest).routes block_actions messages to the Slack thread conversation when container.thread_ts is presentchannel: 'thread', tags: { id, thread }, discriminateByTags: ['id', 'thread']and the synthesized message lands on the thread's conversation.keeps top-level block_actions messages on the existing channel conversation pathchannel: 'channel'with onlytags: { id }.getInteractiveThreadTs prefers container.thread_ts over message.thread_ts for interactive payloadsgetInteractiveThreadTs extracts message.thread_ts for view_submission and message_action style payloads without a container threadVerification (local, Node 22.17.0, pnpm 10.12.4)
pnpm --filter @botpresshub/slack run test:TypeError: getInteractiveThreadTs is not a functionand agetOrCreateConversationargument-mismatch on the headline assertion. The 4th new test (top-level fallback) passes on master because that path is already correct. Confirms TDD in both directions — exactly the failure pattern operators were seeing.pnpm --filter @botpresshub/slack run check:lint: ✅ clean.pnpm --filter @botpresshub/slack run check:type: existing master errors inslack-manifest-client.ts(appCredentialsvscredentialsrename drift) andmessage-received.ts(missingchannelOrigintag) reproduce on a clean checkout ofmasterafter stashing this PR's changes — they are pre-existing and unrelated to this fix. No new typecheck errors are introduced by this PR.pnpm --filter @botpresshub/slack run check:format: only complaint is.claude/settings.local.json, which is an untracked local IDE config file (not in this PR, not in git).Why this fix and not a bigger refactor
The smallest correct change. The threaded-conversation path already exists for inbound
messageevents withthread_ts— this PR just makes interactive callbacks reuse it. No new abstractions, no churn in surrounding code.Consumer-side workaround already in place
In parallel with this fix, the bot-auditor consumer (botpress/enterprise-solutions#99) ships a prompt change that instructs its operator bot to never emit
choicepayloads, presenting options as plain numbered text instead. That keeps prod operators unblocked today. Once this slack-integration fix lands, future ADK consumers can re-enable interactive Block Kit options with confidence.Out of scope
slack-manifest-client.tsandmessage-received.ts— separate PRs.invalid_refresh_tokenregression onadk deploy(deploy pipeline recreates the integration instance and exchanges a now-invalid refresh token); tracked separately by infra/platform engineering.