fix(msteams): download DM inline images via Graph API (#52212)

Fix three bugs preventing inline image downloads in Teams 1:1 DM chats: wrong conversation ID format for Graph API, missing media URL extraction, and incorrect content type detection.

Fixes #24797

Thanks @Ted-developer
This commit is contained in:
Ted-developer 2026-04-03 11:14:02 +08:00 committed by GitHub
parent 985533efbc
commit dd080b6fb0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 476 additions and 10 deletions

View File

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

View File

@ -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) {

View File

@ -108,6 +108,30 @@ export function stripMSTeamsMentionTags(text: string): string {
return text.replace(/<at[^>]*>.*?<\/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) {

View File

@ -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: "<div><img src='x'/></div>" },
{ 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: "<div><img src='x'/></div>" }],
});
expect(downloadMSTeamsGraphMedia).not.toHaveBeenCalled();
});
});

View File

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

View File

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

View File

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