Add web provider inbound monitor with auto-replies

This commit is contained in:
Peter Steinberger
2025-11-24 18:33:50 +01:00
parent 5ee4f3219d
commit 9b4dceecfe
4 changed files with 474 additions and 48 deletions

View File

@@ -1,5 +1,6 @@
import path from "node:path";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
DisconnectReason,
fetchLatestBaileysVersion,
@@ -7,10 +8,11 @@ import {
makeWASocket,
useMultiFileAuthState,
} from "baileys";
import type { proto } from "baileys";
import pino from "pino";
import qrcode from "qrcode-terminal";
import { danger, info, logVerbose, success } from "./globals.js";
import { ensureDir, toWhatsappJid } from "./utils.js";
import { ensureDir, jidToE164, toWhatsappJid } from "./utils.js";
const WA_WEB_AUTH_DIR = path.join(os.homedir(), ".warelay", "waweb");
@@ -25,6 +27,7 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ["Warelay", "CLI", "1.0.0"],
syncFullHistory: false,
@@ -32,25 +35,27 @@ export async function createWaSocket(printQr: boolean, verbose: boolean) {
});
sock.ev.on("creds.update", saveCreds);
sock.ev.on("connection.update", (update: Partial<import("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 code = (lastDisconnect?.error as { output?: { statusCode?: number } })
?.output?.statusCode;
if (code === DisconnectReason.loggedOut) {
console.error(
danger("WhatsApp session logged out. Run: warelay web:login"),
);
sock.ev.on(
"connection.update",
(update: Partial<import("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 === "open" && verbose) {
console.log(success("WhatsApp Web connected."));
}
});
if (connection === "close") {
const status = getStatusCode(lastDisconnect?.error);
if (status === DisconnectReason.loggedOut) {
console.error(
danger("WhatsApp session logged out. Run: warelay web:login"),
);
}
}
if (connection === "open" && verbose) {
console.log(success("WhatsApp Web connected."));
}
},
);
return sock;
}
@@ -104,12 +109,44 @@ export async function sendMessageWeb(
}
}
export async function loginWeb(verbose: boolean) {
export async function loginWeb(
verbose: boolean,
waitForConnection: typeof waitForWaConnection = waitForWaConnection,
) {
const sock = await createWaSocket(true, verbose);
console.log(info("Waiting for WhatsApp connection..."));
try {
await waitForWaConnection(sock);
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. You can now send with provider=web.",
),
);
return;
}
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 web:login and scan the QR again.",
),
);
throw new Error("Session logged out; cache cleared. Re-run web:login.");
}
const formatted = formatError(err);
console.error(
danger(
`WhatsApp Web connection ended before fully opening. ${formatted}`,
),
);
throw new Error(formatted);
} finally {
setTimeout(() => {
try {
@@ -122,3 +159,111 @@ export async function loginWeb(verbose: boolean) {
}
export { WA_WEB_AUTH_DIR };
export type WebInboundMessage = {
id?: string;
from: string;
to: string;
body: string;
pushName?: string;
timestamp?: number;
sendComposing: () => Promise<void>;
reply: (text: string) => Promise<void>;
};
export async function monitorWebInbox(options: {
verbose: boolean;
onMessage: (msg: WebInboundMessage) => Promise<void>;
}) {
const sock = await createWaSocket(false, options.verbose);
await waitForWaConnection(sock);
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;
const from = jidToE164(remoteJid);
if (!from) continue;
const body = extractText(msg.message);
if (!body) continue;
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 timestamp = msg.messageTimestamp
? Number(msg.messageTimestamp) * 1000
: undefined;
try {
await options.onMessage({
id,
from,
to: selfE164 ?? "me",
body,
pushName: msg.pushName ?? undefined,
timestamp,
sendComposing,
reply,
});
} catch (err) {
console.error(danger(`Failed handling inbound web message: ${String(err)}`));
}
}
});
return {
close: async () => {
try {
sock.ws?.close();
} catch (err) {
logVerbose(`Socket close failed: ${String(err)}`);
}
},
};
}
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 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);
}