diff --git a/packages/nodes-base/credentials/MicrosoftTeamsOAuth2Api.credentials.ts b/packages/nodes-base/credentials/MicrosoftTeamsOAuth2Api.credentials.ts index 0476c2e007..4865d61d60 100644 --- a/packages/nodes-base/credentials/MicrosoftTeamsOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/MicrosoftTeamsOAuth2Api.credentials.ts @@ -17,5 +17,18 @@ export class MicrosoftTeamsOAuth2Api implements ICredentialType { type: 'hidden', default: 'openid offline_access User.ReadWrite.All Group.ReadWrite.All Chat.ReadWrite', }, + { + displayName: ` + Microsoft Teams Trigger requires the following permissions: +
ChannelMessage.Read.All +
Chat.Read.All +
Team.ReadBasic.All +
Subscription.ReadWrite.All +
Configure these permissions in Microsoft Entra + `, + name: 'notice', + type: 'notice', + default: '', + }, ]; } diff --git a/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.json b/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.json new file mode 100644 index 0000000000..5b628aeeb1 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.microsoftTeamsTrigger", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Communication"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/microsoft/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.microsoftteamstrigger/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.ts b/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.ts new file mode 100644 index 0000000000..7ce0212342 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.ts @@ -0,0 +1,397 @@ +import type { + IExecuteFunctions, + INodeType, + INodeTypeDescription, + IHookFunctions, + IWebhookFunctions, + IWebhookResponseData, + IDataObject, + ILoadOptionsFunctions, + JsonObject, +} from 'n8n-workflow'; +import { NodeApiError, NodeConnectionTypes } from 'n8n-workflow'; + +import type { WebhookNotification, SubscriptionResponse } from './v2/helpers/types'; +import { createSubscription, getResourcePath } from './v2/helpers/utils-trigger'; +import { listSearch } from './v2/methods'; +import { microsoftApiRequest, microsoftApiRequestAllItems } from './v2/transport'; + +export class MicrosoftTeamsTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Microsoft Teams Trigger', + name: 'microsoftTeamsTrigger', + icon: 'file:teams.svg', + group: ['trigger'], + version: 1, + description: + 'Triggers workflows in n8n based on events from Microsoft Teams, such as new messages or team updates, using specified configurations.', + subtitle: 'Microsoft Teams Trigger', + defaults: { + name: 'Microsoft Teams Trigger', + }, + credentials: [ + { + name: 'microsoftTeamsOAuth2Api', + required: true, + }, + ], + inputs: [], + outputs: [NodeConnectionTypes.Main], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Trigger On', + name: 'event', + type: 'options', + default: 'newChannelMessage', + options: [ + { + name: 'New Channel', + value: 'newChannel', + description: 'A new channel is created', + }, + { + name: 'New Channel Message', + value: 'newChannelMessage', + description: 'A message is posted to a channel', + }, + { + name: 'New Chat', + value: 'newChat', + description: 'A new chat is created', + }, + { + name: 'New Chat Message', + value: 'newChatMessage', + description: 'A message is posted to a chat', + }, + { + name: 'New Team Member', + value: 'newTeamMember', + description: 'A new member is added to a team', + }, + ], + description: 'Select the event to trigger the workflow', + }, + { + displayName: 'Watch All Teams', + name: 'watchAllTeams', + type: 'boolean', + default: false, + description: 'Whether to watch for the event in all the available teams', + displayOptions: { + show: { + event: ['newChannel', 'newChannelMessage', 'newTeamMember'], + }, + }, + }, + { + displayName: 'Team', + name: 'teamId', + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + required: true, + description: 'Select a team from the list, enter an ID or a URL', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a team...', + typeOptions: { + searchListMethod: 'getTeams', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g., 61165b04-e4cc-4026-b43f-926b4e2a7182', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: + 'e.g., https://teams.microsoft.com/l/team/19%3A...groupId=your-team-id&tenantId=...', + extractValue: { + type: 'regex', + regex: /groupId=([0-9a-fA-F-]{36})/, + }, + }, + ], + displayOptions: { + show: { + event: ['newChannel', 'newChannelMessage', 'newTeamMember'], + watchAllTeams: [false], + }, + }, + }, + { + displayName: 'Watch All Channels', + name: 'watchAllChannels', + type: 'boolean', + default: false, + description: 'Whether to watch for the event in all the available channels', + displayOptions: { + show: { + event: ['newChannelMessage'], + watchAllTeams: [false], + }, + }, + }, + { + displayName: 'Channel', + name: 'channelId', + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + required: true, + description: 'Select a channel from the list, enter an ID or a URL', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a channel...', + typeOptions: { + searchListMethod: 'getChannels', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: 'e.g., 19:-xlxyqXNSCxpI1SDzgQ_L9ZvzSR26pgphq1BJ9y7QJE1@thread.tacv2', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'e.g., https://teams.microsoft.com/l/channel/19%3A...@thread.tacv2/...', + extractValue: { + type: 'regex', + regex: /channel\/([^\/?]+)/, + }, + }, + ], + displayOptions: { + show: { + event: ['newChannelMessage'], + watchAllTeams: [false], + watchAllChannels: [false], + }, + }, + }, + { + displayName: 'Watch All Chats', + name: 'watchAllChats', + type: 'boolean', + default: false, + description: 'Whether to watch for the event in all the available chats', + displayOptions: { + show: { + event: ['newChatMessage'], + }, + }, + }, + { + displayName: 'Chat', + name: 'chatId', + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + required: true, + description: 'Select a chat from the list, enter an ID or a URL', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a chat...', + typeOptions: { + searchListMethod: 'getChats', + searchable: true, + }, + }, + { + displayName: 'By ID', + name: 'id', + type: 'string', + placeholder: '19:7e2f1174-e8ee-4859-b8b1-a8d1cc63d276@unq.gbl.spaces', + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://teams.microsoft.com/_#/conversations/CHAT_ID', + extractValue: { + type: 'regex', + regex: /conversations\/([^\/?]+)/i, + }, + }, + ], + displayOptions: { + show: { + event: ['newChatMessage'], + watchAllChats: [false], + }, + }, + }, + ], + }; + + methods = { + listSearch, + }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const event = this.getNodeParameter('event', 0) as string; + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + + try { + const subscriptions = (await microsoftApiRequestAllItems.call( + this as unknown as ILoadOptionsFunctions, + 'value', + 'GET', + '/v1.0/subscriptions', + )) as SubscriptionResponse[]; + + const matchingSubscriptions = subscriptions.filter( + (subscription) => subscription.notificationUrl === webhookUrl, + ); + + const now = new Date(); + const thresholdMs = 5 * 60 * 1000; + const validSubscriptions = matchingSubscriptions.filter((subscription) => { + const expiration = new Date(subscription.expirationDateTime); + return expiration.getTime() - now.getTime() > thresholdMs; + }); + + const resourcePaths = await getResourcePath.call(this, event); + const requiredResources = Array.isArray(resourcePaths) ? resourcePaths : [resourcePaths]; + + const subscribedResources = validSubscriptions.map((sub) => sub.resource); + const allResourcesSubscribed = requiredResources.every((resource) => + subscribedResources.includes(resource), + ); + + if (allResourcesSubscribed) { + webhookData.subscriptionIds = validSubscriptions.map((sub) => sub.id); + return true; + } + + return false; + } catch (error) { + return false; + } + }, + + async create(this: IHookFunctions): Promise { + const event = this.getNodeParameter('event', 0) as string; + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + + if (!webhookUrl || !webhookUrl.startsWith('https://')) { + throw new NodeApiError(this.getNode(), { + message: 'Invalid Notification URL', + description: `The webhook URL "${webhookUrl}" is invalid. Microsoft Graph requires an HTTPS URL.`, + }); + } + + const resourcePaths = await getResourcePath.call(this, event); + const subscriptionIds: string[] = []; + + if (Array.isArray(resourcePaths)) { + await Promise.all( + resourcePaths.map(async (resource) => { + const subscription = await createSubscription.call(this, webhookUrl, resource); + subscriptionIds.push(subscription.id); + return subscription; + }), + ); + + webhookData.subscriptionIds = subscriptionIds; + } else { + const subscription = await createSubscription.call(this, webhookUrl, resourcePaths); + webhookData.subscriptionIds = [subscription.id]; + } + + return true; + }, + + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + const storedIds = webhookData.subscriptionIds as string[] | undefined; + + if (!Array.isArray(storedIds)) { + return false; + } + + try { + await Promise.all( + storedIds.map(async (subscriptionId) => { + try { + await microsoftApiRequest.call( + this as unknown as IExecuteFunctions, + 'DELETE', + `/v1.0/subscriptions/${subscriptionId}`, + ); + } catch (error) { + if ((error as JsonObject).httpStatusCode !== 404) { + throw error; + } + } + }), + ); + + delete webhookData.subscriptionIds; + return true; + } catch (error) { + return false; + } + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const req = this.getRequestObject(); + const res = this.getResponseObject(); + + // Handle Microsoft Graph validation request + if (req.query.validationToken) { + res.status(200).send(req.query.validationToken); + return { noWebhookResponse: true }; + } + + const eventNotifications = req.body.value as WebhookNotification[]; + const response: IWebhookResponseData = { + workflowData: eventNotifications.map((event) => [ + { + json: (event.resourceData as IDataObject) ?? event, + }, + ]), + }; + + return response; + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/MicrosoftTeamTrigger.test.ts b/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/MicrosoftTeamTrigger.test.ts new file mode 100644 index 0000000000..ac5b2ef6ab --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/MicrosoftTeamTrigger.test.ts @@ -0,0 +1,232 @@ +import { mock } from 'jest-mock-extended'; + +import { MicrosoftTeamsTrigger } from '../../MicrosoftTeamsTrigger.node'; +import { microsoftApiRequest, microsoftApiRequestAllItems } from '../../v2/transport'; + +jest.mock('../../v2/transport', () => ({ + microsoftApiRequest: { + call: jest.fn(), + }, + microsoftApiRequestAllItems: { + call: jest.fn(), + }, +})); + +describe('Microsoft Teams Trigger Node', () => { + let mockWebhookFunctions: any; + + beforeEach(() => { + mockWebhookFunctions = mock(); + jest.clearAllMocks(); + }); + + describe('webhookMethods', () => { + describe('checkExists', () => { + it('should return true if the subscription exists', async () => { + (microsoftApiRequestAllItems.call as jest.Mock).mockResolvedValue([ + { + id: 'sub1', + notificationUrl: 'https://webhook.url', + resource: '/me/chats', + expirationDateTime: new Date(Date.now() + 3600000).toISOString(), + }, + ]); + + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue('https://webhook.url'); + mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({ + node: { + subscriptionIds: ['sub1'], + }, + }); + + mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => { + if (paramName === 'event') return 'newChat'; + return false; + }); + + const result = await new MicrosoftTeamsTrigger().webhookMethods.default.checkExists.call( + mockWebhookFunctions, + ); + expect(result).toBe(true); + }); + it('should return false if the subscription does not exist', async () => { + (microsoftApiRequestAllItems.call as jest.Mock).mockResolvedValue([]); + + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue('https://webhook.url'); + + mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({ + node: {}, + }); + + const result = await new MicrosoftTeamsTrigger().webhookMethods.default.checkExists.call( + mockWebhookFunctions, + ); + expect(result).toBe(false); + }); + + it('should throw an error if the API request fails', async () => { + (microsoftApiRequestAllItems.call as jest.Mock).mockRejectedValue( + new Error('API request failed'), + ); + + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue('https://webhook.url'); + mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({ + node: {}, + }); + + const result = await new MicrosoftTeamsTrigger().webhookMethods.default.checkExists.call( + mockWebhookFunctions, + ); + expect(result).toBe(false); + }); + }); + + describe('create', () => { + it('should create a subscription successfully', async () => { + (microsoftApiRequest.call as jest.Mock).mockResolvedValue({ id: 'subscription123' }); + + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue('https://webhook.url'); + mockWebhookFunctions.getNodeParameter.mockReturnValue('newChat'); + mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({ + node: { + subscriptionIds: [], + }, + }); + + (microsoftApiRequest.call as jest.Mock).mockResolvedValue({ + value: [{ id: 'team1', displayName: 'Team 1' }], + }); + + const result = await new MicrosoftTeamsTrigger().webhookMethods.default.create.call( + mockWebhookFunctions, + ); + + expect(result).toBe(true); + expect(microsoftApiRequest.call).toHaveBeenCalledWith( + mockWebhookFunctions, + 'POST', + '/v1.0/subscriptions', + expect.objectContaining({ + changeType: 'created', + notificationUrl: 'https://webhook.url', + resource: '/me/chats', + expirationDateTime: expect.any(String), + latestSupportedTlsVersion: 'v1_2', + lifecycleNotificationUrl: 'https://webhook.url', + }), + ); + }); + + it('should throw an error if the URL is invalid', async () => { + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue('invalid-url'); + await expect( + new MicrosoftTeamsTrigger().webhookMethods.default.create.call(mockWebhookFunctions), + ).rejects.toThrow('Invalid Notification URL'); + }); + }); + + describe('delete', () => { + it('should delete subscriptions using stored IDs and clean static data', async () => { + const mockWebhookData = { + subscriptionIds: ['subscription123'], + }; + + mockWebhookFunctions.getWorkflowStaticData.mockReturnValue(mockWebhookData); + + (microsoftApiRequest.call as jest.Mock).mockResolvedValue({}); + + const result = await new MicrosoftTeamsTrigger().webhookMethods.default.delete.call( + mockWebhookFunctions, + ); + expect(result).toBe(true); + expect(microsoftApiRequest.call).toHaveBeenCalledWith( + mockWebhookFunctions, + 'DELETE', + '/v1.0/subscriptions/subscription123', + ); + expect(mockWebhookData.subscriptionIds).toBeUndefined(); + }); + + it('should return false if no subscription matches', async () => { + (microsoftApiRequestAllItems.call as jest.Mock).mockResolvedValue([]); + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue('https://webhook.url'); + mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({ + node: { + subscriptionIds: [], + }, + }); + + const result = await new MicrosoftTeamsTrigger().webhookMethods.default.delete.call( + mockWebhookFunctions, + ); + expect(result).toBe(false); + }); + + it('should throw an error if the API request fails', async () => { + (microsoftApiRequestAllItems.call as jest.Mock).mockResolvedValue([ + { id: 'subscription123', notificationUrl: 'https://webhook.url' }, + ]); + (microsoftApiRequest.call as jest.Mock).mockRejectedValue(new Error('API request failed')); + mockWebhookFunctions.getNodeWebhookUrl.mockReturnValue('https://webhook.url'); + mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({ + node: { + subscriptionIds: ['subscription123'], + }, + }); + + const result = await new MicrosoftTeamsTrigger().webhookMethods.default.delete.call( + mockWebhookFunctions, + ); + expect(result).toBe(false); + }); + }); + }); + + describe('webhook', () => { + it('should handle Microsoft Graph validation request correctly', async () => { + const mockRequest = { + query: { + validationToken: 'validation-token', + }, + }; + const mockResponse = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest); + mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse); + + const result = await new MicrosoftTeamsTrigger().webhook.call(mockWebhookFunctions); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.send).toHaveBeenCalledWith('validation-token'); + expect(result.noWebhookResponse).toBe(true); + }); + + it('should process incoming event notifications', async () => { + const mockRequest = { + body: { + value: [{ resourceData: { message: 'test message' } }], + }, + query: {}, + }; + const mockResponse = { + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + mockWebhookFunctions.getRequestObject.mockReturnValue(mockRequest); + mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse); + + const result = await new MicrosoftTeamsTrigger().webhook.call(mockWebhookFunctions); + + expect(result.workflowData).toEqual([ + [ + { + json: { message: 'test message' }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/utiles-trigger.test.ts b/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/utiles-trigger.test.ts new file mode 100644 index 0000000000..8ab5a8ce06 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/utiles-trigger.test.ts @@ -0,0 +1,209 @@ +import { mock } from 'jest-mock-extended'; +import { NodeApiError } from 'n8n-workflow'; + +import { + fetchAllTeams, + fetchAllChannels, + createSubscription, + getResourcePath, +} from '../../v2/helpers/utils-trigger'; +import { microsoftApiRequest } from '../../v2/transport'; + +jest.mock('../../v2/transport', () => ({ + microsoftApiRequest: { + call: jest.fn(), + }, +})); + +describe('Microsoft Teams Helpers Functions', () => { + let mockLoadOptionsFunctions: any; + let mockHookFunctions: any; + + beforeEach(() => { + mockLoadOptionsFunctions = mock(); + mockHookFunctions = mock(); + jest.clearAllMocks(); + }); + + describe('fetchAllTeams', () => { + it('should fetch all teams and map them correctly', async () => { + (microsoftApiRequest.call as jest.Mock).mockResolvedValue({ + value: [ + { id: 'team1', displayName: 'Team 1' }, + { id: 'team2', displayName: 'Team 2' }, + ], + }); + + const result = await fetchAllTeams.call(mockLoadOptionsFunctions); + + expect(result).toEqual([ + { id: 'team1', displayName: 'Team 1' }, + { id: 'team2', displayName: 'Team 2' }, + ]); + expect(microsoftApiRequest.call).toHaveBeenCalledWith( + mockLoadOptionsFunctions, + 'GET', + '/v1.0/me/joinedTeams', + ); + }); + + it('should throw an error if getTeams fails', async () => { + (microsoftApiRequest.call as jest.Mock).mockRejectedValue(new Error('Failed to fetch teams')); + + await expect(fetchAllTeams.call(mockLoadOptionsFunctions)).rejects.toThrow( + 'Failed to fetch teams', + ); + }); + }); + + describe('fetchAllChannels', () => { + it('should fetch all channels for a team and map them correctly', async () => { + (microsoftApiRequest.call as jest.Mock).mockResolvedValue({ + value: [ + { id: 'channel1', displayName: 'Channel 1' }, + { id: 'channel2', displayName: 'Channel 2' }, + ], + }); + + const result = await fetchAllChannels.call(mockLoadOptionsFunctions, 'team1'); + + expect(result).toEqual([ + { id: 'channel1', displayName: 'Channel 1' }, + { id: 'channel2', displayName: 'Channel 2' }, + ]); + expect(microsoftApiRequest.call).toHaveBeenCalledWith( + mockLoadOptionsFunctions, + 'GET', + '/v1.0/teams/team1/channels', + ); + }); + + it('should throw an error if getChannels fails', async () => { + (microsoftApiRequest.call as jest.Mock).mockRejectedValue( + new Error('Failed to fetch channels'), + ); + + await expect(fetchAllChannels.call(mockLoadOptionsFunctions, 'team1')).rejects.toThrow( + 'Failed to fetch channels', + ); + }); + }); + + describe('createSubscription', () => { + it('should create a subscription and return the subscription ID', async () => { + (microsoftApiRequest.call as jest.Mock).mockResolvedValue({ + id: 'subscription123', + resource: '/resource/path', + notificationUrl: 'https://webhook.url', + expirationDateTime: '2024-01-01T00:00:00Z', + }); + + const result = await createSubscription.call( + mockHookFunctions, + 'https://webhook.url', + '/resource/path', + ); + + expect(result).toEqual({ + id: 'subscription123', + resource: '/resource/path', + notificationUrl: 'https://webhook.url', + expirationDateTime: '2024-01-01T00:00:00Z', + }); + }); + + it('should throw a NodeApiError if the API request fails', async () => { + const error = new NodeApiError(mockHookFunctions.getNode(), { + message: 'API request failed', + httpCode: '400', + }); + (microsoftApiRequest.call as jest.Mock).mockRejectedValue(error); + + await expect( + createSubscription.call(mockHookFunctions, 'https://webhook.url', '/resource/path'), + ).rejects.toThrow(NodeApiError); + }); + }); + + describe('getResourcePath', () => { + it('should return the correct resource path for newChat event', async () => { + const result = await getResourcePath.call(mockHookFunctions, 'newChat'); + expect(result).toBe('/me/chats'); + }); + + it('should return the correct resource path for newChatMessage event with watchAllChats', async () => { + mockHookFunctions.getNodeParameter.mockReturnValueOnce(true); + const result = await getResourcePath.call(mockHookFunctions, 'newChatMessage'); + expect(result).toBe('/me/chats/getAllMessages'); + }); + + it('should return the correct resource path for newChatMessage event with chatId', async () => { + mockHookFunctions.getNodeParameter.mockReturnValueOnce(false).mockReturnValueOnce('chat123'); + + const result = await getResourcePath.call(mockHookFunctions, 'newChatMessage'); + expect(result).toBe('/chats/chat123/messages'); + }); + + it('should return the correct resource path for newChatMessage event with chatId missing', async () => { + mockHookFunctions.getNodeParameter.mockReturnValueOnce(false); + mockHookFunctions.getNodeParameter.mockReturnValueOnce(undefined); + + const result = await getResourcePath.call(mockHookFunctions, 'newChatMessage'); + expect(result).toBe('/chats/undefined/messages'); + }); + it('should return the correct resource path for newChannel event', async () => { + mockHookFunctions.getNodeParameter.mockReturnValueOnce(false); + mockHookFunctions.getNodeParameter.mockReturnValueOnce('team123'); + + const result = await getResourcePath.call(mockHookFunctions, 'newChannel'); + expect(result).toBe('/teams/team123/channels'); + }); + + it('should return the correct resource path for newChannel event with teamId missing', async () => { + mockHookFunctions.getNodeParameter.mockReturnValueOnce(undefined); + + const result = await getResourcePath.call(mockHookFunctions, 'newChannel'); + expect(result).toBe('/teams/undefined/channels'); + }); + + it('should return the correct resource path for newChannelMessage event with a specific team and channel', async () => { + mockHookFunctions.getNodeParameter + .mockReturnValueOnce(false) + .mockReturnValueOnce('team123') + .mockReturnValueOnce(false) + .mockReturnValueOnce('channel123'); + + const result = await getResourcePath.call(mockHookFunctions, 'newChannelMessage'); + expect(result).toBe('/teams/team123/channels/channel123/messages'); + }); + + it('should return the correct resource path for newTeamMember event', async () => { + mockHookFunctions.getNodeParameter.mockReturnValueOnce(false); + mockHookFunctions.getNodeParameter.mockReturnValueOnce('team123'); + + const result = await getResourcePath.call(mockHookFunctions, 'newTeamMember'); + expect(result).toBe('/teams/team123/members'); + }); + + it('should return the correct resource path for newTeamMember event with teamId missing', async () => { + mockHookFunctions.getNodeParameter.mockReturnValueOnce(undefined); + + const result = await getResourcePath.call(mockHookFunctions, 'newTeamMember'); + expect(result).toBe('/teams/undefined/members'); + }); + + it('should return the correct resource path for newTeamMember event with watchAllTeams', async () => { + mockHookFunctions.getNodeParameter.mockReturnValueOnce(true); + + (microsoftApiRequest.call as jest.Mock).mockResolvedValueOnce({ + value: [ + { id: 'team1', displayName: 'Team 1' }, + { id: 'team2', displayName: 'Team 2' }, + ], + }); + + const result = await getResourcePath.call(mockHookFunctions, 'newTeamMember'); + expect(result).toEqual(['/teams/team1/members', '/teams/team2/members']); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/helpers/types.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/helpers/types.ts new file mode 100644 index 0000000000..8322c17f7c --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/helpers/types.ts @@ -0,0 +1,29 @@ +export interface TeamResponse { + id: string; + displayName: string; +} + +export interface ChannelResponse { + id: string; + displayName: string; +} + +export interface WebhookNotification { + subscriptionId: string; + resource: string; + resourceData: ResourceData; + tenantId: string; + subscriptionExpirationDateTime: string; +} + +export interface ResourceData { + id: string; + [key: string]: unknown; +} + +export interface SubscriptionResponse { + id: string; + expirationDateTime: string; + notificationUrl: string; + resource: string; +} diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/helpers/utils-trigger.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/helpers/utils-trigger.ts new file mode 100644 index 0000000000..39b09f0733 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/helpers/utils-trigger.ts @@ -0,0 +1,142 @@ +import type { IHookFunctions, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import type { TeamResponse, ChannelResponse, SubscriptionResponse } from './types'; +import { microsoftApiRequest } from '../transport'; + +export async function fetchAllTeams(this: IHookFunctions): Promise { + const { value: teams } = (await microsoftApiRequest.call( + this, + 'GET', + '/v1.0/me/joinedTeams', + )) as { value: TeamResponse[] }; + return teams; +} + +export async function fetchAllChannels( + this: IHookFunctions, + teamId: string, +): Promise { + const { value: channels } = (await microsoftApiRequest.call( + this, + 'GET', + `/v1.0/teams/${teamId}/channels`, + )) as { value: ChannelResponse[] }; + return channels; +} + +export async function createSubscription( + this: IHookFunctions, + webhookUrl: string, + resourcePath: string, +): Promise { + const expirationTime = new Date(Date.now() + 4318 * 60 * 1000).toISOString(); + const body: IDataObject = { + changeType: 'created', + notificationUrl: webhookUrl, + resource: resourcePath, + expirationDateTime: expirationTime, + latestSupportedTlsVersion: 'v1_2', + lifecycleNotificationUrl: webhookUrl, + }; + + const response = (await microsoftApiRequest.call( + this, + 'POST', + '/v1.0/subscriptions', + body, + )) as SubscriptionResponse; + + return response; +} + +export async function getResourcePath( + this: IHookFunctions, + event: string, +): Promise { + switch (event) { + case 'newChat': { + return '/me/chats'; + } + + case 'newChatMessage': { + const watchAllChats = this.getNodeParameter('watchAllChats', false, { + extractValue: true, + }) as boolean; + + if (watchAllChats) { + return '/me/chats/getAllMessages'; + } else { + const chatId = this.getNodeParameter('chatId', undefined, { extractValue: true }) as string; + return `/chats/${decodeURIComponent(chatId)}/messages`; + } + } + + case 'newChannel': { + const watchAllTeams = this.getNodeParameter('watchAllTeams', false, { + extractValue: true, + }) as boolean; + + if (watchAllTeams) { + const teams = await fetchAllTeams.call(this); + return teams.map((team) => `/teams/${team.id}/channels`); + } else { + const teamId = this.getNodeParameter('teamId', undefined, { extractValue: true }) as string; + return `/teams/${teamId}/channels`; + } + } + + case 'newChannelMessage': { + const watchAllTeams = this.getNodeParameter('watchAllTeams', false, { + extractValue: true, + }) as boolean; + + if (watchAllTeams) { + const teams = await fetchAllTeams.call(this); + const teamChannels = await Promise.all( + teams.map(async (team) => { + const channels = await fetchAllChannels.call(this, team.id); + return channels.map((channel) => `/teams/${team.id}/channels/${channel.id}/messages`); + }), + ); + return teamChannels.flat(); + } else { + const teamId = this.getNodeParameter('teamId', undefined, { extractValue: true }) as string; + const watchAllChannels = this.getNodeParameter('watchAllChannels', false, { + extractValue: true, + }) as boolean; + + if (watchAllChannels) { + const channels = await fetchAllChannels.call(this, teamId); + return channels.map((channel) => `/teams/${teamId}/channels/${channel.id}/messages`); + } else { + const channelId = this.getNodeParameter('channelId', undefined, { + extractValue: true, + }) as string; + return `/teams/${teamId}/channels/${decodeURIComponent(channelId)}/messages`; + } + } + } + + case 'newTeamMember': { + const watchAllTeams = this.getNodeParameter('watchAllTeams', false, { + extractValue: true, + }) as boolean; + + if (watchAllTeams) { + const teams = await fetchAllTeams.call(this); + return teams.map((team) => `/teams/${team.id}/members`); + } else { + const teamId = this.getNodeParameter('teamId', undefined, { extractValue: true }) as string; + return `/teams/${teamId}/members`; + } + } + + default: { + throw new NodeOperationError(this.getNode(), { + message: `Invalid event: ${event}`, + description: `The selected event "${event}" is not recognized.`, + }); + } + } +} diff --git a/packages/nodes-base/nodes/Microsoft/Teams/v2/transport/index.ts b/packages/nodes-base/nodes/Microsoft/Teams/v2/transport/index.ts index 4e53faeb76..7f678bf5c5 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/v2/transport/index.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/v2/transport/index.ts @@ -5,13 +5,14 @@ import type { JsonObject, IHttpRequestMethods, IRequestOptions, + IHookFunctions, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; import { capitalize } from '../../../../../utils/utilities'; export async function microsoftApiRequest( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions, method: IHttpRequestMethods, resource: string, body: any = {}, @@ -56,7 +57,6 @@ export async function microsoftApiRequestAllItems( propertyName: string, method: IHttpRequestMethods, endpoint: string, - body: any = {}, query: IDataObject = {}, ): Promise { @@ -83,7 +83,6 @@ export async function microsoftApiRequestAllItemsSkip( propertyName: string, method: IHttpRequestMethods, endpoint: string, - body: any = {}, query: IDataObject = {}, ): Promise { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index c6a3c6d795..3fe369c434 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -655,6 +655,7 @@ "dist/nodes/Microsoft/Sql/MicrosoftSql.node.js", "dist/nodes/Microsoft/Storage/AzureStorage.node.js", "dist/nodes/Microsoft/Teams/MicrosoftTeams.node.js", + "dist/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.js", "dist/nodes/Microsoft/ToDo/MicrosoftToDo.node.js", "dist/nodes/Mindee/Mindee.node.js", "dist/nodes/Misp/Misp.node.js",