import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { DEFAULT_MAX_MESSAGES, microsoftTeamsConnectorMeta, } from '@/connectors/microsoft-teams/meta' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' import { computeContentHash, htmlToPlainText, parseMultiValue, parseTagDate, } from '@/connectors/utils' const logger = createLogger('MicrosoftTeamsConnector') const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0' const MESSAGES_PER_PAGE = 50 interface TeamsMessage { id: string messageType: string createdDateTime: string lastModifiedDateTime?: string deletedDateTime?: string | null from?: { user?: { id: string displayName: string } application?: { id: string displayName: string } } body: { contentType: string content: string } subject?: string | null } interface TeamsChannel { id: string displayName: string description?: string | null } interface TeamsMessagesResponse { '@odata.nextLink'?: string value: TeamsMessage[] } interface TeamsChannelsResponse { '@odata.nextLink'?: string value: TeamsChannel[] } /** * Calls the Microsoft Graph API with the given path and access token. */ async function graphApiGet( path: string, accessToken: string, retryOptions?: Parameters[2] ): Promise { const url = path.startsWith('https://') ? path : `${GRAPH_API_BASE}${path}` const response = await fetchWithRetry( url, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }, retryOptions ) if (!response.ok) { const errorBody = await response.text().catch(() => '') throw new Error(`Microsoft Graph API error: ${response.status} ${errorBody}`) } return (await response.json()) as T } /** * Fetches all messages from a channel, up to a maximum count, handling pagination. */ async function fetchChannelMessages( accessToken: string, teamId: string, channelId: string, maxMessages: number ): Promise<{ messages: TeamsMessage[]; lastActivityTs?: string }> { const allMessages: TeamsMessage[] = [] let nextLink: string | undefined let lastActivityTs: string | undefined const initialPath = `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages?$top=${Math.min(MESSAGES_PER_PAGE, maxMessages)}` let currentUrl: string = initialPath while (allMessages.length < maxMessages) { const data = await graphApiGet(currentUrl, accessToken) const messages = data.value || [] if (messages.length === 0) break // Filter to actual user messages (skip system/event messages) const userMessages = messages.filter( (msg) => msg.messageType === 'message' && !msg.deletedDateTime ) // Messages are sorted by lastModifiedDateTime (per Graph docs), so the first // user message on the first page reflects the most recent activity. if (!lastActivityTs && userMessages.length > 0) { const first = userMessages[0] lastActivityTs = first.lastModifiedDateTime || first.createdDateTime } allMessages.push(...userMessages) nextLink = data['@odata.nextLink'] if (!nextLink) break currentUrl = nextLink } return { messages: allMessages.slice(0, maxMessages), lastActivityTs } } /** * Converts fetched messages into a single document content string. * Each line: "[ISO timestamp] username: message text" */ function formatMessages(messages: TeamsMessage[]): string { const lines: string[] = [] // Process in reverse so oldest messages come first const chronological = [...messages].reverse() for (const msg of chronological) { const bodyText = msg.body.contentType === 'html' ? htmlToPlainText(msg.body.content) : msg.body.content if (!bodyText.trim()) continue const timestamp = msg.createdDateTime const userName = msg.from?.user?.displayName || msg.from?.application?.displayName || 'unknown' lines.push(`[${timestamp}] ${userName}: ${bodyText}`) } return lines.join('\n') } /** * Resolves a channel name or ID to a channel object within the given team. */ async function resolveChannel( accessToken: string, teamId: string, channelInput: string ): Promise { const trimmed = channelInput.trim() // Fetch all channels for the team let nextLink: string | undefined // $select avoids the expensive `email` property per Graph perf guidance. const initialPath = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName,description` let currentUrl: string = initialPath do { const data = await graphApiGet(currentUrl, accessToken) const channels = data.value || [] // Try matching by ID first, then by display name (case-insensitive) const match = channels.find( (ch) => ch.id === trimmed || ch.displayName.toLowerCase() === trimmed.toLowerCase() ) if (match) return match nextLink = data['@odata.nextLink'] if (nextLink) { currentUrl = nextLink } } while (nextLink) return null } export const microsoftTeamsConnector: ConnectorConfig = { ...microsoftTeamsConnectorMeta, listDocuments: async ( accessToken: string, sourceConfig: Record, _cursor?: string, _syncContext?: Record ): Promise => { const teamId = sourceConfig.teamId as string const channelInputs = parseMultiValue(sourceConfig.channel) if (!teamId?.trim()) { throw new Error('Team ID is required') } if (channelInputs.length === 0) { throw new Error('At least one channel is required') } const maxMessages = sourceConfig.maxMessages ? Number(sourceConfig.maxMessages) : DEFAULT_MAX_MESSAGES logger.info('Syncing Microsoft Teams channels', { teamId, channels: channelInputs, maxMessages, }) const documents: ExternalDocument[] = [] for (const channelInput of channelInputs) { const channel = await resolveChannel(accessToken, teamId, channelInput) if (!channel) { throw new Error(`Channel not found: ${channelInput}`) } const { messages, lastActivityTs } = await fetchChannelMessages( accessToken, teamId, channel.id, maxMessages ) const content = formatMessages(messages) if (!content.trim()) { logger.info(`No messages found in channel: ${channel.displayName}`) continue } const contentHash = await computeContentHash(content) const sourceUrl = `https://teams.microsoft.com/l/channel/${encodeURIComponent(channel.id)}/${encodeURIComponent(channel.displayName)}?groupId=${encodeURIComponent(teamId)}` documents.push({ externalId: channel.id, title: channel.displayName, content, mimeType: 'text/plain', sourceUrl, contentHash, metadata: { channelName: channel.displayName, messageCount: messages.length, lastActivity: lastActivityTs || undefined, description: channel.description || undefined, }, }) } // All selected channels are emitted in a single page; no pagination needed return { documents, hasMore: false, } }, getDocument: async ( accessToken: string, sourceConfig: Record, externalId: string ): Promise => { const teamId = sourceConfig.teamId as string if (!teamId?.trim()) { return null } const maxMessages = sourceConfig.maxMessages ? Number(sourceConfig.maxMessages) : DEFAULT_MAX_MESSAGES try { // Fetch channel info const channelPath = `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(externalId)}` const channel = await graphApiGet(channelPath, accessToken) const { messages, lastActivityTs } = await fetchChannelMessages( accessToken, teamId, externalId, maxMessages ) const content = formatMessages(messages) if (!content.trim()) return null const contentHash = await computeContentHash(content) const sourceUrl = `https://teams.microsoft.com/l/channel/${encodeURIComponent(channel.id)}/${encodeURIComponent(channel.displayName)}?groupId=${encodeURIComponent(teamId)}` return { externalId: channel.id, title: channel.displayName, content, mimeType: 'text/plain', sourceUrl, contentHash, metadata: { channelName: channel.displayName, messageCount: messages.length, lastActivity: lastActivityTs || undefined, description: channel.description || undefined, }, } } catch (error) { logger.warn('Failed to get Microsoft Teams channel document', { externalId, error: toError(error).message, }) return null } }, validateConfig: async ( accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const teamId = sourceConfig.teamId as string | undefined const channelInputs = parseMultiValue(sourceConfig.channel) const maxMessages = sourceConfig.maxMessages as string | undefined if (!teamId?.trim()) { return { valid: false, error: 'Team ID is required' } } if (channelInputs.length === 0) { return { valid: false, error: 'At least one channel is required' } } if (maxMessages && (Number.isNaN(Number(maxMessages)) || Number(maxMessages) <= 0)) { return { valid: false, error: 'Max messages must be a positive number' } } try { for (const channelInput of channelInputs) { const channel = await resolveChannel(accessToken, teamId, channelInput) if (!channel) { return { valid: false, error: `Channel not found: ${channelInput}` } } // Verify we can read messages by fetching a single message const messagesPath = `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channel.id)}/messages?$top=1` await graphApiGet(messagesPath, accessToken, VALIDATE_RETRY_OPTIONS) } return { valid: true } } catch (error) { const message = getErrorMessage(error, 'Failed to validate configuration') return { valid: false, error: message } } }, mapTags: (metadata: Record): Record => { const result: Record = {} if (typeof metadata.channelName === 'string') { result.channelName = metadata.channelName } if (typeof metadata.messageCount === 'number') { result.messageCount = metadata.messageCount } const lastActivity = parseTagDate(metadata.lastActivity) if (lastActivity) { result.lastActivity = lastActivity } return result }, }