improvement(ms-teams): resolve mentions accurately (#1762)

* improvement(ms-teams): resolve mentions accurately

* fix for bots

* add utils file

* add logs

* fix perms issue

* fix scopes

* fetch works for bots

* Revert "fetch works for bots"

This reverts commit 0ac702a8f3.

* update docs
This commit is contained in:
Vikhyath Mondreti
2025-10-29 23:49:05 -07:00
committed by GitHub
parent 47913f87de
commit fdefb14e6b
9 changed files with 364 additions and 12 deletions

View File

@@ -208,3 +208,13 @@ Write or send a message to a Microsoft Teams channel
- Category: `tools`
- Type: `microsoft_teams`
### Mentioning Users
To mention users in your messages (both in chats and channels), wrap their display name in `<at>` tags:
```
<at>John Doe</at> can you review this?
```
The mention will be automatically resolved to the correct user and they will receive a notification in Microsoft Teams. This works for both chat messages and channel messages. Bots/Apps cannot be tagged.

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { generateRequestId } from '@/lib/utils'
import { resolveMentionsForChannel, type TeamsMention } from '@/tools/microsoft_teams/utils'
export const dynamic = 'force-dynamic'
@@ -141,23 +142,55 @@ export async function POST(request: NextRequest) {
}
let messageContent = validatedData.content
let contentType: 'text' | 'html' = 'text'
const mentionEntities: TeamsMention[] = []
try {
const mentionResult = await resolveMentionsForChannel(
validatedData.content,
validatedData.teamId,
validatedData.channelId,
validatedData.accessToken
)
if (mentionResult.hasMentions) {
contentType = 'html'
messageContent = mentionResult.updatedContent
mentionEntities.push(...mentionResult.mentions)
logger.info(`[${requestId}] Resolved ${mentionResult.mentions.length} mention(s)`)
}
} catch (error) {
logger.warn(`[${requestId}] Failed to resolve mentions, continuing without them:`, error)
}
if (attachments.length > 0) {
contentType = 'html'
const attachmentTags = attachments
.map((att) => `<attachment id="${att.id}"></attachment>`)
.join(' ')
messageContent = `${validatedData.content}<br/>${attachmentTags}`
messageContent = `${messageContent}<br/>${attachmentTags}`
}
const messageBody = {
const messageBody: {
body: {
contentType: attachments.length > 0 ? 'html' : 'text',
contentType: 'text' | 'html'
content: string
}
attachments?: any[]
mentions?: TeamsMention[]
} = {
body: {
contentType,
content: messageContent,
},
}
if (attachments.length > 0) {
;(messageBody as any).attachments = attachments
messageBody.attachments = attachments
}
if (mentionEntities.length > 0) {
messageBody.mentions = mentionEntities
}
logger.info(`[${requestId}] Sending message to Teams channel: ${validatedData.channelId}`)

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { generateRequestId } from '@/lib/utils'
import { resolveMentionsForChat, type TeamsMention } from '@/tools/microsoft_teams/utils'
export const dynamic = 'force-dynamic'
@@ -139,23 +140,54 @@ export async function POST(request: NextRequest) {
}
let messageContent = validatedData.content
let contentType: 'text' | 'html' = 'text'
const mentionEntities: TeamsMention[] = []
try {
const mentionResult = await resolveMentionsForChat(
validatedData.content,
validatedData.chatId,
validatedData.accessToken
)
if (mentionResult.hasMentions) {
contentType = 'html'
messageContent = mentionResult.updatedContent
mentionEntities.push(...mentionResult.mentions)
logger.info(`[${requestId}] Resolved ${mentionResult.mentions.length} mention(s)`)
}
} catch (error) {
logger.warn(`[${requestId}] Failed to resolve mentions, continuing without them:`, error)
}
if (attachments.length > 0) {
contentType = 'html'
const attachmentTags = attachments
.map((att) => `<attachment id="${att.id}"></attachment>`)
.join(' ')
messageContent = `${validatedData.content}<br/>${attachmentTags}`
messageContent = `${messageContent}<br/>${attachmentTags}`
}
const messageBody = {
const messageBody: {
body: {
contentType: attachments.length > 0 ? 'html' : 'text',
contentType: 'text' | 'html'
content: string
}
attachments?: any[]
mentions?: TeamsMention[]
} = {
body: {
contentType,
content: messageContent,
},
}
if (attachments.length > 0) {
;(messageBody as any).attachments = attachments
messageBody.attachments = attachments
}
if (mentionEntities.length > 0) {
messageBody.mentions = mentionEntities
}
logger.info(`[${requestId}] Sending message to Teams chat: ${validatedData.chatId}`)

View File

@@ -9,7 +9,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
description: 'Read, write, and create messages',
authMode: AuthMode.OAuth,
longDescription:
'Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel.',
'Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel. To mention users in messages, wrap their name in <at> tags: <at>userName</at>',
docsLink: 'https://docs.sim.ai/tools/microsoft_teams',
category: 'tools',
triggerAllowed: true,
@@ -47,6 +47,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
'Channel.ReadBasic.All',
'ChannelMessage.Send',
'ChannelMessage.Read.All',
'ChannelMember.Read.All',
'Group.Read.All',
'Group.ReadWrite.All',
'Team.ReadBasic.All',
@@ -255,7 +256,10 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
manualChannelId: { type: 'string', description: 'Manual channel identifier' },
teamId: { type: 'string', description: 'Team identifier' },
manualTeamId: { type: 'string', description: 'Manual team identifier' },
content: { type: 'string', description: 'Message content' },
content: {
type: 'string',
description: 'Message content. Mention users with <at>userName</at>',
},
attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' },
files: { type: 'json', description: 'Files to attach (UserFile array)' },
},

View File

@@ -512,6 +512,7 @@ export const auth = betterAuth({
'Channel.ReadBasic.All',
'ChannelMessage.Send',
'ChannelMessage.Read.All',
'ChannelMember.Read.All',
'Group.Read.All',
'Group.ReadWrite.All',
'Team.ReadBasic.All',

View File

@@ -239,6 +239,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
'Channel.ReadBasic.All',
'ChannelMessage.Send',
'ChannelMessage.Read.All',
'ChannelMember.Read.All',
'Group.Read.All',
'Group.ReadWrite.All',
'Team.ReadBasic.All',

View File

@@ -4,6 +4,38 @@ import type { ToolFileData } from '@/tools/types'
const logger = createLogger('MicrosoftTeamsUtils')
interface ParsedMention {
name: string
fullTag: string
mentionId: number
}
interface TeamMember {
id: string
displayName: string
userIdentityType?: string
}
export interface TeamsMention {
id: number
mentionText: string
mentioned:
| {
user: {
id: string
displayName: string
userIdentityType?: string
}
}
| {
application: {
displayName: string
id: string
applicationIdentityType: 'bot'
}
}
}
/**
* Transform raw attachment data from Microsoft Graph API
*/
@@ -99,3 +131,211 @@ export async function fetchHostedContentsForChannelMessage(params: {
return []
}
}
function parseMentions(content: string): ParsedMention[] {
const mentions: ParsedMention[] = []
const mentionRegex = /<at>([^<]+)<\/at>/gi
let match: RegExpExecArray | null
let mentionId = 0
while ((match = mentionRegex.exec(content)) !== null) {
const name = match[1].trim()
if (name) {
mentions.push({
name,
fullTag: match[0],
mentionId: mentionId++,
})
}
}
return mentions
}
async function fetchChatMembers(chatId: string, accessToken: string): Promise<TeamMember[]> {
const response = await fetch(
`https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/members`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
return []
}
const data = await response.json()
return (data.value || []).map((member: TeamMember) => ({
id: member.id,
displayName: member.displayName || '',
userIdentityType: member.userIdentityType,
}))
}
async function fetchChannelMembers(
teamId: string,
channelId: string,
accessToken: string
): Promise<TeamMember[]> {
const response = await fetch(
`https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/members`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
return []
}
const data = await response.json()
return (data.value || []).map((member: TeamMember) => ({
id: member.id,
displayName: member.displayName || '',
userIdentityType: member.userIdentityType,
}))
}
function findMemberByName(members: TeamMember[], name: string): TeamMember | undefined {
const normalizedName = name.trim().toLowerCase()
return members.find((member) => member.displayName.toLowerCase() === normalizedName)
}
export async function resolveMentionsForChat(
content: string,
chatId: string,
accessToken: string
): Promise<{ mentions: TeamsMention[]; hasMentions: boolean; updatedContent: string }> {
const parsedMentions = parseMentions(content)
if (parsedMentions.length === 0) {
return { mentions: [], hasMentions: false, updatedContent: content }
}
const members = await fetchChatMembers(chatId, accessToken)
const mentions: TeamsMention[] = []
const resolvedTags = new Set<string>()
let updatedContent = content
for (const mention of parsedMentions) {
if (resolvedTags.has(mention.fullTag)) {
continue
}
const member = findMemberByName(members, mention.name)
if (member) {
const isBot = member.userIdentityType === 'bot'
if (isBot) {
mentions.push({
id: mention.mentionId,
mentionText: mention.name,
mentioned: {
application: {
displayName: member.displayName,
id: member.id,
applicationIdentityType: 'bot',
},
},
})
} else {
mentions.push({
id: mention.mentionId,
mentionText: mention.name,
mentioned: {
user: {
id: member.id,
displayName: member.displayName,
userIdentityType: member.userIdentityType || 'aadUser',
},
},
})
}
resolvedTags.add(mention.fullTag)
updatedContent = updatedContent.replace(
mention.fullTag,
`<at id="${mention.mentionId}">${mention.name}</at>`
)
}
}
return {
mentions,
hasMentions: mentions.length > 0,
updatedContent,
}
}
export async function resolveMentionsForChannel(
content: string,
teamId: string,
channelId: string,
accessToken: string
): Promise<{ mentions: TeamsMention[]; hasMentions: boolean; updatedContent: string }> {
const parsedMentions = parseMentions(content)
if (parsedMentions.length === 0) {
return { mentions: [], hasMentions: false, updatedContent: content }
}
const members = await fetchChannelMembers(teamId, channelId, accessToken)
const mentions: TeamsMention[] = []
const resolvedTags = new Set<string>()
let updatedContent = content
for (const mention of parsedMentions) {
if (resolvedTags.has(mention.fullTag)) {
continue
}
const member = findMemberByName(members, mention.name)
if (member) {
const isBot = member.userIdentityType === 'bot'
if (isBot) {
mentions.push({
id: mention.mentionId,
mentionText: mention.name,
mentioned: {
application: {
displayName: member.displayName,
id: member.id,
applicationIdentityType: 'bot',
},
},
})
} else {
mentions.push({
id: mention.mentionId,
mentionText: mention.name,
mentioned: {
user: {
id: member.id,
displayName: member.displayName,
userIdentityType: member.userIdentityType || 'aadUser',
},
},
})
}
resolvedTags.add(mention.fullTag)
updatedContent = updatedContent.replace(
mention.fullTag,
`<at id="${mention.mentionId}">${mention.name}</at>`
)
}
}
return {
mentions,
hasMentions: mentions.length > 0,
updatedContent,
}
}

View File

@@ -73,6 +73,12 @@ export const writeChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTea
return '/api/tools/microsoft_teams/write_channel'
}
// If content contains mentions, use custom API route for mention resolution
const hasMentions = /<at>[^<]+<\/at>/i.test(params.content || '')
if (hasMentions) {
return '/api/tools/microsoft_teams/write_channel'
}
const encodedTeamId = encodeURIComponent(teamId)
const encodedChannelId = encodeURIComponent(channelId)
@@ -98,7 +104,8 @@ export const writeChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTea
throw new Error('Content is required')
}
// If using custom API route (with files), pass all params
// If using custom API route (with files or mentions), pass all params
const hasMentions = /<at>[^<]+<\/at>/i.test(params.content || '')
if (params.files && params.files.length > 0) {
return {
accessToken: params.accessToken,
@@ -109,6 +116,15 @@ export const writeChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTea
}
}
if (hasMentions) {
return {
accessToken: params.accessToken,
teamId: params.teamId,
channelId: params.channelId,
content: params.content,
}
}
// Microsoft Teams API expects this specific format for channel messages
const requestBody = {
body: {

View File

@@ -62,6 +62,12 @@ export const writeChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsW
return '/api/tools/microsoft_teams/write_chat'
}
// If content contains mentions, use custom API route for mention resolution
const hasMentions = /<at>[^<]+<\/at>/i.test(params.content || '')
if (hasMentions) {
return '/api/tools/microsoft_teams/write_chat'
}
return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages`
},
method: 'POST',
@@ -82,7 +88,8 @@ export const writeChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsW
throw new Error('Content is required')
}
// If using custom API route (with files), pass all params
// If using custom API route (with files or mentions), pass all params
const hasMentions = /<at>[^<]+<\/at>/i.test(params.content || '')
if (params.files && params.files.length > 0) {
return {
accessToken: params.accessToken,
@@ -92,6 +99,14 @@ export const writeChatTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeamsW
}
}
if (hasMentions) {
return {
accessToken: params.accessToken,
chatId: params.chatId,
content: params.content,
}
}
// Microsoft Teams API expects this specific format
const requestBody = {
body: {