feat(Microsoft Teams Node): Add the chat member get many operation (#37745)

This commit is contained in:
Yen Su 2026-09-07 10:24:43 +00:00 committed by GitHub
parent 6f64dc6ae3
commit 42dcc709f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 465 additions and 69 deletions

View File

@ -0,0 +1,33 @@
{
"type": "object",
"properties": {
"@odata.type": {
"type": "string"
},
"displayName": {
"type": "string"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"roles": {
"type": "array",
"items": {
"type": "string"
}
},
"tenantId": {
"type": "string"
},
"userId": {
"type": "string"
},
"visibleHistoryStartDateTime": {
"type": "string"
}
},
"version": 1
}

View File

@ -0,0 +1,107 @@
{
"name": "Teams chatMember getAll returnAll",
"nodes": [
{
"parameters": {},
"id": "6666-9999-77777",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [880, 380]
},
{
"parameters": {
"resource": "chatMember",
"operation": "getAll",
"chatId": {
"__rl": true,
"value": "19:ff1e2d3c4b5a69788796a5b4c3d2e1f0@thread.v2",
"mode": "list",
"cachedResultName": "Project team (group)"
},
"returnAll": true
},
"id": "6666-5555-77777",
"name": "Microsoft Teams",
"type": "n8n-nodes-base.microsoftTeams",
"typeVersion": 2,
"position": [1100, 380],
"credentials": {
"microsoftTeamsOAuth2Api": {
"id": "6isd5ytvA0qV78eK",
"name": "Microsoft Teams account"
}
}
},
{
"parameters": {},
"id": "9d1a2e59-c71c-486c-b3ac-dec6adbc26b3",
"name": "No Operation, do nothing",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1400, 380]
}
],
"pinData": {
"No Operation, do nothing": [
{
"json": {
"id": "MCMjMCMjMjM3ODZjYTYtN2ZmMi00NjcyLTg3ZDAtNWM2NDllZTBhMzM3IyMxOTplYmVkOWFkNDJjOTA0ZDZjODNhZGYwZGIzNjAwNTNlY0B0aHJlYWQudjIjI2U3NmY0NTZmLTVjM2YtNGYxZS05ZDVlLTRkOGYwZjZhYjExMQ==",
"@odata.type": "#microsoft.graph.aadUserConversationMember",
"roles": ["owner"],
"displayName": "Ann Smith",
"userId": "e76f456f-5c3f-4f1e-9d5e-4d8f0f6ab111",
"email": "ann@contoso.com",
"tenantId": "23786ca6-7ff2-4672-87d0-5c649ee0a337",
"visibleHistoryStartDateTime": "0001-01-01T00:00:00Z"
}
},
{
"json": {
"id": "MCMjMCMjMjM3ODZjYTYtN2ZmMi00NjcyLTg3ZDAtNWM2NDllZTBhMzM3IyMxOTplYmVkOWFkNDJjOTA0ZDZjODNhZGYwZGIzNjAwNTNlY0B0aHJlYWQudjIjI2FhMTFiYjIyLTVjM2YtNGYxZS05ZDVlLTRkOGYwZjZhYjIyMg==",
"@odata.type": "#microsoft.graph.aadUserConversationMember",
"roles": ["owner"],
"displayName": "Bob Jones",
"userId": "aa11bb22-5c3f-4f1e-9d5e-4d8f0f6ab222",
"email": "bob@contoso.com",
"tenantId": "23786ca6-7ff2-4672-87d0-5c649ee0a337",
"visibleHistoryStartDateTime": null
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Microsoft Teams",
"type": "main",
"index": 0
}
]
]
},
"Microsoft Teams": {
"main": [
[
{
"node": "No Operation, do nothing",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "8a1b2c3d-0004-4000-8000-000000000004",
"id": "i3NYGF0LXV4qDFV9",
"meta": {
"instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363"
},
"tags": []
}

View File

@ -0,0 +1,57 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import nock from 'nock';
import { credentials } from '../../../credentials';
// Membership ids are opaque base64 of `0##0##<tenantId>##<chatId>##<userId>`,
// derived here from the tenant, chat and user ids below so the fixture is self-consistent.
const member1 = {
id: 'MCMjMCMjMjM3ODZjYTYtN2ZmMi00NjcyLTg3ZDAtNWM2NDllZTBhMzM3IyMxOTplYmVkOWFkNDJjOTA0ZDZjODNhZGYwZGIzNjAwNTNlY0B0aHJlYWQudjIjI2U3NmY0NTZmLTVjM2YtNGYxZS05ZDVlLTRkOGYwZjZhYjExMQ==',
'@odata.type': '#microsoft.graph.aadUserConversationMember',
roles: ['owner'],
displayName: 'Ann Smith',
userId: 'e76f456f-5c3f-4f1e-9d5e-4d8f0f6ab111',
email: 'ann@contoso.com',
tenantId: '23786ca6-7ff2-4672-87d0-5c649ee0a337',
visibleHistoryStartDateTime: '0001-01-01T00:00:00Z',
};
const member2 = {
id: 'MCMjMCMjMjM3ODZjYTYtN2ZmMi00NjcyLTg3ZDAtNWM2NDllZTBhMzM3IyMxOTplYmVkOWFkNDJjOTA0ZDZjODNhZGYwZGIzNjAwNTNlY0B0aHJlYWQudjIjI2FhMTFiYjIyLTVjM2YtNGYxZS05ZDVlLTRkOGYwZjZhYjIyMg==',
'@odata.type': '#microsoft.graph.aadUserConversationMember',
roles: ['owner'],
displayName: 'Bob Jones',
userId: 'aa11bb22-5c3f-4f1e-9d5e-4d8f0f6ab222',
email: 'bob@contoso.com',
tenantId: '23786ca6-7ff2-4672-87d0-5c649ee0a337',
visibleHistoryStartDateTime: null,
};
describe('Test MicrosoftTeamsV2, chatMember => getAll', () => {
// Registered WITHOUT `.query(...)`: this endpoint supports no OData parameters, so
// the test fails if `$top` is ever added.
// The workflow passes this chat id By ID and percent-encoded, the way the RLC hint
// tells users to copy it out of a Teams URL. The decoded form below is what
// `validateMicrosoftGraphId` must interpolate into the path.
nock('https://graph.microsoft.com')
.get('/v1.0/chats/19:ebed9ad42c904d6c83adf0db360053ec@thread.v2/members')
.reply(200, { value: [member1, member2] });
// Two pages for the returnAll workflow, on its own chat id so the interceptors
// cannot be matched in the wrong order. The nextLink must stay same-origin or the
// transport refuses it.
nock('https://graph.microsoft.com')
.get('/v1.0/chats/19:ff1e2d3c4b5a69788796a5b4c3d2e1f0@thread.v2/members')
.reply(200, {
value: [member1],
'@odata.nextLink':
'https://graph.microsoft.com/v1.0/chats/19:ff1e2d3c4b5a69788796a5b4c3d2e1f0@thread.v2/members?$skiptoken=page2',
})
.get('/v1.0/chats/19:ff1e2d3c4b5a69788796a5b4c3d2e1f0@thread.v2/members')
.query({ $skiptoken: 'page2' })
.reply(200, { value: [member2] });
new NodeTestHarness().setupTests({
credentials,
workflowFiles: ['getAll.workflow.json', 'getAll.returnAll.workflow.json'],
});
});

View File

@ -0,0 +1,94 @@
{
"name": "Teams chatMember getAll",
"nodes": [
{
"parameters": {},
"id": "6666-9999-77777",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [880, 380]
},
{
"parameters": {
"resource": "chatMember",
"operation": "getAll",
"chatId": {
"__rl": true,
"value": "19%3Aebed9ad42c904d6c83adf0db360053ec%40thread.v2",
"mode": "id"
},
"limit": 1
},
"id": "6666-5555-77777",
"name": "Microsoft Teams",
"type": "n8n-nodes-base.microsoftTeams",
"typeVersion": 2,
"position": [1100, 380],
"credentials": {
"microsoftTeamsOAuth2Api": {
"id": "6isd5ytvA0qV78eK",
"name": "Microsoft Teams account"
}
}
},
{
"parameters": {},
"id": "9d1a2e59-c71c-486c-b3ac-dec6adbc26b3",
"name": "No Operation, do nothing",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [1400, 380]
}
],
"pinData": {
"No Operation, do nothing": [
{
"json": {
"id": "MCMjMCMjMjM3ODZjYTYtN2ZmMi00NjcyLTg3ZDAtNWM2NDllZTBhMzM3IyMxOTplYmVkOWFkNDJjOTA0ZDZjODNhZGYwZGIzNjAwNTNlY0B0aHJlYWQudjIjI2U3NmY0NTZmLTVjM2YtNGYxZS05ZDVlLTRkOGYwZjZhYjExMQ==",
"@odata.type": "#microsoft.graph.aadUserConversationMember",
"roles": ["owner"],
"displayName": "Ann Smith",
"userId": "e76f456f-5c3f-4f1e-9d5e-4d8f0f6ab111",
"email": "ann@contoso.com",
"tenantId": "23786ca6-7ff2-4672-87d0-5c649ee0a337",
"visibleHistoryStartDateTime": "0001-01-01T00:00:00Z"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Microsoft Teams",
"type": "main",
"index": 0
}
]
]
},
"Microsoft Teams": {
"main": [
[
{
"node": "No Operation, do nothing",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "8a1b2c3d-0003-4000-8000-000000000003",
"id": "i3NYGF0LXV4qDFV9",
"meta": {
"instanceId": "b888bd11cd1ddbb95450babf3e199556799d999b896f650de768b8370ee50363"
},
"tags": []
}

View File

@ -72,6 +72,26 @@ describe('Microsoft Teams V2 — Service Principal runtime guards', () => {
expect(transport.microsoftApiRequestAllItems).not.toHaveBeenCalled();
});
it.each(['getAll'])(
'chatMember:%s throws a static error and issues no request under SP',
async (operation) => {
selectSp({
resource: 'chatMember',
operation,
chatId: 'chatID',
returnAll: true,
});
await expect(node.execute.call(ctx)).rejects.toThrow(
'Chat members are not available with the Service Principal credential',
);
expect(transport.microsoftApiRequest).not.toHaveBeenCalled();
expect(transport.microsoftApiRequestAllItems).not.toHaveBeenCalled();
// the guard reads only the `authentication` parameter, never the credential itself
expect(ctx.getCredentials).not.toHaveBeenCalled();
},
);
it.each([
['create', { subject: 'Sync', startDateTime: '2026-09-01T10:00:00Z' }],
['get', { meetingId: { __rl: true, mode: 'id', value: 'meeting-id' } }],

View File

@ -32,76 +32,40 @@ describe('Microsoft Teams Service Principal displayOptions contract', () => {
});
});
describe('chatMessage — hidden under SP via the slash-prefixed field-level key', () => {
it('operation selector carries hide["/authentication"] = [SP]', () => {
const op = actionProps.find(
(p) => p.name === 'operation' && p.displayOptions?.show?.resource?.includes('chatMessage'),
);
expect(isSpHidden(op)).toBe(true);
// distinct from the un-prefixed credential gate
expect(op?.displayOptions?.hide?.authentication).toBeUndefined();
});
describe.each(['chatMessage', 'chatMember', 'onlineMeeting'])(
'%s - hidden under SP via the slash-prefixed field-level key',
(resource) => {
it('operation selector carries hide["/authentication"] = [SP]', () => {
const op = actionProps.find(
(p) => p.name === 'operation' && p.displayOptions?.show?.resource?.includes(resource),
);
expect(isSpHidden(op)).toBe(true);
// distinct from the un-prefixed credential gate
expect(op?.displayOptions?.hide?.authentication).toBeUndefined();
});
it('every chatMessage field copy is hidden under SP', () => {
const chatFields = actionProps.filter((p) =>
p.displayOptions?.show?.resource?.includes('chatMessage'),
);
// the chatId RLC, message, contentType, options, messageId, send-and-wait props…
const gatedFields = chatFields.filter((p) => p.type !== 'notice' && p.name !== 'operation');
expect(gatedFields.length).toBeGreaterThan(0);
for (const field of gatedFields) {
expect(isSpHidden(field)).toBe(true);
}
});
it('every field copy is hidden under SP', () => {
const fields = actionProps.filter((p) =>
p.displayOptions?.show?.resource?.includes(resource),
);
const gatedFields = fields.filter((p) => p.type !== 'notice' && p.name !== 'operation');
expect(gatedFields.length).toBeGreaterThan(0);
for (const field of gatedFields) {
expect(isSpHidden(field)).toBe(true);
}
});
it('shows an SP notice for the chatMessage resource', () => {
const notice = actionProps.find(
(p) =>
p.type === 'notice' &&
p.displayOptions?.show?.resource?.includes('chatMessage') &&
p.displayOptions?.show?.authentication?.includes(SERVICE_PRINCIPAL_AUTH),
);
expect(notice).toBeDefined();
expect(notice?.displayOptions?.show?.authentication).toEqual([SERVICE_PRINCIPAL_AUTH]);
});
});
describe('onlineMeeting — hidden under SP via the slash-prefixed field-level key', () => {
it('operation selector carries hide["/authentication"] = [SP]', () => {
const op = actionProps.find(
(p) =>
p.name === 'operation' && p.displayOptions?.show?.resource?.includes('onlineMeeting'),
);
expect(isSpHidden(op)).toBe(true);
// distinct from the un-prefixed credential gate
expect(op?.displayOptions?.hide?.authentication).toBeUndefined();
});
it('every onlineMeeting field copy is hidden under SP', () => {
const meetingFields = actionProps.filter((p) =>
p.displayOptions?.show?.resource?.includes('onlineMeeting'),
);
// subject, start/end times, options, meetingId…
const gatedFields = meetingFields.filter(
(p) => p.type !== 'notice' && p.name !== 'operation',
);
expect(gatedFields.length).toBeGreaterThan(0);
for (const field of gatedFields) {
expect(isSpHidden(field)).toBe(true);
}
});
it('shows an SP notice for the onlineMeeting resource', () => {
const notice = actionProps.find(
(p) =>
p.type === 'notice' &&
p.displayOptions?.show?.resource?.includes('onlineMeeting') &&
p.displayOptions?.show?.authentication?.includes(SERVICE_PRINCIPAL_AUTH),
);
expect(notice).toBeDefined();
expect(notice?.displayOptions?.show?.authentication).toEqual([SERVICE_PRINCIPAL_AUTH]);
});
});
it('shows an SP notice for the resource', () => {
const notice = actionProps.find(
(p) =>
p.type === 'notice' &&
p.displayOptions?.show?.resource?.includes(resource) &&
p.displayOptions?.show?.authentication?.includes(SERVICE_PRINCIPAL_AUTH),
);
expect(notice?.displayOptions?.show?.authentication).toEqual([SERVICE_PRINCIPAL_AUTH]);
});
},
);
describe('channelMessage — only the sending operations are hidden under SP', () => {
const fieldsFor = (operation: string) =>

View File

@ -0,0 +1,38 @@
import type { INodeProperties, IExecuteFunctions } from 'n8n-workflow';
import { returnAllOrLimit } from '@utils/descriptions';
import { updateDisplayOptions } from '@utils/utilities';
import { chatRLC } from '../../descriptions';
import { buildTeamsPath, microsoftApiRequestAllItems, SP_HIDE } from '../../transport';
import { throwIfChatMemberUnsupported } from './sharedGuard';
const properties: INodeProperties[] = [chatRLC, ...returnAllOrLimit];
const displayOptions = {
show: {
resource: ['chatMember'],
operation: ['getAll'],
},
hide: {
...SP_HIDE,
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://learn.microsoft.com/en-us/graph/api/chat-list-members?view=graph-rest-1.0
// The chat picker cannot list chats app-only; fail before any request.
throwIfChatMemberUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
const endpoint = buildTeamsPath.call(this, ['/v1.0/chats/', { id: chatId }, '/members']);
// No `$top`: this endpoint supports no OData query parameters, so the limit is
// applied client-side while paging through @odata.nextLink.
const returnAll = this.getNodeParameter('returnAll', i);
const limit = returnAll ? undefined : this.getNodeParameter('limit', i);
return await microsoftApiRequestAllItems.call(this, 'value', 'GET', endpoint, {}, {}, limit);
}

View File

@ -0,0 +1,47 @@
import type { INodeProperties } from 'n8n-workflow';
import * as getAll from './getAll.operation';
import { SERVICE_PRINCIPAL_AUTH, SP_HIDE } from '../../transport';
export { getAll };
export const description: INodeProperties[] = [
{
displayName:
'Chat members are not available with the Service Principal credential. The chat picker cannot list chats app-only; use an OAuth2 credential for chat actions.',
name: 'chatMemberServicePrincipalNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
resource: ['chatMember'],
authentication: [SERVICE_PRINCIPAL_AUTH],
},
},
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['chatMember'],
},
hide: {
...SP_HIDE,
},
},
options: [
{
name: 'Get Many',
value: 'getAll',
description: 'Get many members of a chat',
action: 'Get many chat members',
},
],
default: 'getAll',
},
...getAll.description,
];

View File

@ -0,0 +1,25 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { getTeamsCredentialType, SERVICE_PRINCIPAL_AUTH } from '../../transport';
/**
* Throws a static `NodeOperationError` when the node is configured with the
* app-only (Service Principal) credential. The endpoints themselves have
* application permissions, but the chat picker does not: `GET /chats` is
* delegated-only (app-only must go through `GET /users/{id}/chats`), so every
* chatMember operation guards on this BEFORE any request - covering
* hand-edited workflows that bypass the hidden UI.
*/
export function throwIfChatMemberUnsupported(this: IExecuteFunctions): void {
if (getTeamsCredentialType.call(this) === SERVICE_PRINCIPAL_AUTH) {
throw new NodeOperationError(
this.getNode(),
'Chat members are not available with the Service Principal credential',
{
description:
'The chat picker cannot list chats app-only. Use an OAuth2 credential for chat actions.',
},
);
}
}

View File

@ -3,6 +3,7 @@ import type { AllEntities } from 'n8n-workflow';
type NodeMap = {
channel: 'create' | 'deleteChannel' | 'get' | 'getAll' | 'update';
channelMessage: 'create' | 'get' | 'getAll' | 'getAllReplies' | 'reply';
chatMember: 'getAll';
chatMessage: 'create' | 'get' | 'getAll' | 'sendAndWait';
onlineMeeting: 'create' | 'get';
task: 'create' | 'deleteTask' | 'get' | 'getAll' | 'update';

View File

@ -9,6 +9,7 @@ import {
import * as channel from './channel';
import * as channelMessage from './channelMessage';
import * as chatMember from './chatMember';
import * as chatMessage from './chatMessage';
import type { MicrosoftTeamsType } from './node.type';
import * as onlineMeeting from './onlineMeeting';
@ -64,6 +65,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
instanceId,
);
break;
case 'chatMember':
responseData = await chatMember[microsoftTeamsTypeData.operation].execute.call(this, i);
break;
case 'chatMessage':
responseData = await chatMessage[microsoftTeamsTypeData.operation].execute.call(
this,

View File

@ -3,6 +3,7 @@ import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
import * as channel from './channel';
import * as channelMessage from './channelMessage';
import * as chatMember from './chatMember';
import * as chatMessage from './chatMessage';
import * as onlineMeeting from './onlineMeeting';
import * as task from './task';
@ -94,6 +95,10 @@ export const versionDescription: INodeTypeDescription = {
name: 'Channel Message',
value: 'channelMessage',
},
{
name: 'Chat Member',
value: 'chatMember',
},
{
name: 'Chat Message',
value: 'chatMessage',
@ -112,6 +117,7 @@ export const versionDescription: INodeTypeDescription = {
...channel.description,
...channelMessage.description,
...chatMember.description,
...chatMessage.description,
...onlineMeeting.description,
...task.description,

View File

@ -67,7 +67,7 @@ export function validateMicrosoftGraphId(id: string, node: INode): string {
throw new NodeOperationError(node, 'A required ID is empty', {
// Teams wording; make it injectable (UserTargetMessages pattern in
// nodes/Microsoft/GenericFunctions.ts) when the second consumer (SharePoint v2) lands.
description: 'Set the team, channel, plan, bucket, task or meeting ID and try again.',
description: 'Set the chat, team, channel, plan, bucket, task or meeting ID and try again.',
});
}
let value: string;