refactor: consolidate reply/media helpers

This commit is contained in:
Peter Steinberger
2026-01-10 02:40:41 +01:00
parent 9cd2662a86
commit 4075895c4c
17 changed files with 437 additions and 277 deletions

View File

@@ -17,12 +17,11 @@ import {
resolveHeartbeatPrompt,
stripHeartbeatToken,
} from "../auto-reply/heartbeat.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import {
buildMentionRegexes,
normalizeMentionText,
} from "../auto-reply/reply/mentions.js";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { ReplyPayload } from "../auto-reply/types.js";
@@ -1219,8 +1218,39 @@ export async function monitorWebProvider(
cfg,
route.agentId,
).responsePrefix;
const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({
const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
ctx: {
Body: combinedBody,
From: msg.from,
To: msg.to,
SessionKey: route.sessionKey,
AccountId: route.accountId,
MessageSid: msg.id,
ReplyToId: msg.replyToId,
ReplyToBody: msg.replyToBody,
ReplyToSender: msg.replyToSender,
MediaPath: msg.mediaPath,
MediaUrl: msg.mediaUrl,
MediaType: msg.mediaType,
ChatType: msg.chatType,
GroupSubject: msg.groupSubject,
GroupMembers: formatGroupMembers(
msg.groupParticipants,
groupMemberNames.get(groupHistoryKey),
msg.senderE164,
),
SenderName: msg.senderName,
SenderE164: msg.senderE164,
WasMentioned: msg.wasMentioned,
...(msg.location ? toLocationContext(msg.location) : {}),
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: msg.from,
},
cfg,
replyResolver,
dispatcherOptions: {
responsePrefix,
onHeartbeatStrip: () => {
if (!didLogHeartbeatStrip) {
@@ -1283,50 +1313,14 @@ export async function monitorWebProvider(
);
},
onReplyStart: msg.sendComposing,
});
const { queuedFinal } = await dispatchReplyFromConfig({
ctx: {
Body: combinedBody,
From: msg.from,
To: msg.to,
SessionKey: route.sessionKey,
AccountId: route.accountId,
MessageSid: msg.id,
ReplyToId: msg.replyToId,
ReplyToBody: msg.replyToBody,
ReplyToSender: msg.replyToSender,
MediaPath: msg.mediaPath,
MediaUrl: msg.mediaUrl,
MediaType: msg.mediaType,
ChatType: msg.chatType,
GroupSubject: msg.groupSubject,
GroupMembers: formatGroupMembers(
msg.groupParticipants,
groupMemberNames.get(groupHistoryKey),
msg.senderE164,
),
SenderName: msg.senderName,
SenderE164: msg.senderE164,
WasMentioned: msg.wasMentioned,
...(msg.location ? toLocationContext(msg.location) : {}),
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: msg.from,
},
cfg,
dispatcher,
replyResolver,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof cfg.whatsapp?.blockStreaming === "boolean"
? !cfg.whatsapp.blockStreaming
: undefined,
},
});
markDispatchIdle();
if (!queuedFinal) {
if (shouldClearGroupHistory && didSendReply) {
groupHistories.set(groupHistoryKey, []);

View File

@@ -7,6 +7,7 @@ import {
maxBytesForKind,
mediaKindFromMime,
} from "../media/constants.js";
import { fetchRemoteMedia } from "../media/fetch.js";
import { resizeToJpeg } from "../media/image-ops.js";
import { detectMime, extensionForMime } from "../media/mime.js";
@@ -22,45 +23,6 @@ type WebMediaOptions = {
optimizeImages?: boolean;
};
function stripQuotes(value: string): string {
return value.replace(/^["']|["']$/g, "");
}
function parseContentDispositionFileName(
header?: string | null,
): string | undefined {
if (!header) return undefined;
const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header);
if (starMatch?.[1]) {
const cleaned = stripQuotes(starMatch[1].trim());
const encoded = cleaned.split("''").slice(1).join("''") || cleaned;
try {
return path.basename(decodeURIComponent(encoded));
} catch {
return path.basename(encoded);
}
}
const match = /filename\s*=\s*([^;]+)/i.exec(header);
if (match?.[1]) return path.basename(stripQuotes(match[1].trim()));
return undefined;
}
async function readErrorBodySnippet(
res: Response,
maxChars = 200,
): Promise<string | undefined> {
try {
const text = await res.text();
if (!text) return undefined;
const collapsed = text.replace(/\s+/g, " ").trim();
if (!collapsed) return undefined;
if (collapsed.length <= maxChars) return collapsed;
return `${collapsed.slice(0, maxChars)}`;
} catch {
return undefined;
}
}
async function loadWebMediaInternal(
mediaUrl: string,
options: WebMediaOptions = {},
@@ -93,53 +55,8 @@ async function loadWebMediaInternal(
};
if (/^https?:\/\//i.test(mediaUrl)) {
let fileNameFromUrl: string | undefined;
try {
const url = new URL(mediaUrl);
const base = path.basename(url.pathname);
fileNameFromUrl = base || undefined;
} catch {
// ignore parse errors; leave undefined
}
let res: Response;
try {
res = await fetch(mediaUrl);
} catch (err) {
throw new Error(`Failed to fetch media from ${mediaUrl}: ${String(err)}`);
}
if (!res.ok || !res.body) {
const statusText = res.statusText ? ` ${res.statusText}` : "";
const redirected =
res.url && res.url !== mediaUrl ? ` (redirected to ${res.url})` : "";
let detail = `HTTP ${res.status}${statusText}`;
if (!res.body) {
detail = `HTTP ${res.status}${statusText}; empty response body`;
} else if (!res.ok) {
const snippet = await readErrorBodySnippet(res);
if (snippet) detail += `; body: ${snippet}`;
}
throw new Error(
`Failed to fetch media from ${mediaUrl}${redirected}: ${detail}`,
);
}
const array = Buffer.from(await res.arrayBuffer());
const headerFileName = parseContentDispositionFileName(
res.headers.get("content-disposition"),
);
let fileName = headerFileName || fileNameFromUrl || undefined;
const filePathForMime =
headerFileName && path.extname(headerFileName)
? headerFileName
: mediaUrl;
const contentType = await detectMime({
buffer: array,
headerMime: res.headers.get("content-type"),
filePath: filePathForMime,
});
if (fileName && !path.extname(fileName) && contentType) {
const ext = extensionForMime(contentType);
if (ext) fileName = `${fileName}${ext}`;
}
const fetched = await fetchRemoteMedia({ url: mediaUrl });
const { buffer, contentType, fileName } = fetched;
const kind = mediaKindFromMime(contentType);
const cap = Math.min(
maxBytes ?? maxBytesForKind(kind),
@@ -148,28 +65,28 @@ async function loadWebMediaInternal(
if (kind === "image") {
// Skip optimization for GIFs to preserve animation.
if (contentType === "image/gif" || !optimizeImages) {
if (array.length > cap) {
if (buffer.length > cap) {
throw new Error(
`${
contentType === "image/gif" ? "GIF" : "Media"
} exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
array.length / (1024 * 1024)
buffer.length / (1024 * 1024)
).toFixed(2)}MB)`,
);
}
return { buffer: array, contentType, kind, fileName };
return { buffer, contentType, kind, fileName };
}
return { ...(await optimizeAndClampImage(array, cap)), fileName };
return { ...(await optimizeAndClampImage(buffer, cap)), fileName };
}
if (array.length > cap) {
if (buffer.length > cap) {
throw new Error(
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
array.length / (1024 * 1024)
buffer.length / (1024 * 1024)
).toFixed(2)}MB)`,
);
}
return {
buffer: array,
buffer,
contentType: contentType ?? undefined,
kind,
fileName,