diff --git a/extensions/msteams/src/attachments/graph.test.ts b/extensions/msteams/src/attachments/graph.test.ts new file mode 100644 index 00000000000..1e407f2d998 --- /dev/null +++ b/extensions/msteams/src/attachments/graph.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi } from "vitest"; + +// Mock shared.js to avoid transitive runtime-api imports that pull in uninstalled packages. +vi.mock("./shared.js", () => ({ + applyAuthorizationHeaderForUrl: vi.fn(), + GRAPH_ROOT: "https://graph.microsoft.com/v1.0", + inferPlaceholder: vi.fn(({ contentType }: { contentType?: string }) => + contentType?.startsWith("image/") ? "[image]" : "[file]", + ), + isRecord: (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v), + isUrlAllowed: vi.fn(() => true), + normalizeContentType: vi.fn((ct: string | null | undefined) => ct ?? undefined), + resolveMediaSsrfPolicy: vi.fn(() => undefined), + resolveAttachmentFetchPolicy: vi.fn(() => ({ allowHosts: ["*"], authAllowHosts: ["*"] })), + resolveRequestUrl: vi.fn((input: string) => input), + safeFetchWithPolicy: vi.fn(), +})); + +vi.mock("../../runtime-api.js", () => ({ + fetchWithSsrFGuard: vi.fn(), +})); + +vi.mock("../runtime.js", () => ({ + getMSTeamsRuntime: vi.fn(() => ({ + media: { + detectMime: vi.fn(async () => "image/png"), + }, + channel: { + media: { + saveMediaBuffer: vi.fn(async (_buf: Buffer, ct: string) => ({ + path: "/tmp/saved.png", + contentType: ct ?? "image/png", + })), + }, + }, + })), +})); + +vi.mock("./download.js", () => ({ + downloadMSTeamsAttachments: vi.fn(async () => []), +})); + +vi.mock("./remote-media.js", () => ({ + downloadAndStoreMSTeamsRemoteMedia: vi.fn(), +})); + +import { fetchWithSsrFGuard } from "../../runtime-api.js"; +import { downloadMSTeamsGraphMedia } from "./graph.js"; + +function mockFetchResponse(body: unknown, status = 200) { + const bodyStr = typeof body === "string" ? body : JSON.stringify(body); + return new Response(bodyStr, { status, headers: { "content-type": "application/json" } }); +} + +function mockBinaryResponse(data: Uint8Array, status = 200) { + return new Response(Buffer.from(data) as BodyInit, { status }); +} + +describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => { + it("fetches $value endpoint when contentBytes is null but item.id exists", async () => { + const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes + + const fetchCalls: string[] = []; + + vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => { + fetchCalls.push(params.url); + const url = params.url; + + // Main message fetch + if (url.endsWith("/messages/msg-1") && !url.includes("hostedContents")) { + return { + response: mockFetchResponse({ body: {}, attachments: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + // hostedContents collection + if (url.endsWith("/hostedContents")) { + return { + response: mockFetchResponse({ + value: [{ id: "hosted-123", contentType: "image/png", contentBytes: null }], + }), + release: async () => {}, + finalUrl: params.url, + }; + } + // $value endpoint (the fallback being tested) + if (url.includes("/hostedContents/hosted-123/$value")) { + return { + response: mockBinaryResponse(imageBytes), + release: async () => {}, + finalUrl: params.url, + }; + } + // attachments collection + if (url.endsWith("/attachments")) { + return { + response: mockFetchResponse({ value: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + return { + response: mockFetchResponse({}, 404), + release: async () => {}, + finalUrl: params.url, + }; + }); + + const result = await downloadMSTeamsGraphMedia({ + messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-1", + tokenProvider: { getAccessToken: vi.fn(async () => "test-token") }, + maxBytes: 10 * 1024 * 1024, + }); + + // Verify the $value endpoint was fetched + const valueCall = fetchCalls.find((u) => u.includes("/hostedContents/hosted-123/$value")); + expect(valueCall).toBeDefined(); + expect(result.media.length).toBeGreaterThan(0); + expect(result.hostedCount).toBe(1); + }); + + it("skips hosted content when contentBytes is null and id is missing", async () => { + vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => { + const url = params.url; + if (url.endsWith("/messages/msg-2") && !url.includes("hostedContents")) { + return { + response: mockFetchResponse({ body: {}, attachments: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/hostedContents")) { + return { + response: mockFetchResponse({ + value: [{ contentType: "image/png", contentBytes: null }], + }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/attachments")) { + return { + response: mockFetchResponse({ value: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + return { + response: mockFetchResponse({}, 404), + release: async () => {}, + finalUrl: params.url, + }; + }); + + const result = await downloadMSTeamsGraphMedia({ + messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-2", + tokenProvider: { getAccessToken: vi.fn(async () => "test-token") }, + maxBytes: 10 * 1024 * 1024, + }); + + // No media because there's no id to fetch $value from and no contentBytes + expect(result.media).toHaveLength(0); + }); + + it("skips $value content when Content-Length exceeds maxBytes", async () => { + const fetchCalls: string[] = []; + + vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => { + fetchCalls.push(params.url); + const url = params.url; + if (url.endsWith("/messages/msg-cl") && !url.includes("hostedContents")) { + return { + response: mockFetchResponse({ body: {}, attachments: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/hostedContents")) { + return { + response: mockFetchResponse({ + value: [{ id: "hosted-big", contentType: "image/png", contentBytes: null }], + }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.includes("/hostedContents/hosted-big/$value")) { + // Return a response whose Content-Length exceeds maxBytes + const data = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + return { + response: new Response(Buffer.from(data) as BodyInit, { + status: 200, + headers: { "content-length": "999999999" }, + }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/attachments")) { + return { + response: mockFetchResponse({ value: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + return { + response: mockFetchResponse({}, 404), + release: async () => {}, + finalUrl: params.url, + }; + }); + + const result = await downloadMSTeamsGraphMedia({ + messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-cl", + tokenProvider: { getAccessToken: vi.fn(async () => "test-token") }, + maxBytes: 1024, // 1 KB limit + }); + + // $value was fetched but skipped due to Content-Length exceeding maxBytes + const valueCall = fetchCalls.find((u) => u.includes("/hostedContents/hosted-big/$value")); + expect(valueCall).toBeDefined(); + expect(result.media).toHaveLength(0); + }); + + it("uses inline contentBytes when available instead of $value", async () => { + const fetchCalls: string[] = []; + const base64Png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64"); + + vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: { url: string }) => { + fetchCalls.push(params.url); + const url = params.url; + if (url.endsWith("/messages/msg-3") && !url.includes("hostedContents")) { + return { + response: mockFetchResponse({ body: {}, attachments: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/hostedContents")) { + return { + response: mockFetchResponse({ + value: [{ id: "hosted-456", contentType: "image/png", contentBytes: base64Png }], + }), + release: async () => {}, + finalUrl: params.url, + }; + } + if (url.endsWith("/attachments")) { + return { + response: mockFetchResponse({ value: [] }), + release: async () => {}, + finalUrl: params.url, + }; + } + return { + response: mockFetchResponse({}, 404), + release: async () => {}, + finalUrl: params.url, + }; + }); + + const result = await downloadMSTeamsGraphMedia({ + messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-3", + tokenProvider: { getAccessToken: vi.fn(async () => "test-token") }, + maxBytes: 10 * 1024 * 1024, + }); + + // Should NOT have fetched $value since contentBytes was available + const valueCall = fetchCalls.find((u) => u.includes("/$value")); + expect(valueCall).toBeUndefined(); + expect(result.media.length).toBeGreaterThan(0); + }); +}); diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index fca5f8d37a6..30cf689873a 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -194,13 +194,44 @@ async function downloadGraphHostedContent(params: { 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 { + if (contentBytes) { + try { + buffer = Buffer.from(contentBytes, "base64"); + } catch { + continue; + } + } else if (item.id) { + // contentBytes not inline — fetch from the individual $value endpoint. + try { + const valueUrl = `${params.messageUrl}/hostedContents/${encodeURIComponent(item.id)}/$value`; + const { response: valRes, release } = await fetchWithSsrFGuard({ + url: valueUrl, + fetchImpl: params.fetchFn ?? fetch, + init: { + headers: { Authorization: `Bearer ${params.accessToken}` }, + }, + policy: params.ssrfPolicy, + auditContext: "msteams.graph.hostedContent.value", + }); + try { + if (!valRes.ok) { + continue; + } + // Check Content-Length before buffering to avoid RSS spikes on large files. + const cl = valRes.headers.get("content-length"); + if (cl && Number(cl) > params.maxBytes) { + continue; + } + const ab = await valRes.arrayBuffer(); + buffer = Buffer.from(ab); + } finally { + await release(); + } + } catch { + continue; + } + } else { continue; } if (buffer.byteLength > params.maxBytes) { diff --git a/extensions/msteams/src/inbound.ts b/extensions/msteams/src/inbound.ts index fff07122e1f..0be6aeae194 100644 --- a/extensions/msteams/src/inbound.ts +++ b/extensions/msteams/src/inbound.ts @@ -108,6 +108,30 @@ export function stripMSTeamsMentionTags(text: string): string { return text.replace(/]*>.*?<\/at>/gi, "").trim(); } +/** + * Bot Framework uses 'a:xxx' conversation IDs for personal chats, but Graph API + * requires the '19:{userId}_{botAppId}@unq.gbl.spaces' format. + * + * This is the documented Graph API format for 1:1 chat thread IDs between a user + * and a bot/app. See Microsoft docs "Get chat between user and app": + * https://learn.microsoft.com/en-us/graph/api/userscopeteamsappinstallation-get-chat + * + * The format is only synthesized when the Bot Framework conversation ID starts with + * 'a:' (the opaque format used by BF but not recognized by Graph). If the ID already + * has the '19:...' Graph format, it is passed through unchanged. + */ +export function translateMSTeamsDmConversationIdForGraph(params: { + isDirectMessage: boolean; + conversationId: string; + aadObjectId?: string | null; + appId?: string | null; +}): string { + const { isDirectMessage, conversationId, aadObjectId, appId } = params; + return isDirectMessage && conversationId.startsWith("a:") && aadObjectId && appId + ? `19:${aadObjectId}_${appId}@unq.gbl.spaces` + : conversationId; +} + export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean { const botId = activity.recipient?.id; if (!botId) { diff --git a/extensions/msteams/src/monitor-handler/inbound-media.test.ts b/extensions/msteams/src/monitor-handler/inbound-media.test.ts new file mode 100644 index 00000000000..f3d4ea96cae --- /dev/null +++ b/extensions/msteams/src/monitor-handler/inbound-media.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../attachments.js", () => ({ + downloadMSTeamsAttachments: vi.fn(async () => []), + downloadMSTeamsGraphMedia: vi.fn(async () => ({ media: [] })), + buildMSTeamsGraphMessageUrls: vi.fn(() => [ + "https://graph.microsoft.com/v1.0/chats/c/messages/m", + ]), +})); + +import { + downloadMSTeamsAttachments, + downloadMSTeamsGraphMedia, + buildMSTeamsGraphMessageUrls, +} from "../attachments.js"; +import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; + +const baseParams = { + maxBytes: 1024 * 1024, + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + conversationType: "personal", + conversationId: "19:user_bot@unq.gbl.spaces", + activity: { id: "msg-1", replyToId: undefined, channelData: {} }, + log: { debug: vi.fn() }, +}; + +describe("resolveMSTeamsInboundMedia graph fallback trigger", () => { + it("triggers Graph fallback when some attachments are text/html (some() behavior)", async () => { + vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]); + vi.mocked(downloadMSTeamsGraphMedia).mockResolvedValue({ + media: [{ path: "/tmp/img.png", contentType: "image/png", placeholder: "[image]" }], + }); + + await resolveMSTeamsInboundMedia({ + ...baseParams, + attachments: [ + { contentType: "text/html", content: "
" }, + { contentType: "image/png", contentUrl: "https://example.com/img.png" }, + ], + }); + + expect(buildMSTeamsGraphMessageUrls).toHaveBeenCalled(); + expect(downloadMSTeamsGraphMedia).toHaveBeenCalled(); + }); + + it("does NOT trigger Graph fallback when no attachments are text/html", async () => { + vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]); + vi.mocked(downloadMSTeamsGraphMedia).mockClear(); + vi.mocked(buildMSTeamsGraphMessageUrls).mockClear(); + + await resolveMSTeamsInboundMedia({ + ...baseParams, + attachments: [ + { contentType: "image/png", contentUrl: "https://example.com/img.png" }, + { contentType: "application/pdf", contentUrl: "https://example.com/doc.pdf" }, + ], + }); + + expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled(); + }); + + it("does NOT trigger Graph fallback when direct download succeeds", async () => { + vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([ + { path: "/tmp/img.png", contentType: "image/png", placeholder: "[image]" }, + ]); + vi.mocked(downloadMSTeamsGraphMedia).mockClear(); + + await resolveMSTeamsInboundMedia({ + ...baseParams, + attachments: [{ contentType: "text/html", content: "
" }], + }); + + expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/msteams/src/monitor-handler/inbound-media.ts b/extensions/msteams/src/monitor-handler/inbound-media.ts index ae9f386561d..d808a2f2f98 100644 --- a/extensions/msteams/src/monitor-handler/inbound-media.ts +++ b/extensions/msteams/src/monitor-handler/inbound-media.ts @@ -52,11 +52,11 @@ export async function resolveMSTeamsInboundMedia(params: { }); if (mediaList.length === 0) { - const onlyHtmlAttachments = + const hasHtmlAttachment = attachments.length > 0 && - attachments.every((att) => String(att.contentType ?? "").startsWith("text/html")); + attachments.some((att) => String(att.contentType ?? "").startsWith("text/html")); - if (onlyHtmlAttachments) { + if (hasHtmlAttachment) { const messageUrls = buildMSTeamsGraphMessageUrls({ conversationType, conversationId, diff --git a/extensions/msteams/src/monitor-handler/message-handler.dm-media.test.ts b/extensions/msteams/src/monitor-handler/message-handler.dm-media.test.ts new file mode 100644 index 00000000000..bfeabd9cb71 --- /dev/null +++ b/extensions/msteams/src/monitor-handler/message-handler.dm-media.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { translateMSTeamsDmConversationIdForGraph } from "../inbound.js"; + +describe("translateMSTeamsDmConversationIdForGraph", () => { + it("translates a: conversation ID to Graph format for DMs", () => { + const result = translateMSTeamsDmConversationIdForGraph({ + isDirectMessage: true, + conversationId: "a:1abc2def3", + aadObjectId: "user-aad-id", + appId: "bot-app-id", + }); + expect(result).toBe("19:user-aad-id_bot-app-id@unq.gbl.spaces"); + }); + + it("passes through non-a: conversation IDs unchanged", () => { + const result = translateMSTeamsDmConversationIdForGraph({ + isDirectMessage: true, + conversationId: "19:existing@unq.gbl.spaces", + aadObjectId: "user-aad-id", + appId: "bot-app-id", + }); + expect(result).toBe("19:existing@unq.gbl.spaces"); + }); + + it("passes through when aadObjectId is missing", () => { + const result = translateMSTeamsDmConversationIdForGraph({ + isDirectMessage: true, + conversationId: "a:1abc2def3", + aadObjectId: null, + appId: "bot-app-id", + }); + expect(result).toBe("a:1abc2def3"); + }); + + it("passes through when appId is missing", () => { + const result = translateMSTeamsDmConversationIdForGraph({ + isDirectMessage: true, + conversationId: "a:1abc2def3", + aadObjectId: "user-aad-id", + appId: null, + }); + expect(result).toBe("a:1abc2def3"); + }); + + it("passes through for non-DM conversations even with a: prefix", () => { + const result = translateMSTeamsDmConversationIdForGraph({ + isDirectMessage: false, + conversationId: "a:1abc2def3", + aadObjectId: "user-aad-id", + appId: "bot-app-id", + }); + expect(result).toBe("a:1abc2def3"); + }); +}); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index 2878fd7d44b..e3ad16f5781 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -35,6 +35,7 @@ import { normalizeMSTeamsConversationId, parseMSTeamsActivityTimestamp, stripMSTeamsMentionTags, + translateMSTeamsDmConversationIdForGraph, wasMSTeamsBotMentioned, } from "../inbound.js"; import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; @@ -435,6 +436,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { return; } } + const graphConversationId = translateMSTeamsDmConversationIdForGraph({ + isDirectMessage, + conversationId, + aadObjectId: from.aadObjectId, + appId, + }); + const mediaList = await resolveMSTeamsInboundMedia({ attachments, htmlSummary: htmlSummary ?? undefined, @@ -443,7 +451,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { allowHosts: msteamsCfg?.mediaAllowHosts, authAllowHosts: msteamsCfg?.mediaAuthAllowHosts, conversationType, - conversationId, + conversationId: graphConversationId, conversationMessageId: conversationMessageId ?? undefined, activity: { id: activity.id,