From 33d0b4f411e43f3e91ba4e73a7b573ad5992ddf5 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Thu, 16 Jul 2026 16:14:09 +0530 Subject: [PATCH 1/2] Add follow-up question support to Slack request action buttons Extends the follow-up question capability shipped for Teams (#2398) to the Slack Request Action block. Buttons with a follow-up question become link buttons opening the collect_input.html page in both interaction modes; the interactions endpoint skips resuming for those clicks so the browser form owns the resume, and the answer surfaces in the step output as parameters.answer. The shared buildWrapperUrl helper moves from the Teams block to @openops/common so both blocks use one implementation. Button texts are stored emoji-normalized in followUpActions and the actionClicked resume param, fixing a latent emoji-button resume mismatch in non-interaction mode. Part of OPS-4626. Co-Authored-By: Claude Fable 5 --- .../src/lib/actions/request-action-message.ts | 2 +- .../src/lib/actions/request-action-message.ts | 42 +++++- packages/blocks/slack/src/lib/common/props.ts | 25 ++++ .../src/lib/common/wait-for-interaction.ts | 25 ++++ .../slack/test/request-action-message.test.ts | 99 ++++++++++++++ .../blocks/slack/test/wait-for-action.test.ts | 7 + .../slack/test/wait-for-interaction.test.ts | 123 +++++++++++++++++- packages/openops/src/index.ts | 1 + .../src/lib}/build-wrapper-url.ts | 0 .../test/build-wrapper-url.test.ts | 2 +- .../api/src/app/slack/is-follow-up-action.ts | 16 +++ .../src/app/slack/slack-interaction-module.ts | 15 +++ .../slack-interaction.test.ts | 85 ++++++++++++ .../unit/slack/is-follow-up-action.test.ts | 54 ++++++++ 14 files changed, 481 insertions(+), 15 deletions(-) rename packages/{blocks/microsoft-teams/src/lib/common => openops/src/lib}/build-wrapper-url.ts (100%) rename packages/{blocks/microsoft-teams => openops}/test/build-wrapper-url.test.ts (97%) create mode 100644 packages/server/api/src/app/slack/is-follow-up-action.ts create mode 100644 packages/server/api/test/unit/slack/is-follow-up-action.test.ts diff --git a/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts b/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts index f4abc03909..6990e5d71a 100644 --- a/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts +++ b/packages/blocks/microsoft-teams/src/lib/actions/request-action-message.ts @@ -4,9 +4,9 @@ import { StoreScope, Validators, } from '@openops/blocks-framework'; +import { buildWrapperUrl } from '@openops/common'; import { networkUtls, SharedSystemProp, system } from '@openops/server-shared'; import { ExecutionType } from '@openops/shared'; -import { buildWrapperUrl } from '../common/build-wrapper-url'; import { chatExists } from '../common/chat-exists'; import { ChannelOption, ChatOption, ChatTypes } from '../common/chat-types'; import { chatsAndChannels } from '../common/chats-and-channels'; diff --git a/packages/blocks/slack/src/lib/actions/request-action-message.ts b/packages/blocks/slack/src/lib/actions/request-action-message.ts index 88e50d95a6..ddaa73746e 100644 --- a/packages/blocks/slack/src/lib/actions/request-action-message.ts +++ b/packages/blocks/slack/src/lib/actions/request-action-message.ts @@ -1,4 +1,5 @@ import { createAction, StoreScope } from '@openops/blocks-framework'; +import { buildWrapperUrl } from '@openops/common'; import { networkUtls, SharedSystemProp, system } from '@openops/server-shared'; import { assertNotNullOrUndefined, @@ -17,7 +18,7 @@ import { username, usersAndChannels, } from '../common/props'; -import { slackSendMessage } from '../common/utils'; +import { normalizeEmojiString, slackSendMessage } from '../common/utils'; import { onReceivedInteraction, waitForInteraction, @@ -111,31 +112,54 @@ const sendMessageAskingForAction = async ( const enableSlackInteractions = system.getBoolean(SharedSystemProp.SLACK_ENABLE_INTERACTIONS) ?? true; - if (!enableSlackInteractions) { + const buttonsNeedingUrl = actions.filter( + (action: SlackActionDefinition) => + !enableSlackInteractions || action.followUpQuestion, + ); + + if (buttonsNeedingUrl.length) { const apiUrl = await networkUtls.getPublicUrl(); const frontendUrl = system .getOrThrow(SharedSystemProp.FRONTEND_URL) .replace(/\/$/, ''); - actions.forEach((action: SlackActionDefinition) => { + buttonsNeedingUrl.forEach((action: SlackActionDefinition) => { const resumeUrl = context.generateResumeUrl( { queryParams: { executionCorrelationId: context.run.pauseId, actionClicked: JSON.stringify({ - value: action.buttonText, + value: normalizeEmojiString(action.buttonText), displayText: action.buttonText, }), }, }, apiUrl, ); - action.url = `${frontendUrl}/html/resume_execution.html?isTest=${ - context.run.isTest - }&redirectUrl=${encodeURIComponent(resumeUrl)}`; + action.url = buildWrapperUrl({ + frontendUrl, + isTest: context.run.isTest, + resumeUrl, + followUp: action.followUpQuestion + ? { + question: action.followUpQuestion, + answerFormat: action.answerFormat, + noAnswerOption: action.noAnswerOption, + title: headerText, + } + : undefined, + }); }); } + // Stored normalized because Slack interaction payloads return button text + // in emoji-shortcode form, which is what the interactions endpoint compares. + const followUpActions = actions + .filter((action: SlackActionDefinition) => action.followUpQuestion) + .map((action: SlackActionDefinition) => + normalizeEmojiString(action.buttonText), + ); + const blocks = createMessageBlocks(headerText, text, actions); return await slackSendMessage({ @@ -154,6 +178,7 @@ const sendMessageAskingForAction = async ( executionCorrelationId: context.run.pauseId, }, }), + followUpActions, }, }); }; @@ -183,6 +208,9 @@ interface SlackActionDefinition { buttonStyle: string; confirmationPrompt: boolean; confirmationPromptText: string; + followUpQuestion?: string; + answerFormat?: string; + noAnswerOption?: string; url?: string; } diff --git a/packages/blocks/slack/src/lib/common/props.ts b/packages/blocks/slack/src/lib/common/props.ts index 3bd14b09ad..d48a058f7a 100644 --- a/packages/blocks/slack/src/lib/common/props.ts +++ b/packages/blocks/slack/src/lib/common/props.ts @@ -158,6 +158,31 @@ export const actions = Property.Array({ description: '', required: false, }), + followUpQuestion: Property.ShortText({ + displayName: 'Follow-up question', + description: + 'Optionally ask a follow-up question in a popup window when this button is clicked.', + required: false, + }), + answerFormat: Property.StaticDropdown({ + displayName: 'Answer format', + description: 'Only used when a follow-up question is set.', + required: false, + defaultValue: 'text', + options: { + options: [ + { label: 'Text', value: 'text' }, + { label: 'Email', value: 'email' }, + { label: 'Number', value: 'number' }, + ], + }, + }), + noAnswerOption: Property.ShortText({ + displayName: 'No answer option', + description: + "Optional text for a secondary choice when the person can't answer.", + required: false, + }), }, }); diff --git a/packages/blocks/slack/src/lib/common/wait-for-interaction.ts b/packages/blocks/slack/src/lib/common/wait-for-interaction.ts index f7dbfc6bc2..6dfe68920c 100644 --- a/packages/blocks/slack/src/lib/common/wait-for-interaction.ts +++ b/packages/blocks/slack/src/lib/common/wait-for-interaction.ts @@ -20,8 +20,23 @@ export interface WaitForInteractionResult { message: MessageInfo; isExpired: boolean | undefined; userSelection: UserSelection | UserSelection[] | null; + parameters: Record; } +// Query params the resume mechanism uses for routing; everything else is +// user-provided data (e.g. from a form wrapper page) surfaced as `parameters`. +const EXCLUDED_RESUME_PARAMS = new Set([ + 'actionClicked', + 'actionType', + 'userName', + 'path', + 'executionCorrelationId', + 'isTest', + '__proto__', + 'constructor', + 'prototype', +]); + export async function waitForInteraction( messageObj: MessageInfo, timeoutInDays: number, @@ -50,6 +65,7 @@ export async function waitForInteraction( userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }; } @@ -72,6 +88,7 @@ export async function onReceivedInteraction( userSelection: null, isExpired: true, message: updatedMessage, + parameters: {}, }; } @@ -113,9 +130,16 @@ export async function onReceivedInteraction( userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }; } + const parameters = Object.fromEntries( + Object.entries(resumePayload as unknown as Record).filter( + ([key]) => !EXCLUDED_RESUME_PARAMS.has(key), + ), + ); + const updatedMessage = await actionReceived( context, messageObj, @@ -131,6 +155,7 @@ export async function onReceivedInteraction( message: updatedMessage, userSelection, isExpired: false, + parameters, }; } diff --git a/packages/blocks/slack/test/request-action-message.test.ts b/packages/blocks/slack/test/request-action-message.test.ts index 9d9639cbc5..5c26a21e21 100644 --- a/packages/blocks/slack/test/request-action-message.test.ts +++ b/packages/blocks/slack/test/request-action-message.test.ts @@ -218,6 +218,7 @@ describe('requestActionMessageAction', () => { domain: mockContextWithHeader.server.publicUrl, resumeUrl: undefined, interactionsDisabled: false, + followUpActions: [], }, }); @@ -342,6 +343,104 @@ describe('requestActionMessageAction', () => { ); }); + describe('follow-up question buttons', () => { + beforeEach(() => { + jest.clearAllMocks(); + waitForInteractionMock.mockImplementation(async (messageObj: any) => + Promise.resolve({ ...messageObj }), + ); + slackSendMessageMock.mockImplementation(async (message: any) => + Promise.resolve(message), + ); + }); + + function buildContextWithFollowUpAction(): any { + const mockContext = buildMockContext('Header Text', true); + mockContext.propsValue.actions.push({ + buttonText: "I'm not the owner", + buttonStyle: '', + followUpQuestion: "What is the correct owner's email?", + answerFormat: 'email', + noAnswerOption: "I don't know the owner", + }); + mockContext.run.isTest = false; + mockContext.generateResumeUrl.mockImplementation( + ({ queryParams }: any) => { + const query = new URLSearchParams(queryParams).toString(); + return `https://example.com/resume?${query}`; + }, + ); + return mockContext; + } + + test('should give only the follow-up button a collect_input url in interactions mode', async () => { + getBooleanMock.mockReturnValueOnce(true); + + const mockContext = buildContextWithFollowUpAction(); + const result = (await requestActionMessageAction.run(mockContext)) as any; + + const actionBlock = result.blocks.find((b: any) => b.type === 'actions'); + const [plainButton, followUpButton] = actionBlock.elements; + + expect(plainButton.url).not.toBeDefined(); + + expect(followUpButton.url).toContain('/html/collect_input.html?'); + const params = new URL(followUpButton.url).searchParams; + expect(params.get('question')).toBe("What is the correct owner's email?"); + expect(params.get('format')).toBe('email'); + expect(params.get('noAnswerOption')).toBe("I don't know the owner"); + expect(params.get('title')).toBe('Header Text'); + expect(params.get('redirectUrl')).toContain( + 'executionCorrelationId=pause_123', + ); + expect(params.get('redirectUrl')).toContain('actionClicked='); + expect(result.eventPayload.interactionsDisabled).toBe(false); + }); + + test('should wrap all buttons in non-interaction mode and use collect_input for the follow-up one', async () => { + getBooleanMock.mockReturnValueOnce(false); + + const mockContext = buildContextWithFollowUpAction(); + const result = (await requestActionMessageAction.run(mockContext)) as any; + + const actionBlock = result.blocks.find((b: any) => b.type === 'actions'); + const [plainButton, followUpButton] = actionBlock.elements; + + expect(plainButton.url).toContain('/html/resume_execution.html?'); + expect(followUpButton.url).toContain('/html/collect_input.html?'); + expect(result.eventPayload.interactionsDisabled).toBe(true); + }); + + test('should list exactly the follow-up button texts in eventPayload.followUpActions', async () => { + getBooleanMock.mockReturnValueOnce(true); + + const mockContext = buildContextWithFollowUpAction(); + const result = (await requestActionMessageAction.run(mockContext)) as any; + + expect(result.eventPayload.followUpActions).toEqual([ + "I'm not the owner", + ]); + }); + + test('should store emoji-shortcode form in followUpActions and the actionClicked value', async () => { + getBooleanMock.mockReturnValueOnce(true); + + const mockContext = buildContextWithFollowUpAction(); + mockContext.propsValue.actions[1].buttonText = '🎲 Reroll'; + const result = (await requestActionMessageAction.run(mockContext)) as any; + + expect(result.eventPayload.followUpActions).toEqual([ + ':game_die: Reroll', + ]); + + const { queryParams } = mockContext.generateResumeUrl.mock.calls[0][0]; + expect(JSON.parse(queryParams.actionClicked)).toEqual({ + value: ':game_die: Reroll', + displayText: '🎲 Reroll', + }); + }); + }); + describe('RESUME execution', () => { test('should throw an error when there is no message stored in the store', async () => { const mockContextWithHeader = buildMockContext('This is a header', true); diff --git a/packages/blocks/slack/test/wait-for-action.test.ts b/packages/blocks/slack/test/wait-for-action.test.ts index 4b5fd42478..a1d519f59c 100644 --- a/packages/blocks/slack/test/wait-for-action.test.ts +++ b/packages/blocks/slack/test/wait-for-action.test.ts @@ -62,6 +62,7 @@ describe('waitForAction', () => { isExpired: undefined, message: context.propsValue.message, userSelection: null, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); @@ -89,6 +90,7 @@ describe('waitForAction', () => { isExpired: true, message: 'some updated message', userSelection: null, + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -146,6 +148,7 @@ describe('waitForAction', () => { displayText: actionText, value: actionValue, }, + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -213,6 +216,7 @@ describe('waitForAction', () => { displayText: 'text 2', }, ], + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -269,6 +273,7 @@ describe('waitForAction', () => { displayText: 'some action', value: 'some action', }, + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -319,6 +324,7 @@ describe('waitForAction', () => { isExpired: undefined, message: context.propsValue.message, userSelection: null, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); expect(context.store.get).toHaveBeenCalledTimes(1); @@ -355,6 +361,7 @@ describe('waitForAction', () => { isExpired: undefined, message: context.propsValue.message, userSelection: null, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); expect(context.store.get).toHaveBeenCalledTimes(1); diff --git a/packages/blocks/slack/test/wait-for-interaction.test.ts b/packages/blocks/slack/test/wait-for-interaction.test.ts index 4911c73c4a..1bcd2c9940 100644 --- a/packages/blocks/slack/test/wait-for-interaction.test.ts +++ b/packages/blocks/slack/test/wait-for-interaction.test.ts @@ -34,6 +34,7 @@ describe('wait-for-interaction', () => { userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); @@ -72,6 +73,7 @@ describe('wait-for-interaction', () => { userSelection: null, isExpired: true, message: 'updated message', + parameters: {}, }); expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1); @@ -316,6 +318,7 @@ describe('wait-for-interaction', () => { userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); @@ -361,6 +364,7 @@ describe('wait-for-interaction', () => { userSelection: null, isExpired: undefined, message: messageObj, + parameters: {}, }); expect(pauseMock).toHaveBeenCalledTimes(1); @@ -392,6 +396,118 @@ describe('wait-for-interaction', () => { }); }); + describe('resume parameters', () => { + test('should surface extra resume query params as parameters on a matched resume', async () => { + const messageObj: MessageInfo = createMockMessage(); + const context = createMockContext({ + resumePayload: { + queryParams: { + userName: 'test_user', + actionType: 'button', + actionClicked: JSON.stringify({ + value: "I'm not the owner", + displayText: "I'm not the owner", + }), + path: 'step_1', + executionCorrelationId: 'corr-1', + isTest: 'false', + answer: 'someone@example.com', + }, + }, + store: { + get: jest.fn().mockResolvedValue({ + executionCorrelationId: 'pause_123', + resumeDateTime: new Date().toISOString(), + }), + }, + }); + + const result = await onReceivedInteraction( + messageObj, + ["I'm not the owner"], + context, + 'step_1', + ); + + expect(result.isExpired).toBe(false); + expect(result.parameters).toEqual({ answer: 'someone@example.com' }); + }); + + test('should not include prototype-pollution keys in parameters', async () => { + const messageObj: MessageInfo = createMockMessage(); + const queryParams = Object.create(null) as Record; + queryParams['userName'] = 'test_user'; + queryParams['actionType'] = 'button'; + queryParams['actionClicked'] = JSON.stringify({ + value: 'Approve', + displayText: 'Approve', + }); + queryParams['path'] = 'step_1'; + Object.defineProperty(queryParams, '__proto__', { + value: 'polluted', + enumerable: true, + configurable: true, + writable: true, + }); + queryParams['constructor'] = 'polluted'; + queryParams['prototype'] = 'polluted'; + queryParams['answer'] = 'someone@example.com'; + + const context = createMockContext({ + resumePayload: { queryParams }, + store: { + get: jest.fn().mockResolvedValue({ + executionCorrelationId: 'pause_123', + resumeDateTime: new Date().toISOString(), + }), + }, + }); + + const result = await onReceivedInteraction( + messageObj, + ['Approve'], + context, + 'step_1', + ); + + expect(result.parameters).toEqual({ answer: 'someone@example.com' }); + }); + + test('should return empty parameters when only routing params are present', async () => { + const messageObj: MessageInfo = createMockMessage(); + const context = createMockContext({ + resumePayload: { + queryParams: { + userName: 'test_user', + actionType: 'button', + actionClicked: JSON.stringify({ + value: 'Approve', + displayText: 'Approve', + }), + path: 'step_1', + executionCorrelationId: 'corr-1', + isTest: 'true', + }, + }, + store: { + get: jest.fn().mockResolvedValue({ + executionCorrelationId: 'pause_123', + resumeDateTime: new Date().toISOString(), + }), + }, + }); + + const result = await onReceivedInteraction( + messageObj, + ['Approve'], + context, + 'step_1', + ); + + expect(result.parameters).toEqual({}); + }); + }); + describe('message updates', () => { test('should update message with action received block', async () => { const messageObj: MessageInfo = createMockMessage(); @@ -446,12 +562,7 @@ describe('wait-for-interaction', () => { function createMockContext(params?: { run?: { pause?: jest.Mock; pauseId?: string }; resumePayload?: { - queryParams?: { - userName?: string; - actionType?: string; - actionClicked?: string; - path?: string; - }; + queryParams?: Record; }; store?: { get?: jest.Mock; diff --git a/packages/openops/src/index.ts b/packages/openops/src/index.ts index af3a4c2614..7fcaf47a96 100644 --- a/packages/openops/src/index.ts +++ b/packages/openops/src/index.ts @@ -68,6 +68,7 @@ export * from './lib/azure/subscription/get-subscription-dropdown'; export * from './lib/azure/subscription/get-subscriptions'; export * from './lib/axios-wrapper'; +export * from './lib/build-wrapper-url'; export * from './lib/cloud-cli-common'; export * from './lib/dry-run-property'; diff --git a/packages/blocks/microsoft-teams/src/lib/common/build-wrapper-url.ts b/packages/openops/src/lib/build-wrapper-url.ts similarity index 100% rename from packages/blocks/microsoft-teams/src/lib/common/build-wrapper-url.ts rename to packages/openops/src/lib/build-wrapper-url.ts diff --git a/packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts b/packages/openops/test/build-wrapper-url.test.ts similarity index 97% rename from packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts rename to packages/openops/test/build-wrapper-url.test.ts index a36e93c3e0..061cf553f6 100644 --- a/packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts +++ b/packages/openops/test/build-wrapper-url.test.ts @@ -1,4 +1,4 @@ -import { buildWrapperUrl } from '../src/lib/common/build-wrapper-url'; +import { buildWrapperUrl } from '../src/lib/build-wrapper-url'; const baseParams = { frontendUrl: 'https://app.openops.com', diff --git a/packages/server/api/src/app/slack/is-follow-up-action.ts b/packages/server/api/src/app/slack/is-follow-up-action.ts new file mode 100644 index 0000000000..4ec8840514 --- /dev/null +++ b/packages/server/api/src/app/slack/is-follow-up-action.ts @@ -0,0 +1,16 @@ +type UserSelection = { value: string; displayText: string }; + +// Follow-up buttons are link buttons that open a browser form which owns the +// resume; Slack still posts an interaction payload for them, so the endpoint +// must not resume the flow. Messages sent by older block versions have no +// followUpActions metadata, which is treated as an empty list. +export function isFollowUpAction( + userSelection: UserSelection | UserSelection[], + followUpActions: unknown, +): boolean { + if (Array.isArray(userSelection) || !Array.isArray(followUpActions)) { + return false; + } + + return followUpActions.includes(userSelection.value); +} diff --git a/packages/server/api/src/app/slack/slack-interaction-module.ts b/packages/server/api/src/app/slack/slack-interaction-module.ts index 6d4a1f5d5a..f9208b2d43 100644 --- a/packages/server/api/src/app/slack/slack-interaction-module.ts +++ b/packages/server/api/src/app/slack/slack-interaction-module.ts @@ -10,6 +10,7 @@ import axios from 'axios'; import { FastifyReply, FastifyRequest } from 'fastify'; import { sendEphemeralMessage } from './ephemeral-message'; import { getUserSelection } from './get-user-selection'; +import { isFollowUpAction } from './is-follow-up-action'; import { verifySignature } from './slack-token-verifier'; export const CreateSlackInteractionRequest = Type.Object({ @@ -140,6 +141,20 @@ async function evaluateUserInteraction(payload: any, reply: FastifyReply) { return reply.code(200).send({ text: 'Finished sending ephemeral' }); } + const followUpActions: unknown = + payload.message.metadata.event_payload.followUpActions; + + if (isFollowUpAction(userSelection, followUpActions)) { + logger.debug( + 'Ignoring a Slack interaction: follow-up action resumes via the browser form', + { userSelection }, + ); + + return reply + .code(200) + .send({ text: 'Follow-up action is handled in the browser' }); + } + const resumeUrl: string = payload.message.metadata.event_payload.resumeUrl; if (resumeUrl) { diff --git a/packages/server/api/test/integration/cloud/slack-integration/slack-interaction.test.ts b/packages/server/api/test/integration/cloud/slack-integration/slack-interaction.test.ts index 80a41d39cb..345d2e7328 100644 --- a/packages/server/api/test/integration/cloud/slack-integration/slack-interaction.test.ts +++ b/packages/server/api/test/integration/cloud/slack-integration/slack-interaction.test.ts @@ -257,6 +257,91 @@ describe('Slack API', () => { expect(axiosGetMock).not.toHaveBeenCalled(); }); + test('should return 200 without resuming when the clicked button is a follow-up action', async () => { + verifySignatureMock.mockReturnValueOnce(true); + + const payload = JSON.stringify( + buildButtonClickPayload("I'm not the owner", { + isTest: false, + resumeUrl: 'http://some-resume-url.com?test=1', + followUpActions: ["I'm not the owner"], + }), + ); + + const response = await makeRequest(payload); + + expect(response?.statusCode).toBe(StatusCodes.OK); + expect(response?.json()).toEqual({ + text: 'Follow-up action is handled in the browser', + }); + expect(axiosGetMock).not.toHaveBeenCalled(); + expect(sendEphemeralMessageMock).not.toHaveBeenCalled(); + }); + + test('should resume when the clicked button is not a follow-up action on a message that has some', async () => { + verifySignatureMock.mockReturnValueOnce(true); + axiosGetMock.mockResolvedValueOnce({}); + + const payload = JSON.stringify( + buildButtonClickPayload('Approve', { + isTest: false, + resumeUrl: 'http://some-resume-url.com?test=1', + followUpActions: ["I'm not the owner"], + }), + ); + + const response = await makeRequest(payload); + + expect(response?.statusCode).toBe(StatusCodes.OK); + expect(axiosGetMock).toHaveBeenCalledTimes(1); + expect(axiosGetMock.mock.calls[0][0]).toContain( + 'http://some-resume-url.com/?test=1', + ); + }); + + test('should resume when the message has no followUpActions metadata', async () => { + verifySignatureMock.mockReturnValueOnce(true); + axiosGetMock.mockResolvedValueOnce({}); + + const payload = JSON.stringify( + buildButtonClickPayload('Approve', { + isTest: false, + resumeUrl: 'http://some-resume-url.com?test=1', + }), + ); + + const response = await makeRequest(payload); + + expect(response?.statusCode).toBe(StatusCodes.OK); + expect(axiosGetMock).toHaveBeenCalledTimes(1); + }); + + function buildButtonClickPayload( + buttonText: string, + eventPayload: Record, + ): Record { + return { + actions: [ + { + type: 'button', + action_id: 'some_id', + text: { type: 'plain_text', text: buttonText }, + }, + ], + user: { + id: 'some_user_id', + name: 'some_user_name', + }, + message: { + metadata: { + event_payload: eventPayload, + }, + }, + response_url: + 'https://hooks.slack.com/actions/XXXXXXXX/XXXXXXXXX/XXXXXXXXX', + }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any async function makeRequest(payload: string): Promise { const request: CreateSlackInteractionRequest = { diff --git a/packages/server/api/test/unit/slack/is-follow-up-action.test.ts b/packages/server/api/test/unit/slack/is-follow-up-action.test.ts new file mode 100644 index 0000000000..21d8a9c597 --- /dev/null +++ b/packages/server/api/test/unit/slack/is-follow-up-action.test.ts @@ -0,0 +1,54 @@ +import { isFollowUpAction } from '../../../src/app/slack/is-follow-up-action'; + +describe('isFollowUpAction', () => { + test('should return true when the selected value is listed in followUpActions', () => { + const result = isFollowUpAction( + { value: "I'm not the owner", displayText: "I'm not the owner" }, + ["I'm not the owner"], + ); + + expect(result).toBe(true); + }); + + test('should return false when the selected value is not listed', () => { + const result = isFollowUpAction( + { value: 'Approve', displayText: 'Approve' }, + ["I'm not the owner"], + ); + + expect(result).toBe(false); + }); + + test.each([undefined, null, 'not-an-array', 42, {}])( + 'should return false when followUpActions is %p', + (followUpActions) => { + const result = isFollowUpAction( + { value: "I'm not the owner", displayText: "I'm not the owner" }, + followUpActions, + ); + + expect(result).toBe(false); + }, + ); + + test('should return false when followUpActions is an empty array', () => { + const result = isFollowUpAction( + { value: "I'm not the owner", displayText: "I'm not the owner" }, + [], + ); + + expect(result).toBe(false); + }); + + test('should return false for multi-select selections', () => { + const result = isFollowUpAction( + [ + { value: "I'm not the owner", displayText: "I'm not the owner" }, + { value: 'Approve', displayText: 'Approve' }, + ], + ["I'm not the owner"], + ); + + expect(result).toBe(false); + }); +}); From a4375bfeb575840953670cb61affe7ae28e247a0 Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Fri, 17 Jul 2026 16:49:08 +0530 Subject: [PATCH 2/2] Add env key to engine pool --- packages/server/worker/src/lib/engine/engine-pool.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/worker/src/lib/engine/engine-pool.ts b/packages/server/worker/src/lib/engine/engine-pool.ts index d119d9c34e..e9e8a9ff3e 100644 --- a/packages/server/worker/src/lib/engine/engine-pool.ts +++ b/packages/server/worker/src/lib/engine/engine-pool.ts @@ -244,6 +244,7 @@ const ENGINE_ALLOWED_ENV_KEYS = new Set([ 'OPS_ENCRYPTION_KEY', 'OPS_CONTAINER_TYPE', 'OPS_SERVER_API_URL', + 'OPS_INTERNAL_BACKEND_PUBLIC_URL', 'OPS_QUEUE_MODE',