feat(Google Chat Node): Updates (#12827)

Co-authored-by: Dana <152518854+dana-gill@users.noreply.github.com>
This commit is contained in:
Michael Kret 2025-01-28 13:26:34 +02:00 committed by GitHub
parent de49c23971
commit e146ad021a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 356 additions and 31 deletions

View file

@ -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(' '),
},
];
}

View file

@ -9,48 +9,70 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow';
import { getSendAndWaitConfig } from '../../../utils/sendAndWait/utils';
import { getGoogleAccessToken } from '../GenericFunctions'; import { getGoogleAccessToken } from '../GenericFunctions';
async function googleServiceAccountApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
options: IRequestOptions,
noCredentials = false,
): Promise<any> {
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( export async function googleApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions, this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods, method: IHttpRequestMethods,
resource: string, resource: string,
body: IDataObject = {},
body: any = {},
qs: IDataObject = {}, qs: IDataObject = {},
uri?: string, uri?: string,
noCredentials = false, noCredentials = false,
encoding?: null | undefined, encoding?: null | undefined,
): Promise<any> { ) {
const options: IRequestOptions = { const options: IRequestOptions = {
headers: { headers: {
Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
method, method,
body, body,
qs, qs,
uri: uri || `https://chat.googleapis.com${resource}`, uri: uri || `https://chat.googleapis.com${resource}`,
qsStringifyOptions: {
arrayFormat: 'repeat',
},
json: true, json: true,
}; };
if (Object.keys(body as IDataObject).length === 0) {
delete options.body;
}
if (encoding === null) { if (encoding === null) {
options.encoding = null; options.encoding = null;
} }
let responseData: IDataObject | undefined; if (Object.keys(body).length === 0) {
try { delete options.body;
if (noCredentials) { }
responseData = await this.helpers.request(options);
} else {
const credentials = await this.getCredentials('googleApi');
const { access_token } = await getGoogleAccessToken.call(this, credentials, 'chat'); let responseData;
options.headers!.Authorization = `Bearer ${access_token}`;
responseData = await this.helpers.request(options); 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) { } catch (error) {
if (error.code === 'ERR_OSSL_PEM_NO_START_LINE') { 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); throw new NodeApiError(this.getNode(), error as JsonObject);
} }
if (Object.keys(responseData as IDataObject).length !== 0) { if (Object.keys(responseData as IDataObject).length !== 0) {
return responseData; return responseData;
} else { } else {
@ -134,3 +157,26 @@ export function getPagingParameters(resource: string, operation = 'getAll') {
]; ];
return pagingParameters; 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;
}

View file

@ -2,7 +2,10 @@
"node": "n8n-nodes-base.googleChat", "node": "n8n-nodes-base.googleChat",
"nodeVersion": "1.0", "nodeVersion": "1.0",
"codexVersion": "1.0", "codexVersion": "1.0",
"categories": ["Communication"], "categories": ["Communication", "HILT"],
"subcategories": {
"HILT": ["Human in the Loop"]
},
"resources": { "resources": {
"credentialDocumentation": [ "credentialDocumentation": [
{ {

View file

@ -13,7 +13,7 @@ import type {
INodeTypeDescription, INodeTypeDescription,
IRequestOptions, IRequestOptions,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import { NodeConnectionType, NodeOperationError, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import { import {
// attachmentFields, // attachmentFields,
@ -27,10 +27,22 @@ import {
messageFields, messageFields,
messageOperations, messageOperations,
spaceFields, spaceFields,
spaceIdProperty,
spaceOperations, spaceOperations,
} from './descriptions'; } from './descriptions';
import { googleApiRequest, googleApiRequestAllItems, validateJSON } from './GenericFunctions'; import {
createSendAndWaitMessageBody,
googleApiRequest,
googleApiRequestAllItems,
validateJSON,
} from './GenericFunctions';
import type { IMessage, IMessageUi } from './MessageInterface'; 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 { export class GoogleChat implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@ -46,14 +58,46 @@ export class GoogleChat implements INodeType {
}, },
inputs: [NodeConnectionType.Main], inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main], outputs: [NodeConnectionType.Main],
webhooks: sendAndWaitWebhooksDescription,
credentials: [ credentials: [
{ {
name: 'googleApi', name: 'googleApi',
required: true, required: true,
testedBy: 'testGoogleTokenAuth', testedBy: 'testGoogleTokenAuth',
displayOptions: {
show: {
authentication: ['serviceAccount'],
},
},
},
{
name: 'googleChatOAuth2Api',
required: true,
displayOptions: {
show: {
authentication: ['oAuth2'],
},
},
}, },
], ],
properties: [ 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', displayName: 'Resource',
name: 'resource', name: 'resource',
@ -100,9 +144,16 @@ export class GoogleChat implements INodeType {
...messageFields, ...messageFields,
...spaceOperations, ...spaceOperations,
...spaceFields, ...spaceFields,
...getSendAndWaitProperties([spaceIdProperty], 'message', undefined, {
noButtonStyle: true,
defaultApproveLabel: '✅ Approve',
defaultDisapproveLabel: '❌ Decline',
}).filter((p) => p.name !== 'subject'),
], ],
}; };
webhook = sendAndWaitWebhook;
methods = { methods = {
loadOptions: { loadOptions: {
// Get all the spaces to display them to user so that they can // Get all the spaces to display them to user so that they can
@ -196,6 +247,19 @@ export class GoogleChat implements INodeType {
let responseData; let responseData;
const resource = this.getNodeParameter('resource', 0); const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 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++) { for (let i = 0; i < length; i++) {
try { try {
if (resource === 'media') { if (resource === 'media') {

View file

@ -1,4 +1,4 @@
import type { INodeProperties } from 'n8n-workflow'; import { SEND_AND_WAIT_OPERATION, type INodeProperties } from 'n8n-workflow';
export const messageOperations: INodeProperties[] = [ export const messageOperations: INodeProperties[] = [
{ {
@ -30,6 +30,12 @@ export const messageOperations: INodeProperties[] = [
description: 'Get a message', description: 'Get a message',
action: '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', name: 'Update',
value: '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 <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
};
export const messageFields: INodeProperties[] = [ export const messageFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* message:create */ /* message:create */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
{ {
displayName: 'Space Name or ID', ...spaceIdProperty,
name: 'spaceId',
type: 'options',
required: true,
typeOptions: {
loadOptionsMethod: 'getSpaces',
},
displayOptions: { displayOptions: {
show: { show: {
resource: ['message'], resource: ['message'],
operation: ['create'], operation: ['create'],
}, },
}, },
default: '',
description:
'Space resource name, in the form "spaces/*". Example: spaces/AAAAMpdlehY. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
}, },
{ {
displayName: 'JSON Parameters', displayName: 'JSON Parameters',

View file

@ -1 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="Layer 1" viewBox="0 0 479.4 499.98"><path d="M121.24 275.69v-162.5h-71a37.86 37.86 0 0 0-37.8 37.9V487c0 16.9 20.4 25.3 32.3 13.4l78.1-78.1h222.4a37.77 37.77 0 0 0 37.8-37.8v-71h-223.9a37.86 37.86 0 0 1-37.9-37.81" style="fill:#00ac47" transform="translate(-12.44 -5.99)"/><path d="M454 6H159.14a37.77 37.77 0 0 0-37.8 37.8v69.4h223.9A37.77 37.77 0 0 1 383 151v162.4h71a37.77 37.77 0 0 0 37.8-37.8V43.79A37.77 37.77 0 0 0 454 6" style="fill:#5bb974" transform="translate(-12.44 -5.99)"/><path d="M345.24 113.19h-224v162.4a37.77 37.77 0 0 0 37.8 37.8h223.9v-162.3a37.71 37.71 0 0 0-37.7-37.9" style="fill:#00832d" transform="translate(-12.44 -5.99)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0.00 0.00 311.00 320.00">
<g stroke-width="2.00" fill="none" stroke-linecap="butt">
<path stroke="#7e916f" vector-effect="non-scaling-stroke" d=" M 76.37 0.51 L 76.38 76.98"/>
<path stroke="#1375eb" vector-effect="non-scaling-stroke" d=" M 76.38 76.98 L 0.00 76.96"/>
<path stroke="#f3801d" vector-effect="non-scaling-stroke" d=" M 235.08 1.09 Q 234.92 1.15 234.81 1.22 Q 234.64 1.31 234.64 1.50 L 234.62 77.01"/>
<path stroke="#7eb426" vector-effect="non-scaling-stroke" d=" M 234.62 77.01 Q 234.60 77.01 234.57 77.01"/>
<path stroke="#91a080" vector-effect="non-scaling-stroke" d=" M 76.41 77.01 Q 76.40 77.00 76.38 76.98"/>
<path stroke="#75783e" vector-effect="non-scaling-stroke" d=" M 310.53 76.77 L 234.62 77.01"/>
<path stroke="#138495" vector-effect="non-scaling-stroke" d=" M 76.43 182.69 L 0.00 182.67"/>
<path stroke="#00983a" vector-effect="non-scaling-stroke" d=" M 76.44 259.13 L 76.43 220.85"/>
</g>
<path fill="#0066da" d=" M 76.37 0.51 L 76.38 76.98 L 0.00 76.96 L 0.00 20.77 Q 0.85 14.81 3.53 10.76 Q 10.14 0.74 22.75 0.67 Q 49.41 0.53 76.37 0.51 Z"/>
<path fill="#fbbc04" d=" M 76.37 0.51 L 233.79 0.53 A 1.61 1.57 -26.7 0 1 234.71 0.82 L 235.08 1.09 Q 234.92 1.15 234.81 1.22 Q 234.64 1.31 234.64 1.50 L 234.62 77.01 Q 234.60 77.01 234.57 77.01 L 76.41 77.01 Q 76.40 77.00 76.38 76.98 L 76.37 0.51 Z"/>
<path fill="#ea4335" d=" M 235.08 1.09 L 310.53 76.77 L 234.62 77.01 L 234.64 1.50 Q 234.64 1.31 234.81 1.22 Q 234.92 1.15 235.08 1.09 Z"/>
<path fill="#2684fc" d=" M 0.00 76.96 L 76.38 76.98 Q 76.40 77.00 76.41 77.01 L 76.43 182.69 L 0.00 182.67 L 0.00 76.96 Z"/>
<path fill="#00ac47" d=" M 310.53 76.77 L 311.00 77.11 L 311.00 239.01 Q 308.34 253.54 295.94 257.78 Q 291.52 259.30 282.91 259.28 Q 227.02 259.19 169.99 259.11 Q 161.71 259.10 153.19 259.23 Q 152.72 259.24 152.39 259.57 Q 124.49 287.34 96.39 315.59 C 93.52 318.48 90.27 320.09 86.15 319.48 Q 80.39 318.63 77.66 313.54 Q 76.51 311.38 76.49 305.66 Q 76.42 282.47 76.44 259.13 L 76.43 220.85 L 114.21 183.07 A 1.79 1.77 22.3 0 1 115.47 182.55 L 233.77 182.59 A 0.83 0.83 0.0 0 0 234.60 181.76 L 234.57 77.01 Q 234.60 77.01 234.62 77.01 L 310.53 76.77 Z"/>
<path fill="#00832d" d=" M 76.43 182.69 L 76.43 220.85 L 76.44 259.13 Q 52.47 259.27 28.91 259.22 Q 19.09 259.20 14.76 257.68 Q 2.62 253.44 0.00 238.88 L 0.00 182.67 L 76.43 182.69 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -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<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mock<IExecuteFunctions>();
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 });
});
});

View file

@ -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<IExecuteFunctions>;
beforeEach(() => {
googleChat = new GoogleChat();
mockExecuteFunctions = mock<IExecuteFunctions>();
});
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<INode>());
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*<http://localhost/waiting-webhook/nodeID?approved=true|Approve>*\n\n_This_ _message_ _was_ _sent_ _automatically_ _with_ _<https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.telegram_instanceId|n8n>_',
});
});
});

View file

@ -132,6 +132,7 @@
"dist/credentials/GoogleBigQueryOAuth2Api.credentials.js", "dist/credentials/GoogleBigQueryOAuth2Api.credentials.js",
"dist/credentials/GoogleBooksOAuth2Api.credentials.js", "dist/credentials/GoogleBooksOAuth2Api.credentials.js",
"dist/credentials/GoogleCalendarOAuth2Api.credentials.js", "dist/credentials/GoogleCalendarOAuth2Api.credentials.js",
"dist/credentials/GoogleChatOAuth2Api.credentials.js",
"dist/credentials/GoogleCloudNaturalLanguageOAuth2Api.credentials.js", "dist/credentials/GoogleCloudNaturalLanguageOAuth2Api.credentials.js",
"dist/credentials/GoogleCloudStorageOAuth2Api.credentials.js", "dist/credentials/GoogleCloudStorageOAuth2Api.credentials.js",
"dist/credentials/GoogleContactsOAuth2Api.credentials.js", "dist/credentials/GoogleContactsOAuth2Api.credentials.js",