From c5d1cb38e2084095ea311b83c261d0de0fbd9cf2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 02:15:34 -0700 Subject: [PATCH] fix(channels): attachment filenames disappear from model context (#129140) * fix(channels): preserve inbound attachment filenames Fixes #128956 * test(discord): verify names on successfully downloaded media * test(discord): verify referenced attachment filenames * fix(telegram): preserve accepted resolved-media shapes --- .../monitor/message-handler.preflight.test.ts | 1 + .../monitor/message-media.references.test.ts | 4 +- .../discord/src/monitor/message-media.test.ts | 6 +++ .../discord/src/monitor/message-media.ts | 8 +++- .../src/mattermost/monitor-resources.test.ts | 4 +- .../src/mattermost/monitor-resources.ts | 10 ++-- extensions/slack/src/monitor/media-types.ts | 1 + extensions/slack/src/monitor/media.test.ts | 6 ++- extensions/slack/src/monitor/media.ts | 2 + .../src/bot-handlers.inbound-media.ts | 1 + .../src/bot-handlers.inbound-processing.ts | 1 + .../src/bot-handlers.message-pipeline.ts | 9 ++++ ...bot-message-context.media-carriers.test.ts | 26 ++++++++++ .../src/bot-message-context.session.ts | 1 + .../telegram/src/bot-message-context.types.ts | 1 + ...te-telegram-bot.channel-post-media.test.ts | 3 +- .../bot/delivery.resolve-media-retry.test.ts | 1 + .../src/bot/delivery.resolve-media.ts | 3 +- extensions/telegram/src/message-cache.test.ts | 21 +++++---- extensions/telegram/src/message-cache.ts | 6 ++- src/auto-reply/media-note.test.ts | 47 +++++++++++++++++++ src/auto-reply/media-note.ts | 38 +++++++-------- src/channels/inbound-event/media.test.ts | 8 +++- src/channels/inbound-event/media.ts | 1 + 24 files changed, 170 insertions(+), 39 deletions(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.test.ts b/extensions/discord/src/monitor/message-handler.preflight.test.ts index e2b72c7b3fa6..8f5a5a3420e4 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.test.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.test.ts @@ -645,6 +645,7 @@ describe("preflightDiscordMessage", () => { { path: "/tmp/openclaw-discord-test/photo.png", contentType: "image/png", + fileName: "photo.png", }, ]); }); diff --git a/extensions/discord/src/monitor/message-media.references.test.ts b/extensions/discord/src/monitor/message-media.references.test.ts index 5404fb42fa01..ffea785ae2dc 100644 --- a/extensions/discord/src/monitor/message-media.references.test.ts +++ b/extensions/discord/src/monitor/message-media.references.test.ts @@ -78,7 +78,9 @@ describe("resolveReferencedReplyMediaList", () => { 512, ); - expect(result).toEqual([{ path: "/tmp/reply-image.png", contentType: "image/png" }]); + expect(result).toEqual([ + { path: "/tmp/reply-image.png", contentType: "image/png", fileName: "reply-image.png" }, + ]); expect(readRemoteMediaBuffer).toHaveBeenCalledWith( expect.objectContaining({ url: attachment.url, diff --git a/extensions/discord/src/monitor/message-media.test.ts b/extensions/discord/src/monitor/message-media.test.ts index f974f7d75039..0ce812a21d3b 100644 --- a/extensions/discord/src/monitor/message-media.test.ts +++ b/extensions/discord/src/monitor/message-media.test.ts @@ -154,6 +154,7 @@ function expectSinglePngDownload(params: { { path: params.expectedPath, contentType: "image/png", + fileName: params.filePathHint, ...(params.kind ? { kind: params.kind } : {}), }, ]); @@ -415,6 +416,7 @@ describe("resolveMediaList", () => { { path: "/tmp/voice.ogg", contentType: undefined, + fileName: "voice.ogg", kind: "audio", }, ]); @@ -464,6 +466,7 @@ describe("resolveMediaList", () => { { path: "/tmp/image.png", contentType: "image/png", + fileName: "image.ogg", }, ]); }); @@ -480,6 +483,7 @@ describe("resolveMediaList", () => { { path: "/tmp/voice", contentType: "audio/ogg", + fileName: "voice", kind: "audio", }, ]); @@ -515,6 +519,7 @@ describe("resolveMediaList", () => { { path: "/tmp/image.png", contentType: "image/png", + fileName: "voice.ogg", }, ]); }); @@ -573,6 +578,7 @@ describe("resolveMediaList", () => { { path: "/tmp/good.png", contentType: "image/png", + fileName: "good.png", }, { contentType: "application/pdf", diff --git a/extensions/discord/src/monitor/message-media.ts b/extensions/discord/src/monitor/message-media.ts index de0ed40763e2..736d9615ed72 100644 --- a/extensions/discord/src/monitor/message-media.ts +++ b/extensions/discord/src/monitor/message-media.ts @@ -2,6 +2,7 @@ import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10"; import { formatMediaPlaceholderText, + type ChannelInboundMediaInput, type MediaPlaceholderTextFact, } from "openclaw/plugin-sdk/channel-inbound"; import { getFileExtension, normalizeMimeType } from "openclaw/plugin-sdk/media-mime"; @@ -36,7 +37,10 @@ const AUDIO_ATTACHMENT_EXTENSIONS = new Set([ const DISCORD_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers"; -export type DiscordMediaInfo = Pick; +export type DiscordMediaInfo = Pick< + ChannelInboundMediaInput, + "contentType" | "fileName" | "kind" | "path" +>; type DiscordMediaResolveOptions = { fetchImpl?: FetchLike; @@ -350,6 +354,7 @@ async function appendResolvedMediaFromAttachments(params: { }); params.out.push({ path: saved.path, + fileName: attachment.filename, ...classification, }); } catch (err) { @@ -455,6 +460,7 @@ async function appendResolvedMediaFromStickers(params: { params.out.push({ path: saved.path, contentType: saved.contentType, + fileName: candidate.fileName, kind: "sticker", }); lastError = null; diff --git a/extensions/mattermost/src/mattermost/monitor-resources.test.ts b/extensions/mattermost/src/mattermost/monitor-resources.test.ts index 65e7d0033450..dcc0034ad59a 100644 --- a/extensions/mattermost/src/mattermost/monitor-resources.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-resources.test.ts @@ -93,6 +93,7 @@ describe("mattermost monitor resources", () => { const saveRemoteMedia = vi.fn(async () => ({ path: "/tmp/file.png", contentType: "image/png", + fileName: "original screenshot.png", })); const resources = createMattermostMonitorResources({ @@ -113,6 +114,7 @@ describe("mattermost monitor resources", () => { { path: "/tmp/file.png", contentType: "image/png", + fileName: "original screenshot.png", kind: "image", }, ]); @@ -166,7 +168,7 @@ describe("mattermost monitor resources", () => { .mockRejectedValueOnce(new Error("download failed")); const request = vi.fn(async (requestPath: string) => { expect(requestPath).toBe("/files/file-audio/info"); - return { mime_type: "audio/mpeg" }; + return { mime_type: "audio/mpeg", name: "private-unavailable-recording.mp3" }; }); const resources = createMattermostMonitorResources({ accountId: "default", diff --git a/extensions/mattermost/src/mattermost/monitor-resources.ts b/extensions/mattermost/src/mattermost/monitor-resources.ts index 2704a10dbc1a..e628291fd981 100644 --- a/extensions/mattermost/src/mattermost/monitor-resources.ts +++ b/extensions/mattermost/src/mattermost/monitor-resources.ts @@ -4,12 +4,13 @@ import { formatInboundMediaUnavailableText, formatMediaPlaceholderText, toInboundMediaFactsWithMetadata, + type ChannelInboundMediaInput, type ChannelInboundMediaPayload, type InboundMediaFacts, type MediaPlaceholderTextFact, } from "openclaw/plugin-sdk/channel-inbound"; import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime"; -import type { MediaKind } from "openclaw/plugin-sdk/media-runtime"; +import type { MediaKind, SavedRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, @@ -27,7 +28,9 @@ import { } from "./client.js"; import { buildButtonProps, type MattermostInteractionResponse } from "./interactions.js"; -type MattermostMediaInfo = Omit & { kind: MediaKind }; +type MattermostMediaInfo = Pick & { + kind: MediaKind; +}; export async function buildMattermostInboundMediaPayload( media: readonly MattermostMediaInfo[], @@ -76,7 +79,7 @@ type SaveRemoteMedia = (params: { ssrfPolicy?: { allowedHostnames?: string[] }; responseHeaderTimeoutMs?: number; readIdleTimeoutMs?: number; -}) => Promise<{ path: string; contentType?: string | null }>; +}) => Promise>; export function createMattermostMonitorResources(params: { accountId: string; @@ -171,6 +174,7 @@ export function createMattermostMonitorResources(params: { out.push({ path: saved.path, contentType, + ...(saved.fileName ? { fileName: saved.fileName } : {}), kind: mediaKindFromMime(contentType) ?? "unknown", }); } catch (err) { diff --git a/extensions/slack/src/monitor/media-types.ts b/extensions/slack/src/monitor/media-types.ts index 1ee8d9e05786..75c97c9e343d 100644 --- a/extensions/slack/src/monitor/media-types.ts +++ b/extensions/slack/src/monitor/media-types.ts @@ -2,6 +2,7 @@ export type SlackMediaResult = { path: string; contentType?: string; + fileName?: string; placeholder: string; }; diff --git a/extensions/slack/src/monitor/media.test.ts b/extensions/slack/src/monitor/media.test.ts index b26ef7a8417a..5820965c4b5c 100644 --- a/extensions/slack/src/monitor/media.test.ts +++ b/extensions/slack/src/monitor/media.test.ts @@ -293,7 +293,7 @@ describe("resolveSlackMedia", () => { }); mockFetch.mockResolvedValueOnce(mockResponse); - await resolveSlackMedia({ + const result = await resolveSlackMedia({ files: [ { url_private: "https://files.slack.com/private.jpg", @@ -306,6 +306,7 @@ describe("resolveSlackMedia", () => { }); expectFetchCalledWithUrl(mockFetch, "https://files.slack.com/download.jpg"); + expect(expectSlackMediaResult(result)[0]?.fileName).toBe("test.jpg"); }); it("preserves Authorization on same-origin redirects for private downloads", async () => { @@ -937,8 +938,10 @@ describe("resolveSlackMedia", () => { const first = expectDefined(media[0], "first Slack media result"); const second = expectDefined(media[1], "second Slack media result"); expect(first.path).toBe("/tmp/a.jpg"); + expect(first.fileName).toBe("a.jpg"); expect(first.placeholder).toBe("[Slack file: a.jpg (image/jpeg, 12 bytes, fileId: FA)]"); expect(second.path).toBe("/tmp/b.png"); + expect(second.fileName).toBe("b.png"); expect(second.placeholder).toBe("[Slack file: b.png (image/png, 34 bytes, fileId: FB)]"); }); @@ -1475,6 +1478,7 @@ describe("resolveSlackAttachmentContent", () => { { path: "/tmp/forwarded.jpg", contentType: "image/jpeg", + fileName: "forwarded.jpg", placeholder: "[Forwarded image: forwarded.jpg]", }, ], diff --git a/extensions/slack/src/monitor/media.ts b/extensions/slack/src/monitor/media.ts index 53b4fc28499f..ff236e9be89a 100644 --- a/extensions/slack/src/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -291,6 +291,7 @@ async function downloadSlackMediaFile(params: { return { path: saved.path, ...(contentType ? { contentType } : {}), + ...(label ? { fileName: label } : {}), placeholder: `[Slack file: ${formatSlackFileReference({ ...params.file, name: label })}]`, }; } @@ -508,6 +509,7 @@ export async function resolveSlackAttachmentContent(params: { attachmentMedia.push({ path: saved.path, contentType: saved.contentType, + ...(saved.fileName ? { fileName: saved.fileName } : {}), placeholder: `[Forwarded image: ${label}]`, }); } catch { diff --git a/extensions/telegram/src/bot-handlers.inbound-media.ts b/extensions/telegram/src/bot-handlers.inbound-media.ts index fe8ba78ba0f2..780ca013f3aa 100644 --- a/extensions/telegram/src/bot-handlers.inbound-media.ts +++ b/extensions/telegram/src/bot-handlers.inbound-media.ts @@ -364,6 +364,7 @@ export function createTelegramInboundMedia({ allMedia.push({ path: media.path, contentType: media.contentType, + ...(media.fileName ? { fileName: media.fileName } : {}), kind: media.kind, stickerMetadata: media.stickerMetadata, sourceMessageId, diff --git a/extensions/telegram/src/bot-handlers.inbound-processing.ts b/extensions/telegram/src/bot-handlers.inbound-processing.ts index cb952eb581e1..10100d4d0bfe 100644 --- a/extensions/telegram/src/bot-handlers.inbound-processing.ts +++ b/extensions/telegram/src/bot-handlers.inbound-processing.ts @@ -318,6 +318,7 @@ export function createTelegramInboundProcessing({ ? { path: media.path, contentType: media.contentType, + ...(media.fileName ? { fileName: media.fileName } : {}), kind: media.kind, stickerMetadata: media.stickerMetadata, } diff --git a/extensions/telegram/src/bot-handlers.message-pipeline.ts b/extensions/telegram/src/bot-handlers.message-pipeline.ts index 2569d96ddb14..cea1859f31b6 100644 --- a/extensions/telegram/src/bot-handlers.message-pipeline.ts +++ b/extensions/telegram/src/bot-handlers.message-pipeline.ts @@ -135,6 +135,7 @@ export interface TelegramMessagePipeline { function resolveRetainedTelegramMedia(params: { media?: TelegramResolvedMedia; + sourceMessage: Message; maxBytes: number; ttlHours?: number; }): TelegramMediaRef | undefined { @@ -148,11 +149,17 @@ function resolveRetainedTelegramMedia(params: { return undefined; } const path = resolveTelegramInboundMediaUri(media.id); + const fileName = + params.sourceMessage.document?.file_name ?? + params.sourceMessage.audio?.file_name ?? + params.sourceMessage.video?.file_name ?? + params.sourceMessage.animation?.file_name; return path ? { path, kind: media.kind, ...(media.contentType ? { contentType: media.contentType } : {}), + ...(fileName ? { fileName } : {}), ...(media.stickerMetadata ? { stickerMetadata: media.stickerMetadata } : {}), } : undefined; @@ -320,6 +327,7 @@ export function createTelegramMessagePipeline({ mediaRuntime.abortSignal?.throwIfAborted(); mediaRef = resolveRetainedTelegramMedia({ media: node.resolvedMedia, + sourceMessage: node.sourceMessage, maxBytes: mediaMaxBytes, ttlHours: cfg.attachments?.ttlHours, }); @@ -338,6 +346,7 @@ export function createTelegramMessagePipeline({ path: media.path, kind: media.kind, ...(media.contentType ? { contentType: media.contentType } : {}), + ...(media.fileName ? { fileName: media.fileName } : {}), ...(media.stickerMetadata ? { stickerMetadata: media.stickerMetadata } : {}), }; await recordReplyMessageResolvedMedia({ diff --git a/extensions/telegram/src/bot-message-context.media-carriers.test.ts b/extensions/telegram/src/bot-message-context.media-carriers.test.ts index a0c81214bb4c..9dec18211d27 100644 --- a/extensions/telegram/src/bot-message-context.media-carriers.test.ts +++ b/extensions/telegram/src/bot-message-context.media-carriers.test.ts @@ -8,6 +8,32 @@ vi.mock("./sticker-vision.runtime.js", () => ({ })); describe("buildTelegramMessageContext media carriers", () => { + it("carries a successfully downloaded original filename into the current-turn media facts", async () => { + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: 42, type: "private", first_name: "Ada" }, + text: "Please read quarterly report.pdf", + document: { + file_id: "file-1", + file_unique_id: "file-u1", + file_name: "quarterly report.pdf", + }, + }, + allMedia: [ + { + kind: "document", + path: "/tmp/opaque-upload", + contentType: "application/pdf", + fileName: "quarterly report.pdf", + }, + ], + }); + + expect(context?.ctxPayload.media).toEqual([ + expect.objectContaining({ path: "/tmp/opaque-upload", fileName: "quarterly report.pdf" }), + ]); + }); + it("carries direct tool policy into a topic-bound admitted turn", async () => { const context = await buildTelegramMessageContextForTest({ message: { diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 677ef0c23e8f..e47993bf7374 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -592,6 +592,7 @@ export async function buildTelegramInboundContextPayload(params: { const toInboundMedia = (media: TelegramMediaRef, index?: number) => ({ ...(media.path ? { path: media.path, url: media.path } : {}), contentType: media.contentType, + ...(media.fileName ? { fileName: media.fileName } : {}), kind: media.kind, transcribed: index !== undefined && audioTranscribedMediaIndex === index, }); diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts index 6bcf12709074..354638d8cde0 100644 --- a/extensions/telegram/src/bot-message-context.types.ts +++ b/extensions/telegram/src/bot-message-context.types.ts @@ -24,6 +24,7 @@ export type TelegramMediaRef = { kind: TelegramMediaKind; path?: string; contentType?: string; + fileName?: string; stickerMetadata?: StickerMetadata; sourceMessageId?: string; }; diff --git a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts index d62bed36be07..2800013ea3d0 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.channel-post-media.test.ts @@ -223,9 +223,10 @@ function expectTypeOnlyMediaPayload(kind: string, rawBody = "") { media: [expect.objectContaining({ kind })], RawBody: rawBody, }); - const media = payload.media as Array<{ path?: string }>; + const media = payload.media as Array<{ path?: string; fileName?: string }>; expect(media).toHaveLength(1); expect(media[0]?.path).toBeUndefined(); + expect(media[0]?.fileName).toBeUndefined(); } function setTelegramIngestGroupConfig( diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 1e6dea50cc86..6569a0e2a099 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -457,6 +457,7 @@ describe("resolveMedia original filename preservation", () => { }); expectResolvedMediaFields(result, "document filename", { path: "/tmp/business-plan---uuid.pdf", + fileName: "business-plan.pdf", }); }); diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index 9b156ab5a937..e8ae8da016d1 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -467,7 +467,7 @@ export async function resolveMedia(params: { trustedLocalFileRoots?: readonly string[]; dangerouslyAllowPrivateNetwork?: boolean; abortSignal?: AbortSignal; -}): Promise<(TelegramResolvedMedia & { path: string }) | null> { +}): Promise<(TelegramResolvedMedia & { path: string; fileName?: string }) | null> { const { ctx, maxBytes, @@ -528,6 +528,7 @@ export async function resolveMedia(params: { path: saved.path, size: saved.size, contentType: saved.contentType, + ...(metadata.fileName ? { fileName: metadata.fileName } : {}), kind, fileUniqueId: m.file_unique_id, savedAt: Date.now(), diff --git a/extensions/telegram/src/message-cache.test.ts b/extensions/telegram/src/message-cache.test.ts index cf372e8a06b8..8309663404cc 100644 --- a/extensions/telegram/src/message-cache.test.ts +++ b/extensions/telegram/src/message-cache.test.ts @@ -150,21 +150,26 @@ describe("telegram message cache", () => { const { bucketKey, entries, store } = createMemoryStore(); const cache = cacheFor(bucketKey, store); await record(cache, message(9000, "Kesava", { photo: photo("photo-1") })); + const downloadedMedia = { + id: "saved-photo.png", + fileUniqueId: "photo-1-unique", + size: 4, + savedAt: 1_736_380_700_000, + kind: "image" as const, + contentType: "image/png", + path: "/private/user/photos/holiday.png", + fileName: "holiday photo.png", + }; await cache.recordResolvedMedia({ accountId: "default", chatId: 7, messageId: "9000", - media: { - id: "saved-photo.png", - fileUniqueId: "photo-1-unique", - size: 4, - savedAt: 1_736_380_700_000, - kind: "image", - contentType: "image/png", - }, + media: downloadedMedia, }); expect(onlyEntry(entries)[1].resolvedMedia?.id).toBe("saved-photo.png"); + expect(onlyEntry(entries)[1].resolvedMedia).not.toHaveProperty("path"); + expect(onlyEntry(entries)[1].resolvedMedia).not.toHaveProperty("fileName"); const reloaded = await reloadGet(bucketKey, store, "9000"); expect(reloaded?.resolvedMedia).toMatchObject({ id: "saved-photo.png", diff --git a/extensions/telegram/src/message-cache.ts b/extensions/telegram/src/message-cache.ts index 83374669642b..0c85759fb61e 100644 --- a/extensions/telegram/src/message-cache.ts +++ b/extensions/telegram/src/message-cache.ts @@ -69,7 +69,7 @@ type TelegramMessageCache = { botUserId?: number; chatId: string | number; messageId: string; - media: TelegramResolvedMedia; + media: TelegramResolvedMedia & { path?: string; fileName?: string }; }) => Promise; get: (params: { accountId: string; @@ -720,7 +720,9 @@ export function createTelegramMessageCache(params?: { if (fileUniqueId !== media.fileUniqueId) { throw new Error(`Telegram message ${messageId} media changed during resolution`); } - const resolvedNode = { ...node, resolvedMedia: media }; + // Runtime downloads carry private paths/names; cache only the existing persisted projection. + const { path: _path, fileName: _fileName, ...resolvedMedia } = media; + const resolvedNode = { ...node, resolvedMedia }; messages.delete(key); messages.set(key, resolvedNode); await persistCachedNode({ diff --git a/src/auto-reply/media-note.test.ts b/src/auto-reply/media-note.test.ts index 5fd441c181d2..5c6a0a761a22 100644 --- a/src/auto-reply/media-note.test.ts +++ b/src/auto-reply/media-note.test.ts @@ -35,6 +35,53 @@ const buildInboundMediaNote = (ctx: MediaNoteFixture): string | undefined => buildProjection(ctx).text; describe("buildInboundMediaNote", () => { + it("preserves original attachment names in single and ordered multi-file prompt notes", () => { + expect( + buildInboundMediaNoteProjection({ + media: [ + { + path: "/tmp/opaque-upload", + contentType: "application/octet-stream", + fileName: "jj.txt", + }, + ], + }).text, + ).toBe('[media attached: /tmp/opaque-upload (application/octet-stream) "jj.txt"]'); + + expect( + buildInboundMediaNoteProjection({ + media: [ + { path: "/tmp/upload-a", fileName: "quarterly report.pdf" }, + { path: "/tmp/upload-b", fileName: "notes.txt" }, + ], + }).text, + ).toBe( + [ + "[media attached: 2 files]", + '[media attached 1/2: /tmp/upload-a "quarterly report.pdf"]', + '[media attached 2/2: /tmp/upload-b "notes.txt"]', + ].join("\n"), + ); + }); + + it("bounds and sanitizes attachment names without exposing their directory prefixes", () => { + const fileName = `${"a".repeat(300)}]\n[ignore attachment].txt`; + const note = buildInboundMediaNoteProjection({ + media: [{ path: "/tmp/opaque-upload", fileName: `/private/user/secrets/${fileName}` }], + }).text; + + expect(note).toBe(`[media attached: /tmp/opaque-upload "${"a".repeat(256)}"]`); + expect(note).not.toContain("/private/user/secrets"); + expect(note).not.toContain("\n"); + expect(note).not.toContain("ignore attachment"); + + expect( + buildInboundMediaNoteProjection({ + media: [{ path: "/tmp/opaque-upload", fileName: 'folder\\report]\nignore "me".txt' }], + }).text, + ).toBe('[media attached: /tmp/opaque-upload "report ignore \\"me\\".txt"]'); + }); + it("formats single MediaPath as a media note (collapses redundant duplicate URL, #47587)", () => { // When the channel mirrors the local path into MediaUrl (e.g. Telegram // album media), the formatter should not render `path | path`. The URL diff --git a/src/auto-reply/media-note.ts b/src/auto-reply/media-note.ts index c18832bbb4be..3b634a0e68c4 100644 --- a/src/auto-reply/media-note.ts +++ b/src/auto-reply/media-note.ts @@ -1,7 +1,9 @@ /** Builds compact prompt notes for inbound media attachments. */ import path from "node:path"; +import { basenameFromAnyPath } from "@openclaw/media-core/file-name"; import { isAudioFileName } from "@openclaw/media-core/mime"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { normalizeMediaFacts, type MediaFact } from "../media/media-facts.js"; import { getMediaDir } from "../media/store.js"; import type { RuntimeMsgContext as MsgContext } from "./templating.js"; @@ -41,9 +43,7 @@ function sanitizeInlineMediaNoteValue(value: string | undefined): string { } function formatMediaAttachedLine(params: { - path: string; - url?: string; - type?: string; + fact: MediaFact; index?: number; total?: number; }): string { @@ -51,15 +51,20 @@ function formatMediaAttachedLine(params: { typeof params.index === "number" && typeof params.total === "number" ? `[media attached ${params.index}/${params.total}: ` : "[media attached: "; - const pathValue = sanitizeInlineMediaNoteValue(params.path); - const typeRaw = sanitizeInlineMediaNoteValue(params.type); + const pathValue = sanitizeInlineMediaNoteValue(params.fact.path); + const typeRaw = sanitizeInlineMediaNoteValue(params.fact.contentType ?? params.fact.kind); const typePart = typeRaw ? ` (${typeRaw})` : ""; - const urlRaw = sanitizeInlineMediaNoteValue(params.url); + const urlRaw = sanitizeInlineMediaNoteValue(params.fact.url); // When the channel mirrors the local path into the fact URL (Telegram album // media is the canonical case), rendering ` | ${url}` adds no information // and clutters the prompt with `path | path` duplication (issue #47587). const urlPart = urlRaw && urlRaw !== pathValue ? ` | ${urlRaw}` : ""; - return `${prefix}${pathValue}${typePart}${urlPart}]`; + const fileName = truncateUtf16Safe( + sanitizeInlineMediaNoteValue(basenameFromAnyPath(params.fact.fileName ?? "")), + 256, + ); + const fileNamePart = fileName ? ` ${JSON.stringify(fileName)}` : ""; + return `${prefix}${pathValue}${typePart}${urlPart}${fileNamePart}]`; } // WebM is ambiguous, while WMA and ALAC do not have canonical extension mappings. @@ -141,8 +146,6 @@ export function buildInboundMediaNoteProjection(ctx: MsgContext): InboundMediaNo { fact, path: mediaPath, - type: fact.contentType ?? fact.kind, - url: fact.url, index, }, ] @@ -161,7 +164,9 @@ export function buildInboundMediaNoteProjection(ctx: MsgContext): InboundMediaNo const visibleEntries = entries.filter((entry) => { // Strip audio attachments when transcription succeeded - the transcript is already // available in the context, raw audio binary would only waste tokens (issue #4197) - const normalizedType = normalizeLowercaseStringOrEmpty(entry.type); + const normalizedType = normalizeLowercaseStringOrEmpty( + entry.fact.contentType ?? entry.fact.kind, + ); const isAudioByMime = normalizedType === "audio" || normalizedType.startsWith("audio/"); const isAudioEntry = entry.fact.kind === "audio" || isAudioPath(entry.path) || isAudioByMime; if (!isAudioEntry) { @@ -185,13 +190,10 @@ export function buildInboundMediaNoteProjection(ctx: MsgContext): InboundMediaNo ...(describedImageIndices.has(entry.index) ? { hydrationSuppressed: true } : {}), })); const mediaIndexes = visibleEntries.map((entry) => entry.index); - if (visibleEntries.length === 1) { + const firstVisibleEntry = visibleEntries[0]; + if (visibleEntries.length === 1 && firstVisibleEntry) { return { - text: formatMediaAttachedLine({ - path: visibleEntries[0]?.path ?? "", - type: visibleEntries[0]?.type, - url: visibleEntries[0]?.url, - }), + text: formatMediaAttachedLine({ fact: firstVisibleEntry.fact }), media, mediaIndexes, }; @@ -202,11 +204,9 @@ export function buildInboundMediaNoteProjection(ctx: MsgContext): InboundMediaNo for (const [idx, entry] of visibleEntries.entries()) { lines.push( formatMediaAttachedLine({ - path: entry.path, + fact: entry.fact, index: idx + 1, total: count, - type: entry.type, - url: entry.url, }), ); } diff --git a/src/channels/inbound-event/media.test.ts b/src/channels/inbound-event/media.test.ts index 5ca7f83714ab..e8d93f953f3d 100644 --- a/src/channels/inbound-event/media.test.ts +++ b/src/channels/inbound-event/media.test.ts @@ -416,7 +416,12 @@ describe("channel inbound media facts", () => { it("normalizes provider media into inbound media facts", () => { const input = [ - { path: " /tmp/image.png ", contentType: " image/png ", messageId: " " }, + { + path: " /tmp/image.png ", + contentType: " image/png ", + fileName: " original image.png ", + messageId: " ", + }, { url: "https://example.test/audio.mp3", contentType: "audio/mpeg", @@ -434,6 +439,7 @@ describe("channel inbound media facts", () => { url: undefined, contentType: "image/png", kind: "image", + fileName: "original image.png", transcribed: false, messageId: "msg-1", }, diff --git a/src/channels/inbound-event/media.ts b/src/channels/inbound-event/media.ts index cdc480520c4c..077e5517b796 100644 --- a/src/channels/inbound-event/media.ts +++ b/src/channels/inbound-event/media.ts @@ -19,6 +19,7 @@ export type ChannelInboundMediaInput = { path?: string | null; url?: string | null; contentType?: string | null; + fileName?: string | null; kind?: InboundMediaFacts["kind"] | null; durationMs?: number | null; width?: number | null;