From e146ad021a0be22cf51bafa3c015d03550e03d97 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Tue, 28 Jan 2025 13:26:34 +0200 Subject: [PATCH] feat(Google Chat Node): Updates (#12827) Co-authored-by: Dana <152518854+dana-gill@users.noreply.github.com> --- .../GoogleChatOAuth2Api.credentials.ts | 26 +++++ .../nodes/Google/Chat/GenericFunctions.ts | 78 ++++++++++++--- .../nodes/Google/Chat/GoogleChat.node.json | 5 +- .../nodes/Google/Chat/GoogleChat.node.ts | 68 ++++++++++++- .../Chat/descriptions/MessageDescription.ts | 32 +++--- .../nodes/Google/Chat/googleChat.svg | 20 +++- .../Google/Chat/test/genericFunctions.test.ts | 97 +++++++++++++++++++ .../test/node/sendAndWait.operation.test.ts | 60 ++++++++++++ packages/nodes-base/package.json | 1 + 9 files changed, 356 insertions(+), 31 deletions(-) create mode 100644 packages/nodes-base/credentials/GoogleChatOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/Google/Chat/test/genericFunctions.test.ts create mode 100644 packages/nodes-base/nodes/Google/Chat/test/node/sendAndWait.operation.test.ts diff --git a/packages/nodes-base/credentials/GoogleChatOAuth2Api.credentials.ts b/packages/nodes-base/credentials/GoogleChatOAuth2Api.credentials.ts new file mode 100644 index 0000000000..3bc678b3c4 --- /dev/null +++ b/packages/nodes-base/credentials/GoogleChatOAuth2Api.credentials.ts @@ -0,0 +1,26 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +const scopes = [ + 'https://www.googleapis.com/auth/chat.spaces', + 'https://www.googleapis.com/auth/chat.messages', + 'https://www.googleapis.com/auth/chat.memberships', +]; + +export class GoogleChatOAuth2Api implements ICredentialType { + name = 'googleChatOAuth2Api'; + + extends = ['googleOAuth2Api']; + + displayName = 'Chat OAuth2 API'; + + documentationUrl = 'google/oauth-single-service'; + + properties: INodeProperties[] = [ + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: scopes.join(' '), + }, + ]; +} diff --git a/packages/nodes-base/nodes/Google/Chat/GenericFunctions.ts b/packages/nodes-base/nodes/Google/Chat/GenericFunctions.ts index 442ba4799d..a446407a58 100644 --- a/packages/nodes-base/nodes/Google/Chat/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Google/Chat/GenericFunctions.ts @@ -9,48 +9,70 @@ import type { } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; +import { getSendAndWaitConfig } from '../../../utils/sendAndWait/utils'; import { getGoogleAccessToken } from '../GenericFunctions'; +async function googleServiceAccountApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + options: IRequestOptions, + noCredentials = false, +): Promise { + if (noCredentials) { + return await this.helpers.request(options); + } + + const credentials = await this.getCredentials('googleApi'); + + const { access_token } = await getGoogleAccessToken.call(this, credentials, 'chat'); + options.headers!.Authorization = `Bearer ${access_token}`; + + return await this.helpers.request(options); +} + export async function googleApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, resource: string, - - body: any = {}, + body: IDataObject = {}, qs: IDataObject = {}, uri?: string, noCredentials = false, encoding?: null | undefined, -): Promise { +) { const options: IRequestOptions = { headers: { + Accept: 'application/json', 'Content-Type': 'application/json', }, method, body, qs, uri: uri || `https://chat.googleapis.com${resource}`, + qsStringifyOptions: { + arrayFormat: 'repeat', + }, json: true, }; - if (Object.keys(body as IDataObject).length === 0) { - delete options.body; - } - if (encoding === null) { options.encoding = null; } - let responseData: IDataObject | undefined; - try { - if (noCredentials) { - responseData = await this.helpers.request(options); - } else { - const credentials = await this.getCredentials('googleApi'); + if (Object.keys(body).length === 0) { + delete options.body; + } - const { access_token } = await getGoogleAccessToken.call(this, credentials, 'chat'); - options.headers!.Authorization = `Bearer ${access_token}`; - responseData = await this.helpers.request(options); + let responseData; + + try { + if (noCredentials || this.getNodeParameter('authentication', 0) === 'serviceAccount') { + responseData = await googleServiceAccountApiRequest.call(this, options, noCredentials); + } else { + responseData = await this.helpers.requestWithAuthentication.call( + this, + 'googleChatOAuth2Api', + options, + ); } } catch (error) { if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') { @@ -59,6 +81,7 @@ export async function googleApiRequest( throw new NodeApiError(this.getNode(), error as JsonObject); } + if (Object.keys(responseData as IDataObject).length !== 0) { return responseData; } else { @@ -134,3 +157,26 @@ export function getPagingParameters(resource: string, operation = 'getAll') { ]; return pagingParameters; } + +export function createSendAndWaitMessageBody(context: IExecuteFunctions) { + const config = getSendAndWaitConfig(context); + + const instanceId = context.getInstanceId(); + const attributionText = '_This_ _message_ _was_ _sent_ _automatically_ _with_'; + const link = `https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=${encodeURIComponent( + 'n8n-nodes-base.telegram', + )}${instanceId ? '_' + instanceId : ''}`; + const attribution = `${attributionText} _<${link}|n8n>_`; + + const buttons: string[] = config.options.map( + (option) => `*<${`${config.url}?approved=${option.value}`}|${option.label}>*`, + ); + + const text = `${config.message}\n\n\n${buttons.join(' ')}\n\n${attribution}`; + + const body = { + text, + }; + + return body; +} diff --git a/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.json b/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.json index 326830ccbf..c9b8f27515 100644 --- a/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.json +++ b/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.json @@ -2,7 +2,10 @@ "node": "n8n-nodes-base.googleChat", "nodeVersion": "1.0", "codexVersion": "1.0", - "categories": ["Communication"], + "categories": ["Communication", "HILT"], + "subcategories": { + "HILT": ["Human in the Loop"] + }, "resources": { "credentialDocumentation": [ { diff --git a/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.ts b/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.ts index 7a66126762..dd731808a5 100644 --- a/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.ts +++ b/packages/nodes-base/nodes/Google/Chat/GoogleChat.node.ts @@ -13,7 +13,7 @@ import type { INodeTypeDescription, IRequestOptions, } from 'n8n-workflow'; -import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import { // attachmentFields, @@ -27,10 +27,22 @@ import { messageFields, messageOperations, spaceFields, + spaceIdProperty, spaceOperations, } from './descriptions'; -import { googleApiRequest, googleApiRequestAllItems, validateJSON } from './GenericFunctions'; +import { + createSendAndWaitMessageBody, + googleApiRequest, + googleApiRequestAllItems, + validateJSON, +} from './GenericFunctions'; import type { IMessage, IMessageUi } from './MessageInterface'; +import { sendAndWaitWebhooksDescription } from '../../../utils/sendAndWait/descriptions'; +import { + configureWaitTillDate, + getSendAndWaitProperties, + sendAndWaitWebhook, +} from '../../../utils/sendAndWait/utils'; export class GoogleChat implements INodeType { description: INodeTypeDescription = { @@ -46,14 +58,46 @@ export class GoogleChat implements INodeType { }, inputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main], + webhooks: sendAndWaitWebhooksDescription, credentials: [ { name: 'googleApi', required: true, testedBy: 'testGoogleTokenAuth', + displayOptions: { + show: { + authentication: ['serviceAccount'], + }, + }, + }, + { + name: 'googleChatOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'OAuth2 (recommended)', + value: 'oAuth2', + }, + { + name: 'Service Account', + value: 'serviceAccount', + }, + ], + default: 'serviceAccount', + }, { displayName: 'Resource', name: 'resource', @@ -100,9 +144,16 @@ export class GoogleChat implements INodeType { ...messageFields, ...spaceOperations, ...spaceFields, + ...getSendAndWaitProperties([spaceIdProperty], 'message', undefined, { + noButtonStyle: true, + defaultApproveLabel: '✅ Approve', + defaultDisapproveLabel: '❌ Decline', + }).filter((p) => p.name !== 'subject'), ], }; + webhook = sendAndWaitWebhook; + methods = { loadOptions: { // Get all the spaces to display them to user so that they can @@ -196,6 +247,19 @@ export class GoogleChat implements INodeType { let responseData; const resource = this.getNodeParameter('resource', 0); const operation = this.getNodeParameter('operation', 0); + + if (resource === 'message' && operation === SEND_AND_WAIT_OPERATION) { + const spaceId = this.getNodeParameter('spaceId', 0) as string; + const body = createSendAndWaitMessageBody(this); + + await googleApiRequest.call(this, 'POST', `/v1/${spaceId}/messages`, body); + + const waitTill = configureWaitTillDate(this); + + await this.putExecutionToWait(waitTill); + return [this.getInputData()]; + } + for (let i = 0; i < length; i++) { try { if (resource === 'media') { diff --git a/packages/nodes-base/nodes/Google/Chat/descriptions/MessageDescription.ts b/packages/nodes-base/nodes/Google/Chat/descriptions/MessageDescription.ts index 18279be8f5..56dcb0d7c1 100644 --- a/packages/nodes-base/nodes/Google/Chat/descriptions/MessageDescription.ts +++ b/packages/nodes-base/nodes/Google/Chat/descriptions/MessageDescription.ts @@ -1,4 +1,4 @@ -import type { INodeProperties } from 'n8n-workflow'; +import { SEND_AND_WAIT_OPERATION, type INodeProperties } from 'n8n-workflow'; export const messageOperations: INodeProperties[] = [ { @@ -30,6 +30,12 @@ export const messageOperations: INodeProperties[] = [ description: 'Get a message', action: 'Get a message', }, + { + name: 'Send and Wait for Response', + value: SEND_AND_WAIT_OPERATION, + description: 'Send a message and wait for response', + action: 'Send message and wait for response', + }, { name: 'Update', value: 'update', @@ -41,27 +47,31 @@ export const messageOperations: INodeProperties[] = [ }, ]; +export const spaceIdProperty: INodeProperties = { + displayName: 'Space Name or ID', + name: 'spaceId', + type: 'options', + required: true, + typeOptions: { + loadOptionsMethod: 'getSpaces', + }, + default: '', + description: + 'Space resource name, in the form "spaces/*". Example: spaces/AAAAMpdlehY. Choose from the list, or specify an ID using an expression.', +}; + export const messageFields: INodeProperties[] = [ /* -------------------------------------------------------------------------- */ /* message:create */ /* -------------------------------------------------------------------------- */ { - displayName: 'Space Name or ID', - name: 'spaceId', - type: 'options', - required: true, - typeOptions: { - loadOptionsMethod: 'getSpaces', - }, + ...spaceIdProperty, displayOptions: { show: { resource: ['message'], operation: ['create'], }, }, - default: '', - description: - 'Space resource name, in the form "spaces/*". Example: spaces/AAAAMpdlehY. Choose from the list, or specify an ID using an expression.', }, { displayName: 'JSON Parameters', diff --git a/packages/nodes-base/nodes/Google/Chat/googleChat.svg b/packages/nodes-base/nodes/Google/Chat/googleChat.svg index 1646e75d0a..8f3922cc3a 100644 --- a/packages/nodes-base/nodes/Google/Chat/googleChat.svg +++ b/packages/nodes-base/nodes/Google/Chat/googleChat.svg @@ -1 +1,19 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + diff --git a/packages/nodes-base/nodes/Google/Chat/test/genericFunctions.test.ts b/packages/nodes-base/nodes/Google/Chat/test/genericFunctions.test.ts new file mode 100644 index 0000000000..79aeb47732 --- /dev/null +++ b/packages/nodes-base/nodes/Google/Chat/test/genericFunctions.test.ts @@ -0,0 +1,97 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import { type IExecuteFunctions } from 'n8n-workflow'; + +import * as googleHelpers from '../../GenericFunctions'; +import { googleApiRequest } from '../GenericFunctions'; + +jest.mock('../../GenericFunctions', () => ({ + ...jest.requireActual('../../GenericFunctions'), + getGoogleAccessToken: jest.fn().mockResolvedValue({ access_token: 'mock-access-token' }), +})); + +describe('Test GoogleChat, googleApiRequest', () => { + let mockExecuteFunctions: MockProxy; + + beforeEach(() => { + mockExecuteFunctions = mock(); + + mockExecuteFunctions.helpers = { + requestWithAuthentication: jest.fn().mockResolvedValue({}), + request: jest.fn().mockResolvedValue({}), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call requestWithAuthentication when authentication set to OAuth2', async () => { + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('oAuth2'); // authentication + + const result = await googleApiRequest.call(mockExecuteFunctions, 'POST', '/test-resource', { + text: 'test', + }); + + expect(result).toEqual({ success: true }); + + expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'googleChatOAuth2Api', + { + body: { text: 'test' }, + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + json: true, + method: 'POST', + qs: {}, + qsStringifyOptions: { arrayFormat: 'repeat' }, + uri: 'https://chat.googleapis.com/test-resource', + }, + ); + }); + + it('should call request when authentication set to serviceAccount', async () => { + const mockCredentials = { + email: 'test@example.com', + privateKey: 'private-key', + }; + + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('serviceAccount'); + mockExecuteFunctions.getCredentials.mockResolvedValueOnce(mockCredentials); + + const result = await googleApiRequest.call(mockExecuteFunctions, 'GET', '/test-resource'); + + expect(googleHelpers.getGoogleAccessToken).toHaveBeenCalledWith(mockCredentials, 'chat'); + expect(mockExecuteFunctions.helpers.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer mock-access-token', + }), + }), + ); + expect(result).toEqual({ success: true }); + }); + + it('should call request when noCredentials equals true', async () => { + const result = await googleApiRequest.call( + mockExecuteFunctions, + 'GET', + '/test-resource', + {}, + {}, + undefined, + true, + ); + + expect(mockExecuteFunctions.helpers.request).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.helpers.request).toHaveBeenCalledWith({ + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + json: true, + method: 'GET', + qs: {}, + qsStringifyOptions: { arrayFormat: 'repeat' }, + uri: 'https://chat.googleapis.com/test-resource', + }); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/packages/nodes-base/nodes/Google/Chat/test/node/sendAndWait.operation.test.ts b/packages/nodes-base/nodes/Google/Chat/test/node/sendAndWait.operation.test.ts new file mode 100644 index 0000000000..b03930541b --- /dev/null +++ b/packages/nodes-base/nodes/Google/Chat/test/node/sendAndWait.operation.test.ts @@ -0,0 +1,60 @@ +import type { MockProxy } from 'jest-mock-extended'; +import { mock } from 'jest-mock-extended'; +import { type INode, SEND_AND_WAIT_OPERATION, type IExecuteFunctions } from 'n8n-workflow'; + +import * as genericFunctions from '../../GenericFunctions'; +import { GoogleChat } from '../../GoogleChat.node'; + +jest.mock('../../GenericFunctions', () => { + const originalModule = jest.requireActual('../../GenericFunctions'); + return { + ...originalModule, + googleApiRequest: jest.fn(), + }; +}); + +describe('Test GoogleChat, message => sendAndWait', () => { + let googleChat: GoogleChat; + let mockExecuteFunctions: MockProxy; + + beforeEach(() => { + googleChat = new GoogleChat(); + mockExecuteFunctions = mock(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should send message and put execution to wait', async () => { + const items = [{ json: { data: 'test' } }]; + //node + mockExecuteFunctions.getInputData.mockReturnValue(items); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(SEND_AND_WAIT_OPERATION); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('spaceID'); + mockExecuteFunctions.getNode.mockReturnValue(mock()); + mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId'); + + //getSendAndWaitConfig + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my message'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('my subject'); + mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('http://localhost/waiting-webhook'); + mockExecuteFunctions.evaluateExpression.mockReturnValueOnce('nodeID'); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('approval'); + + // configureWaitTillDate + mockExecuteFunctions.getNodeParameter.mockReturnValueOnce({}); //options.limitWaitTime.values + + const result = await googleChat.execute.call(mockExecuteFunctions); + + expect(result).toEqual([items]); + expect(genericFunctions.googleApiRequest).toHaveBeenCalledTimes(1); + expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1); + + expect(genericFunctions.googleApiRequest).toHaveBeenCalledWith('POST', '/v1/spaceID/messages', { + text: 'my message\n\n\n**\n\n_This_ _message_ _was_ _sent_ _automatically_ _with_ __', + }); + }); +}); diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 708ab75195..0a2cf4d178 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -132,6 +132,7 @@ "dist/credentials/GoogleBigQueryOAuth2Api.credentials.js", "dist/credentials/GoogleBooksOAuth2Api.credentials.js", "dist/credentials/GoogleCalendarOAuth2Api.credentials.js", + "dist/credentials/GoogleChatOAuth2Api.credentials.js", "dist/credentials/GoogleCloudNaturalLanguageOAuth2Api.credentials.js", "dist/credentials/GoogleCloudStorageOAuth2Api.credentials.js", "dist/credentials/GoogleContactsOAuth2Api.credentials.js",