Skip to main content

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​

RutaRol
meta.module.tsRegistra la cola meta-webhook, controladores y providers
meta.constants.tsConstantes del canal (URLs Graph, errores, OAuth, reintentos, textos)
controllers/meta-webhook.controller.tsVerificación y recepción de webhooks
controllers/meta-oauth.controller.tsInicio y callback OAuth de Instagram
services/meta-webhook.service.tsValida canal, token de verificación y firma; encola
services/meta-webhook-queue.service.tsEncola el job con dedupe y backoff
processors/meta-webhook.processor.tsWorker BullMQ que procesa cada job
services/meta-webhook-processor.service.tsNormaliza y despacha eventos
helpers/meta-event.normalizer.tsConvierte el payload crudo en eventos tipados
services/meta-inbound.service.tsLógica de entrada (mensajes, read, delivery)
services/meta-outbound.dispatcher.tsEnvío saliente por Graph API
services/meta-graph.client.tsCliente HTTP de Graph / OAuth de Instagram
services/meta-media.downloader.tsDescarga de adjuntos entrantes con límites
services/meta-oauth.service.tsFlujo OAuth de Instagram
services/meta-token-refresh.service.ts + jobs/meta-token-refresh.job.tsRenovación periódica de tokens
helpers/meta-signature.helper.tsHMAC, verify token, hash del body, detección de eco
helpers/meta-attachment.helper.tsTipos 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.

VariableObligatoriaUso
META_VERIFY_TOKENOpcional (mín. 16 caracteres)Token de verificación del webhook (hub.verify_token)
META_APP_SECRETOpcionalSecreto de la app de Meta: firma del webhook de facebook y appsecret_proof de las llamadas Graph de facebook
INSTAGRAM_APP_SECRETOpcionalSecreto de la app de Instagram: firma del webhook de instagram, OAuth y firma del state
INSTAGRAM_APP_IDOpcional (solo dígitos)client_id del OAuth de Instagram
META_OAUTH_REDIRECT_URIOpcional (URL http/https)Debe coincidir exactamente con el redirect URI registrado en el Meta App Dashboard
FRONTEND_URLOpcional (URL http/https)Destino de las redirecciones tras el OAuth (/connections)
META_GRAPH_API_VERSIONOpcional (vNN.N)Versión de Graph API; por defecto v25.0
META_ENABLE_HUMAN_AGENT_TAGOpcional (boolean)Envía con MESSAGE_TAG + HUMAN_AGENT en lugar de RESPONSE
META_TOKEN_ENCRYPTION_KEYObligatoriaClave 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étodoRutaAuthDescripción
GET/api/v1/webhooks/meta/:channelTypePública (token de verificación)Handshake de suscripción de Meta
POST/api/v1/webhooks/meta/:channelTypePública (firma HMAC)Recepción de eventos
POST/api/v1/meta/oauth/instagram/authorize@Auth() + permiso CONNECTION_CREATEDevuelve la URL de autorización de Instagram
GET/api/v1/meta/oauth/instagram/callbackPú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)​

  1. Meta llama GET /webhooks/meta/:channelType?hub.mode=subscribe&hub.verify_token=…&hub.challenge=….
  2. MetaWebhookService.verifySubscription resuelve el canal (404 si no está habilitado/configurado).
  3. Exige hub.mode === 'subscribe', hub.challenge presente y hub.verify_token igual a META_VERIFY_TOKEN (comparación en tiempo constante, timingSafeEqual). Si no, 403 Invalid webhook verification request.
  4. Devuelve el challenge como text/plain.

Flujo: recepción del webhook (POST)​

  1. main.ts crea la app con rawBody: true; el controlador recibe request.rawBody.
  2. Se valida x-hub-signature-256 (sha256= + HMAC-SHA256 del body crudo) con el secreto del canal: INSTAGRAM_APP_SECRET para instagram, META_APP_SECRET para facebook. Firma inválida: 401 Invalid webhook signature.
  3. Si payload.object no es el esperado (instagram / page) se descarta con un warning y se responde 200.
  4. MetaWebhookQueueService.enqueue agrega el job process a la cola meta-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; removeOnComplete con ventana de 3600 s; removeOnFail: false.
    • Timeout de 3 s al encolar en Redis; si falla, responde 503 Webhook could not be queued para que Meta reintente.
  5. Se responde 200 sin 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:

kindOrigenNotas
messagemessage.mid o postback.midTexto, adjuntos, isEcho, isUnsupported, replyToMid. El postback usa title/payload como texto
message_deletedmessage.is_deleted—
readread.mid o read.watermark—
deliverydelivery.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:

  1. Eco: se descarta (los mensajes enviados fuera del CRM no se guardan).
  2. 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 ….
  3. Busca la conexión con findByMetaAccount(channelType, accountId). Sin conexión: warning y se descarta.
  4. externalId del contacto = ig:<id> (Instagram) o fb:<id> (Facebook).
  5. Toma el lock por contacto (InboundLockService.withContactLock, ver Message) para evitar tickets duplicados por mensajes simultáneos del mismo contacto.
  6. Idempotencia: omite las partes cuyo externalMessageId ya existe.
  7. Resuelve el contacto: lo busca; si no existe consulta el perfil en Graph (name/username/profile_pic) y lo crea con findOrCreate. 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 a avatars/<tenantId>/<contactId>.jpg.
  8. TicketService.resolveContactTicket obtiene o crea el ticket.
  9. Resuelve el mensaje citado (reply_to.mid) como parentId de la primera parte.
  10. Cada parte pasa por ingestInbound (pipeline compartido con WhatsApp, ver Message). Los adjuntos descargables se bajan con MetaMediaDownloader, se guardan con FileService.storeIncoming y 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)​

  • read con mid: busca el mensaje por externalMessageId y avanza a read los mensajes del ticket hasta su createdAt. Con watermark: avanza los del ticket activo del contacto hasta esa fecha.
  • delivery con mids: avanza cada uno a delivered; con solo watermark, igual que read.
  • Tras avanzar se emite message_ack_changed por socket (ver Chat).
  • message_deleted solo 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).

  1. Resuelve la conexión del ticket con el token (findByIdWithMetaToken) y el destinatario (externalId sin el prefijo ig:/fb:).
  2. Contenido: { text: body } o, si el mensaje tiene MediaId, { attachment: { type, payload: { url } } } con URL firmada del storage. Si el archivo no existe, no se envía.
  3. messaging_type: RESPONSE por defecto; con META_ENABLE_HUMAN_AGENT_TAG=true, MESSAGE_TAG + tag: HUMAN_AGENT.
  4. 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).
  5. Éxito: guarda el message_id devuelto en externalMessageId.
  6. Fallo definitivo: markFailed → ack: failed con failureReason y failureDetail, y emite message_ack_changed con el motivo.
Código GraphfailureReason
10out_of_window
551recipient_unavailable
190auth_error (además marca metaConfig.authErrorAt y la conexión pasa a disconnected)
4, 613, 429rate_limited
36000unsupported_media
otrounknown

markRead envía sender_action: mark_seen al contacto cuando el agente lee mensajes.

Flujo: OAuth de Instagram​

  1. El frontend llama POST /meta/oauth/instagram/authorize y recibe la URL de https://www.instagram.com/oauth/authorize con scopes instagram_business_basic e instagram_business_manage_messages, force_reauth=true y un state.
  2. El state es un JWT firmado con INSTAGRAM_APP_SECRET, TTL 10 min, con purpose: 'meta-oauth', tenantId, userId y opcionalmente connectionId / departmentIds.
  3. Instagram redirige a GET /meta/oauth/instagram/callback. El servicio:
    1. Si viene error → redirige con reason=denied.
    2. Verifica el state (si falla → invalid_state).
    3. Intercambia el code por token corto, luego por token de larga duración (ig_exchange_token) y consulta la cuenta (/me: user_id,username,name) → si falla exchange_failed.
    4. Suscribe la cuenta a los webhooks (subscribed_fields: messages,messaging_seen) → si falla subscription_failed.
    5. Persiste: crea la conexión (estado pending, nombre @username o Instagram) o actualiza la existente, y guarda metaConfig (token cifrado) con estado connected.
  4. Siempre responde 302 a <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.expiresAt dentro de los próximos 10 días y sin authErrorAt.
  • Si el token ya expiró: markMetaAuthError (conexión disconnected).
  • 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, con select: false: hay que pedirlo explícitamente (+metaConfig.accessToken) o readMetaAccessToken lanza un error.
  • Las llamadas Graph de facebook añaden appsecret_proof (HMAC-SHA256 del token con META_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_TOKEN o 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 jobId deduplica 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 failed con out_of_window.
  • META_TOKEN_ENCRYPTION_KEY debe ser distinta de WHATSAPP_SESSION_ENCRYPTION_KEY (validado en env.validation.ts).