diff --git a/CHANGELOG.md b/CHANGELOG.md index 9895e404b..310d438e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -302,6 +302,7 @@ Docs: https://docs.openclaw.ai - Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting. - Nodes/system.run PowerShell wrapper parsing: treat `pwsh`/`powershell` `-EncodedCommand` forms as shell-wrapper payloads so allowlist mode still requires approval instead of falling back to plain argv analysis. Thanks @tdjackey for reporting. - Control UI/auth error reporting: map generic browser `Fetch failed` websocket close errors back to actionable gateway auth messages (`gateway token mismatch`, `authentication failed`, `retry later`) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee. +- Media/mime unknown-kind handling: return `undefined` (not `"unknown"`) for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom `` Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset. ## 2026.3.2 diff --git a/src/media/constants.ts b/src/media/constants.ts index 5dec8cedb..d87dafebc 100644 --- a/src/media/constants.ts +++ b/src/media/constants.ts @@ -3,11 +3,11 @@ export const MAX_AUDIO_BYTES = 16 * 1024 * 1024; // 16MB export const MAX_VIDEO_BYTES = 16 * 1024 * 1024; // 16MB export const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024; // 100MB -export type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; +export type MediaKind = "image" | "audio" | "video" | "document"; -export function mediaKindFromMime(mime?: string | null): MediaKind { +export function mediaKindFromMime(mime?: string | null): MediaKind | undefined { if (!mime) { - return "unknown"; + return undefined; } if (mime.startsWith("image/")) { return "image"; @@ -27,7 +27,7 @@ export function mediaKindFromMime(mime?: string | null): MediaKind { if (mime.startsWith("application/")) { return "document"; } - return "unknown"; + return undefined; } export function maxBytesForKind(kind: MediaKind): number { diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts index 3fd287331..cdc05016c 100644 --- a/src/media/mime.test.ts +++ b/src/media/mime.test.ts @@ -128,7 +128,9 @@ describe("mediaKindFromMime", () => { { mime: "text/plain", expected: "document" }, { mime: "text/csv", expected: "document" }, { mime: "text/html; charset=utf-8", expected: "document" }, - { mime: "model/gltf+json", expected: "unknown" }, + { mime: "model/gltf+json", expected: undefined }, + { mime: null, expected: undefined }, + { mime: undefined, expected: undefined }, ] as const)("classifies $mime", ({ mime, expected }) => { expect(mediaKindFromMime(mime)).toBe(expected); }); @@ -136,4 +138,9 @@ describe("mediaKindFromMime", () => { it("normalizes MIME strings before kind classification", () => { expect(kindFromMime(" Audio/Ogg; codecs=opus ")).toBe("audio"); }); + + it("returns undefined for missing or unrecognized MIME kinds", () => { + expect(kindFromMime(undefined)).toBeUndefined(); + expect(kindFromMime("model/gltf+json")).toBeUndefined(); + }); }); diff --git a/src/media/mime.ts b/src/media/mime.ts index fced9c612..e551350c0 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -187,6 +187,6 @@ export function imageMimeFromFormat(format?: string | null): string | undefined } } -export function kindFromMime(mime?: string | null): MediaKind { +export function kindFromMime(mime?: string | null): MediaKind | undefined { return mediaKindFromMime(normalizeMimeType(mime)); } diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 61292f666..88b9df19d 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -576,7 +576,8 @@ export async function sendMessageTelegram( fileName: media.fileName, }); const isVideoNote = kind === "video" && opts.asVideoNote === true; - const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind)) ?? "file"; + const fileName = + media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind ?? "document")) ?? "file"; const file = new InputFile(media.buffer, fileName); let caption: string | undefined; let followUpText: string | undefined; diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 9db06e302..27a7d6ccb 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -457,7 +457,7 @@ describe("local media root guard", () => { }), ).resolves.toEqual( expect.objectContaining({ - kind: "unknown", + kind: undefined, }), ); @@ -468,7 +468,7 @@ describe("local media root guard", () => { }), ).resolves.toEqual( expect.objectContaining({ - kind: "unknown", + kind: undefined, }), ); }); @@ -498,7 +498,7 @@ describe("local media root guard", () => { }), ).resolves.toEqual( expect.objectContaining({ - kind: "unknown", + kind: undefined, }), ); }); diff --git a/src/web/media.ts b/src/web/media.ts index 1e0842bb7..200a2b033 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -19,7 +19,7 @@ import { resolveUserPath } from "../utils.js"; export type WebMediaResult = { buffer: Buffer; contentType?: string; - kind: MediaKind; + kind: MediaKind | undefined; fileName?: string; }; @@ -284,12 +284,12 @@ async function loadWebMediaInternal( const clampAndFinalize = async (params: { buffer: Buffer; contentType?: string; - kind: MediaKind; + kind: MediaKind | undefined; fileName?: string; }): Promise => { // If caller explicitly provides maxBytes, trust it (for channels that handle large files). // Otherwise fall back to per-kind defaults. - const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind); + const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind ?? "document"); if (params.kind === "image") { const isGif = params.contentType === "image/gif"; if (isGif || !optimizeImages) { @@ -324,7 +324,7 @@ async function loadWebMediaInternal( if (/^https?:\/\//i.test(mediaUrl)) { // Enforce a download cap during fetch to avoid unbounded memory usage. // For optimized images, allow fetching larger payloads before compression. - const defaultFetchCap = maxBytesForKind("unknown"); + const defaultFetchCap = maxBytesForKind("document"); const fetchCap = maxBytes === undefined ? defaultFetchCap