API DM de Bluesky : Messagerie avec le protocole AT
The API Bluesky offre une base solide pour créer des applications sociales, et ses capacités de messagerie directe ouvrent des possibilités passionnantes pour les développeurs. Contrairement aux plateformes sociales traditionnelles avec des API propriétaires, Bluesky Messagerie AT Protocol le système est basé sur des normes ouvertes, vous offrant un contrôle total sur la manière dont vous implémentez les fonctionnalités de chat.
Dans ce guide, vous apprendrez comment intégrer Messages directs Bluesky dans vos applications en utilisant TypeScript. Nous aborderons tout, de l'authentification à l'envoi de messages, en passant par la gestion du texte enrichi et la mise en œuvre des mises à jour en temps réel.
Comprendre l'architecture décentralisée
Avant d'écrire du code, il est essentiel de comprendre comment le système de messagerie de Bluesky diffère des plateformes centralisées. Le protocole AT sépare les préoccupations à travers plusieurs services :
| Component | Purpose | Endpoint |
|---|---|---|
| Serveur de Données Personnelles (PDS) | Stocke les données utilisateur et gère l'authentification. | Spécifique à l'utilisateur (résolu via PLC) |
| Service de chat | Gère le routage et le stockage des messages directs. | api.bsky.chat |
| Répertoire PLC | Résout les DIDs vers des points de service | plc.directory |
| AppView | Agrégation de flux publics | bsky.social |
The API de DM de Bluesky achemine les requêtes via votre Serveur de Données Personnelles (PDS), qui les relaie ensuite au service de chat. Cette architecture signifie que vous ne pouvez pas simplement accéder à un seul point de terminaison. Au lieu de cela, vous devez d'abord résoudre le PDS de l'utilisateur.
interface BlueskySession {
accessJwt: string;
refreshJwt: string;
did: string;
handle: string;
}
async function resolvePdsBaseUrl(userDid: string): Promise {
try {
const plcResponse = await fetch(
`https://plc.directory/${encodeURIComponent(userDid)}`
);
if (!plcResponse.ok) {
throw new Error(`Échec de la recherche PLC : ${plcResponse.status}`);
}
const plcDoc = await plcResponse.json();
const services = plcDoc?.service || [];
const pdsService = services.find((service: any) =>
service?.type?.toLowerCase().includes('atprotopersonaldataserver') ||
service?.id?.includes('#atproto_pds')
);
if (pdsService?.serviceEndpoint) {
return pdsService.serviceEndpoint.replace(/\/$/, '');
}
throw new Error('Aucun point de terminaison PDS trouvé dans le document DID');
} catch (error) {
console.warn(`Échec de la résolution PDS pour ${userDid} :`, error);
return 'https://bsky.social'; // Retour au défaut
}
}
Remarque : L'étape de résolution PDS est essentielle. La sauter entraînera des échecs d'authentification lors de l'accès au service de chat, en particulier pour les utilisateurs sur des instances PDS auto-hébergées.
Authentification avec des mots de passe d'application
The API Bluesky utilise des mots de passe d'application pour l'authentification plutôt qu'OAuth 2.0. Pour accéder aux messages directs, vous devez créer un mot de passe d'application avec des autorisations de messagerie explicites activées.
interface AuthConfig {
identifiant: string; // Identifiant ou email
motDePasse: string; // Mot de passe de l'application (pas le mot de passe principal)
}
async function createSession(config: AuthConfig): Promise<BlueskySession> {
const response = await fetch(
'https://bsky.social/xrpc/com.atproto.server.createSession',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
identifiant: config.identifiant,
motDePasse: config.motDePasse,
}),
}
);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Échec de l'authentification : ${response.status} ${errorBody}`);
}
const session = await response.json();
return {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
did: session.did,
handle: session.handle,
};
}
async function refreshSession(refreshJwt: string): Promise<BlueskySession> {
const response = await fetch(
'https://bsky.social/xrpc/com.atproto.server.refreshSession',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${refreshJwt}`,
},
}
);
if (!response.ok) {
throw new Error('Échec du rafraîchissement de la session. Veuillez vous réauthentifier.');
}
return response.json();
}
When creating your App Password in Bluesky settings, make sure to check "Allow access to your direct messages." Without this permission, you'll receive XRPCNotSupported erreurs lors de l'appel des points de terminaison de chat.
Service de Résolution PDS et de Chat
Le service de chat nécessite un en-tête proxy spécial pour acheminer les demandes correctement. Voici une implémentation complète du gestionnaire de demandes de chat :
const CHAT_SERVICE_DID = 'did:web:api.bsky.chat#bsky_chat';
interface ChatRequestOptions {
accessToken: string;
method: 'GET' | 'POST';
endpoint: string;
params?: Record;
body?: any;
userDid: string;
refreshToken?: string;
}
interface ChatResponse {
data: T;
newTokens?: {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
}
async function chatRequest(
options: ChatRequestOptions
): Promise> {
const { accessToken, method, endpoint, params, body, userDid, refreshToken } = options;
// Résoudre l'URL de base du PDS de l'utilisateur
const pdsBaseUrl = await resolvePdsBaseUrl(userDid);
const url = new URL(`${pdsBaseUrl}/xrpc/${endpoint}`);
// Ajouter des paramètres de requête pour les requêtes GET
if (method === 'GET' && params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
value.forEach(v => url.searchParams.append(key, String(v)));
} else {
url.searchParams.append(key, String(value));
}
}
});
}
const headers: Record = {
'Authorization': `Bearer ${accessToken}`,
'atproto-proxy': CHAT_SERVICE_DID,
};
if (method === 'POST' && body) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(url.toString(), {
method,
headers,
body: method === 'POST' && body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorBody = await response.text();
// Gérer l'expiration du token avec un rafraîchissement automatique
if (refreshToken && errorBody.includes('ExpiredToken')) {
const newSession = await refreshSession(refreshToken);
// Réessayer avec le nouveau token
return chatRequest({
...options,
accessToken: newSession.accessJwt,
refreshToken: newSession.refreshJwt,
});
}
//
Lister les conversations avec l'API DM de Bluesky
Implémentons maintenant la fonctionnalité principale pour lister les conversations. Messagerie AT Protocol le système renvoie les conversations avec les informations des participants et le dernier message :
interface Participant {
did: string;
handle: string;
displayName?: string;
avatar?: string;
}
interface Message {
id: string;
text: string;
sentAt: string;
sender: {
did: string;
};
}
interface Conversation {
id: string;
rev: string;
unreadCount: number;
muted: boolean;
participants: Participant[];
lastMessage: Message | null;
}
interface ListConversationsOptions {
limit?: number;
cursor?: string;
readState?: 'unread';
status?: 'request' | 'accepted';
}
async function listConversations(
accessToken: string,
userDid: string,
options: ListConversationsOptions = {}
): Promise<{ conversations: Conversation[]; cursor?: string }> {
const params: Record = {
limit: options.limit || 50,
};
if (options.cursor) params.cursor = options.cursor;
if (options.readState) params.readState = options.readState;
if (options.status) params.status = options.status;
const result = await chatRequest({
accessToken,
method: 'GET',
endpoint: 'chat.bsky.convo.listConvos',
params,
userDid,
});
const conversations = (result.data.convos || []).map((convo: any) => ({
id: convo.id,
rev: convo.rev,
unreadCount: convo.unreadCount || 0,
muted: convo.muted || false,
participants: (convo.members || []).map((member: any) => ({
did: member.did,
handle: member.handle,
displayName: member.displayName,
avatar: member.avatar,
})),
lastMessage: convo.lastMessage ? {
id: convo.lastMessage.id,
text: convo.lastMessage.text,
sentAt: convo.lastMessage.sentAt,
sender: { did: convo.lastMessage.sender?.did },
} : null,
}));
return {
conversations,
cursor: result.data.cursor,
};
}
// Exemple d'utilisation
async function displayInbox(session: BlueskySession) {
const { conversations } = await listConversations(
session.accessJwt,
session.did,
{ limit: 20 }
);
for (const convo of conversations) {
const otherParticipants = convo
Envoi de messages directs sur Bluesky
Envoyer des messages via le API de DM de Bluesky nécessite l'ID de la conversation. Vous pouvez soit utiliser une conversation existante, soit en créer une avec des participants spécifiques :
interface SendMessageResult {
id: string;
rev: string;
text: string;
sentAt: string;
}
async function sendMessage(
accessToken: string,
userDid: string,
conversationId: string,
text: string
): Promise<SendMessageResult> {
const result = await chatRequest<any>({
accessToken,
method: 'POST',
endpoint: 'chat.bsky.convo.sendMessage',
body: {
convoId: conversationId,
message: { text },
},
userDid,
});
return {
id: result.data.id,
rev: result.data.rev,
text: result.data.text,
sentAt: result.data.sentAt,
};
}
async function getOrCreateConversation(
accessToken: string,
userDid: string,
memberDids: string[]
): Promise<{ id: string; rev: string }> {
const result = await chatRequest<any>({
accessToken,
method: 'GET',
endpoint: 'chat.bsky.convo.getConvoForMembers',
params: { members: memberDids },
userDid,
});
return {
id: result.data.convo.id,
rev: result.data.convo.rev,
};
}
// Exemple complet : Envoyer un message direct à un utilisateur par son identifiant
async function sendDirectMessage(
session: BlueskySession,
recipientHandle: string,
messageText: string
): Promise<SendMessageResult> {
// D'abord, résoudre le DID du destinataire à partir de son identifiant
const resolveResponse = await fetch(
`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(recipientHandle)}`
);
if (!resolveResponse.ok) {
throw new Error(`Impossible de résoudre l'identifiant : ${recipientHandle}`);
}
const { did: recipientDid } = await resolveResponse.json();
// Obtenir ou créer la conversation
const conversation = await getOrCreateConversation(
session.accessJwt,
session.did,
[recipientDid]
);
// Envoyer le message
return sendMessage(
session.accessJwt,
Texte enrichi et facettes
Le protocole AT prend en charge le texte enrichi grâce aux "facettes", qui sont des annotations ajoutant des liens, des mentions et des hashtags au texte brut. Lors de la création de messages, il est nécessaire de calculer les décalages en octets (et non en caractères) pour un rendu correct :
interface Facet {
index: {
byteStart: number;
byteEnd: number;
};
features: Array<{
$type: string;
uri?: string;
did?: string;
tag?: string;
}>;
}
function parseRichTextFacets(text: string): Facet[] {
const facets: Facet[] = [];
// Analyser les URL
const urlRegex = /https?:\/\/[^\s<>"{}|\\^`[\]]+/gi;
let urlMatch;
while ((urlMatch = urlRegex.exec(text)) !== null) {
const url = urlMatch[0];
const start = urlMatch.index;
const end = start + url.length;
// Convertir les positions de caractères en positions d'octets
const byteStart = Buffer.byteLength(text.substring(0, start), 'utf8');
const byteEnd = Buffer.byteLength(text.substring(0, end), 'utf8');
facets.push({
index: { byteStart, byteEnd },
features: [{
$type: 'app.bsky.richtext.facet#link',
uri: url,
}],
});
}
// Analyser les mentions (@handle.domaine)
const mentionRegex = /@([a-zA-Z0-9._-]+(?:\.[a-zA-Z0-9._-]+)*)/gi;
let mentionMatch;
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
const mention = mentionMatch[0];
const handle = mentionMatch[1];
const start = mentionMatch.index;
const end = start + mention.length;
const byteStart = Buffer.byteLength(text.substring(0, start), 'utf8');
const byteEnd = Buffer.byteLength(text.substring(0, end), 'utf8');
facets.push({
index: { byteStart, byteEnd },
features: [{
$type: 'app.bsky.richtext.facet#mention',
did: handle, // Sera résolu en DID réel
}],
});
}
// Trier par position
facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
return facets;
}
// Résoudre les handles de mention en DIDs
async function resolveMentionDids(
accessToken: string,
Remarque : Le calcul des décalages d'octets est essentiel pour les caractères non-ASCII. Un seul emoji peut occuper 4 octets, donc utiliser des indices de caractères peut perturber le rendu du texte enrichi.
Mises à jour en temps réel
Pour des mises à jour de messages en temps réel, vous devrez mettre en œuvre le polling ou utiliser le flux d'événements du protocole AT. Voici une mise en œuvre pratique du polling :
interface MessagePollerOptions {
accessToken: string;
userDid: string;
conversationId: string;
onMessage: (message: Message) => void;
onError: (error: Error) => void;
pollInterval?: number;
}
class MessagePoller {
private intervalId: NodeJS.Timeout | null = null;
private lastMessageId: string | null = null;
private options: MessagePollerOptions;
constructor(options: MessagePollerOptions) {
this.options = {
pollInterval: 5000, // 5 secondes par défaut
...options,
};
}
async start(): Promise {
// Récupérer les messages initiaux pour établir une base
const { messages } = await this.fetchMessages();
if (messages.length > 0) {
this.lastMessageId = messages[0].id;
}
this.intervalId = setInterval(async () => {
try {
const { messages } = await this.fetchMessages();
// Trouver les nouveaux messages
const newMessages: Message[] = [];
for (const msg of messages) {
if (msg.id === this.lastMessageId) break;
newMessages.push(msg);
}
if (newMessages.length > 0) {
this.lastMessageId = newMessages[0].id;
// Livrer dans l'ordre chronologique
newMessages.reverse().forEach(msg => {
this.options.onMessage(msg);
});
}
} catch (error) {
this.options.onError(error as Error);
}
}, this.options.pollInterval);
}
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private async fetchMessages(): Promise<{ messages: Message[] }> {
const result = await chatRequest({
accessToken: this.options.accessToken,
method: 'GET',
endpoint: 'chat.bsky.convo.getMessages',
params: {
convoId: this.options.conversationId,
limit: 20,
},
userDid: this.options.userDid,
});
return {
messages: (result.data.messages || [])
.filter((msg: any) => msg.$type !== 'chat.bsky.convo.defs#deletedMessageView')
.map
Travailler avec les URI AT
Le protocole AT utilise des URI au format at://did/collection/rkey pour identifier les enregistrements. Comprendre ce format est essentiel pour travailler avec Messages directs Bluesky:
interface ATUri {
did: string;
collection: string;
rkey: string;
}
function parseATUri(uri: string): ATUri {
// Format : at://did:plc:xxx/app.bsky.feed.post/abc123
const match = uri.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/(.+)$/);
if (!match) {
throw new Error(`URI AT invalide : ${uri}`);
}
return {
did: match[1],
collection: match[2],
rkey: match[3],
};
}
function buildATUri(did: string, collection: string, rkey: string): string {
return `at://${did}/${collection}/${rkey}`;
}
// Extraire rkey pour les opérations de suppression
function extractRkey(uri: string): string {
const parts = uri.split('/');
return parts[parts.length - 1];
}
Gestion des erreurs et limites de taux
The API Bluesky a des limites de taux qui varient selon les points de terminaison. Mettez en œuvre une gestion des erreurs appropriée et un retour exponentiel :
interface RateLimitInfo {
limit: number;
remaining: number;
reset: Date;
}
class BlueskyApiError extends Error {
constructor(
message: string,
public statusCode: number,
public errorType: 'rate-limit' | 'auth' | 'validation' | 'server' | 'unknown',
public rateLimitInfo?: RateLimitInfo
) {
super(message);
this.name = 'BlueskyApiError';
}
}
function parseApiError(response: Response, body: string): BlueskyApiError {
const lowerBody = body.toLowerCase();
// Limitation de taux
if (response.status === 429 || lowerBody.includes('limite de taux')) {
const resetHeader = response.headers.get('ratelimit-reset');
return new BlueskyApiError(
'Limite de taux dépassée. Veuillez patienter avant de réessayer.',
response.status,
'rate-limit',
resetHeader ? {
limit: parseInt(response.headers.get('ratelimit-limit') || '0'),
remaining: 0,
reset: new Date(parseInt(resetHeader) * 1000),
} : undefined
);
}
// Erreurs d'authentification
if (
response.status === 401 ||
lowerBody.includes('jeton invalide') ||
lowerBody.includes('expiré')
) {
return new BlueskyApiError(
'Échec de l\'authentification. Veuillez reconnecter votre compte.',
response.status,
'auth'
);
}
// Erreurs de validation
if (response.status === 400 || lowerBody.includes('invalide')) {
return new BlueskyApiError(
`Erreur de validation : ${body}`,
response.status,
'validation'
);
}
// Erreurs serveur
if (response.status >= 500) {
return new BlueskyApiError(
'Service Bluesky temporairement indisponible.',
response.status,
'server'
);
}
return new BlueskyApiError(body, response.status, 'unknown');
}
async function withRetry(
operation: () => Promise,
maxRetries: number = 3
): Promise {
let lastError: Error | null = null;
for (let attempt =
| Type d'erreur | Code d'état | Stratégie de nouvelle tentative |
|---|---|---|
| Limite de taux | 429 | Attendez l'en-tête de réinitialisation, puis réessayez. |
| Jeton expiré | 401 | Rafraîchissez le jeton, puis réessayez. |
| Validation | 400 | Ne pas réessayer, corriger la demande. |
| Erreur du serveur | 5xx | Récupération exponentielle, maximum 3 tentatives |
Utiliser Late pour l'intégration de Bluesky
Créer et maintenir des intégrations directes avec le API de DM de Bluesky nécessite la gestion de la résolution PDS, de la gestion des jetons, du traitement des erreurs et de la mise à jour des changements de protocole. Cette complexité se multiplie lorsque vous devez prendre en charge plusieurs plateformes sociales.
Late fournit une API unifiée qui simplifie ces complexités. Au lieu de gérer des intégrations séparées pour Bluesky, Twitter, Instagram et d'autres plateformes, vous pouvez utiliser une seule interface cohérente :
```javascript
// Avec l'API unifiée de Late
import { Late } from '@late/sdk';
const late = new Late({ apiKey: process.env.LATE_API_KEY });
// Envoyer un message sur n'importe quelle plateforme prise en charge
await late.messages.send({
platform: 'bluesky',
accountId: 'votre-compte-connecté',
conversationId: 'convo-123',
text: 'Bonjour de Late !',
});
// Lister les conversations avec rafraîchissement automatique du token
const { conversations } = await late.messages.listConversations({
platform: 'bluesky',
accountId: 'votre-compte-connecté',
});
```
Late gère les défis d'infrastructure pour vous :
- Résolution PDS AutomatiquePas besoin de consulter vous-même le répertoire PLC.
- Gestion des jetonsRafraîchissement automatique et stockage sécurisé
- Gestion des limites de tauxLogique de nouvelle tentative intégrée avec un retour exponentiel.
- Normalisation des erreursFormats d'erreur cohérents sur toutes les plateformes
- Support des WebhooksNotifications en temps réel pour les nouveaux messages
Que vous développiez un outil de gestion des réseaux sociaux, un système de support client ou une plateforme communautaire, l'approche unifiée de Late vous permet de vous concentrer sur votre produit plutôt que sur la maintenance de l'API.
Découvrez le Documentation de Late pour commencer l'intégration de la messagerie Bluesky en quelques minutes, pas en plusieurs jours.