import { isSilentReplyText, loadWebMedia, type MarkdownTableMode, type MSTeamsReplyStyle, type ReplyPayload, SILENT_REPLY_TOKEN, } from "clawdbot/plugin-sdk"; import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; import type { StoredConversationReference } from "./conversation-store.js"; import { classifyMSTeamsSendError } from "./errors.js"; import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js"; import { buildTeamsFileInfoCard } from "./graph-chat.js"; import { getDriveItemProperties, uploadAndShareOneDrive, uploadAndShareSharePoint, } from "./graph-upload.js"; import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js"; import { getMSTeamsRuntime } from "./runtime.js"; /** * MSTeams-specific media size limit (100MB). * Higher than the default because OneDrive upload handles large files well. */ const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024; /** * Threshold for large files that require FileConsentCard flow in personal chats. * Files >= 4MB use consent flow; smaller images can use inline base64. */ const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024; type SendContext = { sendActivity: (textOrActivity: string | object) => Promise; }; export type MSTeamsConversationReference = { activityId?: string; user?: { id?: string; name?: string; aadObjectId?: string }; agent?: { id?: string; name?: string; aadObjectId?: string } | null; conversation: { id: string; conversationType?: string; tenantId?: string }; channelId: string; serviceUrl?: string; locale?: string; }; export type MSTeamsAdapter = { continueConversation: ( appId: string, reference: MSTeamsConversationReference, logic: (context: SendContext) => Promise, ) => Promise; process: ( req: unknown, res: unknown, logic: (context: unknown) => Promise, ) => Promise; }; export type MSTeamsReplyRenderOptions = { textChunkLimit: number; chunkText?: boolean; mediaMode?: "split" | "inline"; tableMode?: MarkdownTableMode; }; /** * A rendered message that preserves media vs text distinction. * When mediaUrl is present, it will be sent as a Bot Framework attachment. */ export type MSTeamsRenderedMessage = { text?: string; mediaUrl?: string; }; export type MSTeamsSendRetryOptions = { maxAttempts?: number; baseDelayMs?: number; maxDelayMs?: number; }; export type MSTeamsSendRetryEvent = { messageIndex: number; messageCount: number; nextAttempt: number; maxAttempts: number; delayMs: number; classification: ReturnType; }; function normalizeConversationId(rawId: string): string { return rawId.split(";")[0] ?? rawId; } export function buildConversationReference( ref: StoredConversationReference, ): MSTeamsConversationReference { const conversationId = ref.conversation?.id?.trim(); if (!conversationId) { throw new Error("Invalid stored reference: missing conversation.id"); } const agent = ref.agent ?? ref.bot ?? undefined; if (agent == null || !agent.id) { throw new Error("Invalid stored reference: missing agent.id"); } const user = ref.user; if (!user?.id) { throw new Error("Invalid stored reference: missing user.id"); } return { activityId: ref.activityId, user, agent, conversation: { id: normalizeConversationId(conversationId), conversationType: ref.conversation?.conversationType, tenantId: ref.conversation?.tenantId, }, channelId: ref.channelId ?? "msteams", serviceUrl: ref.serviceUrl, locale: ref.locale, }; } function pushTextMessages( out: MSTeamsRenderedMessage[], text: string, opts: { chunkText: boolean; chunkLimit: number; }, ) { if (!text) return; if (opts.chunkText) { for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownText(text, opts.chunkLimit)) { const trimmed = chunk.trim(); if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) continue; out.push({ text: trimmed }); } return; } const trimmed = text.trim(); if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) return; out.push({ text: trimmed }); } function clampMs(value: number, maxMs: number): number { if (!Number.isFinite(value) || value < 0) return 0; return Math.min(value, maxMs); } async function sleep(ms: number): Promise { const delay = Math.max(0, ms); if (delay === 0) return; await new Promise((resolve) => { setTimeout(resolve, delay); }); } function resolveRetryOptions( retry: false | MSTeamsSendRetryOptions | undefined, ): Required & { enabled: boolean } { if (!retry) { return { enabled: false, maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 }; } return { enabled: true, maxAttempts: Math.max(1, retry?.maxAttempts ?? 3), baseDelayMs: Math.max(0, retry?.baseDelayMs ?? 250), maxDelayMs: Math.max(0, retry?.maxDelayMs ?? 10_000), }; } function computeRetryDelayMs( attempt: number, classification: ReturnType, opts: Required, ): number { if (classification.retryAfterMs != null) { return clampMs(classification.retryAfterMs, opts.maxDelayMs); } const exponential = opts.baseDelayMs * 2 ** Math.max(0, attempt - 1); return clampMs(exponential, opts.maxDelayMs); } function shouldRetry(classification: ReturnType): boolean { return classification.kind === "throttled" || classification.kind === "transient"; } export function renderReplyPayloadsToMessages( replies: ReplyPayload[], options: MSTeamsReplyRenderOptions, ): MSTeamsRenderedMessage[] { const out: MSTeamsRenderedMessage[] = []; const chunkLimit = Math.min(options.textChunkLimit, 4000); const chunkText = options.chunkText !== false; const mediaMode = options.mediaMode ?? "split"; const tableMode = options.tableMode ?? getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ cfg: getMSTeamsRuntime().config.loadConfig(), channel: "msteams", }); for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( payload.text ?? "", tableMode, ); if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { pushTextMessages(out, text, { chunkText, chunkLimit }); continue; } if (mediaMode === "inline") { // For inline mode, combine text with first media as attachment const firstMedia = mediaList[0]; if (firstMedia) { out.push({ text: text || undefined, mediaUrl: firstMedia }); // Additional media URLs as separate messages for (let i = 1; i < mediaList.length; i++) { if (mediaList[i]) out.push({ mediaUrl: mediaList[i] }); } } else { pushTextMessages(out, text, { chunkText, chunkLimit }); } continue; } // mediaMode === "split" pushTextMessages(out, text, { chunkText, chunkLimit }); for (const mediaUrl of mediaList) { if (!mediaUrl) continue; out.push({ mediaUrl }); } } return out; } async function buildActivity( msg: MSTeamsRenderedMessage, conversationRef: StoredConversationReference, tokenProvider?: MSTeamsAccessTokenProvider, sharePointSiteId?: string, mediaMaxBytes?: number, ): Promise> { const activity: Record = { type: "message" }; if (msg.text) { activity.text = msg.text; } if (msg.mediaUrl) { let contentUrl = msg.mediaUrl; let contentType = await getMimeType(msg.mediaUrl); let fileName = await extractFilename(msg.mediaUrl); if (isLocalPath(msg.mediaUrl)) { const maxBytes = mediaMaxBytes ?? MSTEAMS_MAX_MEDIA_BYTES; const media = await loadWebMedia(msg.mediaUrl, maxBytes); contentType = media.contentType ?? contentType; fileName = media.fileName ?? fileName; // Determine conversation type and file type // Teams only accepts base64 data URLs for images const conversationType = conversationRef.conversation?.conversationType?.toLowerCase(); const isPersonal = conversationType === "personal"; const isImage = contentType?.startsWith("image/") ?? false; if (requiresFileConsent({ conversationType, contentType, bufferSize: media.buffer.length, thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES, })) { // Large file or non-image in personal chat: use FileConsentCard flow const conversationId = conversationRef.conversation?.id ?? "unknown"; const { activity: consentActivity } = prepareFileConsentActivity({ media: { buffer: media.buffer, filename: fileName, contentType }, conversationId, description: msg.text || undefined, }); // Return the consent activity (caller sends it) return consentActivity; } if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) { // Non-image in group chat/channel with SharePoint site configured: // Upload to SharePoint and use native file card attachment const chatId = conversationRef.conversation?.id; // Upload to SharePoint const uploaded = await uploadAndShareSharePoint({ buffer: media.buffer, filename: fileName, contentType, tokenProvider, siteId: sharePointSiteId, chatId: chatId ?? undefined, usePerUserSharing: conversationType === "groupchat", }); // Get driveItem properties needed for native file card attachment const driveItem = await getDriveItemProperties({ siteId: sharePointSiteId, itemId: uploaded.itemId, tokenProvider, }); // Build native Teams file card attachment const fileCardAttachment = buildTeamsFileInfoCard(driveItem); activity.attachments = [fileCardAttachment]; return activity; } if (!isPersonal && !isImage && tokenProvider) { // Fallback: no SharePoint site configured, try OneDrive upload const uploaded = await uploadAndShareOneDrive({ buffer: media.buffer, filename: fileName, contentType, tokenProvider, }); // Bot Framework doesn't support "reference" attachment type for sending const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`; activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink; return activity; } // Image (any chat): use base64 (works for images in all conversation types) const base64 = media.buffer.toString("base64"); contentUrl = `data:${media.contentType};base64,${base64}`; } activity.attachments = [ { name: fileName, contentType, contentUrl, }, ]; } return activity; } export async function sendMSTeamsMessages(params: { replyStyle: MSTeamsReplyStyle; adapter: MSTeamsAdapter; appId: string; conversationRef: StoredConversationReference; context?: SendContext; messages: MSTeamsRenderedMessage[]; retry?: false | MSTeamsSendRetryOptions; onRetry?: (event: MSTeamsSendRetryEvent) => void; /** Token provider for OneDrive/SharePoint uploads in group chats/channels */ tokenProvider?: MSTeamsAccessTokenProvider; /** SharePoint site ID for file uploads in group chats/channels */ sharePointSiteId?: string; /** Max media size in bytes. Default: 100MB. */ mediaMaxBytes?: number; }): Promise { const messages = params.messages.filter( (m) => (m.text && m.text.trim().length > 0) || m.mediaUrl, ); if (messages.length === 0) return []; const retryOptions = resolveRetryOptions(params.retry); const sendWithRetry = async ( sendOnce: () => Promise, meta: { messageIndex: number; messageCount: number }, ): Promise => { if (!retryOptions.enabled) return await sendOnce(); let attempt = 1; while (true) { try { return await sendOnce(); } catch (err) { const classification = classifyMSTeamsSendError(err); const canRetry = attempt < retryOptions.maxAttempts && shouldRetry(classification); if (!canRetry) throw err; const delayMs = computeRetryDelayMs(attempt, classification, retryOptions); const nextAttempt = attempt + 1; params.onRetry?.({ messageIndex: meta.messageIndex, messageCount: meta.messageCount, nextAttempt, maxAttempts: retryOptions.maxAttempts, delayMs, classification, }); await sleep(delayMs); attempt = nextAttempt; } } }; if (params.replyStyle === "thread") { const ctx = params.context; if (!ctx) { throw new Error("Missing context for replyStyle=thread"); } const messageIds: string[] = []; for (const [idx, message] of messages.entries()) { const response = await sendWithRetry( async () => await ctx.sendActivity( await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes), ), { messageIndex: idx, messageCount: messages.length }, ); messageIds.push(extractMessageId(response) ?? "unknown"); } return messageIds; } const baseRef = buildConversationReference(params.conversationRef); const proactiveRef: MSTeamsConversationReference = { ...baseRef, activityId: undefined, }; const messageIds: string[] = []; await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => { for (const [idx, message] of messages.entries()) { const response = await sendWithRetry( async () => await ctx.sendActivity( await buildActivity(message, params.conversationRef, params.tokenProvider, params.sharePointSiteId, params.mediaMaxBytes), ), { messageIndex: idx, messageCount: messages.length }, ); messageIds.push(extractMessageId(response) ?? "unknown"); } }); return messageIds; }