From 61dc7ac67994b8a8ae370d8d90200e87746d1b22 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 23:08:07 +0100 Subject: [PATCH] refactor(msteams,bluebubbles): dedupe inbound media download helpers --- .../bluebubbles/src/attachments.test.ts | 6 ++- extensions/bluebubbles/src/attachments.ts | 24 +++++------ extensions/bluebubbles/src/request-url.ts | 12 ++++++ .../msteams/src/attachments/download.ts | 43 ++++--------------- extensions/msteams/src/attachments/graph.ts | 38 +++------------- .../msteams/src/attachments/remote-media.ts | 42 ++++++++++++++++++ extensions/msteams/src/attachments/shared.ts | 13 ++++++ 7 files changed, 99 insertions(+), 79 deletions(-) create mode 100644 extensions/bluebubbles/src/request-url.ts create mode 100644 extensions/msteams/src/attachments/remote-media.ts diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 1dc3a0119..47f6e6d03 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -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, diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 9fcb747c8..48331f215 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -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}`); } } diff --git a/extensions/bluebubbles/src/request-url.ts b/extensions/bluebubbles/src/request-url.ts new file mode 100644 index 000000000..0be775359 --- /dev/null +++ b/extensions/bluebubbles/src/request-url.ts @@ -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); +} diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts index dc6496e2a..4583a30df 100644 --- a/extensions/msteams/src/attachments/download.ts +++ b/extensions/msteams/src/attachments/download.ts @@ -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. } diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts index fd73909fe..7ac94887d 100644 --- a/extensions/msteams/src/attachments/graph.ts +++ b/extensions/msteams/src/attachments/graph.ts @@ -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. diff --git a/extensions/msteams/src/attachments/remote-media.ts b/extensions/msteams/src/attachments/remote-media.ts new file mode 100644 index 000000000..20842b2b5 --- /dev/null +++ b/extensions/msteams/src/attachments/remote-media.ts @@ -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; + +export async function downloadAndStoreMSTeamsRemoteMedia(params: { + url: string; + filePathHint: string; + maxBytes: number; + fetchImpl?: FetchLike; + contentTypeHint?: string; + placeholder?: string; + preserveFilenames?: boolean; +}): Promise { + 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 }), + }; +} diff --git a/extensions/msteams/src/attachments/shared.ts b/extensions/msteams/src/attachments/shared.ts index d7be89532..c3cb01294 100644 --- a/extensions/msteams/src/attachments/shared.ts +++ b/extensions/msteams/src/attachments/shared.ts @@ -63,6 +63,19 @@ export function isRecord(value: unknown): value is Record { 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;