Guía de conversación completa (Socket.io)
Este documento muestra paso a paso cómo establecer una conversación entre un contacto y un agente usando el gateway /chat. Está pensado como tutorial práctico; para la referencia de eventos y DTOs ver Chat (WebSocket).
:::info Datos de prueba
Los tokens y IDs de prueba están en socket.json en la raíz del monorepo.
:::
Requisitos previos
- Servidor NestJS corriendo (por defecto en
http://localhost:3000) socket.io-clientinstalado:npm i socket.io-client- Un
tokende Tenant válido (colecciónTenant.tokensen MongoDB) - Para el agente: un
userId(ObjectId de un documentoUser) - Para el contacto: un
externalIdúnico dentro del tenant (puede ser cualquier string/número)
Paso 1 — Conectar ambos clientes
Ambos se conectan al namespace /chat. La conexión HTTP se upgradea automáticamente a WebSocket.
import { io } from 'socket.io-client';
const SERVER = 'http://localhost:3000';
const user = io(`${SERVER}/chat`);
const contact = io(`${SERVER}/chat`);
:::caution Timeout de autenticación
Desde el momento de connect, el servidor espera 10 segundos para recibir el evento authenticate. Si no llega, desconecta al cliente con exception: chat.authTimeout.
:::
Paso 2 — Autenticar el usuario (agente CRM)
user.on('connect', () => {
user.emit('authenticate', {
type: 'user',
token: 'TOKEN_DEL_TENANT',
id: 31912, // externalId numérico del usuario en el sistema externo
});
});
user.on('authenticated', (data) => {
console.log('Usuario autenticado:', data);
// { type: 'user', tenantId, externalId, username, userId?, message }
// userId solo aparece si el User existe en MongoDB con ese externalId
});
user.on('exception', (err) => console.error('Error usuario:', err));
:::note Flujo de autenticación de usuario
El gateway llama a la API externa configurada en el tenant con { token, type: 'user', id: 31912 }.
- Si la API retorna datos → sesión autenticada
- Si hay un
Useren MongoDB conexternalId = '31912'→ su_idqueda en sesión - Si no existe en MongoDB → la sesión funciona igual, sin
userIdvinculado :::
Paso 3 — Autenticar el contacto
contact.on('connect', () => {
contact.emit('authenticate', {
type: 'contact',
token: 'TOKEN_DEL_TENANT', // mismo tenant que el usuario
id: 31914, // externalId numérico del contacto
lang: 'es', // opcional, defecto 'es'
});
});
contact.on('authenticated', (data) => {
console.log('Contacto autenticado:', data);
// { type: 'contact', tenantId, contactId, externalId, username, message }
});
contact.on('exception', (err) => console.error('Error contacto:', err));
:::note findOrCreate de contacto
El gateway llama a la API externa con { token, type: 'contact', id: 31914 }.
Si el contacto no existe en MongoDB → se crea. Si ya existe → se reutiliza.
:::
Paso 4 — El contacto crea una sala
Solo hay lugar para 2 participantes por sala. El contacto la crea y recibe el roomId.
let roomId; // se comparte entre contacto y agente
contact.emit('create-room');
contact.on('room-created', (data) => {
roomId = data.roomId;
console.log('Sala creada:', data);
// { roomId, ticketId, participants: [socketId], createdAt }
// ticketId se asigna automáticamente si sendType = 'contact'
});
Cuando el contacto crea la sala, el backend hace findOrCreate de un Ticket en MongoDB y lo vincula a la sala. Todos los mensajes siguientes se persisten bajo ese ticket.
Paso 5 — El agente se une a la sala
El agente necesita conocer el roomId (enviado por tu backend, por un canal HTTP, o por un mecanismo de señalización propio).
user.emit('join-room', { roomId });
user.on('room-joined', (data) => {
console.log('Agente unido:', data);
// { roomId, ticketId, participants: [contactSocketId, agentSocketId], createdAt }
});
// El contacto recibe notificación de que alguien se unió
contact.on('user-joined', (data) => {
console.log('Nuevo participante:', data);
// { socketId, roomId }
});
Paso 6 — Intercambio de mensajes
Texto
Cualquier participante puede enviar mensajes una vez dentro de la sala.
// El contacto escribe
contact.emit('send-message', {
roomId,
content: 'Hola, necesito ayuda con mi pedido.',
});
// Ambos reciben el mensaje (incluyendo el emisor)
user.on('new-message', (msg) => {
console.log('Mensaje recibido (agente):', msg);
});
contact.on('new-message', (msg) => {
console.log('Mensaje recibido (contacto):', msg);
});
// msg: { id, roomId, ticketId?, dbId?, senderId, senderName, content, timestamp }
// El agente responde
user.emit('send-message', {
roomId,
content: 'Claro, con gusto te ayudo. ¿Cuál es tu número de pedido?',
});
Archivo
El archivo debe subirse antes vía REST (POST /api/v1/files/upload). Solo se envía el fileId por socket.
// fileId obtenido de la respuesta del endpoint REST de archivos
user.emit('send-file', {
roomId,
fileId: 'OBJECT_ID_DEL_FILE',
});
contact.on('new-file-message', (msg) => {
console.log('Archivo recibido:', msg.file);
// msg.file: { id, url, filename, mimetype, type, size }
});
Paso 7 — Marcar mensajes como leídos
Cuando el agente ve los mensajes del contacto, los marca como leídos. El broadcast va a todos los clientes conectados (no solo a la sala).
user.emit('mark-read', { ticketId: 'OBJECT_ID_DEL_TICKET' });
// Todos reciben confirmación
contact.on('messages-read', (data) => {
console.log('Leído por:', data);
// { ticketId, readBy: ObjectId, readAt: Date }
});
Paso 8 — Consultar historial y salas activas
Los mensajes en memoria se pueden consultar en cualquier momento mientras la sala exista.
// Listar salas activas del usuario
user.emit('get-rooms');
user.on('rooms-list', ({ rooms }) => {
console.log('Mis salas:', rooms);
});
// Historial de mensajes de una sala
contact.emit('get-messages', { roomId });
contact.on('messages-list', ({ roomId, messages }) => {
console.log('Mensajes:', messages);
});
:::warning Estado en memoria
El historial de mensajes y la sala solo viven mientras el proceso NestJS esté corriendo. Si el servidor reinicia, las salas desaparecen. Los mensajes vinculados a un ticket sí quedan en MongoDB (colección Message).
:::
Paso 9 — Salir de la sala
contact.emit('leave-room', { roomId });
contact.on('room-left', (data) => {
console.log('Salí de la sala:', data.roomId);
});
// El agente recibe notificación
agent.on('user-left', (data) => {
console.log('Participante salió:', data.socketId);
});
:::note leave-room vs. disconnect
leave-room abandona el room de Socket.IO pero no elimina el socketId de Room.participants en memoria. Un disconnect tampoco lo elimina. El participante sigue "listado" hasta que el servidor reinicie.
:::
Conversación completa — ejemplo self-contained
import { io } from 'socket.io-client';
const SERVER = 'http://localhost:3000';
const TOKEN = 'TOKEN_DEL_TENANT';
const user = io(`${SERVER}/chat`);
const contact = io(`${SERVER}/chat`);
let roomId;
// ── Auth ─────────────────────────────────────────────────────────────────────
user.on('connect', () =>
user.emit('authenticate', { type: 'user', token: TOKEN, id: 31912 })
);
contact.on('connect', () =>
contact.emit('authenticate', { type: 'contact', token: TOKEN, id: 31914 })
);
// ── Una vez autenticado el contacto, crea la sala ────────────────────────────
contact.on('authenticated', () => contact.emit('create-room'));
contact.on('room-created', (data) => {
roomId = data.roomId;
console.log('Sala:', roomId, '| Ticket:', data.ticketId);
// Notificar al usuario por tu propio canal (REST, pub/sub, etc.)
// Aquí lo hacemos directo para el ejemplo:
user.emit('join-room', { roomId });
});
// ── Conversación ─────────────────────────────────────────────────────────────
user.on('room-joined', () => {
contact.emit('send-message', { roomId, content: 'Hola, necesito ayuda.' });
});
user.on('new-message', (msg) => {
console.log(`[${msg.senderName}]: ${msg.content}`);
user.emit('send-message', { roomId, content: '¡Hola! Con gusto te ayudo.' });
});
contact.on('new-message', (msg) => {
console.log(`[${msg.senderName}]: ${msg.content}`);
});
// ── Cleanup ───────────────────────────────────────────────────────────────────
user.on('exception', (e) => console.error('User error:', e));
contact.on('exception', (e) => console.error('Contact error:', e));
Diagrama de secuencia — flujo completo
sequenceDiagram
participant C as Contacto
participant GW as ChatGateway
participant U as Usuario (CRM)
C->>GW: connect()
U->>GW: connect()
C->>GW: authenticate({ type:'contact', token, id:31914 })
GW-->>C: authenticated
U->>GW: authenticate({ type:'user', token, id:31912 })
GW-->>U: authenticated
C->>GW: create-room
GW-->>C: room-created { roomId, ticketId }
Note over C,U: El roomId se comparte fuera del socket (REST, pub/sub, etc.)
U->>GW: join-room { roomId }
GW-->>U: room-joined
GW-->>C: user-joined
C->>GW: send-message { roomId, content }
GW-->>C: new-message
GW-->>U: new-message
U->>GW: send-message { roomId, content }
GW-->>C: new-message
GW-->>U: new-message
U->>GW: mark-read { ticketId }
GW-->>C: messages-read
GW-->>U: messages-read
C->>GW: leave-room { roomId }
GW-->>C: room-left
GW-->>U: user-left
Manejo de errores — resumen
| Error recibido | Causa | Acción recomendada |
|---|---|---|
chat.authTimeout | No se envió authenticate en 10 s | Reconectar y autenticar inmediatamente |
chat.unauthorized | Token inválido, tenant inactivo, o sin externalIntegration configurada | Verificar token con el equipo backend |
chat.tokenExpired | Token con dateExpiration vencida | Renovar token vía REST y reconectar |
chat.contactBlocked | Contacto con isBlocked = true | El contacto no puede usar el chat |
chat.userIdRequired | id ausente en auth de tipo user | Enviar id (externalId numérico) |
chat.userNotFound | La API externa no encontró el usuario para el id enviado | Verificar que el id existe en el sistema externo |
chat.roomNotFound | roomId no existe o es de otro tenant | Crear una sala nueva |
chat.roomFull | Ya hay 2 participantes en la sala | No se pueden agregar más participantes |
chat.notParticipant | Socket no está en Room.participants | Unirse a la sala con join-room |
chat.sessionInvalidatedTenant | Tenant invalidado durante sesión activa | Reconectar con un token válido |
chat.sessionInvalidatedContact | Contacto bloqueado durante revalidación periódica | El contacto perdió acceso |