Gérer les conversations avec les clients sur Facebook, Instagram, Twitter, Bluesky, Reddit et Telegram nécessite de passer constamment d'un contexte à un autre. Une boîte de réception unifiée pour les réseaux sociaux résout ce problème en regroupant tous les messages dans une seule interface, permettant à votre équipe de répondre plus rapidement sans avoir à naviguer entre les plateformes.
Ce guide vous accompagne dans la création d'une boîte de réception multi-plateformes à partir de zéro. Vous apprendrez à concevoir le modèle de données, à normaliser les messages provenant de différentes API, à gérer les webhooks en temps réel et à mettre en œuvre une pagination basée sur des curseurs qui fonctionne sur toutes les plateformes. À la fin, vous disposerez d'une architecture prête pour la production pour agréger des messages sociaux à grande échelle.
Le défi de la messagerie multi-plateforme
Chaque plateforme sociale dispose de sa propre API de messagerie avec des flux d'authentification uniques, des structures de données, des limites de taux et des formats de webhook spécifiques. Voici ce à quoi vous devez faire face :
Facebook et Instagram utilisez l'API de la plateforme Messenger avec des identifiants d'utilisateur spécifiques à la page. Les messages arrivent via des webhooks, et vous avez besoin de jetons d'accès de page distincts pour chaque compte connecté.
Twitter/X fournit l'API des Messages Directs, mais l'accès nécessite des niveaux d'API supérieurs. Le sondage des messages est la méthode principale, car le support des webhooks est limité.
Bluesky utilise le protocole AT avec une architecture décentralisée. Les messages directs fonctionnent différemment des plateformes traditionnelles, nécessitant des requêtes basées sur un lexique.
Reddit propose la messagerie privée via leur API, mais les limites de taux sont strictes (60 requêtes par minute pour les clients OAuth).
Telegram fournit à l'API Bot un accès par polling long ou par webhooks. Chaque bot dispose de son propre token, et les messages incluent des métadonnées riches sur les types de chat.
Les principaux défis lors de la création d'une API de boîte de réception pour les réseaux sociaux incluent :
- Complexité de l'authentificationChaque plateforme nécessite des flux OAuth différents, des stratégies de rafraîchissement de jetons variées et des portées de permissions spécifiques.
- Normalisation des donnéesUn « message » a une signification différente sur chaque plateforme.
- Livraison en temps réelLes formats de webhook varient énormément, et certaines plateformes ne prennent même pas en charge les webhooks.
- Gestion des limites de tauxAtteindre des limites sur une plateforme ne devrait pas faire tomber tout votre fil d'actualités.
- Incohérence de paginationCertains utilisent des curseurs, d'autres des décalages, et la pagination basée sur les horodatages fonctionne différemment selon les plateformes.
Vue d'ensemble de l'architecture pour une boîte de réception unifiée des réseaux sociaux
Une boîte de réception bien conçue pour plusieurs plateformes sépare les préoccupations en couches distinctes :

// Couches d'architecture pour un système de boîte de réception unifiée
interface InboxArchitecture {
// Couche 1 : Adaptateurs de plateforme
adapters: {
facebook: FacebookAdapter;
instagram: InstagramAdapter;
twitter: TwitterAdapter;
bluesky: BlueskyAdapter;
reddit: RedditAdapter;
telegram: TelegramAdapter;
};
// Couche 2 : Moteur de normalisation
normalizer: {
transformMessage: (platformMessage: unknown, platform: string) => NormalizedMessage;
transformConversation: (platformConvo: unknown, platform: string) => NormalizedConversation;
};
// Couche 3 : Service d'agrégation
aggregator: {
fetchAllConversations: (accounts: SocialAccount[]) => Promise>;
fetchAllMessages: (conversationIds: string[]) => Promise>;
};
// Couche 4 : Couche de stockage
storage: {
conversations: ConversationRepository;
messages: MessageRepository;
accounts: AccountRepository;
};
// Couche 5 : Couche API
api: {
getConversations: (filters: ConversationFilters) => Promise>;
getMessages: (conversationId: string, cursor?: string) => Promise>;
sendMessage: (conversationId: string, content: MessageContent) => Promise;
};
}
Le modèle d'adaptateur isole la logique spécifique à chaque plateforme. Lorsque Instagram modifie son API, vous mettez à jour un adaptateur sans toucher au reste de votre code.
Plateformes prises en charge et leurs API
Toutes les plateformes ne prennent pas en charge toutes les fonctionnalités de la boîte de réception. Voici une approche basée sur la configuration pour gérer les capacités des plateformes :
```typescript
// Configuration de support de plateforme pour les fonctionnalités de la boîte de réception
export type InboxFeature = 'messages' | 'comments' | 'reviews';
export const INBOX_PLATFORMS = {
messages: ['facebook', 'instagram', 'twitter', 'bluesky', 'reddit', 'telegram'] as const,
comments: ['facebook', 'instagram', 'twitter', 'bluesky', 'threads', 'youtube', 'linkedin', 'reddit'] as const,
reviews: ['facebook', 'googlebusiness'] as const,
} as const;
export type MessagesPlatform = (typeof INBOX_PLATFORMS.messages)[number];
export type CommentsPlatform = (typeof INBOX_PLATFORMS.comments)[number];
export type ReviewsPlatform = (typeof INBOX_PLATFORMS.reviews)[number];
// Vérifie si une plateforme prend en charge une fonctionnalité spécifique
export function isPlatformSupported(platform: string, feature: InboxFeature): boolean {
return (INBOX_PLATFORMS[feature] as readonly string[]).includes(platform);
}
// Valide et retourne des messages d'erreur utiles
export function validatePlatformSupport(
platform: string,
feature: InboxFeature
): { valid: true } | { valid: false; error: string; supportedPlatforms: readonly string[] } {
if (!isPlatformSupported(platform, feature)) {
const featureLabel = feature === 'messages' ? 'messages directs' : feature;
return {
valid: false,
error: `La plateforme '${platform}' ne prend pas en charge ${featureLabel}`,
supportedPlatforms: INBOX_PLATFORMS[feature],
};
}
return { valid: true };
}
```
Remarque : TikTok et Pinterest sont notablement absents des listes de messages et de commentaires. Leurs API ne permettent pas d'accéder en lecture aux messages ou commentaires des utilisateurs, ce qui les rend inadaptés à l'agrégation de la messagerie.
Voici une comparaison des capacités des API entre les différentes plateformes :
| Platform | Messages | Comments | Reviews | Webhooks | Limites de taux |
|---|---|---|---|---|---|
| ✅ | ✅ | ✅ | ✅ | 200 €/heure/utilisateur | |
| ✅ | ✅ | ❌ | ✅ | 200 €/heure/utilisateur | |
| ✅ | ✅ | ❌ | Limited | 15/15min (DMs) | |
| Bluesky | ✅ | ✅ | ❌ | ❌ | 3000/5min |
| ✅ | ✅ | ❌ | ❌ | 60/min | |
| Telegram | ✅ | ❌ | ❌ | ✅ | 30/s |
| ❌ | ✅ (Organisations) | ❌ | ✅ | 100/jour | |
| YouTube | ❌ | ✅ | ❌ | ❌ | 10000/jour |
Conception du modèle de données pour les conversations et les messages
Votre modèle de données doit gérer l'union de toutes les fonctionnalités des plateformes tout en maintenant des relations claires. Voici un schéma MongoDB qui fonctionne bien :
```javascript
// Schéma de conversation - représente un fil de discussion avec un participant
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,
},
// Identifiant de conversation natif de la plateforme
platformConversationId: {
type: String,
required: true,
index: true,
},
status: {
type: String,
enum: ["active", "archived"],
default: "active",
index: true,
},
// Informations sur le participant mises en cache pour un affichage rapide
participantName: String,
participantUsername: String,
participantPicture: String,
participantId: String,
// Données d'aperçu pour la vue de la liste de la boîte de réception
lastMessage: String,
lastMessageAt: {
type: Date,
index: true,
},
// Extras spécifiques à la plateforme
metadata: {
type: Map,
of: mongoose.Schema.Types.Mixed,
},
},
{ timestamps: true }
);
// Empêcher les conversations en double par compte
conversationSchema.index(
{ accountId: 1, platformConversationId: 1 },
{ unique: true }
);
// Optimiser les requêtes de la liste de la boîte de réception
conversationSchema.index(
{ accountId: 1, status: 1, lastMessageAt: -1 }
);
```
Le schéma de message capture l'ensemble des types de contenu :
// Schéma de message - messages individuels au sein des conversations
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,
}],
// Interactions avec les stories 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,
},
// Conserver les données brutes pour le débogage
rawPayload: mongoose.Schema.Types.Mixed,
},
{ timestamps: true }
);
// Prévenir les messages en double
messageSchema.index(
{ accountId: 1, platformMessageId: 1 },
{ unique: true }
);
// Récupération chronologique des messages
messageSchema.index(
{ conversationId: 1, platformTimestamp: -1
The direction le champ est essentiel. Il fait la distinction entre les messages envoyés par votre équipe (outgoing) et messages des clients (incomingCela alimente des fonctionnalités telles que le comptage des messages non lus et l'analyse des temps de réponse.
Stratégie d'agrégation et normalisation
La couche d'agrégation récupère des données de plusieurs comptes en parallèle tout en gérant les échecs de manière fluide. Voici l'implémentation principale :
```typescript
// Utilitaires d'agrégation pour la récupération de données multi-comptes
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('Délai d\'attente de la requête dépassé')), 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 || 'Erreur inconnue',
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 };
}
```
Ce modèle garantit qu'une limite de taux sur Twitter ne vous empêche pas d'afficher des messages sur Facebook. errors array permet à votre interface de présenter des résultats partiels avec des avertissements appropriés.
Pour normaliser les données spécifiques à chaque plateforme dans votre format unifié :
```javascript
// Normalisation des messages à partir de formats spécifiques aux plateformes
interface NormalizedMessage {
platformMessageId: string;
platform: string;
direction: 'entrant' | 'sortant';
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 ? 'sortant' : 'entrant',
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 ? 'sortant' : 'entrant',
senderId: raw.sender_id,
senderName: raw.sender?.name,
text: raw.text,
attachments: (raw.attachments?.media_keys || []).map(key => ({
type: 'image', // Les pièces jointes des DM Twitter sont généralement des images
url: raw.includes?.media?.find(m => m.media_key === key)?.url,
})),
platformTimestamp: new Date(raw.created_at),
};
}
```
Mises à jour en temps réel avec les Webhooks
Les webhooks éliminent le besoin de polling constant. Chaque plateforme a son propre format de webhook, vous devez donc disposer de gestionnaires spécifiques à chaque plateforme qui s'intègrent dans votre pipeline de traitement unifié.
// Gestionnaire de webhook pour les messages 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');
// Vérifier l'authenticité du webhook
if (!verifyInstagramSignature(body, signature)) {
return NextResponse.json({ error: 'Signature invalide' }, { status: 401 });
}
const payload = JSON.parse(body);
// Traiter chaque entrée (peut contenir plusieurs mises à jour)
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
) {
// Trouver le compte connecté
const account = await SocialAccount.findOne({
platform: 'instagram',
platformAccountId: pageId,
isActive: true,
});
if (!account) {
console.warn(`Aucun compte actif trouvé pour la page Instagram ${pageId}`);
return;
}
// Trouver ou créer une conversation
const conversation = await findOrCreateConversation({
accountId: account._id,
platform: 'instagram',
platformConversationId: messaging.sender.id, // Pour Instagram, l'ID de l'expéditeur est l'ID de la conversation
participantId: messaging.sender.id,
});
// Normaliser et stocker le message
const normalizedMessage = normalizeInstagramWebhookMessage(messaging, account);
await Message.findOneAndUpdate(
{
accountId: account._id,
platformMessageId: normalizedMessage.platform
Remarque : Vérifiez toujours les signatures des webhooks avant de les traiter. Sans vérification, des attaquants pourraient injecter de faux messages dans votre système.
Pour les plateformes sans support de webhook (comme Bluesky et Reddit), mettez en place un service de polling :
// Service de sondage pour les plateformes sans webhooks
class MessagePollingService {
private intervals: Map<string, NodeJS.Timeout> = 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(`Erreur de sondage pour ${accountId}:`, error);
}
};
// Sondage initial
await poll();
// Planifier des sondages récurrents
const interval = setInterval(poll, pollInterval);
this.intervals.set(accountId, interval);
}
private getPollInterval(platform: string): number {
// Respecter les limites de fréquence avec des intervalles prudents
const intervals: Record<string, number> = {
bluesky: 30000, // 30 secondes
reddit: 60000, // 60 secondes (limites strictes)
twitter: 60000, // 60 secondes (accès limité aux DM)
};
return intervals[platform] || 30000;
}
stopPolling(accountId: string) {
const interval = this.intervals.get(accountId);
if (interval) {
clearInterval(interval);
this.intervals.delete(accountId);
}
}
}
Pagination par curseur sur plusieurs plateformes
Lorsque vous regroupez des messages sociaux provenant de plusieurs comptes, la pagination traditionnelle par décalage devient inefficace. L'arrivée d'un nouveau message déplace tous les décalages. La pagination basée sur les curseurs résout ce problème en utilisant des identifiants stables.
Le défi : chaque plateforme utilise des formats de curseur différents. Votre API unifiée doit disposer d'un curseur qui encode suffisamment d'informations pour reprendre la pagination sur toutes les sources.
```typescript
// Pagination par curseur pour les résultats agrégés
export interface PaginationInfo {
hasMore: boolean;
nextCursor: string | null;
totalCount?: number;
}
// Format du curseur : {timestamp}_{accountId}_{itemId}
// Cela permet une pagination stable même lorsque des éléments sont ajoutés
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;
// Appliquer le filtre de curseur si fourni
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);
// Éléments avant le curseur (pour un ordre décroissant)
if (itemTime < cursorTime) return true;
if (itemTime === cursorTime) {
if (itemAccountId > cursorAccountId) return true;
if (itemAccountId === cursorAccountId && itemId > cursorItemId) return true;
}
return false;
});
}
// Appliquer la limite
const paginatedItems = filteredItems.slice(0, limit);
const hasMore = filteredItems.length > limit;
// Générer le prochain curseur à partir du dernier élément
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}_${
Utilisation dans votre point de terminaison API :
// Point de terminaison API utilisant la pagination par curseur
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';
// Récupérer les comptes de l'utilisateur
const accounts = await getInboxAccounts(userId, 'messages');
// Récupérer les conversations de tous les comptes
const { items: conversations, errors } = await aggregateFromAccounts(
accounts,
async (account) => {
return Conversation.find({
accountId: account._id,
status,
})
.sort({ lastMessageAt: -1 })
.limit(limit * 2) // Récupérer des éléments supplémentaires pour la fusion
.lean();
}
);
// Trier toutes les conversations par date du dernier message
const sorted = sortItems(
conversations,
'lastMessageAt',
'desc',
{ lastMessageAt: (c) => c.lastMessageAt }
);
// Appliquer la pagination par curseur
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),
});
}
Gestion des fonctionnalités spécifiques à chaque plateforme
Chaque plateforme possède des fonctionnalités uniques qui ne s'intègrent pas facilement dans un modèle unifié. Instagram propose des réponses aux stories et des mentions. Twitter a des DM cités. Telegram offre des chaînes de réponses et des réactions.
La clé est de stocker les données spécifiques à chaque plateforme de manière flexible. metadata champ tout en exposant les fonctionnalités communes via votre interface normalisée :
// Gestion des réponses aux stories Instagram
interface InstagramStoryReply {
storyId: string;
storyUrl: string;
storyMediaType: 'image' | 'vidéo';
expiresAt: Date; // Les stories expirent après 24 heures
}
function normalizeInstagramWebhookMessage(
messaging: InstagramMessagingEvent,
account: SocialAccount
): Partial {
const base = {
platformMessageId: messaging.message.mid,
platform: 'instagram',
direction: messaging.sender.id === account.platformAccountId ? 'sortant' : 'entrant',
senderId: messaging.sender.id,
platformTimestamp: new Date(messaging.timestamp),
};
// Gestion des réponses aux stories
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,
};
}
// Gestion des mentions de stories
if (messaging.message.attachments?.[0]?.type === 'story_mention') {
return {
...base,
isStoryMention: true,
attachments: [{
type: 'partage',
url: messaging.message.attachments[0].payload.url,
payload: messaging.message.attachments[0].payload,
}],
};
}
// Message standard
return {
...base,
text: messaging.message.text,
attachments: normalizeAttachments(messaging.message.attachments),
};
}
Pour LinkedIn, un traitement spécial est nécessaire car les fonctionnalités de messagerie ne fonctionnent qu'avec des comptes d'organisation :
// Validation du compte d'organisation LinkedIn
export function isLinkedInOrgAccount(
metadata: Map<string, unknown> | Record<string, unknown> | null | undefined
): boolean {
if (!metadata) return false;
// Gestion du type Map de Mongoose
if (metadata instanceof Map) {
return metadata.get('accountType') === 'organization' ||
metadata.has('selectedOrganization');
}
// Gestion de l'objet simple
return metadata.accountType === 'organization' ||
'selectedOrganization' in metadata;
}
// Filtrer les comptes pour ne garder que ceux prenant en charge les fonctionnalités de boîte de réception
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 nécessite un type de compte d'organisation
if (account.platform === 'linkedin' && !isLinkedInOrgAccount(account.metadata)) {
return false;
}
return true;
});
}
Dé-duplication et Fil de messages
Lors de l'agrégation de plusieurs sources, des doublons peuvent apparaître. Un message peut arriver via un webhook puis à nouveau lors d'une synchronisation par polling. Votre système doit disposer d'une déduplication robuste :
// Outils de dé-duplication
export function deduplicateItems<T>(
items: T[],
keyFn: (item: T) => string
): T[] {
const seen = new Set<string>();
return items.filter((item) => {
const key = keyFn(item);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// Utilisation : dé-duplication des messages par ID de plateforme
const uniqueMessages = deduplicateItems(
allMessages,
(msg) => `${msg.platform}_${msg.platformMessageId}`
);
Pour la déduplication au niveau de la base de données, utilisez des opérations d'upsert avec des index uniques :
```javascript
// Modèle d'upsert pour le traitement des messages 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,
// Retourner le document qu'il ait été inséré ou mis à jour
rawResult: false,
}
);
}
```
L'index unique sur { accountId: 1, platformMessageId: 1 } garantit que MongoDB rejette les doublons véritables au niveau de la base de données, même lors du traitement simultané des webhooks.
Optimisation des performances et mise en cache
Une boîte de réception multi-plateforme effectue de nombreux appels API. Sans mise en cache, vous atteindrez les limites de taux et créerez des expériences utilisateur lentes.
// Couche de cache Redis pour les listes de conversations
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
interface CacheOptions {
ttl: number; // secondes
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(',')}`;
// Essayer le cache en premier
const cached = await redis.get(cacheKey);
if (cached) {
const data = JSON.parse(cached);
const age = Date.now() - data.timestamp;
// Retourner les données mises en cache immédiatement
if (age < options.ttl * 1000) {
return { conversations: data.conversations, fromCache: true };
}
// Stale-while-revalidate : retourner des données périmées mais déclencher un rafraîchissement en arrière-plan
if (age < (options.staleWhileRevalidate || options.ttl) * 1000) {
// Lancer un rafraîchissement en arrière-plan
refreshConversationsCache(userId, accountIds, cacheKey).catch(console.error);
return { conversations: data.conversations, fromCache: true };
}
}
// Cache manquant ou expiré : récupérer des données fraîches
const conversations = await fetchConversationsFromDB(userId, accountIds);
// Stocker dans le 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, // TTL de 5 minutes pour le rafraîchissement en arrière-plan
JSON.stringify({ conversations, timestamp: Date.now() })
);
}
Stratégies d'optimisation supplémentaires :
- Traitement par lots des webhooksMettez en file d'attente les webhooks entrants et traitez-les par lots pour réduire les allers-retours vers la base de données.
- Pool de connexionsRéutilisez les connexions HTTP vers les API des plateformes
- Synchronisation incrémentale: Suivez l'horodatage de la dernière synchronisation par compte et ne récupérez que les messages plus récents.
- DenormalizationStockez les informations des participants directement dans les conversations pour éviter les regroupements.
Création de l'interface frontend
Votre interface frontend doit gérer les complexités d'une boîte de réception multi-plateformes tout en offrant une interface épurée. Voici un modèle de composant React :
```javascript
// Hook React pour une boîte de réception unifiée
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
interface UseInboxOptions {
status?: 'actif' | 'archivé';
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('Échec de la récupération des conversations');
}
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('Échec de la récupération des messages');
}
return response.json();
},
getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
initialPageParam: null as string | null,
enabled: !!conversationId,
});
}
```
Affichez des fonctionnalités spécifiques à chaque plateforme avec un rendu conditionnel :
```javascript
// Composant Message avec rendu spécifique à la plateforme
function MessageBubble({ message }: { message: Message }) {
const isOutgoing = message.direction === 'outgoing';
return (
{/* Indicateur de réponse à une story */}
{message.storyReply && (
Répondu à votre story
{message.storyReply.storyUrl && (
)}
)}
{/* Indicateur de mention dans une story */}
{message.isStoryMention && (
Vous a mentionné dans sa story
)}
{/* Texte du message */}
{message.text && (
{message.text}
)}
{/* Pièces jointes */}
{message.attachments?.map((attachment, i) => (
))}
{/* Métadonnées */}
);
}
```
Utiliser l'API Unified Inbox de Late
Créer une boîte de réception unifiée pour les réseaux sociaux de zéro nécessite des mois de développement. Il faut maintenir les intégrations avec plusieurs plateformes, gérer leurs modifications d'API, administrer les flux OAuth et faire face aux limites de taux.
Late propose une API de boîte de réception pour les réseaux sociaux préconçue qui gère toute cette complexité. Au lieu de vous intégrer à six API de plateformes différentes, vous vous intégrez à une seule.
Voici comment Late simplifie l'intégration de la boîte de réception :
// Utilisation de l'API de boîte de réception unifiée de Late
import { LateClient } from '@late/sdk';
const late = new LateClient({
apiKey: process.env.LATE_API_KEY,
});
// Récupérer les conversations de tous les comptes connectés
const { conversations, pagination, meta } = await late.inbox.getConversations({
status: 'active',
limit: 20,
});
// Obtenir les messages d'une conversation spécifique
const { messages } = await late.inbox.getMessages(conversationId, {
limit: 50,
});
// Envoyer une réponse (Late gère le formatage spécifique à chaque plateforme)
await late.inbox.sendMessage(conversationId, {
text: 'Merci de nous avoir contactés ! Comment puis-je vous aider ?',
});
// Filtrer par plateforme si nécessaire
const instagramOnly = await late.inbox.getConversations({
platform: 'instagram',
});
L'API unifiée de Late propose :
- Flux OAuth uniqueConnectez vos comptes une fois, accédez à toutes les fonctionnalités de la boîte de réception.
- Données normaliséesFormat de message cohérent sur Facebook, Instagram, Twitter, Bluesky, Reddit et Telegram
- Webhooks en temps réelUn point de terminaison webhook pour toutes les plateformes
- Pagination intégréePagination basée sur le curseur qui fonctionne sur plusieurs comptes
- Gestion des erreursDégradation gracieuse en cas de problèmes sur des plateformes individuelles
- Gestion des limites de tauxLogique de retour automatique et de nouvelle tentative
La méta d'agrégation dans les réponses de Late vous indique exactement quels comptes ont réussi et lesquels ont rencontré des problèmes :
// La réponse de Late inclut des métadonnées d'agrégation
interface LateInboxResponse {
conversations: Conversation[];
pagination: {
hasMore: boolean;
nextCursor: string | null;
};
meta: {
accountsQueried: number;
accountsFailed: number;
failedAccounts: Array<{
accountId: string;
platform: string;
error: string;
retryAfter?: number;
}>;
lastUpdated: string;
};
}
Cette transparence vous permet de créer des interfaces utilisateur qui informent les utilisateurs lorsque des comptes spécifiques rencontrent des problèmes temporaires, sans compromettre l'expérience globale de la boîte de réception.
Découvrez La documentation de Late pour commencer avec l'API de boîte de réception unifiée. Vous pouvez avoir une boîte de réception multi-plateforme opérationnelle en quelques heures au lieu de plusieurs mois.