Tutti gli articoli

Dal chatbot a una persona vera: come funziona il passaggio alla chat live

Sul sito c'è un assistente basato sull'IA che risponde a domande su servizi, progetti e strumenti. Per le domande frequenti funziona bene, ma a volte chi scrive vuole parlare con una persona: ha un progetto preciso in mente, o una domanda a cui un modello linguistico non dovrebbe rispondere tirando a indovinare. Prima l'unica strada era il modulo di contatto. Ora, mentre scrivi nella chat, può comparire un invito: "Vuoi parlare direttamente con Gent?". Se rispondi di sì, ricevo un'email, apro un link ed entro nella stessa conversazione, in tempo reale.

In questo articolo racconto come funziona, con il codice (Angular, NestJS, Socket.IO, MongoDB) e i bug che in locale non si vedevano. Gli estratti sono semplificati rispetto a quelli in produzione, ma la logica è la stessa.

Il caso d'uso

Un visitatore chiede all'assistente quanto può costare un gestionale su misura. Riceve una risposta generale, fa un'altra domanda, poi comincia a descrivere il suo caso. Mentre scrive compare il banner. Clicca "Sì, contattalo" e legge "In attesa di Gent…".

Io ricevo un'email con oggetto "🔴 Un visitatore vuole parlare con te ora", gli ultimi messaggi della conversazione e un pulsante. Lo apro anche dal telefono, entro nella chat e il visitatore vede "Gent sta entrando…", poi "Stai parlando con Gent". Da quel momento i suoi messaggi arrivano a me, non all'IA.

Se non rispondo entro 15 minuti, la richiesta scade e il visitatore lo viene a sapere: "Gent non è disponibile in questo momento, ma ha ricevuto la tua richiesta". Può continuare con l'assistente, e nessuno resta ad aspettare davanti a un cursore che lampeggia.

Il flusso, dall'inizio alla fine

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

La scelta di fondo: la richiesta passa per REST, la conversazione per WebSocket. La richiesta è un evento singolo da validare, limitare e registrare; la conversazione è un flusso bidirezionale in cui conta la latenza.

1. Il banner: chiedere al momento giusto

Un popup che compare all'apertura della pagina è fastidioso. Questo compare solo quando sono vere tre condizioni:

  • c'è già stato almeno uno scambio con l'assistente;
  • il visitatore sta scrivendo: ogni tasto alimenta uno stream RxJS con debounceTime di 1,2 secondi, quindi il banner arriva durante una pausa, non a ogni lettera;
  • non l'ha già rifiutato negli ultimi 10 minuti.
// 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
}

Dopo 8 secondi senza risposta il banner si riduce a una pillola "Parla con Gent": resta disponibile, ma non copre la conversazione. Il rifiuto viene ricordato in sessionStorage, quindi vale per la scheda aperta e non segue il visitatore per sempre. E un interruttore nella dashboard spegne tutta la funzione quando non posso rispondere.

2. La richiesta: REST, con dei limiti

// 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);
}

Ogni riga ha un motivo:

  • Idempotenza: un doppio clic, o un secondo tentativo dopo un errore di rete, restituisce la richiesta già aperta invece di crearne un'altra e mandarmi due email.
  • Tre richieste ogni dieci minuti e un tetto giornaliero: un endpoint pubblico che fa partire email è un invito agli abusi.
  • L'IP non viene salvato: ne conservo solo un hash troncato, utile per riconoscere un abuso ripetuto, inutile per identificare qualcuno.
  • L'email parte senza await: il visitatore riceve subito la risposta, e se l'invio fallisce resta traccia nei log.

3. L'email: tutto il contesto in un colpo d'occhio

Quando apro l'email devo capire in pochi secondi chi mi sta cercando e perché. Per questo contiene gli ultimi sei messaggi della conversazione, la lingua del visitatore, l'ora di scadenza e un link diretto alla pagina di amministrazione di quella 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}`;

Il link porta a una pagina protetta, raggiungibile solo da amministratore. E l'email non è l'unico canale: la richiesta compare anche nella dashboard, con una notifica in tempo reale.

4. Il WebSocket: stanze e ruoli

Il gateway Socket.IO vive in un namespace dedicato, /live-chat. Ogni conversazione è una stanza: visitatore e amministratore entrano nella stessa, e ogni messaggio viene prima salvato e poi trasmesso a tutta la stanza.

@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,
    });
  }
}

I miei messaggi finiscono nella stessa sessione della chat con l'IA, con un terzo ruolo accanto a user e assistant: agent. Ho esteso lo schema esistente invece di crearne uno nuovo, così la conversazione resta una sola.

Il visitatore non ha un account: entra nella stanza con l'identificativo della sessione, un UUID casuale che conosce solo il suo browser. È quello il suo permesso. L'amministratore invece si autentica all'apertura del socket, con il suo 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';
}

Nella prima versione bastava un JWT valido qualunque. Con la registrazione aperta a tutti, un utente normale avrebbe potuto entrare in una chat presentandosi come "Gent", scrivere al posto mio e chiuderla. Ora il ruolo si legge dal database a ogni connessione: un amministratore declassato perde l'accesso subito, senza aspettare che il token scada.

5. Una macchina a stati, con le scadenze

requested ──→ notified ──→ agent_joining ──→ live ──→ closed
    │             │                            │
    └─────────────┴──→ expired                 └──→ closed  (5 min without messages,
         (15 min without an answer)                          or 2 h at most)

Gli stati li fanno avanzare gli eventi, ma anche il tempo. Per questo ci sono tre job pianificati e un indice TTL di 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 });

Senza la chiusura per inattività, due schede lasciate aperte senza scriversi resterebbero "live" per ore, occupando la dashboard come se fosse una conversazione in corso.

6. La rete di sicurezza: il polling

Un WebSocket può non connettersi per tanti motivi: un proxy aziendale, una rete mobile instabile, una configurazione sbagliata. Il frontend si difende in due modi:

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);
}

Primo, non forza il trasporto websocket: Socket.IO parte con il long polling su HTTP e passa al WebSocket se ci riesce, quindi se il WebSocket è bloccato i messaggi viaggiano comunque. Secondo, se Socket.IO non riesce a connettersi affatto, il polling REST continua a rilevare i cambi di stato: il visitatore scopre lo stesso che sono entrato in chat, o che la richiesta è scaduta, invece di restare in attesa per sempre.

I bug che in locale non si vedevano

L'amministratore bloccato su "Ingresso nella chat…"

Il primo giorno, aprendo il link dall'email, restavo fermo su "Ingresso nella chat…" per sempre. Il motivo: se la richiesta era già scaduta, il servizio lanciava un'eccezione, ma il gateway non la catturava. Nessun evento tornava al client, né di successo né di errore. E una richiesta scaduta era il caso più comune: basta leggere l'email con un quarto d'ora di ritardo.

// 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 correzione ha tre parti. Una richiesta scaduta ora si può riaprire: se il visitatore ha ancora la pagina aperta, la conversazione riparte. Il gateway trasforma ogni eccezione in un evento error leggibile. E un messaggio inviato a una chat non più attiva produce un errore invece di sparire. La lezione: in una funzione con delle scadenze, il caso da testare non è la richiesta appena creata. È quella di un quarto d'ora fa.

Il socket che in produzione non si collegava

In locale tutto funzionava; in produzione la pagina di amministrazione restava su "Connessione in corso…". Ho sbagliato diagnosi due volte. Prima ho pensato a CORS, ma gli header erano corretti. Poi al proxy del servizio cloud che allora ospitava il backend: un handshake WebSocket fatto con curl rispondeva 400. Ma era curl a negoziare HTTP/2, dove quell'upgrade non esiste; un browser vero, sullo stesso host, si collegava senza problemi.

La causa vera era la Content Security Policy. connect-src elencava il backend come https://, e per la CSP lo schema conta: https://host non autorizza wss://host. Il browser bloccava il socket e lasciava solo una riga nella 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"

L'ho dimostrato ascoltando le violazioni nel browser, sulla pagina reale, invece di affidarmi a strumenti che non si comportano come un browser:

// 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

Ora c'è un test automatico che legge la CSP e fallisce se manca la voce wss://. E anche da quando il backend sta sullo stesso dominio del sito, la regola resta esplicita: wss:// del proprio dominio accanto a 'self', perché non tutti i browser lo considerano già compreso.

Il messaggio scartato in silenzio

Il terzo bug era il più subdolo: rispondevo, e il visitatore non riceveva niente. Nessun errore, da nessuna parte. Una sessione può avere più richieste (una scaduta e una nuova, per esempio), e la funzione che porta lo stato a live cercava il documento senza ordinarlo: a volte aggiornava quello vecchio. Il più recente restava fermo, il gateway lo considerava non attivo e scartava i miei messaggi.

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);
}

Da allora ogni messaggio rifiutato produce un errore esplicito. Un messaggio che sparisce in silenzio è il bug più difficile da diagnosticare: non lascia indizi.

Privacy e limiti

  • Nessun IP in chiaro, solo un hash troncato.
  • Le richieste si cancellano da sole dopo 24 ore, grazie all'indice TTL.
  • Limiti per client e per giorno, e un interruttore per spegnere tutto.
  • Un limite dichiarato: dall'altra parte ci sono io, non un servizio clienti attivo 24 ore su 24. Per questo la scadenza è breve e il messaggio che la comunica è chiaro.

In sintesi

Passare da un chatbot a una persona richiede poco codice ma molte decisioni: quando chiedere, come avvisare, come autenticare chi risponde e cosa fare quando nessuno risponde. REST per la richiesta, WebSocket per la conversazione, un polling come rete di sicurezza e una scadenza esplicita per ogni stato. Ho raccolto altre note su cookie, cache e WebSocket in questa guida. Se vuoi provarla, apri la chat del sito: se sono disponibile, rispondo io. E se il tuo progetto ha bisogno di qualcosa di simile, scrivimi.

💬 Note dei lettori

0 note

Scrivi una nota

Condividi la tua opinione, un suggerimento o un complimento

Ultime note

Nessuna nota ancora. Sii il primo a commentare!