feat: download inbound media and expose to templating
parent
a1fab23439
commit
6d41df2941
|
|
@ -3,6 +3,9 @@ export type MsgContext = {
|
||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
MessageSid?: string;
|
MessageSid?: string;
|
||||||
|
MediaPath?: string;
|
||||||
|
MediaUrl?: string;
|
||||||
|
MediaType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TemplateContext = MsgContext & {
|
export type TemplateContext = MsgContext & {
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,33 @@ describe("config and templating", () => {
|
||||||
{ onReplyStart },
|
{ onReplyStart },
|
||||||
cfg,
|
cfg,
|
||||||
);
|
);
|
||||||
expect(result?.text).toBe("Hello whatsapp:+1555 [pfx] hi");
|
expect(result?.text).toBe("Hello whatsapp:+1555 [pfx] hi");
|
||||||
expect(onReplyStart).toHaveBeenCalled();
|
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 () => {
|
it("getReplyFromConfig runs command and manages session store", async () => {
|
||||||
const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`);
|
const tmpStore = path.join(os.tmpdir(), `warelay-store-${Date.now()}.json`);
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,13 @@ function looksLikeUrl(src: string) {
|
||||||
return /^https?:\/\//i.test(src);
|
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) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const req = request(url, (res) => {
|
const req = request(url, { headers }, (res) => {
|
||||||
if (!res.statusCode || res.statusCode >= 400) {
|
if (!res.statusCode || res.statusCode >= 400) {
|
||||||
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`));
|
||||||
return;
|
return;
|
||||||
|
|
@ -70,13 +74,16 @@ export type SavedMedia = {
|
||||||
|
|
||||||
export async function saveMediaSource(
|
export async function saveMediaSource(
|
||||||
source: string,
|
source: string,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
subdir = "",
|
||||||
): Promise<SavedMedia> {
|
): Promise<SavedMedia> {
|
||||||
await ensureMediaDir();
|
const dir = subdir ? path.join(MEDIA_DIR, subdir) : MEDIA_DIR;
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await cleanOldMedia();
|
await cleanOldMedia();
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const dest = path.join(MEDIA_DIR, id);
|
const dest = path.join(dir, id);
|
||||||
if (looksLikeUrl(source)) {
|
if (looksLikeUrl(source)) {
|
||||||
await downloadToFile(source, dest);
|
await downloadToFile(source, dest, headers);
|
||||||
const stat = await fs.stat(dest);
|
const stat = await fs.stat(dest);
|
||||||
return { id, path: dest, size: stat.size };
|
return { id, path: dest, size: stat.size };
|
||||||
}
|
}
|
||||||
|
|
@ -91,3 +98,19 @@ export async function saveMediaSource(
|
||||||
await fs.copyFile(source, dest);
|
await fs.copyFile(source, dest);
|
||||||
return { id, path: dest, size: stat.size };
|
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;
|
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 {
|
function getLastSocket(): MockBaileysSocket {
|
||||||
const getter = (globalThis as Record<PropertyKey, unknown>)[
|
const getter = (globalThis as Record<PropertyKey, unknown>)[
|
||||||
Symbol.for("warelay:lastSocket")
|
Symbol.for("warelay:lastSocket")
|
||||||
|
|
@ -170,6 +181,34 @@ describe("provider-web", () => {
|
||||||
await listener.close();
|
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", () => {
|
it("logWebSelfId prints cached E.164 when creds exist", () => {
|
||||||
const existsSpy = vi
|
const existsSpy = vi
|
||||||
.spyOn(fsSync, "existsSync")
|
.spyOn(fsSync, "existsSync")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
makeCacheableSignalKeyStore,
|
makeCacheableSignalKeyStore,
|
||||||
makeWASocket,
|
makeWASocket,
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
|
downloadMediaMessage,
|
||||||
type AnyMessageContent,
|
type AnyMessageContent,
|
||||||
} from "@whiskeysockets/baileys";
|
} from "@whiskeysockets/baileys";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
@ -20,6 +21,7 @@ import { waitForever } from "./cli/wait.js";
|
||||||
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
import { getReplyFromConfig } from "./auto-reply/reply.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
|
||||||
import { logInfo, logWarn } from "./logger.js";
|
import { logInfo, logWarn } from "./logger.js";
|
||||||
|
import { saveMediaBuffer } from "./media/store.js";
|
||||||
|
|
||||||
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
|
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
|
||||||
|
|
||||||
|
|
@ -226,6 +228,9 @@ export type WebInboundMessage = {
|
||||||
sendComposing: () => Promise<void>;
|
sendComposing: () => Promise<void>;
|
||||||
reply: (text: string) => Promise<void>;
|
reply: (text: string) => Promise<void>;
|
||||||
sendMedia: (payload: { image: Buffer; caption?: string; mimetype?: string }) => Promise<void>;
|
sendMedia: (payload: { image: Buffer; caption?: string; mimetype?: string }) => Promise<void>;
|
||||||
|
mediaPath?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function monitorWebInbox(options: {
|
export async function monitorWebInbox(options: {
|
||||||
|
|
@ -253,8 +258,26 @@ export async function monitorWebInbox(options: {
|
||||||
continue;
|
continue;
|
||||||
const from = jidToE164(remoteJid);
|
const from = jidToE164(remoteJid);
|
||||||
if (!from) continue;
|
if (!from) continue;
|
||||||
const body = extractText(msg.message ?? undefined);
|
let body = extractText(msg.message ?? undefined);
|
||||||
if (!body) continue;
|
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 chatJid = remoteJid;
|
||||||
const sendComposing = async () => {
|
const sendComposing = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -287,6 +310,8 @@ export async function monitorWebInbox(options: {
|
||||||
sendComposing,
|
sendComposing,
|
||||||
reply,
|
reply,
|
||||||
sendMedia,
|
sendMedia,
|
||||||
|
mediaPath,
|
||||||
|
mediaType,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -330,6 +355,9 @@ export async function monitorWebProvider(
|
||||||
From: msg.from,
|
From: msg.from,
|
||||||
To: msg.to,
|
To: msg.to,
|
||||||
MessageSid: msg.id,
|
MessageSid: msg.id,
|
||||||
|
MediaPath: msg.mediaPath,
|
||||||
|
MediaUrl: msg.mediaUrl,
|
||||||
|
MediaType: msg.mediaType,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onReplyStart: msg.sendComposing,
|
onReplyStart: msg.sendComposing,
|
||||||
|
|
@ -441,6 +469,48 @@ function extractText(message: proto.IMessage | undefined): string | undefined {
|
||||||
return 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(
|
async function loadWebMedia(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
): Promise<{ buffer: Buffer; contentType?: string }> {
|
): Promise<{ buffer: Buffer; contentType?: string }> {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import chalk from "chalk";
|
||||||
import type { Server } from "http";
|
import type { Server } from "http";
|
||||||
|
|
||||||
import { success, logVerbose, danger } from "../globals.js";
|
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 { createClient } from "./client.js";
|
||||||
import { normalizePath } from "../utils.js";
|
import { normalizePath } from "../utils.js";
|
||||||
import { getReplyFromConfig, type ReplyPayload } from "../auto-reply/reply.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 { logTwilioSendError } from "./utils.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { attachMediaRoutes } from "../media/server.js";
|
import { attachMediaRoutes } from "../media/server.js";
|
||||||
|
import { saveMediaSource } from "../media/store.js";
|
||||||
|
|
||||||
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
/** Start the inbound webhook HTTP server and wire optional auto-replies. */
|
||||||
export async function startWebhook(
|
export async function startWebhook(
|
||||||
|
|
@ -39,12 +40,33 @@ export async function startWebhook(
|
||||||
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
[INBOUND] ${From ?? "unknown"} -> ${To ?? "unknown"} (${MessageSid ?? "no-sid"})`);
|
||||||
if (verbose) runtime.log(chalk.gray(`Body: ${Body ?? ""}`));
|
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);
|
const client = createClient(env);
|
||||||
let replyResult: ReplyPayload | undefined =
|
let replyResult: ReplyPayload | undefined =
|
||||||
autoReply !== undefined ? { text: autoReply } : undefined;
|
autoReply !== undefined ? { text: autoReply } : undefined;
|
||||||
if (!replyResult) {
|
if (!replyResult) {
|
||||||
replyResult = await getReplyFromConfig(
|
replyResult = await getReplyFromConfig(
|
||||||
{ Body, From, To, MessageSid },
|
{ Body, From, To, MessageSid, MediaPath: mediaPath, MediaUrl: mediaUrlInbound, MediaType: mediaType },
|
||||||
{
|
{
|
||||||
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
onReplyStart: () => sendTypingIndicator(client, runtime, MessageSid),
|
||||||
},
|
},
|
||||||
|
|
@ -105,3 +127,10 @@ export async function startWebhook(
|
||||||
server.once("error", onError);
|
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");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export type MockBaileysModule = {
|
||||||
useMultiFileAuthState: ReturnType<typeof vi.fn>;
|
useMultiFileAuthState: ReturnType<typeof vi.fn>;
|
||||||
jidToE164?: (jid: string) => string | null;
|
jidToE164?: (jid: string) => string | null;
|
||||||
proto?: unknown;
|
proto?: unknown;
|
||||||
|
downloadMediaMessage?: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () => MockBaileysSocket } {
|
export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () => MockBaileysSocket } {
|
||||||
|
|
@ -44,6 +45,7 @@ export function createMockBaileys(): { mod: MockBaileysModule; lastSocket: () =>
|
||||||
saveCreds: vi.fn(),
|
saveCreds: vi.fn(),
|
||||||
})),
|
})),
|
||||||
jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"),
|
jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"),
|
||||||
|
downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue