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:
- Complejidad de autenticaciónCada plataforma requiere diferentes flujos de OAuth, estrategias de renovación de tokens y ámbitos de permisos.
- Normalización de datosUn "mensaje" significa algo diferente en cada plataforma.
- Entrega en tiempo realLos formatos de webhook varían enormemente, y algunas plataformas ni siquiera los soportan.
- Gestión de límites de tasaAlcanzar los límites en una plataforma no debería afectar todo tu buzón.
- 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:

```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:
| Platform | Messages | Comments | Reviews | Webhooks | Límites de tasa |
|---|---|---|---|---|---|
| ✅ | ✅ | ✅ | ✅ | 200€/hora/usuario | |
| ✅ | ✅ | ❌ | ✅ | 200€/hora/usuario | |
| ✅ | ✅ | ❌ | Limited | 15/15min (DMs) | |
| Bluesky | ✅ | ✅ | ❌ | ❌ | 3000/5min |
| ✅ | ✅ | ❌ | ❌ | 60/minuto | |
| Telegram | ✅ | ❌ | ❌ | ✅ | 30/seg |
| ❌ | ✅ (Organizaciones) | ❌ | ✅ | 100/día | |
| YouTube | ❌ | ✅ | ❌ | ❌ | 10,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);
}
}
}
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:
- Procesamiento por lotes de webhooksEncola los webhooks entrantes y procésalos en lotes para reducir las consultas a la base de datos.
- Agrupamiento de conexionesReutiliza las conexiones HTTP para las APIs de las plataformas.
- 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.
- 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 && (
)}
)}
{/* 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.