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 # Changelog
## [Unreleased] 1.0.5 ## 1.1.0 — 2025-11-25
### Pending ### Pending
- Web auto-replies now resize/recompress media and honor `inbound.reply.mediaMaxMb` in `~/.warelay/warelay.json` (default 5MB) to avoid provider/API limits. - 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", "$schema": "https://biomejs.dev/schemas/biome.json",
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"indentWidth": 2 "indentWidth": 2,
"indentStyle": "space"
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,

View File

@@ -91,6 +91,7 @@ function summarizeClaudeMetadata(payload: unknown): string | undefined {
export type ReplyPayload = { export type ReplyPayload = {
text?: string; text?: string;
mediaUrl?: string; mediaUrl?: string;
mediaUrls?: string[];
}; };
export async function getReplyFromConfig( 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). // 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); splitMediaFromOutput(trimmed);
trimmed = cleanedText; trimmed = cleanedText;
if (mediaFound) { if (mediaFound?.length) {
mediaFromCommand = mediaFound; mediaFromCommand = mediaFound;
if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`); if (isVerbose()) logVerbose(`MEDIA token extracted: ${mediaFound}`);
} else if (isVerbose()) { } else if (isVerbose()) {
@@ -455,10 +456,15 @@ export async function getReplyFromConfig(
); );
return undefined; return undefined;
} }
const mediaUrl = mediaFromCommand ?? reply.mediaUrl; const mediaUrls =
mediaFromCommand ?? (reply.mediaUrl ? [reply.mediaUrl] : undefined);
const result = const result =
trimmed || mediaUrl trimmed || mediaUrls?.length
? { text: trimmed || undefined, mediaUrl } ? {
text: trimmed || undefined,
mediaUrl: mediaUrls?.[0],
mediaUrls,
}
: undefined; : undefined;
cleanupTyping(); cleanupTyping();
return result; return result;
@@ -559,7 +565,13 @@ export async function autoReplyIfConfigured(
}, },
cfg, cfg,
); );
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) return; if (
!replyResult ||
(!replyResult.text &&
!replyResult.mediaUrl &&
!replyResult.mediaUrls?.length)
)
return;
const replyFrom = message.to; const replyFrom = message.to;
const replyTo = message.from; const replyTo = message.from;
@@ -583,17 +595,35 @@ export async function autoReplyIfConfigured(
} }
try { try {
let mediaUrl = replyResult.mediaUrl; const mediaList = replyResult.mediaUrls?.length
if (mediaUrl && !/^https?:\/\//i.test(mediaUrl)) { ? replyResult.mediaUrls
const hosted = await ensureMediaHosted(mediaUrl); : replyResult.mediaUrl
mediaUrl = hosted.url; ? [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({ await client.messages.create({
from: replyFrom, from: replyFrom,
to: replyTo, to: replyTo,
body: replyResult.text ?? "", body,
...(mediaUrl ? { mediaUrl: [mediaUrl] } : {}), ...(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()) { if (isVerbose()) {
console.log( console.log(
info( info(

View File

@@ -1,7 +1,7 @@
// Shared helpers for parsing MEDIA tokens from command/stdout text. // Shared helpers for parsing MEDIA tokens from command/stdout text.
// Allow optional wrapping backticks and punctuation after the token; capture the core token. // 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) { export function normalizeMediaSource(src: string) {
return src.startsWith("file://") ? src.replace("file://", "") : src; return src.startsWith("file://") ? src.replace("file://", "") : src;
@@ -24,30 +24,78 @@ function isValidMedia(candidate: string) {
export function splitMediaFromOutput(raw: string): { export function splitMediaFromOutput(raw: string): {
text: string; text: string;
mediaUrl?: string; mediaUrls?: string[];
mediaUrl?: string; // legacy first item for backward compatibility
} { } {
const trimmedRaw = raw.trim(); const trimmedRaw = raw.trim();
const match = MEDIA_TOKEN_RE.exec(trimmedRaw); if (!trimmedRaw) return { text: "" };
if (!match?.[1]) return { text: trimmedRaw };
const candidate = normalizeMediaSource(cleanCandidate(match[1])); const media: string[] = [];
const mediaUrl = isValidMedia(candidate) ? candidate : undefined; let foundMediaToken = false;
const cleanedText = mediaUrl // Collect tokens line by line so we can strip them cleanly.
? trimmedRaw const lines = trimmedRaw.split("\n");
.replace(match[0], "") const keptLines: string[] = [];
.replace(/[ \t]+\n/g, "\n")
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(/[ \t]{2,}/g, " ")
.replace(/\n{2,}/g, "\n") .trim();
.trim()
: trimmedRaw // If the line becomes empty, drop it.
.split("\n") if (cleanedLine) {
.filter((line) => !MEDIA_TOKEN_RE.test(line)) keptLines.push(cleanedLine);
}
}
const cleanedText = keptLines
.join("\n") .join("\n")
.replace(/[ \t]+\n/g, "\n") .replace(/[ \t]+\n/g, "\n")
.replace(/[ \t]{2,}/g, " ") .replace(/[ \t]{2,}/g, " ")
.replace(/\n{2,}/g, "\n") .replace(/\n{2,}/g, "\n")
.trim(); .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 { getReplyFromConfig } from "./auto-reply/reply.js";
import { waitForever } from "./cli/wait.js"; import { waitForever } from "./cli/wait.js";
import { loadConfig } from "./config/config.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 { logInfo } from "./logger.js";
import { getChildLogger } from "./logging.js"; import { getChildLogger } from "./logging.js";
import { maxBytesForKind, mediaKindFromMime } from "./media/constants.js"; import { maxBytesForKind, mediaKindFromMime } from "./media/constants.js";
@@ -466,34 +473,45 @@ export async function monitorWebProvider(
onReplyStart: msg.sendComposing, onReplyStart: msg.sendComposing,
}, },
); );
if (!replyResult || (!replyResult.text && !replyResult.mediaUrl)) { if (
!replyResult ||
(!replyResult.text &&
!replyResult.mediaUrl &&
!replyResult.mediaUrls?.length)
) {
logVerbose( logVerbose(
"Skipping auto-reply: no text/media returned from resolver", "Skipping auto-reply: no text/media returned from resolver",
); );
return; return;
} }
try { try {
if (replyResult.mediaUrl) { const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl
? [replyResult.mediaUrl]
: [];
if (mediaList.length > 0) {
logVerbose( 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 { try {
const media = await loadWebMedia( const media = await loadWebMedia(mediaUrl, maxMediaBytes);
replyResult.mediaUrl,
maxMediaBytes,
);
if (isVerbose()) { if (isVerbose()) {
logVerbose( logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`, `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
); );
logVerbose( 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") { if (media.kind === "image") {
await msg.sendMedia({ await msg.sendMedia({
image: media.buffer, image: media.buffer,
caption: replyResult.text || undefined, caption,
mimetype: media.contentType, mimetype: media.contentType,
}); });
} else if (media.kind === "audio") { } else if (media.kind === "audio") {
@@ -501,21 +519,20 @@ export async function monitorWebProvider(
audio: media.buffer, audio: media.buffer,
ptt: true, ptt: true,
mimetype: media.contentType, mimetype: media.contentType,
caption: replyResult.text || undefined, caption,
} as AnyMessageContent); } as AnyMessageContent);
} else if (media.kind === "video") { } else if (media.kind === "video") {
await msg.sendMedia({ await msg.sendMedia({
video: media.buffer, video: media.buffer,
caption: replyResult.text || undefined, caption,
mimetype: media.contentType, mimetype: media.contentType,
}); });
} else { } else {
const fileName = const fileName = mediaUrl.split("/").pop() ?? "file";
replyResult.mediaUrl.split("/").pop() ?? "file";
await msg.sendMedia({ await msg.sendMedia({
document: media.buffer, document: media.buffer,
fileName, fileName,
caption: replyResult.text || undefined, caption,
mimetype: media.contentType, mimetype: media.contentType,
} as AnyMessageContent); } as AnyMessageContent);
} }
@@ -527,8 +544,8 @@ export async function monitorWebProvider(
{ {
to: msg.from, to: msg.from,
from: msg.to, from: msg.to,
text: replyResult.text ?? null, text: index === 0 ? (replyResult.text ?? null) : null,
mediaUrl: replyResult.mediaUrl, mediaUrl,
mediaSizeBytes: media.buffer.length, mediaSizeBytes: media.buffer.length,
mediaKind: media.kind, mediaKind: media.kind,
durationMs: Date.now() - replyStarted, durationMs: Date.now() - replyStarted,
@@ -541,39 +558,30 @@ export async function monitorWebProvider(
`Failed sending web media to ${msg.from}: ${String(err)}`, `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); 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 durationMs = Date.now() - replyStarted;
const hasMedia = mediaList.length > 0;
if (isVerbose()) { if (isVerbose()) {
console.log( console.log(
success( 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 { } else {
console.log( console.log(
success( success(
`↩️ ${replyResult.text ?? "<media>"}${replyResult.mediaUrl ? " (media)" : ""}`, `↩️ ${replyResult.text ?? "<media>"}${hasMedia ? " (media)" : ""}`,
), ),
); );
} }
@@ -582,7 +590,7 @@ export async function monitorWebProvider(
to: msg.from, to: msg.from,
from: msg.to, from: msg.to,
text: replyResult.text ?? null, text: replyResult.text ?? null,
mediaUrl: replyResult.mediaUrl, mediaUrl: mediaList[0] ?? null,
durationMs, durationMs,
}, },
"auto-reply sent", "auto-reply sent",