310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|