queue-med/server/services/whatsapp.ts

310 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/<clinicId>/, 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<typeof makeWASocket> | 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<number, WASession>();
// ─── Rate limiting : une queue p-queue par cabinet ───────────────────────────
// Intervalle de 2.5s entre messages pour éviter les bans WhatsApp
const messageQueues = new Map<number, PQueue>();
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<string> {
// 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<void> {
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<void> {
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<Record<TemplateType, string | null>>;
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);
}