Blog

Construyendo una bandeja de entrada unificada para redes sociales con APIs

Descubre cómo crear una bandeja de entrada unificada para redes sociales que agrupe mensajes de Facebook, Instagram, Twitter y más en una sola interfaz API.

Por

+8

Publica en todo. Una API.

Try Free

Gestionar las conversaciones con los clientes en Facebook, Instagram, Twitter, Bluesky, Reddit y Telegram requiere un cambio constante de contexto. Un buzón unificado de redes sociales resuelve esto al agrupar todos los mensajes en una única interfaz, permitiendo que tu equipo responda más rápido sin tener que saltar entre plataformas.

Esta guía te llevará a construir una bandeja de entrada multiplataforma desde cero. Aprenderás a diseñar el modelo de datos, normalizar mensajes de diferentes APIs, gestionar webhooks en tiempo real e implementar paginación basada en cursores que funcione en todas las plataformas. Al final, tendrás una arquitectura lista para producción para agregar mensajes sociales a gran escala.

El reto de la mensajería en múltiples plataformas

Cada plataforma social tiene su propia API de mensajería con flujos de autenticación únicos, estructuras de datos, límites de tasa y formatos de webhook. Esto es lo que te encontrarás:

Facebook e Instagram utiliza la API de la Plataforma Messenger con IDs de usuario específicos de la página. Los mensajes llegan a través de webhooks y necesitas tokens de acceso de página separados para cada cuenta conectada.

Twitter/X ofrece la API de Mensajes Directos, pero el acceso requiere niveles de API superiores. La consulta de mensajes es el método principal, ya que el soporte para webhooks es limitado.

Bluesky utiliza el Protocolo AT con una arquitectura descentralizada. Los mensajes directos funcionan de manera diferente a las plataformas tradicionales, requiriendo consultas basadas en léxico.

Reddit ofrece mensajería privada a través de su API, pero los límites de tasa son agresivos (60 solicitudes por minuto para clientes OAuth).

Telegram proporciona a la API de Bot la opción de polling prolongado o webhooks. Cada bot recibe su propio token, y los mensajes incluyen metadatos detallados sobre los tipos de chat.

Los principales desafíos al construir una API de bandeja de entrada para redes sociales incluyen:

  1. Complejidad de autenticaciónCada plataforma requiere diferentes flujos de OAuth, estrategias de renovación de tokens y ámbitos de permisos.
  2. Normalización de datosUn "mensaje" significa algo diferente en cada plataforma.
  3. Entrega en tiempo realLos formatos de webhook varían enormemente, y algunas plataformas ni siquiera los soportan.
  4. Gestión de límites de tasaAlcanzar los límites en una plataforma no debería afectar todo tu buzón.
  5. Inconsistencia en la paginaciónAlgunos utilizan cursores, otros utilizan desplazamientos, y la paginación basada en marcas de tiempo se comporta de manera diferente en cada lugar.

Visión General de la Arquitectura para una Bandeja de Entrada Unificada de Redes Sociales

Una bandeja de entrada bien diseñada para múltiples plataformas separa las preocupaciones en capas distintas:

Unified Inbox Architecture

```typescript
// Capas de arquitectura para un sistema de bandeja de entrada unificada

interface InboxArchitecture {
  // Capa 1: Adaptadores de Plataforma
  adapters: {
    facebook: FacebookAdapter;
    instagram: InstagramAdapter;
    twitter: TwitterAdapter;
    bluesky: BlueskyAdapter;
    reddit: RedditAdapter;
    telegram: TelegramAdapter;
  };
  
  // Capa 2: Motor de Normalización
  normalizer: {
    transformMessage: (platformMessage: unknown, platform: string) => NormalizedMessage;
    transformConversation: (platformConvo: unknown, platform: string) => NormalizedConversation;
  };
  
  // Capa 3: Servicio de Agregación
  aggregator: {
    fetchAllConversations: (accounts: SocialAccount[]) => Promise>;
    fetchAllMessages: (conversationIds: string[]) => Promise>;
  };
  
  // Capa 4: Capa de Almacenamiento
  storage: {
    conversations: ConversationRepository;
    messages: MessageRepository;
    accounts: AccountRepository;
  };
  
  // Capa 5: Capa API
  api: {
    getConversations: (filters: ConversationFilters) => Promise>;
    getMessages: (conversationId: string, cursor?: string) => Promise>;
    sendMessage: (conversationId: string, content: MessageContent) => Promise;
  };
}
```

El patrón de adaptador aísla la lógica específica de la plataforma. Cuando Instagram cambia su API, actualizas un adaptador sin modificar el resto de tu base de código.

Plataformas Soportadas y Sus APIs

No todas las plataformas admiten todas las funciones de bandeja de entrada. Aquí tienes un enfoque basado en la configuración para gestionar las capacidades de las plataformas:

```typescript
// Configuración de soporte de plataforma para funciones de bandeja de entrada

export type InboxFeature = 'mensajes' | 'comentarios' | 'reseñas';

export const INBOX_PLATFORMS = {
  mensajes: ['facebook', 'instagram', 'twitter', 'bluesky', 'reddit', 'telegram'] as const,
  comentarios: ['facebook', 'instagram', 'twitter', 'bluesky', 'threads', 'youtube', 'linkedin', 'reddit'] as const,
  reseñas: ['facebook', 'googlebusiness'] as const,
} as const;

export type MessagesPlatform = (typeof INBOX_PLATFORMS.mensajes)[number];
export type CommentsPlatform = (typeof INBOX_PLATFORMS.comentarios)[number];
export type ReviewsPlatform = (typeof INBOX_PLATFORMS.reseñas)[number];

// Comprobar si una plataforma admite una función específica
export function isPlatformSupported(platform: string, feature: InboxFeature): boolean {
  return (INBOX_PLATFORMS[feature] as readonly string[]).includes(platform);
}

// Validar y devolver mensajes de error útiles
export function validatePlatformSupport(
  platform: string,
  feature: InboxFeature
): { valid: true } | { valid: false; error: string; supportedPlatforms: readonly string[] } {
  if (!isPlatformSupported(platform, feature)) {
    const featureLabel = feature === 'mensajes' ? 'mensajes directos' : feature;
    return {
      valid: false,
      error: `La plataforma '${platform}' no admite ${featureLabel}`,
      supportedPlatforms: INBOX_PLATFORMS[feature],
    };
  }
  return { valid: true };
}
```

Nota: TikTok y Pinterest están notablemente ausentes de las listas de mensajes y comentarios. Sus APIs no ofrecen acceso de lectura a los mensajes o comentarios de los usuarios, lo que las hace inadecuadas para la agregación de bandejas de entrada.

Aquí tienes una comparación de las capacidades de la API entre plataformas:

PlatformMessagesCommentsReviewsWebhooksLímites de tasa
Facebook200€/hora/usuario
Instagram200€/hora/usuario
TwitterLimited15/15min (DMs)
Bluesky3000/5min
Reddit60/minuto
Telegram30/seg
LinkedIn✅ (Organizaciones)100/día
YouTube10,000/día

Diseño del Modelo de Datos para Conversaciones y Mensajes

Tu modelo de datos debe gestionar la unión de todas las características de las plataformas mientras mantiene relaciones limpias. Aquí tienes un esquema de MongoDB que funciona bien:

```javascript
// Esquema de conversación - representa un hilo con un participante

import mongoose from "mongoose";

const conversationSchema = new mongoose.Schema(
  {
    userId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "User",
      required: true,
      index: true,
    },
    accountId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "SocialAccount",
      required: true,
      index: true,
    },
    profileId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Profile",
      required: false,
    },
    platform: {
      type: String,
      enum: ["instagram", "facebook", "telegram", "twitter", "bluesky", "reddit"],
      required: true,
      index: true,
    },
    // Identificador de conversación nativo de la plataforma
    platformConversationId: {
      type: String,
      required: true,
      index: true,
    },
    status: {
      type: String,
      enum: ["active", "archived"],
      default: "active",
      index: true,
    },
    // Información del participante en caché para una visualización rápida
    participantName: String,
    participantUsername: String,
    participantPicture: String,
    participantId: String,
    // Datos de vista previa para la lista de bandeja de entrada
    lastMessage: String,
    lastMessageAt: {
      type: Date,
      index: true,
    },
    // Extras específicos de la plataforma
    metadata: {
      type: Map,
      of: mongoose.Schema.Types.Mixed,
    },
  },
  { timestamps: true }
);

// Prevenir conversaciones duplicadas por cuenta
conversationSchema.index(
  { accountId: 1, platformConversationId: 1 }, 
  { unique: true }
);

// Optimizar consultas de la lista de bandeja de entrada
conversationSchema.index(
  { accountId: 1, status: 1, lastMessageAt: -1 }
);
```

El esquema de mensajes abarca toda la variedad de tipos de contenido:

// Esquema de mensaje - mensajes individuales dentro de conversaciones

const messageSchema = new mongoose.Schema(
  {
    userId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "User",
      required: true,
      index: true,
    },
    accountId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "SocialAccount",
      required: true,
      index: true,
    },
    conversationId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Conversation",
      required: true,
      index: true,
    },
    platform: {
      type: String,
      enum: ["instagram", "facebook", "telegram", "twitter", "bluesky", "reddit"],
      required: true,
      index: true,
    },
    platformMessageId: {
      type: String,
      required: true,
      index: true,
    },
    direction: {
      type: String,
      enum: ["incoming", "outgoing"],
      required: true,
      index: true,
    },
    senderId: {
      type: String,
      required: true,
    },
    senderName: String,
    senderPicture: String,
    text: String,
    attachments: [{
      type: {
        type: String,
        enum: ["image", "video", "audio", "file", "sticker", "share"],
      },
      url: String,
      payload: mongoose.Schema.Types.Mixed,
    }],
    // Interacciones de historias de Instagram
    storyReply: {
      storyId: String,
      storyUrl: String,
    },
    isStoryMention: {
      type: Boolean,
      default: false,
    },
    platformTimestamp: {
      type: Date,
      required: true,
      index: true,
    },
    isRead: {
      type: Boolean,
      default: false,
    },
    autoResponseSent: {
      type: Boolean,
      default: false,
    },
    // Mantener datos en bruto para depuración
    rawPayload: mongoose.Schema.Types.Mixed,
  },
  { timestamps: true }
);

// Prevenir mensajes duplicados
messageSchema.index(
  { accountId: 1, platformMessageId: 1 }, 
  { unique: true }
);

// Obtención de mensajes en orden cronológico
messageSchema.index(
  { conversationId: 1, platformTimestamp: -1 }
);

// Consultas de

The direction el campo es crítico. Distingue entre los mensajes que envió tu equipo (outgoing) y mensajes de los clientes (incomingEsto potencia funciones como el conteo de mensajes no leídos y el análisis del tiempo de respuesta.

Estrategia de Agregación y Normalización

La capa de agregación obtiene datos de múltiples cuentas en paralelo, gestionando las fallas de manera eficiente. Aquí tienes la implementación principal:

```typescript
// Utilidades de agregación para la obtención de datos de múltiples cuentas

export interface AggregationError {
  accountId: string;
  accountUsername?: string;
  platform: string;
  error: string;
  code?: string;
  retryAfter?: number;
}

export interface AggregatedResult {
  items: T[];
  errors: AggregationError[];
}

export async function aggregateFromAccounts(
  accounts: SocialAccount[],
  fetcher: (account: SocialAccount) => Promise,
  options?: { timeout?: number }
): Promise> {
  const timeout = options?.timeout || 10000;
  const results: T[] = [];
  const errors: AggregationError[] = [];

  const fetchPromises = accounts.map(async (account) => {
    try {
      const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => reject(new Error('Tiempo de espera agotado')), timeout);
      });

      const items = await Promise.race([fetcher(account), timeoutPromise]);
      return { account, items, error: null };
    } catch (error: unknown) {
      const err = error as Error & { code?: string; retryAfter?: number };
      return {
        account,
        items: [] as T[],
        error: {
          accountId: account._id?.toString() || account.id,
          accountUsername: account.username,
          platform: account.platform,
          error: err.message || 'Error desconocido',
          code: err.code,
          retryAfter: err.retryAfter,
        },
      };
    }
  });

  const settledResults = await Promise.all(fetchPromises);

  for (const result of settledResults) {
    if (result.error) {
      errors.push(result.error);
    } else {
      results.push(...result.items);
    }
  }

  return { items: results, errors };
}
```

Este patrón garantiza que un límite de tasa en Twitter no te impida mostrar mensajes de Facebook. errors array permite que tu frontend muestre resultados parciales con las advertencias adecuadas.

Para normalizar los datos específicos de cada plataforma en tu formato unificado:

```typescript
// Normalización de mensajes de formatos específicos de la plataforma

interface MensajeNormalizado {
  idMensajePlataforma: string;
  plataforma: string;
  direccion: 'entrante' | 'saliente';
  idRemitente: string;
  nombreRemitente?: string;
  texto?: string;
  adjuntos: Adjunto[];
  timestampPlataforma: Date;
}

function normalizarMensajeInstagram(
  crudo: MensajeInstagram, 
  idCuenta: string
): MensajeNormalizado {
  const esSaliente = crudo.from.id === idCuenta;
  
  return {
    idMensajePlataforma: crudo.id,
    plataforma: 'instagram',
    direccion: esSaliente ? 'saliente' : 'entrante',
    idRemitente: crudo.from.id,
    nombreRemitente: crudo.from.username,
    texto: crudo.message,
    adjuntos: (crudo.attachments?.data || []).map(att => ({
      tipo: mapearTipoAdjuntoInstagram(att.type),
      url: att.url || att.payload?.url,
      payload: att.payload,
    })),
    timestampPlataforma: new Date(crudo.created_time),
  };
}

function normalizarMensajeTwitter(
  crudo: DMdeTwitter,
  idCuenta: string
): MensajeNormalizado {
  const esSaliente = crudo.sender_id === idCuenta;
  
  return {
    idMensajePlataforma: crudo.id,
    plataforma: 'twitter',
    direccion: esSaliente ? 'saliente' : 'entrante',
    idRemitente: crudo.sender_id,
    nombreRemitente: crudo.sender?.name,
    texto: crudo.text,
    adjuntos: (crudo.attachments?.media_keys || []).map(key => ({
      tipo: 'imagen', // Los adjuntos de DM de Twitter son típicamente imágenes
      url: crudo.includes?.media?.find(m => m.media_key === key)?.url,
    })),
    timestampPlataforma: new Date(crudo.created_at),
  };
}
```

Actualizaciones en tiempo real con Webhooks

Los webhooks eliminan la necesidad de realizar sondeos constantes. Cada plataforma tiene su propio formato de webhook, por lo que necesitas controladores específicos para cada plataforma que se integren en tu canal de procesamiento unificado.

// Manejador de webhook para mensajes de Instagram

import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get('x-hub-signature-256');
  
  // Verificar la autenticidad del webhook
  if (!verifyInstagramSignature(body, signature)) {
    return NextResponse.json({ error: 'Firma no válida' }, { status: 401 });
  }
  
  const payload = JSON.parse(body);
  
  // Procesar cada entrada (puede contener múltiples actualizaciones)
  for (const entry of payload.entry) {
    for (const messaging of entry.messaging || []) {
      await processInstagramMessage(entry.id, messaging);
    }
  }
  
  return NextResponse.json({ success: true });
}

function verifyInstagramSignature(body: string, signature: string | null): boolean {
  if (!signature || !process.env.INSTAGRAM_APP_SECRET) {
    return false;
  }
  
  const expectedSignature = 'sha256=' + crypto
    .createHmac('sha256', process.env.INSTAGRAM_APP_SECRET)
    .update(body)
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

async function processInstagramMessage(
  pageId: string, 
  messaging: InstagramMessagingEvent
) {
  // Encontrar la cuenta conectada
  const account = await SocialAccount.findOne({
    platform: 'instagram',
    platformAccountId: pageId,
    isActive: true,
  });
  
  if (!account) {
    console.warn(`No se encontró ninguna cuenta activa para la página de Instagram ${pageId}`);
    return;
  }
  
  // Encontrar o crear conversación
  const conversation = await findOrCreateConversation({
    accountId: account._id,
    platform: 'instagram',
    platformConversationId: messaging.sender.id, // Para Instagram, el ID del remitente es el ID de la conversación
    participantId: messaging.sender.id,
  });
  
  // Normalizar y almacenar el mensaje
  const normalizedMessage = normalizeInstagramWebhookMessage(messaging, account);
  
  await Message.findOneAndUpdate(
    { 
      accountId: account._id, 
      platformMessageId: normalizedMessage.platformMessageId 
    },

Nota: Verifica siempre las firmas de los webhooks antes de procesarlos. Sin esta verificación, los atacantes podrían inyectar mensajes falsos en tu sistema.

Para plataformas sin soporte de webhook (como Bluesky y Reddit), implementa un servicio de sondeo:

// Servicio de sondeo para plataformas sin webhooks

class ServicioSondeoMensajes {
  private intervalos: Map<string, NodeJS.Timeout> = new Map();
  
  async iniciarSondeo(cuentaId: string, plataforma: string) {
    const intervaloSondeo = this.obtenerIntervaloSondeo(plataforma);
    
    const sondear = async () => {
      try {
        const cuenta = await CuentaSocial.findById(cuentaId);
        if (!cuenta || !cuenta.isActive) {
          this.detenerSondeo(cuentaId);
          return;
        }
        
        const adaptador = this.obtenerAdaptador(plataforma);
        const mensajes = await adaptador.obtenerNuevosMensajes(cuenta);
        
        for (const mensaje of mensajes) {
          await this.procesarMensaje(cuenta, mensaje);
        }
      } catch (error) {
        console.error(`Error de sondeo para ${cuentaId}:`, error);
      }
    };
    
    // Sondeo inicial
    await sondear();
    
    // Programar sondeos recurrentes
    const intervalo = setInterval(sondear, intervaloSondeo);
    this.intervalos.set(cuentaId, intervalo);
  }
  
  private obtenerIntervaloSondeo(plataforma: string): number {
    // Respetar los límites de tasa con intervalos conservadores
    const intervalos: Record<string, number> = {
      bluesky: 30000,  // 30 segundos
      reddit: 60000,   // 60 segundos (límites de tasa estrictos)
      twitter: 60000,  // 60 segundos (acceso limitado a DM)
    };
    return intervalos[plataforma] || 30000;
  }
  
  detenerSondeo(cuentaId: string) {
    const intervalo = this.intervalos.get(cuentaId);
    if (intervalo) {
      clearInterval(intervalo);
      this.intervalos.delete(cuentaId);
    }
  }
}

Build faster with Late

One API call to post everywhere. No OAuth headaches. No platform-specific code.

Free tier • No credit card • 99.97% uptime

Paginación basada en cursor en múltiples plataformas

Cuando agregas mensajes sociales de múltiples cuentas, la paginación tradicional por desplazamiento se vuelve ineficaz. La llegada de un nuevo mensaje desplaza todos los desplazamientos. La paginación basada en cursores resuelve este problema utilizando identificadores estables.

El reto: cada plataforma utiliza diferentes formatos de cursor. Tu API unificada necesita un cursor que codifique suficiente información para reanudar la paginación en todas las fuentes.

```typescript
// Paginación basada en cursor para resultados agregados

export interface PaginationInfo {
  hasMore: boolean;
  nextCursor: string | null;
  totalCount?: number;
}

// Formato del cursor: {timestamp}_{accountId}_{itemId}
// Esto permite una paginación estable incluso cuando se añaden elementos

export function paginateWithCursor<T extends { id?: string; _id?: unknown }>(
  items: T[],
  cursor: string | null,
  limit: number,
  getTimestamp: (item: T) => string,
  getAccountId: (item: T) => string
): { items: T[]; pagination: PaginationInfo } {
  let filteredItems = items;

  // Aplicar filtro de cursor si se proporciona
  if (cursor) {
    const [cursorTimestamp, cursorAccountId, cursorItemId] = cursor.split('_');
    const cursorTime = new Date(cursorTimestamp).getTime();

    filteredItems = items.filter((item) => {
      const itemTime = new Date(getTimestamp(item)).getTime();
      const itemAccountId = getAccountId(item);
      const itemId = item.id || String(item._id);

      // Elementos antes del cursor (para orden descendente)
      if (itemTime < cursorTime) return true;
      if (itemTime === cursorTime) {
        if (itemAccountId > cursorAccountId) return true;
        if (itemAccountId === cursorAccountId && itemId > cursorItemId) return true;
      }
      return false;
    });
  }

  // Aplicar límite
  const paginatedItems = filteredItems.slice(0, limit);
  const hasMore = filteredItems.length > limit;

  // Generar el siguiente cursor a partir del último elemento
  let nextCursor: string | null = null;
  if (hasMore && paginatedItems.length > 0) {
    const lastItem = paginatedItems[paginatedItems.length - 1];
    const timestamp = getTimestamp(lastItem);
    const accountId = getAccountId(lastItem);
    const itemId = lastItem.id || String(lastItem._id);
    nextCursor = `${timestamp}_${accountId}_${itemId}`;
  }

Uso en tu punto final de API:

// Punto final de la API utilizando paginación por cursor

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const cursor = searchParams.get('cursor');
  const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
  const status = searchParams.get('status') || 'active';
  
  // Obtener cuentas del usuario
  const accounts = await getInboxAccounts(userId, 'messages');
  
  // Obtener conversaciones de todas las cuentas
  const { items: conversations, errors } = await aggregateFromAccounts(
    accounts,
    async (account) => {
      return Conversation.find({
        accountId: account._id,
        status,
      })
        .sort({ lastMessageAt: -1 })
        .limit(limit * 2) // Obtener extra para fusionar
        .lean();
    }
  );
  
  // Ordenar todas las conversaciones por la hora del último mensaje
  const sorted = sortItems(
    conversations,
    'lastMessageAt',
    'desc',
    { lastMessageAt: (c) => c.lastMessageAt }
  );
  
  // Aplicar paginación por cursor
  const { items: paginated, pagination } = paginateWithCursor(
    sorted,
    cursor,
    limit,
    (c) => c.lastMessageAt?.toISOString() || '',
    (c) => c.accountId.toString()
  );
  
  return NextResponse.json({
    conversations: paginated,
    pagination,
    meta: createAggregationMeta(accounts.length, errors),
  });
}

Manejo de Funciones Específicas de la Plataforma

Cada plataforma tiene características únicas que no se ajustan de manera sencilla a un modelo unificado. Instagram cuenta con respuestas a historias y menciones. Twitter tiene DMs citados. Telegram dispone de cadenas de respuestas y reacciones.

La clave está en almacenar datos específicos de la plataforma de manera flexible. metadata campo mientras expones características comunes a través de tu interfaz normalizada:

```typescript
// Manejo de respuestas a historias de Instagram

interface InstagramStoryReply {
  storyId: string;
  storyUrl: string;
  storyMediaType: 'imagen' | 'video';
  expiresAt: Date; // Las historias expiran después de 24 horas
}

function normalizeInstagramWebhookMessage(
  messaging: InstagramMessagingEvent,
  account: SocialAccount
): Partial {
  const base = {
    platformMessageId: messaging.message.mid,
    platform: 'instagram',
    direction: messaging.sender.id === account.platformAccountId ? 'saliente' : 'entrante',
    senderId: messaging.sender.id,
    platformTimestamp: new Date(messaging.timestamp),
  };
  
  // Manejo de respuestas a historias
  if (messaging.message.reply_to?.story) {
    return {
      ...base,
      text: messaging.message.text,
      storyReply: {
        storyId: messaging.message.reply_to.story.id,
        storyUrl: messaging.message.reply_to.story.url,
      },
      isStoryMention: false,
    };
  }
  
  // Manejo de menciones en historias
  if (messaging.message.attachments?.[0]?.type === 'story_mention') {
    return {
      ...base,
      isStoryMention: true,
      attachments: [{
        type: 'compartir',
        url: messaging.message.attachments[0].payload.url,
        payload: messaging.message.attachments[0].payload,
      }],
    };
  }
  
  // Mensaje estándar
  return {
    ...base,
    text: messaging.message.text,
    attachments: normalizeAttachments(messaging.message.attachments),
  };
}
```

Para LinkedIn, necesitas un tratamiento especial porque las funciones de bandeja de entrada solo funcionan con cuentas de organización:

// Validación de cuenta de organización de LinkedIn

export function isLinkedInOrgAccount(
  metadata: Map | Record | null | undefined
): boolean {
  if (!metadata) return false;

  // Manejar tipo de Mongoose Map
  if (metadata instanceof Map) {
    return metadata.get('accountType') === 'organization' || 
           metadata.has('selectedOrganization');
  }

  // Manejar objeto simple
  return metadata.accountType === 'organization' || 
         'selectedOrganization' in metadata;
}

// Filtrar cuentas para solo aquellas que soportan características de bandeja de entrada
export function filterAccountsForFeature(
  accounts: SocialAccount[],
  feature: InboxFeature
): SocialAccount[] {
  const supportedPlatforms = INBOX_PLATFORMS[feature];

  return accounts.filter((account) => {
    if (!supportedPlatforms.includes(account.platform)) {
      return false;
    }

    // LinkedIn requiere tipo de cuenta de organización
    if (account.platform === 'linkedin' && !isLinkedInOrgAccount(account.metadata)) {
      return false;
    }

    return true;
  });
}

Desduplicación y Hilo de Mensajes

Al agregar datos de múltiples fuentes, pueden aparecer duplicados. Un mensaje puede llegar a través de un webhook y luego nuevamente durante una sincronización por polling. Tu sistema necesita una deduplicación sólida:

```javascript
// Utilidades de deduplicación

export function deduplicateItems(
  items: T[],
  keyFn: (item: T) => string
): T[] {
  const seen = new Set();
  return items.filter((item) => {
    const key = keyFn(item);
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

// Uso: deduplicar mensajes por ID de plataforma
const uniqueMessages = deduplicateItems(
  allMessages,
  (msg) => `${msg.platform}_${msg.platformMessageId}`
);
```

Para la deduplicación a nivel de base de datos, utiliza operaciones de upsert con índices únicos:

```javascript
// Patrón Upsert para el procesamiento de mensajes de webhook

async function upsertMessage(
  accountId: string,
  normalizedMessage: NormalizedMessage
): Promise<Message> {
  return Message.findOneAndUpdate(
    { 
      accountId, 
      platformMessageId: normalizedMessage.platformMessageId 
    },
    {
      $set: normalizedMessage,
      $setOnInsert: {
        createdAt: new Date(),
      },
    },
    { 
      upsert: true, 
      new: true,
      // Devuelve el documento ya sea que se haya insertado o actualizado
      rawResult: false,
    }
  );
}
```

El índice único en { accountId: 1, platformMessageId: 1 } asegura que MongoDB rechace duplicados verdaderos a nivel de base de datos, incluso bajo procesamiento concurrente de webhooks.

Optimización del Rendimiento y Caché

Una bandeja de entrada multiplataforma realiza numerosas llamadas a la API. Sin almacenamiento en caché, alcanzarás los límites de tasa y generarás experiencias de usuario lentas.

// Capa de caché Redis para listas de conversaciones

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

interface CacheOptions {
  ttl: number; // segundos
  staleWhileRevalidate?: number;
}

async function getCachedConversations(
  userId: string,
  accountIds: string[],
  options: CacheOptions = { ttl: 60, staleWhileRevalidate: 300 }
): Promise<{ conversations: Conversation[]; fromCache: boolean }> {
  const cacheKey = `inbox:${userId}:conversations:${accountIds.sort().join(',')}`;
  
  // Intentar primero con la caché
  const cached = await redis.get(cacheKey);
  if (cached) {
    const data = JSON.parse(cached);
    const age = Date.now() - data.timestamp;
    
    // Devolver datos de caché inmediatamente
    if (age < options.ttl * 1000) {
      return { conversations: data.conversations, fromCache: true };
    }
    
    // Stale-while-revalidate: devolver datos obsoletos pero activar la actualización en segundo plano
    if (age < (options.staleWhileRevalidate || options.ttl) * 1000) {
      // Activar actualización en segundo plano
      refreshConversationsCache(userId, accountIds, cacheKey).catch(console.error);
      return { conversations: data.conversations, fromCache: true };
    }
  }
  
  // Fallo en la caché o expirado: obtener datos frescos
  const conversations = await fetchConversationsFromDB(userId, accountIds);
  
  // Almacenar en caché
  await redis.setex(
    cacheKey,
    options.staleWhileRevalidate || options.ttl,
    JSON.stringify({ conversations, timestamp: Date.now() })
  );
  
  return { conversations, fromCache: false };
}

async function refreshConversationsCache(
  userId: string,
  accountIds: string[],
  cacheKey: string
) {
  const conversations = await fetchConversationsFromDB(userId, accountIds);
  await redis.setex(
    cacheKey,
    300, // 5 minutos de TTL para la actualización en segundo plano
    JSON.stringify({ conversations, timestamp: Date.now() })
  );
}

Estrategias adicionales de optimización:

  1. Procesamiento por lotes de webhooksEncola los webhooks entrantes y procésalos en lotes para reducir las consultas a la base de datos.
  2. Agrupamiento de conexionesReutiliza las conexiones HTTP para las APIs de las plataformas.
  3. Sincronización incremental: Realiza un seguimiento de la última marca de tiempo de sincronización por cuenta y solo recupera los mensajes más recientes.
  4. DenormalizationAlmacena la información de los participantes directamente en las conversaciones para evitar uniones.

Construyendo la Interfaz del Frontend

Tu frontend debe gestionar las complejidades de una bandeja de entrada multiplataforma mientras presenta una interfaz limpia. Aquí tienes un patrón de componente en React:

```javascript
// Hook de React para bandeja de entrada unificada

import { useQuery, useInfiniteQuery } from '@tanstack/react-query';

interface UseInboxOptions {
  status?: 'activo' | 'archivado';
  platform?: string;
  accountId?: string;
}

export function useInbox(options: UseInboxOptions = {}) {
  return useInfiniteQuery({
    queryKey: ['inbox', 'conversations', options],
    queryFn: async ({ pageParam }) => {
      const params = new URLSearchParams();
      if (pageParam) params.set('cursor', pageParam);
      if (options.status) params.set('status', options.status);
      if (options.platform) params.set('platform', options.platform);
      if (options.accountId) params.set('accountId', options.accountId);
      
      const response = await fetch(`/api/inbox/conversations?${params}`);
      if (!response.ok) {
        throw new Error('Error al obtener las conversaciones');
      }
      return response.json();
    },
    getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
    initialPageParam: null as string | null,
  });
}

export function useConversationMessages(conversationId: string) {
  return useInfiniteQuery({
    queryKey: ['inbox', 'messages', conversationId],
    queryFn: async ({ pageParam }) => {
      const params = new URLSearchParams();
      if (pageParam) params.set('cursor', pageParam);
      
      const response = await fetch(
        `/api/inbox/conversations/${conversationId}/messages?${params}`
      );
      if (!response.ok) {
        throw new Error('Error al obtener los mensajes');
      }
      return response.json();
    },
    getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
    initialPageParam: null as string | null,
    enabled: !!conversationId,
  });
}
```

Muestra características específicas de la plataforma con renderizado condicional:

```javascript
// Componente de mensaje con renderizado específico para cada plataforma

function MessageBubble({ message }: { message: Message }) {
  const isOutgoing = message.direction === 'outgoing';
  
  return (
    
{/* Indicador de respuesta a historia */} {message.storyReply && (
Has respondido a su historia {message.storyReply.storyUrl && ( Historia )}
)} {/* Indicador de mención en historia */} {message.isStoryMention && (
Te mencionaron en su historia
)} {/* Texto del mensaje */} {message.text && (

{message.text}

)} {/* Adjuntos */} {message.attachments?.map((attachment, i) => ( ))} {/* Metadatos */}
); } ```

Uso de la API de Buzón Unificado de Late

Construir una bandeja de entrada unificada para redes sociales desde cero requiere meses de desarrollo. Necesitas mantener integraciones con múltiples plataformas, gestionar sus cambios de API, manejar flujos de OAuth y lidiar con los límites de tasa.

Late ofrece una API de bandeja de entrada de redes sociales preconstruida que gestiona toda esta complejidad. En lugar de integrar seis APIs de diferentes plataformas, solo necesitas integrar una.

Así es como Late simplifica la integración de bandejas de entrada:

```javascript
// Usando la API de bandeja unificada de Late

import { LateClient } from '@late/sdk';

const late = new LateClient({
  apiKey: process.env.LATE_API_KEY,
});

// Obtener conversaciones de todas las cuentas conectadas
const { conversations, pagination, meta } = await late.inbox.getConversations({
  status: 'active',
  limit: 20,
});

// Obtener mensajes de una conversación específica
const { messages } = await late.inbox.getMessages(conversationId, {
  limit: 50,
});

// Enviar una respuesta (Late se encarga del formato específico de cada plataforma)
await late.inbox.sendMessage(conversationId, {
  text: '¡Gracias por ponerte en contacto! ¿En qué puedo ayudarte?',
});

// Filtrar por plataforma si es necesario
const instagramOnly = await late.inbox.getConversations({
  platform: 'instagram',
});
```

La API unificada de Late ofrece:

  • Flujo único de OAuthConecta tus cuentas una vez y accede a todas las funciones de la bandeja de entrada.
  • Datos normalizadosFormato de mensaje consistente en Facebook, Instagram, Twitter, Bluesky, Reddit y Telegram.
  • Webhooks en tiempo realUn endpoint de webhook para todas las plataformas
  • Paginación integradaPaginación basada en cursor que funciona en todas las cuentas
  • Manejo de erroresDegradación elegante cuando las plataformas individuales tienen problemas
  • Gestión de límites de tasaLógica automática de retroceso y reintento

La meta de agregación en las respuestas de Late te indica exactamente qué cuentas tuvieron éxito y cuáles tuvieron problemas:

// La respuesta de Late incluye metadatos de agregación

interface LateInboxResponse {
  conversaciones: Conversación[];
  paginación: {
    hayMás: boolean;
    siguienteCursor: string | null;
  };
  meta: {
    cuentasConsultadas: number;
    cuentasFallidas: number;
    cuentasFallidas: Array<{
      idCuenta: string;
      plataforma: string;
      error: string;
      reintentarDespués?: number;
    }>;
    últimaActualización: string;
  };
}

Esta transparencia te permite crear interfaces que informen a los usuarios cuando cuentas específicas tienen problemas temporales, sin interrumpir toda la experiencia de la bandeja de entrada.

Echa un vistazo Documentación de Late para comenzar con la API de bandeja de entrada unificada. Puedes tener una bandeja de entrada multi-plataforma funcionando en horas en lugar de meses.

Una API. 13+ plataformas.

Integra redes sociales en minutos, no semanas.

Diseñada para desarrolladores. Usada por agencias. Más de 6,325 usuarios.