From bc08c7da2d28ea6fa57b48119df9c3b4564fd5bc Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 20 Oct 2019 22:57:06 +0200 Subject: [PATCH 1/2] :sparkles: Add basic Mattermost-Node --- .../credentials/MattermostApi.credentials.ts | 24 + .../nodes/Mattermost/GenericFunctions.ts | 59 +++ .../nodes/Mattermost/Mattermost.node.ts | 444 ++++++++++++++++++ .../nodes/Mattermost/mattermost.png | Bin 0 -> 1307 bytes packages/nodes-base/package.json | 2 + 5 files changed, 529 insertions(+) create mode 100644 packages/nodes-base/credentials/MattermostApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Mattermost/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Mattermost/Mattermost.node.ts create mode 100644 packages/nodes-base/nodes/Mattermost/mattermost.png diff --git a/packages/nodes-base/credentials/MattermostApi.credentials.ts b/packages/nodes-base/credentials/MattermostApi.credentials.ts new file mode 100644 index 0000000000..65a5e0a900 --- /dev/null +++ b/packages/nodes-base/credentials/MattermostApi.credentials.ts @@ -0,0 +1,24 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class MattermostApi implements ICredentialType { + name = 'mattermostApi'; + displayName = 'Mattermost API'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts b/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts new file mode 100644 index 0000000000..1f7b56d95f --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts @@ -0,0 +1,59 @@ +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { OptionsWithUri } from 'request'; +import { IDataObject } from 'n8n-workflow'; + + +/** + * Make an API request to Telegram + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: IDataObject): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('mattermostApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + query = query || {}; + + const options: OptionsWithUri = { + method, + body, + qs: query, + uri: `${credentials.baseUrl}/api/v4/${endpoint}`, + headers: { + Authorization: `Bearer ${credentials.accessToken}`, + 'content-type': 'application/json; charset=utf-8' + }, + json: true + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + + if (error.statusCode === 401) { + // Return a clear error + throw new Error('The Mattermost credentials are not valid!'); + } + + if (error.response && error.response.body && error.response.body.message) { + // Try to return the error prettier + const errorBody = error.response.body; + throw new Error(`Mattermost error response: ${errorBody.message}`); + } + + // Expected error data did not get returned so throw the actual error + throw error; + } +} diff --git a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts new file mode 100644 index 0000000000..1746c7f033 --- /dev/null +++ b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts @@ -0,0 +1,444 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { + IDataObject, + ILoadOptionsFunctions, + INodeTypeDescription, + INodePropertyOptions, + INodeExecutionData, + INodeType, +} from 'n8n-workflow'; + +import { + apiRequest, +} from './GenericFunctions'; + + +export class Mattermost implements INodeType { + description: INodeTypeDescription = { + displayName: 'Mattermost', + name: 'mattermost', + icon: 'file:mattermost.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Sends data to Mattermost', + defaults: { + name: 'Mattermost', + color: '#0058CC', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'mattermostApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Channel', + value: 'channel', + }, + { + name: 'Message', + value: 'message', + }, + ], + default: 'message', + description: 'The resource to operate on.', + }, + + + + // ---------------------------------- + // operations + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'channel', + ], + }, + }, + options: [ + { + name: 'Add User', + value: 'addUser', + description: 'Add a user to a channel', + }, + { + name: 'Create', + value: 'create', + description: 'Create a new channel', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, + + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'message', + ], + }, + }, + options: [ + { + name: 'Post', + value: 'post', + description: 'Post a message into a channel', + }, + ], + default: 'post', + description: 'The operation to perform.', + }, + + + + // ---------------------------------- + // channel + // ---------------------------------- + + // ---------------------------------- + // channel:create + // ---------------------------------- + { + displayName: 'Team ID', + name: 'teamId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTeams', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'create' + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The Mattermost Team.', + }, + { + displayName: 'Display Name', + name: 'displayName', + type: 'string', + default: '', + placeholder: 'Announcements', + displayOptions: { + show: { + operation: [ + 'create' + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The non-unique UI name for the channel.', + }, + { + displayName: 'Name', + name: 'channel', + type: 'string', + default: '', + placeholder: 'announcements', + displayOptions: { + show: { + operation: [ + 'create' + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The unique handle for the channel, will be present in the channel URL.', + }, + + // ---------------------------------- + // channel:addUser + // ---------------------------------- + { + displayName: 'Channel ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannels', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'addUser' + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The ID of the channel to invite user to.', + }, + { + displayName: 'User ID', + name: 'userId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'addUser' + ], + resource: [ + 'channel', + ], + }, + }, + description: 'The ID of the user to invite into channel.', + }, + + + + // ---------------------------------- + // message + // ---------------------------------- + + // ---------------------------------- + // message:post + // ---------------------------------- + { + displayName: 'Channel ID', + name: 'channelId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getChannels', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'post' + ], + resource: [ + 'message', + ], + }, + }, + description: 'The ID of the channel to post to.', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + operation: [ + 'post' + ], + resource: [ + 'message', + ], + }, + }, + description: 'The text to send.', + }, + ], + }; + + methods = { + loadOptions: { + // Get all the available workspaces to display them to user so that he can + // select them easily + async getChannels(this: ILoadOptionsFunctions): Promise { + const endpoint = 'channels'; + const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + + if (responseData === undefined) { + throw new Error('No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + let name: string; + for (const data of responseData) { + if (data.delete_at !== 0) { + continue; + } + + name = `${data.name} (${data.type === 'O' ? 'public' : 'private'})`; + + returnData.push({ + name, + value: data.id, + }); + } + + return returnData; + }, + + + + async getTeams(this: ILoadOptionsFunctions): Promise { + const endpoint = 'teams'; + const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + + if (responseData === undefined) { + throw new Error('No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + let name: string; + for (const data of responseData) { + + if (data.delete_at !== 0) { + continue; + } + + name = `${data.display_name} (${data.type === 'O' ? 'public' : 'private'})`; + + returnData.push({ + name, + value: data.id, + }); + } + + return returnData; + }, + async getUsers(this: ILoadOptionsFunctions): Promise { + const endpoint = 'users'; + const responseData = await apiRequest.call(this, 'GET', endpoint, {}); + + if (responseData === undefined) { + throw new Error('No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + for (const data of responseData) { + + if (data.delete_at !== 0) { + continue; + } + + returnData.push({ + name: data.username, + value: data.id, + }); + } + + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + const credentials = this.getCredentials('mattermostApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let operation: string; + let resource: string; + let requestMethod = 'POST'; + + // For Post + let body: IDataObject; + // For Query string + let qs: IDataObject; + + for (let i = 0; i < items.length; i++) { + let endpoint = ''; + body = {}; + qs = {}; + + resource = this.getNodeParameter('resource', i) as string; + operation = this.getNodeParameter('operation', i) as string; + + if (resource === 'channel') { + if (operation === 'create') { + // ---------------------------------- + // channel:create + // ---------------------------------- + + requestMethod = 'POST'; + endpoint = 'channels'; + + body.team_id = this.getNodeParameter('teamId', i) as string; + body.displayName = this.getNodeParameter('displayName', i) as string; + body.name = this.getNodeParameter('channel', i) as string; + // TODO: Make type configurable + body.type = 'O'; + + } else if (operation === 'addUser') { + // ---------------------------------- + // channel:addUser + // ---------------------------------- + + requestMethod = 'POST'; + + const channelId = this.getNodeParameter('channelId', i) as string; + body.user_id = this.getNodeParameter('userId', i) as string; + + endpoint = `channels/${channelId}/members`; + + } + } else if (resource === 'message') { + if (operation === 'post') { + // ---------------------------------- + // message:post + // ---------------------------------- + + requestMethod = 'POST'; + endpoint = 'posts'; + + body.channel_id = this.getNodeParameter('channelId', i) as string; + body.message = this.getNodeParameter('message', i) as string; + + } + } else { + throw new Error(`The resource "${resource}" is not known!`); + } + + const responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); + returnData.push(responseData); + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Mattermost/mattermost.png b/packages/nodes-base/nodes/Mattermost/mattermost.png new file mode 100644 index 0000000000000000000000000000000000000000..628b426dbc9b7915461affdfd7a666732aa2a242 GIT binary patch literal 1307 zcmV+$1?2jPP)>Z`%1<*XXc%mGvBV5Cwa1&bNl8w*Y|zTY+6-SmGvQ^ zj`e{d790zX1;>J8!Li_4goLC}GMNnX8()FF!LDEjFcWM7ZUC2pMlGMh!ou)^1E3{1 z2^<4?ue&~`mH&?M~zNtAx4Aiz*d_4 zYvAwP5yhG^Tfr=_QEe7npGC}38*Y zqdlL=^#q%u4&`jk*|8uB!Nb9JiuCQm`VIN0Y9c%Z+^`$GZhIcW7MfAP;}mHRz`um` z=PH&%P{B0H2OP)iD3gL|bowaLX)XOHsGq?GgJnoC;e_LPs6>xcaA)>}oVX#ZPd4!9 zh6NY!NWVe0X2}H-oUKWJD6F4l`Gdp)eCY7HJ_S3O65K(P{!&=quQPv7Q0A$^r~cBE z;I=Lfgaj9S3O*##x^Sf;VWqChiV>#EVcZc6YSkF%6^%*`nhyq;}ym2F*S(p%c+kxy%C zdBkGpgJU&$drZ5By97PBh~rFAfSmkQE{?=0(}M4@XfIH>heEhb4GE>_2wmZ#XVdmC`ZotNzKvW2i?KLmO8X#->h~-5#k|GTzH8OmyoQcqgM58VPhY@a9<7` z(KPMvQ9f5V2(OVQ`4Z1@Ax=|_?st3p?5uym)8H9CKJN2Tm*CDwatNp&*iqQ#7VyWn zJP(fx@gRcV*wAMUtW)xn>ss%|xh2Tw&MEUFIOtVNA=}W!Aq6J_ioL@7M48`;VZH!3 z6ZA(zg6w(<8Q%_4IqWA7u4*lvE3gFl&%pvr*l&~bH;*Xlr?MMeP_?i R`ttw)002ovPDHLkV1m^ScAfwL literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a6485c3689..ee6136855d 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -40,6 +40,7 @@ "dist/credentials/Imap.credentials.js", "dist/credentials/LinkFishApi.credentials.js", "dist/credentials/MailgunApi.credentials.js", + "dist/credentials/MattermostApi.credentials.js", "dist/credentials/NextCloudApi.credentials.js", "dist/credentials/OpenWeatherMapApi.credentials.js", "dist/credentials/PipedriveApi.credentials.js", @@ -79,6 +80,7 @@ "dist/nodes/Interval.node.js", "dist/nodes/LinkFish/LinkFish.node.js", "dist/nodes/Mailgun/Mailgun.node.js", + "dist/nodes/Mattermost/Mattermost.node.js", "dist/nodes/Merge.node.js", "dist/nodes/NextCloud/NextCloud.node.js", "dist/nodes/NoOp.node.js", From 6da0cad9cc73307325f345083cda1d32858e5328 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 22 Oct 2019 00:12:35 +0200 Subject: [PATCH 2/2] :sparkles: Add additional functionality to Mattermost-Node --- .../nodes/Mattermost/GenericFunctions.ts | 7 + .../nodes/Mattermost/Mattermost.node.ts | 274 +++++++++++++++++- 2 files changed, 279 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts b/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts index 1f7b56d95f..bb08acdf76 100644 --- a/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Mattermost/GenericFunctions.ts @@ -8,6 +8,13 @@ import { OptionsWithUri } from 'request'; import { IDataObject } from 'n8n-workflow'; +export interface IAttachment { + fields: { + item?: object[]; + }; +} + + /** * Make an API request to Telegram * diff --git a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts index 1746c7f033..064c97c5f1 100644 --- a/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts +++ b/packages/nodes-base/nodes/Mattermost/Mattermost.node.ts @@ -10,6 +10,7 @@ import { import { apiRequest, + IAttachment, } from './GenericFunctions'; @@ -176,6 +177,34 @@ export class Mattermost implements INodeType { required: true, description: 'The unique handle for the channel, will be present in the channel URL.', }, + { + displayName: 'Type', + name: 'type', + type: 'options', + displayOptions: { + show: { + operation: [ + 'create' + ], + resource: [ + 'channel', + ], + }, + }, + options: [ + { + name: 'Private', + value: 'private', + }, + { + name: 'Public', + value: 'public', + }, + ], + default: 'public', + description: 'The type of channel to create.', + }, + // ---------------------------------- // channel:addUser @@ -276,6 +305,222 @@ export class Mattermost implements INodeType { }, description: 'The text to send.', }, + { + displayName: 'Attachments', + name: 'attachments', + type: 'collection', + typeOptions: { + multipleValues: true, + multipleValueButtonText: 'Add attachment', + }, + displayOptions: { + show: { + operation: [ + 'post' + ], + resource: [ + 'message', + ], + }, + }, + default: {}, + description: 'The attachment to add', + placeholder: 'Add attachment item', + options: [ + { + displayName: 'Fallback Text', + name: 'fallback', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Required plain-text summary of the attachment.', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Text to send.', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Title of the message.', + }, + { + displayName: 'Title Link', + name: 'title_link', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Link of the title.', + }, + { + displayName: 'Color', + name: 'color', + type: 'color', + default: '#ff0000', + description: 'Color of the line left of text.', + }, + { + displayName: 'Pretext', + name: 'pretext', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Text which appears before the message block.', + }, + { + displayName: 'Author Name', + name: 'author_name', + type: 'string', + default: '', + description: 'Name that should appear.', + }, + { + displayName: 'Author Link', + name: 'author_link', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Link for the author.', + }, + { + displayName: 'Author Icon', + name: 'author_icon', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Icon which should appear for the user.', + }, + { + displayName: 'Image URL', + name: 'image_url', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'URL of image.', + }, + { + displayName: 'Thumbnail URL', + name: 'thumb_url', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'URL of thumbnail.', + }, + { + displayName: 'Footer', + name: 'footer', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Text of footer to add.', + }, + { + displayName: 'Footer Icon', + name: 'footer_icon', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Icon which should appear next to footer.', + }, + { + displayName: 'Fields', + name: 'fields', + placeholder: 'Add Fields', + description: 'Fields to add to message.', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'item', + displayName: 'Item', + values: [ + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + description: 'Title of the item.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value of the item.', + }, + { + displayName: 'Short', + name: 'short', + type: 'boolean', + default: true, + description: 'If items can be displayed next to each other.', + }, + ] + }, + ], + } + ], + }, + { + displayName: 'Other Options', + name: 'otherOptions', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'post' + ], + resource: [ + 'message', + ], + }, + }, + default: {}, + description: 'Other options to set', + placeholder: 'Add options', + options: [ + { + displayName: 'Make Comment', + name: 'root_id', + type: 'string', + default: '', + description: 'The post ID to comment on', + }, + ], + }, + ], }; @@ -402,8 +647,9 @@ export class Mattermost implements INodeType { body.team_id = this.getNodeParameter('teamId', i) as string; body.displayName = this.getNodeParameter('displayName', i) as string; body.name = this.getNodeParameter('channel', i) as string; - // TODO: Make type configurable - body.type = 'O'; + + const type = this.getNodeParameter('type', i) as string; + body.type = type === 'public' ? 'O' : 'P'; } else if (operation === 'addUser') { // ---------------------------------- @@ -430,6 +676,30 @@ export class Mattermost implements INodeType { body.channel_id = this.getNodeParameter('channelId', i) as string; body.message = this.getNodeParameter('message', i) as string; + const attachments = this.getNodeParameter('attachments', i, []) as unknown as IAttachment[]; + + // The node does save the fields data differently than the API + // expects so fix the data befre we send the request + for (const attachment of attachments) { + if (attachment.fields !== undefined) { + if (attachment.fields.item !== undefined) { + // Move the field-content up + // @ts-ignore + attachment.fields = attachment.fields.item; + } else { + // If it does not have any items set remove it + delete attachment.fields; + } + } + } + + body.props = { + attachments, + }; + + // Add all the other options to the request + const otherOptions = this.getNodeParameter('otherOptions', i) as IDataObject; + Object.assign(body, otherOptions); } } else { throw new Error(`The resource "${resource}" is not known!`);