Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
42 changes: 35 additions & 7 deletions packages/blocks/slack/src/lib/actions/request-action-message.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,7 +18,7 @@ import {
username,
usersAndChannels,
} from '../common/props';
import { slackSendMessage } from '../common/utils';
import { normalizeEmojiString, slackSendMessage } from '../common/utils';
import {
onReceivedInteraction,
waitForInteraction,
Expand Down Expand Up @@ -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({
Expand All @@ -154,6 +178,7 @@ const sendMessageAskingForAction = async (
executionCorrelationId: context.run.pauseId,
},
}),
followUpActions,
},
});
};
Expand Down Expand Up @@ -183,6 +208,9 @@ interface SlackActionDefinition {
buttonStyle: string;
confirmationPrompt: boolean;
confirmationPromptText: string;
followUpQuestion?: string;
answerFormat?: string;
noAnswerOption?: string;
url?: string;
}

Expand Down
25 changes: 25 additions & 0 deletions packages/blocks/slack/src/lib/common/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
},
});

Expand Down
25 changes: 25 additions & 0 deletions packages/blocks/slack/src/lib/common/wait-for-interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,23 @@ export interface WaitForInteractionResult {
message: MessageInfo;
isExpired: boolean | undefined;
userSelection: UserSelection | UserSelection[] | null;
parameters: Record<string, string>;
}

// 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,
Expand Down Expand Up @@ -50,6 +65,7 @@ export async function waitForInteraction(
userSelection: null,
isExpired: undefined,
message: messageObj,
parameters: {},
};
}

Expand All @@ -72,6 +88,7 @@ export async function onReceivedInteraction(
userSelection: null,
isExpired: true,
message: updatedMessage,
parameters: {},
};
}

Expand Down Expand Up @@ -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<string, string>).filter(
([key]) => !EXCLUDED_RESUME_PARAMS.has(key),
),
);

const updatedMessage = await actionReceived(
context,
messageObj,
Expand All @@ -131,6 +155,7 @@ export async function onReceivedInteraction(
message: updatedMessage,
userSelection,
isExpired: false,
parameters,
};
}

Expand Down
99 changes: 99 additions & 0 deletions packages/blocks/slack/test/request-action-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ describe('requestActionMessageAction', () => {
domain: mockContextWithHeader.server.publicUrl,
resumeUrl: undefined,
interactionsDisabled: false,
followUpActions: [],
},
});

Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions packages/blocks/slack/test/wait-for-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe('waitForAction', () => {
isExpired: undefined,
message: context.propsValue.message,
userSelection: null,
parameters: {},
});

expect(pauseMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -89,6 +90,7 @@ describe('waitForAction', () => {
isExpired: true,
message: 'some updated message',
userSelection: null,
parameters: {},
});

expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -146,6 +148,7 @@ describe('waitForAction', () => {
displayText: actionText,
value: actionValue,
},
parameters: {},
});

expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -213,6 +216,7 @@ describe('waitForAction', () => {
displayText: 'text 2',
},
],
parameters: {},
});

expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -269,6 +273,7 @@ describe('waitForAction', () => {
displayText: 'some action',
value: 'some action',
},
parameters: {},
});

expect(slackUpdateMessageMock).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading