import { createLogger } from '@sim/logger' import type { MicrosoftTeamsAttachment } from '@/tools/microsoft_teams/types' 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 */ function transformAttachment(rawAttachment: any): MicrosoftTeamsAttachment { return { id: rawAttachment.id, contentType: rawAttachment.contentType, contentUrl: rawAttachment.contentUrl, content: rawAttachment.content, name: rawAttachment.name, thumbnailUrl: rawAttachment.thumbnailUrl, size: rawAttachment.size, sourceUrl: rawAttachment.sourceUrl, providerType: rawAttachment.providerType, item: rawAttachment.item, } } /** * Extract attachments from message data * Returns all attachments without any content processing */ export function extractMessageAttachments(message: any): MicrosoftTeamsAttachment[] { const attachments = (message.attachments || []).map(transformAttachment) return attachments } /** * Fetch hostedContents for a chat message, upload each item to storage, and return uploaded file infos. * Hosted contents expose base64 contentBytes via Microsoft Graph. */ export async function fetchHostedContentsForChatMessage(params: { accessToken: string chatId: string messageId: string }): Promise { const { accessToken, chatId, messageId } = params try { const url = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(messageId)}/hostedContents` const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }) if (!res.ok) { return [] } const data = await res.json() const items = Array.isArray(data.value) ? data.value : [] const results: ToolFileData[] = [] for (const item of items) { const base64: string | undefined = item.contentBytes if (!base64) continue const contentType: string = typeof item.contentType === 'string' ? item.contentType : 'application/octet-stream' const name: string = item.id ? `teams-hosted-${item.id}` : 'teams-hosted-content' results.push({ name, mimeType: contentType, data: base64 }) } return results } catch (error) { logger.error('Error fetching/uploading hostedContents for chat message:', error) return [] } } /** * Fetch hostedContents for a channel message, upload each item to storage, and return uploaded file infos. */ export async function fetchHostedContentsForChannelMessage(params: { accessToken: string teamId: string channelId: string messageId: string }): Promise { const { accessToken, teamId, channelId, messageId } = params try { const url = `https://graph.microsoft.com/v1.0/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(messageId)}/hostedContents` const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }) if (!res.ok) { return [] } const data = await res.json() const items = Array.isArray(data.value) ? data.value : [] const results: ToolFileData[] = [] for (const item of items) { const base64: string | undefined = item.contentBytes if (!base64) continue const contentType: string = typeof item.contentType === 'string' ? item.contentType : 'application/octet-stream' const name: string = item.id ? `teams-hosted-${item.id}` : 'teams-hosted-content' results.push({ name, mimeType: contentType, data: base64 }) } return results } catch (error) { logger.error('Error fetching/uploading hostedContents for channel message:', error) return [] } } /** * Download a reference-type attachment (SharePoint/OneDrive file) from Teams. * These are files shared in Teams that are stored in SharePoint/OneDrive. * */ async function downloadReferenceAttachment(params: { accessToken: string attachment: MicrosoftTeamsAttachment }): Promise { const { accessToken, attachment } = params if (attachment.contentType !== 'reference') { return null } const contentUrl = attachment.contentUrl if (!contentUrl) { logger.warn('Reference attachment has no contentUrl', { attachmentId: attachment.id }) return null } try { const encodedUrl = Buffer.from(contentUrl) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') const shareId = `u!${encodedUrl}` const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareId}/driveItem` const metadataRes = await fetch(metadataUrl, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!metadataRes.ok) { const errorData = await metadataRes.json().catch(() => ({})) logger.error('Failed to get driveItem metadata via shares API', { status: metadataRes.status, error: errorData, attachmentName: attachment.name, }) return null } const driveItem = await metadataRes.json() const mimeType = driveItem.file?.mimeType || 'application/octet-stream' const fileName = attachment.name || driveItem.name || 'attachment' const downloadUrl = `https://graph.microsoft.com/v1.0/shares/${shareId}/driveItem/content` const downloadRes = await fetch(downloadUrl, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!downloadRes.ok) { logger.error('Failed to download file content', { status: downloadRes.status, fileName, }) return null } const arrayBuffer = await downloadRes.arrayBuffer() const base64Data = Buffer.from(arrayBuffer).toString('base64') logger.info('Successfully downloaded reference attachment', { fileName, size: arrayBuffer.byteLength, }) return { name: fileName, mimeType, data: base64Data, } } catch (error) { logger.error('Error downloading reference attachment:', { error, attachmentId: attachment.id, attachmentName: attachment.name, }) return null } } export async function downloadAllReferenceAttachments(params: { accessToken: string attachments: MicrosoftTeamsAttachment[] }): Promise { const { accessToken, attachments } = params const results: ToolFileData[] = [] const referenceAttachments = attachments.filter((att) => att.contentType === 'reference') if (referenceAttachments.length === 0) { return results } logger.info(`Downloading ${referenceAttachments.length} reference attachment(s)`) for (const attachment of referenceAttachments) { const file = await downloadReferenceAttachment({ accessToken, attachment }) if (file) { results.push(file) } } return results } function parseMentions(content: string): ParsedMention[] { const mentions: ParsedMention[] = [] const mentionRegex = /([^<]+)<\/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 { 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<{ 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() 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.replaceAll( mention.fullTag, `${mention.name}` ) } } 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() 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.replaceAll( mention.fullTag, `${mention.name}` ) } } return { mentions, hasMentions: mentions.length > 0, updatedContent, } }