perf(pi): reuse tau rpc for command auto-replies
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
import { danger, info, isVerbose, logVerbose, success } from "../globals.js";
|
||||
import { logInfo } from "../logger.js";
|
||||
import { getChildLogger } from "../logging.js";
|
||||
import { enqueueCommand, getQueueSize } from "../process/command-queue.js";
|
||||
import { getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { monitorWebInbox } from "./inbound.js";
|
||||
@@ -621,21 +621,19 @@ export async function monitorWebProvider(
|
||||
: new Date().toISOString();
|
||||
console.log(`\n[${tsDisplay}] ${from} -> ${latest.to}: ${combinedBody}`);
|
||||
|
||||
const replyResult = await enqueueCommand(() =>
|
||||
(replyResolver ?? getReplyFromConfig)(
|
||||
{
|
||||
Body: combinedBody,
|
||||
From: latest.from,
|
||||
To: latest.to,
|
||||
MessageSid: latest.id,
|
||||
MediaPath: latest.mediaPath,
|
||||
MediaUrl: latest.mediaUrl,
|
||||
MediaType: latest.mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: latest.sendComposing,
|
||||
},
|
||||
),
|
||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||
{
|
||||
Body: combinedBody,
|
||||
From: latest.from,
|
||||
To: latest.to,
|
||||
MessageSid: latest.id,
|
||||
MediaPath: latest.mediaPath,
|
||||
MediaUrl: latest.mediaUrl,
|
||||
MediaType: latest.mediaType,
|
||||
},
|
||||
{
|
||||
onReplyStart: latest.sendComposing,
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -931,24 +929,19 @@ export async function monitorWebProvider(
|
||||
"reply heartbeat start",
|
||||
);
|
||||
}
|
||||
const hbFrom = lastInboundMsg.from;
|
||||
const hbTo = lastInboundMsg.to;
|
||||
const hbComposing = lastInboundMsg.sendComposing;
|
||||
const replyResult = await enqueueCommand(() =>
|
||||
(replyResolver ?? getReplyFromConfig)(
|
||||
{
|
||||
Body: HEARTBEAT_PROMPT,
|
||||
From: hbFrom,
|
||||
To: hbTo,
|
||||
MessageSid: snapshot.entry?.sessionId,
|
||||
MediaPath: undefined,
|
||||
MediaUrl: undefined,
|
||||
MediaType: undefined,
|
||||
},
|
||||
{
|
||||
onReplyStart: hbComposing,
|
||||
},
|
||||
),
|
||||
const replyResult = await (replyResolver ?? getReplyFromConfig)(
|
||||
{
|
||||
Body: HEARTBEAT_PROMPT,
|
||||
From: lastInboundMsg.from,
|
||||
To: lastInboundMsg.to,
|
||||
MessageSid: snapshot.entry?.sessionId,
|
||||
MediaPath: undefined,
|
||||
MediaUrl: undefined,
|
||||
MediaType: undefined,
|
||||
},
|
||||
{
|
||||
onReplyStart: lastInboundMsg.sendComposing,
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { isVerbose, logVerbose } from "../globals.js";
|
||||
@@ -12,7 +13,12 @@ import { detectMime } from "../media/mime.js";
|
||||
export async function loadWebMedia(
|
||||
mediaUrl: string,
|
||||
maxBytes?: number,
|
||||
): Promise<{ buffer: Buffer; contentType?: string; kind: MediaKind }> {
|
||||
): Promise<{
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
kind: MediaKind;
|
||||
fileName?: string;
|
||||
}> {
|
||||
if (mediaUrl.startsWith("file://")) {
|
||||
mediaUrl = mediaUrl.replace("file://", "");
|
||||
}
|
||||
@@ -40,6 +46,14 @@ export async function loadWebMedia(
|
||||
};
|
||||
|
||||
if (/^https?:\/\//i.test(mediaUrl)) {
|
||||
let fileName: string | undefined;
|
||||
try {
|
||||
const url = new URL(mediaUrl);
|
||||
const base = path.basename(url.pathname);
|
||||
fileName = base || undefined;
|
||||
} catch {
|
||||
// ignore parse errors; leave undefined
|
||||
}
|
||||
const res = await fetch(mediaUrl);
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
|
||||
@@ -56,7 +70,7 @@ export async function loadWebMedia(
|
||||
maxBytesForKind(kind),
|
||||
);
|
||||
if (kind === "image") {
|
||||
return optimizeAndClampImage(array, cap);
|
||||
return { ...(await optimizeAndClampImage(array, cap)), fileName };
|
||||
}
|
||||
if (array.length > cap) {
|
||||
throw new Error(
|
||||
@@ -65,19 +79,25 @@ export async function loadWebMedia(
|
||||
).toFixed(2)}MB)`,
|
||||
);
|
||||
}
|
||||
return { buffer: array, contentType: contentType ?? undefined, kind };
|
||||
return {
|
||||
buffer: array,
|
||||
contentType: contentType ?? undefined,
|
||||
kind,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
|
||||
// Local path
|
||||
const data = await fs.readFile(mediaUrl);
|
||||
const mime = detectMime({ buffer: data, filePath: mediaUrl });
|
||||
const kind = mediaKindFromMime(mime);
|
||||
const fileName = path.basename(mediaUrl) || undefined;
|
||||
const cap = Math.min(
|
||||
maxBytes ?? maxBytesForKind(kind),
|
||||
maxBytesForKind(kind),
|
||||
);
|
||||
if (kind === "image") {
|
||||
return optimizeAndClampImage(data, cap);
|
||||
return { ...(await optimizeAndClampImage(data, cap)), fileName };
|
||||
}
|
||||
if (data.length > cap) {
|
||||
throw new Error(
|
||||
@@ -86,7 +106,7 @@ export async function loadWebMedia(
|
||||
).toFixed(2)}MB)`,
|
||||
);
|
||||
}
|
||||
return { buffer: data, contentType: mime, kind };
|
||||
return { buffer: data, contentType: mime, kind, fileName };
|
||||
}
|
||||
|
||||
export async function optimizeImageToJpeg(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AnyMessageContent } from "@whiskeysockets/baileys";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
@@ -17,6 +18,11 @@ vi.mock("./session.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const loadWebMediaMock = vi.fn();
|
||||
vi.mock("./media.js", () => ({
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||
}));
|
||||
|
||||
import { sendMessageWeb } from "./outbound.js";
|
||||
|
||||
const { createWaSocket } = await import("./session.js");
|
||||
@@ -37,4 +43,98 @@ describe("web outbound", () => {
|
||||
expect(sock.sendMessage).toHaveBeenCalled();
|
||||
expect(sock.ws.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps audio to PTT with opus mime when ogg", async () => {
|
||||
const buf = Buffer.from("audio");
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: buf,
|
||||
contentType: "audio/ogg",
|
||||
kind: "audio",
|
||||
});
|
||||
await sendMessageWeb("+1555", "voice note", {
|
||||
verbose: false,
|
||||
mediaUrl: "/tmp/voice.ogg",
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const [, payload] = sock.sendMessage.mock.calls.at(-1) as [
|
||||
string,
|
||||
AnyMessageContent,
|
||||
];
|
||||
expect(payload).toMatchObject({
|
||||
audio: buf,
|
||||
ptt: true,
|
||||
mimetype: "audio/ogg; codecs=opus",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps video with caption", async () => {
|
||||
const buf = Buffer.from("video");
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: buf,
|
||||
contentType: "video/mp4",
|
||||
kind: "video",
|
||||
});
|
||||
await sendMessageWeb("+1555", "clip", {
|
||||
verbose: false,
|
||||
mediaUrl: "/tmp/video.mp4",
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const [, payload] = sock.sendMessage.mock.calls.at(-1) as [
|
||||
string,
|
||||
AnyMessageContent,
|
||||
];
|
||||
expect(payload).toMatchObject({
|
||||
video: buf,
|
||||
caption: "clip",
|
||||
mimetype: "video/mp4",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps image with caption", async () => {
|
||||
const buf = Buffer.from("img");
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: buf,
|
||||
contentType: "image/jpeg",
|
||||
kind: "image",
|
||||
});
|
||||
await sendMessageWeb("+1555", "pic", {
|
||||
verbose: false,
|
||||
mediaUrl: "/tmp/pic.jpg",
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const [, payload] = sock.sendMessage.mock.calls.at(-1) as [
|
||||
string,
|
||||
AnyMessageContent,
|
||||
];
|
||||
expect(payload).toMatchObject({
|
||||
image: buf,
|
||||
caption: "pic",
|
||||
mimetype: "image/jpeg",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps other kinds to document with filename", async () => {
|
||||
const buf = Buffer.from("pdf");
|
||||
loadWebMediaMock.mockResolvedValueOnce({
|
||||
buffer: buf,
|
||||
contentType: "application/pdf",
|
||||
kind: "document",
|
||||
fileName: "file.pdf",
|
||||
});
|
||||
await sendMessageWeb("+1555", "doc", {
|
||||
verbose: false,
|
||||
mediaUrl: "/tmp/file.pdf",
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
const [, payload] = sock.sendMessage.mock.calls.at(-1) as [
|
||||
string,
|
||||
AnyMessageContent,
|
||||
];
|
||||
expect(payload).toMatchObject({
|
||||
document: buf,
|
||||
fileName: "file.pdf",
|
||||
caption: "doc",
|
||||
mimetype: "application/pdf",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,11 +35,39 @@ export async function sendMessageWeb(
|
||||
let payload: AnyMessageContent = { text: body };
|
||||
if (options.mediaUrl) {
|
||||
const media = await loadWebMedia(options.mediaUrl);
|
||||
payload = {
|
||||
image: media.buffer,
|
||||
caption: body || undefined,
|
||||
mimetype: media.contentType,
|
||||
};
|
||||
const caption = body || undefined;
|
||||
if (media.kind === "audio") {
|
||||
// WhatsApp expects explicit opus codec for PTT voice notes.
|
||||
const mimetype =
|
||||
media.contentType === "audio/ogg"
|
||||
? "audio/ogg; codecs=opus"
|
||||
: media.contentType ?? "application/octet-stream";
|
||||
payload = { audio: media.buffer, ptt: true, mimetype };
|
||||
} else if (media.kind === "video") {
|
||||
const mimetype = media.contentType ?? "application/octet-stream";
|
||||
payload = {
|
||||
video: media.buffer,
|
||||
caption,
|
||||
mimetype,
|
||||
};
|
||||
} else if (media.kind === "image") {
|
||||
const mimetype = media.contentType ?? "application/octet-stream";
|
||||
payload = {
|
||||
image: media.buffer,
|
||||
caption,
|
||||
mimetype,
|
||||
};
|
||||
} else {
|
||||
// Fallback to document for anything else (pdf, etc.).
|
||||
const fileName = media.fileName ?? "file";
|
||||
const mimetype = media.contentType ?? "application/octet-stream";
|
||||
payload = {
|
||||
document: media.buffer,
|
||||
fileName,
|
||||
caption,
|
||||
mimetype,
|
||||
};
|
||||
}
|
||||
}
|
||||
logInfo(
|
||||
`📤 Sending via web session -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
|
||||
|
||||
Reference in New Issue
Block a user