feat: download inbound media and expose to templating
This commit is contained in:
@@ -3,6 +3,9 @@ export type MsgContext = {
|
||||
From?: string;
|
||||
To?: string;
|
||||
MessageSid?: string;
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
};
|
||||
|
||||
export type TemplateContext = MsgContext & {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user