Módulo Meta
src/app/modules/meta/
Integra los canales de Meta con el CRM: Instagram (channelType: instagram) y Messenger / Facebook (channelType: facebook). Recibe webhooks, los encola en BullMQ, los convierte en mensajes/tickets del CRM, envía los mensajes de los agentes por la Graph API y gestiona el ciclo de vida del token de acceso (OAuth + renovación).
:::info Estado por canal
La constante META_ENABLED_CHANNELS (meta.constants.ts) habilita instagram y facebook en webhooks y envío. Sin embargo, el flujo OAuth (/meta/oauth/instagram/*) solo existe para Instagram, y POST /connections/create solo permite crear conexiones web y whatsapp-qr (ver Connection). Para Facebook no hay endpoint de alta en este código.
:::
Arquitectura
Archivos
| Ruta | Rol |
|---|---|
meta.module.ts | Registra la cola meta-webhook, controladores y providers |
meta.constants.ts | Constantes del canal (URLs Graph, errores, OAuth, reintentos, textos) |
controllers/meta-webhook.controller.ts | Verificación y recepción de webhooks |
controllers/meta-oauth.controller.ts | Inicio y callback OAuth de Instagram |
services/meta-webhook.service.ts | Valida canal, token de verificación y firma; encola |
services/meta-webhook-queue.service.ts | Encola el job con dedupe y backoff |
processors/meta-webhook.processor.ts | Worker BullMQ que procesa cada job |
services/meta-webhook-processor.service.ts | Normaliza y despacha eventos |
helpers/meta-event.normalizer.ts | Convierte el payload crudo en eventos tipados |
services/meta-inbound.service.ts | Lógica de entrada (mensajes, read, delivery) |
services/meta-outbound.dispatcher.ts | Envío saliente por Graph API |
services/meta-graph.client.ts | Cliente HTTP de Graph / OAuth de Instagram |
services/meta-media.downloader.ts | Descarga de adjuntos entrantes con límites |
services/meta-oauth.service.ts | Flujo OAuth de Instagram |
services/meta-token-refresh.service.ts + jobs/meta-token-refresh.job.ts | Renovación periódica de tokens |
helpers/meta-signature.helper.ts | HMAC, verify token, hash del body, detección de eco |
helpers/meta-attachment.helper.ts | Tipos de adjunto, avisos, ids externos por parte |
Variables de entorno
Solo nombres; los valores son secretos y no se documentan. Ver también Variables de entorno.
| Variable | Obligatoria | Uso |
|---|---|---|
META_VERIFY_TOKEN | Opcional (mín. 16 caracteres) | Token de verificación del webhook (hub.verify_token) |
META_APP_SECRET | Opcional | Secreto de la app de Meta: firma del webhook de facebook y appsecret_proof de las llamadas Graph de facebook |
INSTAGRAM_APP_SECRET | Opcional | Secreto de la app de Instagram: firma del webhook de instagram, OAuth y firma del state |
INSTAGRAM_APP_ID | Opcional (solo dígitos) | client_id del OAuth de Instagram |
META_OAUTH_REDIRECT_URI | Opcional (URL http/https) | Debe coincidir exactamente con el redirect URI registrado en el Meta App Dashboard |
FRONTEND_URL | Opcional (URL http/https) | Destino de las redirecciones tras el OAuth (/connections) |
META_GRAPH_API_VERSION | Opcional (vNN.N) | Versión de Graph API; por defecto v25.0 |
META_ENABLE_HUMAN_AGENT_TAG | Opcional (boolean) | Envía con MESSAGE_TAG + HUMAN_AGENT en lugar de RESPONSE |
META_TOKEN_ENCRYPTION_KEY | Obligatoria | Clave AES-256 (base64, 32 bytes) para cifrar el token de acceso en BD |
Las opcionales se leen en src/config/meta.config.ts (namespace meta). Si falta META_VERIFY_TOKEN o el secreto del canal, el webhook responde 404 (_resolveChannel), y el OAuth responde 404 con code: META_NOT_CONFIGURED si falta alguna de INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET, META_OAUTH_REDIRECT_URI, FRONTEND_URL.
Endpoints
| Método | Ruta | Auth | Descripción |
|---|---|---|---|
GET | /api/v1/webhooks/meta/:channelType | Pública (token de verificación) | Handshake de suscripción de Meta |
POST | /api/v1/webhooks/meta/:channelType | Pública (firma HMAC) | Recepción de eventos |
POST | /api/v1/meta/oauth/instagram/authorize | @Auth() + permiso CONNECTION_CREATE | Devuelve la URL de autorización de Instagram |
GET | /api/v1/meta/oauth/instagram/callback | Pública (valida state JWT) | Callback de Instagram; responde 302 al frontend |
:channelType es instagram o facebook. Los controladores de webhook usan @RawResponse() (ver Interceptor): la respuesta no se envuelve en el formato { statusCode, status, data }. El controlador de webhooks está oculto de Swagger (@ApiExcludeController) y el callback también (@ApiExcludeEndpoint).
:::tip URL a registrar en Meta
https://<host>/api/v1/webhooks/meta/instagram (comentario de .env.example).
:::
POST /meta/oauth/instagram/authorize
Body (AuthorizeInstagramDto):
{
connectionId?: string // MongoId: reautoriza esa conexión; omitido crea una nueva
departmentIds?: string[] // MongoIds únicos: departamentos de la conexión nueva
}
Respuesta: { data: { url } } (HTTP 200). Validaciones: si se envía connectionId debe ser una conexión instagram del tenant (si no, 404); si se envían departmentIds todos deben existir en el tenant (si no, 400 Unknown departments).
Flujo: verificación del webhook (GET)
- Meta llama
GET /webhooks/meta/:channelType?hub.mode=subscribe&hub.verify_token=…&hub.challenge=…. MetaWebhookService.verifySubscriptionresuelve el canal (404 si no está habilitado/configurado).- Exige
hub.mode === 'subscribe',hub.challengepresente yhub.verify_tokenigual aMETA_VERIFY_TOKEN(comparación en tiempo constante,timingSafeEqual). Si no,403 Invalid webhook verification request. - Devuelve el
challengecomotext/plain.
Flujo: recepción del webhook (POST)
main.tscrea la app conrawBody: true; el controlador reciberequest.rawBody.- Se valida
x-hub-signature-256(sha256=+ HMAC-SHA256 del body crudo) con el secreto del canal:INSTAGRAM_APP_SECRETpara instagram,META_APP_SECRETpara facebook. Firma inválida:401 Invalid webhook signature. - Si
payload.objectno es el esperado (instagram/page) se descarta con un warning y se responde 200. MetaWebhookQueueService.enqueueagrega el jobprocessa la colameta-webhook:jobId = <channelType>-<sha256(rawBody)>→ deduplicación de reintentos idénticos de Meta.- Si el payload contiene un evento eco (
is_echo), se retrasa 2 s (ECHO_DELAY_MS). - 5 intentos, backoff exponencial base 5 s;
removeOnCompletecon ventana de 3600 s;removeOnFail: false. - Timeout de 3 s al encolar en Redis; si falla, responde
503 Webhook could not be queuedpara que Meta reintente.
- Se responde
200sin cuerpo; el procesamiento ocurre en el worker.
Constantes en src/app/core/constants/queue.constants.ts (QUEUE_NAMES.META_WEBHOOK, META_WEBHOOK_JOB, META_WEBHOOK_QUEUE_DEFAULTS).
Normalización de eventos
normalizeMetaWebhook recorre entry[].messaging, entry[].standby y entry[].changes (solo field === 'messages', usado por los eventos de prueba del dashboard) y produce MetaInboundEvent con kind:
kind | Origen | Notas |
|---|---|---|
message | message.mid o postback.mid | Texto, adjuntos, isEcho, isUnsupported, replyToMid. El postback usa title/payload como texto |
message_deleted | message.is_deleted | — |
read | read.mid o read.watermark | — |
delivery | delivery.mids / watermark | — |
Detalles verificados: se ignoran los mensajes is_self; para un eco la cuenta es el sender y el contacto el recipient (al revés en mensajes normales); los timestamps menores a 1e12 se interpretan como segundos; en adjuntos se elimina el share duplicado de un ig_post con el mismo ig_post_media_id.
Flujo: mensaje entrante
MetaInboundService.handleMessage:
- Eco: se descarta (los mensajes enviados fuera del CRM no se guardan).
- Construye las partes del mensaje: una por adjunto (
externalMessageId = mid,mid#1,mid#2… con separador#), o una sola de texto; un mensaje no soportado genera un aviso[Instagram] Mensaje no soportado. Ábrelo en …. - Busca la conexión con
findByMetaAccount(channelType, accountId). Sin conexión: warning y se descarta. externalIddel contacto =ig:<id>(Instagram) ofb:<id>(Facebook).- Toma el lock por contacto (
InboundLockService.withContactLock, ver Message) para evitar tickets duplicados por mensajes simultáneos del mismo contacto. - Idempotencia: omite las partes cuyo
externalMessageIdya existe. - Resuelve el contacto: lo busca; si no existe consulta el perfil en Graph (
name/username/profile_pic) y lo crea confindOrCreate. Nombre visible:name→@username→Instagram ······NNNNNN(últimos 6 dígitos). Los errores Graph 230, 9010 y 100 se tratan como perfil anónimo; el 190 marca la conexión con error de autenticación. El avatar se descarga en segundo plano y se sube aavatars/<tenantId>/<contactId>.jpg. TicketService.resolveContactTicketobtiene o crea el ticket.- Resuelve el mensaje citado (
reply_to.mid) comoparentIdde la primera parte. - Cada parte pasa por
ingestInbound(pipeline compartido con WhatsApp, ver Message). Los adjuntos descargables se bajan conMetaMediaDownloader, se guardan conFileService.storeIncomingy se firma su URL.
Adjuntos (meta.constants.ts): son descargables todos los tipos salvo fallback y post (que se tratan como enlace y se anexan al texto). Si la descarga falla de forma definitiva (HTTP no 2xx distinto de 429/5xx, o tamaño excedido) se inserta un aviso No se pudo recuperar el adjunto; los errores transitorios (red, 429, 5xx) relanzan y el job se reintenta. Límites por tipo de archivo: imagen 5 MB, documento 10 MB, audio 15 MB, video 20 MB (FILE_SIZE), timeout de descarga 20 s.
Acuses (read / delivery)
readconmid: busca el mensaje porexternalMessageIdy avanza areadlos mensajes del ticket hasta sucreatedAt. Conwatermark: avanza los del ticket activo del contacto hasta esa fecha.deliveryconmids: avanza cada uno adelivered; con solowatermark, igual que read.- Tras avanzar se emite
message_ack_changedpor socket (ver Chat). message_deletedsolo registra un warning: el CRM no tiene concepto de mensaje eliminado.
Flujo: mensaje saliente
MetaOutboundDispatcher se registra en OutboundDispatcherRegistry para cada canal de META_ENABLED_CHANNELS (ver Message).
- Resuelve la conexión del ticket con el token (
findByIdWithMetaToken) y el destinatario (externalIdsin el prefijoig:/fb:). - Contenido:
{ text: body }o, si el mensaje tieneMediaId,{ attachment: { type, payload: { url } } }con URL firmada del storage. Si el archivo no existe, no se envía. messaging_type:RESPONSEpor defecto; conMETA_ENABLE_HUMAN_AGENT_TAG=true,MESSAGE_TAG+tag: HUMAN_AGENT.- Envío con reintentos: hasta 2 reintentos con esperas de 1 s y 3 s, solo para errores transitorios o códigos de rate limit (4, 613, 429).
- Éxito: guarda el
message_iddevuelto enexternalMessageId. - Fallo definitivo:
markFailed→ack: failedconfailureReasonyfailureDetail, y emitemessage_ack_changedcon el motivo.
| Código Graph | failureReason |
|---|---|
| 10 | out_of_window |
| 551 | recipient_unavailable |
| 190 | auth_error (además marca metaConfig.authErrorAt y la conexión pasa a disconnected) |
| 4, 613, 429 | rate_limited |
| 36000 | unsupported_media |
| otro | unknown |
markRead envía sender_action: mark_seen al contacto cuando el agente lee mensajes.
Flujo: OAuth de Instagram
- El frontend llama
POST /meta/oauth/instagram/authorizey recibe la URL dehttps://www.instagram.com/oauth/authorizecon scopesinstagram_business_basiceinstagram_business_manage_messages,force_reauth=truey unstate. - El
statees un JWT firmado conINSTAGRAM_APP_SECRET, TTL 10 min, conpurpose: 'meta-oauth',tenantId,userIdy opcionalmenteconnectionId/departmentIds. - Instagram redirige a
GET /meta/oauth/instagram/callback. El servicio:- Si viene
error→ redirige conreason=denied. - Verifica el
state(si falla →invalid_state). - Intercambia el
codepor token corto, luego por token de larga duración (ig_exchange_token) y consulta la cuenta (/me:user_id,username,name) → si fallaexchange_failed. - Suscribe la cuenta a los webhooks (
subscribed_fields: messages,messaging_seen) → si fallasubscription_failed. - Persiste: crea la conexión (estado
pending, nombre@usernameoInstagram) o actualiza la existente, y guardametaConfig(token cifrado) con estadoconnected.
- Si viene
- Siempre responde
302a<FRONTEND_URL>/connections?meta=connected&connectionId=…o?meta=error&reason=….
Razones de error posibles (META_OAUTH_ERROR): denied, invalid_state, exchange_failed, subscription_failed, account_in_use (la cuenta pertenece a otro tenant o a otra conexión), account_mismatch (reautorización con otra cuenta de Instagram), connection_not_found.
Renovación de tokens
MetaTokenRefreshJob (@Interval, cada 1 h) llama a refreshExpiringTokens:
- Selecciona conexiones de Instagram con
metaConfig.expiresAtdentro de los próximos 10 días y sinauthErrorAt. - Si el token ya expiró:
markMetaAuthError(conexióndisconnected). - Si no:
refresh_access_token(ig_refresh_token) y guarda el token nuevo (cifrado) con su nueva expiración (si Meta no informa duración, se asume 60 días). - Si Graph responde 190 (token revocado) →
markMetaAuthError. Otros errores solo se registran.
La reautorización (OAuth con connectionId) limpia authErrorAt y vuelve a poner la conexión en connected.
Seguridad
- El token de acceso se guarda cifrado (AES-256-GCM, Crypto) en
metaConfig.accessToken, conselect: false: hay que pedirlo explícitamente (+metaConfig.accessToken) oreadMetaAccessTokenlanza un error. - Las llamadas Graph de facebook añaden
appsecret_proof(HMAC-SHA256 del token conMETA_APP_SECRET); las de Instagram no. - Webhook: firma HMAC y verify token comparados con
timingSafeEqual. - Timeout de 3 s en las llamadas Graph (
META_GRAPH.TIMEOUT_MS); un fallo de red se marca como transitorio.
Gotchas
- Sin
META_VERIFY_TOKENo sin el secreto del canal, el webhook responde 404 (no 403/401): parece "ruta inexistente". - Facebook y Instagram usan secretos distintos para validar la firma.
- Los mensajes enviados desde la app de Instagram/Messenger (ecos) no se guardan en el CRM.
- El
jobIddeduplica por hash del body: un reintento idéntico de Meta no se procesa dos veces durante la ventana de 1 h. - Instagram limita el envío a la ventana de respuesta de Meta; fuera de ella el mensaje queda
failedconout_of_window. META_TOKEN_ENCRYPTION_KEYdebe ser distinta deWHATSAPP_SESSION_ENCRYPTION_KEY(validado enenv.validation.ts).