feat: download inbound media and expose to templating

This commit is contained in:
Peter Steinberger
2025-11-25 05:17:59 +01:00
parent a1fab23439
commit 6d41df2941
7 changed files with 202 additions and 12 deletions

View File

@@ -3,6 +3,9 @@ export type MsgContext = {
From?: string;
To?: string;
MessageSid?: string;
MediaPath?: string;
MediaUrl?: string;
MediaType?: string;
};
export type TemplateContext = MsgContext & {

View File

@@ -86,9 +86,33 @@ describe("config and templating", () => {
{ onReplyStart },
cfg,
);
expect(result?.text).toBe("Hello whatsapp:+1555 [pfx] hi");
expect(onReplyStart).toHaveBeenCalled();
});
expect(result?.text).toBe("Hello whatsapp:+1555 [pfx] hi");
expect(onReplyStart).toHaveBeenCalled();
});
it("getReplyFromConfig templating includes media fields", async () => {
const cfg = {
inbound: {
reply: {
mode: "text" as const,
text: "{{MediaPath}} {{MediaType}} {{MediaUrl}}",
},
},
};
const result = await index.getReplyFromConfig(
{
Body: "",
From: "+1",
To: "+2",
MediaPath: "/tmp/a.jpg",
MediaType: "image/jpeg",
MediaUrl: "http://example.com/a.jpg",
},
undefined,
cfg,
);
expect(result?.text).toBe("/tmp/a.jpg image/jpeg http://example.com/a.jpg");
});
it("getReplyFromConfig runs command and manages session store", async () => {
const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`);

View File

@@ -39,9 +39,13 @@ function looksLikeUrl(src: string) {
return /^https?:\/\//i.test(src);
}
async function downloadToFile(url: string, dest: string) {
async function downloadToFile(
url: string,
dest: string,
headers?: Record<string, string>,
) {
await new Promise<void>((resolve, reject) => {
const req = request(url, (res) => {
const req = request(url, { headers }, (res) => {
if (!res.statusCode || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
return;
@@ -70,13 +74,16 @@ export type SavedMedia = {
export async function saveMediaSource(
source: string,
headers?: Record<string, string>,
subdir = "",
): Promise<SavedMedia> {
await ensureMediaDir();
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
await fs.mkdir(dir, { recursive: true });
await cleanOldMedia();
const id = crypto.randomUUID();
const dest = path.join(MEDIA_DIR, id);
const dest = path.join(dir, id);
if (looksLikeUrl(source)) {
await downloadToFile(source, dest);
await downloadToFile(source, dest, headers);
const stat = await fs.stat(dest);
return { id, path: dest, size: stat.size };
}
@@ -91,3 +98,19 @@ export async function saveMediaSource(
await fs.copyFile(source, dest);
return { id, path: dest, size: stat.size };
}
export async function saveMediaBuffer(
buffer: Buffer,
contentType?: string,
subdir = "inbound",
): Promise<SavedMedia> {
if (buffer.byteLength > MAX_BYTES) {
throw new Error("Media exceeds 5MB limit");
}
const dir = path.join(MEDIA_DIR, subdir);
await fs.mkdir(dir, { recursive: true });
const id = crypto.randomUUID();
const dest = path.join(dir, id);
await fs.writeFile(dest, buffer);
return { id, path: dest, size: buffer.byteLength, contentType };
}

View File

@@ -12,6 +12,17 @@ vi.mock("@whiskeysockets/baileys", () => {
return created.mod;
});
vi.mock("./media/store.js", () => ({
saveMediaBuffer: vi
.fn()
.mockImplementation(async (_buf: Buffer, contentType?: string) => ({
id: "mid",
path: "/tmp/mid",
size: _buf.length,
contentType,
})),
}));
function getLastSocket(): MockBaileysSocket {
const getter = (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("warelay:lastSocket")
@@ -170,6 +181,34 @@ describe("provider-web", () => {
await listener.close();
});
it("monitorWebInbox captures media path for image messages", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = getLastSocket();
const upsert = {
type: "notify",
messages: [
{
key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" },
message: { imageMessage: { mimetype: "image/jpeg" } },
messageTimestamp: 1_700_000_100,
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
body: "<media:image>",
mediaPath: "/tmp/mid",
mediaType: "image/jpeg",
}),
);
await listener.close();
});
it("logWebSelfId prints cached E.164 when creds exist", () => {
const existsSpy = vi
.spyOn(fsSync, "existsSync")

View File

@@ -9,6 +9,7 @@ import {
makeCacheableSignalKeyStore,
makeWASocket,
useMultiFileAuthState,
downloadMediaMessage,
type AnyMessageContent,
} from "@whiskeysockets/baileys";
import pino from "pino";
@@ -20,6 +21,7 @@ import { waitForever } from "./cli/wait.js";
import { getReplyFromConfig } from "./auto-reply/reply.js";
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
import { logInfo, logWarn } from "./logger.js";
import { saveMediaBuffer } from "./media/store.js";
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
@@ -226,6 +228,9 @@ export type WebInboundMessage = {
sendComposing: () => Promise<void>;
reply: (text: string) => Promise<void>;
sendMedia: (payload: { image: Buffer; caption?: string; mimetype?: string }) => Promise<void>;
mediaPath?: string;
mediaType?: string;
mediaUrl?: string;
};
export async function monitorWebInbox(options: {
@@ -253,8 +258,26 @@ export async function monitorWebInbox(options: {
continue;
const from = jidToE164(remoteJid);
if (!from) continue;
const body = extractText(msg.message ?? undefined);
if (!body) continue;
let body = extractText(msg.message ?? undefined);
if (!body) {
body = extractMediaPlaceholder(msg.message ?? undefined);
if (!body) continue;
}
let mediaPath: string | undefined;
let mediaType: string | undefined;
try {
const inboundMedia = await downloadInboundMedia(msg, sock);
if (inboundMedia) {
const saved = await saveMediaBuffer(
inboundMedia.buffer,
inboundMedia.mimetype,
);
mediaPath = saved.path;
mediaType = inboundMedia.mimetype;
}
} catch (err) {
logVerbose(`Inbound media download failed: ${String(err)}`);
}
const chatJid = remoteJid;
const sendComposing = async () => {
try {
@@ -287,6 +310,8 @@ export async function monitorWebInbox(options: {
sendComposing,
reply,
sendMedia,
mediaPath,
mediaType,
});
} catch (err) {
console.error(
@@ -330,6 +355,9 @@ export async function monitorWebProvider(
From: msg.from,
To: msg.to,
MessageSid: msg.id,
MediaPath: msg.mediaPath,
MediaUrl: msg.mediaUrl,
MediaType: msg.mediaType,
},
{
onReplyStart: msg.sendComposing,
@@ -441,6 +469,48 @@ function extractText(message: proto.IMessage | undefined): string | undefined {
return undefined;
}
function extractMediaPlaceholder(message: proto.IMessage | undefined): string | undefined {
if (!message) return undefined;
if (message.imageMessage) return "<media:image>";
if (message.videoMessage) return "<media:video>";
if (message.audioMessage) return "<media:audio>";
if (message.documentMessage) return "<media:document>";
if (message.stickerMessage) return "<media:sticker>";
return undefined;
}
async function downloadInboundMedia(
msg: proto.IWebMessageInfo,
sock: ReturnType<typeof makeWASocket>,
): Promise<{ buffer: Buffer; mimetype?: string } | undefined> {
const message = msg.message;
if (!message) return undefined;
const mimetype =
message.imageMessage?.mimetype ??
message.videoMessage?.mimetype ??
message.documentMessage?.mimetype ??
message.audioMessage?.mimetype ??
message.stickerMessage?.mimetype;
if (
!message.imageMessage &&
!message.videoMessage &&
!message.documentMessage &&
!message.audioMessage &&
!message.stickerMessage
) {
return undefined;
}
try {
const buffer = (await downloadMediaMessage(msg as any, "buffer", {}, {
reuploadRequest: sock.updateMediaMessage,
})) as Buffer;
return { buffer, mimetype };
} catch (err) {
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
return undefined;
}
}
async function loadWebMedia(
mediaUrl: string,
): Promise<{ buffer: Buffer; contentType?: string }> {

View File

@@ -4,7 +4,7 @@ import chalk from "chalk";
import type { Server } from "http";
import { success, logVerbose, danger } from "../globals.js";
import { readEnv } from "../env.js";
import { readEnv, type EnvConfig } from "../env.js";
import { createClient } from "./client.js";
import { normalizePath } from "../utils.js";
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.js";
@@ -12,6 +12,7 @@ import { sendTypingIndicator } from "./typing.js";
import { logTwilioSendError } from "./utils.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { attachMediaRoutes } from "../media/server.js";
import { saveMediaSource } from "../media/store.js";
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
export async function startWebhook(
@@ -39,12 +40,33 @@ export async function startWebhook(
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
const numMedia = Number.parseInt((req.body?.NumMedia ?? "0") as string, 10);
let mediaPath: string | undefined;
let mediaUrlInbound: string | undefined;
let mediaType: string | undefined;
if (numMedia > 0 && typeof req.body?.MediaUrl0 === "string") {
mediaUrlInbound = req.body.MediaUrl0 as string;
mediaType = typeof req.body?.MediaContentType0 === "string"
? (req.body.MediaContentType0 as string)
: undefined;
try {
const creds = buildTwilioBasicAuth(env);
const saved = await saveMediaSource(mediaUrlInbound, {
Authorization: `Basic ${creds}`,
}, "inbound");
mediaPath = saved.path;
if (!mediaType && saved.contentType) mediaType = saved.contentType;
} catch (err) {
runtime.error(danger(`Failed to download inbound media: ${String(err)}`));
}
}
const client = createClient(env);
let replyResult: ReplyPayload | undefined =
autoReply !== undefined ? { text: autoReply } : undefined;
if (!replyResult) {
replyResult = await getReplyFromConfig(
{ Body, From, To, MessageSid },
{ Body, From, To, MessageSid, MediaPath: mediaPath, MediaUrl: mediaUrlInbound, MediaType: mediaType },
{
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
},
@@ -105,3 +127,10 @@ export async function startWebhook(
server.once("error", onError);
});
}
function buildTwilioBasicAuth(env: EnvConfig) {
if ("authToken" in env.auth) {
return Buffer.from(`${env.accountSid}:${env.auth.authToken}`).toString("base64");
}
return Buffer.from(`${env.auth.apiKey}:${env.auth.apiSecret}`).toString("base64");
}