chore: format to 2-space and bump changelog
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased] 1.0.5
|
||||
## 1.1.0 — 2025-11-25
|
||||
|
||||
### Pending
|
||||
- Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5 MB) to avoid provider/API limits.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"$schema": "https://biomejs.dev/schemas/biome.json",
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentWidth": 2
|
||||
"indentWidth": 2,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -91,6 +91,7 @@ function summarizeClaudeMetadata(payload: unknown): string | undefined {
|
||||
export type ReplyPayload = {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
|
||||
export async function getReplyFromConfig(
|
||||
@@ -423,10 +424,10 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
}
|
||||
// Run media extraction once on the final human text (post-JSON parse if available).
|
||||
const { text: cleanedText, mediaUrl: mediaFound } =
|
||||
const { text: cleanedText, mediaUrls: mediaFound } =
|
||||
splitMediaFromOutput(trimmed);
|
||||
trimmed = cleanedText;
|
||||
if (mediaFound) {
|
||||
if (mediaFound?.length) {
|
||||
mediaFromCommand = mediaFound;
|
||||
if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`);
|
||||
} else if (isVerbose()) {
|
||||
@@ -455,10 +456,15 @@ export async function getReplyFromConfig(
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const mediaUrl = mediaFromCommand ?? reply.mediaUrl;
|
||||
const mediaUrls =
|
||||
mediaFromCommand ?? (reply.mediaUrl ? [reply.mediaUrl] : undefined);
|
||||
const result =
|
||||
trimmed || mediaUrl
|
||||
? { text: trimmed || undefined, mediaUrl }
|
||||
trimmed || mediaUrls?.length
|
||||
? {
|
||||
text: trimmed || undefined,
|
||||
mediaUrl: mediaUrls?.[0],
|
||||
mediaUrls,
|
||||
}
|
||||
: undefined;
|
||||
cleanupTyping();
|
||||
return result;
|
||||
@@ -559,7 +565,13 @@ export async function autoReplyIfConfigured(
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) return;
|
||||
if (
|
||||
!replyResult ||
|
||||
(!replyResult.text &&
|
||||
!replyResult.mediaUrl &&
|
||||
!replyResult.mediaUrls?.length)
|
||||
)
|
||||
return;
|
||||
|
||||
const replyFrom = message.to;
|
||||
const replyTo = message.from;
|
||||
@@ -583,17 +595,35 @@ export async function autoReplyIfConfigured(
|
||||
}
|
||||
|
||||
try {
|
||||
let mediaUrl = replyResult.mediaUrl;
|
||||
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) {
|
||||
const hosted = await ensureMediaHosted(mediaUrl);
|
||||
mediaUrl = hosted.url;
|
||||
const mediaList = replyResult.mediaUrls?.length
|
||||
? replyResult.mediaUrls
|
||||
: replyResult.mediaUrl
|
||||
? [replyResult.mediaUrl]
|
||||
: [];
|
||||
|
||||
const sendTwilio = async (body: string, media?: string) => {
|
||||
let resolvedMedia = media;
|
||||
if (resolvedMedia && !/^https?:\/\//i.test(resolvedMedia)) {
|
||||
const hosted = await ensureMediaHosted(resolvedMedia);
|
||||
resolvedMedia = hosted.url;
|
||||
}
|
||||
await client.messages.create({
|
||||
from: replyFrom,
|
||||
to: replyTo,
|
||||
body: replyResult.text ?? "",
|
||||
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}),
|
||||
body,
|
||||
...(resolvedMedia ? { mediaUrl: [resolvedMedia] } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
await sendTwilio(replyResult.text ?? "");
|
||||
} else {
|
||||
// First media with body (if any), then remaining as separate media-only sends.
|
||||
await sendTwilio(replyResult.text ?? "", mediaList[0]);
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
await sendTwilio("", extra);
|
||||
}
|
||||
}
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
info(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
||||
|
||||
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\s`]+)`?/i;
|
||||
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
|
||||
|
||||
export function normalizeMediaSource(src: string) {
|
||||
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
||||
@@ -24,30 +24,78 @@ function isValidMedia(candidate: string) {
|
||||
|
||||
export function splitMediaFromOutput(raw: string): {
|
||||
text: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string; // legacy first item for backward compatibility
|
||||
} {
|
||||
const trimmedRaw = raw.trim();
|
||||
const match = MEDIA_TOKEN_RE.exec(trimmedRaw);
|
||||
if (!match?.[1]) return { text: trimmedRaw };
|
||||
if (!trimmedRaw) return { text: "" };
|
||||
|
||||
const candidate = normalizeMediaSource(cleanCandidate(match[1]));
|
||||
const mediaUrl = isValidMedia(candidate) ? candidate : undefined;
|
||||
const media: string[] = [];
|
||||
let foundMediaToken = false;
|
||||
|
||||
const cleanedText = mediaUrl
|
||||
? trimmedRaw
|
||||
.replace(match[0], "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
// Collect tokens line by line so we can strip them cleanly.
|
||||
const lines = trimmedRaw.split("\n");
|
||||
const keptLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
|
||||
if (matches.length === 0) {
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
foundMediaToken = true;
|
||||
const pieces: string[] = [];
|
||||
let cursor = 0;
|
||||
let hasValidMedia = false;
|
||||
|
||||
for (const match of matches) {
|
||||
const start = match.index ?? 0;
|
||||
pieces.push(line.slice(cursor, start));
|
||||
|
||||
const payload = match[1];
|
||||
const parts = payload.split(/\s+/).filter(Boolean);
|
||||
const invalidParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
const candidate = normalizeMediaSource(cleanCandidate(part));
|
||||
if (isValidMedia(candidate)) {
|
||||
media.push(candidate);
|
||||
hasValidMedia = true;
|
||||
} else {
|
||||
invalidParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasValidMedia && invalidParts.length > 0) {
|
||||
pieces.push(invalidParts.join(" "));
|
||||
}
|
||||
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
|
||||
pieces.push(line.slice(cursor));
|
||||
|
||||
const cleanedLine = pieces
|
||||
.join("")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim()
|
||||
: trimmedRaw
|
||||
.split("\n")
|
||||
.filter((line) => !MEDIA_TOKEN_RE.test(line))
|
||||
.trim();
|
||||
|
||||
// If the line becomes empty, drop it.
|
||||
if (cleanedLine) {
|
||||
keptLines.push(cleanedLine);
|
||||
}
|
||||
}
|
||||
|
||||
const cleanedText = keptLines
|
||||
.join("\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
|
||||
return mediaUrl ? { text: cleanedText, mediaUrl } : { text: cleanedText };
|
||||
if (media.length === 0) {
|
||||
return { text: foundMediaToken ? cleanedText : trimmedRaw };
|
||||
}
|
||||
|
||||
return { text: cleanedText, mediaUrls: media, mediaUrl: media[0] };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,14 @@ import sharp from "sharp";
|
||||
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
||||
import { waitForever } from "./cli/wait.js";
|
||||
import { loadConfig } from "./config/config.js";
|
||||
import { danger, info, isVerbose, logVerbose, success } from "./globals.js";
|
||||
import {
|
||||
danger,
|
||||
info,
|
||||
isVerbose,
|
||||
logVerbose,
|
||||
success,
|
||||
warn,
|
||||
} from "./globals.js";
|
||||
import { logInfo } from "./logger.js";
|
||||
import { getChildLogger } from "./logging.js";
|
||||
import { maxBytesForKind, mediaKindFromMime } from "./media/constants.js";
|
||||
@@ -466,34 +473,45 @@ export async function monitorWebProvider(
|
||||
onReplyStart: msg.sendComposing,
|
||||
},
|
||||
);
|
||||
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) {
|
||||
if (
|
||||
!replyResult ||
|
||||
(!replyResult.text &&
|
||||
!replyResult.mediaUrl &&
|
||||
!replyResult.mediaUrls?.length)
|
||||
) {
|
||||
logVerbose(
|
||||
"Skipping auto-reply: no text/media returned from resolver",
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (replyResult.mediaUrl) {
|
||||
const mediaList = replyResult.mediaUrls?.length
|
||||
? replyResult.mediaUrls
|
||||
: replyResult.mediaUrl
|
||||
? [replyResult.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
logVerbose(
|
||||
`Web auto-reply media detected: ${replyResult.mediaUrl}`,
|
||||
`Web auto-reply media detected: ${mediaList.filter(Boolean).join(", ")}`,
|
||||
);
|
||||
for (const [index, mediaUrl] of mediaList.entries()) {
|
||||
try {
|
||||
const media = await loadWebMedia(
|
||||
replyResult.mediaUrl,
|
||||
maxMediaBytes,
|
||||
);
|
||||
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
|
||||
);
|
||||
logVerbose(
|
||||
`Web auto-reply media source: ${replyResult.mediaUrl} (kind ${media.kind})`,
|
||||
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
|
||||
);
|
||||
}
|
||||
const caption =
|
||||
index === 0 ? replyResult.text || undefined : undefined;
|
||||
if (media.kind === "image") {
|
||||
await msg.sendMedia({
|
||||
image: media.buffer,
|
||||
caption: replyResult.text || undefined,
|
||||
caption,
|
||||
mimetype: media.contentType,
|
||||
});
|
||||
} else if (media.kind === "audio") {
|
||||
@@ -501,21 +519,20 @@ export async function monitorWebProvider(
|
||||
audio: media.buffer,
|
||||
ptt: true,
|
||||
mimetype: media.contentType,
|
||||
caption: replyResult.text || undefined,
|
||||
caption,
|
||||
} as AnyMessageContent);
|
||||
} else if (media.kind === "video") {
|
||||
await msg.sendMedia({
|
||||
video: media.buffer,
|
||||
caption: replyResult.text || undefined,
|
||||
caption,
|
||||
mimetype: media.contentType,
|
||||
});
|
||||
} else {
|
||||
const fileName =
|
||||
replyResult.mediaUrl.split("/").pop() ?? "file";
|
||||
const fileName = mediaUrl.split("/").pop() ?? "file";
|
||||
await msg.sendMedia({
|
||||
document: media.buffer,
|
||||
fileName,
|
||||
caption: replyResult.text || undefined,
|
||||
caption,
|
||||
mimetype: media.contentType,
|
||||
} as AnyMessageContent);
|
||||
}
|
||||
@@ -527,8 +544,8 @@ export async function monitorWebProvider(
|
||||
{
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: replyResult.text ?? null,
|
||||
mediaUrl: replyResult.mediaUrl,
|
||||
text: index === 0 ? (replyResult.text ?? null) : null,
|
||||
mediaUrl,
|
||||
mediaSizeBytes: media.buffer.length,
|
||||
mediaKind: media.kind,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
@@ -541,39 +558,30 @@ export async function monitorWebProvider(
|
||||
`Failed sending web media to ${msg.from}: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
if (replyResult.text) {
|
||||
if (index === 0 && replyResult.text) {
|
||||
console.log(
|
||||
warn(`⚠️ Media skipped; sent text-only to ${msg.from}`),
|
||||
);
|
||||
await msg.reply(replyResult.text || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (replyResult.text) {
|
||||
await msg.reply(replyResult.text);
|
||||
logInfo(
|
||||
`⚠️ Media skipped; sent text-only to ${msg.from}`,
|
||||
runtime,
|
||||
);
|
||||
replyLogger.info(
|
||||
{
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: replyResult.text,
|
||||
mediaUrl: replyResult.mediaUrl,
|
||||
durationMs: Date.now() - replyStarted,
|
||||
mediaSendFailed: true,
|
||||
},
|
||||
"auto-reply sent (text fallback)",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await msg.reply(replyResult.text ?? "");
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - replyStarted;
|
||||
const hasMedia = mediaList.length > 0;
|
||||
if (isVerbose()) {
|
||||
console.log(
|
||||
success(
|
||||
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${replyResult.mediaUrl ? ", media" : ""}, ${formatDuration(durationMs)})`,
|
||||
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${hasMedia ? ", media" : ""}, ${formatDuration(durationMs)})`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
success(
|
||||
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl ? " (media)" : ""}`,
|
||||
`↩️ ${replyResult.text ?? "<media>"}${hasMedia ? " (media)" : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -582,7 +590,7 @@ export async function monitorWebProvider(
|
||||
to: msg.from,
|
||||
from: msg.to,
|
||||
text: replyResult.text ?? null,
|
||||
mediaUrl: replyResult.mediaUrl,
|
||||
mediaUrl: mediaList[0] ?? null,
|
||||
durationMs,
|
||||
},
|
||||
"auto-reply sent",
|
||||
|
||||
Reference in New Issue
Block a user