fix: allow MEDIA local paths with spaces
This commit is contained in:
@@ -21,6 +21,7 @@ Docs: https://docs.clawd.bot
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
- Media: accept MEDIA paths with spaces/tilde and prefer the message tool hint for image replies.
|
||||
- Config: avoid stack traces for invalid configs and log the config path.
|
||||
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
|
||||
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)
|
||||
|
||||
@@ -247,7 +247,7 @@ export async function runPreparedReply(
|
||||
const prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
|
||||
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."
|
||||
? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:/path or MEDIA:https://example.com/image.jpg (spaces ok, quote if needed). Keep caption in the text body."
|
||||
: undefined;
|
||||
let prefixedCommandBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()
|
||||
|
||||
@@ -9,6 +9,24 @@ describe("splitMediaFromOutput", () => {
|
||||
expect(result.text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("captures media paths with spaces", () => {
|
||||
const result = splitMediaFromOutput("MEDIA:/Users/pete/My File.png");
|
||||
expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("captures quoted media paths with spaces", () => {
|
||||
const result = splitMediaFromOutput('MEDIA:"/Users/pete/My File.png"');
|
||||
expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("captures tilde media paths with spaces", () => {
|
||||
const result = splitMediaFromOutput("MEDIA:~/Pictures/My File.png");
|
||||
expect(result.mediaUrls).toEqual(["~/Pictures/My File.png"]);
|
||||
expect(result.text).toBe("");
|
||||
});
|
||||
|
||||
it("keeps audio_as_voice detection stable across calls", () => {
|
||||
const input = "Hello [[audio_as_voice]]";
|
||||
const first = splitMediaFromOutput(input);
|
||||
|
||||
@@ -14,11 +14,26 @@ function cleanCandidate(raw: string) {
|
||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||
}
|
||||
|
||||
function isValidMedia(candidate: string) {
|
||||
function isValidMedia(candidate: string, opts?: { allowSpaces?: boolean }) {
|
||||
if (!candidate) return false;
|
||||
if (candidate.length > 1024) return false;
|
||||
if (/\s/.test(candidate)) return false;
|
||||
return /^https?:\/\//i.test(candidate) || candidate.startsWith("/") || candidate.startsWith("./");
|
||||
if (candidate.length > 4096) return false;
|
||||
if (!opts?.allowSpaces && /\s/.test(candidate)) return false;
|
||||
if (/^https?:\/\//i.test(candidate)) return true;
|
||||
if (candidate.startsWith("/")) return true;
|
||||
if (candidate.startsWith("./")) return true;
|
||||
if (candidate.startsWith("../")) return true;
|
||||
if (candidate.startsWith("~")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function unwrapQuoted(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length < 2) return undefined;
|
||||
const first = trimmed[0];
|
||||
const last = trimmed[trimmed.length - 1];
|
||||
if (first !== last) return undefined;
|
||||
if (first !== `"` && first !== "'" && first !== "`") return undefined;
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
// Check if a character offset is inside any fenced code block
|
||||
@@ -73,18 +88,55 @@ export function splitMediaFromOutput(raw: string): {
|
||||
pieces.push(line.slice(cursor, start));
|
||||
|
||||
const payload = match[1];
|
||||
const parts = payload.split(/\s+/).filter(Boolean);
|
||||
const unwrapped = unwrapQuoted(payload);
|
||||
const payloadValue = unwrapped ?? payload;
|
||||
const parts = unwrapped ? [unwrapped] : payload.split(/\s+/).filter(Boolean);
|
||||
const mediaStartIndex = media.length;
|
||||
let validCount = 0;
|
||||
const invalidParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
const candidate = normalizeMediaSource(cleanCandidate(part));
|
||||
if (isValidMedia(candidate)) {
|
||||
if (isValidMedia(candidate, unwrapped ? { allowSpaces: true } : undefined)) {
|
||||
media.push(candidate);
|
||||
hasValidMedia = true;
|
||||
validCount += 1;
|
||||
} else {
|
||||
invalidParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedPayload = payloadValue.trim();
|
||||
const looksLikeLocalPath =
|
||||
trimmedPayload.startsWith("/") ||
|
||||
trimmedPayload.startsWith("./") ||
|
||||
trimmedPayload.startsWith("../") ||
|
||||
trimmedPayload.startsWith("~") ||
|
||||
trimmedPayload.startsWith("file://");
|
||||
if (
|
||||
!unwrapped &&
|
||||
validCount === 1 &&
|
||||
invalidParts.length > 0 &&
|
||||
/\s/.test(payloadValue) &&
|
||||
looksLikeLocalPath
|
||||
) {
|
||||
const fallback = normalizeMediaSource(cleanCandidate(payloadValue));
|
||||
if (isValidMedia(fallback, { allowSpaces: true })) {
|
||||
media.splice(mediaStartIndex, media.length - mediaStartIndex, fallback);
|
||||
hasValidMedia = true;
|
||||
validCount = 1;
|
||||
invalidParts.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidMedia) {
|
||||
const fallback = normalizeMediaSource(cleanCandidate(payloadValue));
|
||||
if (isValidMedia(fallback, { allowSpaces: true })) {
|
||||
media.push(fallback);
|
||||
hasValidMedia = true;
|
||||
invalidParts.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidMedia && invalidParts.length > 0) {
|
||||
pieces.push(invalidParts.join(" "));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user