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.
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
|
|
||||||
### Fixes
|
### 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.
|
- Config: avoid stack traces for invalid configs and log the config path.
|
||||||
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
|
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
|
||||||
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)
|
- 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 prefixedBody = [threadStarterNote, prefixedBodyBase].filter(Boolean).join("\n\n");
|
||||||
const mediaNote = buildInboundMediaNote(ctx);
|
const mediaNote = buildInboundMediaNote(ctx);
|
||||||
const mediaReplyHint = mediaNote
|
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;
|
: undefined;
|
||||||
let prefixedCommandBody = mediaNote
|
let prefixedCommandBody = mediaNote
|
||||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()
|
? [mediaNote, mediaReplyHint, prefixedBody ?? ""].filter(Boolean).join("\n").trim()
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ describe("splitMediaFromOutput", () => {
|
|||||||
expect(result.text).toBe("Hello world");
|
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", () => {
|
it("keeps audio_as_voice detection stable across calls", () => {
|
||||||
const input = "Hello [[audio_as_voice]]";
|
const input = "Hello [[audio_as_voice]]";
|
||||||
const first = splitMediaFromOutput(input);
|
const first = splitMediaFromOutput(input);
|
||||||
|
|||||||
@@ -14,11 +14,26 @@ function cleanCandidate(raw: string) {
|
|||||||
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidMedia(candidate: string) {
|
function isValidMedia(candidate: string, opts?: { allowSpaces?: boolean }) {
|
||||||
if (!candidate) return false;
|
if (!candidate) return false;
|
||||||
if (candidate.length > 1024) return false;
|
if (candidate.length > 4096) return false;
|
||||||
if (/\s/.test(candidate)) return false;
|
if (!opts?.allowSpaces && /\s/.test(candidate)) return false;
|
||||||
return /^https?:\/\//i.test(candidate) || candidate.startsWith("/") || candidate.startsWith("./");
|
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
|
// 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));
|
pieces.push(line.slice(cursor, start));
|
||||||
|
|
||||||
const payload = match[1];
|
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[] = [];
|
const invalidParts: string[] = [];
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const candidate = normalizeMediaSource(cleanCandidate(part));
|
const candidate = normalizeMediaSource(cleanCandidate(part));
|
||||||
if (isValidMedia(candidate)) {
|
if (isValidMedia(candidate, unwrapped ? { allowSpaces: true } : undefined)) {
|
||||||
media.push(candidate);
|
media.push(candidate);
|
||||||
hasValidMedia = true;
|
hasValidMedia = true;
|
||||||
|
validCount += 1;
|
||||||
} else {
|
} else {
|
||||||
invalidParts.push(part);
|
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) {
|
if (hasValidMedia && invalidParts.length > 0) {
|
||||||
pieces.push(invalidParts.join(" "));
|
pieces.push(invalidParts.join(" "));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user