En la web hay un asistente basado en IA que responde preguntas sobre servicios, proyectos y herramientas. Para las preguntas frecuentes funciona bien, pero a veces quien escribe quiere hablar con una persona: tiene un proyecto concreto en mente, o una pregunta que un modelo de lenguaje no debería responder a ciegas. Antes, el único camino era el formulario de contacto. Ahora, mientras escribes en el chat, puede aparecer una invitación: "¿Quieres hablar directamente con Gent?". Si respondes que sí, recibo un email, abro un enlace y entro en la misma conversación, en tiempo real.
En este artículo cuento cómo funciona, con el código (Angular, NestJS, Socket.IO, MongoDB) y los bugs que en local no se veían. Los fragmentos están simplificados respecto a los de producción, pero la lógica es la misma.
El caso de uso
Un visitante le pregunta al asistente cuánto puede costar un software de gestión a medida. Recibe una respuesta general, hace otra pregunta y luego empieza a describir su caso. Mientras escribe, aparece el banner. Hace clic en "Sí, contactarlo" y lee "Esperando a Gent…".
Yo recibo un email con el asunto "🔴 Un visitatore vuole parlare con te ora" ("Un visitante quiere hablar contigo ahora"), los últimos mensajes de la conversación y un botón. Lo abro incluso desde el móvil, entro en el chat y el visitante ve "Gent se está uniendo…", luego "Estás hablando con Gent". A partir de ese momento sus mensajes me llegan a mí, no a la IA.
Si no respondo en 15 minutos, la solicitud caduca y el visitante se entera: "Gent no está disponible en este momento, pero ha recibido tu solicitud". Puede seguir con el asistente, y nadie se queda esperando delante de un cursor que parpadea.
El flujo, de principio a 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
La decisión de fondo: la solicitud pasa por REST, la conversación por WebSocket. La solicitud es un evento único que hay que validar, limitar y registrar; la conversación es un flujo bidireccional en el que la latencia importa.
1. El banner: preguntar en el momento justo
Un popup que aparece nada más abrir la página es molesto. Este solo aparece cuando se cumplen tres condiciones:
- ya ha habido al menos un intercambio con el asistente;
- el visitante está escribiendo: cada tecla alimenta un stream RxJS con un
debounceTimede 1,2 segundos, así que el banner llega durante una pausa, no con cada letra; - no lo ha rechazado ya en los últimos 10 minutos.
// 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
}
Tras 8 segundos sin respuesta, el banner se reduce a una píldora "Hablar con Gent": sigue disponible, pero no tapa la conversación. El rechazo se recuerda en sessionStorage, así que vale para la pestaña abierta y no persigue al visitante para siempre. Y un interruptor en el dashboard apaga toda la función cuando no puedo responder.
2. La solicitud: REST, con límites
// 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);
}
Cada línea tiene un motivo:
- Idempotencia: un doble clic, o un segundo intento tras un error de red, devuelve la solicitud ya abierta en lugar de crear otra y enviarme dos emails.
- Tres solicitudes cada diez minutos y un tope diario: un endpoint público que dispara emails es una invitación al abuso.
- La IP no se guarda: solo conservo un hash truncado, útil para reconocer un abuso repetido, inútil para identificar a nadie.
- El email sale sin
await: el visitante recibe la respuesta al instante y, si el envío falla, queda constancia en los logs.
3. El email: todo el contexto de un vistazo
Cuando abro el email necesito entender en pocos segundos quién me busca y por qué. Por eso contiene los últimos seis mensajes de la conversación, el idioma del visitante, la hora de caducidad y un enlace directo a la página de administración de ese 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}`;
El enlace lleva a una página protegida, accesible solo como administrador. Y el email no es el único canal: la solicitud aparece también en el dashboard, con una notificación en tiempo real.
4. El WebSocket: salas y roles
El gateway de Socket.IO vive en un namespace dedicado, /live-chat. Cada conversación es una sala: visitante y administrador entran en la misma, y cada mensaje primero se guarda y luego se transmite a toda la sala.
@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,
});
}
}
Mis mensajes acaban en la misma sesión que el chat con la IA, con un tercer rol junto a user y assistant: agent. Amplié el schema existente en lugar de crear uno nuevo, así la conversación sigue siendo una sola.
El visitante no tiene cuenta: entra en la sala con el identificador de la sesión, un UUID aleatorio que solo conoce su navegador. Ese es su permiso. El administrador, en cambio, se autentica al abrir el socket, con su 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';
}
En la primera versión bastaba con cualquier JWT válido. Con el registro abierto a todos, un usuario normal podría haber entrado en un chat haciéndose pasar por "Gent", escribir en mi lugar y cerrarlo. Ahora el rol se lee de la base de datos en cada conexión: un administrador degradado pierde el acceso al instante, sin esperar a que caduque el token.
5. Una máquina de estados, con plazos
requested ──→ notified ──→ agent_joining ──→ live ──→ closed
│ │ │
└─────────────┴──→ expired └──→ closed (5 min without messages,
(15 min without an answer) or 2 h at most)
Los estados avanzan con los eventos, pero también con el tiempo. Por eso hay tres tareas programadas y un índice 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 });
Sin el cierre por inactividad, dos pestañas abiertas sin escribirse seguirían "live" durante horas, ocupando el dashboard como si fuera una conversación en curso.
6. La red de seguridad: el polling
Un WebSocket puede no conectarse por muchos motivos: un proxy corporativo, una red móvil inestable, una configuración equivocada. El frontend se defiende de dos maneras:
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);
}
Primero, no fuerza el transporte websocket: Socket.IO empieza con long polling sobre HTTP y pasa a WebSocket si puede, así que, si el WebSocket está bloqueado, los mensajes llegan igualmente. Segundo, si Socket.IO no consigue conectarse en absoluto, el polling REST sigue detectando los cambios de estado: el visitante se entera igualmente de que he entrado en el chat, o de que la solicitud ha caducado, en lugar de esperar para siempre.
Los bugs que en local no se veían
El administrador atascado en "Entrando en el chat…"
El primer día, al abrir el enlace del email, me quedaba atascado en "Entrando en el chat…" para siempre. El motivo: si la solicitud ya había caducado, el servicio lanzaba una excepción, pero el gateway no la capturaba. Ningún evento volvía al cliente, ni de éxito ni de error. Y una solicitud caducada era el caso más común: basta con leer el email con un cuarto de hora de retraso.
// 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 corrección tiene tres partes. Una solicitud caducada ahora se puede reabrir: si el visitante todavía tiene la página abierta, la conversación se reanuda. El gateway convierte cada excepción en un evento error legible. Y un mensaje enviado a un chat que ya no está activo produce un error en lugar de desaparecer. La lección: en una función con plazos, el caso que hay que probar no es la solicitud recién creada. Es la de hace un cuarto de hora.
El socket que en producción no se conectaba
En local todo funcionaba; en producción, la página de administración se quedaba en "Conectando…". Me equivoqué de diagnóstico dos veces. Primero pensé en CORS, pero las cabeceras eran correctas. Luego en el proxy del servicio cloud que entonces alojaba el backend: un handshake WebSocket hecho con curl respondía 400. Pero era curl el que negociaba HTTP/2, donde ese upgrade no existe; un navegador de verdad, contra el mismo host, se conectaba sin problemas.
La causa real era la Content Security Policy. connect-src listaba el backend como https://, y para la CSP el esquema importa: https://host no autoriza wss://host. El navegador bloqueaba el socket y dejaba solo una línea en la consola.
# 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"
Lo demostré escuchando las violaciones en el navegador, en la página real, en lugar de fiarme de herramientas que no se comportan como un navegador:
// 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
Ahora hay un test automático que lee la CSP y falla si falta la entrada wss://. E incluso ahora que el backend está en el mismo dominio que la web, la regla sigue siendo explícita: el wss:// del propio dominio junto a 'self', porque no todos los navegadores lo consideran ya incluido.
El mensaje descartado en silencio
El tercer bug era el más traicionero: yo respondía y el visitante no recibía nada. Ningún error, en ninguna parte. Una sesión puede tener varias solicitudes (una caducada y una nueva, por ejemplo), y la función que pasa el estado a live buscaba el documento sin ordenarlo: a veces actualizaba el antiguo. El más reciente se quedaba parado, el gateway lo consideraba inactivo y descartaba mis mensajes.
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);
}
Desde entonces, cada mensaje rechazado produce un error explícito. Un mensaje que desaparece en silencio es el bug más difícil de diagnosticar: no deja pistas.
Privacidad y límites
- Ninguna IP en claro, solo un hash truncado.
- Las solicitudes se borran solas a las 24 horas, gracias al índice TTL.
- Límites por cliente y por día, y un interruptor para apagarlo todo.
- Un límite declarado: al otro lado estoy yo, no un servicio de atención al cliente activo las 24 horas. Por eso el plazo es corto y el mensaje que lo comunica es claro.
En resumen
Pasar de un chatbot a una persona requiere poco código, pero muchas decisiones: cuándo preguntar, cómo avisar, cómo autenticar a quien responde y qué hacer cuando nadie responde. REST para la solicitud, WebSocket para la conversación, un polling como red de seguridad y un plazo explícito para cada estado. Reuní más notas sobre cookies, caché y WebSockets en esta guía. Si quieres probarlo, abre el chat de la web: si estoy disponible, te respondo yo. Y si tu proyecto necesita algo parecido, escríbeme.
