refactor(msteams,bluebubbles): dedupe inbound media download helpers

This commit is contained in:
Peter Steinberger
2026-02-21 23:08:07 +01:00
parent 73d93dee64
commit 61dc7ac679
7 changed files with 99 additions and 79 deletions

View File

@@ -24,7 +24,11 @@ const fetchRemoteMediaMock = vi.fn(
}
const buffer = Buffer.from(await res.arrayBuffer());
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
throw new Error(`payload exceeds maxBytes ${params.maxBytes}`);
const error = new Error(`payload exceeds maxBytes ${params.maxBytes}`) as Error & {
code?: string;
};
error.code = "max_bytes";
throw error;
}
return {
buffer,

View File

@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { resolveRequestUrl } from "./request-url.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
import { resolveChatGuidForTarget } from "./send.js";
@@ -58,17 +59,16 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
return resolveBlueBubblesServerAccount(params);
}
function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
if (!error || typeof error !== "object") {
return undefined;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
return input.url;
}
return String(input);
const code = (error as { code?: unknown }).code;
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
? code
: undefined;
}
export async function downloadBlueBubblesAttachment(
@@ -103,10 +103,10 @@ export async function downloadBlueBubblesAttachment(
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
};
} catch (error) {
const text = error instanceof Error ? error.message : String(error);
if (/(?:maxBytes|content length|payload exceeds)/i.test(text)) {
if (readMediaFetchErrorCode(error) === "max_bytes") {
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} bytes)`);
}
const text = error instanceof Error ? error.message : String(error);
throw new Error(`BlueBubbles attachment download failed: ${text}`);
}
}

View File

@@ -0,0 +1,12 @@
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;
}
return String(input);
}

View File

@@ -1,4 +1,5 @@
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
extractInlineImageCandidates,
inferPlaceholder,
@@ -6,6 +7,7 @@ import {
isRecord,
isUrlAllowed,
normalizeContentType,
resolveRequestUrl,
resolveAuthAllowedHosts,
resolveAllowedHosts,
} from "./shared.js";
@@ -149,19 +151,6 @@ async function fetchWithAuthFallback(params: {
return firstAttempt;
}
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;
}
return String(input);
}
function readRedirectUrl(baseUrl: string, res: Response): string | null {
if (![301, 302, 303, 307, 308].includes(res.status)) {
return null;
@@ -258,8 +247,13 @@ export async function downloadMSTeamsAttachments(params: {
continue;
}
try {
const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: candidate.url,
filePathHint: candidate.fileHint ?? candidate.url,
maxBytes: params.maxBytes,
contentTypeHint: candidate.contentTypeHint,
placeholder: candidate.placeholder,
preserveFilenames: params.preserveFilenames,
fetchImpl: (input, init) =>
fetchWithAuthFallback({
url: resolveRequestUrl(input),
@@ -269,27 +263,8 @@ export async function downloadMSTeamsAttachments(params: {
allowHosts,
authAllowHosts,
}),
filePathHint: candidate.fileHint ?? candidate.url,
maxBytes: params.maxBytes,
});
const mime = await getMSTeamsRuntime().media.detectMime({
buffer: fetched.buffer,
headerMime: fetched.contentType,
filePath: candidate.fileHint ?? candidate.url,
});
const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
mime ?? candidate.contentTypeHint,
"inbound",
params.maxBytes,
originalFilename,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: candidate.placeholder,
});
out.push(media);
} catch {
// Ignore download failures and continue with next candidate.
}

View File

@@ -1,10 +1,12 @@
import { getMSTeamsRuntime } from "../runtime.js";
import { downloadMSTeamsAttachments } from "./download.js";
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
import {
GRAPH_ROOT,
inferPlaceholder,
isRecord,
normalizeContentType,
resolveRequestUrl,
resolveAllowedHosts,
} from "./shared.js";
import type {
@@ -14,19 +16,6 @@ import type {
MSTeamsInboundMedia,
} from "./types.js";
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;
}
return String(input);
}
type GraphHostedContent = {
id?: string | null;
contentType?: string | null;
@@ -278,10 +267,12 @@ export async function downloadMSTeamsGraphMedia(params: {
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
const media = await downloadAndStoreMSTeamsRemoteMedia({
url: sharesUrl,
filePathHint: name,
maxBytes: params.maxBytes,
contentTypeHint: "application/octet-stream",
preserveFilenames: params.preserveFilenames,
fetchImpl: async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${accessToken}`);
@@ -292,24 +283,7 @@ export async function downloadMSTeamsGraphMedia(params: {
});
},
});
const mime = await getMSTeamsRuntime().media.detectMime({
buffer: fetched.buffer,
headerMime: fetched.contentType,
filePath: name,
});
const originalFilename = params.preserveFilenames ? name : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
mime ?? "application/octet-stream",
"inbound",
params.maxBytes,
originalFilename,
);
sharePointMedia.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }),
});
sharePointMedia.push(media);
downloadedReferenceUrls.add(shareUrl);
} catch {
// Ignore SharePoint download failures.

View File

@@ -0,0 +1,42 @@
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>;
export async function downloadAndStoreMSTeamsRemoteMedia(params: {
url: string;
filePathHint: string;
maxBytes: number;
fetchImpl?: FetchLike;
contentTypeHint?: string;
placeholder?: string;
preserveFilenames?: boolean;
}): Promise<MSTeamsInboundMedia> {
const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
url: params.url,
fetchImpl: params.fetchImpl,
filePathHint: params.filePathHint,
maxBytes: params.maxBytes,
});
const mime = await getMSTeamsRuntime().media.detectMime({
buffer: fetched.buffer,
headerMime: fetched.contentType ?? params.contentTypeHint,
filePath: params.filePathHint,
});
const originalFilename = params.preserveFilenames ? params.filePathHint : undefined;
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
fetched.buffer,
mime ?? params.contentTypeHint,
"inbound",
params.maxBytes,
originalFilename,
);
return {
path: saved.path,
contentType: saved.contentType,
placeholder:
params.placeholder ??
inferPlaceholder({ contentType: saved.contentType, fileName: params.filePathHint }),
};
}

View File

@@ -63,6 +63,19 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
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;
}
return String(input);
}
export function normalizeContentType(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;