Le site dispose d'un assistant basé sur l'IA qui répond aux questions sur les services, les projets et les outils. Pour les questions fréquentes, il fonctionne bien, mais parfois la personne qui écrit veut parler à un humain : elle a un projet précis en tête, ou une question à laquelle un modèle de langage ne devrait pas répondre au hasard. Avant, le seul moyen était le formulaire de contact. Désormais, pendant que vous écrivez dans le chat, une invitation peut apparaître : "Souhaitez-vous parler directement à Gent ?". Si vous répondez oui, je reçois un email, j'ouvre un lien et je rejoins la même conversation, en temps réel.
Dans cet article, j'explique comment cela fonctionne, avec le code (Angular, NestJS, Socket.IO, MongoDB) et les bugs qui n'apparaissaient pas en local. Les extraits sont simplifiés par rapport à ceux de production, mais la logique est la même.
Le cas d'usage
Un visiteur demande à l'assistant combien peut coûter un logiciel de gestion sur mesure. Il obtient une réponse générale, pose une autre question, puis commence à décrire son cas. Pendant qu'il écrit, la bannière apparaît. Il clique sur "Oui, le contacter" et lit "En attente de Gent…".
Je reçois un email avec pour objet "🔴 Un visitatore vuole parlare con te ora" ("Un visiteur veut te parler maintenant"), les derniers messages de la conversation et un bouton. Je l'ouvre même depuis mon téléphone, je rejoins le chat et le visiteur voit "Gent est en train de rejoindre…", puis "Vous discutez avec Gent". À partir de ce moment, ses messages m'arrivent à moi, et non plus à l'IA.
Si je ne réponds pas dans les 15 minutes, la demande expire et le visiteur en est informé : "Gent n'est pas disponible pour le moment, mais il a bien reçu votre demande". Il peut continuer avec l'assistant, et personne ne reste à attendre devant un curseur qui clignote.
Le flux, du début à la fin
Visitor (Angular) Backend (NestJS) Gent
───────────────── ──────────────── ────
types... (debounce 1.2 s)
→ banner "Talk to Gent?"
clicks "Yes"
POST /chatbot/:id/live-handoff → saves request [requested]
→ sends email ─────────────────────────→ 📧
io('/live-chat')
join_session ─────────────────→ room live-handoff:<id> [notified]
←── admin_join (JWT)
←── agent_joined, status: live [live]
visitor_message ══════════════ chat_message (both ways) ══════════════ admin_message
Le choix de fond : la demande passe par REST, la conversation par WebSocket. La demande est un événement unique à valider, limiter et enregistrer ; la conversation est un flux bidirectionnel où la latence compte.
1. La bannière : demander au bon moment
Une popup qui s'affiche dès l'ouverture de la page est agaçante. Celle-ci n'apparaît que lorsque trois conditions sont réunies :
- il y a déjà eu au moins un échange avec l'assistant ;
- le visiteur est en train d'écrire : chaque frappe alimente un stream RxJS avec un
debounceTimede 1,2 seconde, la bannière arrive donc pendant une pause, pas à chaque lettre ; - il ne l'a pas déjà refusée dans les 10 dernières minutes.
// chatbot.component.ts: every keystroke feeds a stream, debounced
private readonly typing$ = new Subject<void>();
ngOnInit(): void {
this.typing$.pipe(debounceTime(1200)).subscribe(() => {
const sessionId = this.chatbot.currentSessionId;
if (sessionId && this.flags.liveHandoffEnabled()) {
this.liveHandoff.notifyTyping(sessionId, this.chatbot.hasMessages);
}
});
}
onInputChange(): void {
if (this.liveState === 'idle' || this.liveState === 'prompt_shown') {
this.typing$.next();
}
}
// live-handoff.service.ts
notifyTyping(sessionId: string, hasExchangedMessages: boolean): void {
if (!hasExchangedMessages) return; // never on the very first message
if (this.state !== 'idle') return; // already asked, or already live
if (this.isDismissedRecently(sessionId)) return; // "no" means no, for 10 minutes
this._state.next('prompt_shown');
this.armAutoMinimize(); // shrinks to a pill after 8 s
}
Après 8 secondes sans réponse, la bannière se réduit à une pastille "Parler à Gent" : toujours disponible, mais elle ne masque pas la conversation. Le refus est mémorisé dans sessionStorage : il vaut pour l'onglet ouvert et ne poursuit pas le visiteur indéfiniment. Et un interrupteur dans le dashboard désactive toute la fonctionnalité quand je ne peux pas répondre.
2. La demande : REST, avec des limites
// live-handoff.controller.ts
@Post(':sessionId/live-handoff')
@RequireFeature('liveHandoffEnabled') // the switch in the dashboard
@Throttle({ default: { limit: 3, ttl: 600_000 } }) // 3 requests every 10 minutes
create(
@Req() req: Request,
@Param('sessionId', ParseUUIDPipe) sessionId: string,
@Body() dto: CreateLiveHandoffDto,
) {
return this.liveHandoffService.createRequest(sessionId, dto, req.ip ?? '');
}
// live-handoff.service.ts
async createRequest(sessionId: string, dto: CreateLiveHandoffDto, ip: string) {
// Idempotent: a double click returns the request that is already open
const existing = await this.model.findOne({ sessionId, status: { $in: ACTIVE_STATUSES } });
if (existing) return this.toStatusDto(existing);
const todayCount = await this.model.countDocuments({ createdAt: { $gte: startOfToday() } });
if (todayCount >= this.config.get<number>('LIVE_HANDOFF_MAX_PER_DAY', 20)) {
throw new ConflictException('Live handoff request limit reached for today.');
}
const doc = await this.model.create({
sessionId,
status: 'requested',
lastUserMessage: dto.lastUserMessage?.slice(0, 500),
locale: dto.locale,
// Never the raw IP: a truncated hash is enough to spot repeated abuse
ipHash: createHash('sha256').update(ip).digest('hex').slice(0, 16),
expiresAt: new Date(Date.now() + 15 * 60_000),
});
// Fire-and-forget: a slow mail provider must not keep the visitor waiting
this.notifyGent(doc).catch((err) => this.logger.error('Live handoff email failed', err));
return this.toStatusDto(doc);
}
Chaque ligne a sa raison d'être :
- Idempotence : un double clic, ou une seconde tentative après une erreur réseau, renvoie la demande déjà ouverte au lieu d'en créer une autre et de m'envoyer deux emails.
- Trois demandes toutes les dix minutes et un plafond quotidien : un endpoint public qui déclenche des emails est une invitation aux abus.
- L'IP n'est pas enregistrée : je n'en garde qu'un hash tronqué, utile pour repérer un abus répété, inutile pour identifier qui que ce soit.
- L'email part sans
await: le visiteur reçoit la réponse immédiatement, et si l'envoi échoue, il en reste une trace dans les logs.
3. L'email : tout le contexte d'un coup d'œil
Quand j'ouvre l'email, je dois comprendre en quelques secondes qui me cherche et pourquoi. Il contient donc les six derniers messages de la conversation, la langue du visiteur, l'heure d'expiration et un lien direct vers la page d'administration de ce chat :
private async notifyGent(doc: LiveHandoffRequestDocument): Promise<void> {
const session = await this.chatbotService.getSession(doc.sessionId).catch(() => null);
await this.mailService.sendLiveHandoffRequest({
sessionId: doc.sessionId,
lastUserMessage: doc.lastUserMessage,
locale: doc.locale,
recentMessages: session?.messages?.slice(-6) ?? [], // the context at a glance
expiresAt: doc.expiresAt,
});
doc.status = 'notified';
doc.notifiedAt = new Date();
await doc.save();
this.gateway.emitStatusChanged(doc.sessionId, doc.status); // the visitor's UI follows
}
// mail.service.ts: the button in the email
const joinUrl = `${frontendUrl}/dashboard/live-chat/${opts.sessionId}`;
Le lien mène à une page protégée, accessible uniquement en tant qu'administrateur. Et l'email n'est pas le seul canal : la demande apparaît aussi dans le dashboard, avec une notification en temps réel.
4. Le WebSocket : salons et rôles
La gateway Socket.IO vit dans un namespace dédié, /live-chat. Chaque conversation est un salon : visiteur et administrateur rejoignent le même, et chaque message est d'abord enregistré puis diffusé à tout le salon.
@WebSocketGateway({ namespace: '/live-chat', cors: { origin: corsOriginValidator } })
export class LiveHandoffGateway {
@WebSocketServer() server: Server;
private room(sessionId: string): string {
return `live-handoff:${sessionId}`;
}
@SubscribeMessage('join_session')
async onJoinSession(@ConnectedSocket() client: Socket, @MessageBody() body: { sessionId?: string }) {
if (body?.sessionId) await client.join(this.room(body.sessionId));
}
@SubscribeMessage('visitor_message')
async onVisitorMessage(
@ConnectedSocket() client: Socket,
@MessageBody() body: { sessionId?: string; text?: string },
) {
const text = body?.text?.trim().slice(0, 1000);
if (!body?.sessionId || !text) return;
const { status } = await this.liveHandoffService.getStatus(body.sessionId);
if (!CHATTABLE_STATUSES.includes(status)) return;
// Stored in the same session as the AI chat (roles: user, assistant, agent)
const message = await this.chatbotService.appendLiveMessage(body.sessionId, 'user', text);
await this.liveHandoffService.touchActivity(body.sessionId);
this.server.to(this.room(body.sessionId)).emit('chat_message', {
from: 'visitor',
text,
sentAt: message.timestamp,
});
}
}
Mes messages finissent dans la même session que le chat avec l'IA, avec un troisième rôle à côté de user et assistant : agent. J'ai étendu le schéma existant au lieu d'en créer un nouveau, pour que la conversation reste unique.
Le visiteur n'a pas de compte : il rejoint le salon avec l'identifiant de session, un UUID aléatoire que seul son navigateur connaît. C'est sa permission. L'administrateur, lui, s'authentifie à l'ouverture du socket, avec son token :
async handleConnection(client: Socket): Promise<void> {
const token = client.handshake.auth?.token;
if (typeof token === 'string' && (await this.isAdminToken(token))) {
client.data.isAdmin = true;
await client.join(ADMINS_ROOM); // also receives the dashboard notifications
}
// Visitors connect without a token: their sessionId is their capability
}
private async isAdminToken(token: string): Promise<boolean> {
let sub: string | undefined;
try {
sub = this.jwtService.verify<{ sub?: string }>(token).sub;
} catch {
return false;
}
if (!sub) return false;
// A valid signature is not enough: read the role from the database,
// so a demoted admin loses access immediately
const user = await this.usersService.findById(sub);
return user?.role === 'admin';
}
Dans la première version, n'importe quel JWT valide suffisait. Avec l'inscription ouverte à tous, un utilisateur ordinaire aurait pu rejoindre un chat en se faisant passer pour "Gent", écrire à ma place et le fermer. Désormais, le rôle est lu dans la base de données à chaque connexion : un administrateur rétrogradé perd l'accès immédiatement, sans attendre l'expiration du token.
5. Une machine à états, avec des échéances
requested ──→ notified ──→ agent_joining ──→ live ──→ closed
│ │ │
└─────────────┴──→ expired └──→ closed (5 min without messages,
(15 min without an answer) or 2 h at most)
Les états avancent avec les événements, mais aussi avec le temps. D'où trois tâches planifiées et un index TTL de MongoDB :
// Every minute: unanswered requests past their deadline become 'expired'
@Cron(CronExpression.EVERY_MINUTE)
async expireStaleRequests(): Promise<number> {
const stale = await this.model.find({
status: { $in: ['requested', 'notified'] },
expiresAt: { $lt: new Date() },
});
for (const doc of stale) {
doc.status = 'expired';
await doc.save();
this.gateway.emitStatusChanged(doc.sessionId, doc.status); // the visitor is told
}
return stale.length;
}
// Same pattern for the other two jobs:
// - every minute: 'live' with no messages for 5 minutes → 'closed'
// - every 10 minutes: open for more than 2 hours (tab closed) → 'closed'
// Data retention: each request is deleted 24 h after creation, whatever its outcome
LiveHandoffRequestSchema.index({ createdAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 });
Sans la fermeture pour inactivité, deux onglets laissés ouverts sans échanger de messages resteraient "live" pendant des heures, en occupant le dashboard comme s'il s'agissait d'une conversation en cours.
6. Le filet de sécurité : le polling
Un WebSocket peut ne pas se connecter pour bien des raisons : un proxy d'entreprise, un réseau mobile instable, une mauvaise configuration. Le frontend se protège de deux façons :
private connectSocket(sessionId: string): void {
// No `transports: ['websocket']`: start with HTTP long polling and
// upgrade to WebSocket when possible, instead of failing outright
this.socket = this.io(`${this.wsOrigin}/live-chat`);
this.socket.on('connect', () => this.socket?.emit('join_session', { sessionId }));
this.socket.on('handoff_status_changed', ({ status }) => this.applyStatus(status));
this.socket.on('chat_message', (msg) => this.appendMessage(msg));
// Independent safety net: even if the socket never connects,
// a REST poll every 4 s still finds out when Gent joins
this.pollTimer = setInterval(() => {
this.http
.get<{ status: string }>(`${this.apiUrl}/${sessionId}/live-handoff/status`)
.subscribe({ next: (res) => this.applyStatus(res.status), error: () => undefined });
}, 4000);
}
D'abord, il n'impose pas le transport websocket : Socket.IO démarre en long polling sur HTTP et passe au WebSocket s'il le peut, donc si le WebSocket est bloqué, les messages passent quand même. Ensuite, si Socket.IO ne parvient pas du tout à se connecter, le polling REST continue de détecter les changements d'état : le visiteur apprend quand même que j'ai rejoint le chat, ou que la demande a expiré, au lieu d'attendre indéfiniment.
Les bugs qui n'apparaissaient pas en local
L'administrateur bloqué sur "Entrée dans le chat…"
Le premier jour, en ouvrant le lien de l'email, je restais bloqué sur "Entrée dans le chat…" indéfiniment. La raison : si la demande avait déjà expiré, le service levait une exception, mais la gateway ne l'interceptait pas. Aucun événement ne revenait au client, ni de succès ni d'erreur. Et une demande expirée était le cas le plus courant : il suffit de lire l'email avec un quart d'heure de retard.
// Before (simplified): an expired request threw, and nobody caught it
@SubscribeMessage('admin_join')
async onAdminJoin(@ConnectedSocket() client: Socket, @MessageBody() body: { sessionId: string }) {
const status = await this.liveHandoffService.markAgentJoining(body.sessionId);
// ConflictException on 'expired' → no event ever reached the client
}
// After: every failure becomes an event the UI can show
let status: LiveHandoffStatusDto;
try {
status = await this.liveHandoffService.markAgentJoining(body.sessionId);
} catch (err) {
client.emit('error', {
message: err instanceof Error ? err.message : 'Impossibile entrare in questa chat.',
});
return;
}
La correction comporte trois volets. Une demande expirée peut désormais être rouverte : si le visiteur a encore la page ouverte, la conversation reprend. La gateway transforme chaque exception en un événement error lisible. Et un message envoyé à un chat qui n'est plus actif produit une erreur au lieu de disparaître. La leçon : dans une fonctionnalité avec des échéances, le cas à tester n'est pas la demande qu'on vient de créer. C'est celle d'il y a un quart d'heure.
Le socket qui ne se connectait pas en production
En local, tout fonctionnait ; en production, la page d'administration restait sur "Connexion en cours…". Je me suis trompé de diagnostic deux fois. D'abord, j'ai pensé à CORS, mais les en-têtes étaient corrects. Puis au proxy du service cloud qui hébergeait alors le backend : un handshake WebSocket fait avec curl renvoyait 400. Mais c'était curl qui négociait HTTP/2, où cet upgrade n'existe pas ; un vrai navigateur, sur le même hôte, se connectait sans problème.
La vraie cause était la Content Security Policy. connect-src listait le backend en https://, et pour la CSP, le schéma compte : https://host n'autorise pas wss://host. Le navigateur bloquait le socket et ne laissait qu'une ligne dans la console.
# Before: the WebSocket was silently blocked
Header always set Content-Security-Policy "... connect-src 'self' https://api.example.com"
# After: the same host, listed once per scheme
Header always set Content-Security-Policy "... connect-src 'self' https://api.example.com wss://api.example.com"
Je l'ai démontré en écoutant les violations dans le navigateur, sur la vraie page, au lieu de me fier à des outils qui ne se comportent pas comme un navigateur :
// Paste it in the console of the real page, then open the chat
document.addEventListener('securitypolicyviolation', (e) => {
console.warn(e.violatedDirective, e.blockedURI);
});
// → connect-src wss://api.example.com
Il existe désormais un test automatique qui lit la CSP et échoue s'il manque l'entrée wss://. Et même maintenant que le backend est sur le même domaine que le site, la règle reste explicite : le wss:// du domaine lui-même à côté de 'self', car tous les navigateurs ne le considèrent pas comme déjà inclus.
Le message écarté en silence
Le troisième bug était le plus sournois : je répondais, et le visiteur ne recevait rien. Aucune erreur, nulle part. Une session peut avoir plusieurs demandes (une expirée et une nouvelle, par exemple), et la fonction qui fait passer l'état à live cherchait le document sans le trier : parfois, elle mettait à jour l'ancien. Le plus récent restait figé, la gateway le considérait comme inactif et écartait mes messages.
async markLive(sessionId: string): Promise<void> {
// Must target the SAME document markAgentJoining updated: the most recent one.
// Without sort(), findOne could pick an older, expired request.
const doc = await this.model.findOne({ sessionId }).sort({ createdAt: -1 });
if (!doc || !['agent_joining', 'notified'].includes(doc.status)) return;
doc.status = 'live';
doc.lastActivityAt = new Date(); // the inactivity clock starts from here
await doc.save();
this.gateway.emitStatusChanged(doc.sessionId, doc.status);
}
Depuis, chaque message refusé produit une erreur explicite. Un message qui disparaît en silence est le bug le plus difficile à diagnostiquer : il ne laisse aucun indice.
Confidentialité et limites
- Aucune IP en clair, seulement un hash tronqué.
- Les demandes s'effacent d'elles-mêmes au bout de 24 heures, grâce à l'index TTL.
- Des limites par client et par jour, et un interrupteur pour tout désactiver.
- Une limite assumée : de l'autre côté, il y a moi, pas un service client disponible 24 heures sur 24. C'est pourquoi l'échéance est courte et le message qui l'annonce est clair.
En résumé
Passer d'un chatbot à une personne demande peu de code, mais beaucoup de décisions : quand demander, comment prévenir, comment authentifier celui qui répond et que faire quand personne ne répond. REST pour la demande, WebSocket pour la conversation, un polling comme filet de sécurité et une échéance explicite pour chaque état. J'ai réuni d'autres notes sur les cookies, le cache et les WebSockets dans ce guide. Si vous voulez l'essayer, ouvrez le chat du site : si je suis disponible, c'est moi qui réponds. Et si votre projet a besoin de quelque chose de similaire, écrivez-moi.
