Blog

API DM de Bluesky : Messagerie avec le protocole AT

Découvrez comment créer des fonctionnalités de messagerie directe avec l'API Bluesky et le protocole AT. Guide complet avec des exemples TypeScript pour l'intég

Par

+8

Publiez partout. Une API.

Try Free

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 :

ComponentPurposeEndpoint
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 chatGère le routage et le stockage des messages directs.api.bsky.chat
Répertoire PLCRésout les DIDs vers des points de serviceplc.directory
AppViewAgrégation de flux publicsbsky.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,

Build faster with Late

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

Free tier • No credit card • 99.97% uptime

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'erreurCode d'étatStratégie de nouvelle tentative
Limite de taux429Attendez l'en-tête de réinitialisation, puis réessayez.
Jeton expiré401Rafraîchissez le jeton, puis réessayez.
Validation400Ne pas réessayer, corriger la demande.
Erreur du serveur5xxRé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.

Une API. 13+ plateformes.

Intégrez les réseaux sociaux en minutes, pas en semaines.

Conçu pour les développeurs. Apprécié par les agences. Fiable pour 6 325 utilisateurs.