Blog

Bluesky DM API: Mensagens com AT Protocol

Aprenda a construir recursos de mensagens diretas com a Bluesky API e AT Protocol. Guia completo com exemplos em TypeScript para integração de chat.

Por

+8

Poste em tudo. Uma API.

Try Free

Bluesky DM API: Mensagens com AT Protocol

A Bluesky API fornece uma base poderosa para construir aplicações sociais, e suas capacidades de mensagens diretas abrem possibilidades empolgantes para desenvolvedores. Diferente de plataformas sociais tradicionais com APIs proprietárias, o sistema de mensagens do AT Protocol do Bluesky é construído sobre padrões abertos, dando a você controle completo sobre como implementar recursos de chat.

Neste guia, você aprenderá como integrar mensagens diretas do Bluesky em suas aplicações usando TypeScript. Cobriremos tudo, desde autenticação até envio de mensagens, manipulação de rich text e implementação de atualizações em tempo real.

Entendendo a Arquitetura Descentralizada

Antes de escrever qualquer código, é essencial entender como o sistema de mensagens do Bluesky difere de plataformas centralizadas. O AT Protocol separa responsabilidades entre múltiplos serviços:

Visão Geral da Arquitetura do Bluesky

ComponentePropósitoEndpoint
PDS (Personal Data Server)Armazena dados do usuário e gerencia autenticaçãoEspecífico do usuário (resolvido via PLC)
Serviço de ChatGerencia roteamento e armazenamento de mensagens diretasapi.bsky.chat
Diretório PLCResolve DIDs para endpoints de serviçoplc.directory
AppViewAgregação de feed públicobsky.social

A Bluesky DM API roteia requisições através do seu Personal Data Server (PDS), que então as encaminha para o serviço de chat. Essa arquitetura significa que você não pode simplesmente acessar um único endpoint. Em vez disso, você precisa resolver o PDS do usuário primeiro.

interface BlueskySession {
  accessJwt: string;
  refreshJwt: string;
  did: string;
  handle: string;
}

async function resolvePdsBaseUrl(userDid: string): Promise<string> {
  try {
    const plcResponse = await fetch(
      `https://plc.directory/${encodeURIComponent(userDid)}`
    );
    
    if (!plcResponse.ok) {
      throw new Error(`PLC lookup failed: ${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('No PDS endpoint found in DID document');
  } catch (error) {
    console.warn(`Failed to resolve PDS for ${userDid}:`, error);
    return 'https://bsky.social'; // Fallback to default
  }
}

Nota: A etapa de resolução do PDS é crítica. Ignorá-la causará falhas de autenticação ao acessar o serviço de chat, especialmente para usuários em instâncias PDS auto-hospedadas.

Autenticação com App Passwords

A Bluesky API usa App Passwords para autenticação em vez de OAuth 2.0. Para acesso a DM, você deve criar um App Password com permissões de mensagens explicitamente habilitadas.

interface AuthConfig {
  identifier: string; // Handle ou email
  password: string;   // App Password (não a senha 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({
        identifier: config.identifier,
        password: config.password,
      }),
    }
  );

  if (!response.ok) {
    const errorBody = await response.text();
    throw new Error(`Authentication failed: ${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('Session refresh failed. Please re-authenticate.');
  }

  return response.json();
}

Ao criar seu App Password nas configurações do Bluesky, certifique-se de marcar "Allow access to your direct messages." Sem essa permissão, você receberá erros XRPCNotSupported ao chamar endpoints de chat.

Resolução de PDS e Serviço de Chat

O serviço de chat requer um header de proxy especial para rotear requisições corretamente. Aqui está uma implementação completa do manipulador de requisições de chat:

const CHAT_SERVICE_DID = 'did:web:api.bsky.chat#bsky_chat';

interface ChatRequestOptions {
  accessToken: string;
  method: 'GET' | 'POST';
  endpoint: string;
  params?: Record<string, any>;
  body?: any;
  userDid: string;
  refreshToken?: string;
}

interface ChatResponse<T> {
  data: T;
  newTokens?: {
    accessToken: string;
    refreshToken: string;
    expiresIn: number;
  };
}

async function chatRequest<T>(
  options: ChatRequestOptions
): Promise<ChatResponse<T>> {
  const { accessToken, method, endpoint, params, body, userDid, refreshToken } = options;
  
  // Resolve user's PDS endpoint
  const pdsBaseUrl = await resolvePdsBaseUrl(userDid);
  const url = new URL(`${pdsBaseUrl}/xrpc/${endpoint}`);

  // Add query parameters for GET requests
  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<string, string> = {
    '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();
    
    // Handle token expiration with auto-refresh
    if (refreshToken && errorBody.includes('ExpiredToken')) {
      const newSession = await refreshSession(refreshToken);
      
      // Retry with new token
      return chatRequest({
        ...options,
        accessToken: newSession.accessJwt,
        refreshToken: newSession.refreshJwt,
      });
    }

    // Provide helpful error messages
    if (errorBody.includes('XRPCNotSupported')) {
      throw new Error(
        'DM access not enabled. Create a new App Password with "Allow access to your direct messages" checked.'
      );
    }

    throw new Error(`Chat request failed: ${response.status} ${errorBody}`);
  }

  return { data: await response.json() };
}

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

Listando Conversas com a Bluesky DM API

Agora vamos implementar a funcionalidade principal para listar conversas. O sistema de mensagens do AT Protocol retorna conversas com informações dos participantes e a última mensagem:

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<string, any> = {
    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<any>({
    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,
  };
}

// Usage example
async function displayInbox(session: BlueskySession) {
  const { conversations } = await listConversations(
    session.accessJwt,
    session.did,
    { limit: 20 }
  );

  for (const convo of conversations) {
    const otherParticipants = convo.participants
      .filter(p => p.did !== session.did)
      .map(p => p.displayName || p.handle)
      .join(', ');

    console.log(`Conversation with: ${otherParticipants}`);
    console.log(`  Unread: ${convo.unreadCount}`);
    
    if (convo.lastMessage) {
      console.log(`  Last message: ${convo.lastMessage.text.slice(0, 50)}...`);
    }
  }
}

Enviando Mensagens Diretas no Bluesky

Enviar mensagens através da Bluesky DM API requer o ID da conversa. Você pode usar uma conversa existente ou criar uma com participantes específicos:

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,
  };
}

// Complete example: Send a DM to a user by handle
async function sendDirectMessage(
  session: BlueskySession,
  recipientHandle: string,
  messageText: string
): Promise<SendMessageResult> {
  // First, resolve the recipient's DID from their handle
  const resolveResponse = await fetch(
    `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(recipientHandle)}`
  );
  
  if (!resolveResponse.ok) {
    throw new Error(`Could not resolve handle: ${recipientHandle}`);
  }
  
  const { did: recipientDid } = await resolveResponse.json();

  // Get or create the conversation
  const conversation = await getOrCreateConversation(
    session.accessJwt,
    session.did,
    [recipientDid]
  );

  // Send the message
  return sendMessage(
    session.accessJwt,
    session.did,
    conversation.id,
    messageText
  );
}

Rich Text e Facets

O AT Protocol suporta rich text através de "facets", que são anotações que adicionam links, menções e hashtags ao texto simples. Ao construir mensagens, você precisa calcular offsets em bytes (não offsets de caracteres) para renderização correta:

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[] = [];

  // Parse URLs
  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;

    // Convert character positions to byte positions
    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,
      }],
    });
  }

  // Parse mentions (@handle.domain)
  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, // Will be resolved to actual DID
      }],
    });
  }

  // Sort by position
  facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
  
  return facets;
}

// Resolve mention handles to DIDs
async function resolveMentionDids(
  accessToken: string,
  fac

Uma API. 13+ plataformas.

Integre redes sociais em minutos, não semanas.

Criado para desenvolvedores. Adorado por agências. Confiado por 6.325 usuários.