feat(n8n Microsoft Teams Node): New trigger node (#12875)

This commit is contained in:
Stanimira Rikova
2025-05-15 14:03:00 +03:00
committed by GitHub
parent ab1047ebde
commit 870940b543
9 changed files with 1043 additions and 3 deletions

View File

@@ -17,5 +17,18 @@ export class MicrosoftTeamsOAuth2Api implements ICredentialType {
type: 'hidden', type: 'hidden',
default: 'openid offline_access User.ReadWrite.All Group.ReadWrite.All Chat.ReadWrite', default: 'openid offline_access User.ReadWrite.All Group.ReadWrite.All Chat.ReadWrite',
}, },
{
displayName: `
Microsoft Teams Trigger requires the following permissions:
<br><code>ChannelMessage.Read.All</code>
<br><code>Chat.Read.All</code>
<br><code>Team.ReadBasic.All</code>
<br><code>Subscription.ReadWrite.All</code>
<br>Configure these permissions in <a href="https://portal.azure.com">Microsoft Entra</a>
`,
name: 'notice',
type: 'notice',
default: '',
},
]; ];
} }

View File

@@ -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/"
}
]
}
}

View File

@@ -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<boolean> {
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<boolean> {
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<boolean> {
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<IWebhookResponseData> {
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;
}
}

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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<TeamResponse[]> {
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<ChannelResponse[]> {
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<SubscriptionResponse> {
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<string | string[]> {
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.`,
});
}
}
}

View File

@@ -5,13 +5,14 @@ import type {
JsonObject, JsonObject,
IHttpRequestMethods, IHttpRequestMethods,
IRequestOptions, IRequestOptions,
IHookFunctions,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow';
import { capitalize } from '../../../../../utils/utilities'; import { capitalize } from '../../../../../utils/utilities';
export async function microsoftApiRequest( export async function microsoftApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions, this: IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions,
method: IHttpRequestMethods, method: IHttpRequestMethods,
resource: string, resource: string,
body: any = {}, body: any = {},
@@ -56,7 +57,6 @@ export async function microsoftApiRequestAllItems(
propertyName: string, propertyName: string,
method: IHttpRequestMethods, method: IHttpRequestMethods,
endpoint: string, endpoint: string,
body: any = {}, body: any = {},
query: IDataObject = {}, query: IDataObject = {},
): Promise<any> { ): Promise<any> {
@@ -83,7 +83,6 @@ export async function microsoftApiRequestAllItemsSkip(
propertyName: string, propertyName: string,
method: IHttpRequestMethods, method: IHttpRequestMethods,
endpoint: string, endpoint: string,
body: any = {}, body: any = {},
query: IDataObject = {}, query: IDataObject = {},
): Promise<any> { ): Promise<any> {

View File

@@ -655,6 +655,7 @@
"dist/nodes/Microsoft/Sql/MicrosoftSql.node.js", "dist/nodes/Microsoft/Sql/MicrosoftSql.node.js",
"dist/nodes/Microsoft/Storage/AzureStorage.node.js", "dist/nodes/Microsoft/Storage/AzureStorage.node.js",
"dist/nodes/Microsoft/Teams/MicrosoftTeams.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/Microsoft/ToDo/MicrosoftToDo.node.js",
"dist/nodes/Mindee/Mindee.node.js", "dist/nodes/Mindee/Mindee.node.js",
"dist/nodes/Misp/Misp.node.js", "dist/nodes/Misp/Misp.node.js",