feat(Telegram Node): New operation sendAndWait (#12771)

This commit is contained in:
Michael Kret 2025-01-24 13:44:05 +02:00 committed by GitHub
parent 5b760e7f7f
commit 2c58d47f8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 269 additions and 124 deletions

View file

@ -13,6 +13,7 @@ import { labelFields, labelOperations } from './LabelDescription';
import { getGmailAliases, getLabels, getThreadMessages } from './loadOptions'; import { getGmailAliases, getLabels, getThreadMessages } from './loadOptions';
import { messageFields, messageOperations } from './MessageDescription'; import { messageFields, messageOperations } from './MessageDescription';
import { threadFields, threadOperations } from './ThreadDescription'; import { threadFields, threadOperations } from './ThreadDescription';
import { sendAndWaitWebhooks } from '../../../../utils/sendAndWait/descriptions';
import type { IEmail } from '../../../../utils/sendAndWait/interfaces'; import type { IEmail } from '../../../../utils/sendAndWait/interfaces';
import { import {
configureWaitTillDate, configureWaitTillDate,
@ -68,26 +69,7 @@ const versionDescription: INodeTypeDescription = {
}, },
}, },
], ],
webhooks: [ webhooks: sendAndWaitWebhooks,
{
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
],
properties: [ properties: [
{ {
displayName: 'Authentication', displayName: 'Authentication',

View file

@ -9,6 +9,7 @@ import * as folder from './folder';
import * as folderMessage from './folderMessage'; import * as folderMessage from './folderMessage';
import * as message from './message'; import * as message from './message';
import * as messageAttachment from './messageAttachment'; import * as messageAttachment from './messageAttachment';
import { sendAndWaitWebhooks } from '../../../../../utils/sendAndWait/descriptions';
export const description: INodeTypeDescription = { export const description: INodeTypeDescription = {
displayName: 'Microsoft Outlook', displayName: 'Microsoft Outlook',
@ -30,26 +31,7 @@ export const description: INodeTypeDescription = {
required: true, required: true,
}, },
], ],
webhooks: [ webhooks: sendAndWaitWebhooks,
{
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
],
properties: [ properties: [
{ {
displayName: 'Resource', displayName: 'Resource',

View file

@ -41,6 +41,7 @@ import { reactionFields, reactionOperations } from './ReactionDescription';
import { starFields, starOperations } from './StarDescription'; import { starFields, starOperations } from './StarDescription';
import { userFields, userOperations } from './UserDescription'; import { userFields, userOperations } from './UserDescription';
import { userGroupFields, userGroupOperations } from './UserGroupDescription'; import { userGroupFields, userGroupOperations } from './UserGroupDescription';
import { sendAndWaitWebhooks } from '../../../utils/sendAndWait/descriptions';
import { import {
configureWaitTillDate, configureWaitTillDate,
getSendAndWaitProperties, getSendAndWaitProperties,
@ -80,26 +81,7 @@ export class SlackV2 implements INodeType {
}, },
}, },
], ],
webhooks: [ webhooks: sendAndWaitWebhooks,
{
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
],
properties: [ properties: [
{ {
displayName: 'Authentication', displayName: 'Authentication',

View file

@ -10,6 +10,8 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow';
import { getSendAndWaitConfig } from '../../utils/sendAndWait/utils';
// Interface in n8n // Interface in n8n
export interface IMarkupKeyboard { export interface IMarkupKeyboard {
rows?: IMarkupKeyboardRow[]; rows?: IMarkupKeyboardRow[];
@ -252,3 +254,36 @@ export function getSecretToken(this: IHookFunctions | IWebhookFunctions) {
const secret_token = `${this.getWorkflow().id}_${this.getNode().id}`; const secret_token = `${this.getWorkflow().id}_${this.getNode().id}`;
return secret_token.replace(/[^a-zA-Z0-9\_\-]+/g, ''); return secret_token.replace(/[^a-zA-Z0-9\_\-]+/g, '');
} }
export function createSendAndWaitMessageBody(context: IExecuteFunctions) {
const chat_id = context.getNodeParameter('chatId', 0) as string;
const config = getSendAndWaitConfig(context);
let text = config.message;
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 : ''}`;
text = `${text}\n\n_${attributionText}_[n8n](${link})`;
const body = {
chat_id,
text,
disable_web_page_preview: true,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
config.options.map((option) => {
return {
text: option.label,
url: `${config.url}?approved=${option.value}`,
};
}),
],
},
};
return body;
}

View file

@ -6,11 +6,27 @@ import type {
INodeTypeDescription, INodeTypeDescription,
IHttpRequestMethods, IHttpRequestMethods,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { BINARY_ENCODING, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import {
BINARY_ENCODING,
SEND_AND_WAIT_OPERATION,
NodeConnectionType,
NodeOperationError,
} from 'n8n-workflow';
import type { Readable } from 'stream'; import type { Readable } from 'stream';
import { addAdditionalFields, apiRequest, getPropertyName } from './GenericFunctions'; import {
addAdditionalFields,
apiRequest,
createSendAndWaitMessageBody,
getPropertyName,
} from './GenericFunctions';
import { appendAttributionOption } from '../../utils/descriptions'; import { appendAttributionOption } from '../../utils/descriptions';
import { sendAndWaitWebhooks } from '../../utils/sendAndWait/descriptions';
import {
configureWaitTillDate,
getSendAndWaitProperties,
sendAndWaitWebhook,
} from '../../utils/sendAndWait/utils';
export class Telegram implements INodeType { export class Telegram implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
@ -33,6 +49,7 @@ export class Telegram implements INodeType {
required: true, required: true,
}, },
], ],
webhooks: sendAndWaitWebhooks,
properties: [ properties: [
{ {
displayName: 'Resource', displayName: 'Resource',
@ -263,6 +280,12 @@ export class Telegram implements INodeType {
description: 'Send a text message', description: 'Send a text message',
action: 'Send a text message', action: 'Send a text 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: 'Send Photo', name: 'Send Photo',
value: 'sendPhoto', value: 'sendPhoto',
@ -1735,9 +1758,31 @@ export class Telegram implements INodeType {
}, },
], ],
}, },
...getSendAndWaitProperties(
[
{
displayName: 'Chat ID',
name: 'chatId',
type: 'string',
default: '',
required: true,
description:
'Unique identifier for the target chat or username of the target channel (in the format @channelusername). To find your chat ID ask @get_id_bot.',
},
],
'message',
undefined,
{
noButtonStyle: true,
defaultApproveLabel: '✅ Approve',
defaultDisapproveLabel: '❌ Decline',
},
).filter((p) => p.name !== 'subject'),
], ],
}; };
webhook = sendAndWaitWebhook;
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> { async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData(); const items = this.getInputData();
const returnData: INodeExecutionData[] = []; const returnData: INodeExecutionData[] = [];
@ -1757,6 +1802,17 @@ export class Telegram implements INodeType {
const nodeVersion = this.getNode().typeVersion; const nodeVersion = this.getNode().typeVersion;
const instanceId = this.getInstanceId(); const instanceId = this.getInstanceId();
if (resource === 'message' && operation === SEND_AND_WAIT_OPERATION) {
body = createSendAndWaitMessageBody(this);
await apiRequest.call(this, 'POST', 'sendMessage', body);
const waitTill = configureWaitTillDate(this);
await this.putExecutionToWait(waitTill);
return [this.getInputData()];
}
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
try { try {
// Reset all values // Reset all values

View file

@ -0,0 +1,71 @@
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 { Telegram } from '../../Telegram.node';
jest.mock('../../GenericFunctions', () => {
const originalModule = jest.requireActual('../../GenericFunctions');
return {
...originalModule,
apiRequest: jest.fn(),
};
});
describe('Test Telegram, message => sendAndWait', () => {
let telegram: Telegram;
let mockExecuteFunctions: MockProxy<IExecuteFunctions>;
beforeEach(() => {
telegram = new Telegram();
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(SEND_AND_WAIT_OPERATION);
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('message');
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce(false);
mockExecuteFunctions.getNode.mockReturnValue(mock<INode>());
mockExecuteFunctions.getInstanceId.mockReturnValue('instanceId');
//createSendAndWaitMessageBody
mockExecuteFunctions.getNodeParameter.mockReturnValueOnce('chatID');
//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 telegram.execute.call(mockExecuteFunctions);
expect(result).toEqual([items]);
expect(genericFunctions.apiRequest).toHaveBeenCalledTimes(1);
expect(mockExecuteFunctions.putExecutionToWait).toHaveBeenCalledTimes(1);
expect(genericFunctions.apiRequest).toHaveBeenCalledWith('POST', 'sendMessage', {
chat_id: 'chatID',
disable_web_page_preview: true,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Approve', url: 'http://localhost/waiting-webhook/nodeID?approved=true' }],
],
},
text: 'my message\n\n_This message was sent automatically with _[n8n](https://n8n.io/?utm_source=n8n-internal&utm_medium=powered_by&utm_campaign=n8n-nodes-base.telegram_instanceId)',
});
});
});

View file

@ -0,0 +1,22 @@
import type { IWebhookDescription } from 'n8n-workflow';
export const sendAndWaitWebhooks: IWebhookDescription[] = [
{
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
responseData: '',
path: '={{ $nodeId }}',
restartWebhook: true,
isFullPath: true,
},
];

View file

@ -137,6 +137,11 @@ export function getSendAndWaitProperties(
targetProperties: INodeProperties[], targetProperties: INodeProperties[],
resource: string = 'message', resource: string = 'message',
additionalProperties: INodeProperties[] = [], additionalProperties: INodeProperties[] = [],
options?: {
noButtonStyle?: boolean;
defaultApproveLabel?: string;
defaultDisapproveLabel?: string;
},
) { ) {
const buttonStyle: INodeProperties = { const buttonStyle: INodeProperties = {
displayName: 'Button Style', displayName: 'Button Style',
@ -154,6 +159,77 @@ export function getSendAndWaitProperties(
}, },
], ],
}; };
const approvalOptionsValues = [
{
displayName: 'Type of Approval',
name: 'approvalType',
type: 'options',
placeholder: 'Add option',
default: 'single',
options: [
{
name: 'Approve Only',
value: 'single',
},
{
name: 'Approve and Disapprove',
value: 'double',
},
],
},
{
displayName: 'Approve Button Label',
name: 'approveLabel',
type: 'string',
default: options?.defaultApproveLabel || 'Approve',
displayOptions: {
show: {
approvalType: ['single', 'double'],
},
},
},
...[
options?.noButtonStyle
? ({} as INodeProperties)
: {
...buttonStyle,
displayName: 'Approve Button Style',
name: 'buttonApprovalStyle',
displayOptions: {
show: {
approvalType: ['single', 'double'],
},
},
},
],
{
displayName: 'Disapprove Button Label',
name: 'disapproveLabel',
type: 'string',
default: options?.defaultDisapproveLabel || 'Decline',
displayOptions: {
show: {
approvalType: ['double'],
},
},
},
...[
options?.noButtonStyle
? ({} as INodeProperties)
: {
...buttonStyle,
displayName: 'Disapprove Button Style',
name: 'buttonDisapprovalStyle',
default: 'secondary',
displayOptions: {
show: {
approvalType: ['double'],
},
},
},
],
].filter((p) => Object.keys(p).length) as INodeProperties[];
const sendAndWait: INodeProperties[] = [ const sendAndWait: INodeProperties[] = [
...targetProperties, ...targetProperties,
{ {
@ -216,68 +292,7 @@ export function getSendAndWaitProperties(
{ {
displayName: 'Values', displayName: 'Values',
name: 'values', name: 'values',
values: [ values: approvalOptionsValues,
{
displayName: 'Type of Approval',
name: 'approvalType',
type: 'options',
placeholder: 'Add option',
default: 'single',
options: [
{
name: 'Approve Only',
value: 'single',
},
{
name: 'Approve and Disapprove',
value: 'double',
},
],
},
{
displayName: 'Approve Button Label',
name: 'approveLabel',
type: 'string',
default: 'Approve',
displayOptions: {
show: {
approvalType: ['single', 'double'],
},
},
},
{
...buttonStyle,
displayName: 'Approve Button Style',
name: 'buttonApprovalStyle',
displayOptions: {
show: {
approvalType: ['single', 'double'],
},
},
},
{
displayName: 'Disapprove Button Label',
name: 'disapproveLabel',
type: 'string',
default: 'Decline',
displayOptions: {
show: {
approvalType: ['double'],
},
},
},
{
...buttonStyle,
displayName: 'Disapprove Button Style',
name: 'buttonDisapprovalStyle',
default: 'secondary',
displayOptions: {
show: {
approvalType: ['double'],
},
},
},
],
}, },
], ],
displayOptions: { displayOptions: {