mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 17:27:15 +00:00
refactor(msteams,bluebubbles): dedupe inbound media download helpers
This commit is contained in:
@@ -24,7 +24,11 @@ const fetchRemoteMediaMock = vi.fn(
|
|||||||
}
|
}
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
if (typeof params.maxBytes === "number" && buffer.byteLength > params.maxBytes) {
|
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 {
|
return {
|
||||||
buffer,
|
buffer,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|||||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||||
import { postMultipartFormData } from "./multipart.js";
|
import { postMultipartFormData } from "./multipart.js";
|
||||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||||
|
import { resolveRequestUrl } from "./request-url.js";
|
||||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||||
import { resolveChatGuidForTarget } from "./send.js";
|
import { resolveChatGuidForTarget } from "./send.js";
|
||||||
@@ -58,17 +59,16 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
|||||||
return resolveBlueBubblesServerAccount(params);
|
return resolveBlueBubblesServerAccount(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequestUrl(input: RequestInfo | URL): string {
|
type MediaFetchErrorCode = "max_bytes" | "http_error" | "fetch_failed";
|
||||||
if (typeof input === "string") {
|
|
||||||
return input;
|
function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefined {
|
||||||
|
if (!error || typeof error !== "object") {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
if (input instanceof URL) {
|
const code = (error as { code?: unknown }).code;
|
||||||
return input.toString();
|
return code === "max_bytes" || code === "http_error" || code === "fetch_failed"
|
||||||
}
|
? code
|
||||||
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
|
: undefined;
|
||||||
return input.url;
|
|
||||||
}
|
|
||||||
return String(input);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadBlueBubblesAttachment(
|
export async function downloadBlueBubblesAttachment(
|
||||||
@@ -103,10 +103,10 @@ export async function downloadBlueBubblesAttachment(
|
|||||||
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
|
contentType: fetched.contentType ?? attachment.mimeType ?? undefined,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = error instanceof Error ? error.message : String(error);
|
if (readMediaFetchErrorCode(error) === "max_bytes") {
|
||||||
if (/(?:maxBytes|content length|payload exceeds)/i.test(text)) {
|
|
||||||
throw new Error(`BlueBubbles attachment too large (limit ${maxBytes} 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}`);
|
throw new Error(`BlueBubbles attachment download failed: ${text}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
extensions/bluebubbles/src/request-url.ts
Normal file
12
extensions/bluebubbles/src/request-url.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getMSTeamsRuntime } from "../runtime.js";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
|
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||||
import {
|
import {
|
||||||
extractInlineImageCandidates,
|
extractInlineImageCandidates,
|
||||||
inferPlaceholder,
|
inferPlaceholder,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
isRecord,
|
isRecord,
|
||||||
isUrlAllowed,
|
isUrlAllowed,
|
||||||
normalizeContentType,
|
normalizeContentType,
|
||||||
|
resolveRequestUrl,
|
||||||
resolveAuthAllowedHosts,
|
resolveAuthAllowedHosts,
|
||||||
resolveAllowedHosts,
|
resolveAllowedHosts,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
@@ -149,19 +151,6 @@ async function fetchWithAuthFallback(params: {
|
|||||||
return firstAttempt;
|
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 {
|
function readRedirectUrl(baseUrl: string, res: Response): string | null {
|
||||||
if (![301, 302, 303, 307, 308].includes(res.status)) {
|
if (![301, 302, 303, 307, 308].includes(res.status)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -258,8 +247,13 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
|
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
||||||
url: candidate.url,
|
url: candidate.url,
|
||||||
|
filePathHint: candidate.fileHint ?? candidate.url,
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
|
contentTypeHint: candidate.contentTypeHint,
|
||||||
|
placeholder: candidate.placeholder,
|
||||||
|
preserveFilenames: params.preserveFilenames,
|
||||||
fetchImpl: (input, init) =>
|
fetchImpl: (input, init) =>
|
||||||
fetchWithAuthFallback({
|
fetchWithAuthFallback({
|
||||||
url: resolveRequestUrl(input),
|
url: resolveRequestUrl(input),
|
||||||
@@ -269,27 +263,8 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
allowHosts,
|
allowHosts,
|
||||||
authAllowHosts,
|
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 {
|
} catch {
|
||||||
// Ignore download failures and continue with next candidate.
|
// Ignore download failures and continue with next candidate.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { getMSTeamsRuntime } from "../runtime.js";
|
import { getMSTeamsRuntime } from "../runtime.js";
|
||||||
import { downloadMSTeamsAttachments } from "./download.js";
|
import { downloadMSTeamsAttachments } from "./download.js";
|
||||||
|
import { downloadAndStoreMSTeamsRemoteMedia } from "./remote-media.js";
|
||||||
import {
|
import {
|
||||||
GRAPH_ROOT,
|
GRAPH_ROOT,
|
||||||
inferPlaceholder,
|
inferPlaceholder,
|
||||||
isRecord,
|
isRecord,
|
||||||
normalizeContentType,
|
normalizeContentType,
|
||||||
|
resolveRequestUrl,
|
||||||
resolveAllowedHosts,
|
resolveAllowedHosts,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -14,19 +16,6 @@ import type {
|
|||||||
MSTeamsInboundMedia,
|
MSTeamsInboundMedia,
|
||||||
} from "./types.js";
|
} 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 = {
|
type GraphHostedContent = {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
@@ -278,10 +267,12 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
const encodedUrl = Buffer.from(shareUrl).toString("base64url");
|
||||||
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`;
|
||||||
|
|
||||||
const fetched = await getMSTeamsRuntime().channel.media.fetchRemoteMedia({
|
const media = await downloadAndStoreMSTeamsRemoteMedia({
|
||||||
url: sharesUrl,
|
url: sharesUrl,
|
||||||
filePathHint: name,
|
filePathHint: name,
|
||||||
maxBytes: params.maxBytes,
|
maxBytes: params.maxBytes,
|
||||||
|
contentTypeHint: "application/octet-stream",
|
||||||
|
preserveFilenames: params.preserveFilenames,
|
||||||
fetchImpl: async (input, init) => {
|
fetchImpl: async (input, init) => {
|
||||||
const headers = new Headers(init?.headers);
|
const headers = new Headers(init?.headers);
|
||||||
headers.set("Authorization", `Bearer ${accessToken}`);
|
headers.set("Authorization", `Bearer ${accessToken}`);
|
||||||
@@ -292,24 +283,7 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
sharePointMedia.push(media);
|
||||||
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 }),
|
|
||||||
});
|
|
||||||
downloadedReferenceUrls.add(shareUrl);
|
downloadedReferenceUrls.add(shareUrl);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore SharePoint download failures.
|
// Ignore SharePoint download failures.
|
||||||
|
|||||||
42
extensions/msteams/src/attachments/remote-media.ts
Normal file
42
extensions/msteams/src/attachments/remote-media.ts
Normal 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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -63,6 +63,19 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
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 {
|
export function normalizeContentType(value: unknown): string | undefined {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user