Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
609
extensions/msteams/src/attachments/bot-framework.test.ts
Normal file
609
extensions/msteams/src/attachments/bot-framework.test.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
// Msteams tests cover bot framework plugin behavior.
|
||||
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 };
|
||||
},
|
||||
readRemoteMediaBuffer: async () => ({ buffer: Buffer.alloc(0), contentType: undefined }),
|
||||
saveRemoteMedia: async () => ({
|
||||
path: state.savePath,
|
||||
contentType: state.savedContentType,
|
||||
}),
|
||||
saveResponseMedia: async (
|
||||
response: Response,
|
||||
options: {
|
||||
fallbackContentType?: string;
|
||||
subdir?: string;
|
||||
maxBytes?: number;
|
||||
originalFilename?: string;
|
||||
},
|
||||
) => {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
state.saveCalls.push({
|
||||
buffer,
|
||||
contentType: options.fallbackContentType,
|
||||
direction: options.subdir ?? "inbound",
|
||||
maxBytes: options.maxBytes ?? 0,
|
||||
originalFilename: options.originalFilename,
|
||||
});
|
||||
return { path: state.savePath, contentType: state.savedContentType };
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof setMSTeamsRuntime>[0]);
|
||||
return state;
|
||||
}
|
||||
|
||||
function createMockFetch(entries: Array<{ match: RegExp; response: Response }>): typeof fetch {
|
||||
return vi.fn(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";
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function firstMockCall(mock: ReturnType<typeof vi.fn>, label: string): unknown[] {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
async function resolvePublicHost(): Promise<{ address: string }> {
|
||||
return { address: "93.184.216.34" };
|
||||
}
|
||||
|
||||
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,
|
||||
fetchFnSupportsDispatcher: true,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
expect(media?.path).toBe(runtime.savePath);
|
||||
expect(media?.contentType).toBe(runtime.savedContentType);
|
||||
expect(runtime.saveCalls).toHaveLength(1);
|
||||
expect(runtime.saveCalls[0].buffer.toString("utf-8")).toBe("PDFBYTES");
|
||||
});
|
||||
|
||||
it("skips malformed attachment view content-length before saving media", async () => {
|
||||
const info = {
|
||||
name: "report.pdf",
|
||||
type: "application/pdf",
|
||||
views: [{ viewId: "original", size: 3 }],
|
||||
};
|
||||
const warn = vi.fn();
|
||||
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("PDFBYTES", {
|
||||
status: 200,
|
||||
headers: { "content-length": "0x3" },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer/",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: true,
|
||||
resolveFn: resolvePublicHost,
|
||||
logger: { warn },
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
expect(runtime.saveCalls).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"msteams botFramework attachmentView invalid content-length",
|
||||
{ error: "invalid content-length header: 0x3" },
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
fetchFnSupportsDispatcher: true,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
expect(runtime.saveCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not send Bot Framework service tokens to non-auth-allowlisted media hosts", async () => {
|
||||
const seenAuth: Array<string | null> = [];
|
||||
const fetchFn: typeof fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seenAuth.push(new Headers(init?.headers).get("authorization"));
|
||||
return new Response("unauthorized", { status: 401 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://attacker.trafficmanager.net",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: true,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
expect(seenAuth).toEqual([null]);
|
||||
expect(runtime.saveCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends Bot Framework service tokens to auth-allowlisted service hosts", async () => {
|
||||
const seenAuth: Array<string | null> = [];
|
||||
const fileBytes = Buffer.from("BFBYTES", "utf-8");
|
||||
const fetchFn: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
seenAuth.push(new Headers(init?.headers).get("authorization"));
|
||||
if (url.endsWith("/v3/attachments/att-1")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name: "doc.pdf",
|
||||
type: "application/pdf",
|
||||
views: [{ viewId: "original", size: fileBytes.byteLength }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/v3/attachments/att-1/views/original")) {
|
||||
return new Response(fileBytes, {
|
||||
status: 200,
|
||||
headers: { "content-length": String(fileBytes.byteLength) },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: true,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
expect(media?.path).toBe(runtime.savePath);
|
||||
expect(seenAuth).toEqual(["Bearer bf-token", "Bearer bf-token"]);
|
||||
});
|
||||
|
||||
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,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
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,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
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("guarded attachment fetches", () => {
|
||||
it("drives dispatcher-aware caller fetchFn hooks through a pinned dispatcher", async () => {
|
||||
const fileBytes = Buffer.from("BFBYTES", "utf-8");
|
||||
const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fetchFn: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
fetchCalls.push({ url, init });
|
||||
if (url.endsWith("/v3/attachments/att-1")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name: "doc.pdf",
|
||||
type: "application/pdf",
|
||||
views: [{ viewId: "original", size: fileBytes.byteLength }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/v3/attachments/att-1/views/original")) {
|
||||
return new Response(fileBytes, {
|
||||
status: 200,
|
||||
headers: { "content-length": String(fileBytes.byteLength) },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: true,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
expect(media?.path).toBe(runtime.savePath);
|
||||
expect(media?.contentType).toBe(runtime.savedContentType);
|
||||
// Both the attachment info call and the view call should be observed,
|
||||
// confirming the guarded fetch path still preserves caller fetch hooks.
|
||||
expect(fetchCalls).toHaveLength(2);
|
||||
expect(fetchCalls[0].url.endsWith("/v3/attachments/att-1")).toBe(true);
|
||||
expect(fetchCalls[1].url.endsWith("/v3/attachments/att-1/views/original")).toBe(true);
|
||||
for (const call of fetchCalls) {
|
||||
const init = call.init as RequestInit & { dispatcher?: unknown };
|
||||
expect(init?.dispatcher).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs a warning when the attachmentInfo fetch throws (no longer silently swallowed)", async () => {
|
||||
const warn = vi.fn();
|
||||
const logger = { warn };
|
||||
const error = new TypeError("fetch failed | invalid onRequestStart method");
|
||||
const fetchFn: typeof fetch = (async () => {
|
||||
throw error;
|
||||
}) as typeof fetch;
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: true,
|
||||
resolveFn: resolvePublicHost,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
|
||||
"msteams botFramework attachmentInfo fetch failed",
|
||||
{ error: "fetch failed | invalid onRequestStart method" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("logs a warning when the attachmentView fetch throws", async () => {
|
||||
const warn = vi.fn();
|
||||
const logger = { warn };
|
||||
const fetchFn: typeof fetch = (async (input: RequestInfo | URL) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.endsWith("/v3/attachments/att-1")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
name: "doc.pdf",
|
||||
type: "application/pdf",
|
||||
views: [{ viewId: "original", size: 10 }],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
throw new TypeError("fetch failed");
|
||||
}) as typeof fetch;
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: true,
|
||||
resolveFn: resolvePublicHost,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
|
||||
"msteams botFramework attachmentView fetch failed",
|
||||
{ error: "fetch failed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("logs a warning on non-ok attachmentInfo response", async () => {
|
||||
const warn = vi.fn();
|
||||
const fetchFn = createMockFetch([
|
||||
{
|
||||
match: /\/v3\/attachments\/att-1$/,
|
||||
response: new Response("server error", { status: 500 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const media = await downloadMSTeamsBotFrameworkAttachment({
|
||||
serviceUrl: "https://smba.trafficmanager.net/amer",
|
||||
attachmentId: "att-1",
|
||||
tokenProvider: buildTokenProvider(),
|
||||
maxBytes: 10_000_000,
|
||||
fetchFn,
|
||||
resolveFn: resolvePublicHost,
|
||||
logger: { warn },
|
||||
});
|
||||
|
||||
expect(media).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockCall(warn, "logger.warn")).toStrictEqual([
|
||||
"msteams botFramework attachmentInfo non-ok",
|
||||
{ status: 500 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
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).toStrictEqual([]);
|
||||
});
|
||||
|
||||
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,
|
||||
resolveFn: resolvePublicHost,
|
||||
});
|
||||
|
||||
expect(result.media).toHaveLength(1);
|
||||
expect(result.attachmentCount).toBe(2);
|
||||
});
|
||||
});
|
||||
386
extensions/msteams/src/attachments/bot-framework.ts
Normal file
386
extensions/msteams/src/attachments/bot-framework.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
// Msteams plugin module implements bot framework behavior.
|
||||
import { parseMediaContentLength } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { ensureUserAgentHeader } from "../user-agent.js";
|
||||
import {
|
||||
applyAuthorizationHeaderForUrl,
|
||||
inferPlaceholder,
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentDownloadLogger,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
type MSTeamsAttachmentResolveFn,
|
||||
resolveAttachmentFetchPolicy,
|
||||
safeFetchWithPolicy,
|
||||
} 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(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildBotFrameworkAttachmentHeaders(params: {
|
||||
url: string;
|
||||
accessToken: string;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
}): Headers {
|
||||
const headers = ensureUserAgentHeader();
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: params.url,
|
||||
authAllowHosts: params.policy.authAllowHosts,
|
||||
bearerToken: params.accessToken,
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function fetchBotFrameworkAttachmentInfo(params: {
|
||||
serviceUrl: string;
|
||||
attachmentId: string;
|
||||
accessToken: string;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): Promise<BotFrameworkAttachmentInfo | undefined> {
|
||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeFetchWithPolicy({
|
||||
url,
|
||||
policy: params.policy,
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
requestInit: {
|
||||
headers: buildBotFrameworkAttachmentHeaders({
|
||||
url,
|
||||
accessToken: params.accessToken,
|
||||
policy: params.policy,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework attachmentInfo fetch failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
params.logger?.warn?.("msteams botFramework attachmentInfo non-ok", {
|
||||
status: response.status,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return (await response.json()) as BotFrameworkAttachmentInfo;
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework attachmentInfo parse failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBotFrameworkAttachmentView(params: {
|
||||
serviceUrl: string;
|
||||
attachmentId: string;
|
||||
viewId: string;
|
||||
accessToken: string;
|
||||
maxBytes: number;
|
||||
fileNameHint?: string;
|
||||
contentTypeHint?: string;
|
||||
preserveFilenames?: boolean;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): Promise<{ path: string; contentType?: string } | undefined> {
|
||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await safeFetchWithPolicy({
|
||||
url,
|
||||
policy: params.policy,
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
requestInit: {
|
||||
headers: buildBotFrameworkAttachmentHeaders({
|
||||
url,
|
||||
accessToken: params.accessToken,
|
||||
policy: params.policy,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework attachmentView fetch failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
params.logger?.warn?.("msteams botFramework attachmentView non-ok", {
|
||||
status: response.status,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
let contentLength: number | null;
|
||||
try {
|
||||
contentLength = parseMediaContentLength(response.headers.get("content-length"));
|
||||
} catch (err) {
|
||||
await response.body?.cancel();
|
||||
params.logger?.warn?.("msteams botFramework attachmentView invalid content-length", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (contentLength !== null && contentLength > params.maxBytes) {
|
||||
await response.body?.cancel();
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await getMSTeamsRuntime().channel.media.saveResponseMedia(response, {
|
||||
sourceUrl: url,
|
||||
filePathHint: params.fileNameHint,
|
||||
maxBytes: params.maxBytes,
|
||||
fallbackContentType: params.contentTypeHint,
|
||||
subdir: "inbound",
|
||||
originalFilename: params.preserveFilenames ? params.fileNameHint : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework attachmentView save failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
fileNameHint?: string | null;
|
||||
contentTypeHint?: string | null;
|
||||
preserveFilenames?: boolean;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): 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;
|
||||
}
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await params.tokenProvider.getAccessToken(BOT_FRAMEWORK_SCOPE);
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework token acquisition failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
if (!accessToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const info = await fetchBotFrameworkAttachmentInfo({
|
||||
serviceUrl: params.serviceUrl,
|
||||
attachmentId: params.attachmentId,
|
||||
accessToken,
|
||||
policy,
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
logger: params.logger,
|
||||
});
|
||||
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 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 saved = await saveBotFrameworkAttachmentView({
|
||||
serviceUrl: params.serviceUrl,
|
||||
attachmentId: params.attachmentId,
|
||||
viewId,
|
||||
accessToken,
|
||||
maxBytes: params.maxBytes,
|
||||
fileNameHint,
|
||||
contentTypeHint,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
policy,
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
logger: params.logger,
|
||||
});
|
||||
if (!saved) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: fileNameHint }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
fileNameHint?: string | null;
|
||||
contentTypeHint?: string | null;
|
||||
preserveFilenames?: boolean;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): 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,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
fileNameHint: params.fileNameHint,
|
||||
contentTypeHint: params.contentTypeHint,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
logger: params.logger,
|
||||
});
|
||||
if (item) {
|
||||
media.push(item);
|
||||
}
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams botFramework attachment download failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
attachmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
media,
|
||||
attachmentCount: unique.length,
|
||||
};
|
||||
}
|
||||
335
extensions/msteams/src/attachments/download.ts
Normal file
335
extensions/msteams/src/attachments/download.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
// Msteams plugin module implements download behavior.
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
extractInlineImageCandidates,
|
||||
inferPlaceholder,
|
||||
isDownloadableAttachment,
|
||||
isRecord,
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentDownloadLogger,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
type MSTeamsAttachmentResolveFn,
|
||||
normalizeContentType,
|
||||
resolveMediaSsrfPolicy,
|
||||
resolveAttachmentFetchPolicy,
|
||||
resolveRequestUrl,
|
||||
safeFetchWithPolicy,
|
||||
tryBuildGraphSharesUrlForSharedLink,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
MSTeamsInboundMedia,
|
||||
} from "./types.js";
|
||||
|
||||
type DownloadCandidate = {
|
||||
url: string;
|
||||
fileHint?: string;
|
||||
contentTypeHint?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate | null {
|
||||
const contentType = normalizeContentType(att.contentType);
|
||||
const name = normalizeOptionalString(att.name) ?? "";
|
||||
|
||||
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
|
||||
if (!isRecord(att.content)) {
|
||||
return null;
|
||||
}
|
||||
const downloadUrl = normalizeOptionalString(att.content.downloadUrl) ?? "";
|
||||
if (!downloadUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileType = normalizeOptionalString(att.content.fileType) ?? "";
|
||||
const uniqueId = normalizeOptionalString(att.content.uniqueId) ?? "";
|
||||
const fileName = normalizeOptionalString(att.content.fileName) ?? "";
|
||||
|
||||
const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` : "");
|
||||
return {
|
||||
url: downloadUrl,
|
||||
fileHint: fileHint || undefined,
|
||||
contentTypeHint: undefined,
|
||||
placeholder: inferPlaceholder({
|
||||
contentType,
|
||||
fileName: fileHint,
|
||||
fileType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const contentUrl = normalizeOptionalString(att.contentUrl) ?? "";
|
||||
if (!contentUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// OneDrive/SharePoint shared links (delivered in 1:1 DMs when the user
|
||||
// picks "Attach > OneDrive") cannot be fetched directly — the URL returns
|
||||
// an HTML landing page rather than the file bytes. Rewrite them to the
|
||||
// Graph shares endpoint so the auth fallback attaches a Graph-scoped token
|
||||
// and the response is the real file content.
|
||||
const sharesUrl = tryBuildGraphSharesUrlForSharedLink(contentUrl);
|
||||
const resolvedUrl = sharesUrl ?? contentUrl;
|
||||
// Graph shares returns raw bytes without a declared content type we can
|
||||
// trust for routing — let the downloader infer MIME from the buffer.
|
||||
const resolvedContentTypeHint = sharesUrl ? undefined : contentType;
|
||||
|
||||
return {
|
||||
url: resolvedUrl,
|
||||
fileHint: name || undefined,
|
||||
contentTypeHint: resolvedContentTypeHint,
|
||||
placeholder: inferPlaceholder({ contentType, fileName: name }),
|
||||
};
|
||||
}
|
||||
|
||||
function scopeCandidatesForUrl(url: string): string[] {
|
||||
try {
|
||||
const host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
|
||||
const looksLikeGraph =
|
||||
host.endsWith("graph.microsoft.com") ||
|
||||
host.endsWith("sharepoint.com") ||
|
||||
host.endsWith("1drv.ms") ||
|
||||
host.includes("sharepoint");
|
||||
return looksLikeGraph
|
||||
? ["https://graph.microsoft.com", "https://api.botframework.com"]
|
||||
: ["https://api.botframework.com", "https://graph.microsoft.com"];
|
||||
} catch {
|
||||
return ["https://api.botframework.com", "https://graph.microsoft.com"];
|
||||
}
|
||||
}
|
||||
|
||||
function isRedirectStatus(status: number): boolean {
|
||||
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
||||
}
|
||||
|
||||
async function resolveInlineDataImageMime(inline: {
|
||||
data: Buffer;
|
||||
contentType?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const detectedMime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer: inline.data,
|
||||
headerMime: inline.contentType,
|
||||
});
|
||||
const mime = normalizeOptionalLowercaseString(detectedMime ?? inline.contentType);
|
||||
return mime?.startsWith("image/") ? mime : undefined;
|
||||
}
|
||||
|
||||
async function fetchWithAuthFallback(params: {
|
||||
url: string;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
}): Promise<Response> {
|
||||
const firstAttempt = await safeFetchWithPolicy({
|
||||
url: params.url,
|
||||
policy: params.policy,
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: params.requestInit,
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
if (firstAttempt.ok) {
|
||||
return firstAttempt;
|
||||
}
|
||||
if (!params.tokenProvider) {
|
||||
return firstAttempt;
|
||||
}
|
||||
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
|
||||
return firstAttempt;
|
||||
}
|
||||
if (!isUrlAllowed(params.url, params.policy.authAllowHosts)) {
|
||||
return firstAttempt;
|
||||
}
|
||||
await firstAttempt.body?.cancel();
|
||||
|
||||
const scopes = scopeCandidatesForUrl(params.url);
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
for (const scope of scopes) {
|
||||
try {
|
||||
const token = await params.tokenProvider.getAccessToken(scope);
|
||||
const authHeaders = new Headers(params.requestInit?.headers);
|
||||
authHeaders.set("Authorization", `Bearer ${token}`);
|
||||
const authAttempt = await safeFetchWithPolicy({
|
||||
url: params.url,
|
||||
policy: params.policy,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: {
|
||||
...params.requestInit,
|
||||
headers: authHeaders,
|
||||
},
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
if (authAttempt.ok) {
|
||||
return authAttempt;
|
||||
}
|
||||
if (isRedirectStatus(authAttempt.status)) {
|
||||
// Redirects in guarded fetch mode must propagate to the outer guard.
|
||||
return authAttempt;
|
||||
}
|
||||
if (authAttempt.status !== 401 && authAttempt.status !== 403) {
|
||||
// Preserve scope fallback semantics for non-auth failures.
|
||||
await authAttempt.body?.cancel();
|
||||
continue;
|
||||
}
|
||||
await authAttempt.body?.cancel();
|
||||
} catch {
|
||||
// Try the next scope.
|
||||
}
|
||||
}
|
||||
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all file attachments from a Teams message (images, documents, etc.).
|
||||
* Renamed from downloadMSTeamsImageAttachments to support all file types.
|
||||
*/
|
||||
export async function downloadMSTeamsAttachments(params: {
|
||||
attachments: MSTeamsAttachmentLike[] | undefined;
|
||||
maxBytes: number;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
/**
|
||||
* Optional logger used to surface inline data decode failures and remote
|
||||
* media download errors. Errors that are not logged here are invisible at
|
||||
* INFO level and block diagnosis of issues like #63396.
|
||||
*/
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): Promise<MSTeamsInboundMedia[]> {
|
||||
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
||||
if (list.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const policy = resolveAttachmentFetchPolicy({
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
});
|
||||
const allowHosts = policy.allowHosts;
|
||||
const ssrfPolicy = resolveMediaSsrfPolicy(allowHosts);
|
||||
|
||||
// Download ANY downloadable attachment (not just images)
|
||||
const downloadable = list.filter(isDownloadableAttachment);
|
||||
const candidates: DownloadCandidate[] = downloadable
|
||||
.map(resolveDownloadCandidate)
|
||||
.filter(Boolean) as DownloadCandidate[];
|
||||
|
||||
const inlineCandidates = extractInlineImageCandidates(list, {
|
||||
maxInlineBytes: params.maxBytes,
|
||||
maxInlineTotalBytes: params.maxBytes,
|
||||
});
|
||||
|
||||
const seenUrls = new Set<string>();
|
||||
for (const inline of inlineCandidates) {
|
||||
if (inline.kind === "url") {
|
||||
if (!isUrlAllowed(inline.url, allowHosts)) {
|
||||
continue;
|
||||
}
|
||||
if (seenUrls.has(inline.url)) {
|
||||
continue;
|
||||
}
|
||||
seenUrls.add(inline.url);
|
||||
candidates.push({
|
||||
url: inline.url,
|
||||
fileHint: inline.fileHint,
|
||||
contentTypeHint: inline.contentType,
|
||||
placeholder: inline.placeholder,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0 && inlineCandidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const out: MSTeamsInboundMedia[] = [];
|
||||
for (const inline of inlineCandidates) {
|
||||
if (inline.kind !== "data") {
|
||||
continue;
|
||||
}
|
||||
if (inline.data.byteLength > params.maxBytes) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const contentType = await resolveInlineDataImageMime(inline);
|
||||
if (!contentType) {
|
||||
continue;
|
||||
}
|
||||
// Data inline candidates (base64 data URLs) don't have original filenames
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
inline.data,
|
||||
contentType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder({ contentType: saved.contentType ?? contentType }),
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams inline attachment decode failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!isUrlAllowed(candidate.url, allowHosts)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: candidate.url,
|
||||
filePathHint: candidate.fileHint ?? candidate.url,
|
||||
maxBytes: params.maxBytes,
|
||||
contentTypeHint: candidate.contentTypeHint,
|
||||
placeholder: candidate.placeholder,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
ssrfPolicy,
|
||||
// `fetchImpl` below owns Teams auth fallback and enforces the
|
||||
// attachment fetch policy through `safeFetchWithPolicy`.
|
||||
useDirectFetch: true,
|
||||
fetchImpl: (input, init) =>
|
||||
fetchWithAuthFallback({
|
||||
url: resolveRequestUrl(input),
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: init,
|
||||
resolveFn: params.resolveFn,
|
||||
policy,
|
||||
}),
|
||||
});
|
||||
out.push(media);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
params.logger?.warn?.(
|
||||
`msteams attachment download failed host=${safeHostForLog(candidate.url)} error=${msg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeHostForLog(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return "invalid-url";
|
||||
}
|
||||
}
|
||||
444
extensions/msteams/src/attachments/graph.test.ts
Normal file
444
extensions/msteams/src/attachments/graph.test.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
// Msteams tests cover graph plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock shared.js to avoid transitive runtime-api imports that pull in uninstalled packages.
|
||||
vi.mock("./shared.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./shared.js")>();
|
||||
return {
|
||||
...actual,
|
||||
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("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMSTeamsRuntime: vi.fn(() => ({
|
||||
media: {
|
||||
detectMime: vi.fn(async () => "image/png"),
|
||||
},
|
||||
channel: {
|
||||
media: {
|
||||
saveResponseMedia: vi.fn(
|
||||
async (
|
||||
response: Response,
|
||||
options?: { fallbackContentType?: string; maxBytes?: number },
|
||||
) => {
|
||||
const length = Number(response.headers.get("content-length"));
|
||||
if (
|
||||
Number.isFinite(length) &&
|
||||
options?.maxBytes !== undefined &&
|
||||
length > options.maxBytes
|
||||
) {
|
||||
throw new Error("content length exceeds maxBytes");
|
||||
}
|
||||
return {
|
||||
path: "/tmp/saved.png",
|
||||
contentType: options?.fallbackContentType ?? "image/png",
|
||||
};
|
||||
},
|
||||
),
|
||||
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 "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { downloadMSTeamsGraphMedia } from "./graph.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import { safeFetchWithPolicy } from "./shared.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 });
|
||||
}
|
||||
|
||||
type GuardedFetchParams = { url: string; init?: RequestInit };
|
||||
|
||||
function guardedFetchResult(params: GuardedFetchParams, response: Response) {
|
||||
return {
|
||||
response,
|
||||
release: async () => {},
|
||||
finalUrl: params.url,
|
||||
};
|
||||
}
|
||||
|
||||
function requireFirstMockCall<TArgs extends unknown[]>(
|
||||
mock: { mock: { calls: TArgs[] } },
|
||||
label: string,
|
||||
): TArgs {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function mockGraphMediaFetch(options: {
|
||||
messageId: string;
|
||||
messageResponse?: unknown;
|
||||
hostedContents?: unknown[];
|
||||
valueResponses?: Record<string, Response>;
|
||||
fetchCalls?: string[];
|
||||
}) {
|
||||
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
|
||||
options.fetchCalls?.push(params.url);
|
||||
const url = params.url;
|
||||
if (url.endsWith(`/messages/${options.messageId}`) && !url.includes("hostedContents")) {
|
||||
return guardedFetchResult(
|
||||
params,
|
||||
mockFetchResponse(options.messageResponse ?? { body: {}, attachments: [] }),
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/hostedContents")) {
|
||||
return guardedFetchResult(params, mockFetchResponse({ value: options.hostedContents ?? [] }));
|
||||
}
|
||||
for (const [fragment, response] of Object.entries(options.valueResponses ?? {})) {
|
||||
if (url.includes(fragment)) {
|
||||
return guardedFetchResult(params, response);
|
||||
}
|
||||
}
|
||||
return guardedFetchResult(params, mockFetchResponse({}, 404));
|
||||
});
|
||||
}
|
||||
|
||||
describe("downloadMSTeamsGraphMedia hosted content $value fallback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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[] = [];
|
||||
|
||||
mockGraphMediaFetch({
|
||||
messageId: "msg-1",
|
||||
hostedContents: [{ id: "hosted-123", contentType: "image/png", contentBytes: null }],
|
||||
valueResponses: {
|
||||
"/hostedContents/hosted-123/$value": mockBinaryResponse(imageBytes),
|
||||
},
|
||||
fetchCalls,
|
||||
});
|
||||
|
||||
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
|
||||
expect(fetchCalls).toContain(
|
||||
"https://graph.microsoft.com/v1.0/chats/c/messages/msg-1/hostedContents/hosted-123/$value",
|
||||
);
|
||||
expect(result.media.length).toBeGreaterThan(0);
|
||||
expect(result.hostedCount).toBe(1);
|
||||
});
|
||||
|
||||
it("skips hosted content when contentBytes is null and id is missing", async () => {
|
||||
mockGraphMediaFetch({
|
||||
messageId: "msg-2",
|
||||
hostedContents: [{ contentType: "image/png", contentBytes: null }],
|
||||
});
|
||||
|
||||
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[] = [];
|
||||
|
||||
mockGraphMediaFetch({
|
||||
messageId: "msg-cl",
|
||||
hostedContents: [{ id: "hosted-big", contentType: "image/png", contentBytes: null }],
|
||||
valueResponses: {
|
||||
"/hostedContents/hosted-big/$value": new Response(
|
||||
Buffer.from(new Uint8Array([0x89, 0x50, 0x4e, 0x47])) as BodyInit,
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-length": "999999999" },
|
||||
},
|
||||
),
|
||||
},
|
||||
fetchCalls,
|
||||
});
|
||||
|
||||
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
|
||||
expect(fetchCalls).toContain(
|
||||
"https://graph.microsoft.com/v1.0/chats/c/messages/msg-cl/hostedContents/hosted-big/$value",
|
||||
);
|
||||
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");
|
||||
|
||||
mockGraphMediaFetch({
|
||||
messageId: "msg-3",
|
||||
hostedContents: [{ id: "hosted-456", contentType: "image/png", contentBytes: base64Png }],
|
||||
fetchCalls,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("adds the OpenClaw User-Agent to guarded Graph attachment fetches", async () => {
|
||||
mockGraphMediaFetch({ messageId: "msg-ua" });
|
||||
|
||||
await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-ua",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const guardCalls = vi.mocked(fetchWithSsrFGuard).mock.calls;
|
||||
for (const [call] of guardCalls) {
|
||||
const headers = call.init?.headers;
|
||||
expect(headers).toBeInstanceOf(Headers);
|
||||
expect((headers as Headers).get("Authorization")).toBe("Bearer test-token");
|
||||
expect((headers as Headers).get("User-Agent")).toMatch(
|
||||
/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds the OpenClaw User-Agent to Graph shares downloads for reference attachments", async () => {
|
||||
mockGraphMediaFetch({
|
||||
messageId: "msg-share",
|
||||
messageResponse: {
|
||||
body: {},
|
||||
attachments: [
|
||||
{
|
||||
contentType: "reference",
|
||||
contentUrl: "https://tenant.sharepoint.com/file.docx",
|
||||
name: "file.docx",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockImplementation(async (params) => {
|
||||
if (params.fetchImpl) {
|
||||
await params.fetchImpl(params.url, {});
|
||||
}
|
||||
return {
|
||||
path: "/tmp/file.docx",
|
||||
contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
placeholder: "[file]",
|
||||
};
|
||||
});
|
||||
|
||||
await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-share",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const [fetchParams] = requireFirstMockCall(
|
||||
vi.mocked(safeFetchWithPolicy),
|
||||
"safeFetchWithPolicy call",
|
||||
);
|
||||
expect(fetchParams.requestInit?.headers).toBeInstanceOf(Headers);
|
||||
const requestInit = fetchParams.requestInit;
|
||||
const headers = requestInit?.headers as Headers;
|
||||
expect(headers.get("User-Agent")).toMatch(/^teams\.ts\[apps\]\/.+ OpenClaw\/.+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMSTeamsGraphMedia attachment sourcing and error logging", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does NOT call the nonexistent ${messageUrl}/attachments sub-resource", async () => {
|
||||
// The Graph v1.0 API does not expose a `/attachments` sub-resource on
|
||||
// channel or chat messages. Issue #58617 documented that the old code
|
||||
// path called this endpoint and recorded a 404 in diagnostics. After
|
||||
// this fix, the helper must source attachments from the main message
|
||||
// resource's inline `attachments` array instead.
|
||||
const fetchCalls: string[] = [];
|
||||
|
||||
mockGraphMediaFetch({
|
||||
messageId: "msg-no-sub",
|
||||
messageResponse: {
|
||||
body: { content: "hi" },
|
||||
attachments: [],
|
||||
},
|
||||
fetchCalls,
|
||||
});
|
||||
|
||||
await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-no-sub",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const calledSubResource = fetchCalls.some((u) =>
|
||||
u.endsWith("/messages/msg-no-sub/attachments"),
|
||||
);
|
||||
expect(calledSubResource).toBe(false);
|
||||
});
|
||||
|
||||
it("sources reference attachments from the message body's attachments array", async () => {
|
||||
// Before the fix, the helper fetched `/attachments` and used that list.
|
||||
// After the fix, it must use `msgData.attachments` from the main fetch.
|
||||
mockGraphMediaFetch({
|
||||
messageId: "msg-inline",
|
||||
messageResponse: {
|
||||
body: {},
|
||||
attachments: [
|
||||
{
|
||||
contentType: "reference",
|
||||
contentUrl: "https://tenant.sharepoint.com/inline.pdf",
|
||||
name: "inline.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
vi.mocked(safeFetchWithPolicy).mockResolvedValue(new Response(null, { status: 200 }));
|
||||
vi.mocked(downloadAndStoreMSTeamsRemoteMedia).mockResolvedValue({
|
||||
path: "/tmp/inline.pdf",
|
||||
contentType: "application/pdf",
|
||||
placeholder: "[file]",
|
||||
});
|
||||
|
||||
const result = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-inline",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
expect(result.media).toHaveLength(1);
|
||||
expect(result.media[0]?.path).toBe("/tmp/inline.pdf");
|
||||
// Regression guard: attachmentCount now reflects real inline attachments,
|
||||
// not the imaginary `/attachments` sub-resource count.
|
||||
expect(result.attachmentCount).toBe(1);
|
||||
});
|
||||
|
||||
it("logs a debug event when the message fetch throws instead of swallowing it", async () => {
|
||||
// Regression test for #51749: empty `catch {}` blocks used to hide the
|
||||
// real error, producing misleading `graph media fetch empty` diagnostics
|
||||
// without surfacing the underlying cause.
|
||||
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
|
||||
if (params.url.endsWith("/messages/msg-err")) {
|
||||
throw new Error("network boom");
|
||||
}
|
||||
// hostedContents and any other paths succeed so the error branch under
|
||||
// test is the only one that fires.
|
||||
return guardedFetchResult(params, mockFetchResponse({ value: [] }));
|
||||
});
|
||||
const logger = { warn: vi.fn() };
|
||||
|
||||
const result = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-err",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result.media).toHaveLength(0);
|
||||
const [message, context] = requireFirstMockCall(logger.warn, "message fetch warning");
|
||||
expect(message).toBe("msteams graph message fetch failed");
|
||||
expect((context as { error?: unknown }).error).toBe("network boom");
|
||||
});
|
||||
|
||||
it("logs a debug event when the message fetch returns non-ok", async () => {
|
||||
// If the message endpoint returns 403/404, we want that recorded so
|
||||
// operators can distinguish auth issues from empty result sets.
|
||||
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) => {
|
||||
const url = params.url;
|
||||
if (url.endsWith("/hostedContents")) {
|
||||
return guardedFetchResult(params, mockFetchResponse({ value: [] }));
|
||||
}
|
||||
return guardedFetchResult(params, mockFetchResponse({ error: "forbidden" }, 403));
|
||||
});
|
||||
const log = { debug: vi.fn() };
|
||||
|
||||
const result = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-403",
|
||||
tokenProvider: { getAccessToken: vi.fn(async () => "test-token") },
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
log,
|
||||
});
|
||||
|
||||
expect(result.media).toHaveLength(0);
|
||||
expect(result.attachmentStatus).toBe(403);
|
||||
const [message, context] = requireFirstMockCall(log.debug, "message fetch debug event");
|
||||
expect(message).toBe("graph media message fetch not ok");
|
||||
expect((context as { status?: unknown }).status).toBe(403);
|
||||
});
|
||||
|
||||
it("logs a debug event when token acquisition fails", async () => {
|
||||
vi.mocked(fetchWithSsrFGuard).mockImplementation(async (params: GuardedFetchParams) =>
|
||||
guardedFetchResult(params, mockFetchResponse({})),
|
||||
);
|
||||
const logger = { warn: vi.fn() };
|
||||
|
||||
const result = await downloadMSTeamsGraphMedia({
|
||||
messageUrl: "https://graph.microsoft.com/v1.0/chats/c/messages/msg-token",
|
||||
tokenProvider: {
|
||||
getAccessToken: vi.fn(async () => {
|
||||
throw new Error("token expired");
|
||||
}),
|
||||
},
|
||||
maxBytes: 10 * 1024 * 1024,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result.tokenError).toBe(true);
|
||||
const [message, context] = requireFirstMockCall(logger.warn, "token acquisition warning");
|
||||
expect(message).toBe("msteams graph token acquisition failed");
|
||||
expect((context as { error?: unknown }).error).toBe("token expired");
|
||||
});
|
||||
});
|
||||
494
extensions/msteams/src/attachments/graph.ts
Normal file
494
extensions/msteams/src/attachments/graph.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
// Msteams plugin module implements graph behavior.
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
uniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { ensureUserAgentHeader } from "../user-agent.js";
|
||||
import { downloadMSTeamsAttachments } from "./download.js";
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
import {
|
||||
applyAuthorizationHeaderForUrl,
|
||||
encodeGraphShareId,
|
||||
GRAPH_ROOT,
|
||||
estimateBase64DecodedBytes,
|
||||
inferPlaceholder,
|
||||
readNestedString,
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentDownloadLogger,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
type MSTeamsAttachmentResolveFn,
|
||||
normalizeContentType,
|
||||
resolveMediaSsrfPolicy,
|
||||
resolveAttachmentFetchPolicy,
|
||||
resolveRequestUrl,
|
||||
safeFetchWithPolicy,
|
||||
} from "./shared.js";
|
||||
import type {
|
||||
MSTeamsAccessTokenProvider,
|
||||
MSTeamsAttachmentLike,
|
||||
MSTeamsGraphMediaLogger,
|
||||
MSTeamsGraphMediaResult,
|
||||
MSTeamsInboundMedia,
|
||||
} from "./types.js";
|
||||
|
||||
type GraphHostedContent = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
contentBytes?: string | null;
|
||||
};
|
||||
|
||||
type GraphAttachment = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
contentUrl?: string | null;
|
||||
name?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export function buildMSTeamsGraphMessageUrls(params: {
|
||||
conversationType?: string | null;
|
||||
conversationId?: string | null;
|
||||
messageId?: string | null;
|
||||
replyToId?: string | null;
|
||||
conversationMessageId?: string | null;
|
||||
channelData?: unknown;
|
||||
}): string[] {
|
||||
const conversationType = normalizeLowercaseStringOrEmpty(params.conversationType ?? "");
|
||||
const messageIdCandidates = new Set<string>();
|
||||
const pushCandidate = (value: string | null | undefined) => {
|
||||
const trimmed = normalizeOptionalString(value) ?? "";
|
||||
if (trimmed) {
|
||||
messageIdCandidates.add(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
pushCandidate(params.messageId);
|
||||
pushCandidate(params.conversationMessageId);
|
||||
pushCandidate(readNestedString(params.channelData, ["messageId"]));
|
||||
pushCandidate(readNestedString(params.channelData, ["teamsMessageId"]));
|
||||
|
||||
const replyToId = normalizeOptionalString(params.replyToId) ?? "";
|
||||
|
||||
if (conversationType === "channel") {
|
||||
const teamId =
|
||||
readNestedString(params.channelData, ["team", "id"]) ??
|
||||
readNestedString(params.channelData, ["teamId"]);
|
||||
const channelId =
|
||||
readNestedString(params.channelData, ["channel", "id"]) ??
|
||||
readNestedString(params.channelData, ["channelId"]) ??
|
||||
readNestedString(params.channelData, ["teamsChannelId"]);
|
||||
if (!teamId || !channelId) {
|
||||
return [];
|
||||
}
|
||||
const urls: string[] = [];
|
||||
if (replyToId) {
|
||||
for (const candidate of messageIdCandidates) {
|
||||
if (candidate === replyToId) {
|
||||
continue;
|
||||
}
|
||||
urls.push(
|
||||
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messageIdCandidates.size === 0 && replyToId) {
|
||||
messageIdCandidates.add(replyToId);
|
||||
}
|
||||
for (const candidate of messageIdCandidates) {
|
||||
urls.push(
|
||||
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
}
|
||||
return uniqueStrings(urls);
|
||||
}
|
||||
|
||||
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
|
||||
if (!chatId) {
|
||||
return [];
|
||||
}
|
||||
if (messageIdCandidates.size === 0 && replyToId) {
|
||||
messageIdCandidates.add(replyToId);
|
||||
}
|
||||
const urls = Array.from(messageIdCandidates).map(
|
||||
(candidate) =>
|
||||
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
|
||||
);
|
||||
return uniqueStrings(urls);
|
||||
}
|
||||
|
||||
async function fetchGraphCollection(params: {
|
||||
url: string;
|
||||
accessToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
}): Promise<{ status: number; items: unknown[] }> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: params.url,
|
||||
fetchImpl: fetchFn,
|
||||
init: {
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
|
||||
},
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "msteams.graph.collection",
|
||||
});
|
||||
try {
|
||||
const status = response.status;
|
||||
if (!response.ok) {
|
||||
return { status, items: [] };
|
||||
}
|
||||
try {
|
||||
const data = (await response.json()) as { value?: unknown[] };
|
||||
return { status, items: Array.isArray(data.value) ? data.value : [] };
|
||||
} catch {
|
||||
return { status, items: [] };
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike {
|
||||
let content: unknown = att.content;
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
content = JSON.parse(content);
|
||||
} catch {
|
||||
// Keep as raw string if it's not JSON.
|
||||
}
|
||||
}
|
||||
return {
|
||||
contentType: normalizeContentType(att.contentType) ?? undefined,
|
||||
contentUrl: att.contentUrl ?? undefined,
|
||||
name: att.name ?? undefined,
|
||||
thumbnailUrl: att.thumbnailUrl ?? undefined,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all hosted content from a Teams message (images, documents, etc.).
|
||||
* Renamed from downloadGraphHostedImages to support all file types.
|
||||
*/
|
||||
async function downloadGraphHostedContent(params: {
|
||||
accessToken: string;
|
||||
messageUrl: string;
|
||||
maxBytes: number;
|
||||
fetchFn?: typeof fetch;
|
||||
preserveFilenames?: boolean;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> {
|
||||
const hosted = (await fetchGraphCollection({
|
||||
url: `${params.messageUrl}/hostedContents`,
|
||||
accessToken: params.accessToken,
|
||||
fetchFn: params.fetchFn,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
})) as { status: number; items: GraphHostedContent[] };
|
||||
if (hosted.items.length === 0) {
|
||||
return { media: [], status: hosted.status, count: 0 };
|
||||
}
|
||||
|
||||
const out: MSTeamsInboundMedia[] = [];
|
||||
for (const item of hosted.items) {
|
||||
const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
|
||||
let buffer: Buffer;
|
||||
if (contentBytes) {
|
||||
if (estimateBase64DecodedBytes(contentBytes) > params.maxBytes) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
buffer = Buffer.from(contentBytes, "base64");
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams graph hostedContent base64 decode failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
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: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
|
||||
},
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "msteams.graph.hostedContent.value",
|
||||
});
|
||||
try {
|
||||
if (!valRes.ok) {
|
||||
continue;
|
||||
}
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveResponseMedia(valRes, {
|
||||
sourceUrl: valueUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
fallbackContentType: item.contentType ?? undefined,
|
||||
subdir: "inbound",
|
||||
});
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder({ contentType: saved.contentType }),
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams graph hostedContent value fetch failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (buffer.byteLength > params.maxBytes) {
|
||||
continue;
|
||||
}
|
||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||
buffer,
|
||||
headerMime: item.contentType ?? undefined,
|
||||
});
|
||||
// Download any file type, not just images
|
||||
try {
|
||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||
buffer,
|
||||
mime ?? item.contentType ?? undefined,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder: inferPlaceholder({ contentType: saved.contentType }),
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams graph hostedContent save failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { media: out, status: hosted.status, count: hosted.items.length };
|
||||
}
|
||||
|
||||
export async function downloadMSTeamsGraphMedia(params: {
|
||||
messageUrl?: string | null;
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
maxBytes: number;
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
/** Optional logger used to surface Graph/SharePoint fetch errors. */
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
/** Back-compat diagnostic logger used by older tests/callers. */
|
||||
log?: MSTeamsGraphMediaLogger;
|
||||
}): Promise<MSTeamsGraphMediaResult> {
|
||||
if (!params.messageUrl || !params.tokenProvider) {
|
||||
return { media: [] };
|
||||
}
|
||||
const policy: MSTeamsAttachmentFetchPolicy = resolveAttachmentFetchPolicy({
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
});
|
||||
const ssrfPolicy = resolveMediaSsrfPolicy(policy.allowHosts);
|
||||
const messageUrl = params.messageUrl;
|
||||
const debugLog =
|
||||
params.log ?? (params.logger as MSTeamsGraphMediaLogger | undefined) ?? undefined;
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
|
||||
} catch (err) {
|
||||
debugLog?.debug?.("graph media token acquisition failed", {
|
||||
messageUrl,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
params.logger?.warn?.("msteams graph token acquisition failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { media: [], messageUrl, tokenError: true };
|
||||
}
|
||||
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const sharePointMedia: MSTeamsInboundMedia[] = [];
|
||||
const downloadedReferenceUrls = new Set<string>();
|
||||
let messageAttachments: GraphAttachment[] = [];
|
||||
let messageStatus: number | undefined;
|
||||
try {
|
||||
const { response: msgRes, release } = await fetchWithSsrFGuard({
|
||||
url: messageUrl,
|
||||
fetchImpl: fetchFn,
|
||||
init: {
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${accessToken}` }),
|
||||
},
|
||||
policy: ssrfPolicy,
|
||||
auditContext: "msteams.graph.message",
|
||||
});
|
||||
try {
|
||||
messageStatus = msgRes.status;
|
||||
if (msgRes.ok) {
|
||||
let msgData: {
|
||||
body?: { content?: string; contentType?: string };
|
||||
attachments?: GraphAttachment[];
|
||||
};
|
||||
try {
|
||||
msgData = (await msgRes.json()) as typeof msgData;
|
||||
} catch (err) {
|
||||
debugLog?.debug?.("graph media message parse failed", {
|
||||
messageUrl,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
params.logger?.warn?.("msteams graph message parse failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
messageUrl,
|
||||
});
|
||||
msgData = {};
|
||||
}
|
||||
messageAttachments = Array.isArray(msgData.attachments) ? msgData.attachments : [];
|
||||
|
||||
const spAttachments = messageAttachments.filter(
|
||||
(a) => a.contentType === "reference" && a.contentUrl && a.name,
|
||||
);
|
||||
for (const att of spAttachments) {
|
||||
const name = att.name ?? "file";
|
||||
const shareUrl = att.contentUrl ?? "";
|
||||
if (!shareUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const sharesUrl = `${GRAPH_ROOT}/shares/${encodeGraphShareId(shareUrl)}/driveItem/content`;
|
||||
if (!isUrlAllowed(sharesUrl, policy.allowHosts)) {
|
||||
debugLog?.debug?.("graph media sharepoint url not in allowHosts", {
|
||||
messageUrl,
|
||||
sharesUrl,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: sharesUrl,
|
||||
filePathHint: name,
|
||||
maxBytes: params.maxBytes,
|
||||
contentTypeHint: "application/octet-stream",
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
ssrfPolicy,
|
||||
useDirectFetch: true,
|
||||
fetchImpl: async (input, init) => {
|
||||
const requestUrl = resolveRequestUrl(input);
|
||||
const headers = ensureUserAgentHeader(init?.headers);
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: requestUrl,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
bearerToken: accessToken,
|
||||
});
|
||||
return await safeFetchWithPolicy({
|
||||
url: requestUrl,
|
||||
policy,
|
||||
fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: {
|
||||
...init,
|
||||
headers,
|
||||
},
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
},
|
||||
});
|
||||
sharePointMedia.push(media);
|
||||
downloadedReferenceUrls.add(shareUrl);
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams SharePoint reference download failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugLog?.debug?.("graph media message fetch not ok", {
|
||||
messageUrl,
|
||||
status: messageStatus,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog?.debug?.("graph media message fetch failed", {
|
||||
messageUrl,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
params.logger?.warn?.("msteams graph message fetch failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
const hosted = await downloadGraphHostedContent({
|
||||
accessToken,
|
||||
messageUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
fetchFn: params.fetchFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
ssrfPolicy,
|
||||
logger: params.logger,
|
||||
});
|
||||
|
||||
const normalizedAttachments = messageAttachments.map(normalizeGraphAttachment);
|
||||
const filteredAttachments =
|
||||
sharePointMedia.length > 0
|
||||
? normalizedAttachments.filter((att) => {
|
||||
const contentType = normalizeOptionalLowercaseString(att.contentType);
|
||||
if (contentType !== "reference") {
|
||||
return true;
|
||||
}
|
||||
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
return !downloadedReferenceUrls.has(url);
|
||||
})
|
||||
: normalizedAttachments;
|
||||
let attachmentMedia: MSTeamsInboundMedia[] = [];
|
||||
try {
|
||||
attachmentMedia = await downloadMSTeamsAttachments({
|
||||
attachments: filteredAttachments,
|
||||
maxBytes: params.maxBytes,
|
||||
tokenProvider: params.tokenProvider,
|
||||
allowHosts: policy.allowHosts,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
resolveFn: params.resolveFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
logger: params.logger,
|
||||
});
|
||||
} catch (err) {
|
||||
params.logger?.warn?.("msteams graph attachment download failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
messageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
media: [...sharePointMedia, ...hosted.media, ...attachmentMedia],
|
||||
hostedCount: hosted.count,
|
||||
attachmentCount: filteredAttachments.length + sharePointMedia.length,
|
||||
hostedStatus: hosted.status,
|
||||
attachmentStatus: messageStatus,
|
||||
messageUrl,
|
||||
};
|
||||
}
|
||||
220
extensions/msteams/src/attachments/html.ts
Normal file
220
extensions/msteams/src/attachments/html.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// Msteams plugin module implements html behavior.
|
||||
import {
|
||||
ATTACHMENT_TAG_RE,
|
||||
extractHtmlFromAttachment,
|
||||
extractInlineImageCandidates,
|
||||
IMG_SRC_RE,
|
||||
isDownloadableAttachment,
|
||||
isLikelyImageAttachment,
|
||||
normalizeContentType,
|
||||
safeHostForUrl,
|
||||
} 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 {
|
||||
const list = Array.isArray(attachments) ? attachments : [];
|
||||
if (list.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
let htmlAttachments = 0;
|
||||
let imgTags = 0;
|
||||
let dataImages = 0;
|
||||
let cidImages = 0;
|
||||
const srcHosts = new Set<string>();
|
||||
let attachmentTags = 0;
|
||||
const attachmentIds = new Set<string>();
|
||||
|
||||
for (const att of list) {
|
||||
const html = extractHtmlFromAttachment(att);
|
||||
if (!html) {
|
||||
continue;
|
||||
}
|
||||
htmlAttachments += 1;
|
||||
IMG_SRC_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
||||
while (match) {
|
||||
imgTags += 1;
|
||||
const src = match[1]?.trim();
|
||||
if (src) {
|
||||
if (src.startsWith("data:")) {
|
||||
dataImages += 1;
|
||||
} else if (src.startsWith("cid:")) {
|
||||
cidImages += 1;
|
||||
} else {
|
||||
srcHosts.add(safeHostForUrl(src));
|
||||
}
|
||||
}
|
||||
match = IMG_SRC_RE.exec(html);
|
||||
}
|
||||
|
||||
ATTACHMENT_TAG_RE.lastIndex = 0;
|
||||
let attachmentMatch: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html);
|
||||
while (attachmentMatch) {
|
||||
attachmentTags += 1;
|
||||
const id = attachmentMatch[1]?.trim();
|
||||
if (id) {
|
||||
attachmentIds.add(id);
|
||||
}
|
||||
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
|
||||
}
|
||||
}
|
||||
|
||||
if (htmlAttachments === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
htmlAttachments,
|
||||
imgTags,
|
||||
dataImages,
|
||||
cidImages,
|
||||
srcHosts: Array.from(srcHosts).slice(0, 5),
|
||||
attachmentTags,
|
||||
attachmentIds: Array.from(attachmentIds).slice(0, 5),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMSTeamsAttachmentPlaceholder(
|
||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
|
||||
): string {
|
||||
return resolveMSTeamsInboundAttachmentPresentation(attachments, limits).placeholder;
|
||||
}
|
||||
|
||||
function isAdvertisedFileAttachment(attachment: MSTeamsAttachmentLike): boolean {
|
||||
const contentType = normalizeContentType(attachment.contentType) ?? "";
|
||||
if (
|
||||
contentType.startsWith("text/html") ||
|
||||
contentType.startsWith("application/vnd.microsoft.card.") ||
|
||||
contentType.startsWith("application/vnd.microsoft.teams.card.")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
isDownloadableAttachment(attachment) ||
|
||||
isLikelyImageAttachment(attachment) ||
|
||||
attachment.name?.trim() ||
|
||||
contentType,
|
||||
);
|
||||
}
|
||||
|
||||
function countDistinctInlineImages(attachments: MSTeamsAttachmentLike[]): number {
|
||||
const seenReferences = new Set<string>();
|
||||
let count = 0;
|
||||
for (const attachment of attachments) {
|
||||
const html = extractHtmlFromAttachment(attachment);
|
||||
if (!html) {
|
||||
continue;
|
||||
}
|
||||
IMG_SRC_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
||||
while (match) {
|
||||
const src = match[1]?.trim();
|
||||
if (src?.startsWith("data:")) {
|
||||
count += 1;
|
||||
} else if (src && !seenReferences.has(src)) {
|
||||
seenReferences.add(src);
|
||||
count += 1;
|
||||
}
|
||||
match = IMG_SRC_RE.exec(html);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function countDistinctInlineCandidates(
|
||||
attachments: MSTeamsAttachmentLike[],
|
||||
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
|
||||
): number {
|
||||
const seenUrls = new Set<string>();
|
||||
let dataCount = 0;
|
||||
for (const candidate of extractInlineImageCandidates(attachments, limits)) {
|
||||
if (candidate.kind === "data") {
|
||||
dataCount += 1;
|
||||
} else {
|
||||
seenUrls.add(candidate.url);
|
||||
}
|
||||
}
|
||||
return dataCount + seenUrls.size;
|
||||
}
|
||||
|
||||
function countUnrepresentedHtmlAttachmentIds(attachments: MSTeamsAttachmentLike[]): number {
|
||||
const representedIds = new Set<string>();
|
||||
for (const attachment of attachments) {
|
||||
const contentType = normalizeContentType(attachment.contentType) ?? "";
|
||||
if (contentType.startsWith("text/html")) {
|
||||
continue;
|
||||
}
|
||||
const id = attachment.id?.trim();
|
||||
if (id) {
|
||||
representedIds.add(id);
|
||||
}
|
||||
}
|
||||
return extractMSTeamsHtmlAttachmentIds(attachments).filter((id) => !representedIds.has(id))
|
||||
.length;
|
||||
}
|
||||
|
||||
export function resolveMSTeamsInboundAttachmentPresentation(
|
||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||
limits?: { maxInlineBytes?: number; maxInlineTotalBytes?: number },
|
||||
): { placeholder: string; expectedMediaCount: number } {
|
||||
const list = Array.isArray(attachments) ? attachments : [];
|
||||
if (list.length === 0) {
|
||||
return { placeholder: "", expectedMediaCount: 0 };
|
||||
}
|
||||
const fileAttachments = list.filter(isAdvertisedFileAttachment);
|
||||
const inlinePlaceholderCount = countDistinctInlineCandidates(list, limits);
|
||||
const inlineExpectedCount = countDistinctInlineImages(list);
|
||||
// Teams HTML uses <attachment> tags as references. A matching attachment
|
||||
// entry is the same resource (and may be a card), so count only unmatched
|
||||
// IDs that need the Graph/Bot Framework hosted-content fallback.
|
||||
const htmlAttachmentCount = countUnrepresentedHtmlAttachmentIds(list);
|
||||
const expectedMediaCount = fileAttachments.length + inlineExpectedCount + htmlAttachmentCount;
|
||||
if (expectedMediaCount === 0) {
|
||||
return { placeholder: "", expectedMediaCount: 0 };
|
||||
}
|
||||
const totalImages =
|
||||
fileAttachments.filter(isLikelyImageAttachment).length + inlinePlaceholderCount;
|
||||
if (totalImages > 0) {
|
||||
return {
|
||||
placeholder: `<media:image>${totalImages > 1 ? ` (${totalImages} images)` : ""}`,
|
||||
expectedMediaCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
placeholder: `<media:document>${expectedMediaCount > 1 ? ` (${expectedMediaCount} files)` : ""}`,
|
||||
expectedMediaCount,
|
||||
};
|
||||
}
|
||||
15
extensions/msteams/src/attachments/payload.ts
Normal file
15
extensions/msteams/src/attachments/payload.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Msteams plugin module implements payload behavior.
|
||||
import { buildMediaPayload } from "../../runtime-api.js";
|
||||
|
||||
export function buildMSTeamsMediaPayload(
|
||||
mediaList: Array<{ path: string; contentType?: string }>,
|
||||
): {
|
||||
MediaPath?: string;
|
||||
MediaType?: string;
|
||||
MediaUrl?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
} {
|
||||
return buildMediaPayload(mediaList, { preserveMediaTypeCardinality: true });
|
||||
}
|
||||
188
extensions/msteams/src/attachments/remote-media.test.ts
Normal file
188
extensions/msteams/src/attachments/remote-media.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// Msteams tests cover remote media plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the runtime so we can assert whether the strict-dispatcher path
|
||||
// (`saveRemoteMedia`) was invoked versus the new direct-fetch path added
|
||||
// for issue #63396 (Node 24+ / undici v7 compat).
|
||||
const runtimeSaveRemoteMediaMock = vi.fn(
|
||||
async (
|
||||
_params: unknown,
|
||||
): Promise<{
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
}> => ({
|
||||
id: "saved",
|
||||
path: "/tmp/saved.png",
|
||||
size: 42,
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
const runtimeDetectMimeMock = vi.fn(async () => "image/png");
|
||||
const runtimeSaveMediaBufferMock = vi.fn(async (_buf: Buffer, contentType?: string) => ({
|
||||
id: "saved",
|
||||
path: "/tmp/saved.png",
|
||||
size: 42,
|
||||
contentType: contentType ?? "image/png",
|
||||
}));
|
||||
const saveResponseMediaMock = vi.hoisted(() =>
|
||||
vi.fn(async (response: Response, options: { maxBytes?: number }) => {
|
||||
if (!response.ok) {
|
||||
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
||||
throw new Error(`HTTP ${response.status}${statusText}`);
|
||||
}
|
||||
const contentLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(contentLength) && options.maxBytes && contentLength > options.maxBytes) {
|
||||
throw new Error(`content length ${contentLength} exceeds maxBytes ${options.maxBytes}`);
|
||||
}
|
||||
return {
|
||||
id: "saved",
|
||||
path: "/tmp/saved.png",
|
||||
size: 42,
|
||||
contentType: response.headers.get("content-type") ?? "image/png",
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async () => ({
|
||||
saveResponseMedia: saveResponseMediaMock,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMSTeamsRuntime: () => ({
|
||||
media: { detectMime: runtimeDetectMimeMock },
|
||||
channel: {
|
||||
media: {
|
||||
saveRemoteMedia: runtimeSaveRemoteMediaMock,
|
||||
saveMediaBuffer: runtimeSaveMediaBufferMock,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||
|
||||
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function jsonResponse(body: BodyInit, init?: ResponseInit): Response {
|
||||
return new Response(body, init);
|
||||
}
|
||||
|
||||
function requireFirstFetchUrl(mock: ReturnType<typeof vi.fn>): unknown {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected direct fetch call");
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
describe("downloadAndStoreMSTeamsRemoteMedia", () => {
|
||||
beforeEach(() => {
|
||||
runtimeSaveRemoteMediaMock.mockClear();
|
||||
saveResponseMediaMock.mockClear();
|
||||
runtimeDetectMimeMock.mockClear();
|
||||
runtimeSaveMediaBufferMock.mockClear();
|
||||
});
|
||||
|
||||
describe("useDirectFetch: true (Node 24+ / undici v7 path for issue #63396)", () => {
|
||||
it("bypasses readRemoteMediaBuffer and calls the supplied fetchImpl directly", async () => {
|
||||
// `fetchImpl` here simulates the "pre-validated hostname" contract from
|
||||
// `safeFetchWithPolicy`: the caller has already enforced the allowlist,
|
||||
// so the strict SSRF dispatcher is not needed.
|
||||
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
jsonResponse(PNG_BYTES, { status: 200, headers: { "content-type": "image/png" } }),
|
||||
);
|
||||
|
||||
const result = await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
|
||||
filePathHint: "file.png",
|
||||
maxBytes: 1024,
|
||||
useDirectFetch: true,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
const calledUrl = requireFirstFetchUrl(fetchImpl);
|
||||
expect(calledUrl).toBe("https://graph.microsoft.com/v1.0/shares/abc/driveItem/content");
|
||||
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
|
||||
expect(result.path).toBe("/tmp/saved.png");
|
||||
});
|
||||
|
||||
it("surfaces HTTP errors as exceptions (no silent drop)", async () => {
|
||||
const fetchImpl = vi.fn(async () => jsonResponse("nope", { status: 403 }));
|
||||
|
||||
await expect(
|
||||
downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
|
||||
filePathHint: "file.png",
|
||||
maxBytes: 1024,
|
||||
useDirectFetch: true,
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow(/HTTP 403/);
|
||||
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a response whose Content-Length exceeds maxBytes", async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
jsonResponse(PNG_BYTES, {
|
||||
status: 200,
|
||||
headers: { "content-length": "999999" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
|
||||
filePathHint: "file.png",
|
||||
maxBytes: 1024,
|
||||
useDirectFetch: true,
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow(/exceeds maxBytes/);
|
||||
expect(runtimeSaveRemoteMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the runtime saveRemoteMedia path when useDirectFetch is omitted", async () => {
|
||||
// Non-SharePoint caller, no pre-validated fetchImpl: make sure the strict
|
||||
// SSRF dispatcher path is still used.
|
||||
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
|
||||
id: "saved",
|
||||
path: "/tmp/saved.png",
|
||||
size: 42,
|
||||
contentType: "image/png",
|
||||
fileName: "file.png",
|
||||
});
|
||||
|
||||
await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: "https://tenant.sharepoint.com/file.png",
|
||||
filePathHint: "file.png",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
|
||||
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not use the direct path when useDirectFetch is true but fetchImpl is missing", async () => {
|
||||
runtimeSaveRemoteMediaMock.mockResolvedValueOnce({
|
||||
id: "saved",
|
||||
path: "/tmp/saved.png",
|
||||
size: 42,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await downloadAndStoreMSTeamsRemoteMedia({
|
||||
url: "https://graph.microsoft.com/v1.0/shares/abc/driveItem/content",
|
||||
filePathHint: "file.png",
|
||||
maxBytes: 1024,
|
||||
useDirectFetch: true,
|
||||
});
|
||||
|
||||
// Without a fetchImpl to delegate to, we must fall back to the runtime
|
||||
// path rather than crashing.
|
||||
expect(runtimeSaveRemoteMediaMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
extensions/msteams/src/attachments/remote-media.ts
Normal file
77
extensions/msteams/src/attachments/remote-media.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// Msteams plugin module implements remote media behavior.
|
||||
import { saveResponseMedia, type SavedRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { SsrFPolicy } from "../../runtime-api.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import { inferPlaceholder } from "./shared.js";
|
||||
import type { MSTeamsInboundMedia } from "./types.js";
|
||||
|
||||
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Direct save path used when the caller supplies the already-guarded fetch
|
||||
* implementation. This lets Teams-specific auth fallback own the request
|
||||
* sequence while keeping redirect and DNS pinning inside `safeFetchWithPolicy`.
|
||||
*/
|
||||
async function saveRemoteMediaDirect(params: {
|
||||
url: string;
|
||||
filePathHint: string;
|
||||
fetchImpl: FetchLike;
|
||||
maxBytes: number;
|
||||
contentTypeHint?: string;
|
||||
originalFilename?: string;
|
||||
}): Promise<SavedRemoteMedia> {
|
||||
const response = await params.fetchImpl(params.url, { redirect: "follow" });
|
||||
return await saveResponseMedia(response, {
|
||||
sourceUrl: params.url,
|
||||
filePathHint: params.filePathHint,
|
||||
maxBytes: params.maxBytes,
|
||||
fallbackContentType: params.contentTypeHint,
|
||||
originalFilename: params.originalFilename,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
|
||||
url: string;
|
||||
filePathHint: string;
|
||||
maxBytes: number;
|
||||
fetchImpl?: FetchLike;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
contentTypeHint?: string;
|
||||
placeholder?: string;
|
||||
preserveFilenames?: boolean;
|
||||
/**
|
||||
* Opt into the Teams-specific guarded fetch path. Only safe when the
|
||||
* supplied `fetchImpl` enforces the attachment fetch policy itself.
|
||||
*/
|
||||
useDirectFetch?: boolean;
|
||||
}): Promise<MSTeamsInboundMedia> {
|
||||
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
|
||||
let saved: SavedRemoteMedia;
|
||||
if (params.useDirectFetch && params.fetchImpl) {
|
||||
saved = await saveRemoteMediaDirect({
|
||||
url: params.url,
|
||||
filePathHint: params.filePathHint,
|
||||
fetchImpl: params.fetchImpl,
|
||||
maxBytes: params.maxBytes,
|
||||
contentTypeHint: params.contentTypeHint,
|
||||
originalFilename,
|
||||
});
|
||||
} else {
|
||||
saved = await getMSTeamsRuntime().channel.media.saveRemoteMedia({
|
||||
url: params.url,
|
||||
fetchImpl: params.fetchImpl,
|
||||
filePathHint: params.filePathHint,
|
||||
maxBytes: params.maxBytes,
|
||||
ssrfPolicy: params.ssrfPolicy,
|
||||
fallbackContentType: params.contentTypeHint,
|
||||
originalFilename,
|
||||
});
|
||||
}
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
placeholder:
|
||||
params.placeholder ??
|
||||
inferPlaceholder({ contentType: saved.contentType, fileName: params.filePathHint }),
|
||||
};
|
||||
}
|
||||
680
extensions/msteams/src/attachments/shared.test.ts
Normal file
680
extensions/msteams/src/attachments/shared.test.ts
Normal file
@@ -0,0 +1,680 @@
|
||||
// Msteams tests cover shared plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
applyAuthorizationHeaderForUrl,
|
||||
encodeGraphShareId,
|
||||
extractInlineImageCandidates,
|
||||
isGraphSharedLinkUrl,
|
||||
isPrivateOrReservedIP,
|
||||
isUrlAllowed,
|
||||
resolveAndValidateIP,
|
||||
resolveAttachmentFetchPolicy,
|
||||
resolveAllowedHosts,
|
||||
resolveAuthAllowedHosts,
|
||||
resolveMediaSsrfPolicy,
|
||||
safeFetch,
|
||||
safeFetchWithPolicy,
|
||||
tryBuildGraphSharesUrlForSharedLink,
|
||||
} from "./shared.js";
|
||||
|
||||
const publicResolve = async () => ({ address: "13.107.136.10" });
|
||||
const privateResolve = (ip: string) => async () => ({ address: ip });
|
||||
const failingResolve = async () => {
|
||||
throw new Error("DNS failure");
|
||||
};
|
||||
|
||||
function mockFetchWithRedirect(redirectMap: Record<string, string>, finalBody = "ok") {
|
||||
return vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const target = redirectMap[url];
|
||||
if (target && init?.redirect === "manual") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: target },
|
||||
});
|
||||
}
|
||||
return new Response(finalBody, { status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
function fetchInitAt(fetchMock: ReturnType<typeof vi.fn>, index: number): unknown {
|
||||
const call = fetchMock.mock.calls[index];
|
||||
if (!call) {
|
||||
throw new Error(`expected fetch call ${index}`);
|
||||
}
|
||||
return call[1];
|
||||
}
|
||||
|
||||
async function expectSafeFetchStatus(params: {
|
||||
fetchMock: ReturnType<typeof vi.fn>;
|
||||
url: string;
|
||||
allowHosts: string[];
|
||||
expectedStatus: number;
|
||||
resolveFn?: typeof publicResolve;
|
||||
}) {
|
||||
const res = await safeFetch({
|
||||
url: params.url,
|
||||
allowHosts: params.allowHosts,
|
||||
fetchFn: params.fetchMock as unknown as typeof fetch,
|
||||
resolveFn: params.resolveFn ?? publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(params.expectedStatus);
|
||||
await res.body?.cancel();
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("msteams attachment allowlists", () => {
|
||||
it("normalizes wildcard host lists", () => {
|
||||
expect(resolveAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
||||
expect(resolveAuthAllowedHosts(["*", "graph.microsoft.com"])).toEqual(["*"]);
|
||||
});
|
||||
|
||||
it("resolves a normalized attachment fetch policy", () => {
|
||||
expect(
|
||||
resolveAttachmentFetchPolicy({
|
||||
allowHosts: ["sharepoint.com"],
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowHosts: ["sharepoint.com"],
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("allows Azure China Bot Framework attachment URLs with auth by default", () => {
|
||||
const policy = resolveAttachmentFetchPolicy();
|
||||
const url = "https://msteams.botframework.azure.cn/teams/v3/attachments/att-1/views/original";
|
||||
const headers = new Headers();
|
||||
|
||||
expect(isUrlAllowed(url, policy.allowHosts)).toBe(true);
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
bearerToken: "token-1",
|
||||
});
|
||||
|
||||
expect(headers.get("Authorization")).toBe("Bearer token-1");
|
||||
});
|
||||
|
||||
it("requires https and host suffix match", () => {
|
||||
const allowHosts = resolveAllowedHosts(["sharepoint.com"]);
|
||||
expect(isUrlAllowed("https://contoso.sharepoint.com/file.png", allowHosts)).toBe(true);
|
||||
expect(isUrlAllowed("http://contoso.sharepoint.com/file.png", allowHosts)).toBe(false);
|
||||
expect(isUrlAllowed("https://evil.example.com/file.png", allowHosts)).toBe(false);
|
||||
});
|
||||
|
||||
it("builds shared SSRF policy from suffix allowlist", () => {
|
||||
expect(resolveMediaSsrfPolicy(["sharepoint.com"])).toEqual({
|
||||
hostnameAllowlist: ["sharepoint.com", "*.sharepoint.com"],
|
||||
});
|
||||
expect(resolveMediaSsrfPolicy(["*"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["999.999.999.999", true],
|
||||
["256.0.0.1", true],
|
||||
["10.0.0.256", true],
|
||||
["-1.0.0.1", false],
|
||||
["1.2.3.4.5", false],
|
||||
["0:0:0:0:0:0:0:1", true],
|
||||
] as const)("malformed/expanded %s → %s (SDK fails closed)", (ip, expected) => {
|
||||
expect(isPrivateOrReservedIP(ip)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveAndValidateIP ────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveAndValidateIP", () => {
|
||||
it("accepts a hostname resolving to a public IP", async () => {
|
||||
const ip = await resolveAndValidateIP("teams.sharepoint.com", publicResolve);
|
||||
expect(ip).toBe("13.107.136.10");
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to 10.x.x.x", async () => {
|
||||
await expect(resolveAndValidateIP("evil.test", privateResolve("10.0.0.1"))).rejects.toThrow(
|
||||
"private/reserved IP",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to 169.254.169.254", async () => {
|
||||
await expect(
|
||||
resolveAndValidateIP("evil.test", privateResolve("169.254.169.254")),
|
||||
).rejects.toThrow("private/reserved IP");
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to loopback", async () => {
|
||||
await expect(resolveAndValidateIP("evil.test", privateResolve("127.0.0.1"))).rejects.toThrow(
|
||||
"private/reserved IP",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a hostname resolving to IPv6 loopback", async () => {
|
||||
await expect(resolveAndValidateIP("evil.test", privateResolve("::1"))).rejects.toThrow(
|
||||
"private/reserved IP",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on DNS resolution failure", async () => {
|
||||
await expect(resolveAndValidateIP("nonexistent.test", failingResolve)).rejects.toThrow(
|
||||
"DNS resolution failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── safeFetch ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("safeFetch", () => {
|
||||
it("fetches a URL directly when no redirect occurs", async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
await expectSafeFetchStatus({
|
||||
fetchMock,
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
expectedStatus: 200,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
// Should have used redirect: "manual"
|
||||
expect(fetchInitAt(fetchMock, 0)).toHaveProperty("redirect", "manual");
|
||||
});
|
||||
|
||||
it("pins the validated DNS result into the request dispatcher", async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
await expectSafeFetchStatus({
|
||||
fetchMock,
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
expectedStatus: 200,
|
||||
});
|
||||
|
||||
expect(fetchInitAt(fetchMock, 0)).toHaveProperty("dispatcher");
|
||||
});
|
||||
|
||||
it("follows a redirect to an allowlisted host with public IP", async () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://cdn.sharepoint.com/storage/file.pdf",
|
||||
});
|
||||
await expectSafeFetchStatus({
|
||||
fetchMock,
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
expectedStatus: 200,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails explicitly for custom fetch functions that cannot receive the pinned dispatcher", async () => {
|
||||
let called = false;
|
||||
const customFetch = async () => {
|
||||
called = true;
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: customFetch as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("fetchFnSupportsDispatcher");
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the redirect response when dispatcher is provided by an outer guard", async () => {
|
||||
const redirectedTo = "https://cdn.sharepoint.com/storage/file.pdf";
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": redirectedTo,
|
||||
});
|
||||
const res = await safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { dispatcher: {} } as RequestInit,
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toBe(redirectedTo);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("still enforces allowlist checks before returning dispatcher-mode redirects", async () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
|
||||
});
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { dispatcher: {} } as RequestInit,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("blocked by allowlist");
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("blocks a redirect to a non-allowlisted host", async () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://evil.example.com/steal",
|
||||
});
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("allowlist");
|
||||
// Should not have fetched the evil URL
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks a redirect to an allowlisted host that resolves to a private IP (DNS rebinding)", async () => {
|
||||
let callCount = 0;
|
||||
const rebindingResolve = async () => {
|
||||
callCount++;
|
||||
// First call (initial URL) resolves to public IP
|
||||
if (callCount === 1) {
|
||||
return { address: "13.107.136.10" };
|
||||
}
|
||||
// Second call (redirect target) resolves to private IP
|
||||
return { address: "169.254.169.254" };
|
||||
};
|
||||
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file.pdf": "https://evil.trafficmanager.net/metadata",
|
||||
});
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com", "trafficmanager.net"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: rebindingResolve,
|
||||
}),
|
||||
).rejects.toThrow("private/internal");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks when the initial URL resolves to a private IP", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://evil.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: privateResolve("10.0.0.1"),
|
||||
}),
|
||||
).rejects.toThrow("private/internal");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private hosts with the default resolver", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://localhost/file.pdf",
|
||||
allowHosts: ["localhost"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
).rejects.toThrow("private/internal");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks when initial URL DNS resolution fails", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://nonexistent.sharepoint.com/file.pdf",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: failingResolve,
|
||||
}),
|
||||
).rejects.toThrow("DNS failure");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("follows multiple redirects when all are valid", async () => {
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url === "https://a.sharepoint.com/1" && init?.redirect === "manual") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://b.sharepoint.com/2" },
|
||||
});
|
||||
}
|
||||
if (url === "https://b.sharepoint.com/2" && init?.redirect === "manual") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://c.sharepoint.com/3" },
|
||||
});
|
||||
}
|
||||
return new Response("final", { status: 200 });
|
||||
});
|
||||
|
||||
const res = await safeFetch({
|
||||
url: "https://a.sharepoint.com/1",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body?.cancel();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("throws on too many redirects", async () => {
|
||||
let counter = 0;
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.redirect === "manual") {
|
||||
counter++;
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: `https://loop${counter}.sharepoint.com/x` },
|
||||
});
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://start.sharepoint.com/x",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("Too many redirects");
|
||||
});
|
||||
|
||||
it("blocks redirect to HTTP (non-HTTPS)", async () => {
|
||||
const fetchMock = mockFetchWithRedirect({
|
||||
"https://teams.sharepoint.com/file": "http://internal.sharepoint.com/file",
|
||||
});
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://teams.sharepoint.com/file",
|
||||
allowHosts: ["sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
}),
|
||||
).rejects.toThrow("https");
|
||||
});
|
||||
|
||||
it("strips authorization across redirects outside auth allowlist", async () => {
|
||||
const seenAuth: string[] = [];
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const auth = new Headers(init?.headers).get("authorization") ?? "";
|
||||
seenAuth.push(`${url}|${auth}`);
|
||||
if (url === "https://graph.microsoft.com/v1.0/me/photo") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
|
||||
});
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
const headers = new Headers({ Authorization: "Bearer secret" });
|
||||
const res = await safeFetch({
|
||||
url: "https://graph.microsoft.com/v1.0/me/photo",
|
||||
allowHosts: ["graph.microsoft.com", "sharepoint.com"],
|
||||
authorizationAllowHosts: ["graph.microsoft.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { headers },
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body?.cancel();
|
||||
expect(seenAuth[0]).toContain("Bearer secret");
|
||||
expect(seenAuth[1]).toMatch(/\|$/);
|
||||
});
|
||||
|
||||
it("keeps authorization across redirects inside auth allowlist", async () => {
|
||||
const seenAuth: string[] = [];
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const auth = new Headers(init?.headers).get("authorization") ?? "";
|
||||
seenAuth.push(`${url}|${auth}`);
|
||||
if (url === "https://graph.microsoft.com/file.pdf") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://cdn.sharepoint.com/storage/file.pdf" },
|
||||
});
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
const headers = new Headers({ Authorization: "Bearer secret" });
|
||||
const res = await safeFetch({
|
||||
url: "https://graph.microsoft.com/file.pdf",
|
||||
allowHosts: ["graph.microsoft.com", "sharepoint.com"],
|
||||
authorizationAllowHosts: ["graph.microsoft.com", "sharepoint.com"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { headers },
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body?.cancel();
|
||||
expect(seenAuth[0]).toContain("Bearer secret");
|
||||
expect(seenAuth[1]).toContain("Bearer secret");
|
||||
});
|
||||
|
||||
it("keeps authorization across HTTPS redirects when auth allowlist is wildcard", async () => {
|
||||
const seenAuth: string[] = [];
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const auth = new Headers(init?.headers).get("authorization") ?? "";
|
||||
seenAuth.push(`${url}|${auth}`);
|
||||
if (url === "https://graph.microsoft.com/file.pdf") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://cdn.example.com/storage/file.pdf" },
|
||||
});
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
const headers = new Headers({ Authorization: "Bearer secret" });
|
||||
const res = await safeFetch({
|
||||
url: "https://graph.microsoft.com/file.pdf",
|
||||
allowHosts: ["*"],
|
||||
authorizationAllowHosts: ["*"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { headers },
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body?.cancel();
|
||||
expect(seenAuth[0]).toContain("Bearer secret");
|
||||
expect(seenAuth[1]).toContain("Bearer secret");
|
||||
});
|
||||
|
||||
it("strips authorization from the initial fetch outside auth allowlist", async () => {
|
||||
const seenAuth: string[] = [];
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
seenAuth.push(new Headers(init?.headers).get("authorization") ?? "");
|
||||
expect(url).toBe("https://attacker.trafficmanager.net/v3/attachments/att-1");
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
const res = await safeFetch({
|
||||
url: "https://attacker.trafficmanager.net/v3/attachments/att-1",
|
||||
allowHosts: ["trafficmanager.net"],
|
||||
authorizationAllowHosts: ["smba.trafficmanager.net"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
requestInit: { headers: { Authorization: "Bearer secret" } },
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(seenAuth).toEqual([""]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment fetch auth helpers", () => {
|
||||
it("sets and clears authorization header by auth allowlist", () => {
|
||||
const headers = new Headers();
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: "https://graph.microsoft.com/v1.0/me",
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
bearerToken: "token-1",
|
||||
});
|
||||
expect(headers.get("authorization")).toBe("Bearer token-1");
|
||||
|
||||
applyAuthorizationHeaderForUrl({
|
||||
headers,
|
||||
url: "https://evil.example.com/collect",
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
bearerToken: "token-1",
|
||||
});
|
||||
expect(headers.get("authorization")).toBeNull();
|
||||
});
|
||||
|
||||
it("safeFetchWithPolicy forwards policy allowlists", async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
const res = await safeFetchWithPolicy({
|
||||
url: "https://teams.sharepoint.com/file.pdf",
|
||||
policy: resolveAttachmentFetchPolicy({
|
||||
allowHosts: ["sharepoint.com"],
|
||||
authAllowHosts: ["graph.microsoft.com"],
|
||||
}),
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
resolveFn: publicResolve,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
await res.body?.cancel();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Graph shared-link helpers", () => {
|
||||
it.each([
|
||||
["https://contoso.sharepoint.com/personal/user/Documents/report.pdf", true],
|
||||
["https://contoso.sharepoint.us/sites/team/file.docx", true],
|
||||
["https://contoso.sharepoint.cn/file", true],
|
||||
["https://tenant-my.sharepoint.com/:b:/g/personal/file", true],
|
||||
["https://1drv.ms/b/s!AkxYabc", true],
|
||||
["https://onedrive.live.com/view.aspx?resid=ABC", true],
|
||||
["https://onedrive.com/share/abc", true],
|
||||
["https://graph.microsoft.com/v1.0/me", false],
|
||||
["https://smba.trafficmanager.net/amer/v3", false],
|
||||
["https://example.com/file.pdf", false],
|
||||
["not-a-url", false],
|
||||
])("isGraphSharedLinkUrl(%s) === %s", (url, expected) => {
|
||||
expect(isGraphSharedLinkUrl(url)).toBe(expected);
|
||||
});
|
||||
|
||||
it("encodeGraphShareId uses u! + base64url without padding", () => {
|
||||
// Graph docs example: encoding "https://onedrive.live.com/redir?resid=..."
|
||||
// should yield u!aHR0cHM6... (base64url, no '+', '/', or trailing '=').
|
||||
const url = "https://contoso.sharepoint.com/sites/a/Shared Documents/file.pdf";
|
||||
const shareId = encodeGraphShareId(url);
|
||||
expect(shareId.startsWith("u!")).toBe(true);
|
||||
const encoded = shareId.slice(2);
|
||||
// base64url alphabet is A-Z, a-z, 0-9, '-', '_' (no padding).
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
// Round-trip check: decoding yields the original URL.
|
||||
const decoded = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
expect(decoded).toBe(url);
|
||||
});
|
||||
|
||||
it("encodeGraphShareId swaps '+' and '/' for '-' and '_'", () => {
|
||||
// A URL whose standard base64 contains '+' and '/' chars.
|
||||
// Choose an input that base64 encodes with those characters.
|
||||
const url = "https://host.sharepoint.com/sites/path?x=???";
|
||||
const shareId = encodeGraphShareId(url);
|
||||
const encoded = shareId.slice(2);
|
||||
expect(encoded).not.toContain("+");
|
||||
expect(encoded).not.toContain("/");
|
||||
expect(encoded).not.toContain("=");
|
||||
});
|
||||
|
||||
it("tryBuildGraphSharesUrlForSharedLink rewrites SharePoint URLs", () => {
|
||||
const url = "https://contoso.sharepoint.com/personal/user/Documents/report.pdf";
|
||||
const result = tryBuildGraphSharesUrlForSharedLink(url);
|
||||
expect(result).toBe(
|
||||
`https://graph.microsoft.com/v1.0/shares/${encodeGraphShareId(url)}/driveItem/content`,
|
||||
);
|
||||
});
|
||||
|
||||
it("tryBuildGraphSharesUrlForSharedLink rewrites OneDrive URLs", () => {
|
||||
const url = "https://1drv.ms/b/s!AkxYabcdefg";
|
||||
const result = tryBuildGraphSharesUrlForSharedLink(url);
|
||||
expect(result).toBe(
|
||||
`https://graph.microsoft.com/v1.0/shares/${encodeGraphShareId(url)}/driveItem/content`,
|
||||
);
|
||||
});
|
||||
|
||||
it("tryBuildGraphSharesUrlForSharedLink returns undefined for non-shared URLs", () => {
|
||||
expect(
|
||||
tryBuildGraphSharesUrlForSharedLink("https://graph.microsoft.com/v1.0/me"),
|
||||
).toBeUndefined();
|
||||
expect(tryBuildGraphSharesUrlForSharedLink("https://example.com/file.pdf")).toBeUndefined();
|
||||
expect(tryBuildGraphSharesUrlForSharedLink("not-a-url")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("msteams inline image limits", () => {
|
||||
const smallPngDataUrl = "data:image/png;base64,aGVsbG8="; // "hello" (5 bytes)
|
||||
|
||||
it("rejects inline data images above per-image limit", () => {
|
||||
const attachments = [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: `<img src="${smallPngDataUrl}" />`,
|
||||
},
|
||||
];
|
||||
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 4 });
|
||||
expect(out).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("accepts inline data images within limit", () => {
|
||||
const attachments = [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: `<img src="${smallPngDataUrl}" />`,
|
||||
},
|
||||
];
|
||||
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 });
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.kind).toBe("data");
|
||||
if (out[0]?.kind === "data") {
|
||||
expect(out[0].data.byteLength).toBeGreaterThan(0);
|
||||
expect(out[0].contentType).toBe("image/png");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects inline data images with malformed base64 padding", () => {
|
||||
const attachments = [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: `<img src="data:image/png;base64,aGV=sbG8=" />`,
|
||||
},
|
||||
];
|
||||
const out = extractInlineImageCandidates(attachments, { maxInlineBytes: 10 });
|
||||
expect(out).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("enforces cumulative inline size limit across attachments", () => {
|
||||
const attachments = [
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: `<img src="${smallPngDataUrl}" />`,
|
||||
},
|
||||
{
|
||||
contentType: "text/html",
|
||||
content: `<img src="${smallPngDataUrl}" />`,
|
||||
},
|
||||
];
|
||||
const out = extractInlineImageCandidates(attachments, {
|
||||
maxInlineBytes: 10,
|
||||
maxInlineTotalBytes: 6,
|
||||
});
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.kind).toBe("data");
|
||||
});
|
||||
});
|
||||
730
extensions/msteams/src/attachments/shared.ts
Normal file
730
extensions/msteams/src/attachments/shared.ts
Normal file
@@ -0,0 +1,730 @@
|
||||
// Msteams plugin module implements shared behavior.
|
||||
import { Buffer } from "node:buffer";
|
||||
import { lookup } from "node:dns/promises";
|
||||
import {
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
||||
isPrivateIpAddress,
|
||||
normalizeHostnameSuffixAllowlist,
|
||||
type SsrFPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-policy";
|
||||
import { fetchWithSsrFGuard, type LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
isRecord,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { responseWithRelease } from "../response-with-release.js";
|
||||
import type { MSTeamsAttachmentLike } from "./types.js";
|
||||
|
||||
type InlineImageCandidate =
|
||||
| {
|
||||
kind: "data";
|
||||
data: Buffer;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
}
|
||||
| {
|
||||
kind: "url";
|
||||
url: string;
|
||||
contentType?: string;
|
||||
fileHint?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
type InlineImageLimitOptions = {
|
||||
maxInlineBytes?: number;
|
||||
maxInlineTotalBytes?: number;
|
||||
};
|
||||
|
||||
const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i;
|
||||
|
||||
export const IMG_SRC_RE = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
||||
export const ATTACHMENT_TAG_RE = /<attachment[^>]+id=["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
const DEFAULT_MEDIA_HOST_ALLOWLIST = [
|
||||
"graph.microsoft.com",
|
||||
"graph.microsoft.us",
|
||||
"graph.microsoft.de",
|
||||
"graph.microsoft.cn",
|
||||
"sharepoint.com",
|
||||
"sharepoint.us",
|
||||
"sharepoint.de",
|
||||
"sharepoint.cn",
|
||||
"sharepoint-df.com",
|
||||
"1drv.ms",
|
||||
"onedrive.com",
|
||||
"teams.microsoft.com",
|
||||
"teams.cdn.office.net",
|
||||
"statics.teams.cdn.office.net",
|
||||
"office.com",
|
||||
"office.net",
|
||||
// Azure Media Services / Skype CDN for clipboard-pasted images
|
||||
"asm.skype.com",
|
||||
"ams.skype.com",
|
||||
"media.ams.skype.com",
|
||||
// Bot Framework attachment URLs
|
||||
"trafficmanager.net",
|
||||
"botframework.azure.cn",
|
||||
"blob.core.windows.net",
|
||||
"azureedge.net",
|
||||
"microsoft.com",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
|
||||
"api.botframework.com",
|
||||
"botframework.com",
|
||||
// Bot Framework Service URL (smba.trafficmanager.net) used for outbound
|
||||
// replies and inbound attachment downloads (clipboard-pasted images).
|
||||
"smba.trafficmanager.net",
|
||||
"botframework.azure.cn",
|
||||
"graph.microsoft.com",
|
||||
"graph.microsoft.us",
|
||||
"graph.microsoft.de",
|
||||
"graph.microsoft.cn",
|
||||
] as const;
|
||||
|
||||
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
|
||||
export { isRecord };
|
||||
|
||||
// Keep this local; importing the broad media-runtime SDK barrel pulls image/audio runtimes into
|
||||
// hot MSTeams attachment tests for one tiny estimator.
|
||||
export function estimateBase64DecodedBytes(base64: string): number {
|
||||
let effectiveLen = 0;
|
||||
for (let i = 0; i < base64.length; i += 1) {
|
||||
const code = base64.charCodeAt(i);
|
||||
if (code <= 0x20) {
|
||||
continue;
|
||||
}
|
||||
effectiveLen += 1;
|
||||
}
|
||||
|
||||
if (effectiveLen === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let padding = 0;
|
||||
let end = base64.length - 1;
|
||||
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
if (end >= 0 && base64[end] === "=") {
|
||||
padding = 1;
|
||||
end -= 1;
|
||||
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
if (end >= 0 && base64[end] === "=") {
|
||||
padding = 2;
|
||||
}
|
||||
}
|
||||
|
||||
const estimated = Math.floor((effectiveLen * 3) / 4) - padding;
|
||||
return Math.max(0, estimated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host suffixes for SharePoint/OneDrive shared links that must be fetched via
|
||||
* the Graph `/shares/{shareId}/driveItem/content` endpoint instead of directly.
|
||||
*
|
||||
* Direct fetches of SharePoint/OneDrive shared URLs return empty/HTML landing
|
||||
* pages unless encoded as a Graph share id. See
|
||||
* https://learn.microsoft.com/en-us/graph/api/shares-get for the encoding.
|
||||
*/
|
||||
const GRAPH_SHARED_LINK_HOST_SUFFIXES = [
|
||||
".sharepoint.com",
|
||||
".sharepoint.us",
|
||||
".sharepoint.de",
|
||||
".sharepoint.cn",
|
||||
".sharepoint-df.com",
|
||||
"1drv.ms",
|
||||
"onedrive.live.com",
|
||||
"onedrive.com",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Returns true when the URL points at a SharePoint or OneDrive host whose
|
||||
* shared-link content must be fetched through the Graph shares API rather
|
||||
* than directly.
|
||||
*/
|
||||
export function isGraphSharedLinkUrl(url: string): boolean {
|
||||
let host: string;
|
||||
try {
|
||||
host = normalizeLowercaseStringOrEmpty(new URL(url).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!host) {
|
||||
return false;
|
||||
}
|
||||
return GRAPH_SHARED_LINK_HOST_SUFFIXES.some((suffix) => host === suffix || host.endsWith(suffix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a SharePoint/OneDrive URL as a Graph shareId using the documented
|
||||
* `u!` + base64url (no padding) scheme:
|
||||
* https://learn.microsoft.com/en-us/graph/api/shares-get#encoding-sharing-urls
|
||||
*/
|
||||
export function encodeGraphShareId(url: string): string {
|
||||
// Buffer.from(...).toString("base64url") already returns base64url without
|
||||
// padding, matching the Graph spec exactly.
|
||||
return `u!${Buffer.from(url, "utf8").toString("base64url")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* When `url` is a SharePoint/OneDrive shared link, return the matching
|
||||
* `GET /shares/{shareId}/driveItem/content` URL that actually yields the file
|
||||
* bytes. Returns `undefined` for non-shared-link URLs so callers can fall
|
||||
* through to the existing fetch path.
|
||||
*/
|
||||
export function tryBuildGraphSharesUrlForSharedLink(url: string): string | undefined {
|
||||
if (!isGraphSharedLinkUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return `${GRAPH_ROOT}/shares/${encodeGraphShareId(url)}/driveItem/content`;
|
||||
}
|
||||
|
||||
export function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
||||
let current: unknown = value;
|
||||
for (const key of keys) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key as keyof typeof current];
|
||||
}
|
||||
return normalizeOptionalString(current);
|
||||
}
|
||||
|
||||
export function resolveRequestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
|
||||
return input.url;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(input);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeContentType(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function inferPlaceholder(params: {
|
||||
contentType?: string;
|
||||
fileName?: string;
|
||||
fileType?: string;
|
||||
}): string {
|
||||
const mime = normalizeLowercaseStringOrEmpty(params.contentType ?? "");
|
||||
const name = normalizeLowercaseStringOrEmpty(params.fileName ?? "");
|
||||
const fileType = normalizeLowercaseStringOrEmpty(params.fileType ?? "");
|
||||
|
||||
const looksLikeImage =
|
||||
mime.startsWith("image/") || IMAGE_EXT_RE.test(name) || IMAGE_EXT_RE.test(`x.${fileType}`);
|
||||
|
||||
return looksLikeImage ? "<media:image>" : "<media:document>";
|
||||
}
|
||||
|
||||
export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||
const name = typeof att.name === "string" ? att.name : "";
|
||||
if (contentType.startsWith("image/")) {
|
||||
return true;
|
||||
}
|
||||
if (IMAGE_EXT_RE.test(name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
||||
isRecord(att.content)
|
||||
) {
|
||||
const fileType = typeof att.content.fileType === "string" ? att.content.fileType : "";
|
||||
if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) {
|
||||
return true;
|
||||
}
|
||||
const fileName = typeof att.content.fileName === "string" ? att.content.fileName : "";
|
||||
if (fileName && IMAGE_EXT_RE.test(fileName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the attachment can be downloaded (any file type).
|
||||
* Used when downloading all files, not just images.
|
||||
*/
|
||||
export function isDownloadableAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||
|
||||
// Teams file download info always has a downloadUrl
|
||||
if (
|
||||
contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
||||
isRecord(att.content) &&
|
||||
typeof att.content.downloadUrl === "string"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Any attachment with a contentUrl can be downloaded
|
||||
if (typeof att.contentUrl === "string" && att.contentUrl.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||
return contentType.startsWith("text/html");
|
||||
}
|
||||
|
||||
export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined {
|
||||
if (!isHtmlAttachment(att)) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof att.content === "string") {
|
||||
return att.content;
|
||||
}
|
||||
if (!isRecord(att.content)) {
|
||||
return undefined;
|
||||
}
|
||||
const text =
|
||||
typeof att.content.text === "string"
|
||||
? att.content.text
|
||||
: typeof att.content.body === "string"
|
||||
? att.content.body
|
||||
: typeof att.content.content === "string"
|
||||
? att.content.content
|
||||
: undefined;
|
||||
return text;
|
||||
}
|
||||
|
||||
function canonicalizeInlineBase64Payload(value: string): string | undefined {
|
||||
let cleaned = "";
|
||||
let padding = 0;
|
||||
let sawPadding = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x20) {
|
||||
continue;
|
||||
}
|
||||
if (code === 0x3d) {
|
||||
padding += 1;
|
||||
if (padding > 2) {
|
||||
return undefined;
|
||||
}
|
||||
sawPadding = true;
|
||||
cleaned += "=";
|
||||
continue;
|
||||
}
|
||||
const isDataChar =
|
||||
(code >= 0x41 && code <= 0x5a) ||
|
||||
(code >= 0x61 && code <= 0x7a) ||
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
code === 0x2b ||
|
||||
code === 0x2f;
|
||||
if (sawPadding || !isDataChar) {
|
||||
return undefined;
|
||||
}
|
||||
cleaned += value[index];
|
||||
}
|
||||
return cleaned && cleaned.length % 4 === 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
function decodeDataImageWithLimits(
|
||||
src: string,
|
||||
opts: { maxInlineBytes?: number },
|
||||
): { candidate: InlineImageCandidate | null; estimatedBytes: number } {
|
||||
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
|
||||
if (!match) {
|
||||
return { candidate: null, estimatedBytes: 0 };
|
||||
}
|
||||
const contentType = normalizeLowercaseStringOrEmpty(match[1] ?? "");
|
||||
const isBase64 = Boolean(match[2]);
|
||||
if (!isBase64) {
|
||||
return { candidate: null, estimatedBytes: 0 };
|
||||
}
|
||||
const payload = match[3] ?? "";
|
||||
const canonicalPayload = canonicalizeInlineBase64Payload(payload);
|
||||
if (!canonicalPayload) {
|
||||
return { candidate: null, estimatedBytes: 0 };
|
||||
}
|
||||
|
||||
const estimatedBytes = estimateBase64DecodedBytes(canonicalPayload);
|
||||
if (estimatedBytes <= 0) {
|
||||
return { candidate: null, estimatedBytes: 0 };
|
||||
}
|
||||
if (typeof opts.maxInlineBytes === "number" && estimatedBytes > opts.maxInlineBytes) {
|
||||
return { candidate: null, estimatedBytes };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = Buffer.from(canonicalPayload, "base64");
|
||||
return {
|
||||
candidate: { kind: "data", data, contentType, placeholder: "<media:image>" },
|
||||
estimatedBytes,
|
||||
};
|
||||
} catch {
|
||||
return { candidate: null, estimatedBytes: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function fileHintFromUrl(src: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
const name = url.pathname.split("/").pop();
|
||||
return name || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractInlineImageCandidates(
|
||||
attachments: MSTeamsAttachmentLike[],
|
||||
limits?: InlineImageLimitOptions,
|
||||
): InlineImageCandidate[] {
|
||||
const out: InlineImageCandidate[] = [];
|
||||
let totalEstimatedInlineBytes = 0;
|
||||
outerLoop: for (const att of attachments) {
|
||||
const html = extractHtmlFromAttachment(att);
|
||||
if (!html) {
|
||||
continue;
|
||||
}
|
||||
IMG_SRC_RE.lastIndex = 0;
|
||||
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
||||
while (match) {
|
||||
const src = match[1]?.trim();
|
||||
if (src && !src.startsWith("cid:")) {
|
||||
if (src.startsWith("data:")) {
|
||||
const { candidate: decoded, estimatedBytes } = decodeDataImageWithLimits(src, {
|
||||
maxInlineBytes: limits?.maxInlineBytes,
|
||||
});
|
||||
if (decoded) {
|
||||
const nextTotal = totalEstimatedInlineBytes + estimatedBytes;
|
||||
if (
|
||||
typeof limits?.maxInlineTotalBytes === "number" &&
|
||||
nextTotal > limits.maxInlineTotalBytes
|
||||
) {
|
||||
break outerLoop;
|
||||
}
|
||||
totalEstimatedInlineBytes = nextTotal;
|
||||
out.push(decoded);
|
||||
}
|
||||
} else {
|
||||
out.push({
|
||||
kind: "url",
|
||||
url: src,
|
||||
fileHint: fileHintFromUrl(src),
|
||||
placeholder: "<media:image>",
|
||||
});
|
||||
}
|
||||
}
|
||||
match = IMG_SRC_RE.exec(html);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function safeHostForUrl(url: string): string {
|
||||
try {
|
||||
return normalizeLowercaseStringOrEmpty(new URL(url).hostname);
|
||||
} catch {
|
||||
return "invalid-url";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAllowedHosts(input?: string[]): string[] {
|
||||
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_HOST_ALLOWLIST);
|
||||
}
|
||||
|
||||
export function resolveAuthAllowedHosts(input?: string[]): string[] {
|
||||
return normalizeHostnameSuffixAllowlist(input, DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST);
|
||||
}
|
||||
|
||||
export type MSTeamsAttachmentFetchPolicy = {
|
||||
allowHosts: string[];
|
||||
authAllowHosts: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Logger surface for attachment download errors. Structured so callers can
|
||||
* pass `MSTeamsMonitorLogger` directly without adapters. Optional `warn`/
|
||||
* `error` methods prevent silent swallowing of fetch failures — see issue
|
||||
* #63396 where empty `catch {}` blocks hid a Node 24+ undici incompatibility.
|
||||
*/
|
||||
export type MSTeamsAttachmentDownloadLogger = {
|
||||
warn?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
error?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export type MSTeamsAttachmentResolveFn = (hostname: string) => Promise<{ address: string }>;
|
||||
|
||||
function isMockFetchFn(fetchFn: typeof fetch): boolean {
|
||||
const candidate = fetchFn as unknown as { mock?: unknown };
|
||||
return Boolean(candidate.mock || Object.hasOwn(candidate, "_isMockFunction"));
|
||||
}
|
||||
|
||||
function resolveGuardedFetchImpl(params: {
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
}): typeof fetch | undefined {
|
||||
if (!params.fetchFn) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
params.fetchFnSupportsDispatcher === true ||
|
||||
params.fetchFn === fetch ||
|
||||
params.fetchFn === globalThis.fetch ||
|
||||
isMockFetchFn(params.fetchFn)
|
||||
) {
|
||||
return params.fetchFn;
|
||||
}
|
||||
throw new Error(
|
||||
"MSTeams attachment fetchFn must set fetchFnSupportsDispatcher to use guarded DNS pinning",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRetainedAuthorizationRedirectHostnameAllowlist(
|
||||
input?: string[],
|
||||
): string[] | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
if (input.includes("*")) {
|
||||
return ["*"];
|
||||
}
|
||||
return resolveMediaSsrfPolicy(input)?.hostnameAllowlist;
|
||||
}
|
||||
|
||||
export function resolveAttachmentFetchPolicy(params?: {
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
}): MSTeamsAttachmentFetchPolicy {
|
||||
return {
|
||||
allowHosts: resolveAllowedHosts(params?.allowHosts),
|
||||
authAllowHosts: resolveAuthAllowedHosts(params?.authAllowHosts),
|
||||
};
|
||||
}
|
||||
|
||||
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
||||
return isHttpsUrlAllowedByHostnameSuffixAllowlist(url, allowlist);
|
||||
}
|
||||
|
||||
export function applyAuthorizationHeaderForUrl(params: {
|
||||
headers: Headers;
|
||||
url: string;
|
||||
authAllowHosts: string[];
|
||||
bearerToken?: string;
|
||||
}): void {
|
||||
if (!params.bearerToken) {
|
||||
params.headers.delete("Authorization");
|
||||
return;
|
||||
}
|
||||
if (isUrlAllowed(params.url, params.authAllowHosts)) {
|
||||
params.headers.set("Authorization", `Bearer ${params.bearerToken}`);
|
||||
return;
|
||||
}
|
||||
params.headers.delete("Authorization");
|
||||
}
|
||||
|
||||
export function resolveMediaSsrfPolicy(allowHosts: string[]): SsrFPolicy | undefined {
|
||||
return buildHostnameAllowlistPolicyFromSuffixAllowlist(allowHosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given IPv4 or IPv6 address is in a private, loopback,
|
||||
* or link-local range that must never be reached from media downloads.
|
||||
*
|
||||
* Delegates to the SDK's `isPrivateIpAddress` which handles IPv4-mapped IPv6,
|
||||
* expanded notation, NAT64, 6to4, Teredo, octal IPv4, and fails closed on
|
||||
* parse errors.
|
||||
*/
|
||||
export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress;
|
||||
|
||||
/**
|
||||
* Resolve a hostname via DNS and reject private/reserved IPs.
|
||||
* Throws if the resolved IP is private or resolution fails.
|
||||
*/
|
||||
export async function resolveAndValidateIP(
|
||||
hostname: string,
|
||||
resolveFn?: MSTeamsAttachmentResolveFn,
|
||||
): Promise<string> {
|
||||
const resolve = resolveFn ?? lookup;
|
||||
let resolved: { address: string };
|
||||
try {
|
||||
resolved = await resolve(hostname);
|
||||
} catch {
|
||||
throw new Error(`DNS resolution failed for "${hostname}"`);
|
||||
}
|
||||
if (isPrivateOrReservedIP(resolved.address)) {
|
||||
throw new Error(`Hostname "${hostname}" resolves to private/reserved IP (${resolved.address})`);
|
||||
}
|
||||
return resolved.address;
|
||||
}
|
||||
|
||||
/** Maximum number of redirects to follow in safeFetch. */
|
||||
const MAX_SAFE_REDIRECTS = 5;
|
||||
/**
|
||||
* Fetch a URL with redirect: "manual", validating each redirect target
|
||||
* against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).
|
||||
*
|
||||
* This prevents:
|
||||
* - Auto-following redirects to non-allowlisted hosts
|
||||
* - DNS rebinding attacks when a lookup function is provided
|
||||
*/
|
||||
export async function safeFetch(params: {
|
||||
url: string;
|
||||
allowHosts: string[];
|
||||
/**
|
||||
* Optional allowlist for forwarding Authorization across redirects.
|
||||
* When set, Authorization is stripped before following redirects to hosts
|
||||
* outside this list.
|
||||
*/
|
||||
authorizationAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
}): Promise<Response> {
|
||||
const resolveFn = params.resolveFn ?? lookup;
|
||||
const hasDispatcher = Boolean(
|
||||
params.requestInit &&
|
||||
typeof params.requestInit === "object" &&
|
||||
"dispatcher" in (params.requestInit as Record<string, unknown>),
|
||||
);
|
||||
const currentHeaders = new Headers(params.requestInit?.headers);
|
||||
let currentUrl = params.url;
|
||||
|
||||
if (!isUrlAllowed(currentUrl, params.allowHosts)) {
|
||||
throw new Error(`Initial download URL blocked: ${currentUrl}`);
|
||||
}
|
||||
|
||||
// Authorization is only allowed on explicitly auth-allowlisted hosts, including
|
||||
// the first hop. Redirect hops apply the same rule below or in fetchWithSsrFGuard.
|
||||
if (
|
||||
currentHeaders.has("authorization") &&
|
||||
params.authorizationAllowHosts &&
|
||||
!isUrlAllowed(currentUrl, params.authorizationAllowHosts)
|
||||
) {
|
||||
currentHeaders.delete("authorization");
|
||||
}
|
||||
|
||||
if (!hasDispatcher) {
|
||||
const guarded = await fetchWithSsrFGuard({
|
||||
url: currentUrl,
|
||||
fetchImpl: resolveGuardedFetchImpl({
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
}),
|
||||
init: {
|
||||
...params.requestInit,
|
||||
headers: currentHeaders,
|
||||
},
|
||||
maxRedirects: MAX_SAFE_REDIRECTS,
|
||||
requireHttps: true,
|
||||
policy: resolveMediaSsrfPolicy(params.allowHosts),
|
||||
lookupFn: resolveFn as LookupFn,
|
||||
retainAuthorizationRedirectHostnameAllowlist:
|
||||
resolveRetainedAuthorizationRedirectHostnameAllowlist(params.authorizationAllowHosts),
|
||||
auditContext: "msteams.attachment",
|
||||
});
|
||||
return responseWithRelease(guarded.response, guarded.release);
|
||||
}
|
||||
|
||||
if (resolveFn) {
|
||||
try {
|
||||
const initialHost = new URL(currentUrl).hostname;
|
||||
await resolveAndValidateIP(initialHost, resolveFn);
|
||||
} catch {
|
||||
throw new Error(`Initial download URL blocked: ${currentUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i <= MAX_SAFE_REDIRECTS; i++) {
|
||||
const res = await (params.fetchFn ?? fetch)(currentUrl, {
|
||||
...params.requestInit,
|
||||
headers: currentHeaders,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (![301, 302, 303, 307, 308].includes(res.status)) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const location = res.headers.get("location");
|
||||
if (!location) {
|
||||
return res;
|
||||
}
|
||||
|
||||
let redirectUrl: string;
|
||||
try {
|
||||
redirectUrl = new URL(location, currentUrl).toString();
|
||||
} catch {
|
||||
throw new Error(`Invalid redirect URL: ${location}`);
|
||||
}
|
||||
|
||||
// Validate redirect target against hostname allowlist
|
||||
if (!isUrlAllowed(redirectUrl, params.allowHosts)) {
|
||||
throw new Error(`Media redirect target blocked by allowlist: ${redirectUrl}`);
|
||||
}
|
||||
|
||||
// Prevent credential bleed: only keep Authorization on redirect hops that
|
||||
// are explicitly auth-allowlisted.
|
||||
if (
|
||||
currentHeaders.has("authorization") &&
|
||||
params.authorizationAllowHosts &&
|
||||
!isUrlAllowed(redirectUrl, params.authorizationAllowHosts)
|
||||
) {
|
||||
currentHeaders.delete("authorization");
|
||||
}
|
||||
|
||||
// When a pinned dispatcher is already injected by an upstream guard
|
||||
// (for example fetchWithSsrFGuard), let that guard own redirect handling
|
||||
// after this allowlist validation step.
|
||||
if (hasDispatcher) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Validate redirect target's resolved IP
|
||||
if (resolveFn) {
|
||||
const redirectHost = new URL(redirectUrl).hostname;
|
||||
await resolveAndValidateIP(redirectHost, resolveFn);
|
||||
}
|
||||
|
||||
currentUrl = redirectUrl;
|
||||
}
|
||||
|
||||
throw new Error(`Too many redirects (>${MAX_SAFE_REDIRECTS})`);
|
||||
}
|
||||
|
||||
export async function safeFetchWithPolicy(params: {
|
||||
url: string;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
fetchFn?: typeof fetch;
|
||||
fetchFnSupportsDispatcher?: boolean;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
}): Promise<Response> {
|
||||
return await safeFetch({
|
||||
url: params.url,
|
||||
allowHosts: params.policy.allowHosts,
|
||||
authorizationAllowHosts: params.policy.authAllowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
fetchFnSupportsDispatcher: params.fetchFnSupportsDispatcher,
|
||||
requestInit: params.requestInit,
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
}
|
||||
49
extensions/msteams/src/attachments/types.ts
Normal file
49
extensions/msteams/src/attachments/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Msteams type declarations define plugin contracts.
|
||||
export type MSTeamsAttachmentLike = {
|
||||
id?: string | null;
|
||||
contentType?: string | null;
|
||||
contentUrl?: string | null;
|
||||
name?: string | null;
|
||||
thumbnailUrl?: string | null;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export type MSTeamsAccessTokenProvider = {
|
||||
getAccessToken: (scope: string) => Promise<string>;
|
||||
};
|
||||
|
||||
export type MSTeamsInboundMedia = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export type MSTeamsHtmlAttachmentSummary = {
|
||||
htmlAttachments: number;
|
||||
imgTags: number;
|
||||
dataImages: number;
|
||||
cidImages: number;
|
||||
srcHosts: string[];
|
||||
attachmentTags: number;
|
||||
attachmentIds: string[];
|
||||
};
|
||||
|
||||
export type MSTeamsGraphMediaResult = {
|
||||
media: MSTeamsInboundMedia[];
|
||||
hostedCount?: number;
|
||||
attachmentCount?: number;
|
||||
hostedStatus?: number;
|
||||
attachmentStatus?: number;
|
||||
messageUrl?: string;
|
||||
tokenError?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow logger surface used by `downloadMSTeamsGraphMedia` for diagnostic
|
||||
* events. Accepting an optional callback keeps the helper testable without
|
||||
* pulling in the full channel logger type, while still allowing the monitor
|
||||
* handler to forward its plugin logger.
|
||||
*/
|
||||
export type MSTeamsGraphMediaLogger = {
|
||||
debug?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
Reference in New Issue
Block a user