From ab8db941d0ab678d1ed3a4f945cdfe3fadd71180 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 05:20:04 +0100 Subject: [PATCH] feat: expand inbound media notes --- src/auto-reply/media-note.test.ts | 29 ++++++++ src/auto-reply/media-note.ts | 61 ++++++++++++++++ src/auto-reply/reply.ts | 111 +++++++++++++++++++++--------- 3 files changed, 167 insertions(+), 34 deletions(-) create mode 100644 src/auto-reply/media-note.test.ts create mode 100644 src/auto-reply/media-note.ts diff --git a/src/auto-reply/media-note.test.ts b/src/auto-reply/media-note.test.ts new file mode 100644 index 000000000..ac50b552f --- /dev/null +++ b/src/auto-reply/media-note.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { buildInboundMediaNote } from "./media-note.js"; + +describe("buildInboundMediaNote", () => { + it("formats single MediaPath as a media note", () => { + const note = buildInboundMediaNote({ + MediaPath: "/tmp/a.png", + MediaType: "image/png", + MediaUrl: "/tmp/a.png", + }); + expect(note).toBe("[media attached: /tmp/a.png (image/png) | /tmp/a.png]"); + }); + + it("formats multiple MediaPaths as numbered media notes", () => { + const note = buildInboundMediaNote({ + MediaPaths: ["/tmp/a.png", "/tmp/b.png", "/tmp/c.png"], + MediaUrls: ["/tmp/a.png", "/tmp/b.png", "/tmp/c.png"], + }); + expect(note).toBe( + [ + "[media attached: 3 files]", + "[media attached 1/3: /tmp/a.png | /tmp/a.png]", + "[media attached 2/3: /tmp/b.png | /tmp/b.png]", + "[media attached 3/3: /tmp/c.png | /tmp/c.png]", + ].join("\n"), + ); + }); +}); + diff --git a/src/auto-reply/media-note.ts b/src/auto-reply/media-note.ts new file mode 100644 index 000000000..515173a33 --- /dev/null +++ b/src/auto-reply/media-note.ts @@ -0,0 +1,61 @@ +import type { MsgContext } from "./templating.js"; + +function formatMediaAttachedLine(params: { + path: string; + url?: string; + type?: string; + index?: number; + total?: number; +}): string { + const prefix = + typeof params.index === "number" && typeof params.total === "number" + ? `[media attached ${params.index}/${params.total}: ` + : "[media attached: "; + const typePart = params.type?.trim() ? ` (${params.type.trim()})` : ""; + const urlRaw = params.url?.trim(); + const urlPart = urlRaw ? ` | ${urlRaw}` : ""; + return `${prefix}${params.path}${typePart}${urlPart}]`; +} + +export function buildInboundMediaNote(ctx: MsgContext): string | undefined { + const hasPathsArray = Array.isArray(ctx.MediaPaths) && ctx.MediaPaths.length > 0; + const paths = hasPathsArray + ? ctx.MediaPaths + : ctx.MediaPath?.trim() + ? [ctx.MediaPath.trim()] + : []; + if (paths.length === 0) return undefined; + + const urls = + Array.isArray(ctx.MediaUrls) && ctx.MediaUrls.length === paths.length + ? ctx.MediaUrls + : undefined; + const types = + Array.isArray(ctx.MediaTypes) && ctx.MediaTypes.length === paths.length + ? ctx.MediaTypes + : undefined; + + if (paths.length === 1) { + return formatMediaAttachedLine({ + path: paths[0] ?? "", + type: types?.[0] ?? ctx.MediaType, + url: urls?.[0] ?? ctx.MediaUrl, + }); + } + + const count = paths.length; + const lines: string[] = [`[media attached: ${count} files]`]; + for (const [idx, mediaPath] of paths.entries()) { + lines.push( + formatMediaAttachedLine({ + path: mediaPath, + index: idx + 1, + total: count, + type: types?.[idx], + url: urls?.[idx], + }), + ); + } + return lines.join("\n"); +} + diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 1a8511174..af3e847fa 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -80,6 +80,7 @@ import { import { SILENT_REPLY_TOKEN } from "./tokens.js"; import { isAudio, transcribeInboundAudio } from "./transcription.js"; import type { GetReplyOptions, ReplyPayload } from "./types.js"; +import { buildInboundMediaNote } from "./media-note.js"; export { extractElevatedDirective, @@ -713,9 +714,7 @@ export async function getReplyFromConfig( .filter(Boolean) .join("\n\n") : [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n"); - const mediaNote = ctx.MediaPath?.length - ? `[media attached: ${ctx.MediaPath}${ctx.MediaType ? ` (${ctx.MediaType})` : ""}${ctx.MediaUrl ? ` | ${ctx.MediaUrl}` : ""}]` - : undefined; + const mediaNote = buildInboundMediaNote(ctx); const mediaReplyHint = mediaNote ? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body." : undefined; @@ -857,8 +856,13 @@ async function stageSandboxMedia(params: { workspaceDir: string; }) { const { ctx, sessionCtx, cfg, sessionKey, workspaceDir } = params; - const rawPath = ctx.MediaPath?.trim(); - if (!rawPath || !sessionKey) return; + const hasPathsArray = Array.isArray(ctx.MediaPaths) && ctx.MediaPaths.length > 0; + const rawPaths = hasPathsArray + ? ctx.MediaPaths + : ctx.MediaPath?.trim() + ? [ctx.MediaPath.trim()] + : []; + if (rawPaths.length === 0 || !sessionKey) return; const sandbox = await ensureSandboxWorkspaceForSession({ config: cfg, @@ -867,45 +871,84 @@ async function stageSandboxMedia(params: { }); if (!sandbox) return; - let source = rawPath; - if (source.startsWith("file://")) { - try { - source = fileURLToPath(source); - } catch { - return; + const resolveAbsolutePath = (value: string): string | null => { + let resolved = value.trim(); + if (!resolved) return null; + if (resolved.startsWith("file://")) { + try { + resolved = fileURLToPath(resolved); + } catch { + return null; + } } - } - if (!path.isAbsolute(source)) return; - - const originalMediaPath = ctx.MediaPath; - const originalMediaUrl = ctx.MediaUrl; + if (!path.isAbsolute(resolved)) return null; + return resolved; + }; try { - const fileName = path.basename(source); - if (!fileName) return; const destDir = path.join(sandbox.workspaceDir, "media", "inbound"); await fs.mkdir(destDir, { recursive: true }); - const dest = path.join(destDir, fileName); - await fs.copyFile(source, dest); - const relative = path.posix.join("media", "inbound", fileName); - ctx.MediaPath = relative; - sessionCtx.MediaPath = relative; + const usedNames = new Set(); + const staged = new Map(); // absolute source -> relative sandbox path - if (originalMediaUrl) { - let normalizedUrl = originalMediaUrl; - if (normalizedUrl.startsWith("file://")) { - try { - normalizedUrl = fileURLToPath(normalizedUrl); - } catch { - normalizedUrl = originalMediaUrl; - } + for (const raw of rawPaths) { + const source = resolveAbsolutePath(raw); + if (!source) continue; + if (staged.has(source)) continue; + + const baseName = path.basename(source); + if (!baseName) continue; + const parsed = path.parse(baseName); + let fileName = baseName; + let suffix = 1; + while (usedNames.has(fileName)) { + fileName = `${parsed.name}-${suffix}${parsed.ext}`; + suffix += 1; } - if (normalizedUrl === originalMediaPath || normalizedUrl === source) { - ctx.MediaUrl = relative; - sessionCtx.MediaUrl = relative; + usedNames.add(fileName); + + const dest = path.join(destDir, fileName); + await fs.copyFile(source, dest); + const relative = path.posix.join("media", "inbound", fileName); + staged.set(source, relative); + } + + const rewriteIfStaged = (value: string | undefined): string | undefined => { + const raw = value?.trim(); + if (!raw) return value; + const abs = resolveAbsolutePath(raw); + if (!abs) return value; + const mapped = staged.get(abs); + return mapped ?? value; + }; + + const nextMediaPaths = hasPathsArray + ? rawPaths.map((p) => rewriteIfStaged(p) ?? p) + : undefined; + if (nextMediaPaths) { + ctx.MediaPaths = nextMediaPaths; + sessionCtx.MediaPaths = nextMediaPaths; + ctx.MediaPath = nextMediaPaths[0]; + sessionCtx.MediaPath = nextMediaPaths[0]; + } else { + const rewritten = rewriteIfStaged(ctx.MediaPath); + if (rewritten && rewritten !== ctx.MediaPath) { + ctx.MediaPath = rewritten; + sessionCtx.MediaPath = rewritten; } } + + if (Array.isArray(ctx.MediaUrls) && ctx.MediaUrls.length > 0) { + const nextUrls = ctx.MediaUrls.map((u) => rewriteIfStaged(u) ?? u); + ctx.MediaUrls = nextUrls; + sessionCtx.MediaUrls = nextUrls; + } + const rewrittenUrl = rewriteIfStaged(ctx.MediaUrl); + if (rewrittenUrl && rewrittenUrl !== ctx.MediaUrl) { + ctx.MediaUrl = rewrittenUrl; + sessionCtx.MediaUrl = rewrittenUrl; + } } catch (err) { logVerbose(`Failed to stage inbound media for sandbox: ${String(err)}`); }