import { detectMime, saveMediaBuffer } from "clawdbot/plugin-sdk"; import { downloadMSTeamsImageAttachments } from "./download.js"; import { GRAPH_ROOT, isRecord, normalizeContentType, resolveAllowedHosts } from "./shared.js"; import type { MSTeamsAccessTokenProvider, MSTeamsAttachmentLike, MSTeamsGraphMediaResult, MSTeamsInboundMedia, } from "./types.js"; type GraphHostedContent = { id?: string | null; contentType?: string | null; contentBytes?: string | null; }; type GraphAttachment = { id?: string | null; contentType?: string | null; contentUrl?: string | null; name?: string | null; thumbnailUrl?: string | null; content?: unknown; }; function readNestedString(value: unknown, keys: Array): string | undefined { let current: unknown = value; for (const key of keys) { if (!isRecord(current)) return undefined; current = current[key as keyof typeof current]; } return typeof current === "string" && current.trim() ? current.trim() : undefined; } export function buildMSTeamsGraphMessageUrls(params: { conversationType?: string | null; conversationId?: string | null; messageId?: string | null; replyToId?: string | null; conversationMessageId?: string | null; channelData?: unknown; }): string[] { const conversationType = params.conversationType?.trim().toLowerCase() ?? ""; const messageIdCandidates = new Set(); const pushCandidate = (value: string | null | undefined) => { const trimmed = typeof value === "string" ? value.trim() : ""; if (trimmed) messageIdCandidates.add(trimmed); }; pushCandidate(params.messageId); pushCandidate(params.conversationMessageId); pushCandidate(readNestedString(params.channelData, ["messageId"])); pushCandidate(readNestedString(params.channelData, ["teamsMessageId"])); const replyToId = typeof params.replyToId === "string" ? params.replyToId.trim() : ""; if (conversationType === "channel") { const teamId = readNestedString(params.channelData, ["team", "id"]) ?? readNestedString(params.channelData, ["teamId"]); const channelId = readNestedString(params.channelData, ["channel", "id"]) ?? readNestedString(params.channelData, ["channelId"]) ?? readNestedString(params.channelData, ["teamsChannelId"]); if (!teamId || !channelId) return []; const urls: string[] = []; if (replyToId) { for (const candidate of messageIdCandidates) { if (candidate === replyToId) continue; urls.push( `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`, ); } } if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId); for (const candidate of messageIdCandidates) { urls.push( `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`, ); } return Array.from(new Set(urls)); } const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]); if (!chatId) return []; if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId); const urls = Array.from(messageIdCandidates).map( (candidate) => `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`, ); return Array.from(new Set(urls)); } async function fetchGraphCollection(params: { url: string; accessToken: string; fetchFn?: typeof fetch; }): Promise<{ status: number; items: T[] }> { const fetchFn = params.fetchFn ?? fetch; const res = await fetchFn(params.url, { headers: { Authorization: `Bearer ${params.accessToken}` }, }); const status = res.status; if (!res.ok) return { status, items: [] }; try { const data = (await res.json()) as { value?: T[] }; return { status, items: Array.isArray(data.value) ? data.value : [] }; } catch { return { status, items: [] }; } } function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike { let content: unknown = att.content; if (typeof content === "string") { try { content = JSON.parse(content); } catch { // Keep as raw string if it's not JSON. } } return { contentType: normalizeContentType(att.contentType) ?? undefined, contentUrl: att.contentUrl ?? undefined, name: att.name ?? undefined, thumbnailUrl: att.thumbnailUrl ?? undefined, content, }; } async function downloadGraphHostedImages(params: { accessToken: string; messageUrl: string; maxBytes: number; fetchFn?: typeof fetch; }): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> { const hosted = await fetchGraphCollection({ url: `${params.messageUrl}/hostedContents`, accessToken: params.accessToken, fetchFn: params.fetchFn, }); if (hosted.items.length === 0) { return { media: [], status: hosted.status, count: 0 }; } const out: MSTeamsInboundMedia[] = []; for (const item of hosted.items) { const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : ""; if (!contentBytes) continue; let buffer: Buffer; try { buffer = Buffer.from(contentBytes, "base64"); } catch { continue; } if (buffer.byteLength > params.maxBytes) continue; const mime = await detectMime({ buffer, headerMime: item.contentType ?? undefined, }); if (mime && !mime.startsWith("image/")) continue; try { const saved = await saveMediaBuffer( buffer, mime ?? item.contentType ?? undefined, "inbound", params.maxBytes, ); out.push({ path: saved.path, contentType: saved.contentType, placeholder: "", }); } catch { // Ignore save failures. } } return { media: out, status: hosted.status, count: hosted.items.length }; } export async function downloadMSTeamsGraphMedia(params: { messageUrl?: string | null; tokenProvider?: MSTeamsAccessTokenProvider; maxBytes: number; allowHosts?: string[]; fetchFn?: typeof fetch; }): Promise { if (!params.messageUrl || !params.tokenProvider) return { media: [] }; const allowHosts = resolveAllowedHosts(params.allowHosts); const messageUrl = params.messageUrl; let accessToken: string; try { accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default"); } catch { return { media: [], messageUrl, tokenError: true }; } const hosted = await downloadGraphHostedImages({ accessToken, messageUrl, maxBytes: params.maxBytes, fetchFn: params.fetchFn, }); const attachments = await fetchGraphCollection({ url: `${messageUrl}/attachments`, accessToken, fetchFn: params.fetchFn, }); const normalizedAttachments = attachments.items.map(normalizeGraphAttachment); const attachmentMedia = await downloadMSTeamsImageAttachments({ attachments: normalizedAttachments, maxBytes: params.maxBytes, tokenProvider: params.tokenProvider, allowHosts, fetchFn: params.fetchFn, }); return { media: [...hosted.media, ...attachmentMedia], hostedCount: hosted.count, attachmentCount: attachments.items.length, hostedStatus: hosted.status, attachmentStatus: attachments.status, messageUrl, }; }