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",