queue-med/server/services/whatsapp.ts
Hermes 81c6bccf8a security: Phase 1 hardening - rate limit, helmet, CORS, JWT, session persistence
- express-rate-limit: 100/15min global, 5/15min on auth.login + auth.register,
  3/hour reserved for password-reset endpoints; trust proxy enabled.
- helmet: enabled with contentSecurityPolicy + crossOriginEmbedderPolicy off
  to keep Vite dev and the SPA bundle working.
- CORS: explicit allowlist (https://attente.cosmolan.fr in prod, localhost in
  dev), credentials true, restricted methods/headers; same allowlist applied
  to socket.io.
- JWT_SECRET: must be set and >= 32 chars; assertAuthEnv() called from the
  server bootstrap so the process refuses to start without one. The insecure
  "changeme-in-production" fallback in docker-compose.yml is removed.
- qm_auth cookie: maxAge reduced from 30d to 7d, JWT expiry matches.
- WhatsApp sessions: path now driven by WHATSAPP_SESSION_DIR and defaults to
  /app/data/whatsapp-sessions; docker-compose.yml mounts a named app_data
  volume so credentials survive container restarts.
- scripts/backup-db.sh: timestamped, gzipped mysqldump into /app/data/backups
  with rotation (keeps last 7); Dockerfile installs mysql-client and bundles
  the script.
- .env.example refreshed with documented placeholders for every required var
  (DATABASE_URL, JWT_SECRET, WHATSAPP_SESSION_DIR, MYSQL_*, BACKUP_*).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:06:51 +00:00

292 lines
10 KiB
TypeScript
Raw 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";
// ─── 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) {
// eslint-disable-next-line no-console
console.error(`[whatsapp] failed to create session dir at ${SESSION_DIR}:`, err);
}
// 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;
}
session.events.emit("qr", { qrCode: session.qrCode, qrRaw: qr });
}
if (connection === "open") {
session.status = "connected";
session.qrCode = null;
session.qrRaw = null;
session.retryCount = 0;
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;
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,
};
}
// ─── 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);
}