mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
test(core): Cover Microsoft Teams channel and group chat conversations (no-changelog) (#38801)
This commit is contained in:
parent
eb684d2256
commit
4f64f8c4a6
|
|
@ -91,6 +91,57 @@ const mentionEntity = {
|
|||
mentioned: { id: `28:${TEAMS_APP_ID}`, name: 'n8n Agent' },
|
||||
};
|
||||
|
||||
// A channel conversation id carries the id of the thread's root message, so
|
||||
// every thread in a channel is its own conversation — and its own session.
|
||||
// `channelData.team.aadGroupId` + `channelData.channel.id` are what mark the
|
||||
// activity as a channel one.
|
||||
|
||||
const TEAMS_CHANNEL_ID = '19:channel_engineering@thread.tacv2';
|
||||
const TEAMS_TEAM_ID = '19:team_platform@thread.tacv2';
|
||||
const TEAMS_TEAM_AAD_GROUP_ID = '77777777-6666-5555-4444-333333333333';
|
||||
|
||||
export const TEAMS_CHANNEL_CONVERSATION_ID = `${TEAMS_CHANNEL_ID};messageid=1700000000001`;
|
||||
export const TEAMS_CHANNEL_SECOND_THREAD_CONVERSATION_ID = `${TEAMS_CHANNEL_ID};messageid=1700000000002`;
|
||||
|
||||
function channelActivity(overrides: Partial<TeamsActivityFixture> = {}): TeamsActivityFixture {
|
||||
return baseActivity({
|
||||
conversation: {
|
||||
id: TEAMS_CHANNEL_CONVERSATION_ID,
|
||||
conversationType: 'channel',
|
||||
tenantId: TEAMS_TENANT_ID,
|
||||
},
|
||||
channelData: {
|
||||
tenant: { id: TEAMS_TENANT_ID },
|
||||
team: { id: TEAMS_TEAM_ID, aadGroupId: TEAMS_TEAM_AAD_GROUP_ID },
|
||||
channel: { id: TEAMS_CHANNEL_ID },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export const channelMention: TeamsActivityFixture = channelActivity({
|
||||
id: 'activity-channel-1',
|
||||
text: '<at>n8n Agent</at> hello agent',
|
||||
entities: [mentionEntity],
|
||||
});
|
||||
|
||||
/** No mention: it only reaches the agent because the mention subscribed the thread. */
|
||||
export const channelFollowUp: TeamsActivityFixture = channelActivity({
|
||||
id: 'activity-channel-2',
|
||||
text: 'follow up',
|
||||
});
|
||||
|
||||
export const channelSecondThreadMention: TeamsActivityFixture = channelActivity({
|
||||
id: 'activity-channel-3',
|
||||
text: '<at>n8n Agent</at> a different thread',
|
||||
entities: [mentionEntity],
|
||||
conversation: {
|
||||
id: TEAMS_CHANNEL_SECOND_THREAD_CONVERSATION_ID,
|
||||
conversationType: 'channel',
|
||||
tenantId: TEAMS_TENANT_ID,
|
||||
},
|
||||
});
|
||||
|
||||
// A group chat conversation id carries no message id, so the whole chat is one
|
||||
// conversation and one session.
|
||||
|
||||
|
|
@ -113,3 +164,42 @@ export const groupChatMention: TeamsActivityFixture = groupChatActivity({
|
|||
text: '<at>n8n Agent</at> hello agent',
|
||||
entities: [mentionEntity],
|
||||
});
|
||||
|
||||
export const groupChatFollowUp: TeamsActivityFixture = groupChatActivity({
|
||||
id: 'activity-group-2',
|
||||
text: 'follow up',
|
||||
});
|
||||
|
||||
/**
|
||||
* A group chat whose conversation id does not start with `19:`.
|
||||
*
|
||||
* The adapter reads the conversation id alone as "not a direct message" only
|
||||
* when it starts with `19:`. This shape disagrees with that rule, so the
|
||||
* explicit `conversationType` must carry the classification instead. If it does
|
||||
* not, the chat is read as a direct message, and the adapter then holds the
|
||||
* webhook response open for the whole agent run.
|
||||
*/
|
||||
export const TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID = 'a:group_chat_legacy';
|
||||
|
||||
export const legacyGroupChatMention: TeamsActivityFixture = groupChatActivity({
|
||||
id: 'activity-legacy-group-1',
|
||||
text: '<at>n8n Agent</at> hello agent',
|
||||
entities: [mentionEntity],
|
||||
conversation: {
|
||||
id: TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID,
|
||||
conversationType: 'groupChat',
|
||||
tenantId: TEAMS_TENANT_ID,
|
||||
isGroup: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const legacyGroupChatFollowUp: TeamsActivityFixture = groupChatActivity({
|
||||
id: 'activity-legacy-group-2',
|
||||
text: 'follow up',
|
||||
conversation: {
|
||||
id: TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID,
|
||||
conversationType: 'groupChat',
|
||||
tenantId: TEAMS_TENANT_ID,
|
||||
isGroup: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,12 +3,20 @@ import { isRecord } from '@n8n/utils/is-record';
|
|||
import { createTeamsReplayContext } from '../../../__tests__/helpers/teams/replay-test-context';
|
||||
import {
|
||||
cardAction,
|
||||
channelFollowUp,
|
||||
channelMention,
|
||||
channelSecondThreadMention,
|
||||
dmFollowUp,
|
||||
dmMessage,
|
||||
groupChatFollowUp,
|
||||
groupChatMention,
|
||||
legacyGroupChatFollowUp,
|
||||
legacyGroupChatMention,
|
||||
selfMessage,
|
||||
TEAMS_CHANNEL_CONVERSATION_ID,
|
||||
TEAMS_DM_CONVERSATION_ID,
|
||||
TEAMS_GROUP_CHAT_CONVERSATION_ID,
|
||||
TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID,
|
||||
TEAMS_USER_ID,
|
||||
} from '../../../__tests__/helpers/teams/synthetic-fixtures';
|
||||
|
||||
|
|
@ -76,6 +84,7 @@ describe('Microsoft Teams integration scenarios', () => {
|
|||
try {
|
||||
await ctx.sendWebhook(dmMessage);
|
||||
const firstThreadId = ctx.latestThreadId();
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
|
||||
|
||||
await ctx.sendWebhook(dmFollowUp);
|
||||
|
||||
|
|
@ -338,4 +347,173 @@ describe('Microsoft Teams integration scenarios', () => {
|
|||
await ctx.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes a team channel mention to the agent and replies in the same thread', async () => {
|
||||
const ctx = await createTeamsReplayContext();
|
||||
try {
|
||||
await expect(ctx.sendWebhook(channelMention)).resolves.toMatchObject({ status: 200 });
|
||||
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: '@n8n Agent hello agent',
|
||||
author: { id: TEAMS_USER_ID, name: 'Alice' },
|
||||
integrationType: 'teams',
|
||||
}),
|
||||
);
|
||||
expect(ctx.lastPost()?.body).toMatchObject({
|
||||
type: 'message',
|
||||
text: 'Got it',
|
||||
conversation: { id: TEAMS_CHANNEL_CONVERSATION_ID },
|
||||
});
|
||||
} finally {
|
||||
await ctx.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes an unmentioned follow-up in a subscribed channel thread to the same session', async () => {
|
||||
const ctx = await createTeamsReplayContext();
|
||||
try {
|
||||
await ctx.sendWebhook(channelMention);
|
||||
const firstThreadId = ctx.latestThreadId();
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
|
||||
|
||||
await ctx.sendWebhook(channelFollowUp);
|
||||
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ message: 'follow up' }),
|
||||
);
|
||||
expect(ctx.latestThreadId()).toBe(firstThreadId);
|
||||
} finally {
|
||||
await ctx.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a separate session for each thread in the same channel', async () => {
|
||||
const ctx = await createTeamsReplayContext();
|
||||
try {
|
||||
await ctx.sendWebhook(channelMention);
|
||||
const firstThreadId = ctx.latestThreadId();
|
||||
|
||||
await ctx.sendWebhook(channelSecondThreadMention);
|
||||
|
||||
expect(ctx.latestThreadId()).not.toBe(firstThreadId);
|
||||
} finally {
|
||||
await ctx.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps one session for a whole group chat', async () => {
|
||||
const ctx = await createTeamsReplayContext();
|
||||
try {
|
||||
await ctx.sendWebhook(groupChatMention);
|
||||
const firstThreadId = ctx.latestThreadId();
|
||||
|
||||
// One run for one mention: Teams retries an activity that the adapter
|
||||
// leaves unanswered, and a retry would show up as a second run here.
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.lastPost()?.body).toMatchObject({
|
||||
conversation: { id: TEAMS_GROUP_CHAT_CONVERSATION_ID },
|
||||
});
|
||||
|
||||
await ctx.sendWebhook(groupChatFollowUp);
|
||||
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ message: 'follow up' }),
|
||||
);
|
||||
expect(ctx.latestThreadId()).toBe(firstThreadId);
|
||||
} finally {
|
||||
await ctx.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads a group chat whose id does not start with 19: as a group chat', async () => {
|
||||
const { decodeThreadId } = await import('@chat-adapter/teams');
|
||||
const ctx = await createTeamsReplayContext();
|
||||
try {
|
||||
await ctx.sendWebhook(legacyGroupChatMention);
|
||||
const firstThreadId = ctx.latestThreadId();
|
||||
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
|
||||
// Without the explicit conversation type the adapter would read this id
|
||||
// as a direct message, and then hold the webhook response open for the
|
||||
// whole agent run. The thread id carries the agent prefix, so decode
|
||||
// only the platform part.
|
||||
const platformThreadId = (firstThreadId ?? '').slice((firstThreadId ?? '').indexOf('teams:'));
|
||||
expect(decodeThreadId(platformThreadId)).toMatchObject({
|
||||
conversationId: TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID,
|
||||
conversationType: 'groupChat',
|
||||
});
|
||||
|
||||
await ctx.sendWebhook(legacyGroupChatFollowUp);
|
||||
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ message: 'follow up' }),
|
||||
);
|
||||
expect(ctx.latestThreadId()).toBe(firstThreadId);
|
||||
} finally {
|
||||
await ctx.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('resumes a suspended approval from a card click in a channel thread', async () => {
|
||||
const ctx = await createTeamsReplayContext({
|
||||
stream: [
|
||||
{
|
||||
type: 'tool-call-suspended',
|
||||
runId: 'run-channel-card-1',
|
||||
toolCallId: 'tool-channel-card-1',
|
||||
toolName: 'approval',
|
||||
suspendPayload: {
|
||||
type: 'approval',
|
||||
toolName: 'send_teams_message',
|
||||
displayName: 'Send Teams message',
|
||||
args: { text: 'Continue?' },
|
||||
},
|
||||
resumeSchema: {
|
||||
type: 'object',
|
||||
properties: { approved: { type: 'boolean' } },
|
||||
required: ['approved'],
|
||||
},
|
||||
},
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
await ctx.sendWebhook(channelMention);
|
||||
|
||||
const cardMessageId = ctx.lastPostedMessageId();
|
||||
if (!cardMessageId) throw new Error('Expected the approval card to have been posted');
|
||||
const approve = cardActions(ctx.lastPost()?.body)[0];
|
||||
if (!approve) throw new Error('Expected an Adaptive Card action on the approval card');
|
||||
|
||||
ctx.nextStream([
|
||||
{ type: 'text-delta', id: 'resume-text', delta: 'Card handled' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
]);
|
||||
await ctx.sendWebhook(
|
||||
cardAction(approve.data as Record<string, unknown>, cardMessageId, channelMention),
|
||||
);
|
||||
|
||||
expect(ctx.agentExecutor.resumeForChat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'run-channel-card-1',
|
||||
toolCallId: 'tool-channel-card-1',
|
||||
integrationType: 'teams',
|
||||
resumeData: { approved: true },
|
||||
}),
|
||||
);
|
||||
// The resume payload carries no thread id, so the outbound reply is what
|
||||
// proves the resumed run stayed in the channel thread.
|
||||
expect(ctx.lastPost()?.body).toMatchObject({
|
||||
conversation: { id: TEAMS_CHANNEL_CONVERSATION_ID },
|
||||
});
|
||||
// Removed rather than relabelled, so a stale button cannot be clicked.
|
||||
expect(ctx.lastDelete()?.body.uri).toContain(cardMessageId);
|
||||
} finally {
|
||||
await ctx.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,9 +39,18 @@ const TENANT_ID_DOMAIN =
|
|||
* endpoint is configured once in Azure Bot Service, so there is no API call to
|
||||
* register or release it and no `onAfterConnect`/`onBeforeDisconnect` hook.
|
||||
*
|
||||
* This first slice targets 1:1 direct messages. Group chats and channels are not
|
||||
* blocked, but they are untested: without RSC permissions Teams only delivers an
|
||||
* @-mention there, so nothing arrives ambiently.
|
||||
* Direct messages, team channels and group chats are all supported. Outside a
|
||||
* DM the bot must be @-mentioned once; the mention subscribes the conversation,
|
||||
* so later messages reach the agent without a mention. In a channel that
|
||||
* subscription covers the one thread, because the thread id carries the root
|
||||
* message id. In a group chat it covers the whole chat.
|
||||
*
|
||||
* Whether a later message arrives at all is a setup choice. The manifest
|
||||
* carries `ChannelMessage.Read.Group` and `ChatMessage.Read.Chat` only when
|
||||
* `readAllChannelMessages` and `readAllGroupMessages` are on. Teams grants them
|
||||
* when the app is added to a team or a chat, and they also make Teams deliver
|
||||
* every message instead of mentions alone. Without them Teams delivers only the
|
||||
* mention, and the subscription stays inert.
|
||||
*/
|
||||
@Service()
|
||||
export class TeamsIntegration extends AgentChatIntegration {
|
||||
|
|
@ -58,13 +67,15 @@ export class TeamsIntegration extends AgentChatIntegration {
|
|||
|
||||
readonly builderGuidance = {
|
||||
capabilities: [
|
||||
'Receive Microsoft Teams direct messages as agent triggers.',
|
||||
'Respond in the same Microsoft Teams conversation.',
|
||||
'Receive Microsoft Teams direct messages, team channel messages and group chat messages as agent triggers.',
|
||||
'Respond in the same Microsoft Teams conversation, and in the same channel thread.',
|
||||
'Stay in the conversation after an @-mention, so later messages need no mention.',
|
||||
'Render Adaptive Cards with buttons.',
|
||||
],
|
||||
useIntegrationWhen: [
|
||||
'The agent should be chatted with from Microsoft Teams or act as a Teams bot.',
|
||||
'The agent needs to reply to Teams users in the same conversation context.',
|
||||
'The agent should take part in a Microsoft Teams channel or group chat.',
|
||||
],
|
||||
useNodeToolWhen: [
|
||||
'Microsoft Teams is only a backend API step and the agent does not need to be connected as a Teams chat surface.',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user