Skip to main content

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-client instalado: npm i socket.io-client
  • Un token de Tenant válido (colección Tenant.tokens en MongoDB)
  • Para el agente: un userId (ObjectId de un documento User)
  • 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 User en MongoDB con externalId = '31912' → su _id queda en sesión
  • Si no existe en MongoDB → la sesión funciona igual, sin userId vinculado :::

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 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 recibidoCausaAcción recomendada
chat.authTimeoutNo se envió authenticate en 10 sReconectar y autenticar inmediatamente
chat.unauthorizedToken inválido, tenant inactivo, o sin externalIntegration configuradaVerificar token con el equipo backend
chat.tokenExpiredToken con dateExpiration vencidaRenovar token vía REST y reconectar
chat.contactBlockedContacto con isBlocked = trueEl contacto no puede usar el chat
chat.userIdRequiredid ausente en auth de tipo userEnviar id (externalId numérico)
chat.userNotFoundLa API externa no encontró el usuario para el id enviadoVerificar que el id existe en el sistema externo
chat.roomNotFoundroomId no existe o es de otro tenantCrear una sala nueva
chat.roomFullYa hay 2 participantes en la salaNo se pueden agregar más participantes
chat.notParticipantSocket no está en Room.participantsUnirse a la sala con join-room
chat.sessionInvalidatedTenantTenant invalidado durante sesión activaReconectar con un token válido
chat.sessionInvalidatedContactContacto bloqueado durante revalidación periódicaEl contacto perdió acceso