refactor: consolidate reply/media helpers
This commit is contained in:
@@ -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, []);
|
||||
|
||||
103
src/web/media.ts
103
src/web/media.ts
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user