diff --git a/packages/nodes-base/credentials/TwistOAuth2Api.credentials.ts b/packages/nodes-base/credentials/TwistOAuth2Api.credentials.ts new file mode 100644 index 0000000000..850a53dabc --- /dev/null +++ b/packages/nodes-base/credentials/TwistOAuth2Api.credentials.ts @@ -0,0 +1,55 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes = [ + 'attachments:write', + 'channels:remove', + 'messages:remove', + 'workspaces:read', +]; + +export class TwistOAuth2Api implements ICredentialType { + name = 'twistOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Twist OAuth2 API'; + documentationUrl = 'twist'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://twist.com/oauth/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://twist.com/oauth/access_token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(','), + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + description: 'Resource to consume.', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Twist/ChannelDescription.ts b/packages/nodes-base/nodes/Twist/ChannelDescription.ts new file mode 100644 index 0000000000..6412125330 --- /dev/null +++ b/packages/nodes-base/nodes/Twist/ChannelDescription.ts @@ -0,0 +1,428 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const channelOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'channel', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Initiates a public or private channel-based conversation', + }, + { + name: 'Get', + value: 'get', + description: 'Get information about a channel', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all channels', + }, + { + name: 'Update', + value: 'update', + description: 'Update a channel', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const channelFields = [ + /*-------------------------------------------------------------------------- */ + /* channel:create */ + /* ------------------------------------------------------------------------- */ + { + displayName: 'Workspace ID', + name: 'workspaceId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getWorkspaces', + }, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The id of the workspace.', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The name of the channel.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'channel', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Color', + name: 'color', + type: 'options', + options: [ + { + name: 'Berry Red', + value: 6, + }, + { + name: 'Blue', + value: 1, + }, + { + name: 'Green', + value: 4, + }, + { + name: 'Grey', + value: 0, + }, + { + name: 'Magenta', + value: 7, + }, + { + name: 'Mint Green', + value: 9, + }, + { + name: 'Red', + value: 5, + }, + { + name: 'Salmon', + value: 11, + }, + { + name: 'Sky Blue', + value: 8, + }, + { + name: 'Teal Blue', + value: 3, + }, + { + name: 'Turquoise', + value: 2, + }, + { + name: 'Yellow', + value: 10, + }, + ], + default: 0, + description: 'The color of the channel', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'The description of the channel', + }, + { + displayName: 'Public', + name: 'public', + type: 'boolean', + default: false, + description: 'If enabled, the channel will be marked as public', + }, + { + displayName: 'Temp ID', + name: 'temp_id', + type: 'number', + default: -1, + description: 'The temporary id of the channel. It needs to be a negative number.', + }, + { + displayName: 'User IDs', + name: 'user_ids', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getUsers', + loadOptionsDependsOn: [ + 'workspaceId', + ], + }, + default: [], + description: 'The users that will participate in the channel.', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* channel:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'string', + default: '', + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The ID of the channel', + }, + /* -------------------------------------------------------------------------- */ + /* channel:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Workspace ID', + name: 'workspaceId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getWorkspaces', + }, + default: '', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The ID of the workspace.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'channel', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'channel', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return.', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'channel', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Archived', + name: 'archived', + type: 'boolean', + default: false, + description: 'If enabled, only archived conversations are returned', + }, + ], + }, + + /* -------------------------------------------------------------------------- */ + /* channel:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Channel ID', + name: 'channelId', + type: 'string', + default: '', + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'channel', + ], + }, + }, + required: true, + description: 'The ID of the channel.', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'channel', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Color', + name: 'color', + type: 'options', + options: [ + { + name: 'Berry Red', + value: 6, + }, + { + name: 'Blue', + value: 1, + }, + { + name: 'Green', + value: 4, + }, + { + name: 'Grey', + value: 0, + }, + { + name: 'Magenta', + value: 7, + }, + { + name: 'Mint Green', + value: 9, + }, + { + name: 'Red', + value: 5, + }, + { + name: 'Salmon', + value: 11, + }, + { + name: 'Sky Blue', + value: 8, + }, + { + name: 'Teal Blue', + value: 3, + }, + { + name: 'Turquoise', + value: 2, + }, + { + name: 'Yellow', + value: 10, + }, + ], + default: 0, + description: 'The color of the channel', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'The description of the channel', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'The name of the channel', + }, + { + displayName: 'Public', + name: 'public', + type: 'boolean', + default: false, + description: 'If enabled, the channel will be marked as public', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Twist/GenericFunctions.ts b/packages/nodes-base/nodes/Twist/GenericFunctions.ts new file mode 100644 index 0000000000..8a633ea5f0 --- /dev/null +++ b/packages/nodes-base/nodes/Twist/GenericFunctions.ts @@ -0,0 +1,50 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function twistApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const options: OptionsWithUri = { + method, + body, + qs, + uri: `https://api.twist.com/api/v3${endpoint}`, + json: true, + }; + + if (Object.keys(body).length === 0) { + delete options.body; + } + + if (Object.keys(qs).length === 0) { + delete options.qs; + } + + Object.assign(options, option); + + try { + //@ts-ignore + return await this.helpers.requestOAuth2.call(this, 'twistOAuth2Api', options); + + } catch (error) { + if (error.response && error.response.body && error.response.body.error_string) { + + const message = error.response.body.error_string; + + // Try to return the error prettier + throw new Error( + `Twist error response [${error.statusCode}]: ${message}`, + ); + } + throw error; + } +} diff --git a/packages/nodes-base/nodes/Twist/MessageConversationDescription.ts b/packages/nodes-base/nodes/Twist/MessageConversationDescription.ts new file mode 100644 index 0000000000..d81f1254dc --- /dev/null +++ b/packages/nodes-base/nodes/Twist/MessageConversationDescription.ts @@ -0,0 +1,230 @@ +import { + INodeProperties +} from 'n8n-workflow'; + +export const messageConversationOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'messageConversation', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a message in a conversation', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const messageConversationFields = [ + + /* -------------------------------------------------------------------------- */ + /* messageConversation:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Workspace ID', + name: 'workspaceId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getWorkspaces', + }, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'messageConversation', + ], + }, + }, + required: true, + description: 'The ID of the workspace.', + }, + { + displayName: 'Conversation ID', + name: 'conversationId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getConversations', + loadOptionsDependsOn: [ + 'workspaceId', + ], + }, + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'messageConversation', + ], + }, + }, + required: true, + description: 'The ID of the conversation.', + }, + { + displayName: 'Content', + name: 'content', + type: 'string', + default: '', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'messageConversation', + ], + }, + }, + description: `The content of the new message. Mentions can be used as [Name](twist-mention://user_id) for users or [Group name](twist-group-mention://group_id) for groups.`, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'create', + ], + resource: [ + 'messageConversation', + ], + }, + }, + default: {}, + description: 'Other options to set', + placeholder: 'Add options', + options: [ + { + displayName: 'Actions', + name: 'actionsUi', + type: 'fixedCollection', + placeholder: 'Add Action', + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Action', + name: 'actionValues', + values: [ + { + displayName: 'Action', + name: 'action', + type: 'options', + description: 'The action of the button', + options: [ + { + name: 'Open URL', + value: 'open_url', + }, + { + name: 'Prefill Message', + value: 'prefill_message', + }, + { + name: 'Send Reply', + value: 'send_reply', + }, + ], + default: '', + }, + { + displayName: 'Button Text', + name: 'button_text', + type: 'string', + description: 'The text for the action button.', + default: '', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + displayOptions: { + show: { + action: [ + 'send_reply', + 'prefill_message', + ], + }, + }, + description: 'The text for the action button.', + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The type of the button, for now just action is available.', + options: [ + { + name: 'Action', + value: 'action', + }, + ], + default: '', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + displayOptions: { + show: { + action: [ + 'open_url', + ], + }, + }, + description: 'URL to redirect', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Attachments', + name: 'binaryProperties', + type: 'string', + default: 'data', + description: 'Name of the property that holds the binary data. Multiple can be defined separated by comma.', + }, + { + displayName: 'Direct Mentions', + name: 'direct_mentions', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + default: [], + description: `The users that are directly mentioned`, + }, + // { + // displayName: 'Direct Group Mentions ', + // name: 'direct_group_mentions', + // type: 'multiOptions', + // typeOptions: { + // loadOptionsMethod: 'getGroups', + // }, + // default: [], + // description: `The groups that are directly mentioned`, + // }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Twist/Twist.node.ts b/packages/nodes-base/nodes/Twist/Twist.node.ts new file mode 100644 index 0000000000..f46017176d --- /dev/null +++ b/packages/nodes-base/nodes/Twist/Twist.node.ts @@ -0,0 +1,293 @@ +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryData, + IBinaryKeyData, + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + twistApiRequest, +} from './GenericFunctions'; + +import { + channelFields, + channelOperations, +} from './ChannelDescription'; + +import { + messageConversationFields, + messageConversationOperations, +} from './MessageConversationDescription'; + +import uuid = require('uuid'); + +export class Twist implements INodeType { + description: INodeTypeDescription = { + displayName: 'Twist', + name: 'twist', + icon: 'file:twist.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Twist API', + defaults: { + name: 'Twist', + color: '#316fea', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'twistOAuth2Api', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Channel', + value: 'channel', + }, + { + name: 'Message Conversation', + value: 'messageConversation', + }, + ], + default: 'messageConversation', + description: 'The resource to operate on.', + }, + ...channelOperations, + ...channelFields, + ...messageConversationOperations, + ...messageConversationFields, + ], + }; + + methods = { + loadOptions: { + // Get all the available workspaces to display them to user so that he can + // select them easily + async getWorkspaces(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const workspaces = await twistApiRequest.call(this, 'GET', '/workspaces/get'); + for (const workspace of workspaces) { + returnData.push({ + name: workspace.name, + value: workspace.id, + }); + } + + return returnData; + }, + // Get all the available conversations to display them to user so that he can + // select them easily + async getConversations(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const qs: IDataObject = { + workspace_id: this.getCurrentNodeParameter('workspaceId') as string, + }; + const conversations = await twistApiRequest.call(this, 'GET', '/conversations/get', {}, qs); + for (const conversation of conversations) { + returnData.push({ + name: conversation.title || conversation.id, + value: conversation.id, + }); + } + return returnData; + }, + + // Get all the available users to display them to user so that he can + // select them easily + async getUsers(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const qs: IDataObject = { + id: this.getCurrentNodeParameter('workspaceId') as string, + }; + const users = await twistApiRequest.call(this, 'GET', '/workspaces/get_users', {}, qs); + for (const user of users) { + returnData.push({ + name: user.name, + value: user.id, + }); + } + return returnData; + }, + + // Get all the available groups to display them to user so that he can + // select them easily + async getGroups(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const qs: IDataObject = { + workspace_id: this.getCurrentNodeParameter('workspaceId') as string, + }; + const groups = await twistApiRequest.call(this, 'GET', '/groups/get', {}, qs); + for (const group of groups) { + returnData.push({ + name: group.name, + value: group.id, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = (items.length as unknown) as number; + const qs: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + if (resource === 'channel') { + //https://developer.twist.com/v3/#add-channel + if (operation === 'create') { + const workspaceId = this.getNodeParameter('workspaceId', i) as string; + const name = this.getNodeParameter('name', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const body: IDataObject = { + workspace_id: workspaceId, + name, + }; + Object.assign(body, additionalFields); + + responseData = await twistApiRequest.call(this, 'POST', '/channels/add', body); + } + //https://developer.twist.com/v3/#get-channel + if (operation === 'get') { + const channelId = this.getNodeParameter('channelId', i) as string; + qs.id = channelId; + + responseData = await twistApiRequest.call(this, 'GET', '/channels/getone', {}, qs); + } + //https://developer.twist.com/v3/#get-all-channels + if (operation === 'getAll') { + const workspaceId = this.getNodeParameter('workspaceId', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const filters = this.getNodeParameter('filters', i) as IDataObject; + qs.workspace_id = workspaceId; + Object.assign(qs, filters); + + responseData = await twistApiRequest.call(this, 'GET', '/channels/get', {}, qs); + + if (!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + } + //https://developer.twist.com/v3/#update-channel + if (operation === 'update') { + const channelId = this.getNodeParameter('channelId', i) as string; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + const body: IDataObject = { + id: channelId, + }; + Object.assign(body, updateFields); + + responseData = await twistApiRequest.call(this, 'POST', '/channels/update', body); + } + } + if (resource === 'messageConversation') { + //https://developer.twist.com/v3/#add-message-to-conversation + if (operation === 'create') { + const workspaceId = this.getNodeParameter('workspaceId', i) as string; + const conversationId = this.getNodeParameter('conversationId', i) as string; + const content = this.getNodeParameter('content', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const body: IDataObject = { + conversation_id: conversationId, + workspace_id: workspaceId, + content, + }; + Object.assign(body, additionalFields); + + if (body.actionsUi) { + const actions = (body.actionsUi as IDataObject).actionValues as IDataObject[]; + + if (actions) { + body.actions = actions; + delete body.actionsUi; + } + } + + if (body.binaryProperties) { + const binaryProperties = (body.binaryProperties as string).split(',') as string[]; + + const attachments: IDataObject[] = []; + + for (const binaryProperty of binaryProperties) { + + const item = items[i].binary as IBinaryKeyData; + + const binaryData = item[binaryProperty] as IBinaryData; + + if (binaryData === undefined) { + throw new Error(`No binary data property "${binaryProperty}" does not exists on item!`); + } + + attachments.push(await twistApiRequest.call( + this, + 'POST', + `/attachments/upload`, + {}, + {}, + { + formData: { + file_name: { + value: Buffer.from(binaryData.data, BINARY_ENCODING), + options: { + filename: binaryData.fileName, + }, + }, + attachment_id: uuid(), + }, + }, + )); + } + + body.attachments = attachments; + } + + if (body.direct_mentions) { + const direcMentions: string[] = []; + for (const directMention of body.direct_mentions as number[]) { + direcMentions.push(`[name](twist-mention://${directMention})`); + } + body.content = `${direcMentions.join(' ')} ${body.content}`; + } + + // if (body.direct_group_mentions) { + // const directGroupMentions: string[] = []; + // for (const directGroupMention of body.direct_group_mentions as number[]) { + // directGroupMentions.push(`[Group name](twist-group-mention://${directGroupMention})`); + // } + // body.content = `${directGroupMentions.join(' ')} ${body.content}`; + // } + + responseData = await twistApiRequest.call(this, 'POST', '/conversation_messages/add', body); + } + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Twist/twist.png b/packages/nodes-base/nodes/Twist/twist.png new file mode 100644 index 0000000000..37a8217209 Binary files /dev/null and b/packages/nodes-base/nodes/Twist/twist.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index f22717a75b..e31f16b2f6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -206,6 +206,7 @@ "dist/credentials/TravisCiApi.credentials.js", "dist/credentials/TrelloApi.credentials.js", "dist/credentials/TwilioApi.credentials.js", + "dist/credentials/TwistOAuth2Api.credentials.js", "dist/credentials/TwitterOAuth1Api.credentials.js", "dist/credentials/TypeformApi.credentials.js", "dist/credentials/TypeformOAuth2Api.credentials.js", @@ -440,6 +441,7 @@ "dist/nodes/Trello/Trello.node.js", "dist/nodes/Trello/TrelloTrigger.node.js", "dist/nodes/Twilio/Twilio.node.js", + "dist/nodes/Twist/Twist.node.js", "dist/nodes/Twitter/Twitter.node.js", "dist/nodes/Typeform/TypeformTrigger.node.js", "dist/nodes/Twake/Twake.node.js",