Skip to content
Merged
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 @@ -6,6 +6,7 @@ import {
} from '@openops/blocks-framework';
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 Expand Up @@ -68,6 +69,31 @@ export const requestActionMessageAction = createAction({
],
},
}),
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,
}),
},
}),
timeoutInDays: Property.Number({
Expand Down Expand Up @@ -133,9 +159,19 @@ export const requestActionMessageAction = createAction({
);
return {
...action,
resumeUrl: `${frontendUrl}/html/resume_execution.html?isTest=${
context.run.isTest
}&redirectUrl=${encodeURIComponent(resumeUrl)}`,
resumeUrl: buildWrapperUrl({
frontendUrl,
isTest: context.run.isTest,
resumeUrl,
followUp: action.followUpQuestion
? {
question: action.followUpQuestion,
answerFormat: action.answerFormat,
noAnswerOption: action.noAnswerOption,
title: header,
}
: undefined,
}),
};
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const RESUME_PAGE = 'resume_execution.html';
const COLLECT_INPUT_PAGE = 'collect_input.html';

const ANSWER_FORMATS = new Set(['text', 'email', 'number']);
const DEFAULT_ANSWER_FORMAT = 'text';

export type ButtonFollowUp = {
question: string;
answerFormat?: string;
noAnswerOption?: string;
title?: string;
};

// The collect-input page sends the answer back as the `answer` resume query
// param, surfaced in the step output as parameters.answer.
export function buildWrapperUrl({
frontendUrl,
isTest,
resumeUrl,
followUp,
}: {
frontendUrl: string;
isTest: boolean;
resumeUrl: string;
followUp?: ButtonFollowUp;
}): string {
const question = followUp?.question?.trim();

if (!question) {
return `${frontendUrl}/html/${RESUME_PAGE}?isTest=${isTest}&redirectUrl=${encodeURIComponent(
resumeUrl,
)}`;
}

const format =
followUp?.answerFormat && ANSWER_FORMATS.has(followUp.answerFormat)
? followUp.answerFormat
: DEFAULT_ANSWER_FORMAT;

const params = new URLSearchParams({
isTest: String(isTest),
question,
format,
redirectUrl: resumeUrl,
});

const noAnswerOption = followUp?.noAnswerOption?.trim();
if (noAnswerOption) {
params.set('noAnswerOption', noAnswerOption);
}

const title = followUp?.title?.trim();
if (title) {
params.set('title', title);
}

return `${frontendUrl}/html/${COLLECT_INPUT_PAGE}?${params.toString()}`;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export type TeamsMessageAction = {
buttonText: string;
buttonStyle: string;
followUpQuestion?: string;
answerFormat?: string;
noAnswerOption?: string;
};

export type TeamsMessageButton = TeamsMessageAction & { resumeUrl?: string };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import {
TeamsMessageButton,
} from './generate-message-with-buttons';

// 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([
'button',
'path',
'executionCorrelationId',
'isTest',
'__proto__',
'constructor',
'prototype',
]);

export const onActionReceived = async ({
messageObj,
actions,
Expand All @@ -14,15 +26,17 @@ export const onActionReceived = async ({
actions: TeamsMessageButton[];
context: any;
}) => {
const resumePayload = context.resumePayload
?.queryParams as unknown as InteractionPayload;
const resumePayload = context.resumePayload?.queryParams as unknown as
| (InteractionPayload & Record<string, string>)
| undefined;
const isResumedDueToButtonClicked = !!resumePayload?.button;

if (!isResumedDueToButtonClicked) {
return {
action: '',
isExpired: true,
message: messageObj,
parameters: {},
};
}

Expand Down Expand Up @@ -52,12 +66,20 @@ export const onActionReceived = async ({
action: '',
isExpired: undefined,
message: messageObj,
parameters: {},
};
}

const parameters = Object.fromEntries(
Object.entries(resumePayload).filter(
([key]) => !EXCLUDED_RESUME_PARAMS.has(key),
),
);

return {
action: resumePayload.button,
message: messageObj,
isExpired: false,
parameters,
};
};
101 changes: 101 additions & 0 deletions packages/blocks/microsoft-teams/test/build-wrapper-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { buildWrapperUrl } from '../src/lib/common/build-wrapper-url';

const baseParams = {
frontendUrl: 'https://app.openops.com',
isTest: false,
resumeUrl:
'https://api.openops.com/v1/flow-runs/run-1/requests/pause-1?button=Not%20mine&path=step_1',
};

const encodedResumeUrl = encodeURIComponent(baseParams.resumeUrl);

describe('buildWrapperUrl', () => {
test('uses the default resume page when no follow-up question is given', () => {
const url = buildWrapperUrl(baseParams);

expect(url).toBe(
`https://app.openops.com/html/resume_execution.html?isTest=false&redirectUrl=${encodedResumeUrl}`,
);
});

test('uses the collect-input page when a follow-up question is given', () => {
const url = buildWrapperUrl({
...baseParams,
followUp: {
question: "What is the correct owner's email?",
answerFormat: 'email',
},
});

const parsed = new URL(url);
expect(parsed.origin).toBe('https://app.openops.com');
expect(parsed.pathname).toBe('/html/collect_input.html');
expect(parsed.searchParams.get('question')).toBe(
"What is the correct owner's email?",
);
expect(parsed.searchParams.get('format')).toBe('email');
expect(parsed.searchParams.get('isTest')).toBe('false');
expect(parsed.searchParams.get('redirectUrl')).toBe(baseParams.resumeUrl);
});

test('includes the message header as the form title when given', () => {
const url = buildWrapperUrl({
...baseParams,
followUp: {
question: 'Who owns this?',
title: 'Cost savings opportunity: vol-123',
},
});

expect(new URL(url).searchParams.get('title')).toBe(
'Cost savings opportunity: vol-123',
);
});

test('includes the no-answer option when given', () => {
const url = buildWrapperUrl({
...baseParams,
followUp: {
question: "What is the correct owner's email?",
noAnswerOption: "I don't know the owner",
},
});

expect(new URL(url).searchParams.get('noAnswerOption')).toBe(
"I don't know the owner",
);
});

test('omits the no-answer option param when not given', () => {
const url = buildWrapperUrl({
...baseParams,
followUp: { question: 'Why was this dismissed?' },
});

expect(new URL(url).searchParams.has('noAnswerOption')).toBe(false);
});

test('ignores a follow-up with an empty question', () => {
const url = buildWrapperUrl({
...baseParams,
followUp: { question: ' ' },
});

expect(url).toContain('/html/resume_execution.html?');
});

test('defaults the answer format to text for unknown formats', () => {
const url = buildWrapperUrl({
...baseParams,
followUp: { question: 'Why?', answerFormat: 'dropdown' },
});

expect(new URL(url).searchParams.get('format')).toBe('text');
});

test('passes isTest through', () => {
const url = buildWrapperUrl({ ...baseParams, isTest: true });

expect(url).toContain('isTest=true');
});
});
75 changes: 74 additions & 1 deletion packages/blocks/microsoft-teams/test/on-action-received.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const mockContext = {
queryParams: {
button: 'Approve',
path: 'execution-path-1',
},
} as Record<string, string>,
},
currentExecutionPath: 'execution-path-1',
run: { pause: jest.fn() },
Expand All @@ -50,6 +50,7 @@ describe('onActionReceived', () => {
action: '',
isExpired: true,
message: mockMessageObj,
parameters: {},
});
});

Expand Down Expand Up @@ -89,6 +90,78 @@ describe('onActionReceived', () => {
action: 'Approve',
message: mockMessageObj,
isExpired: false,
parameters: {},
});
});

test('should surface extra resume query params as parameters', async () => {
mockContext.store.get.mockResolvedValueOnce({ pauseMetadata: 'mock-data' });
mockContext.resumePayload = {
queryParams: {
button: 'Approve',
path: 'execution-path-1',
executionCorrelationId: 'corr-1',
isTest: 'false',
newOwner: 'alice@example.com',
},
};

const result = await onActionReceived({
messageObj: mockMessageObj,
actions: mockActions,
context: mockContext,
});

expect(result).toEqual({
action: 'Approve',
message: mockMessageObj,
isExpired: false,
parameters: { newOwner: 'alice@example.com' },
});
});

test('should not include prototype-pollution keys in parameters', async () => {
mockContext.store.get.mockResolvedValueOnce({ pauseMetadata: 'mock-data' });
const queryParams = Object.create(null) as Record<string, string>;
queryParams['button'] = 'Approve';
queryParams['path'] = 'execution-path-1';
Object.defineProperty(queryParams, '__proto__', {
value: 'polluted',
Comment thread
ravikiranvm marked this conversation as resolved.
Dismissed
enumerable: true,
configurable: true,
writable: true,
});
queryParams['constructor'] = 'polluted';
queryParams['prototype'] = 'polluted';
queryParams['answer'] = 'alice@example.com';
mockContext.resumePayload = { queryParams };

const result = await onActionReceived({
messageObj: mockMessageObj,
actions: mockActions,
context: mockContext,
});

expect(result.parameters).toEqual({ answer: 'alice@example.com' });
});

test('should not include internal routing params in parameters', async () => {
mockContext.store.get.mockResolvedValueOnce({ pauseMetadata: 'mock-data' });
mockContext.resumePayload = {
queryParams: {
button: 'Approve',
path: 'execution-path-1',
executionCorrelationId: 'corr-1',
isTest: 'true',
},
};

const result = await onActionReceived({
messageObj: mockMessageObj,
actions: mockActions,
context: mockContext,
});

expect(result.parameters).toEqual({});
});
});
Loading
Loading