mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
feat: Slack App addon (#4238)
https://linear.app/unleash/issue/2-1232/implement-first-iteration-of-the-new-slack-app-addon This PR implements the first iteration of the new Slack App addon. Unlike the old Slack addon, this one uses a Slack App (bot) that is installed to Slack workspaces in order to post messages. This uses `@slack/web-api`, which internally uses the latest Slack API endpoints like `postMessage`. This is currently behind a flag: `slackAppAddon`. The current flow is that the Unleash Slack App is installed from whatever source: - Unleash addons page; - Direct link; - https://unleash-slack-app.vercel.app/ (temporary URL); - Slack App Directory (in the future); - Etc; After installed, we resolve the authorization to an `access_token` that the user can paste into the Unleash Slack App addon configuration form. https://github.com/Unleash/unleash/assets/14320932/6a6621b9-5b8a-4921-a279-30668be6d46c Co-authored by: @daveleek --------- Co-authored-by: David Leek <david@getunleash.io>
This commit is contained in:
parent
fc9cacfb6d
commit
383e522127
@ -12,6 +12,12 @@ export const StyledFormSection = styled('section')({
|
||||
marginBottom: '36px',
|
||||
});
|
||||
|
||||
export const StyledAlerts = styled(StyledFormSection)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
export const StyledHelpText = styled('p')({
|
||||
marginBottom: '0.5rem',
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, {
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
FormEventHandler,
|
||||
MouseEventHandler,
|
||||
@ -6,11 +6,18 @@ import React, {
|
||||
useState,
|
||||
VFC,
|
||||
} from 'react';
|
||||
import { Button, Divider, FormControlLabel, Switch } from '@mui/material';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
} from '@mui/material';
|
||||
import produce from 'immer';
|
||||
import { trim } from 'component/common/util';
|
||||
import { IAddon, IAddonProvider } from 'interfaces/addons';
|
||||
import { AddonParameters } from './AddonParameters/AddonParameters';
|
||||
import { AddonInstall } from './AddonInstall/AddonInstall';
|
||||
import cloneDeep from 'lodash.clonedeep';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
|
||||
@ -29,6 +36,7 @@ import {
|
||||
import {
|
||||
StyledForm,
|
||||
StyledFormSection,
|
||||
StyledAlerts,
|
||||
StyledHelpText,
|
||||
StyledTextField,
|
||||
StyledContainer,
|
||||
@ -37,6 +45,7 @@ import {
|
||||
} from './AddonForm.styles';
|
||||
import { useTheme } from '@mui/system';
|
||||
import { GO_BACK } from 'constants/navigate';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
|
||||
interface IAddonFormProps {
|
||||
provider?: IAddonProvider;
|
||||
@ -241,6 +250,8 @@ export const AddonForm: VFC<IAddonFormProps> = ({
|
||||
name,
|
||||
description,
|
||||
documentationUrl = 'https://unleash.github.io/docs/addons',
|
||||
installation,
|
||||
alerts,
|
||||
} = provider ? provider : ({} as Partial<IAddonProvider>);
|
||||
|
||||
return (
|
||||
@ -253,6 +264,21 @@ export const AddonForm: VFC<IAddonFormProps> = ({
|
||||
>
|
||||
<StyledForm onSubmit={onSubmit}>
|
||||
<StyledContainer>
|
||||
<StyledAlerts>
|
||||
{alerts?.map(({ type, text }) => (
|
||||
<Alert severity={type}>{text}</Alert>
|
||||
))}
|
||||
</StyledAlerts>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(installation)}
|
||||
show={() => (
|
||||
<AddonInstall
|
||||
url={installation!.url}
|
||||
title={installation!.title}
|
||||
helpText={installation!.helpText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<StyledFormSection>
|
||||
<StyledTextField
|
||||
size="small"
|
||||
|
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
StyledFormSection,
|
||||
StyledHelpText,
|
||||
StyledTitle,
|
||||
} from '../AddonForm.styles';
|
||||
import { Button } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export interface IAddonInstallProps {
|
||||
url: string;
|
||||
title?: string;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
export const AddonInstall = ({
|
||||
url,
|
||||
title = 'Install addon',
|
||||
helpText = 'Click this button to install this addon.',
|
||||
}: IAddonInstallProps) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<StyledFormSection>
|
||||
<StyledTitle>{title}</StyledTitle>
|
||||
<StyledHelpText>{helpText}</StyledHelpText>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
component={Link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
to={url}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
</StyledFormSection>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
@ -21,6 +21,7 @@ interface IAddonIconProps {
|
||||
export const AddonIcon = ({ name }: IAddonIconProps) => {
|
||||
switch (name) {
|
||||
case 'slack':
|
||||
case 'slack-app':
|
||||
return (
|
||||
<img
|
||||
style={style}
|
||||
|
@ -0,0 +1,35 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
|
||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||
import { IAddonProvider } from 'interfaces/addons';
|
||||
|
||||
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||
cursor: 'pointer',
|
||||
marginLeft: theme.spacing(1),
|
||||
}));
|
||||
|
||||
interface IAddonNameCellProps {
|
||||
provider: Pick<
|
||||
IAddonProvider,
|
||||
'displayName' | 'description' | 'deprecated'
|
||||
>;
|
||||
}
|
||||
|
||||
export const AddonNameCell = ({ provider }: IAddonNameCellProps) => (
|
||||
<HighlightCell
|
||||
value={provider.displayName}
|
||||
subtitle={provider.description}
|
||||
afterTitle={
|
||||
<ConditionallyRender
|
||||
condition={Boolean(provider.deprecated)}
|
||||
show={
|
||||
<HtmlTooltip title={provider.deprecated} arrow>
|
||||
<StyledBadge color="neutral">Deprecated</StyledBadge>
|
||||
</HtmlTooltip>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
@ -11,7 +11,6 @@ import {
|
||||
} from 'component/common/Table';
|
||||
|
||||
import { useTable, useSortBy } from 'react-table';
|
||||
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
@ -19,6 +18,8 @@ import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
|
||||
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||
import { ConfigureAddonButton } from './ConfigureAddonButton/ConfigureAddonButton';
|
||||
import { AddonIcon } from '../AddonIcon/AddonIcon';
|
||||
import { AddonNameCell } from '../AddonNameCell/AddonNameCell';
|
||||
import { IAddonInstallation } from 'interfaces/addons';
|
||||
|
||||
interface IProvider {
|
||||
name: string;
|
||||
@ -27,6 +28,8 @@ interface IProvider {
|
||||
documentationUrl: string;
|
||||
parameters: object[];
|
||||
events: string[];
|
||||
installation?: IAddonInstallation;
|
||||
deprecated?: string;
|
||||
}
|
||||
|
||||
interface IAvailableAddonsProps {
|
||||
@ -46,11 +49,15 @@ export const AvailableAddons = ({
|
||||
});
|
||||
}
|
||||
|
||||
return providers.map(({ name, displayName, description }) => ({
|
||||
name,
|
||||
displayName,
|
||||
description,
|
||||
}));
|
||||
return providers.map(
|
||||
({ name, displayName, description, deprecated, installation }) => ({
|
||||
name,
|
||||
displayName,
|
||||
description,
|
||||
deprecated,
|
||||
installation,
|
||||
})
|
||||
);
|
||||
}, [providers, loading]);
|
||||
|
||||
const columns = useMemo(
|
||||
@ -71,19 +78,9 @@ export const AvailableAddons = ({
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
width: '90%',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { name, description },
|
||||
},
|
||||
}: any) => {
|
||||
return (
|
||||
<LinkCell
|
||||
data-loading
|
||||
title={name}
|
||||
subtitle={description}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Cell: ({ row: { original } }: any) => (
|
||||
<AddonNameCell provider={original} />
|
||||
),
|
||||
sortType: 'alphanumeric',
|
||||
},
|
||||
{
|
||||
@ -91,7 +88,7 @@ export const AvailableAddons = ({
|
||||
align: 'center',
|
||||
Cell: ({ row: { original } }: any) => (
|
||||
<ActionCell>
|
||||
<ConfigureAddonButton name={original.name} />
|
||||
<ConfigureAddonButton provider={original} />
|
||||
</ActionCell>
|
||||
),
|
||||
width: 150,
|
||||
|
@ -1,19 +1,24 @@
|
||||
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||||
import { CREATE_ADDON } from 'component/providers/AccessProvider/permissions';
|
||||
import { IAddonProvider } from 'interfaces/addons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface IConfigureAddonButtonProps {
|
||||
name: string;
|
||||
provider: IAddonProvider;
|
||||
}
|
||||
|
||||
export const ConfigureAddonButton = ({ name }: IConfigureAddonButtonProps) => {
|
||||
export const ConfigureAddonButton = ({
|
||||
provider,
|
||||
}: IConfigureAddonButtonProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<PermissionButton
|
||||
permission={CREATE_ADDON}
|
||||
variant="outlined"
|
||||
onClick={() => navigate(`/addons/create/${name}`)}
|
||||
onClick={() => {
|
||||
navigate(`/addons/create/${provider.name}`);
|
||||
}}
|
||||
>
|
||||
Configure
|
||||
</PermissionButton>
|
||||
|
@ -8,7 +8,6 @@ import useAddonsApi from 'hooks/api/actions/useAddonsApi/useAddonsApi';
|
||||
import { IAddon } from 'interfaces/addons';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import { useTable, useSortBy } from 'react-table';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
@ -16,9 +15,10 @@ import { SortableTableHeader, TablePlaceholder } from 'component/common/Table';
|
||||
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
|
||||
import { AddonIcon } from '../AddonIcon/AddonIcon';
|
||||
import { ConfiguredAddonsActionsCell } from './ConfiguredAddonsActionCell/ConfiguredAddonsActionsCell';
|
||||
import { AddonNameCell } from '../AddonNameCell/AddonNameCell';
|
||||
|
||||
export const ConfiguredAddons = () => {
|
||||
const { refetchAddons, addons, loading } = useAddons();
|
||||
const { refetchAddons, addons, providers, loading } = useAddons();
|
||||
const { updateAddon, removeAddon } = useAddonsApi();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
@ -84,15 +84,18 @@ export const ConfiguredAddons = () => {
|
||||
row: {
|
||||
original: { provider, description },
|
||||
},
|
||||
}: any) => {
|
||||
return (
|
||||
<LinkCell
|
||||
data-loading
|
||||
title={provider}
|
||||
subtitle={description}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}: any) => (
|
||||
<AddonNameCell
|
||||
provider={{
|
||||
...(providers.find(
|
||||
({ name }) => name === provider
|
||||
) || {
|
||||
displayName: provider,
|
||||
}),
|
||||
description,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
sortType: 'alphanumeric',
|
||||
},
|
||||
{
|
||||
|
@ -19,6 +19,21 @@ export interface IAddonProvider {
|
||||
name: string;
|
||||
parameters: IAddonProviderParams[];
|
||||
tagTypes: ITagType[];
|
||||
installation?: IAddonInstallation;
|
||||
alerts?: IAddonAlert[];
|
||||
deprecated?: string;
|
||||
}
|
||||
|
||||
export interface IAddonInstallation {
|
||||
url: string;
|
||||
warning?: string;
|
||||
title?: string;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
export interface IAddonAlert {
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface IAddonProviderParams {
|
||||
|
@ -104,6 +104,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@slack/web-api": "^6.8.1",
|
||||
"@unleash/express-openapi": "^0.3.0",
|
||||
"ajv": "^8.11.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
|
@ -1,12 +1,14 @@
|
||||
import joi from 'joi';
|
||||
import { nameType } from '../routes/util';
|
||||
import { tagTypeSchema } from '../services/tag-type-schema';
|
||||
import { installationDefinitionSchema } from './installation-definition-schema';
|
||||
|
||||
export const addonDefinitionSchema = joi.object().keys({
|
||||
name: nameType,
|
||||
displayName: joi.string(),
|
||||
documentationUrl: joi.string().uri({ scheme: [/https?/] }),
|
||||
description: joi.string().allow(''),
|
||||
deprecated: joi.boolean().optional().default(false),
|
||||
parameters: joi
|
||||
.array()
|
||||
.optional()
|
||||
@ -23,4 +25,14 @@ export const addonDefinitionSchema = joi.object().keys({
|
||||
),
|
||||
events: joi.array().optional().items(joi.string()),
|
||||
tagTypes: joi.array().optional().items(tagTypeSchema),
|
||||
installation: installationDefinitionSchema.optional(),
|
||||
alerts: joi
|
||||
.array()
|
||||
.optional()
|
||||
.items(
|
||||
joi.object().keys({
|
||||
type: joi.string().valid('success', 'info', 'warning', 'error'),
|
||||
text: joi.string().required(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
@ -4,6 +4,8 @@ import TeamsAddon from './teams';
|
||||
import DatadogAddon from './datadog';
|
||||
import Addon from './addon';
|
||||
import { LogProvider } from '../logger';
|
||||
import SlackAppAddon from './slack-app';
|
||||
import { IFlagResolver } from '../types';
|
||||
|
||||
export interface IAddonProviders {
|
||||
[key: string]: Addon;
|
||||
@ -12,14 +14,28 @@ export interface IAddonProviders {
|
||||
export const getAddons: (args: {
|
||||
getLogger: LogProvider;
|
||||
unleashUrl: string;
|
||||
newFeatureLink?: boolean;
|
||||
}) => IAddonProviders = ({ getLogger, unleashUrl }) => {
|
||||
const addons = [
|
||||
flagResolver: IFlagResolver;
|
||||
}) => IAddonProviders = ({ getLogger, unleashUrl, flagResolver }) => {
|
||||
const slackAppAddonEnabled = flagResolver.isEnabled('slackAppAddon');
|
||||
|
||||
const slackAddon = new SlackAddon({ getLogger, unleashUrl });
|
||||
|
||||
if (slackAppAddonEnabled) {
|
||||
slackAddon.definition.deprecated =
|
||||
'This addon is deprecated. Please try the new Slack App addon instead.';
|
||||
}
|
||||
|
||||
const addons: Addon[] = [
|
||||
new Webhook({ getLogger }),
|
||||
new SlackAddon({ getLogger, unleashUrl }),
|
||||
slackAddon,
|
||||
new TeamsAddon({ getLogger, unleashUrl }),
|
||||
new DatadogAddon({ getLogger, unleashUrl }),
|
||||
];
|
||||
|
||||
if (slackAppAddonEnabled) {
|
||||
addons.push(new SlackAppAddon({ getLogger, unleashUrl }));
|
||||
}
|
||||
|
||||
return addons.reduce((map, addon) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
map[addon.name] = addon;
|
||||
|
7
src/lib/addons/installation-definition-schema.ts
Normal file
7
src/lib/addons/installation-definition-schema.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import joi from 'joi';
|
||||
|
||||
export const installationDefinitionSchema = joi.object().keys({
|
||||
url: joi.string().uri({ scheme: [/https?/] }),
|
||||
title: joi.string().optional(),
|
||||
helpText: joi.string().optional(),
|
||||
});
|
77
src/lib/addons/slack-app-definition.ts
Normal file
77
src/lib/addons/slack-app-definition.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import {
|
||||
FEATURE_CREATED,
|
||||
FEATURE_UPDATED,
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_REVIVED,
|
||||
FEATURE_STALE_ON,
|
||||
FEATURE_STALE_OFF,
|
||||
FEATURE_ENVIRONMENT_ENABLED,
|
||||
FEATURE_ENVIRONMENT_DISABLED,
|
||||
FEATURE_STRATEGY_REMOVE,
|
||||
FEATURE_STRATEGY_UPDATE,
|
||||
FEATURE_STRATEGY_ADD,
|
||||
FEATURE_METADATA_UPDATED,
|
||||
FEATURE_PROJECT_CHANGE,
|
||||
FEATURE_VARIANTS_UPDATED,
|
||||
} from '../types/events';
|
||||
import { IAddonDefinition } from '../types/model';
|
||||
|
||||
const slackAppDefinition: IAddonDefinition = {
|
||||
name: 'slack-app',
|
||||
displayName: 'Slack App',
|
||||
description:
|
||||
'The Unleash Slack App posts messages to your Slack workspace. You can decide which channels to post to by configuring your feature toggles with "slack" tags. For example, if you\'d like the bot to post messages to the #general channel, you should configure your feature toggle with the "slack:general" tag.',
|
||||
documentationUrl: 'https://docs.getunleash.io/docs/addons/slack-app',
|
||||
alerts: [
|
||||
{
|
||||
type: 'info',
|
||||
text: `The Unleash Slack App bot has access to public channels by default. If you want the bot to post messages to private channels, you'll need to invite it to those channels.`,
|
||||
},
|
||||
{
|
||||
type: 'warning',
|
||||
text: `Please ensure you have the Unleash Slack App installed in your Slack workspace if you haven't installed it already.`,
|
||||
},
|
||||
],
|
||||
installation: {
|
||||
url: 'https://unleash-slack-app.vercel.app/install',
|
||||
title: 'Slack App installation',
|
||||
helpText:
|
||||
'After installing the Unleash Slack app in your Slack workspace, paste the access token into the appropriate field below in order to configure this addon.',
|
||||
},
|
||||
parameters: [
|
||||
{
|
||||
name: 'accessToken',
|
||||
displayName: 'Access token',
|
||||
description: '(Required)',
|
||||
type: 'text',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
events: [
|
||||
FEATURE_CREATED,
|
||||
FEATURE_UPDATED,
|
||||
FEATURE_ARCHIVED,
|
||||
FEATURE_REVIVED,
|
||||
FEATURE_STALE_ON,
|
||||
FEATURE_STALE_OFF,
|
||||
FEATURE_ENVIRONMENT_ENABLED,
|
||||
FEATURE_ENVIRONMENT_DISABLED,
|
||||
FEATURE_STRATEGY_REMOVE,
|
||||
FEATURE_STRATEGY_UPDATE,
|
||||
FEATURE_STRATEGY_ADD,
|
||||
FEATURE_METADATA_UPDATED,
|
||||
FEATURE_VARIANTS_UPDATED,
|
||||
FEATURE_PROJECT_CHANGE,
|
||||
],
|
||||
tagTypes: [
|
||||
{
|
||||
name: 'slack',
|
||||
description:
|
||||
'Slack tag used by the slack-addon to specify the slack channel.',
|
||||
icon: 'S',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default slackAppDefinition;
|
91
src/lib/addons/slack-app.ts
Normal file
91
src/lib/addons/slack-app.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { WebClient } from '@slack/web-api';
|
||||
import Addon from './addon';
|
||||
|
||||
import slackAppDefinition from './slack-app-definition';
|
||||
import { IAddonConfig } from '../types/model';
|
||||
|
||||
import {
|
||||
FeatureEventFormatter,
|
||||
FeatureEventFormatterMd,
|
||||
LinkStyle,
|
||||
} from './feature-event-formatter-md';
|
||||
import { IEvent } from '../types/events';
|
||||
|
||||
interface ISlackAppAddonParameters {
|
||||
accessToken: string;
|
||||
}
|
||||
export default class SlackAppAddon extends Addon {
|
||||
private msgFormatter: FeatureEventFormatter;
|
||||
|
||||
private slackClient?: WebClient;
|
||||
|
||||
constructor(args: IAddonConfig) {
|
||||
super(slackAppDefinition, args);
|
||||
this.msgFormatter = new FeatureEventFormatterMd(
|
||||
args.unleashUrl,
|
||||
LinkStyle.SLACK,
|
||||
);
|
||||
}
|
||||
|
||||
async handleEvent(
|
||||
event: IEvent,
|
||||
parameters: ISlackAppAddonParameters,
|
||||
): Promise<void> {
|
||||
const { accessToken } = parameters;
|
||||
|
||||
if (!accessToken) return;
|
||||
|
||||
if (!this.slackClient) {
|
||||
this.slackClient = new WebClient(accessToken);
|
||||
}
|
||||
|
||||
const slackChannels = await this.slackClient.conversations.list({
|
||||
types: 'public_channel,private_channel',
|
||||
});
|
||||
const taggedChannels = this.findTaggedChannels(event);
|
||||
|
||||
if (slackChannels.channels?.length && taggedChannels.length) {
|
||||
const slackChannelsToPostTo = slackChannels.channels.filter(
|
||||
({ id, name }) => id && name && taggedChannels.includes(name),
|
||||
);
|
||||
|
||||
const text = this.msgFormatter.format(event);
|
||||
const featureLink = this.msgFormatter.featureLink(event);
|
||||
|
||||
const requests = slackChannelsToPostTo.map(({ id }) =>
|
||||
this.slackClient!.chat.postMessage({
|
||||
channel: id!,
|
||||
text,
|
||||
attachments: [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
name: 'featureToggle',
|
||||
text: 'Open in Unleash',
|
||||
type: 'button',
|
||||
value: 'featureToggle',
|
||||
style: 'primary',
|
||||
url: featureLink,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(requests);
|
||||
this.logger.info(`Handled event ${event.type}.`);
|
||||
}
|
||||
}
|
||||
|
||||
findTaggedChannels({ tags }: Pick<IEvent, 'tags'>): string[] {
|
||||
if (tags) {
|
||||
return tags
|
||||
.filter((tag) => tag.type === 'slack')
|
||||
.map((t) => t.value);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SlackAppAddon;
|
@ -119,6 +119,66 @@ export const addonTypeSchema = {
|
||||
'feature-project-change',
|
||||
],
|
||||
},
|
||||
installation: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['url'],
|
||||
description: 'The installation configuration for this addon type.',
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
description:
|
||||
'A URL to where the addon configuration should redirect to install addons of this type.',
|
||||
example: 'https://unleash-slack-app.vercel.app/install',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The title of the installation configuration. This will be displayed to the user when installing addons of this type.',
|
||||
example: 'Slack App installation',
|
||||
},
|
||||
helpText: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The help text of the installation configuration. This will be displayed to the user when installing addons of this type.',
|
||||
example:
|
||||
'Clicking the Install button will send you to Slack to initiate the installation procedure for the Unleash Slack app for your workspace',
|
||||
},
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
type: 'array',
|
||||
description:
|
||||
'A list of alerts to display to the user when installing addons of this type.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['type', 'text'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['success', 'info', 'warning', 'error'],
|
||||
description:
|
||||
'The type of alert. This determines the color of the alert.',
|
||||
example: 'info',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The text of the alert. This is what will be displayed to the user.',
|
||||
example:
|
||||
"Please ensure you have the Unleash Slack App installed in your Slack workspace if you haven't installed it already. If you want the Unleash Slack App bot to post messages to private channels, you'll need to invite it to those channels.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
deprecated: {
|
||||
type: 'string',
|
||||
description:
|
||||
'This should be used to inform the user that this addon type is deprecated and should not be used. Deprecated addons will show a badge with this information on the UI.',
|
||||
example:
|
||||
'This addon is deprecated. Please try the new addon instead.',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
|
@ -49,7 +49,11 @@ export default class AddonService {
|
||||
IUnleashStores,
|
||||
'addonStore' | 'eventStore' | 'featureToggleStore'
|
||||
>,
|
||||
{ getLogger, server }: Pick<IUnleashConfig, 'getLogger' | 'server'>,
|
||||
{
|
||||
getLogger,
|
||||
server,
|
||||
flagResolver,
|
||||
}: Pick<IUnleashConfig, 'getLogger' | 'server' | 'flagResolver'>,
|
||||
tagTypeService: TagTypeService,
|
||||
addons?: IAddonProviders,
|
||||
) {
|
||||
@ -64,6 +68,7 @@ export default class AddonService {
|
||||
getAddons({
|
||||
getLogger,
|
||||
unleashUrl: server.unleashUrl,
|
||||
flagResolver,
|
||||
});
|
||||
this.sensitiveParams = this.loadSensitiveParams(this.addonProviders);
|
||||
if (addonStore) {
|
||||
|
@ -249,9 +249,23 @@ export interface IAddonDefinition {
|
||||
displayName: string;
|
||||
documentationUrl: string;
|
||||
description: string;
|
||||
deprecated?: string;
|
||||
parameters?: IAddonParameterDefinition[];
|
||||
events?: string[];
|
||||
tagTypes?: ITagType[];
|
||||
installation?: IAddonInstallation;
|
||||
alerts?: IAddonAlert[];
|
||||
}
|
||||
|
||||
export interface IAddonInstallation {
|
||||
url: string;
|
||||
title?: string;
|
||||
helpText?: string;
|
||||
}
|
||||
|
||||
export interface IAddonAlert {
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface IAddonConfig {
|
||||
|
@ -43,6 +43,7 @@ process.nextTick(async () => {
|
||||
strategyVariant: true,
|
||||
newProjectLayout: true,
|
||||
emitPotentiallyStaleEvents: true,
|
||||
slackAppAddon: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
@ -13,6 +13,7 @@ beforeAll(async () => {
|
||||
experimental: {
|
||||
flags: {
|
||||
strictSchemaValidation: true,
|
||||
slackAppAddon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -32,7 +33,7 @@ test('gets all addons', async () => {
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.addons.length).toBe(0);
|
||||
expect(res.body.providers.length).toBe(4);
|
||||
expect(res.body.providers.length).toBe(5);
|
||||
expect(res.body.providers[0].name).toBe('webhook');
|
||||
});
|
||||
});
|
||||
|
108
yarn.lock
108
yarn.lock
@ -1041,6 +1041,35 @@
|
||||
dependencies:
|
||||
"@sinonjs/commons" "^2.0.0"
|
||||
|
||||
"@slack/logger@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@slack/logger/-/logger-3.0.0.tgz#b736d4e1c112c22a10ffab0c2d364620aedcb714"
|
||||
integrity sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==
|
||||
dependencies:
|
||||
"@types/node" ">=12.0.0"
|
||||
|
||||
"@slack/types@^2.0.0":
|
||||
version "2.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@slack/types/-/types-2.8.0.tgz#11ea10872262a7e6f86f54e5bcd4f91e3a41fe91"
|
||||
integrity sha512-ghdfZSF0b4NC9ckBA8QnQgC9DJw2ZceDq0BIjjRSv6XAZBXJdWgxIsYz0TYnWSiqsKZGH2ZXbj9jYABZdH3OSQ==
|
||||
|
||||
"@slack/web-api@^6.8.1":
|
||||
version "6.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@slack/web-api/-/web-api-6.8.1.tgz#c6c1e7405c884c4d9048f8b1d3901bd138d00610"
|
||||
integrity sha512-eMPk2S99S613gcu7odSw/LV+Qxr8A+RXvBD0GYW510wJuTERiTjP5TgCsH8X09+lxSumbDE88wvWbuFuvGa74g==
|
||||
dependencies:
|
||||
"@slack/logger" "^3.0.0"
|
||||
"@slack/types" "^2.0.0"
|
||||
"@types/is-stream" "^1.1.0"
|
||||
"@types/node" ">=12.0.0"
|
||||
axios "^0.27.2"
|
||||
eventemitter3 "^3.1.0"
|
||||
form-data "^2.5.0"
|
||||
is-electron "2.2.0"
|
||||
is-stream "^1.1.0"
|
||||
p-queue "^6.6.1"
|
||||
p-retry "^4.0.0"
|
||||
|
||||
"@swc/core-darwin-arm64@1.3.67":
|
||||
version "1.3.67"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.67.tgz#8076dcd75862b92a7987a8da5a24986ab559d793"
|
||||
@ -1267,6 +1296,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/hash-sum/-/hash-sum-1.0.0.tgz#838f4e8627887d42b162d05f3d96ca636c2bc504"
|
||||
integrity sha512-FdLBT93h3kcZ586Aee66HPCVJ6qvxVjBlDWNmxSGSbCZe9hTsjRKdSsl4y1T+3zfujxo9auykQMnFsfyHWD7wg==
|
||||
|
||||
"@types/is-stream@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/is-stream/-/is-stream-1.1.0.tgz#b84d7bb207a210f2af9bed431dc0fbe9c4143be1"
|
||||
integrity sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
|
||||
@ -1363,6 +1399,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.37.tgz#a1f8728e4dc30163deb41e9b7aba65d0c2d4eda1"
|
||||
integrity sha512-ql+4dw4PlPFBP495k8JzUX/oMNRI2Ei4PrMHgj8oT4VhGlYUzF4EYr0qk2fW+XBVGIrq8Zzk13m4cvyXZuv4pA==
|
||||
|
||||
"@types/node@>=12.0.0":
|
||||
version "20.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.1.tgz#a6033a8718653c50ac4962977e14d0f984d9527d"
|
||||
integrity sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==
|
||||
|
||||
"@types/nodemailer@6.4.8":
|
||||
version "6.4.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.8.tgz#f06c661e9b201fc2acc3a00a0fded42ba7eaca9d"
|
||||
@ -1409,6 +1450,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a"
|
||||
integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==
|
||||
|
||||
"@types/retry@0.12.0":
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
|
||||
integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
|
||||
|
||||
"@types/semver@7.5.0":
|
||||
version "7.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.0.tgz#591c1ce3a702c45ee15f47a42ade72c2fd78978a"
|
||||
@ -3174,6 +3220,16 @@ event-stream@=3.3.4:
|
||||
stream-combiner "~0.0.4"
|
||||
through "~2.3.1"
|
||||
|
||||
eventemitter3@^3.1.0:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
|
||||
integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==
|
||||
|
||||
eventemitter3@^4.0.4:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
|
||||
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
|
||||
|
||||
execa@^5.0.0:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
|
||||
@ -3504,6 +3560,15 @@ forever-agent@~0.6.1:
|
||||
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==
|
||||
|
||||
form-data@^2.5.0:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4"
|
||||
integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==
|
||||
dependencies:
|
||||
asynckit "^0.4.0"
|
||||
combined-stream "^1.0.6"
|
||||
mime-types "^2.1.12"
|
||||
|
||||
form-data@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f"
|
||||
@ -4110,6 +4175,11 @@ is-date-object@^1.0.1:
|
||||
dependencies:
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-electron@2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/is-electron/-/is-electron-2.2.0.tgz#8943084f09e8b731b3a7a0298a7b5d56f6b7eef0"
|
||||
integrity sha512-SpMppC2XR3YdxSzczXReBjqs2zGscWQpBIKqwXYBFic0ERaxNVgwLCHwOLZeESfdJQjX0RDvrJ1lBXX2ij+G1Q==
|
||||
|
||||
is-extendable@^0.1.1:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
|
||||
@ -4221,6 +4291,11 @@ is-shared-array-buffer@^1.0.2:
|
||||
dependencies:
|
||||
call-bind "^1.0.2"
|
||||
|
||||
is-stream@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
|
||||
integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==
|
||||
|
||||
is-stream@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
||||
@ -5724,6 +5799,11 @@ owasp-password-strength-test@^1.3.0:
|
||||
resolved "https://registry.yarnpkg.com/owasp-password-strength-test/-/owasp-password-strength-test-1.3.0.tgz#4f629e42903e8f6d279b230d657ab61e58e44b12"
|
||||
integrity sha512-33/Z+vyjlFaVZsT7aAFe3SkQZdU6su59XNkYdU5o2Fssz0D9dt6uiFaMm62M7dFQSKogULq8UYvdKnHkeqNB2w==
|
||||
|
||||
p-finally@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
|
||||
integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==
|
||||
|
||||
p-limit@^2.2.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
|
||||
@ -5766,6 +5846,29 @@ p-map@^5.5.0:
|
||||
dependencies:
|
||||
aggregate-error "^4.0.0"
|
||||
|
||||
p-queue@^6.6.1:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426"
|
||||
integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==
|
||||
dependencies:
|
||||
eventemitter3 "^4.0.4"
|
||||
p-timeout "^3.2.0"
|
||||
|
||||
p-retry@^4.0.0:
|
||||
version "4.6.2"
|
||||
resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16"
|
||||
integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==
|
||||
dependencies:
|
||||
"@types/retry" "0.12.0"
|
||||
retry "^0.13.1"
|
||||
|
||||
p-timeout@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe"
|
||||
integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==
|
||||
dependencies:
|
||||
p-finally "^1.0.0"
|
||||
|
||||
p-try@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
|
||||
@ -6425,6 +6528,11 @@ retry@^0.12.0:
|
||||
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
|
||||
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
|
||||
|
||||
retry@^0.13.1:
|
||||
version "0.13.1"
|
||||
resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658"
|
||||
integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==
|
||||
|
||||
reusify@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
|
||||
|
Loading…
Reference in New Issue
Block a user