mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-16 11:41:08 +00:00
* 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:
@@ -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";
|
||||
|
||||
317
extensions/msteams/src/attachments/bot-framework.test.ts
Normal file
317
extensions/msteams/src/attachments/bot-framework.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
306
extensions/msteams/src/attachments/bot-framework.ts
Normal file
306
extensions/msteams/src/attachments/bot-framework.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user