/** * WhatsApp notification service using Baileys (WhatsApp Web unofficial API) * Manages one WhatsApp session per clinic (identified by clinicId). * Sessions are stored in $WHATSAPP_SESSION_DIR//, which defaults * to /app/data/whatsapp-sessions inside the container so credentials survive * container restarts (the path must live on a persistent Docker volume). * * ⚠️ Disclaimer: Baileys uses the WhatsApp Web protocol which is unofficial. * Use at low volume (< 500 msg/day) to minimise ban risk. */ import makeWASocket, { DisconnectReason, fetchLatestBaileysVersion, makeCacheableSignalKeyStore, useMultiFileAuthState, } from "@whiskeysockets/baileys"; import { Boom } from "@hapi/boom"; import * as fs from "fs"; import * as path from "path"; import pino from "pino"; import { EventEmitter } from "events"; import PQueue from "p-queue"; import { insertWhatsAppLog, maskPhone } from "../db.js"; import { childLogger } from "../_core/logger.js"; const waLog = childLogger("whatsapp"); // ─── Types ──────────────────────────────────────────────────────────────────── export type WAStatus = "disconnected" | "connecting" | "qr_ready" | "connected"; interface WASession { socket: ReturnType | null; status: WAStatus; qrCode: string | null; // base64 QR image data URL qrRaw: string | null; // raw QR string for display retryCount: number; events: EventEmitter; } // ─── In-memory session store ────────────────────────────────────────────────── const sessions = new Map(); // ─── Rate limiting : une queue p-queue par cabinet ─────────────────────────── // Intervalle de 2.5s entre messages pour éviter les bans WhatsApp const messageQueues = new Map(); function getMessageQueue(clinicId: number): PQueue { if (!messageQueues.has(clinicId)) { messageQueues.set(clinicId, new PQueue({ concurrency: 1, interval: 2500, intervalCap: 1, })); } return messageQueues.get(clinicId)!; } const SESSION_DIR = process.env.WHATSAPP_SESSION_DIR ?? "/app/data/whatsapp-sessions"; try { fs.mkdirSync(SESSION_DIR, { recursive: true }); } catch (err) { waLog.error({ err, sessionDir: SESSION_DIR }, "failed to create session dir"); } // Silent logger to avoid spamming server logs const logger = pino({ level: "silent" }); // ─── Helpers ────────────────────────────────────────────────────────────────── function sessionPath(clinicId: number) { return path.join(SESSION_DIR, String(clinicId)); } function getOrCreateSession(clinicId: number): WASession { if (!sessions.has(clinicId)) { sessions.set(clinicId, { socket: null, status: "disconnected", qrCode: null, qrRaw: null, retryCount: 0, events: new EventEmitter(), }); } return sessions.get(clinicId)!; } // ─── QR code as base64 data URL ─────────────────────────────────────────────── async function qrToDataUrl(qrString: string): Promise { // Dynamically import qrcode to avoid circular deps const QRCode = await import("qrcode"); return QRCode.default.toDataURL(qrString, { width: 300, margin: 2, color: { dark: "#128C7E", light: "#FFFFFF" }, }); } // ─── Connect / start session ────────────────────────────────────────────────── export async function connectWhatsApp(clinicId: number): Promise { const session = getOrCreateSession(clinicId); if (session.status === "connected" || session.status === "connecting") return; session.status = "connecting"; session.qrCode = null; session.qrRaw = null; const authDir = sessionPath(clinicId); fs.mkdirSync(authDir, { recursive: true }); const { state, saveCreds } = await useMultiFileAuthState(authDir); const { version } = await fetchLatestBaileysVersion(); const sock = makeWASocket({ version, logger, auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger), }, printQRInTerminal: false, generateHighQualityLinkPreview: false, browser: ["Salle d'attente", "Chrome", "1.0.0"], }); session.socket = sock; // ── QR code event ────────────────────────────────────────────────────────── sock.ev.on("connection.update", async (update) => { const { connection, lastDisconnect, qr } = update; if (qr) { session.qrRaw = qr; session.status = "qr_ready"; try { session.qrCode = await qrToDataUrl(qr); } catch { session.qrCode = null; } waLog.info({ clinicId, event: "qr_ready" }, "QR code generated"); session.events.emit("qr", { qrCode: session.qrCode, qrRaw: qr }); } if (connection === "open") { session.status = "connected"; session.qrCode = null; session.qrRaw = null; session.retryCount = 0; waLog.info({ clinicId, event: "connected" }, "session connected"); session.events.emit("connected"); } if (connection === "close") { const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode; const shouldReconnect = statusCode !== DisconnectReason.loggedOut; session.status = "disconnected"; session.socket = null; waLog.warn( { clinicId, event: "disconnected", statusCode, shouldReconnect, retryCount: session.retryCount }, "session disconnected" ); if (shouldReconnect && session.retryCount < 5) { session.retryCount++; setTimeout(() => connectWhatsApp(clinicId), 3000 * session.retryCount); } else if (!shouldReconnect) { // Logged out – clear saved credentials clearSession(clinicId); } session.events.emit("disconnected", { statusCode }); } }); sock.ev.on("creds.update", saveCreds); } // ─── Disconnect / logout ────────────────────────────────────────────────────── export async function disconnectWhatsApp(clinicId: number): Promise { const session = sessions.get(clinicId); if (!session) return; try { await session.socket?.logout(); } catch { // ignore } clearSession(clinicId); } function clearSession(clinicId: number) { const session = sessions.get(clinicId); if (session) { session.socket = null; session.status = "disconnected"; session.qrCode = null; session.qrRaw = null; } // Remove saved auth files const authDir = sessionPath(clinicId); if (fs.existsSync(authDir)) { fs.rmSync(authDir, { recursive: true, force: true }); } } // ─── Status getter ──────────────────────────────────────────────────────────── export function getWhatsAppStatus(clinicId: number): { status: WAStatus; qrCode: string | null; qrRaw: string | null; } { const session = sessions.get(clinicId); if (!session) return { status: "disconnected", qrCode: null, qrRaw: null }; return { status: session.status, qrCode: session.qrCode, qrRaw: session.qrRaw, }; } /** Returns the number of currently connected WhatsApp sessions across all clinics. */ export function getActiveWhatsAppSessionsCount(): number { let count = 0; for (const session of sessions.values()) { if (session.status === "connected") count += 1; } return count; } // ─── Send message ───────────────────────────────────────────────────────────── /** * Send a WhatsApp text message to a phone number. * @param clinicId The clinic whose WhatsApp session to use * @param phone Phone number in international format without + (e.g. "33612345678") * @param message Text content */ export async function sendWhatsAppMessage( clinicId: number, phone: string, message: string ): Promise<{ success: boolean; error?: string }> { const session = sessions.get(clinicId); if (!session || session.status !== "connected" || !session.socket) { return { success: false, error: "WhatsApp non connecté pour ce cabinet" }; } // Normalize phone: strip leading +, spaces, dashes const normalized = phone.replace(/[^0-9]/g, ""); const jid = `${normalized}@s.whatsapp.net`; try { await session.socket.sendMessage(jid, { text: message }); return { success: true }; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); return { success: false, error: errorMessage }; } } // ─── Template-based message builders ───────────────────────────────────────── import { buildMessage, type TemplateContext, type TemplateType, } from "../../shared/whatsappTemplates.js"; export type { TemplateContext, TemplateType }; /** * Custom templates from the clinic DB record (null = use default). */ export type ClinicTemplates = Partial>; export function buildJoinMessage( clinicName: string, ticketNumber: number, position: number, estimatedWait: number, customTemplates?: ClinicTemplates ): string { return buildMessage("joined", { nom: "", ticket: ticketNumber, position, attente: estimatedWait, cabinet: clinicName, }, customTemplates); } export function buildSoonMessage( clinicName: string, ticketNumber: number, minutesLeft: number, customTemplates?: ClinicTemplates ): string { return buildMessage("soon", { nom: "", ticket: ticketNumber, position: 0, attente: minutesLeft, cabinet: clinicName, }, customTemplates); } export function buildCalledMessage( clinicName: string, ticketNumber: number, customTemplates?: ClinicTemplates ): string { return buildMessage("called", { nom: "", ticket: ticketNumber, position: 0, attente: 0, cabinet: clinicName, }, customTemplates); } export function buildWithdrawnMessage( clinicName: string, ticketNumber: number, customTemplates?: ClinicTemplates ): string { return buildMessage("withdrawn", { nom: "", ticket: ticketNumber, position: 0, attente: 0, cabinet: clinicName, }, customTemplates); }