refactor(web): split provider module

This commit is contained in:
Peter Steinberger
2025-11-26 01:16:54 +01:00
parent e5f677803f
commit 4dd2f3b7f7
10 changed files with 1079 additions and 940 deletions

View File

@@ -395,7 +395,7 @@ export async function getReplyFromConfig(
},
);
const rawStdout = stdout.trim();
let mediaFromCommand: string | undefined;
let mediaFromCommand: string[] | undefined;
let trimmed = rawStdout;
if (stderr?.trim()) {
logVerbose(`Command auto-reply stderr: ${stderr.trim()}`);
@@ -538,18 +538,13 @@ export async function autoReplyIfConfigured(
if (mediaUrl) ctx.MediaUrl = mediaUrl;
// Optional audio transcription before building reply.
if (cfg.inbound?.transcribeAudio && message.media?.length) {
const media = message.media[0];
const mediaField = (message as { media?: unknown }).media;
const mediaItems = Array.isArray(mediaField) ? mediaField : [];
if (cfg.inbound?.transcribeAudio && mediaItems.length) {
const media = mediaItems[0];
const contentType = (media as { contentType?: string }).contentType;
if (contentType?.startsWith("audio")) {
const transcribed = await transcribeInboundAudio(
cfg,
{
mediaUrl: mediaUrl ?? undefined,
contentType,
},
runtime,
);
const transcribed = await transcribeInboundAudio(cfg, ctx, runtime);
if (transcribed?.text) {
ctx.Body = transcribed.text;
ctx.MediaType = contentType;

View File

@@ -1,929 +1,26 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { proto } from "@whiskeysockets/baileys";
import {
type AnyMessageContent,
DisconnectReason,
downloadMediaMessage,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
makeWASocket,
useMultiFileAuthState,
type WAMessage,
} from "@whiskeysockets/baileys";
import qrcode from "qrcode-terminal";
import sharp from "sharp";
import { getReplyFromConfig } from "./auto-reply/reply.js";
import { waitForever } from "./cli/wait.js";
import { loadConfig } from "./config/config.js";
import {
danger,
info,
isVerbose,
logVerbose,
success,
warn,
} from "./globals.js";
import { logInfo } from "./logger.js";
import { getChildLogger } from "./logging.js";
import { maxBytesForKind, mediaKindFromMime } from "./media/constants.js";
import { saveMediaBuffer } from "./media/store.js";
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";
import type { Provider } from "./utils.js";
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
import { VERSION } from "./version.js";
function formatDuration(ms: number) {
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
}
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "credentials");
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
export async function createWaSocket(printQr: boolean, verbose: boolean) {
const logger = getChildLogger(
{ module: "baileys" },
{
level: verbose ? "info" : "silent",
},
);
// Some Baileys internals call logger.trace even when silent; ensure it's present.
const loggerAny = logger as unknown as Record<string, unknown>;
if (typeof loggerAny.trace !== "function") {
loggerAny.trace = () => {};
}
await ensureDir(WA_WEB_AUTH_DIR);
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ["warelay", "cli", VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
sock.ev.on("creds.update", saveCreds);
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
const { connection, lastDisconnect, qr } = update;
if (qr && printQr) {
console.log("Scan this QR in WhatsApp (Linked Devices):");
qrcode.generate(qr, { small: true });
}
if (connection === "close") {
const status = getStatusCode(lastDisconnect?.error);
if (status === DisconnectReason.loggedOut) {
console.error(
danger("WhatsApp session logged out. Run: warelay login"),
);
}
}
if (connection === "open" && verbose) {
console.log(success("WhatsApp Web connected."));
}
},
);
return sock;
}
export async function waitForWaConnection(
sock: ReturnType<typeof makeWASocket>,
) {
return new Promise<void>((resolve, reject) => {
type OffCapable = {
off?: (event: string, listener: (...args: unknown[]) => void) => void;
};
const evWithOff = sock.ev as unknown as OffCapable;
const handler = (...args: unknown[]) => {
const update = (args[0] ?? {}) as Partial<
import("@whiskeysockets/baileys").ConnectionState
>;
if (update.connection === "open") {
evWithOff.off?.("connection.update", handler);
resolve();
}
if (update.connection === "close") {
evWithOff.off?.("connection.update", handler);
reject(update.lastDisconnect ?? new Error("Connection closed"));
}
};
sock.ev.on("connection.update", handler);
});
}
export async function sendMessageWeb(
to: string,
body: string,
options: { verbose: boolean; mediaUrl?: string },
): Promise<{ messageId: string; toJid: string }> {
const sock = await createWaSocket(false, options.verbose);
try {
logInfo("🔌 Connecting to WhatsApp Web…");
await waitForWaConnection(sock);
const jid = toWhatsappJid(to);
try {
await sock.sendPresenceUpdate("composing", jid);
} catch (err) {
logVerbose(`Presence update skipped: ${String(err)}`);
}
let payload: AnyMessageContent = { text: body };
if (options.mediaUrl) {
const media = await loadWebMedia(options.mediaUrl);
payload = {
image: media.buffer,
caption: body || undefined,
mimetype: media.contentType,
};
}
logInfo(
`📤 Sending via web session -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
);
const result = await sock.sendMessage(jid, payload);
const messageId = result?.key?.id ?? "unknown";
logInfo(
`✅ Sent via web session. Message ID: ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
);
return { messageId, toJid: jid };
} finally {
try {
sock.ws?.close();
} catch (err) {
logVerbose(`Socket close failed: ${String(err)}`);
}
}
}
export async function loginWeb(
verbose: boolean,
waitForConnection: typeof waitForWaConnection = waitForWaConnection,
runtime: RuntimeEnv = defaultRuntime,
) {
const sock = await createWaSocket(true, verbose);
logInfo("Waiting for WhatsApp connection...", runtime);
try {
await waitForConnection(sock);
console.log(success("✅ Linked! Credentials saved for future sends."));
} catch (err) {
const code =
(err as { error?: { output?: { statusCode?: number } } })?.error?.output
?.statusCode ??
(err as { output?: { statusCode?: number } })?.output?.statusCode;
if (code === 515) {
console.log(
info(
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
),
);
try {
sock.ws?.close();
} catch {
// ignore
}
const retry = await createWaSocket(false, verbose);
try {
await waitForConnection(retry);
console.log(
success(
"✅ Linked after restart; web session ready. You can now send with provider=web.",
),
);
return;
} finally {
setTimeout(() => retry.ws?.close(), 500);
}
}
if (code === DisconnectReason.loggedOut) {
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
console.error(
danger(
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun warelay login and scan the QR again.",
),
);
throw new Error("Session logged out; cache cleared. Re-run login.");
}
const formatted = formatError(err);
console.error(
danger(
`WhatsApp Web connection ended before fully opening. ${formatted}`,
),
);
throw new Error(formatted);
} finally {
setTimeout(() => {
try {
sock.ws?.close();
} catch {
// ignore
}
}, 500);
}
}
export { WA_WEB_AUTH_DIR };
export function webAuthExists() {
return fs
.access(WA_WEB_AUTH_DIR)
.then(() => true)
.catch(() => false);
}
type WebListenerCloseReason = {
status?: number;
isLoggedOut: boolean;
error?: unknown;
};
export type WebInboundMessage = {
id?: string;
from: string;
to: string;
body: string;
pushName?: string;
timestamp?: number;
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: {
verbose: boolean;
onMessage: (msg: WebInboundMessage) => Promise<void>;
}) {
const inboundLogger = getChildLogger({ module: "web-inbound" });
const sock = await createWaSocket(false, options.verbose);
await waitForWaConnection(sock);
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
onCloseResolve = resolve;
});
try {
// Advertise that the relay is online right after connecting.
await sock.sendPresenceUpdate("available");
if (isVerbose()) logVerbose("Sent global 'available' presence on connect");
} catch (err) {
logVerbose(
`Failed to send 'available' presence on connect: ${String(err)}`,
);
}
const selfJid = sock.user?.id;
const selfE164 = selfJid ? jidToE164(selfJid) : null;
const seen = new Set<string>();
sock.ev.on("messages.upsert", async (upsert) => {
if (upsert.type !== "notify") return;
for (const msg of upsert.messages) {
const id = msg.key?.id ?? undefined;
// De-dupe on message id; Baileys can emit retries.
if (id && seen.has(id)) continue;
if (id) seen.add(id);
if (msg.key?.fromMe) continue;
const remoteJid = msg.key?.remoteJid;
if (!remoteJid) continue;
// Ignore status/broadcast traffic; we only care about direct chats.
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
continue;
if (id) {
const participant = msg.key?.participant;
try {
await sock.readMessages([
{ remoteJid, id, participant, fromMe: false },
]);
if (isVerbose()) {
const suffix = participant ? ` (participant ${participant})` : "";
logVerbose(
`Marked message ${id} as read for ${remoteJid}${suffix}`,
);
}
} catch (err) {
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
}
}
const from = jidToE164(remoteJid);
if (!from) 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 {
await sock.sendPresenceUpdate("composing", chatJid);
} catch (err) {
logVerbose(`Presence update failed: ${String(err)}`);
}
};
const reply = async (text: string) => {
await sock.sendMessage(chatJid, { text });
};
const sendMedia = async (payload: {
image: Buffer;
caption?: string;
mimetype?: string;
}) => {
await sock.sendMessage(chatJid, payload);
};
const timestamp = msg.messageTimestamp
? Number(msg.messageTimestamp) * 1000
: undefined;
inboundLogger.info(
{
from,
to: selfE164 ?? "me",
body,
mediaPath,
mediaType,
timestamp,
},
"inbound message",
);
try {
await options.onMessage({
id,
from,
to: selfE164 ?? "me",
body,
pushName: msg.pushName ?? undefined,
timestamp,
sendComposing,
reply,
sendMedia,
mediaPath,
mediaType,
});
} catch (err) {
console.error(
danger(`Failed handling inbound web message: ${String(err)}`),
);
}
}
});
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
if (update.connection === "close") {
const status = getStatusCode(update.lastDisconnect?.error);
onCloseResolve?.({
status,
isLoggedOut: status === DisconnectReason.loggedOut,
error: update.lastDisconnect?.error,
});
}
},
);
return {
close: async () => {
try {
sock.ws?.close();
} catch (err) {
logVerbose(`Socket close failed: ${String(err)}`);
}
},
onClose,
};
}
export async function monitorWebProvider(
verbose: boolean,
listenerFactory = monitorWebInbox,
keepAlive = true,
replyResolver: typeof getReplyFromConfig = getReplyFromConfig,
runtime: RuntimeEnv = defaultRuntime,
abortSignal?: AbortSignal,
) {
const replyLogger = getChildLogger({ module: "web-auto-reply" });
const cfg = loadConfig();
const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb;
const maxMediaBytes =
typeof configuredMaxMb === "number" && configuredMaxMb > 0
? configuredMaxMb * 1024 * 1024
: DEFAULT_WEB_MEDIA_BYTES;
const stopRequested = () => abortSignal?.aborted === true;
const abortPromise =
abortSignal &&
new Promise<"aborted">((resolve) =>
abortSignal.addEventListener("abort", () => resolve("aborted"), {
once: true,
}),
);
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
while (true) {
if (stopRequested()) break;
const listener = await listenerFactory({
verbose,
onMessage: async (msg) => {
const ts = msg.timestamp
? new Date(msg.timestamp).toISOString()
: new Date().toISOString();
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
const replyStarted = Date.now();
const replyResult = await replyResolver(
{
Body: msg.body,
From: msg.from,
To: msg.to,
MessageSid: msg.id,
MediaPath: msg.mediaPath,
MediaUrl: msg.mediaUrl,
MediaType: msg.mediaType,
},
{
onReplyStart: msg.sendComposing,
},
);
if (
!replyResult ||
(!replyResult.text &&
!replyResult.mediaUrl &&
!replyResult.mediaUrls?.length)
) {
logVerbose(
"Skipping auto-reply: no text/media returned from resolver",
);
return;
}
try {
const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl
? [replyResult.mediaUrl]
: [];
if (mediaList.length > 0) {
logVerbose(
`Web auto-reply media detected: ${mediaList.filter(Boolean).join(", ")}`,
);
for (const [index, mediaUrl] of mediaList.entries()) {
try {
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
if (isVerbose()) {
logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
);
logVerbose(
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
);
}
const caption =
index === 0 ? replyResult.text || undefined : undefined;
if (media.kind === "image") {
await msg.sendMedia({
image: media.buffer,
caption,
mimetype: media.contentType,
});
} else if (media.kind === "audio") {
await msg.sendMedia({
audio: media.buffer,
ptt: true,
mimetype: media.contentType,
caption,
} as AnyMessageContent);
} else if (media.kind === "video") {
await msg.sendMedia({
video: media.buffer,
caption,
mimetype: media.contentType,
});
} else {
const fileName = mediaUrl.split("/").pop() ?? "file";
await msg.sendMedia({
document: media.buffer,
fileName,
caption,
mimetype: media.contentType,
} as AnyMessageContent);
}
logInfo(
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
runtime,
);
replyLogger.info(
{
to: msg.from,
from: msg.to,
text: index === 0 ? (replyResult.text ?? null) : null,
mediaUrl,
mediaSizeBytes: media.buffer.length,
mediaKind: media.kind,
durationMs: Date.now() - replyStarted,
},
"auto-reply sent (media)",
);
} catch (err) {
console.error(
danger(
`Failed sending web media to ${msg.from}: ${String(err)}`,
),
);
if (index === 0 && replyResult.text) {
console.log(
warn(`⚠️ Media skipped; sent text-only to ${msg.from}`),
);
await msg.reply(replyResult.text || "");
}
}
}
} else if (replyResult.text) {
await msg.reply(replyResult.text);
}
const durationMs = Date.now() - replyStarted;
const hasMedia = mediaList.length > 0;
if (isVerbose()) {
console.log(
success(
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${hasMedia ? ", media" : ""}, ${formatDuration(durationMs)})`,
),
);
} else {
console.log(
success(
`↩️ ${replyResult.text ?? "<media>"}${hasMedia ? " (media)" : ""}`,
),
);
}
replyLogger.info(
{
to: msg.from,
from: msg.to,
text: replyResult.text ?? null,
mediaUrl: mediaList[0] ?? null,
durationMs,
},
"auto-reply sent",
);
} catch (err) {
console.error(
danger(
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
),
);
}
},
});
logInfo(
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
runtime,
);
let stop = false;
process.on("SIGINT", () => {
stop = true;
void listener.close().finally(() => {
logInfo("👋 Web monitor stopped", runtime);
runtime.exit(0);
});
});
if (!keepAlive) return;
const reason = await Promise.race([
listener.onClose ?? waitForever(),
abortPromise ?? waitForever(),
]);
if (stopRequested() || stop || reason === "aborted") {
await listener.close();
break;
}
const status =
(typeof reason === "object" && reason && "status" in reason
? (reason as WebListenerCloseReason).status
: undefined) ?? "unknown";
const loggedOut =
typeof reason === "object" &&
reason &&
"isLoggedOut" in reason &&
(reason as WebListenerCloseReason).isLoggedOut;
if (loggedOut) {
runtime.error(
danger(
"WhatsApp session logged out. Run `warelay login --provider web` to relink.",
),
);
break;
}
runtime.error(
danger(
`WhatsApp Web connection closed (status ${status}). Reconnecting in 2s…`,
),
);
await listener.close();
await sleep(2_000);
}
}
function readWebSelfId() {
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
try {
if (!fsSync.existsSync(credsPath)) {
return { e164: null, jid: null };
}
const raw = fsSync.readFileSync(credsPath, "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
const jid = parsed?.me?.id ?? null;
const e164 = jid ? jidToE164(jid) : null;
return { e164, jid };
} catch {
return { e164: null, jid: null };
}
}
export function logWebSelfId(
runtime: RuntimeEnv = defaultRuntime,
includeProviderPrefix = false,
) {
// Human-friendly log of the currently linked personal web session.
const { e164, jid } = readWebSelfId();
const details =
e164 || jid
? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}`
: "unknown";
const prefix = includeProviderPrefix ? "Web Provider: " : "";
runtime.log(info(`${prefix}${details}`));
}
export async function pickProvider(pref: Provider | "auto"): Promise<Provider> {
// Auto-select web when logged in; otherwise fall back to twilio.
if (pref !== "auto") return pref;
const hasWeb = await webAuthExists();
if (hasWeb) return "web";
return "twilio";
}
function extractText(message: proto.IMessage | undefined): string | undefined {
if (!message) return undefined;
if (typeof message.conversation === "string" && message.conversation.trim()) {
return message.conversation.trim();
}
const extended = message.extendedTextMessage?.text;
if (extended?.trim()) return extended.trim();
const caption =
message.imageMessage?.caption ?? message.videoMessage?.caption;
if (caption?.trim()) return caption.trim();
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 ??
undefined;
if (
!message.imageMessage &&
!message.videoMessage &&
!message.documentMessage &&
!message.audioMessage &&
!message.stickerMessage
) {
return undefined;
}
try {
const buffer = (await downloadMediaMessage(
msg as WAMessage,
"buffer",
{},
{
reuploadRequest: sock.updateMediaMessage,
logger: sock.logger,
},
)) as Buffer;
return { buffer, mimetype };
} catch (err) {
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
return undefined;
}
}
async function loadWebMedia(
mediaUrl: string,
maxBytes?: number,
): Promise<{ buffer: Buffer; contentType?: string; kind: MediaKind }> {
if (mediaUrl.startsWith("file://")) {
mediaUrl = mediaUrl.replace("file://", "");
}
const optimizeAndClampImage = async (buffer: Buffer, cap: number) => {
const originalSize = buffer.length;
const optimized = await optimizeImageToJpeg(buffer, cap);
if (optimized.optimizedSize < originalSize && isVerbose()) {
logVerbose(
`Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`,
);
}
if (optimized.buffer.length > cap) {
throw new Error(
`Media could not be reduced below ${(maxBytes / (1024 * 1024)).toFixed(0)}MB (got ${(
optimized.buffer.length / (1024 * 1024)
).toFixed(2)}MB)`,
);
}
return {
buffer: optimized.buffer,
contentType: "image/jpeg",
kind: "image" as const,
};
};
if (/^https?:\/\//i.test(mediaUrl)) {
const res = await fetch(mediaUrl);
if (!res.ok || !res.body) {
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
}
const array = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type");
const kind = mediaKindFromMime(contentType);
const cap = Math.min(
maxBytes ?? maxBytesForKind(kind),
maxBytesForKind(kind),
);
if (kind === "image") {
return optimizeAndClampImage(array, cap);
}
if (array.length > cap) {
throw new Error(
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
array.length / (1024 * 1024)
).toFixed(2)}MB)`,
);
}
return { buffer: array, contentType: contentType ?? undefined, kind };
}
// Local path
const data = await fs.readFile(mediaUrl);
const ext = path.extname(mediaUrl);
const mime =
(ext &&
(
{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".pdf": "application/pdf",
} as Record<string, string | undefined>
)[ext.toLowerCase()]) ??
undefined;
const kind = mediaKindFromMime(mime);
const cap = Math.min(
maxBytes ?? maxBytesForKind(kind),
maxBytesForKind(kind),
);
if (kind === "image") {
return optimizeAndClampImage(data, cap);
}
if (data.length > cap) {
throw new Error(
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
data.length / (1024 * 1024)
).toFixed(2)}MB)`,
);
}
return { buffer: data, contentType: mime, kind };
}
function getStatusCode(err: unknown) {
return (
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status
);
}
function formatError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
const status = getStatusCode(err);
const code = (err as { code?: unknown })?.code;
if (status || code)
return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
return String(err);
}
async function optimizeImageToJpeg(
buffer: Buffer,
maxBytes: number,
): Promise<{
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
quality: number;
}> {
// Try a grid of sizes/qualities until under the limit.
const sides = [2048, 1536, 1280, 1024, 800];
const qualities = [80, 70, 60, 50, 40];
let smallest: {
buffer: Buffer;
size: number;
resizeSide: number;
quality: number;
} | null = null;
for (const side of sides) {
for (const quality of qualities) {
const out = await sharp(buffer)
.resize({
width: side,
height: side,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality, mozjpeg: true })
.toBuffer();
const size = out.length;
if (!smallest || size < smallest.size) {
smallest = { buffer: out, size, resizeSide: side, quality };
}
if (size <= maxBytes) {
return {
buffer: out,
optimizedSize: size,
resizeSide: side,
quality,
};
}
}
}
if (smallest) {
return {
buffer: smallest.buffer,
optimizedSize: smallest.size,
resizeSide: smallest.resizeSide,
quality: smallest.quality,
};
}
throw new Error("Failed to optimize image");
}
// Barrel exports for the web provider pieces. Splitting the original 900+ line
// module keeps responsibilities small and testable without changing the public API.
export {
DEFAULT_WEB_MEDIA_BYTES,
monitorWebProvider,
} from "./web/auto-reply.js";
export {
extractMediaPlaceholder,
extractText,
monitorWebInbox,
type WebInboundMessage,
type WebListenerCloseReason,
} from "./web/inbound.js";
export { loginWeb } from "./web/login.js";
export { loadWebMedia, optimizeImageToJpeg } from "./web/media.js";
export { sendMessageWeb } from "./web/outbound.js";
export {
createWaSocket,
formatError,
getStatusCode,
logWebSelfId,
pickProvider,
WA_WEB_AUTH_DIR,
waitForWaConnection,
webAuthExists,
} from "./web/session.js";

256
src/web/auto-reply.ts Normal file
View File

@@ -0,0 +1,256 @@
import { getReplyFromConfig } from "../auto-reply/reply.js";
import { waitForever } from "../cli/wait.js";
import { loadConfig } from "../config/config.js";
import { danger, isVerbose, logVerbose, success } from "../globals.js";
import { logInfo } from "../logger.js";
import { getChildLogger } from "../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { monitorWebInbox } from "./inbound.js";
import { loadWebMedia } from "./media.js";
const DEFAULT_WEB_MEDIA_BYTES = 5 * 1024 * 1024;
const formatDuration = (ms: number) =>
ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`;
export async function monitorWebProvider(
verbose: boolean,
listenerFactory = monitorWebInbox,
keepAlive = true,
replyResolver: typeof getReplyFromConfig = getReplyFromConfig,
runtime: RuntimeEnv = defaultRuntime,
abortSignal?: AbortSignal,
) {
const replyLogger = getChildLogger({ module: "web-auto-reply" });
const cfg = loadConfig();
const configuredMaxMb = cfg.inbound?.reply?.mediaMaxMb;
const maxMediaBytes =
typeof configuredMaxMb === "number" && configuredMaxMb > 0
? configuredMaxMb * 1024 * 1024
: DEFAULT_WEB_MEDIA_BYTES;
const stopRequested = () => abortSignal?.aborted === true;
const abortPromise =
abortSignal &&
new Promise<"aborted">((resolve) =>
abortSignal.addEventListener("abort", () => resolve("aborted"), {
once: true,
}),
);
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
while (true) {
if (stopRequested()) break;
const listener = await listenerFactory({
verbose,
onMessage: async (msg) => {
const ts = msg.timestamp
? new Date(msg.timestamp).toISOString()
: new Date().toISOString();
console.log(`\n[${ts}] ${msg.from} -> ${msg.to}: ${msg.body}`);
const replyStarted = Date.now();
const replyResult = await replyResolver(
{
Body: msg.body,
From: msg.from,
To: msg.to,
MessageSid: msg.id,
MediaPath: msg.mediaPath,
MediaUrl: msg.mediaUrl,
MediaType: msg.mediaType,
},
{
onReplyStart: msg.sendComposing,
},
);
if (
!replyResult ||
(!replyResult.text &&
!replyResult.mediaUrl &&
!replyResult.mediaUrls?.length)
) {
logVerbose(
"Skipping auto-reply: no text/media returned from resolver",
);
return;
}
try {
const mediaList = replyResult.mediaUrls?.length
? replyResult.mediaUrls
: replyResult.mediaUrl
? [replyResult.mediaUrl]
: [];
if (mediaList.length > 0) {
logVerbose(
`Web auto-reply media detected: ${mediaList.filter(Boolean).join(", ")}`,
);
for (const [index, mediaUrl] of mediaList.entries()) {
try {
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
if (isVerbose()) {
logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,
);
logVerbose(
`Web auto-reply media source: ${mediaUrl} (kind ${media.kind})`,
);
}
const caption =
index === 0 ? replyResult.text || undefined : undefined;
if (media.kind === "image") {
await msg.sendMedia({
image: media.buffer,
caption,
mimetype: media.contentType,
});
} else if (media.kind === "audio") {
await msg.sendMedia({
audio: media.buffer,
ptt: true,
mimetype: media.contentType,
caption,
});
} else if (media.kind === "video") {
await msg.sendMedia({
video: media.buffer,
caption,
mimetype: media.contentType,
});
} else {
const fileName = mediaUrl.split("/").pop() ?? "file";
const mimetype = media.contentType ?? "application/octet-stream";
await msg.sendMedia({
document: media.buffer,
fileName,
caption,
mimetype,
});
}
logInfo(
`✅ Sent web media reply to ${msg.from} (${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB)`,
runtime,
);
replyLogger.info(
{
to: msg.from,
from: msg.to,
text: index === 0 ? (replyResult.text ?? null) : null,
mediaUrl,
mediaSizeBytes: media.buffer.length,
mediaKind: media.kind,
durationMs: Date.now() - replyStarted,
},
"auto-reply sent (media)",
);
} catch (err) {
console.error(
danger(
`Failed sending web media to ${msg.from}: ${String(err)}`,
),
);
if (index === 0 && replyResult.text) {
console.log(
`⚠️ Media skipped; sent text-only to ${msg.from}`,
);
await msg.reply(replyResult.text || "");
}
}
}
} else if (replyResult.text) {
await msg.reply(replyResult.text);
}
const durationMs = Date.now() - replyStarted;
const hasMedia = mediaList.length > 0;
if (isVerbose()) {
console.log(
success(
`↩️ Auto-replied to ${msg.from} (web, ${replyResult.text?.length ?? 0} chars${hasMedia ? ", media" : ""}, ${formatDuration(durationMs)})`,
),
);
} else {
console.log(
success(
`↩️ ${replyResult.text ?? "<media>"}${hasMedia ? " (media)" : ""}`,
),
);
}
replyLogger.info(
{
to: msg.from,
from: msg.to,
text: replyResult.text ?? null,
mediaUrl: mediaList[0] ?? null,
durationMs,
},
"auto-reply sent",
);
} catch (err) {
console.error(
danger(
`Failed sending web auto-reply to ${msg.from}: ${String(err)}`,
),
);
}
},
});
logInfo(
"📡 Listening for personal WhatsApp Web inbound messages. Leave this running; Ctrl+C to stop.",
runtime,
);
let stop = false;
process.on("SIGINT", () => {
stop = true;
void listener.close().finally(() => {
logInfo("👋 Web monitor stopped", runtime);
runtime.exit(0);
});
});
if (!keepAlive) return;
const reason = await Promise.race([
listener.onClose ?? waitForever(),
abortPromise ?? waitForever(),
]);
if (stopRequested() || stop || reason === "aborted") {
await listener.close();
break;
}
const status =
(typeof reason === "object" && reason && "status" in reason
? (reason as { status?: number }).status
: undefined) ?? "unknown";
const loggedOut =
typeof reason === "object" &&
reason &&
"isLoggedOut" in reason &&
(reason as { isLoggedOut?: boolean }).isLoggedOut;
if (loggedOut) {
runtime.error(
danger(
"WhatsApp session logged out. Run `warelay login --provider web` to relink.",
),
);
break;
}
runtime.error(
danger(
`WhatsApp Web connection closed (status ${status}). Reconnecting in 2s…`,
),
);
await listener.close();
await sleep(2_000);
}
}
export { DEFAULT_WEB_MEDIA_BYTES };

32
src/web/inbound.test.ts Normal file
View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { extractMediaPlaceholder, extractText } from "./inbound.js";
describe("web inbound helpers", () => {
it("prefers the main conversation body", () => {
const body = extractText({
conversation: " hello ",
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
expect(body).toBe("hello");
});
it("falls back to captions when conversation text is missing", () => {
const body = extractText({
imageMessage: { caption: " caption " },
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
expect(body).toBe("caption");
});
it("returns placeholders for media-only payloads", () => {
expect(
extractMediaPlaceholder({
imageMessage: {},
} as unknown as import("@whiskeysockets/baileys").proto.IMessage),
).toBe("<media:image>");
expect(
extractMediaPlaceholder({
audioMessage: {},
} as unknown as import("@whiskeysockets/baileys").proto.IMessage),
).toBe("<media:audio>");
});
});

255
src/web/inbound.ts Normal file
View File

@@ -0,0 +1,255 @@
import type {
AnyMessageContent,
proto,
WAMessage,
} from "@whiskeysockets/baileys";
import {
DisconnectReason,
downloadMediaMessage,
} from "@whiskeysockets/baileys";
import { isVerbose, logVerbose } from "../globals.js";
import { getChildLogger } from "../logging.js";
import { saveMediaBuffer } from "../media/store.js";
import { jidToE164 } from "../utils.js";
import {
createWaSocket,
getStatusCode,
waitForWaConnection,
} from "./session.js";
export type WebListenerCloseReason = {
status?: number;
isLoggedOut: boolean;
error?: unknown;
};
export type WebInboundMessage = {
id?: string;
from: string;
to: string;
body: string;
pushName?: string;
timestamp?: number;
sendComposing: () => Promise<void>;
reply: (text: string) => Promise<void>;
sendMedia: (payload: AnyMessageContent) => Promise<void>;
mediaPath?: string;
mediaType?: string;
mediaUrl?: string;
};
export async function monitorWebInbox(options: {
verbose: boolean;
onMessage: (msg: WebInboundMessage) => Promise<void>;
}) {
const inboundLogger = getChildLogger({ module: "web-inbound" });
const sock = await createWaSocket(false, options.verbose);
await waitForWaConnection(sock);
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
onCloseResolve = resolve;
});
try {
// Advertise that the relay is online right after connecting.
await sock.sendPresenceUpdate("available");
if (isVerbose()) logVerbose("Sent global 'available' presence on connect");
} catch (err) {
logVerbose(
`Failed to send 'available' presence on connect: ${String(err)}`,
);
}
const selfJid = sock.user?.id;
const selfE164 = selfJid ? jidToE164(selfJid) : null;
const seen = new Set<string>();
sock.ev.on("messages.upsert", async (upsert) => {
if (upsert.type !== "notify") return;
for (const msg of upsert.messages) {
const id = msg.key?.id ?? undefined;
// De-dupe on message id; Baileys can emit retries.
if (id && seen.has(id)) continue;
if (id) seen.add(id);
if (msg.key?.fromMe) continue;
const remoteJid = msg.key?.remoteJid;
if (!remoteJid) continue;
// Ignore status/broadcast traffic; we only care about direct chats.
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast"))
continue;
if (id) {
const participant = msg.key?.participant;
try {
await sock.readMessages([
{ remoteJid, id, participant, fromMe: false },
]);
if (isVerbose()) {
const suffix = participant ? ` (participant ${participant})` : "";
logVerbose(
`Marked message ${id} as read for ${remoteJid}${suffix}`,
);
}
} catch (err) {
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
}
}
const from = jidToE164(remoteJid);
if (!from) 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 {
await sock.sendPresenceUpdate("composing", chatJid);
} catch (err) {
logVerbose(`Presence update failed: ${String(err)}`);
}
};
const reply = async (text: string) => {
await sock.sendMessage(chatJid, { text });
};
const sendMedia = async (payload: AnyMessageContent) => {
await sock.sendMessage(chatJid, payload);
};
const timestamp = msg.messageTimestamp
? Number(msg.messageTimestamp) * 1000
: undefined;
inboundLogger.info(
{
from,
to: selfE164 ?? "me",
body,
mediaPath,
mediaType,
timestamp,
},
"inbound message",
);
try {
await options.onMessage({
id,
from,
to: selfE164 ?? "me",
body,
pushName: msg.pushName ?? undefined,
timestamp,
sendComposing,
reply,
sendMedia,
mediaPath,
mediaType,
});
} catch (err) {
console.error("Failed handling inbound web message:", String(err));
}
}
});
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
if (update.connection === "close") {
const status = getStatusCode(update.lastDisconnect?.error);
onCloseResolve?.({
status,
isLoggedOut: status === DisconnectReason.loggedOut,
error: update.lastDisconnect?.error,
});
}
},
);
return {
close: async () => {
try {
sock.ws?.close();
} catch (err) {
logVerbose(`Socket close failed: ${String(err)}`);
}
},
onClose,
} as const;
}
export function extractText(
message: proto.IMessage | undefined,
): string | undefined {
if (!message) return undefined;
if (typeof message.conversation === "string" && message.conversation.trim()) {
return message.conversation.trim();
}
const extended = message.extendedTextMessage?.text;
if (extended?.trim()) return extended.trim();
const caption =
message.imageMessage?.caption ?? message.videoMessage?.caption;
if (caption?.trim()) return caption.trim();
return undefined;
}
export 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: Awaited<ReturnType<typeof createWaSocket>>,
): 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 ??
undefined;
if (
!message.imageMessage &&
!message.videoMessage &&
!message.documentMessage &&
!message.audioMessage &&
!message.stickerMessage
) {
return undefined;
}
try {
const buffer = (await downloadMediaMessage(
msg as WAMessage,
"buffer",
{},
{
reuploadRequest: sock.updateMediaMessage,
logger: sock.logger,
},
)) as Buffer;
return { buffer, mimetype };
} catch (err) {
logVerbose(`downloadMediaMessage failed: ${String(err)}`);
return undefined;
}
}

80
src/web/login.ts Normal file
View File

@@ -0,0 +1,80 @@
import fs from "node:fs/promises";
import { DisconnectReason } from "@whiskeysockets/baileys";
import { danger, info, success } from "../globals.js";
import { logInfo } from "../logger.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import {
createWaSocket,
formatError,
WA_WEB_AUTH_DIR,
waitForWaConnection,
} from "./session.js";
export async function loginWeb(
verbose: boolean,
waitForConnection: typeof waitForWaConnection = waitForWaConnection,
runtime: RuntimeEnv = defaultRuntime,
) {
const sock = await createWaSocket(true, verbose);
logInfo("Waiting for WhatsApp connection...", runtime);
try {
await waitForConnection(sock);
console.log(success("✅ Linked! Credentials saved for future sends."));
} catch (err) {
const code =
(err as { error?: { output?: { statusCode?: number } } })?.error?.output
?.statusCode ??
(err as { output?: { statusCode?: number } })?.output?.statusCode;
if (code === 515) {
console.log(
info(
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
),
);
try {
sock.ws?.close();
} catch {
// ignore
}
const retry = await createWaSocket(false, verbose);
try {
await waitForConnection(retry);
console.log(
success(
"✅ Linked after restart; web session ready. You can now send with provider=web.",
),
);
return;
} finally {
setTimeout(() => retry.ws?.close(), 500);
}
}
if (code === DisconnectReason.loggedOut) {
await fs.rm(WA_WEB_AUTH_DIR, { recursive: true, force: true });
console.error(
danger(
"WhatsApp reported the session is logged out. Cleared cached web session; please rerun warelay login and scan the QR again.",
),
);
throw new Error("Session logged out; cache cleared. Re-run login.");
}
const formatted = formatError(err);
console.error(
danger(
`WhatsApp Web connection ended before fully opening. ${formatted}`,
),
);
throw new Error(formatted);
} finally {
// Let Baileys flush any final events before closing the socket.
setTimeout(() => {
try {
sock.ws?.close();
} catch {
// ignore
}
}, 500);
}
}

41
src/web/media.test.ts Normal file
View File

@@ -0,0 +1,41 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import { afterEach, describe, expect, it } from "vitest";
import { loadWebMedia } from "./media.js";
const tmpFiles: string[] = [];
afterEach(async () => {
await Promise.all(tmpFiles.map((file) => fs.rm(file, { force: true })));
tmpFiles.length = 0;
});
describe("web media loading", () => {
it("compresses large local images under the provided cap", async () => {
const buffer = await sharp({
create: {
width: 1600,
height: 1600,
channels: 3,
background: "#ff0000",
},
})
.jpeg({ quality: 95 })
.toBuffer();
const file = path.join(os.tmpdir(), `warelay-media-${Date.now()}.jpg`);
tmpFiles.push(file);
await fs.writeFile(file, buffer);
const cap = Math.floor(buffer.length * 0.8);
const result = await loadWebMedia(file, cap);
expect(result.kind).toBe("image");
expect(result.buffer.length).toBeLessThanOrEqual(cap);
expect(result.buffer.length).toBeLessThan(buffer.length);
});
});

160
src/web/media.ts Normal file
View File

@@ -0,0 +1,160 @@
import fs from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
import { isVerbose, logVerbose } from "../globals.js";
import {
type MediaKind,
maxBytesForKind,
mediaKindFromMime,
} from "../media/constants.js";
export async function loadWebMedia(
mediaUrl: string,
maxBytes?: number,
): Promise<{ buffer: Buffer; contentType?: string; kind: MediaKind }> {
if (mediaUrl.startsWith("file://")) {
mediaUrl = mediaUrl.replace("file://", "");
}
const optimizeAndClampImage = async (buffer: Buffer, cap: number) => {
const originalSize = buffer.length;
const optimized = await optimizeImageToJpeg(buffer, cap);
if (optimized.optimizedSize < originalSize && isVerbose()) {
logVerbose(
`Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`,
);
}
if (optimized.buffer.length > cap) {
throw new Error(
`Media could not be reduced below ${(cap / (1024 * 1024)).toFixed(0)}MB (got ${(
optimized.buffer.length / (1024 * 1024)
).toFixed(2)}MB)`,
);
}
return {
buffer: optimized.buffer,
contentType: "image/jpeg",
kind: "image" as const,
};
};
if (/^https?:\/\//i.test(mediaUrl)) {
const res = await fetch(mediaUrl);
if (!res.ok || !res.body) {
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
}
const array = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type");
const kind = mediaKindFromMime(contentType);
const cap = Math.min(
maxBytes ?? maxBytesForKind(kind),
maxBytesForKind(kind),
);
if (kind === "image") {
return optimizeAndClampImage(array, cap);
}
if (array.length > cap) {
throw new Error(
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
array.length / (1024 * 1024)
).toFixed(2)}MB)`,
);
}
return { buffer: array, contentType: contentType ?? undefined, kind };
}
// Local path
const data = await fs.readFile(mediaUrl);
const ext = path.extname(mediaUrl);
const mime =
(ext &&
(
{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".pdf": "application/pdf",
} as Record<string, string | undefined>
)[ext.toLowerCase()]) ??
undefined;
const kind = mediaKindFromMime(mime);
const cap = Math.min(
maxBytes ?? maxBytesForKind(kind),
maxBytesForKind(kind),
);
if (kind === "image") {
return optimizeAndClampImage(data, cap);
}
if (data.length > cap) {
throw new Error(
`Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${(
data.length / (1024 * 1024)
).toFixed(2)}MB)`,
);
}
return { buffer: data, contentType: mime, kind };
}
export async function optimizeImageToJpeg(
buffer: Buffer,
maxBytes: number,
): Promise<{
buffer: Buffer;
optimizedSize: number;
resizeSide: number;
quality: number;
}> {
// Try a grid of sizes/qualities until under the limit.
const sides = [2048, 1536, 1280, 1024, 800];
const qualities = [80, 70, 60, 50, 40];
let smallest: {
buffer: Buffer;
size: number;
resizeSide: number;
quality: number;
} | null = null;
for (const side of sides) {
for (const quality of qualities) {
const out = await sharp(buffer)
.resize({
width: side,
height: side,
fit: "inside",
withoutEnlargement: true,
})
.jpeg({ quality, mozjpeg: true })
.toBuffer();
const size = out.length;
if (!smallest || size < smallest.size) {
smallest = { buffer: out, size, resizeSide: side, quality };
}
if (size <= maxBytes) {
return {
buffer: out,
optimizedSize: size,
resizeSide: side,
quality,
};
}
}
}
if (smallest) {
return {
buffer: smallest.buffer,
optimizedSize: smallest.size,
resizeSide: smallest.resizeSide,
quality: smallest.quality,
};
}
throw new Error("Failed to optimize image");
}

50
src/web/outbound.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { AnyMessageContent } from "@whiskeysockets/baileys";
import { logVerbose } from "../globals.js";
import { logInfo } from "../logger.js";
import { toWhatsappJid } from "../utils.js";
import { loadWebMedia } from "./media.js";
import { createWaSocket, waitForWaConnection } from "./session.js";
export async function sendMessageWeb(
to: string,
body: string,
options: { verbose: boolean; mediaUrl?: string },
): Promise<{ messageId: string; toJid: string }> {
const sock = await createWaSocket(false, options.verbose);
try {
logInfo("🔌 Connecting to WhatsApp Web…");
await waitForWaConnection(sock);
// waitForWaConnection sets up listeners and error handling; keep the presence update safe.
const jid = toWhatsappJid(to);
try {
await sock.sendPresenceUpdate("composing", jid);
} catch (err) {
logVerbose(`Presence update skipped: ${String(err)}`);
}
let payload: AnyMessageContent = { text: body };
if (options.mediaUrl) {
const media = await loadWebMedia(options.mediaUrl);
payload = {
image: media.buffer,
caption: body || undefined,
mimetype: media.contentType,
};
}
logInfo(
`📤 Sending via web session -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
);
const result = await sock.sendMessage(jid, payload);
const messageId = result?.key?.id ?? "unknown";
logInfo(
`✅ Sent via web session. Message ID: ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
);
return { messageId, toJid: jid };
} finally {
try {
sock.ws?.close();
} catch (err) {
logVerbose(`Socket close failed: ${String(err)}`);
}
}
}

173
src/web/session.ts Normal file
View File

@@ -0,0 +1,173 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
makeWASocket,
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import qrcode from "qrcode-terminal";
import { danger, info, success } from "../globals.js";
import { getChildLogger } from "../logging.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import type { Provider } from "../utils.js";
import { ensureDir, jidToE164 } from "../utils.js";
import { VERSION } from "../version.js";
export const WA_WEB_AUTH_DIR = path.join(
os.homedir(),
".warelay",
"credentials",
);
/**
* Create a Baileys socket backed by the multi-file auth store we keep on disk.
* Consumers can opt into QR printing for interactive login flows.
*/
export async function createWaSocket(printQr: boolean, verbose: boolean) {
const logger = getChildLogger(
{ module: "baileys" },
{
level: verbose ? "info" : "silent",
},
);
// Some Baileys internals call logger.trace even when silent; ensure it's present.
const loggerAny = logger as unknown as Record<string, unknown>;
if (typeof loggerAny.trace !== "function") {
loggerAny.trace = () => {};
}
await ensureDir(WA_WEB_AUTH_DIR);
const { state, saveCreds } = await useMultiFileAuthState(WA_WEB_AUTH_DIR);
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ["warelay", "cli", VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
sock.ev.on("creds.update", saveCreds);
sock.ev.on(
"connection.update",
(update: Partial<import("@whiskeysockets/baileys").ConnectionState>) => {
const { connection, lastDisconnect, qr } = update;
if (qr && printQr) {
console.log("Scan this QR in WhatsApp (Linked Devices):");
qrcode.generate(qr, { small: true });
}
if (connection === "close") {
const status = getStatusCode(lastDisconnect?.error);
if (status === DisconnectReason.loggedOut) {
console.error(
danger("WhatsApp session logged out. Run: warelay login"),
);
}
}
if (connection === "open" && verbose) {
console.log(success("WhatsApp Web connected."));
}
},
);
return sock;
}
export async function waitForWaConnection(
sock: ReturnType<typeof makeWASocket>,
) {
return new Promise<void>((resolve, reject) => {
type OffCapable = {
off?: (event: string, listener: (...args: unknown[]) => void) => void;
};
const evWithOff = sock.ev as unknown as OffCapable;
const handler = (...args: unknown[]) => {
const update = (args[0] ?? {}) as Partial<
import("@whiskeysockets/baileys").ConnectionState
>;
if (update.connection === "open") {
evWithOff.off?.("connection.update", handler);
resolve();
}
if (update.connection === "close") {
evWithOff.off?.("connection.update", handler);
reject(update.lastDisconnect ?? new Error("Connection closed"));
}
};
sock.ev.on("connection.update", handler);
});
}
export function getStatusCode(err: unknown) {
return (
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status
);
}
export function formatError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
const status = getStatusCode(err);
const code = (err as { code?: unknown })?.code;
if (status || code)
return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
return String(err);
}
export async function webAuthExists() {
return fs
.access(WA_WEB_AUTH_DIR)
.then(() => true)
.catch(() => false);
}
function readWebSelfId() {
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
const credsPath = path.join(WA_WEB_AUTH_DIR, "creds.json");
try {
if (!fsSync.existsSync(credsPath)) {
return { e164: null, jid: null } as const;
}
const raw = fsSync.readFileSync(credsPath, "utf-8");
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
const jid = parsed?.me?.id ?? null;
const e164 = jid ? jidToE164(jid) : null;
return { e164, jid } as const;
} catch {
return { e164: null, jid: null } as const;
}
}
export function logWebSelfId(
runtime: RuntimeEnv = defaultRuntime,
includeProviderPrefix = false,
) {
// Human-friendly log of the currently linked personal web session.
const { e164, jid } = readWebSelfId();
const details =
e164 || jid
? `${e164 ?? "unknown"}${jid ? ` (jid ${jid})` : ""}`
: "unknown";
const prefix = includeProviderPrefix ? "Web Provider: " : "";
runtime.log(info(`${prefix}${details}`));
}
export async function pickProvider(pref: Provider | "auto"): Promise<Provider> {
// Auto-select web when logged in; otherwise fall back to twilio.
if (pref !== "auto") return pref;
const hasWeb = await webAuthExists();
if (hasWeb) return "web";
return "twilio";
}