Skip to content

Commit 792b8d4

Browse files
icecrasher321claude
andcommitted
feat(slack): open an opt-in loading modal while the trigger_id is fresh
Slack's trigger_id expires 3 seconds after the interaction, and webhook workflows execute after the ack — so views.open from a workflow reliably loses the race. When the slack_oauth trigger opts in, the ingest path now opens a minimal Block Kit loading modal via views.open (2s timeout, any failure logs and continues) for interactive payloads that carry a fresh trigger_id and are not already inside a modal. The created view id rides the payload into the trigger output as loading_view_id, which never expires — the workflow updates it with Slack Update View. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 53518d0 commit 792b8d4

4 files changed

Lines changed: 382 additions & 2 deletions

File tree

apps/sim/background/webhook-execution.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,34 @@ describe('executeWebhookJob fault vs error handling', () => {
423423
)
424424
})
425425

426+
it('forwards the payload syncInteraction into formatInput', async () => {
427+
const formatInput = vi.fn().mockResolvedValue({ input: { event: {} } })
428+
mockGetProviderHandler.mockReturnValue({ formatInput })
429+
mockExecuteWorkflowCore.mockResolvedValue({
430+
success: true,
431+
status: 'completed',
432+
output: {},
433+
logs: [],
434+
executionState: {
435+
blockStates: {},
436+
executedBlocks: [],
437+
blockLogs: [],
438+
decisions: {},
439+
completedLoops: [],
440+
activeExecutionPath: [],
441+
},
442+
})
443+
444+
await executeWebhookJob({
445+
...payload,
446+
syncInteraction: { loadingViewId: 'V-loading' },
447+
})
448+
449+
expect(formatInput).toHaveBeenCalledWith(
450+
expect.objectContaining({ syncInteraction: { loadingViewId: 'V-loading' } })
451+
)
452+
})
453+
426454
it('loads rows and keeps account checks without warm context', async () => {
427455
mockExecuteWorkflowCore.mockResolvedValue({
428456
success: true,

apps/sim/background/webhook-execution.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
type WebhookEnvResolutionOptions,
4646
} from '@/lib/webhooks/env-resolver'
4747
import { getProviderHandler } from '@/lib/webhooks/providers'
48+
import type { SyncInteractionContext } from '@/lib/webhooks/providers/types'
4849
import {
4950
executeWorkflowCore,
5051
wasExecutionFinalizedByCore,
@@ -289,6 +290,12 @@ export type WebhookExecutionPayload = {
289290
triggerTimestampMs?: number
290291
/** Trusted attempt budget resolved before the webhook enters the queue. */
291292
executionTimeoutMs?: number
293+
/**
294+
* Interaction context created synchronously at ingest (e.g. a Slack loading
295+
* modal's view id). Identifiers only — never token material; this payload is
296+
* persisted by the durable queue branch.
297+
*/
298+
syncInteraction?: SyncInteractionContext
292299
}
293300

294301
/**
@@ -681,6 +688,7 @@ async function executeWebhookJobInternal(
681688
...(formatInputCredentialOwnerUserId
682689
? { credentialOwnerUserId: formatInputCredentialOwnerUserId }
683690
: {}),
691+
...(payload.syncInteraction ? { syncInteraction: payload.syncInteraction } : {}),
684692
})
685693
input = result.input as Record<string, unknown> | null
686694
skipMessage = result.skip?.message

apps/sim/lib/webhooks/providers/slack.test.ts

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import { describe, expect, it } from 'vitest'
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const { mockGetSlackBotCredential } = vi.hoisted(() => ({
4+
mockGetSlackBotCredential: vi.fn(),
5+
}))
6+
7+
vi.mock('@/lib/oauth/credential-service', () => ({
8+
getSlackBotCredential: mockGetSlackBotCredential,
9+
refreshAccessTokenIfNeeded: vi.fn(),
10+
resolveOAuthAccountId: vi.fn(),
11+
}))
12+
213
import {
314
handleSlackChallenge,
415
resolveSlackEventKey,
@@ -595,3 +606,185 @@ describe('slackHandler.shouldSkipEvent (custom-app path)', () => {
595606
expect(slackHandler.shouldSkipEvent!(skipCtx({}, message))).toBe(false)
596607
})
597608
})
609+
610+
describe('slackHandler prepareSyncDispatch', () => {
611+
const fetchMock = vi.fn()
612+
613+
const syncCtx = (body: unknown, providerConfig: Record<string, unknown>) => ({
614+
webhook: {},
615+
workflow: { id: 'wf', userId: 'u' },
616+
body,
617+
requestId: 'slack-test',
618+
providerConfig,
619+
})
620+
621+
const blockActions = (overrides: Record<string, unknown> = {}) => ({
622+
type: 'block_actions',
623+
trigger_id: 'trigger-1',
624+
user: { id: 'U1' },
625+
actions: [{ action_id: 'a1', type: 'button' }],
626+
...overrides,
627+
})
628+
629+
const enabledConfig = { openLoadingModal: true, botToken: 'xoxb-test' }
630+
631+
const slackOk = (viewId = 'V123') => ({
632+
json: async () => ({ ok: true, view: { id: viewId } }),
633+
status: 200,
634+
})
635+
636+
beforeEach(() => {
637+
vi.clearAllMocks()
638+
vi.stubGlobal('fetch', fetchMock)
639+
})
640+
641+
afterEach(() => {
642+
vi.unstubAllGlobals()
643+
})
644+
645+
it('opens the loading modal for a message button click and returns the view id', async () => {
646+
fetchMock.mockResolvedValue(slackOk('V123'))
647+
648+
const result = await slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig))
649+
650+
expect(result).toEqual({ syncInteraction: { loadingViewId: 'V123' } })
651+
expect(fetchMock).toHaveBeenCalledTimes(1)
652+
const [url, init] = fetchMock.mock.calls[0]
653+
expect(url).toBe('https://slack.com/api/views.open')
654+
expect(init.headers.Authorization).toBe('Bearer xoxb-test')
655+
const requestBody = JSON.parse(init.body)
656+
expect(requestBody.trigger_id).toBe('trigger-1')
657+
expect(requestBody.view.callback_id).toBe('sim_loading_modal')
658+
expect(requestBody.view.title.text).toBe('Working on it')
659+
expect(requestBody.view.blocks[0].text.text).toBe('This will just take a moment…')
660+
})
661+
662+
it('opens for message_action and shortcut payloads', async () => {
663+
fetchMock.mockResolvedValue(slackOk())
664+
665+
await expect(
666+
slackHandler.prepareSyncDispatch!(
667+
syncCtx({ type: 'message_action', trigger_id: 't2' }, enabledConfig)
668+
)
669+
).resolves.toEqual({ syncInteraction: { loadingViewId: 'V123' } })
670+
await expect(
671+
slackHandler.prepareSyncDispatch!(
672+
syncCtx({ type: 'shortcut', trigger_id: 't3' }, enabledConfig)
673+
)
674+
).resolves.toEqual({ syncInteraction: { loadingViewId: 'V123' } })
675+
})
676+
677+
it('applies the configured title and text, truncating the title to 24 characters', async () => {
678+
fetchMock.mockResolvedValue(slackOk())
679+
680+
await slackHandler.prepareSyncDispatch!(
681+
syncCtx(blockActions(), {
682+
...enabledConfig,
683+
loadingModalTitle: 'A very long modal title that overflows',
684+
loadingModalText: 'Custom body',
685+
})
686+
)
687+
688+
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body)
689+
expect(requestBody.view.title.text.length).toBeLessThanOrEqual(24)
690+
expect(requestBody.view.blocks[0].text.text).toBe('Custom body')
691+
})
692+
693+
it('never fires for ineligible payloads or when the option is off', async () => {
694+
const cases: Array<[unknown, Record<string, unknown>]> = [
695+
[blockActions(), { botToken: 'xoxb-test' }],
696+
[blockActions({ view: { id: 'V-open' } }), enabledConfig],
697+
[{ type: 'view_submission', trigger_id: 't' }, enabledConfig],
698+
[{ type: 'view_closed', trigger_id: 't' }, enabledConfig],
699+
[{ event: { type: 'message' }, type: 'event_callback' }, enabledConfig],
700+
[{ command: '/run', trigger_id: 't' }, enabledConfig],
701+
[blockActions({ trigger_id: undefined }), enabledConfig],
702+
[blockActions({ trigger_id: '' }), enabledConfig],
703+
['not-an-object', enabledConfig],
704+
]
705+
706+
for (const [body, config] of cases) {
707+
await expect(slackHandler.prepareSyncDispatch!(syncCtx(body, config))).resolves.toBeNull()
708+
}
709+
expect(fetchMock).not.toHaveBeenCalled()
710+
})
711+
712+
it('returns null without calling Slack when no bot token resolves', async () => {
713+
await expect(
714+
slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), { openLoadingModal: true }))
715+
).resolves.toBeNull()
716+
expect(fetchMock).not.toHaveBeenCalled()
717+
})
718+
719+
it('resolves the token from a custom-bot credential', async () => {
720+
mockGetSlackBotCredential.mockResolvedValue({ botToken: 'xoxb-cred' })
721+
fetchMock.mockResolvedValue(slackOk('V-cred'))
722+
723+
const result = await slackHandler.prepareSyncDispatch!(
724+
syncCtx(blockActions(), { openLoadingModal: true, credentialId: 'credential-1' })
725+
)
726+
727+
expect(result).toEqual({ syncInteraction: { loadingViewId: 'V-cred' } })
728+
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer xoxb-cred')
729+
})
730+
731+
it('tolerates Slack rejections, network failures, and timeouts without throwing', async () => {
732+
fetchMock.mockResolvedValueOnce({
733+
json: async () => ({ ok: false, error: 'expired_trigger_id' }),
734+
status: 200,
735+
})
736+
await expect(
737+
slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig))
738+
).resolves.toBeNull()
739+
740+
fetchMock.mockResolvedValueOnce({
741+
json: async () => ({ ok: false, error: 'exchanged_trigger_id' }),
742+
status: 200,
743+
})
744+
await expect(
745+
slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig))
746+
).resolves.toBeNull()
747+
748+
fetchMock.mockRejectedValueOnce(new Error('network down'))
749+
await expect(
750+
slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig))
751+
).resolves.toBeNull()
752+
753+
fetchMock.mockRejectedValueOnce(
754+
Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' })
755+
)
756+
await expect(
757+
slackHandler.prepareSyncDispatch!(syncCtx(blockActions(), enabledConfig))
758+
).resolves.toBeNull()
759+
})
760+
})
761+
762+
describe('slackHandler formatInput - loading_view_id', () => {
763+
const interactiveBody = {
764+
type: 'block_actions',
765+
trigger_id: 'trigger-1',
766+
user: { id: 'U1', username: 'alice' },
767+
actions: [{ action_id: 'a1', type: 'button', value: 'go' }],
768+
}
769+
770+
it('surfaces the ingest-created loading view id on interactive payloads', async () => {
771+
const { input } = await slackHandler.formatInput!({
772+
...ctx(interactiveBody),
773+
syncInteraction: { loadingViewId: 'V123' },
774+
})
775+
776+
expect(eventOf(input).loading_view_id).toBe('V123')
777+
})
778+
779+
it('defaults to an empty loading_view_id without a sync interaction', async () => {
780+
const { input } = await slackHandler.formatInput!(ctx(interactiveBody))
781+
expect(eventOf(input).loading_view_id).toBe('')
782+
})
783+
784+
it('keeps the empty default on Events API payloads', async () => {
785+
const { input } = await slackHandler.formatInput!(
786+
ctx({ team_id: 'T1', event: { type: 'app_mention', channel: 'C1', ts: '1.2' } })
787+
)
788+
expect(eventOf(input).loading_view_id).toBe('')
789+
})
790+
})

0 commit comments

Comments
 (0)