From ab9be8dba547ae7ff80b8aa31a2eb0b6fa07638c Mon Sep 17 00:00:00 2001 From: sudie-codes Date: Thu, 9 Apr 2026 19:04:11 -0700 Subject: [PATCH] fix(msteams): fetch DM media via Bot Framework path for a: conversation IDs (#62219) (#63951) * fix(msteams): fetch DM media via Bot Framework path for a: conversation IDs (#62219) * fix(msteams): log skipped BF DM media fetches --------- Co-authored-by: Brad Groux --- extensions/msteams/src/attachments.ts | 6 + .../src/attachments/bot-framework.test.ts | 317 ++++++++++++++++++ .../msteams/src/attachments/bot-framework.ts | 306 +++++++++++++++++ extensions/msteams/src/attachments/html.ts | 31 ++ .../src/monitor-handler/inbound-media.test.ts | 154 ++++++++- .../src/monitor-handler/inbound-media.ts | 50 ++- .../src/monitor-handler/message-handler.ts | 1 + 7 files changed, 862 insertions(+), 3 deletions(-) create mode 100644 extensions/msteams/src/attachments/bot-framework.test.ts create mode 100644 extensions/msteams/src/attachments/bot-framework.ts diff --git a/extensions/msteams/src/attachments.ts b/extensions/msteams/src/attachments.ts index d29a3ef310f..bf678545e7a 100644 --- a/extensions/msteams/src/attachments.ts +++ b/extensions/msteams/src/attachments.ts @@ -1,3 +1,8 @@ +export { + downloadMSTeamsBotFrameworkAttachment, + downloadMSTeamsBotFrameworkAttachments, + isBotFrameworkPersonalChatId, +} from "./attachments/bot-framework.js"; export { downloadMSTeamsAttachments, /** @deprecated Use `downloadMSTeamsAttachments` instead. */ @@ -6,6 +11,7 @@ export { export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js"; export { buildMSTeamsAttachmentPlaceholder, + extractMSTeamsHtmlAttachmentIds, summarizeMSTeamsHtmlAttachments, } from "./attachments/html.js"; export { buildMSTeamsMediaPayload } from "./attachments/payload.js"; diff --git a/extensions/msteams/src/attachments/bot-framework.test.ts b/extensions/msteams/src/attachments/bot-framework.test.ts new file mode 100644 index 00000000000..8bfd67c063d --- /dev/null +++ b/extensions/msteams/src/attachments/bot-framework.test.ts @@ -0,0 +1,317 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setMSTeamsRuntime } from "../runtime.js"; +import { + downloadMSTeamsBotFrameworkAttachment, + downloadMSTeamsBotFrameworkAttachments, + isBotFrameworkPersonalChatId, +} from "./bot-framework.js"; +import type { MSTeamsAccessTokenProvider } from "./types.js"; + +type SavedCall = { + buffer: Buffer; + contentType?: string; + direction: string; + maxBytes: number; + originalFilename?: string; +}; + +type MockRuntime = { + saveCalls: SavedCall[]; + savePath: string; + savedContentType: string; +}; + +function installRuntime(): MockRuntime { + const state: MockRuntime = { + saveCalls: [], + savePath: "/tmp/bf-attachment.bin", + savedContentType: "application/pdf", + }; + setMSTeamsRuntime({ + media: { + detectMime: async ({ headerMime }: { headerMime?: string }) => + headerMime ?? "application/pdf", + }, + channel: { + media: { + saveMediaBuffer: async ( + buffer: Buffer, + contentType: string | undefined, + direction: string, + maxBytes: number, + originalFilename?: string, + ) => { + state.saveCalls.push({ + buffer, + contentType, + direction, + maxBytes, + originalFilename, + }); + return { path: state.savePath, contentType: state.savedContentType }; + }, + fetchRemoteMedia: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }), + }, + }, + } as unknown as Parameters[0]); + return state; +} + +function createMockFetch(entries: Array<{ match: RegExp; response: Response }>): typeof fetch { + return (async (input: RequestInfo | URL) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const entry = entries.find((e) => e.match.test(url)); + if (!entry) { + return new Response("not found", { status: 404 }); + } + return entry.response.clone(); + }) as typeof fetch; +} + +function buildTokenProvider(): MSTeamsAccessTokenProvider { + return { + getAccessToken: vi.fn(async (scope: string) => { + if (scope.includes("botframework.com")) { + return "bf-token"; + } + return "graph-token"; + }), + }; +} + +describe("isBotFrameworkPersonalChatId", () => { + it("detects a: prefix personal chat IDs", () => { + expect(isBotFrameworkPersonalChatId("a:1dRsHCobZ1AxURzY05Dc")).toBe(true); + }); + + it("detects 8:orgid: prefix chat IDs", () => { + expect(isBotFrameworkPersonalChatId("8:orgid:12345678-1234-1234-1234-123456789abc")).toBe(true); + }); + + it("returns false for Graph-compatible 19: thread IDs", () => { + expect(isBotFrameworkPersonalChatId("19:abc@thread.tacv2")).toBe(false); + }); + + it("returns false for synthetic DM Graph IDs", () => { + expect(isBotFrameworkPersonalChatId("19:aad-user-id_bot-app-id@unq.gbl.spaces")).toBe(false); + }); + + it("returns false for null/undefined/empty", () => { + expect(isBotFrameworkPersonalChatId(null)).toBe(false); + expect(isBotFrameworkPersonalChatId(undefined)).toBe(false); + expect(isBotFrameworkPersonalChatId("")).toBe(false); + }); +}); + +describe("downloadMSTeamsBotFrameworkAttachment", () => { + let runtime: MockRuntime; + beforeEach(() => { + runtime = installRuntime(); + }); + + it("fetches attachment info then view and saves media", async () => { + const info = { + name: "report.pdf", + type: "application/pdf", + views: [{ viewId: "original", size: 1024 }], + }; + const fileBytes = Buffer.from("PDFBYTES", "utf-8"); + const fetchFn = createMockFetch([ + { + match: /\/v3\/attachments\/att-1$/, + response: new Response(JSON.stringify(info), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }, + { + match: /\/v3\/attachments\/att-1\/views\/original$/, + response: new Response(fileBytes, { + status: 200, + headers: { "content-length": String(fileBytes.byteLength) }, + }), + }, + ]); + + const media = await downloadMSTeamsBotFrameworkAttachment({ + serviceUrl: "https://smba.trafficmanager.net/amer/", + attachmentId: "att-1", + tokenProvider: buildTokenProvider(), + maxBytes: 10_000_000, + fetchFn, + }); + + expect(media).toBeDefined(); + expect(media?.path).toBe(runtime.savePath); + expect(runtime.saveCalls).toHaveLength(1); + expect(runtime.saveCalls[0].buffer.toString("utf-8")).toBe("PDFBYTES"); + }); + + it("returns undefined when attachment info fetch fails", async () => { + const fetchFn = createMockFetch([ + { + match: /\/v3\/attachments\//, + response: new Response("unauthorized", { status: 401 }), + }, + ]); + + const media = await downloadMSTeamsBotFrameworkAttachment({ + serviceUrl: "https://smba.trafficmanager.net/amer", + attachmentId: "att-1", + tokenProvider: buildTokenProvider(), + maxBytes: 10_000_000, + fetchFn, + }); + + expect(media).toBeUndefined(); + expect(runtime.saveCalls).toHaveLength(0); + }); + + it("skips when attachment view size exceeds maxBytes", async () => { + const info = { + name: "huge.bin", + type: "application/octet-stream", + views: [{ viewId: "original", size: 50_000_000 }], + }; + const fetchFn = createMockFetch([ + { + match: /\/v3\/attachments\/big-1$/, + response: new Response(JSON.stringify(info), { status: 200 }), + }, + ]); + + const media = await downloadMSTeamsBotFrameworkAttachment({ + serviceUrl: "https://smba.trafficmanager.net/amer", + attachmentId: "big-1", + tokenProvider: buildTokenProvider(), + maxBytes: 10_000_000, + fetchFn, + }); + + expect(media).toBeUndefined(); + expect(runtime.saveCalls).toHaveLength(0); + }); + + it("returns undefined when no views are returned", async () => { + const info = { name: "nothing", type: "application/pdf", views: [] }; + const fetchFn = createMockFetch([ + { + match: /\/v3\/attachments\/empty-1$/, + response: new Response(JSON.stringify(info), { status: 200 }), + }, + ]); + + const media = await downloadMSTeamsBotFrameworkAttachment({ + serviceUrl: "https://smba.trafficmanager.net/amer", + attachmentId: "empty-1", + tokenProvider: buildTokenProvider(), + maxBytes: 10_000_000, + fetchFn, + }); + + expect(media).toBeUndefined(); + }); + + it("returns undefined without a tokenProvider", async () => { + const fetchFn = vi.fn(); + const media = await downloadMSTeamsBotFrameworkAttachment({ + serviceUrl: "https://smba.trafficmanager.net/amer", + attachmentId: "att-1", + tokenProvider: undefined, + maxBytes: 10_000_000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + expect(media).toBeUndefined(); + expect(fetchFn).not.toHaveBeenCalled(); + }); +}); + +describe("downloadMSTeamsBotFrameworkAttachments", () => { + beforeEach(() => { + installRuntime(); + }); + + it("fetches every unique attachment id and returns combined media", async () => { + const mkInfo = (viewId: string) => ({ + name: `file-${viewId}.pdf`, + type: "application/pdf", + views: [{ viewId, size: 10 }], + }); + const fetchFn = createMockFetch([ + { + match: /\/v3\/attachments\/att-1$/, + response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }), + }, + { + match: /\/v3\/attachments\/att-1\/views\/original$/, + response: new Response(Buffer.from("A"), { status: 200 }), + }, + { + match: /\/v3\/attachments\/att-2$/, + response: new Response(JSON.stringify(mkInfo("original")), { status: 200 }), + }, + { + match: /\/v3\/attachments\/att-2\/views\/original$/, + response: new Response(Buffer.from("B"), { status: 200 }), + }, + ]); + + const result = await downloadMSTeamsBotFrameworkAttachments({ + serviceUrl: "https://smba.trafficmanager.net/amer", + attachmentIds: ["att-1", "att-2", "att-1"], + tokenProvider: buildTokenProvider(), + maxBytes: 10_000, + fetchFn, + }); + + expect(result.media).toHaveLength(2); + expect(result.attachmentCount).toBe(2); + }); + + it("returns empty when no valid attachment ids", async () => { + const result = await downloadMSTeamsBotFrameworkAttachments({ + serviceUrl: "https://smba.trafficmanager.net/amer", + attachmentIds: [], + tokenProvider: buildTokenProvider(), + maxBytes: 10_000, + fetchFn: vi.fn() as unknown as typeof fetch, + }); + expect(result.media).toEqual([]); + }); + + it("continues past a per-attachment failure", async () => { + const fetchFn = createMockFetch([ + { + match: /\/v3\/attachments\/ok$/, + response: new Response( + JSON.stringify({ + name: "ok.pdf", + type: "application/pdf", + views: [{ viewId: "original", size: 1 }], + }), + { status: 200 }, + ), + }, + { + match: /\/v3\/attachments\/ok\/views\/original$/, + response: new Response(Buffer.from("OK"), { status: 200 }), + }, + { + match: /\/v3\/attachments\/bad$/, + response: new Response("nope", { status: 500 }), + }, + ]); + + const result = await downloadMSTeamsBotFrameworkAttachments({ + serviceUrl: "https://smba.trafficmanager.net/amer", + attachmentIds: ["bad", "ok"], + tokenProvider: buildTokenProvider(), + maxBytes: 10_000, + fetchFn, + }); + + expect(result.media).toHaveLength(1); + expect(result.attachmentCount).toBe(2); + }); +}); diff --git a/extensions/msteams/src/attachments/bot-framework.ts b/extensions/msteams/src/attachments/bot-framework.ts new file mode 100644 index 00000000000..70ade3ec419 --- /dev/null +++ b/extensions/msteams/src/attachments/bot-framework.ts @@ -0,0 +1,306 @@ +import { Buffer } from "node:buffer"; +import { fetchWithSsrFGuard, type SsrFPolicy } from "../../runtime-api.js"; +import { getMSTeamsRuntime } from "../runtime.js"; +import { ensureUserAgentHeader } from "../user-agent.js"; +import { + inferPlaceholder, + isUrlAllowed, + type MSTeamsAttachmentFetchPolicy, + resolveAttachmentFetchPolicy, + resolveMediaSsrfPolicy, +} from "./shared.js"; +import type { + MSTeamsAccessTokenProvider, + MSTeamsGraphMediaResult, + MSTeamsInboundMedia, +} from "./types.js"; + +/** + * Bot Framework Service token scope for requesting a token used against + * the Bot Connector (v3) REST endpoints such as `/v3/attachments/{id}`. + */ +const BOT_FRAMEWORK_SCOPE = "https://api.botframework.com"; + +/** + * Detect Bot Framework personal chat ("a:") and MSA orgid ("8:orgid:") conversation + * IDs. These identifiers are not recognized by Graph's `/chats/{id}` endpoint, so we + * must fetch media via the Bot Framework v3 attachments endpoint instead. + * + * Graph-compatible IDs start with `19:` and are left untouched by this detector. + */ +export function isBotFrameworkPersonalChatId(conversationId: string | null | undefined): boolean { + if (typeof conversationId !== "string") { + return false; + } + const trimmed = conversationId.trim(); + return trimmed.startsWith("a:") || trimmed.startsWith("8:orgid:"); +} + +type BotFrameworkView = { + viewId?: string | null; + size?: number | null; +}; + +type BotFrameworkAttachmentInfo = { + name?: string | null; + type?: string | null; + views?: BotFrameworkView[] | null; +}; + +function normalizeServiceUrl(serviceUrl: string): string { + // Bot Framework service URLs sometimes carry a trailing slash; normalize so + // we can safely append `/v3/attachments/...` below. + return serviceUrl.replace(/\/+$/, ""); +} + +async function fetchBotFrameworkAttachmentInfo(params: { + serviceUrl: string; + attachmentId: string; + accessToken: string; + fetchFn?: typeof fetch; + ssrfPolicy?: SsrFPolicy; +}): Promise { + const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`; + const { response, release } = await fetchWithSsrFGuard({ + url, + fetchImpl: params.fetchFn ?? fetch, + init: { + headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }), + }, + policy: params.ssrfPolicy, + auditContext: "msteams.botframework.attachmentInfo", + }); + try { + if (!response.ok) { + return undefined; + } + try { + return (await response.json()) as BotFrameworkAttachmentInfo; + } catch { + return undefined; + } + } finally { + await release(); + } +} + +async function fetchBotFrameworkAttachmentView(params: { + serviceUrl: string; + attachmentId: string; + viewId: string; + accessToken: string; + maxBytes: number; + fetchFn?: typeof fetch; + ssrfPolicy?: SsrFPolicy; +}): Promise { + const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`; + const { response, release } = await fetchWithSsrFGuard({ + url, + fetchImpl: params.fetchFn ?? fetch, + init: { + headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }), + }, + policy: params.ssrfPolicy, + auditContext: "msteams.botframework.attachmentView", + }); + try { + if (!response.ok) { + return undefined; + } + const contentLength = response.headers.get("content-length"); + if (contentLength && Number(contentLength) > params.maxBytes) { + return undefined; + } + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + if (buffer.byteLength > params.maxBytes) { + return undefined; + } + return buffer; + } finally { + await release(); + } +} + +/** + * Download media for a single attachment via the Bot Framework v3 attachments + * endpoint. Used for personal DM conversations where the Graph `/chats/{id}` + * path is not usable because the Bot Framework conversation ID (`a:...`) is + * not a valid Graph chat identifier. + */ +export async function downloadMSTeamsBotFrameworkAttachment(params: { + serviceUrl: string; + attachmentId: string; + tokenProvider?: MSTeamsAccessTokenProvider; + maxBytes: number; + allowHosts?: string[]; + authAllowHosts?: string[]; + fetchFn?: typeof fetch; + fileNameHint?: string | null; + contentTypeHint?: string | null; + preserveFilenames?: boolean; +}): Promise { + if (!params.serviceUrl || !params.attachmentId || !params.tokenProvider) { + return undefined; + } + const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({ + allowHosts: params.allowHosts, + authAllowHosts: params.authAllowHosts, + }); + const baseUrl = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`; + if (!isUrlAllowed(baseUrl, policy.allowHosts)) { + return undefined; + } + const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts); + + let accessToken: string; + try { + accessToken = await params.tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE); + } catch { + return undefined; + } + if (!accessToken) { + return undefined; + } + + const info = await fetchBotFrameworkAttachmentInfo({ + serviceUrl: params.serviceUrl, + attachmentId: params.attachmentId, + accessToken, + fetchFn: params.fetchFn, + ssrfPolicy, + }); + if (!info) { + return undefined; + } + + const views = Array.isArray(info.views) ? info.views : []; + // Prefer the "original" view when present, otherwise fall back to the first + // view the Bot Framework service returned. + const original = views.find((view) => view?.viewId === "original"); + const candidateView = original ?? views.find((view) => typeof view?.viewId === "string"); + const viewId = + typeof candidateView?.viewId === "string" && candidateView.viewId + ? candidateView.viewId + : undefined; + if (!viewId) { + return undefined; + } + if ( + typeof candidateView?.size === "number" && + candidateView.size > 0 && + candidateView.size > params.maxBytes + ) { + return undefined; + } + + const buffer = await fetchBotFrameworkAttachmentView({ + serviceUrl: params.serviceUrl, + attachmentId: params.attachmentId, + viewId, + accessToken, + maxBytes: params.maxBytes, + fetchFn: params.fetchFn, + ssrfPolicy, + }); + if (!buffer) { + return undefined; + } + + const fileNameHint = + (typeof params.fileNameHint === "string" && params.fileNameHint) || + (typeof info.name === "string" && info.name) || + undefined; + const contentTypeHint = + (typeof params.contentTypeHint === "string" && params.contentTypeHint) || + (typeof info.type === "string" && info.type) || + undefined; + + const mime = await getMSTeamsRuntime().media.detectMime({ + buffer, + headerMime: contentTypeHint, + filePath: fileNameHint, + }); + + try { + const originalFilename = params.preserveFilenames ? fileNameHint : undefined; + const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( + buffer, + mime ?? contentTypeHint, + "inbound", + params.maxBytes, + originalFilename, + ); + return { + path: saved.path, + contentType: saved.contentType, + placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }), + }; + } catch { + return undefined; + } +} + +/** + * Download media for every attachment referenced by a Bot Framework personal + * chat activity. Returns all successfully fetched media along with diagnostics + * compatible with `downloadMSTeamsGraphMedia`'s result shape so callers can + * reuse the existing logging path. + */ +export async function downloadMSTeamsBotFrameworkAttachments(params: { + serviceUrl: string; + attachmentIds: string[]; + tokenProvider?: MSTeamsAccessTokenProvider; + maxBytes: number; + allowHosts?: string[]; + authAllowHosts?: string[]; + fetchFn?: typeof fetch; + fileNameHint?: string | null; + contentTypeHint?: string | null; + preserveFilenames?: boolean; +}): Promise { + const seen = new Set(); + const unique: string[] = []; + for (const id of params.attachmentIds ?? []) { + if (typeof id !== "string") { + continue; + } + const trimmed = id.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + unique.push(trimmed); + } + if (unique.length === 0 || !params.serviceUrl || !params.tokenProvider) { + return { media: [], attachmentCount: unique.length }; + } + + const media: MSTeamsInboundMedia[] = []; + for (const attachmentId of unique) { + try { + const item = await downloadMSTeamsBotFrameworkAttachment({ + serviceUrl: params.serviceUrl, + attachmentId, + tokenProvider: params.tokenProvider, + maxBytes: params.maxBytes, + allowHosts: params.allowHosts, + authAllowHosts: params.authAllowHosts, + fetchFn: params.fetchFn, + fileNameHint: params.fileNameHint, + contentTypeHint: params.contentTypeHint, + preserveFilenames: params.preserveFilenames, + }); + if (item) { + media.push(item); + } + } catch { + // Ignore per-attachment failures and continue. + } + } + + return { + media, + attachmentCount: unique.length, + }; +} diff --git a/extensions/msteams/src/attachments/html.ts b/extensions/msteams/src/attachments/html.ts index c158b955c98..7a334e89b82 100644 --- a/extensions/msteams/src/attachments/html.ts +++ b/extensions/msteams/src/attachments/html.ts @@ -8,6 +8,37 @@ import { } from "./shared.js"; import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js"; +/** + * Extract every `` reference from the HTML attachments in + * the inbound activity. Returns the complete (non-sliced) list; callers that + * need a capped diagnostic summary can truncate after calling this helper. + */ +export function extractMSTeamsHtmlAttachmentIds( + attachments: MSTeamsAttachmentLike[] | undefined, +): string[] { + const list = Array.isArray(attachments) ? attachments : []; + if (list.length === 0) { + return []; + } + const ids = new Set(); + for (const att of list) { + const html = extractHtmlFromAttachment(att); + if (!html) { + continue; + } + ATTACHMENT_TAG_RE.lastIndex = 0; + let match: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html); + while (match) { + const id = match[1]?.trim(); + if (id) { + ids.add(id); + } + match = ATTACHMENT_TAG_RE.exec(html); + } + } + return Array.from(ids); +} + export function summarizeMSTeamsHtmlAttachments( attachments: MSTeamsAttachmentLike[] | undefined, ): MSTeamsHtmlAttachmentSummary | undefined { diff --git a/extensions/msteams/src/monitor-handler/inbound-media.test.ts b/extensions/msteams/src/monitor-handler/inbound-media.test.ts index f3d4ea96cae..25bfe07e5a2 100644 --- a/extensions/msteams/src/monitor-handler/inbound-media.test.ts +++ b/extensions/msteams/src/monitor-handler/inbound-media.test.ts @@ -3,15 +3,25 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("../attachments.js", () => ({ downloadMSTeamsAttachments: vi.fn(async () => []), downloadMSTeamsGraphMedia: vi.fn(async () => ({ media: [] })), + downloadMSTeamsBotFrameworkAttachments: vi.fn(async () => ({ media: [], attachmentCount: 0 })), buildMSTeamsGraphMessageUrls: vi.fn(() => [ "https://graph.microsoft.com/v1.0/chats/c/messages/m", ]), + extractMSTeamsHtmlAttachmentIds: vi.fn(() => ["att-0", "att-1"]), + isBotFrameworkPersonalChatId: vi.fn((id: string | null | undefined) => { + if (typeof id !== "string") { + return false; + } + return id.startsWith("a:") || id.startsWith("8:orgid:"); + }), })); import { - downloadMSTeamsAttachments, - downloadMSTeamsGraphMedia, buildMSTeamsGraphMessageUrls, + downloadMSTeamsAttachments, + downloadMSTeamsBotFrameworkAttachments, + downloadMSTeamsGraphMedia, + extractMSTeamsHtmlAttachmentIds, } from "../attachments.js"; import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; @@ -73,3 +83,143 @@ describe("resolveMSTeamsInboundMedia graph fallback trigger", () => { expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled(); }); }); + +describe("resolveMSTeamsInboundMedia bot framework DM routing", () => { + const dmParams = { + ...baseParams, + conversationType: "personal", + conversationId: "a:1dRsHCobZ1AxURzY05Dc", + serviceUrl: "https://smba.trafficmanager.net/amer/", + }; + + it("routes 'a:' conversation IDs through the Bot Framework attachment endpoint", async () => { + vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]); + vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear(); + vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockResolvedValue({ + media: [ + { + path: "/tmp/report.pdf", + contentType: "application/pdf", + placeholder: "", + }, + ], + attachmentCount: 1, + }); + vi.mocked(downloadMSTeamsGraphMedia).mockClear(); + + const mediaList = await resolveMSTeamsInboundMedia({ + ...dmParams, + attachments: [ + { + contentType: "text/html", + content: '
A file
', + }, + ], + }); + + expect(downloadMSTeamsBotFrameworkAttachments).toHaveBeenCalledTimes(1); + const call = vi.mocked(downloadMSTeamsBotFrameworkAttachments).mock.calls[0]?.[0]; + expect(call?.serviceUrl).toBe(dmParams.serviceUrl); + expect(call?.attachmentIds).toEqual(["att-0", "att-1"]); + expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled(); + expect(mediaList).toHaveLength(1); + expect(mediaList[0].path).toBe("/tmp/report.pdf"); + }); + + it("skips the Graph fallback entirely for 'a:' conversation IDs", async () => { + vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]); + vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear(); + vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockResolvedValue({ + media: [], + attachmentCount: 1, + }); + vi.mocked(downloadMSTeamsGraphMedia).mockClear(); + vi.mocked(buildMSTeamsGraphMessageUrls).mockClear(); + + await resolveMSTeamsInboundMedia({ + ...dmParams, + attachments: [ + { + contentType: "text/html", + content: '
', + }, + ], + }); + + expect(downloadMSTeamsBotFrameworkAttachments).toHaveBeenCalled(); + expect(buildMSTeamsGraphMessageUrls).not.toHaveBeenCalled(); + expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled(); + }); + + it("does NOT call the Bot Framework endpoint for Graph-compatible '19:' IDs", async () => { + vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]); + vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear(); + vi.mocked(downloadMSTeamsGraphMedia).mockResolvedValue({ media: [] }); + + await resolveMSTeamsInboundMedia({ + ...baseParams, + conversationId: "19:abc@thread.tacv2", + serviceUrl: "https://smba.trafficmanager.net/amer/", + attachments: [ + { + contentType: "text/html", + content: '
', + }, + ], + }); + + expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled(); + expect(downloadMSTeamsGraphMedia).toHaveBeenCalled(); + }); + + it("logs when no attachment IDs are present on a BF DM with HTML content", async () => { + vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]); + vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear(); + vi.mocked(extractMSTeamsHtmlAttachmentIds).mockReturnValueOnce([]); + const log = { debug: vi.fn() }; + + await resolveMSTeamsInboundMedia({ + ...dmParams, + log, + attachments: [{ contentType: "text/html", content: "
no attachments here
" }], + }); + + expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalledWith( + "bot framework attachment ids unavailable", + expect.objectContaining({ conversationType: "personal" }), + ); + }); + + it("logs when serviceUrl is missing for a BF DM with HTML content", async () => { + vi.mocked(downloadMSTeamsAttachments).mockResolvedValue([]); + vi.mocked(downloadMSTeamsBotFrameworkAttachments).mockClear(); + vi.mocked(downloadMSTeamsGraphMedia).mockClear(); + vi.mocked(buildMSTeamsGraphMessageUrls).mockClear(); + const log = { debug: vi.fn() }; + + await resolveMSTeamsInboundMedia({ + ...baseParams, + log, + conversationType: "personal", + conversationId: "a:bf-dm-id", + attachments: [ + { + contentType: "text/html", + content: '
', + }, + ], + }); + + expect(downloadMSTeamsBotFrameworkAttachments).not.toHaveBeenCalled(); + // Graph fallback is also skipped because the ID is 'a:' + expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled(); + expect(log.debug).toHaveBeenCalledWith( + "bot framework attachment skipped (missing serviceUrl)", + expect.objectContaining({ + conversationType: "personal", + conversationId: "a:bf-dm-id", + }), + ); + }); +}); diff --git a/extensions/msteams/src/monitor-handler/inbound-media.ts b/extensions/msteams/src/monitor-handler/inbound-media.ts index ed72552252f..b74a437d3e9 100644 --- a/extensions/msteams/src/monitor-handler/inbound-media.ts +++ b/extensions/msteams/src/monitor-handler/inbound-media.ts @@ -1,7 +1,10 @@ import { buildMSTeamsGraphMessageUrls, downloadMSTeamsAttachments, + downloadMSTeamsBotFrameworkAttachments, downloadMSTeamsGraphMedia, + extractMSTeamsHtmlAttachmentIds, + isBotFrameworkPersonalChatId, type MSTeamsAccessTokenProvider, type MSTeamsAttachmentLike, type MSTeamsHtmlAttachmentSummary, @@ -23,6 +26,7 @@ export async function resolveMSTeamsInboundMedia(params: { conversationType: string; conversationId: string; conversationMessageId?: string; + serviceUrl?: string; activity: Pick; log: MSTeamsLogger; /** When true, embeds original filename in stored path for later extraction. */ @@ -37,6 +41,7 @@ export async function resolveMSTeamsInboundMedia(params: { conversationType, conversationId, conversationMessageId, + serviceUrl, activity, log, preserveFilenames, @@ -56,7 +61,50 @@ export async function resolveMSTeamsInboundMedia(params: { (att) => typeof att.contentType === "string" && att.contentType.startsWith("text/html"), ); - if (hasHtmlAttachment) { + // Personal DMs with the bot use Bot Framework conversation IDs (`a:...` + // or `8:orgid:...`) which Graph's `/chats/{id}` endpoint rejects with + // "Invalid ThreadId". Fetch media via the Bot Framework v3 attachments + // endpoint instead, which speaks the same identifier space. + if (hasHtmlAttachment && isBotFrameworkPersonalChatId(conversationId)) { + if (!serviceUrl) { + log.debug?.("bot framework attachment skipped (missing serviceUrl)", { + conversationType, + conversationId, + }); + } else { + const attachmentIds = extractMSTeamsHtmlAttachmentIds(attachments); + if (attachmentIds.length === 0) { + log.debug?.("bot framework attachment ids unavailable", { + conversationType, + conversationId, + }); + } else { + const bfMedia = await downloadMSTeamsBotFrameworkAttachments({ + serviceUrl, + attachmentIds, + tokenProvider, + maxBytes, + allowHosts, + authAllowHosts: params.authAllowHosts, + preserveFilenames, + }); + if (bfMedia.media.length > 0) { + mediaList = bfMedia.media; + } else { + log.debug?.("bot framework attachments fetch empty", { + conversationType, + attachmentCount: bfMedia.attachmentCount ?? attachmentIds.length, + }); + } + } + } + } + + if ( + hasHtmlAttachment && + mediaList.length === 0 && + !isBotFrameworkPersonalChatId(conversationId) + ) { const messageUrls = buildMSTeamsGraphMessageUrls({ conversationType, conversationId, diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index e06d7679bd7..62eec2c5b30 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -543,6 +543,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { conversationType, conversationId: graphConversationId, conversationMessageId: conversationMessageId ?? undefined, + serviceUrl: activity.serviceUrl, activity: { id: activity.id, replyToId: activity.replyToId,