Blog

Bluesky DM API: Mensajería con AT Protocol

Aprende a construir funciones de mensajería directa con la Bluesky API y AT Protocol. Guía completa con ejemplos en TypeScript para integración de chat.

Por

+8

Publica en todo. Una API.

Try Free

Bluesky DM API: Mensajería con AT Protocol

La Bluesky API proporciona una base poderosa para construir aplicaciones sociales, y sus capacidades de mensajería directa abren posibilidades emocionantes para los desarrolladores. A diferencia de las plataformas sociales tradicionales con APIs propietarias, el sistema de mensajería de AT Protocol está construido sobre estándares abiertos, dándote control completo sobre cómo implementas las funciones de chat.

En esta guía, aprenderás cómo integrar mensajes directos de Bluesky en tus aplicaciones usando TypeScript. Cubriremos todo, desde la autenticación hasta el envío de mensajes, manejo de texto enriquecido e implementación de actualizaciones en tiempo real.

Entendiendo la Arquitectura Descentralizada

Antes de escribir cualquier código, es esencial entender cómo el sistema de mensajería de Bluesky difiere de las plataformas centralizadas. El AT Protocol separa las responsabilidades entre múltiples servicios:

Descripción General de la Arquitectura de Bluesky

ComponentePropósitoEndpoint
PDS (Personal Data Server)Almacena datos de usuario y maneja la autenticaciónEspecífico del usuario (resuelto vía PLC)
Servicio de ChatManeja el enrutamiento y almacenamiento de mensajes directosapi.bsky.chat
Directorio PLCResuelve DIDs a endpoints de servicioplc.directory
AppViewAgregación de feed públicobsky.social

La Bluesky DM API enruta las solicitudes a través de tu Personal Data Server (PDS), que luego las redirige al servicio de chat. Esta arquitectura significa que no puedes simplemente acceder a un único endpoint. En su lugar, necesitas resolver primero el PDS del usuario.

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: El paso de resolución del PDS es crítico. Omitirlo causará fallos de autenticación al acceder al servicio de chat, especialmente para usuarios en instancias PDS auto-alojadas.

Autenticación con App Passwords

La Bluesky API usa App Passwords para autenticación en lugar de OAuth 2.0. Para acceso a DM, debes crear un App Password con permisos de mensajería explícitamente habilitados.

interface AuthConfig {
  identifier: string; // Handle o email
  password: string;   // App Password (no la contraseña 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();
}

Al crear tu App Password en la configuración de Bluesky, asegúrate de marcar "Allow access to your direct messages." Sin este permiso, recibirás errores XRPCNotSupported al llamar a los endpoints de chat.

Resolución de PDS y Servicio de Chat

El servicio de chat requiere un header proxy especial para enrutar las solicitudes correctamente. Aquí hay una implementación completa del manejador de solicitudes 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;
  
  // Resolver el endpoint PDS del usuario
  const pdsBaseUrl = await resolvePdsBaseUrl(userDid);
  const url = new URL(`${pdsBaseUrl}/xrpc/${endpoint}`);

  // Agregar parámetros de consulta para solicitudes 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<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();
    
    // Manejar expiración de token con auto-refresh
    if (refreshToken && errorBody.includes('ExpiredToken')) {
      const newSession = await refreshSession(refreshToken);
      
      // Reintentar con nuevo token
      return chatRequest({
        ...options,
        accessToken: newSession.accessJwt,
        refreshToken: newSession.refreshJwt,
      });
    }

    // Proporcionar mensajes de error útiles
    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 Conversaciones con la Bluesky DM API

Ahora implementemos la funcionalidad principal para listar conversaciones. El sistema de mensajería de AT Protocol devuelve conversaciones con información de participantes y el último mensaje:

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

// Ejemplo de uso
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(`Conversación con: ${otherParticipants}`);
    console.log(`  Sin leer: ${convo.unreadCount}`);
    
    if (convo.lastMessage) {
      console.log(`  Último mensaje: ${convo.lastMessage.text.slice(0, 50)}...`);
    }
  }
}

Enviando Mensajes Directos de Bluesky

Enviar mensajes a través de la Bluesky DM API requiere el ID de conversación. Puedes usar una conversación existente o crear una con 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,
  };
}

// Ejemplo completo: Enviar un DM a un usuario por handle
async function sendDirectMessage(
  session: BlueskySession,
  recipientHandle: string,
  messageText: string
): Promise<SendMessageResult> {
  // Primero, resolver el DID del destinatario desde su 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();

  // Obtener o crear la conversación
  const conversation = await getOrCreateConversation(
    session.accessJwt,
    session.did,
    [recipientDid]
  );

  // Enviar el mensaje
  return sendMessage(
    session.accessJwt,
    session.did,
    conversation.id,
    messageText
  );
}

Texto Enriquecido y Facets

El AT Protocol soporta texto enriquecido a través de "facets", que son anotaciones que agregan enlaces, menciones y hashtags al texto plano. Al construir mensajes, necesitas calcular offsets de bytes (no offsets de caracteres) para un renderizado correcto:

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

  // Parsear 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;

    // Convertir posiciones de caracteres a posiciones de bytes
    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,
      }],
    });
  }

  // Parsear menciones (@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, // Se resolverá al DID real
      }],
    });
  }

  // Ordenar por posición
  facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
  
  return facets;
}

// Resolver handles de menciones a DIDs
async function resolveMentionDids(
  accessToken: string,
  fac

Una API. 13+ plataformas.

Integra redes sociales en minutos, no semanas.

Diseñada para desarrolladores. Usada por agencias. Más de 6,325 usuarios.