diff --git a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts index ef56d496b7..fad1296281 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_channel/route.ts @@ -4,6 +4,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' import { downloadFileFromStorage, processFilesToUserFiles } from '@/lib/uploads/file-processing' import { generateRequestId } from '@/lib/utils' +import { resolveMentionsForChannel } from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' @@ -140,23 +141,62 @@ export async function POST(request: NextRequest) { } let messageContent = validatedData.content + let contentType: 'text' | 'html' = 'text' + const mentionEntities: Array<{ + type: 'mention' + mentioned: { id: string; name: string } + text: string + }> = [] + + try { + const mentionResult = await resolveMentionsForChannel( + validatedData.content, + validatedData.teamId, + validatedData.channelId, + validatedData.accessToken + ) + + if (mentionResult.hasMentions) { + contentType = 'html' + mentionEntities.push(...mentionResult.entities) + logger.info(`[${requestId}] Resolved ${mentionResult.entities.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) => ``) .join(' ') messageContent = `${validatedData.content}
${attachmentTags}` } - const messageBody = { + const messageBody: { body: { - contentType: attachments.length > 0 ? 'html' : 'text', + contentType: 'text' | 'html' + content: string + } + attachments?: any[] + mentions?: Array<{ + type: 'mention' + mentioned: { id: string; name: string } + text: string + }> + } = { + 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}`) diff --git a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts index f7dd519760..445edf480c 100644 --- a/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts +++ b/apps/sim/app/api/tools/microsoft_teams/write_chat/route.ts @@ -4,6 +4,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid' import { createLogger } from '@/lib/logs/console/logger' import { downloadFileFromStorage, processFilesToUserFiles } from '@/lib/uploads/file-processing' import { generateRequestId } from '@/lib/utils' +import { resolveMentionsForChat } from '@/tools/microsoft_teams/utils' export const dynamic = 'force-dynamic' @@ -138,23 +139,61 @@ export async function POST(request: NextRequest) { } let messageContent = validatedData.content + let contentType: 'text' | 'html' = 'text' + const mentionEntities: Array<{ + type: 'mention' + mentioned: { id: string; name: string } + text: string + }> = [] + + try { + const mentionResult = await resolveMentionsForChat( + validatedData.content, + validatedData.chatId, + validatedData.accessToken + ) + + if (mentionResult.hasMentions) { + contentType = 'html' + mentionEntities.push(...mentionResult.entities) + logger.info(`[${requestId}] Resolved ${mentionResult.entities.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) => ``) .join(' ') messageContent = `${validatedData.content}
${attachmentTags}` } - const messageBody = { + const messageBody: { body: { - contentType: attachments.length > 0 ? 'html' : 'text', + contentType: 'text' | 'html' + content: string + } + attachments?: any[] + mentions?: Array<{ + type: 'mention' + mentioned: { id: string; name: string } + text: string + }> + } = { + 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}`) diff --git a/apps/sim/tools/microsoft_teams/utils.ts b/apps/sim/tools/microsoft_teams/utils.ts index 27e25489dc..20b6939318 100644 --- a/apps/sim/tools/microsoft_teams/utils.ts +++ b/apps/sim/tools/microsoft_teams/utils.ts @@ -4,6 +4,28 @@ import type { ToolFileData } from '@/tools/types' const logger = createLogger('MicrosoftTeamsUtils') +interface ParsedMention { + name: string + fullTag: string + startIndex: number + endIndex: number +} + +interface TeamMember { + id: string + displayName: string + userIdentityType?: string +} + +export interface MentionEntity { + type: 'mention' + mentioned: { + id: string + name: string + } + text: string +} + /** * Transform raw attachment data from Microsoft Graph API */ @@ -99,3 +121,161 @@ export async function fetchHostedContentsForChannelMessage(params: { return [] } } + +function parseMentions(content: string): ParsedMention[] { + const mentions: ParsedMention[] = [] + const mentionRegex = /([^<]+)<\/at>/gi + let match: RegExpExecArray | null + + while ((match = mentionRegex.exec(content)) !== null) { + const name = match[1].trim() + if (name) { + mentions.push({ + name, + fullTag: match[0], + startIndex: match.index, + endIndex: match.index + match[0].length, + }) + } + } + + return mentions +} + +async function fetchChatMembers(chatId: string, accessToken: string): Promise { + 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 { + 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<{ entities: MentionEntity[]; hasMentions: boolean }> { + const parsedMentions = parseMentions(content) + + if (parsedMentions.length === 0) { + return { entities: [], hasMentions: false } + } + + const members = await fetchChatMembers(chatId, accessToken) + const entities: MentionEntity[] = [] + const resolvedNames = new Set() + + for (const mention of parsedMentions) { + if (resolvedNames.has(mention.fullTag)) { + continue + } + + const member = findMemberByName(members, mention.name) + + if (member) { + entities.push({ + type: 'mention', + mentioned: { + id: member.id, + name: member.displayName, + }, + text: mention.fullTag, + }) + resolvedNames.add(mention.fullTag) + } + } + + return { + entities, + hasMentions: entities.length > 0, + } +} + +export async function resolveMentionsForChannel( + content: string, + teamId: string, + channelId: string, + accessToken: string +): Promise<{ entities: MentionEntity[]; hasMentions: boolean }> { + const parsedMentions = parseMentions(content) + + if (parsedMentions.length === 0) { + return { entities: [], hasMentions: false } + } + + const members = await fetchChannelMembers(teamId, channelId, accessToken) + const entities: MentionEntity[] = [] + const resolvedNames = new Set() + + for (const mention of parsedMentions) { + if (resolvedNames.has(mention.fullTag)) { + continue + } + + const member = findMemberByName(members, mention.name) + + if (member) { + entities.push({ + type: 'mention', + mentioned: { + id: member.id, + name: member.displayName, + }, + text: mention.fullTag, + }) + resolvedNames.add(mention.fullTag) + } + } + + return { + entities, + hasMentions: entities.length > 0, + } +} diff --git a/apps/sim/tools/microsoft_teams/write_channel.ts b/apps/sim/tools/microsoft_teams/write_channel.ts index 043cda86d7..3b372cc323 100644 --- a/apps/sim/tools/microsoft_teams/write_channel.ts +++ b/apps/sim/tools/microsoft_teams/write_channel.ts @@ -73,6 +73,12 @@ export const writeChannelTool: ToolConfig[^<]+<\/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[^<]+<\/at>/i.test(params.content || '') if (params.files && params.files.length > 0) { return { accessToken: params.accessToken, @@ -109,6 +116,15 @@ export const writeChannelTool: ToolConfig[^<]+<\/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[^<]+<\/at>/i.test(params.content || '') if (params.files && params.files.length > 0) { return { accessToken: params.accessToken, @@ -92,6 +99,14 @@ export const writeChatTool: ToolConfig