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 <bradgroux@users.noreply.github.com>
This commit is contained in:
sudie-codes
2026-04-09 19:04:11 -07:00
committed by GitHub
parent 11f924ba04
commit ab9be8dba5
7 changed files with 862 additions and 3 deletions

View File

@@ -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";

View File

@@ -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<typeof setMSTeamsRuntime>[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);
});
});

View File

@@ -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<BotFrameworkAttachmentInfo | undefined> {
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<Buffer | undefined> {
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<MSTeamsInboundMedia | undefined> {
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<MSTeamsGraphMediaResult> {
const seen = new Set<string>();
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,
};
}

View File

@@ -8,6 +8,37 @@ import {
} from "./shared.js";
import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js";
/**
* Extract every `<attachment id="...">` 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<string>();
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 {

View File

@@ -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: "<media:document>",
},
],
attachmentCount: 1,
});
vi.mocked(downloadMSTeamsGraphMedia).mockClear();
const mediaList = await resolveMSTeamsInboundMedia({
...dmParams,
attachments: [
{
contentType: "text/html",
content: '<div>A file <attachment id="att-0"></attachment></div>',
},
],
});
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: '<div><attachment id="att-0"></attachment></div>',
},
],
});
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: '<div><attachment id="att-0"></attachment></div>',
},
],
});
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: "<div>no attachments here</div>" }],
});
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: '<div><attachment id="att-0"></attachment></div>',
},
],
});
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",
}),
);
});
});

View File

@@ -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<MSTeamsTurnContext["activity"], "id" | "replyToId" | "channelData">;
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,

View File

@@ -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,