Gerenciar conversas com clientes em Facebook, Instagram, Twitter, Bluesky, Reddit e Telegram exige uma constante troca de contexto. Uma caixa de entrada unificada para redes sociais resolve isso ao agregar todas as mensagens em uma única interface, permitindo que sua equipe responda mais rapidamente sem precisar alternar entre plataformas.
Este guia orienta na construção de uma caixa de entrada multi-plataforma do zero. Você aprenderá a projetar o modelo de dados, normalizar mensagens de diferentes APIs, lidar com webhooks em tempo real e implementar paginação baseada em cursor que funcione em várias plataformas. Ao final, você terá uma arquitetura pronta para produção para agregar mensagens sociais em grande escala.
O Desafio da Mensagem em Múltiplas Plataformas
Cada plataforma social possui sua própria API de mensagens, com fluxos de autenticação únicos, estruturas de dados, limites de taxa e formatos de webhook. Aqui está o que você encontrará:
Facebook e Instagram use a API da Plataforma Messenger com IDs de usuários específicos da página. As mensagens chegam através de webhooks, e você precisa de tokens de acesso separados para cada conta conectada.
Twitter/X oferece a API de Mensagens Diretas, mas o acesso requer níveis elevados de API. A consulta de mensagens é o método principal, uma vez que o suporte a webhooks é limitado.
Bluesky usa o Protocolo AT com uma arquitetura descentralizada. As mensagens diretas funcionam de forma diferente das plataformas tradicionais, exigindo consultas baseadas em léxico.
Reddit oferece mensagens privadas através da sua API, mas os limites de taxa são rigorosos (60 solicitações por minuto para clientes OAuth).
Telegram fornece à API do Bot opções de polling longo ou webhooks. Cada bot recebe seu próprio token, e as mensagens incluem metadados ricos sobre os tipos de chat.
Os principais desafios ao construir uma API de caixa de entrada para redes sociais incluem:
- Complexidade de autenticaçãoCada plataforma exige diferentes fluxos de OAuth, estratégias de atualização de tokens e escopos de permissão.
- Normalização de dadosUma "mensagem" tem um significado diferente em cada plataforma.
- Entrega em tempo realOs formatos de webhook variam bastante, e algumas plataformas não oferecem suporte a webhooks.
- Gestão de limites de taxaUltrapassar os limites em uma plataforma não deveria comprometer toda a sua caixa de entrada.
- Inconsistência na paginaçãoAlguns utilizam cursores, outros usam deslocamentos, e a paginação baseada em timestamps se comporta de maneira diferente em cada lugar.
Visão Geral da Arquitetura para uma Caixa de Entrada Unificada de Mídias Sociais
Uma caixa de entrada bem projetada para múltiplas plataformas separa as preocupações em camadas distintas:

```
// Camadas da arquitetura para um sistema de caixa de entrada unificada
interface InboxArchitecture {
// Camada 1: Adaptadores de Plataforma
adapters: {
facebook: FacebookAdapter;
instagram: InstagramAdapter;
twitter: TwitterAdapter;
bluesky: BlueskyAdapter;
reddit: RedditAdapter;
telegram: TelegramAdapter;
};
// Camada 2: Motor de Normalização
normalizer: {
transformMessage: (platformMessage: unknown, platform: string) => NormalizedMessage;
transformConversation: (platformConvo: unknown, platform: string) => NormalizedConversation;
};
// Camada 3: Serviço de Agregação
aggregator: {
fetchAllConversations: (accounts: SocialAccount[]) => Promise<AggregatedResult<Conversation>>;
fetchAllMessages: (conversationIds: string[]) => Promise<AggregatedResult<Message>>;
};
// Camada 4: Camada de Armazenamento
storage: {
conversations: ConversationRepository;
messages: MessageRepository;
accounts: AccountRepository;
};
// Camada 5: Camada de API
api: {
getConversations: (filters: ConversationFilters) => Promise<PaginatedResponse<Conversation>>;
getMessages: (conversationId: string, cursor?: string) => Promise<PaginatedResponse<Message>>;
sendMessage: (conversationId: string, content: MessageContent) => Promise<Message>;
};
}
```
O padrão de adaptador isola a lógica específica da plataforma. Quando o Instagram altera sua API, você atualiza apenas um adaptador sem precisar mexer no restante do seu código.
Plataformas Suportadas e Suas APIs
Nem todas as plataformas suportam todos os recursos de caixa de entrada. Aqui está uma abordagem orientada por configurações para gerenciar as capacidades das plataformas:
```typescript
// Configuração de suporte da plataforma para recursos de caixa de entrada
export type InboxFeature = 'mensagens' | 'comentários' | 'avaliações';
export const INBOX_PLATFORMS = {
mensagens: ['facebook', 'instagram', 'twitter', 'bluesky', 'reddit', 'telegram'] as const,
comentários: ['facebook', 'instagram', 'twitter', 'bluesky', 'threads', 'youtube', 'linkedin', 'reddit'] as const,
avaliações: ['facebook', 'googlebusiness'] as const,
} as const;
export type MessagesPlatform = (typeof INBOX_PLATFORMS.mensagens)[number];
export type CommentsPlatform = (typeof INBOX_PLATFORMS.comentários)[number];
export type ReviewsPlatform = (typeof INBOX_PLATFORMS.avaliações)[number];
// Verifica se uma plataforma suporta um recurso específico
export function isPlatformSupported(platform: string, feature: InboxFeature): boolean {
return (INBOX_PLATFORMS[feature] as readonly string[]).includes(platform);
}
// Valida e retorna mensagens de erro úteis
export function validatePlatformSupport(
platform: string,
feature: InboxFeature
): { valid: true } | { valid: false; error: string; supportedPlatforms: readonly string[] } {
if (!isPlatformSupported(platform, feature)) {
const featureLabel = feature === 'mensagens' ? 'mensagens diretas' : feature;
return {
valid: false,
error: `A plataforma '${platform}' não suporta ${featureLabel}`,
supportedPlatforms: INBOX_PLATFORMS[feature],
};
}
return { valid: true };
}
```
Nota: O TikTok e o Pinterest estão notavelmente ausentes das listas de mensagens e comentários. As suas APIs não oferecem acesso de leitura às mensagens ou comentários dos usuários, tornando-os inadequados para a agregação de caixas de entrada.
Aqui está uma comparação das capacidades da API entre as plataformas:
| Platform | Messages | Comments | Reviews | Webhooks | Limites de Taxa |
|---|---|---|---|---|---|
| ✅ | ✅ | ✅ | ✅ | 200/hora/usuário | |
| ✅ | ✅ | ❌ | ✅ | 200/hora/usuário | |
| ✅ | ✅ | ❌ | Limited | 15/15min (DMs) | |
| Bluesky | ✅ | ✅ | ❌ | ❌ | 3000/5min |
| ✅ | ✅ | ❌ | ❌ | 60/minuto | |
| Telegram | ✅ | ❌ | ❌ | ✅ | 30/seg |
| ❌ | ✅ (Organizações) | ❌ | ✅ | 100/dia | |
| YouTube | ❌ | ✅ | ❌ | ❌ | 10.000/dia |
Design de Modelo de Dados para Conversas e Mensagens
Seu modelo de dados precisa gerenciar a união de todos os recursos das plataformas, mantendo relacionamentos claros. Aqui está um esquema MongoDB que funciona bem:
```javascript
// Esquema de conversa - representa um thread com um 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 nativo da conversa na plataforma
platformConversationId: {
type: String,
required: true,
index: true,
},
status: {
type: String,
enum: ["active", "archived"],
default: "active",
index: true,
},
// Informações do participante em cache para exibição rápida
participantName: String,
participantUsername: String,
participantPicture: String,
participantId: String,
// Dados de pré-visualização para a lista de mensagens
lastMessage: String,
lastMessageAt: {
type: Date,
index: true,
},
// Extras específicos da plataforma
metadata: {
type: Map,
of: mongoose.Schema.Types.Mixed,
},
},
{ timestamps: true }
);
// Impedir conversas duplicadas por conta
conversationSchema.index(
{ accountId: 1, platformConversationId: 1 },
{ unique: true }
);
// Otimizar consultas da lista de mensagens
conversationSchema.index(
{ accountId: 1, status: 1, lastMessageAt: -1 }
);
```
O esquema de mensagem abrange toda a gama de tipos de conteúdo:
// Esquema de mensagem - mensagens individuais dentro de conversas
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,
}],
// Interações de histórias do 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,
},
// Manter dados brutos para depuração
rawPayload: mongoose.Schema.Types.Mixed,
},
{ timestamps: true }
);
// Prevenir mensagens duplicadas
messageSchema.index(
{ accountId: 1, platformMessageId: 1 },
{ unique: true }
);
// Busca de mensagens em ordem cronológica
messageSchema.index(
{ conversationId: 1, platformTimestamp: -1 }
);
// Consultas de
The direction o campo é crítico. Ele distingue entre as mensagens enviadas pela sua equipe (outgoing) e mensagens de clientes (incomingIsso possibilita funcionalidades como contagem de mensagens não lidas e análises de tempo de resposta.
Estratégia de Agregação e Normalização
A camada de agregação busca dados de várias contas em paralelo, lidando com falhas de forma eficiente. Aqui está a implementação central:
```typescript
// Utilitários de agregação para busca de dados em múltiplas contas
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('Tempo de solicitação esgotado')), 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 || 'Erro desconhecido',
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 padrão garante que um limite de taxa no Twitter não impeça que você exiba mensagens do Facebook. errors array permite que seu frontend exiba resultados parciais com os avisos apropriados.
Para normalizar dados específicos de plataformas em seu formato unificado:
```typescript
// Normalização de mensagens de formatos específicos da plataforma
interface NormalizedMessage {
platformMessageId: string;
platform: string;
direction: 'incoming' | 'outgoing';
senderId: string;
senderName?: string;
text?: string;
attachments: Attachment[];
platformTimestamp: Date;
}
function normalizeInstagramMessage(
raw: InstagramMessage,
accountId: string
): NormalizedMessage {
const isOutgoing = raw.from.id === accountId;
return {
platformMessageId: raw.id,
platform: 'instagram',
direction: isOutgoing ? 'outgoing' : 'incoming',
senderId: raw.from.id,
senderName: raw.from.username,
text: raw.message,
attachments: (raw.attachments?.data || []).map(att => ({
type: mapInstagramAttachmentType(att.type),
url: att.url || att.payload?.url,
payload: att.payload,
})),
platformTimestamp: new Date(raw.created_time),
};
}
function normalizeTwitterMessage(
raw: TwitterDM,
accountId: string
): NormalizedMessage {
const isOutgoing = raw.sender_id === accountId;
return {
platformMessageId: raw.id,
platform: 'twitter',
direction: isOutgoing ? 'outgoing' : 'incoming',
senderId: raw.sender_id,
senderName: raw.sender?.name,
text: raw.text,
attachments: (raw.attachments?.media_keys || []).map(key => ({
type: 'image', // Os anexos de DM do Twitter são tipicamente imagens
url: raw.includes?.media?.find(m => m.media_key === key)?.url,
})),
platformTimestamp: new Date(raw.created_at),
};
}
```
Atualizações em tempo real com Webhooks
Os webhooks eliminam a necessidade de polling constante. Cada plataforma possui seu próprio formato de webhook, portanto, você precisa de manipuladores específicos para cada plataforma que se integrem ao seu pipeline de processamento unificado.
// Manipulador de webhook para mensagens do 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 a autenticidade do webhook
if (!verifyInstagramSignature(body, signature)) {
return NextResponse.json({ error: 'Assinatura inválida' }, { status: 401 });
}
const payload = JSON.parse(body);
// Processar cada entrada (pode conter várias atualizações)
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 a conta conectada
const account = await SocialAccount.findOne({
platform: 'instagram',
platformAccountId: pageId,
isActive: true,
});
if (!account) {
console.warn(`Nenhuma conta ativa encontrada para a página do Instagram ${pageId}`);
return;
}
// Encontrar ou criar conversa
const conversation = await findOrCreateConversation({
accountId: account._id,
platform: 'instagram',
platformConversationId: messaging.sender.id, // Para o Instagram, o ID do remetente é o ID da conversa
participantId: messaging.sender.id,
});
// Normalizar e armazenar a mensagem
const normalizedMessage = normalizeInstagramWebhookMessage(messaging, account);
await Message.findOneAndUpdate(
{
accountId: account._id,
platformMessageId: normalizedMessage.platformMessageId
Nota: Verifique sempre as assinaturas dos webhooks antes de processá-los. Sem essa verificação, atacantes podem injetar mensagens falsas em seu sistema.
Para plataformas sem suporte a webhook (como Bluesky e Reddit), implemente um serviço de polling:
// Serviço de polling para plataformas sem webhooks
class MessagePollingService {
private intervals: Map = new Map();
async startPolling(accountId: string, platform: string) {
const pollInterval = this.getPollInterval(platform);
const poll = async () => {
try {
const account = await SocialAccount.findById(accountId);
if (!account || !account.isActive) {
this.stopPolling(accountId);
return;
}
const adapter = this.getAdapter(platform);
const messages = await adapter.fetchNewMessages(account);
for (const message of messages) {
await this.processMessage(account, message);
}
} catch (error) {
console.error(`Erro de polling para ${accountId}:`, error);
}
};
// Polling inicial
await poll();
// Agendar polls recorrentes
const interval = setInterval(poll, pollInterval);
this.intervals.set(accountId, interval);
}
private getPollInterval(platform: string): number {
// Respeitar limites de taxa com intervalos conservadores
const intervals: Record = {
bluesky: 30000, // 30 segundos
reddit: 60000, // 60 segundos (limites de taxa rigorosos)
twitter: 60000, // 60 segundos (acesso limitado a DMs)
};
return intervals[platform] || 30000;
}
stopPolling(accountId: string) {
const interval = this.intervals.get(accountId);
if (interval) {
clearInterval(interval);
this.intervals.delete(accountId);
}
}
}
Paginação Baseada em Cursor em Múltiplas Plataformas
Quando você agrega mensagens sociais de várias contas, a paginação tradicional por offset falha. A chegada de uma nova mensagem desloca todos os offsets. A paginação baseada em cursor resolve isso utilizando identificadores estáveis.
O desafio: cada plataforma utiliza formatos de cursor diferentes. A sua API unificada precisa de um cursor que codifique informações suficientes para retomar a paginação em todas as fontes.
```typescript
// Paginação baseada em cursor para resultados agregados
export interface PaginationInfo {
hasMore: boolean;
nextCursor: string | null;
totalCount?: number;
}
// Formato do cursor: {timestamp}_{accountId}_{itemId}
// Isso permite uma paginação estável mesmo quando itens são adicionados
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;
// Aplica o filtro de cursor se fornecido
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);
// Itens antes do cursor (para ordem decrescente)
if (itemTime < cursorTime) return true;
if (itemTime === cursorTime) {
if (itemAccountId > cursorAccountId) return true;
if (itemAccountId === cursorAccountId && itemId > cursorItemId) return true;
}
return false;
});
}
// Aplica o limite
const paginatedItems = filteredItems.slice(0, limit);
const hasMore = filteredItems.length > limit;
// Gera o próximo cursor a partir do último item
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 no seu endpoint de API:
// Endpoint da API utilizando paginação 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';
// Obter contas do usuário
const accounts = await getInboxAccounts(userId, 'messages');
// Buscar conversas de todas as contas
const { items: conversations, errors } = await aggregateFromAccounts(
accounts,
async (account) => {
return Conversation.find({
accountId: account._id,
status,
})
.sort({ lastMessageAt: -1 })
.limit(limit * 2) // Buscar extras para mesclar
.lean();
}
);
// Ordenar todas as conversas pelo horário da última mensagem
const sorted = sortItems(
conversations,
'lastMessageAt',
'desc',
{ lastMessageAt: (c) => c.lastMessageAt }
);
// Aplicar paginação 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),
});
}
Gerenciando Funcionalidades Específicas da Plataforma
Cada plataforma possui características únicas que não se encaixam perfeitamente em um modelo unificado. O Instagram tem respostas a stories e menções. O Twitter possui DMs citadas. O Telegram conta com cadeias de respostas e reações.
A chave é armazenar dados específicos da plataforma de forma flexível. metadata campo enquanto expõe recursos comuns através da sua interface normalizada:
```typescript
// Tratamento de respostas a histórias do Instagram
interface InstagramStoryReply {
storyId: string;
storyUrl: string;
storyMediaType: 'imagem' | 'vídeo';
expiresAt: Date; // As histórias expiram após 24 horas
}
function normalizeInstagramWebhookMessage(
messaging: InstagramMessagingEvent,
account: SocialAccount
): Partial {
const base = {
platformMessageId: messaging.message.mid,
platform: 'instagram',
direction: messaging.sender.id === account.platformAccountId ? 'saída' : 'entrada',
senderId: messaging.sender.id,
platformTimestamp: new Date(messaging.timestamp),
};
// Tratamento de respostas a histórias
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,
};
}
// Tratamento de menções a histórias
if (messaging.message.attachments?.[0]?.type === 'story_mention') {
return {
...base,
isStoryMention: true,
attachments: [{
type: 'compartilhar',
url: messaging.message.attachments[0].payload.url,
payload: messaging.message.attachments[0].payload,
}],
};
}
// Mensagem padrão
return {
...base,
text: messaging.message.text,
attachments: normalizeAttachments(messaging.message.attachments),
};
}
```
Para o LinkedIn, é necessário um tratamento especial, pois os recursos de inbox funcionam apenas com contas de organização:
```javascript
// Validação de conta organizacional do LinkedIn
export function isLinkedInOrgAccount(
metadata: Map | Record | null | undefined
): boolean {
if (!metadata) return false;
// Lidar com o tipo Mongoose Map
if (metadata instanceof Map) {
return metadata.get('accountType') === 'organization' ||
metadata.has('selectedOrganization');
}
// Lidar com objeto simples
return metadata.accountType === 'organization' ||
'selectedOrganization' in metadata;
}
// Filtrar contas para apenas aquelas que suportam recursos de caixa 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;
}
// O LinkedIn requer tipo de conta organizacional
if (account.platform === 'linkedin' && !isLinkedInOrgAccount(account.metadata)) {
return false;
}
return true;
});
}
```
Desduplicação e Agrupamento de Mensagens
Ao agregar de várias fontes, podem surgir duplicatas. Uma mensagem pode chegar via webhook e depois novamente durante uma sincronização de polling. Seu sistema precisa de uma deduplicação robusta:
```javascript
// Utilitários de deduplicação
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 mensagens por ID de plataforma
const uniqueMessages = deduplicateItems(
allMessages,
(msg) => `${msg.platform}_${msg.platformMessageId}`
);
```
Para deduplicação a nível de base de dados, utilize operações upsert com índices únicos:
```javascript
// Padrão Upsert para processamento de mensagens 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,
// Retornar o documento, seja ele inserido ou atualizado
rawResult: false,
}
);
}
```
O índice único em { accountId: 1, platformMessageId: 1 } garante que o MongoDB rejeite duplicatas verdadeiras no nível do banco de dados, mesmo durante o processamento simultâneo de webhooks.
Otimização de Desempenho e Cache
Uma caixa de entrada multiplataforma faz várias chamadas de API. Sem cache, você atingirá os limites de taxa e criará experiências lentas para os usuários.
// Camada de cache Redis para listas de conversas
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(',')}`;
// Tenta o cache primeiro
const cached = await redis.get(cacheKey);
if (cached) {
const data = JSON.parse(cached);
const age = Date.now() - data.timestamp;
// Retorna os dados do cache imediatamente
if (age < options.ttl * 1000) {
return { conversations: data.conversations, fromCache: true };
}
// Stale-while-revalidate: retorna dados desatualizados mas aciona a atualização em segundo plano
if (age < (options.staleWhileRevalidate || options.ttl) * 1000) {
// Atualização em segundo plano
refreshConversationsCache(userId, accountIds, cacheKey).catch(console.error);
return { conversations: data.conversations, fromCache: true };
}
}
// Cache não encontrado ou expirado: busca dados novos
const conversations = await fetchConversationsFromDB(userId, accountIds);
// Armazena no cache
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 atualização em segundo plano
JSON.stringify({ conversations, timestamp: Date.now() })
);
}
Estratégias adicionais de otimização:
- Processamento de webhooks em loteColoque os webhooks recebidos em fila e processe em lotes para reduzir as idas e vindas ao banco de dados.
- Pooling de conexõesReutilize conexões HTTP para APIs da plataforma
- Sincronização incremental: Registe a última marcação de sincronização por conta e busque apenas as mensagens mais recentes.
- DenormalizationArmazene informações dos participantes diretamente nas conversas para evitar junções.
Construindo a Interface Frontend
Seu frontend precisa lidar com as complexidades de uma caixa de entrada multi-plataforma, enquanto apresenta uma interface limpa. Aqui está um padrão de componente React:
```javascript
// Hook React para caixa de entrada unificada
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
interface UseInboxOptions {
status?: 'ativo' | 'arquivado';
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('Falha ao buscar conversas');
}
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('Falha ao buscar mensagens');
}
return response.json();
},
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
initialPageParam: null as string | null,
enabled: !!conversationId,
});
}
```
Exiba recursos específicos da plataforma com renderização condicional:
```javascript
// Componente de mensagem com renderização específica para cada plataforma
function MessageBubble({ message }: { message: Message }) {
const isOutgoing = message.direction === 'outgoing';
return (
{/* Indicador de resposta a história */}
{message.storyReply && (
Respondeu à sua história
{message.storyReply.storyUrl && (
)}
)}
{/* Indicador de menção em história */}
{message.isStoryMention && (
Mencionou você em sua história
)}
{/* Texto da mensagem */}
{message.text && (
{message.text}
)}
{/* Anexos */}
{message.attachments?.map((attachment, i) => (
))}
{/* Metadados */}
);
}
```
Utilizando a API da Caixa de Entrada Unificada do Late
Construir uma caixa de entrada unificada para redes sociais do zero exige meses de desenvolvimento. É necessário manter integrações com várias plataformas, lidar com as mudanças em suas APIs, gerenciar fluxos de OAuth e enfrentar limites de taxa.
Late oferece uma API de caixa de entrada de redes sociais pré-construída que lida com toda essa complexidade. Em vez de integrar com seis APIs de plataformas diferentes, você integra com uma só.
Veja como o Late simplifica a integração da caixa de entrada:
```javascript
// Usando a API de caixa de entrada unificada do Late
import { LateClient } from '@late/sdk';
const late = new LateClient({
apiKey: process.env.LATE_API_KEY,
});
// Buscar conversas de todas as contas conectadas
const { conversations, pagination, meta } = await late.inbox.getConversations({
status: 'active',
limit: 20,
});
// Obter mensagens de uma conversa específica
const { messages } = await late.inbox.getMessages(conversationId, {
limit: 50,
});
// Enviar uma resposta (o Late cuida da formatação específica da plataforma)
await late.inbox.sendMessage(conversationId, {
text: 'Obrigado por entrar em contato! Como posso ajudar?',
});
// Filtrar por plataforma, se necessário
const instagramOnly = await late.inbox.getConversations({
platform: 'instagram',
});
```
A API unificada da Late oferece:
- Fluxo OAuth únicoConecte suas contas uma vez e acesse todos os recursos de caixa de entrada.
- Dados normalizadosFormato de mensagem consistente em Facebook, Instagram, Twitter, Bluesky, Reddit e Telegram
- Webhooks em tempo realUm endpoint de webhook para todas as plataformas
- Paginação integradaPaginação baseada em cursor que funciona em várias contas
- Tratamento de errosDegradação elegante quando plataformas individuais apresentam problemas
- Gestão de limites de taxaLógica automática de retrocesso e nova tentativa
A meta de agregação nas respostas do Late informa exatamente quais contas tiveram sucesso e quais enfrentaram problemas:
// A resposta do Late inclui metadados de agregação
interface LateInboxResponse {
conversas: Conversation[];
paginação: {
temMais: boolean;
próximoCursor: string | null;
};
meta: {
contasConsultadas: number;
contasComFalha: number;
contasFalhadas: Array<{
idConta: string;
plataforma: string;
erro: string;
tentarNovamenteApos?: number;
}>;
ultimaAtualização: string;
};
}
Essa transparência permite que você crie interfaces que informam os usuários quando contas específicas enfrentam problemas temporários, sem comprometer toda a experiência da caixa de entrada.
Confira Documentação do Late para começar a usar a API de caixa de entrada unificada. Você pode ter uma caixa de entrada multi-plataforma funcionando em poucas horas, em vez de meses.