Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

fix(slack): preserve thread_ts on block_actions interactive payloads#15162

Open
erichugy 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
Open

fix(slack): preserve thread_ts on block_actions interactive payloads#15162
erichugy 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

@erichugy
Copy link
Copy Markdown
Contributor

@erichugy erichugy commented May 5, 2026

Linear: ENG-3882

Summary

When Slack POSTs a block_actions interactive callback (button click) inside a thread, the parent thread's thread_ts 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%2C%22user%22%3A%7B%22id%22%3A%22U0A6E7PA7FH%22...
              container.message_ts=1778003274.930659, channel_id=C0B1H6AJFN...,
              container.thread_ts present (the click was inside an existing thread).
17:53:27.589  Slack also delivers a message_changed event for the bot's choice
              message (Slack edits the original buttons message to record the
              user's selection).
17:53:30.034  Bot starts running (startTypingIndicator) — but in conversation
              conv_01KQWMBZBQ9X8Y3YA6NPVBJ24N, a NEW conversation. The original
              "hello" thread used conv_01KQWM1N0CVNDP4R2KFYH2V1X5.
17:53:48.464  Bot sends its response — lands in the channel root, not in the thread.

What changed

integrations/slack/src/webhook-events/handlers/interactive-request.ts

  • Added an exported getInteractiveThreadTs(body) helper that reads body.container?.thread_ts ?? body.message?.thread_ts. Same shape applies to view_submission / message_action / shortcut payloads — the helper handles all of them.
  • handleInteractiveRequest now passes slackThreadId: getInteractiveThreadTs(body) to getBotpressConversationFromSlackThread so 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).
  • Reordered the body.type !== 'block_actions' guard before _respondInteractive, so unsupported interaction types short-circuit cleanly instead of POSTing a (possibly missing) response_url first.
  • Relaxed InteractiveBody to mark response_url, actions, channel, and message as optional — Slack genuinely omits some of these on certain payload shapes — with explicit RuntimeError throws when a required field is actually missing for the path being taken.
  • Added defensive helpers _getInteractiveSlackChannelId and _getInteractiveMessageTs that fall back from body.channel / body.message to body.container.

integrations/slack/src/misc/utils.ts

  • getBotpressConversationFromSlackThread now passes discriminateByTags: ['id', 'thread'] to getOrCreateConversation when 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 in message-received.ts:97 for inbound threaded messages.

Tests

New file: integrations/slack/src/webhook-events/handlers/interactive-request.test.ts (vitest).

Test What it locks down
routes block_actions messages to the Slack thread conversation when container.thread_ts is present The headline regression: a button click inside a thread upserts channel: '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 path No regression for clicks on top-level (non-threaded) messages — they still resolve to channel: 'channel' with only tags: { id }.
getInteractiveThreadTs prefers container.thread_ts over message.thread_ts for interactive payloads Locks the precedence (Slack sometimes populates both with slightly different values; container wins).
getInteractiveThreadTs extracts message.thread_ts for view_submission and message_action style payloads without a container thread Extends coverage beyond block_actions to the other interactive shapes Slack uses.

Verification (local, Node 22.17.0, pnpm 10.12.4)

pnpm --filter @botpresshub/slack run test:

  • With fix: 13/13 tests pass (3 existing files + my 4 new cases).
  • Without fix (production-code changes stashed, test file kept): 3 of the 4 new tests FAIL with TypeError: getInteractiveThreadTs is not a function and a getOrCreateConversation argument-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 in slack-manifest-client.ts (appCredentials vs credentials rename drift) and message-received.ts (missing channelOrigin tag) reproduce on a clean checkout of master after 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 message events with thread_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 choice payloads, 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

  • Pre-existing master typecheck errors in slack-manifest-client.ts and message-received.ts — separate PRs.
  • The orthogonal invalid_refresh_token regression on adk deploy (deploy pipeline recreates the integration instance and exchanges a now-invalid refresh token); tracked separately by infra/platform engineering.

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.
@erichugy erichugy requested a review from a team as a code owner May 5, 2026 18:30
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR fixes a routing bug where Slack block_actions interactive callbacks fired inside a thread were creating brand-new channel conversations instead of routing to the existing thread conversation, causing bot replies to land in the channel root. It extracts a shared getReplyDispatch utility and plumbs thread_ts from the interactive payload through to conversation lookup.

  • Core fix: handleInteractiveRequest now calls getInteractiveThreadTs(body) to extract container.thread_ts ?? message.thread_ts and passes it to getBotpressConversationFromSlackThread, which gained discriminateByTags: ['id', 'thread'] to prevent distinct threads collapsing onto one conversation.
  • getReplyDispatch refactor: Logic for shouldRespondInChannel / shouldRespondInThread / threadTsForReply is lifted into a shared helper used by both message-received.ts and interactive-request.ts, eliminating duplication.
  • Defensive helpers & multi-user dedup: _getInteractiveSlackChannelId and _getInteractiveMessageTs fall back to container.* fields; getOrCreateMessage now discriminates by ['ts', 'channelId', 'userId', 'forkedToThread'] so multiple users clicking the same button each get their own Botpress message.

Confidence Score: 5/5

Safe to merge — the fix is narrowly scoped to interactive callback routing and is thoroughly covered by new and existing tests.

The change correctly threads thread_ts through from block_actions payloads to conversation lookup, adding discriminateByTags to prevent thread-conversation collapse. The refactored getReplyDispatch helper is a pure extraction of logic that already existed in message-received.ts with no behaviour change. Multi-user deduplication via userId in discriminateByTags is a genuine improvement. All paths are covered by 13 tests, including regression cases that fail without the fix.

No files require special attention.

Important Files Changed

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

Comment thread integrations/slack/src/webhook-events/handlers/interactive-request.ts Outdated
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.
@erichugy
Copy link
Copy Markdown
Contributor Author

erichugy commented May 5, 2026

Addressed both pieces of @greptile-apps feedback in 80db18c:

1. P2 — _parseInteractiveBody use of String.replace: replaced with an anchored helper:

const PAYLOAD_PREFIX = 'payload='

const _stripPayloadPrefixIfPresent = (decoded: string): string =>
  decoded.startsWith(PAYLOAD_PREFIX) ? decoded.slice(PAYLOAD_PREFIX.length) : decoded

The helper's name documents the invariant (only strips when actually a prefix), so a future contributor can't quietly simplify back to .replace('payload=', '') without realizing the trade-off.

2. Error-path test coverage gap: added six negative-path cases to interactive-request.test.ts:

Test What it locks
short-circuits non-block_actions interaction types without responding or persisting a message The reordered guard — view_submission / message_action / shortcut must not call axios.post(response_url) or persist a message
throws when actions are missing from a block_actions payload _respondInteractive's !body.actions?.length guard
throws when response_url is missing from a block_actions payload _respondInteractive's !body.response_url guard
throws when no slack channel id can be resolved from the payload _getInteractiveSlackChannelId falls back from body.channel?.id to body.container?.channel_id; throws if neither
throws when no slack message timestamp can be resolved from the payload _getInteractiveMessageTs falls back from body.message?.ts to body.container?.message_ts; throws if neither
parses raw JSON bodies received via /interactive without stripping payload= substrings inside values The fix above — uses redirect=payload=baz as an action value to prove the embedded substring survives parsing

Also added a beforeEach(() => vi.mocked(axios.post).mockClear()) so the cross-test axios mock state is clean (the new short-circuit test relies on not.toHaveBeenCalled).

Verification

  • pnpm --filter @botpresshub/slack run test: 19/19 pass (was 13; 6 new).
  • pnpm --filter @botpresshub/slack run check:lint: clean.
  • The "raw JSON parsing" regression test would fail against String.replace, confirming the parse fix has real teeth.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Both items addressed cleanly — this is in better shape than my original suggestion.

P2 — _stripPayloadPrefixIfPresent: Extracting a named helper with a PAYLOAD_PREFIX constant is strictly better than the inline one-liner I suggested. The name encodes the invariant, and the constant eliminates the magic string. The regression test (redirect=payload=baz) is exactly the right way to lock this down — it would catch any future regression to replace since replace would silently corrupt the value to redirect=baz.

Error-path tests: The six new cases are well-scoped. A few notes on what makes them particularly good:

  • The beforeEach(() => vi.mocked(axios.post).mockClear()) is the right fix — without it the short-circuit test's not.toHaveBeenCalled assertion could have been a false pass if a prior test had already cleared state coincidentally.
  • The view_submission short-circuit test checks all three side-effect points (axios.post, getOrCreateConversation, getOrCreateMessage), not just one — that's thorough.
  • Scoping the Pick<InteractiveBody, ...> types on the private helpers (_getInteractiveSlackChannelId, _getInteractiveMessageTs) is a nice touch that keeps the test surface honest.

19/19 passing, lint clean. LGTM — no further concerns.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

erichugy added 2 commits May 5, 2026 15:27
…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.
@erichugy
Copy link
Copy Markdown
Contributor Author

erichugy commented May 5, 2026

Addressed both follow-ups in 229c994:

1. CI: check-integration-versions was failing

Bumped integration.definition.ts from 5.0.25.0.3 (patch — pure bug fix).

2. replyBehaviour.location was being ignored on interactive callbacks

Caught by Eric's review. The previous fix only checked for thread_ts and routed accordingly, which meant a top-level click + replyLocation: 'thread' (or 'channelAndThread') would still land in the channel conversation only — out of sync with how regular message-received events dispatch.

Replicated message-received.ts's exact dispatch logic. Truth table now matches:

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-dispatch forkedToThread value
  • keeps 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.
@erichugy
Copy link
Copy Markdown
Contributor Author

erichugy commented May 5, 2026

Per Eric's note ("mimic, not reinvent"): pulled the dispatch decision into a single getReplyDispatch helper in misc/utils.ts that takes (slackThreadTs, slackMessageTs, replyLocation) and returns { isSentInChannel, shouldRespondInChannel, shouldRespondInThread, threadTsForReply }. Both message-received.ts and interactive-request.ts now call it — single source of truth, zero behavioral drift between the two handlers.

Also stopped reinventing the parse path: _parseInteractiveBody now delegates to the existing safeParseBody in utils.ts, which already handled anchored payload= slicing correctly. Dropped the local PAYLOAD_PREFIX + _stripPayloadPrefixIfPresent helper.

2e4c644 — net -29 / +47 lines, no behavior change. 22/22 tests still green, lint clean.

Comment thread integrations/slack/src/webhook-events/handlers/interactive-request.ts Outdated
… 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-code
Copy link
Copy Markdown

linear-code Bot commented May 5, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Morty Proxy This is a proxified and sanitized view of the page, visit original site.