diff --git a/packages/nodes-base/credentials/ZulipApi.credentials.ts b/packages/nodes-base/credentials/ZulipApi.credentials.ts new file mode 100644 index 0000000000..2fff8fb1b2 --- /dev/null +++ b/packages/nodes-base/credentials/ZulipApi.credentials.ts @@ -0,0 +1,30 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class ZulipApi implements ICredentialType { + name = 'zulipApi'; + displayName = 'Zulip API'; + properties = [ + { + displayName: 'URL', + name: 'url', + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'https://yourZulipDomain.zulipchat.com', + }, + { + displayName: 'Email', + name: 'email', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'API Key', + name: 'apiKey', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Zulip/GenericFunctions.ts b/packages/nodes-base/nodes/Zulip/GenericFunctions.ts new file mode 100644 index 0000000000..6bac0b6136 --- /dev/null +++ b/packages/nodes-base/nodes/Zulip/GenericFunctions.ts @@ -0,0 +1,54 @@ +import { OptionsWithUri } from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions, + IWebhookFunctions +} from 'n8n-workflow'; + +export async function zulipApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('zulipApi'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const endpoint = `${credentials.url}/api/v1`; + + let options: OptionsWithUri = { + auth: { + user: credentials.email as string, + password: credentials.apiKey as string, + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method, + form: body, + qs: query, + uri: uri || `${endpoint}${resource}`, + json: true + }; + if (!Object.keys(body).length) { + delete options.form; + } + if (!Object.keys(query).length) { + delete options.qs; + } + options = Object.assign({}, options, option); + try { + return await this.helpers.request!(options); + } catch (error) { + if (error.response) { + const errorMessage = error.response.body.message || error.response.body.description || error.message; + throw new Error(`Zulip error response [${error.statusCode}]: ${errorMessage}`); + } + throw error; + } +} diff --git a/packages/nodes-base/nodes/Zulip/MessageDescription.ts b/packages/nodes-base/nodes/Zulip/MessageDescription.ts new file mode 100644 index 0000000000..0d06c7c84c --- /dev/null +++ b/packages/nodes-base/nodes/Zulip/MessageDescription.ts @@ -0,0 +1,307 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const messageOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'message', + ], + }, + }, + options: [ + { + name: 'Delete', + value: 'delete', + description: 'Delete a message', + }, + { + name: 'Get', + value: 'get', + description: 'Get a message', + }, + { + name: 'Send Private', + value: 'sendPrivate', + description: 'Send a private message', + }, + { + name: 'Send to Stream', + value: 'sendStream', + description: 'Send a message to stream', + }, + { + name: 'Update', + value: 'update', + description: 'Update a message', + }, + { + name: 'Upload a File', + value: 'updateFile', + description: 'Upload a file', + }, + ], + default: 'sendPrivate', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const messageFields = [ + +/* -------------------------------------------------------------------------- */ +/* message:sendPrivate */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'To', + name: 'to', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getUsers', + }, + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'sendPrivate', + ], + }, + }, + description: 'The destination stream, or a comma separated list containing the usernames (emails) of the recipients.', + }, + { + displayName: 'Content', + name: 'content', + type: 'string', + required: true, + default: '', + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'sendPrivate', + ] + }, + }, + description: 'The content of the message.', + }, +/* -------------------------------------------------------------------------- */ +/* message:sendStream */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Stream', + name: 'stream', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getStreams', + }, + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'sendStream', + ], + }, + }, + description: 'The destination stream, or a comma separated list containing the usernames (emails) of the recipients.', + }, + { + displayName: 'Topic', + name: 'topic', + type: 'options', + typeOptions: { + loadOptionsDependsOn: 'stream', + loadOptionsMethod: 'getTopics', + }, + required: true, + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'sendStream', + ], + }, + }, + default: '', + description: 'The topic of the message. Only required if type is stream, ignored otherwise.', + }, + { + displayName: 'Content', + name: 'content', + type: 'string', + required: true, + default: '', + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'sendStream', + ] + }, + }, + description: 'The content of the message.', + }, +/* -------------------------------------------------------------------------- */ +/* message:update */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Message ID', + name: 'messageId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'update', + ] + }, + }, + description: 'Unique identifier for the message.', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Content', + name: 'content', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'The content of the message', + }, + { + displayName: 'Propagate Mode', + name: 'propagateMode', + type: 'options', + options: [ + { + name: 'Change One', + value: 'changeOne', + }, + { + name: 'Change Later', + value: 'changeLater', + }, + { + name: 'Change All', + value: 'changeAll', + }, + ], + default: 'changeOne', + description: 'Which message(s) should be edited: just the one indicated in message_id, messages in the same topic that had been sent after this one, or all of them', + }, + { + displayName: 'Topic', + name: 'topic', + type: 'string', + default: '', + description: 'The topic of the message. Only required for stream messages', + }, + ] + }, +/* -------------------------------------------------------------------------- */ +/* message:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Message ID', + name: 'messageId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'get', + ] + }, + }, + description: 'Unique identifier for the message.', + }, +/* -------------------------------------------------------------------------- */ +/* message:delete */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Message ID', + name: 'messageId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'delete', + ] + }, + }, + description: 'Unique identifier for the message.', + }, +/* -------------------------------------------------------------------------- */ +/* message:updateFile */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Binary Property', + name: 'dataBinaryProperty', + type: 'string', + required: true, + default: 'data', + displayOptions: { + show: { + resource: [ + 'message', + ], + operation: [ + 'updateFile', + ] + }, + }, + description: 'Name of the binary property to which to
write the data of the read file.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Zulip/MessageInterface.ts b/packages/nodes-base/nodes/Zulip/MessageInterface.ts new file mode 100644 index 0000000000..b6a6da93fa --- /dev/null +++ b/packages/nodes-base/nodes/Zulip/MessageInterface.ts @@ -0,0 +1,7 @@ +export interface IMessage { + type?: string; + to?: string; + topic?: string; + content?: string; + propagat_mode?: string; +} diff --git a/packages/nodes-base/nodes/Zulip/Zulip.node.ts b/packages/nodes-base/nodes/Zulip/Zulip.node.ts new file mode 100644 index 0000000000..83e5c76023 --- /dev/null +++ b/packages/nodes-base/nodes/Zulip/Zulip.node.ts @@ -0,0 +1,211 @@ +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; +import { + IDataObject, + ILoadOptionsFunctions, + INodeTypeDescription, + INodeExecutionData, + INodeType, + INodePropertyOptions, +} from 'n8n-workflow'; +import { + zulipApiRequest, +} from './GenericFunctions'; +import { + messageFields, + messageOperations, +} from './MessageDescription'; +import { + IMessage, +} from './MessageInterface'; +import { snakeCase } from 'change-case'; + +export class Zulip implements INodeType { + description: INodeTypeDescription = { + displayName: 'Zulip', + name: 'zulip', + icon: 'file:zulip.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Zulip API', + defaults: { + name: 'Zulip', + color: '#156742', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'zulipApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Message', + value: 'message', + }, + ], + default: 'message', + description: 'Resource to consume.', + }, + ...messageOperations, + ...messageFields, + ], + }; + + methods = { + loadOptions: { + // Get all the available streams to display them to user so that he can + // select them easily + async getStreams(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { streams } = await zulipApiRequest.call(this, 'GET', '/streams'); + for (const stream of streams) { + const streamName = stream.name; + const streamId = stream.stream_id; + returnData.push({ + name: streamName, + value: streamId, + }); + } + return returnData; + }, + // Get all the available topics to display them to user so that he can + // select them easily + async getTopics(this: ILoadOptionsFunctions): Promise { + const streamId = this.getCurrentNodeParameter('stream') as string; + const returnData: INodePropertyOptions[] = []; + const { topics } = await zulipApiRequest.call(this, 'GET', `/users/me/${streamId}/topics`); + for (const topic of topics) { + const topicName = topic.name; + const topicId = topic.name; + returnData.push({ + name: topicName, + value: topicId, + }); + } + 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 { members } = await zulipApiRequest.call(this, 'GET', '/users'); + for (const member of members) { + const memberName = member.full_name; + const memberId = member.email; + returnData.push({ + name: memberName, + value: memberId, + }); + } + return returnData; + }, + }, + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + let responseData; + const qs: IDataObject = {}; + 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 === 'message') { + //https://zulipchat.com/api/send-message + if (operation === 'sendPrivate') { + const to = (this.getNodeParameter('to', i) as string[]).join(','); + const content = this.getNodeParameter('content', i) as string; + const body: IMessage = { + type: 'private', + to, + content, + }; + responseData = await zulipApiRequest.call(this, 'POST', '/messages', body); + } + //https://zulipchat.com/api/send-message + if (operation === 'sendStream') { + const stream = this.getNodeParameter('stream', i) as string; + const topic = this.getNodeParameter('topic', i) as string; + const content = this.getNodeParameter('content', i) as string; + const body: IMessage = { + type: 'stream', + to: stream, + topic, + content, + }; + responseData = await zulipApiRequest.call(this, 'POST', '/messages', body); + } + //https://zulipchat.com/api/update-message + if (operation === 'update') { + const messageId = this.getNodeParameter('messageId', i) as string; + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + const body: IMessage = {}; + if (updateFields.content) { + body.content = updateFields.content as string; + } + if (updateFields.propagateMode) { + body.propagat_mode = snakeCase(updateFields.propagateMode as string); + } + if (updateFields.topic) { + body.topic = updateFields.topic as string; + } + responseData = await zulipApiRequest.call(this, 'PATCH', `/messages/${messageId}`, body); + } + //https://zulipchat.com/api/get-raw-message + if (operation === 'get') { + const messageId = this.getNodeParameter('messageId', i) as string; + responseData = await zulipApiRequest.call(this, 'GET', `/messages/${messageId}`); + } + //https://zulipchat.com/api/delete-message + if (operation === 'delete') { + const messageId = this.getNodeParameter('messageId', i) as string; + responseData = await zulipApiRequest.call(this, 'DELETE', `/messages/${messageId}`); + } + //https://zulipchat.com/api/upload-file + if (operation === 'updateFile') { + const credentials = this.getCredentials('zulipApi'); + const binaryProperty = this.getNodeParameter('dataBinaryProperty', i) as string; + if (items[i].binary === undefined) { + throw new Error('No binary data exists on item!'); + } + //@ts-ignore + if (items[i].binary[binaryProperty] === undefined) { + throw new Error(`No binary data property "${binaryProperty}" does not exists on item!`); + } + const formData = { + file: { + //@ts-ignore + value: Buffer.from(items[i].binary[binaryProperty].data, BINARY_ENCODING), + options: { + //@ts-ignore + filename: items[i].binary[binaryProperty].fileName, + //@ts-ignore + contentType: items[i].binary[binaryProperty].mimeType, + } + } + }; + responseData = await zulipApiRequest.call(this, 'POST', '/user_uploads', {}, {}, undefined, { formData } ); + responseData.uri = `${credentials!.url}${responseData.uri}`; + } + } + 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/Zulip/zulip.png b/packages/nodes-base/nodes/Zulip/zulip.png new file mode 100644 index 0000000000..e40d60988c Binary files /dev/null and b/packages/nodes-base/nodes/Zulip/zulip.png differ diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a81193ef7d..876d16ff62 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -97,7 +97,8 @@ "dist/credentials/WebflowApi.credentials.js", "dist/credentials/WordpressApi.credentials.js", "dist/credentials/WooCommerceApi.credentials.js", - "dist/credentials/ZendeskApi.credentials.js" + "dist/credentials/ZendeskApi.credentials.js", + "dist/credentials/ZulipApi.credentials.js" ], "nodes": [ "dist/nodes/ActiveCampaign/ActiveCampaign.node.js", @@ -219,7 +220,8 @@ "dist/nodes/WriteBinaryFile.node.js", "dist/nodes/Xml.node.js", "dist/nodes/Zendesk/Zendesk.node.js", - "dist/nodes/Zendesk/ZendeskTrigger.node.js" + "dist/nodes/Zendesk/ZendeskTrigger.node.js", + "dist/nodes/Zulip/Zulip.node.js" ] }, "devDependencies": {