chore: format to 2-space and bump changelog

This commit is contained in:
Peter Steinberger
2025-11-26 00:53:53 +01:00
parent a67f4db5e2
commit e5f677803f
81 changed files with 7086 additions and 6999 deletions

View File

@@ -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 5MB) to avoid provider/API limits.

View File

@@ -2,7 +2,8 @@
"$schema": "https://biomejs.dev/schemas/biome.json",
"formatter": {
"enabled": true,
"indentWidth": 2
"indentWidth": 2,
"indentStyle": "space"
},
"linter": {
"enabled": true,

View File

@@ -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(

View File

@@ -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] };
}

View File

@@ -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",