diff --git a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts index b215702bf43..2df8701e3f1 100644 --- a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts +++ b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts @@ -9,8 +9,9 @@ import type { } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; -import { apiRequest, getImageBySize, getSecretToken } from './GenericFunctions'; +import { apiRequest, getSecretToken } from './GenericFunctions'; import type { IEvent } from './IEvent'; +import { downloadFile } from './util/triggerUtils'; export class TelegramTrigger implements INodeType { description: INodeTypeDescription = { @@ -277,86 +278,10 @@ export class TelegramTrigger implements INodeType { const additionalFields = this.getNodeParameter('additionalFields') as IDataObject; - if (additionalFields.download === true) { - let imageSize = 'large'; + if (additionalFields.download) { + const downloadFilesResult = await downloadFile(this, credentials, bodyData, additionalFields); - let key: 'message' | 'channel_post' = 'message'; - - if (bodyData.channel_post) { - key = 'channel_post'; - } - - if ( - (bodyData[key]?.photo && Array.isArray(bodyData[key]?.photo)) || - bodyData[key]?.document || - bodyData[key]?.video - ) { - if (additionalFields.imageSize) { - imageSize = additionalFields.imageSize as string; - } - - let fileId; - - if (bodyData[key]?.photo) { - let image = getImageBySize( - bodyData[key]?.photo as IDataObject[], - imageSize, - ) as IDataObject; - - // When the image is sent from the desktop app telegram does not resize the image - // So return the only image available - // Basically the Image Size parameter would work just when the images comes from the mobile app - if (image === undefined) { - image = bodyData[key]!.photo![0]; - } - - fileId = image.file_id; - } else if (bodyData[key]?.video) { - fileId = bodyData[key]?.video?.file_id; - } else { - fileId = bodyData[key]?.document?.file_id; - } - - const { - result: { file_path }, - } = await apiRequest.call(this, 'GET', `getFile?file_id=${fileId}`, {}); - - const file = await apiRequest.call( - this, - 'GET', - '', - {}, - {}, - { - json: false, - encoding: null, - uri: `${credentials.baseUrl}/file/bot${credentials.accessToken}/${file_path}`, - resolveWithFullResponse: true, - }, - ); - - const data = Buffer.from(file.body as string); - - const fileName = file_path.split('/').pop(); - - const binaryData = await this.helpers.prepareBinaryData( - data as unknown as Buffer, - fileName as string, - ); - - return { - workflowData: [ - [ - { - json: bodyData as unknown as IDataObject, - binary: { - data: binaryData, - }, - }, - ], - ], - }; - } + if (Object.entries(downloadFilesResult).length !== 0) return downloadFilesResult; } if (nodeVersion >= 1.2) { diff --git a/packages/nodes-base/nodes/Telegram/tests/TelegramTrigger.node.test.ts b/packages/nodes-base/nodes/Telegram/tests/TelegramTrigger.node.test.ts new file mode 100644 index 00000000000..c926544b44d --- /dev/null +++ b/packages/nodes-base/nodes/Telegram/tests/TelegramTrigger.node.test.ts @@ -0,0 +1,176 @@ +import { mock } from 'jest-mock-extended'; +import { type INode, type Workflow } from 'n8n-workflow'; + +import { testWebhookTriggerNode } from '@test/nodes/TriggerHelpers'; + +import { TelegramTrigger } from '../TelegramTrigger.node'; + +jest.mock('../GenericFunctions', () => { + const originalModule = jest.requireActual('../GenericFunctions'); + return { + ...originalModule, + apiRequest: jest.fn(async function (method: string, query: string) { + if (method === 'GET' && query.startsWith('getFile')) { + return { result: { file_path: 'path/to/file' } }; + } + if (method === 'GET' && !query) { + return { body: 'test-file' }; + } + return { result: { file_path: 'path/to/file' } }; + }), + }; +}); + +describe('TelegramTrigger', () => { + let mockResult: Record; + + const binaryData = { + fileName: 'mocked-file', + mimeType: 'image/png', + data: Buffer.from('mocked-data'), + }; + + const createOptions = ({ + type, + attachment, + useChannelPost = false, + imageSize = 'small', + }: { + type: string; + attachment: any; + useChannelPost?: boolean; + imageSize?: string; + }) => { + const messageField = useChannelPost ? 'channel_post' : 'message'; + mockResult[messageField] = { + chat: { id: 555 }, + from: { id: 666 }, + [type]: attachment, + }; + + return { + helpers: { + prepareBinaryData: jest.fn().mockResolvedValue(binaryData), + }, + credential: { + accessToken: '999999', + baseUrl: 'https://api.telegram.org', + }, + workflow: mock({ id: '1', active: true }), + node: mock({ + id: '2', + parameters: { + additionalFields: { + download: true, + chatIds: '555', + imageSize, + }, + }, + }), + headerData: { + 'x-telegram-bot-api-secret-token': '1_2', + }, + bodyData: { + [messageField]: { + [type]: attachment, + chat: { id: 555 }, + from: { id: 666 }, + }, + }, + }; + }; + + beforeEach(() => { + mockResult = {}; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Webhook', () => { + test('should return empty object in download files if attachment is not photo, video, or document', async () => { + const options = createOptions({ type: 'text', attachment: 'Hello world!' }); + const { responseData } = await testWebhookTriggerNode(TelegramTrigger, options); + + expect(responseData).toEqual({ workflowData: [[{ json: mockResult }]] }); + }); + + test('should set the image if it is coming for desktop telegram', async () => { + const options = createOptions({ + type: 'photo', + attachment: [{ file_id: 'photo0909' }], + imageSize: 'desktop', + }); + const { responseData } = await testWebhookTriggerNode(TelegramTrigger, options); + + expect(responseData).toEqual({ + workflowData: [[{ json: mockResult, binary: { data: binaryData } }]], + }); + }); + + it.each([ + { type: 'photo', attachment: [{ file_id: 'photo0909' }] }, + { type: 'video', attachment: { file_id: 'vid666' } }, + { type: 'document', attachment: { file_id: '0909' } }, + ])( + 'should return downloaded files for %s attachments with channel_post', + async ({ type, attachment }) => { + const options = createOptions({ type, attachment, useChannelPost: true }); + const { responseData } = await testWebhookTriggerNode(TelegramTrigger, options); + + expect(responseData).toEqual({ + workflowData: [[{ json: mockResult, binary: { data: binaryData } }]], + }); + }, + ); + + it.each([ + { type: 'photo', attachment: [{ file_id: 'photo0909' }] }, + { type: 'video', attachment: { file_id: 'vid666' } }, + { type: 'document', attachment: { file_id: '0909' } }, + ])( + 'should return downloaded files for %s attachments with message', + async ({ type, attachment }) => { + const options = createOptions({ type, attachment }); + const { responseData } = await testWebhookTriggerNode(TelegramTrigger, options); + + expect(responseData).toEqual({ + workflowData: [[{ json: mockResult, binary: { data: binaryData } }]], + }); + }, + ); + + test('should receive a webhook event without downloading files', async () => { + mockResult.message = { + chat: { id: 555 }, + from: { id: 666 }, + }; + + const { responseData } = await testWebhookTriggerNode(TelegramTrigger, { + workflow: mock({ id: '1', active: true }), + node: mock({ + id: '2', + parameters: { + additionalFields: { + download: false, + chatIds: '555', + userIds: '666', + }, + }, + }), + headerData: { + 'x-telegram-bot-api-secret-token': '1_2', + }, + bodyData: { + message: { + chat: { id: 555 }, + from: { id: 666 }, + }, + }, + }); + + expect(responseData).toEqual({ workflowData: [[{ json: mockResult }]] }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Telegram/util/triggerUtils.ts b/packages/nodes-base/nodes/Telegram/util/triggerUtils.ts new file mode 100644 index 00000000000..4a3dc61e89d --- /dev/null +++ b/packages/nodes-base/nodes/Telegram/util/triggerUtils.ts @@ -0,0 +1,95 @@ +import { + type ICredentialDataDecryptedObject, + type IDataObject, + type IWebhookFunctions, + type IWebhookResponseData, +} from 'n8n-workflow'; + +import { apiRequest, getImageBySize } from '../GenericFunctions'; +import { type IEvent } from '../IEvent'; + +export const downloadFile = async ( + webhookFunctions: IWebhookFunctions, + credentials: ICredentialDataDecryptedObject, + bodyData: IEvent, + additionalFields: IDataObject, +): Promise => { + let imageSize = 'large'; + + let key: 'message' | 'channel_post' = 'message'; + + if (bodyData.channel_post) { + key = 'channel_post'; + } + + if ( + (bodyData[key]?.photo && Array.isArray(bodyData[key]?.photo)) || + bodyData[key]?.document || + bodyData[key]?.video + ) { + if (additionalFields.imageSize) { + imageSize = additionalFields.imageSize as string; + } + + let fileId; + + if (bodyData[key]?.photo) { + let image = getImageBySize(bodyData[key]?.photo as IDataObject[], imageSize) as IDataObject; + + // When the image is sent from the desktop app telegram does not resize the image + // So return the only image available + // Basically the Image Size parameter would work just when the images comes from the mobile app + if (image === undefined) { + image = bodyData[key]!.photo![0]; + } + + fileId = image.file_id; + } else if (bodyData[key]?.video) { + fileId = bodyData[key]?.video?.file_id; + } else { + fileId = bodyData[key]?.document?.file_id; + } + + const { + result: { file_path }, + } = await apiRequest.call(webhookFunctions, 'GET', `getFile?file_id=${fileId}`, {}); + + const file = await apiRequest.call( + webhookFunctions, + 'GET', + '', + {}, + {}, + { + json: false, + encoding: null, + uri: `${credentials.baseUrl}/file/bot${credentials.accessToken}/${file_path}`, + resolveWithFullResponse: true, + }, + ); + + const data = Buffer.from(file.body as string); + + const fileName = file_path.split('/').pop(); + + const binaryData = await webhookFunctions.helpers.prepareBinaryData( + data as unknown as Buffer, + fileName as string, + ); + + return { + workflowData: [ + [ + { + json: bodyData as unknown as IDataObject, + binary: { + data: binaryData, + }, + }, + ], + ], + }; + } + + return {}; +}; diff --git a/packages/nodes-base/test/nodes/TriggerHelpers.ts b/packages/nodes-base/test/nodes/TriggerHelpers.ts index a0a15942f7e..91fbd9aae5a 100644 --- a/packages/nodes-base/test/nodes/TriggerHelpers.ts +++ b/packages/nodes-base/test/nodes/TriggerHelpers.ts @@ -1,4 +1,5 @@ import type * as express from 'express'; +import { type IncomingHttpHeaders } from 'http'; import { mock } from 'jest-mock-extended'; import get from 'lodash/get'; import merge from 'lodash/merge'; @@ -31,6 +32,7 @@ type TestTriggerNodeOptions = { timezone?: string; workflowStaticData?: IDataObject; credential?: ICredentialDataDecryptedObject; + helpers?: Partial; }; type TestWebhookTriggerNodeOptions = TestTriggerNodeOptions & { @@ -38,6 +40,8 @@ type TestWebhookTriggerNodeOptions = TestTriggerNodeOptions & { request?: MockDeepPartial; bodyData?: IDataObject; childNodes?: NodeTypeAndVersion[]; + workflow?: Workflow; + headerData?: IncomingHttpHeaders; }; type TestPollingTriggerNodeOptions = TestTriggerNodeOptions & {}; @@ -117,6 +121,7 @@ export async function testWebhookTriggerNode( const version = trigger.description.version; const node = merge( { + id: options.node?.id ?? '1', type: trigger.description.name, name: trigger.description.defaults.name ?? `Test Node (${trigger.description.name})`, typeVersion: typeof version === 'number' ? version : version.at(-1), @@ -130,6 +135,7 @@ export async function testWebhookTriggerNode( returnJsonArray, registerCron: (cronExpression, onTick) => scheduledTaskManager.registerCron(workflow, cronExpression, onTick), + prepareBinaryData: options.helpers?.prepareBinaryData ?? jest.fn(), }); const request = mock({ @@ -147,13 +153,14 @@ export async function testWebhookTriggerNode( getMode: () => options.mode ?? 'trigger', getInstanceId: () => 'instanceId', getBodyData: () => options.bodyData ?? {}, - getHeaderData: () => ({}), + getHeaderData: () => options.headerData ?? {}, getInputConnectionData: async () => ({}), getNodeWebhookUrl: (name) => `/test-webhook-url/${name}`, getParamsData: () => ({}), getQueryData: () => ({}), getRequestObject: () => request, getResponseObject: () => response, + getWorkflow: () => options.workflow ?? mock(), getWebhookName: () => options.webhookName ?? 'default', getWorkflowStaticData: () => options.workflowStaticData ?? {}, getNodeParameter: (parameterName, fallback) => get(node.parameters, parameterName) ?? fallback,