mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
committed by
GitHub
parent
47913f87de
commit
fdefb14e6b
@@ -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.
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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)' },
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user