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:

| Componente | Propósito | Endpoint |
|---|---|---|
| PDS (Personal Data Server) | Armazena dados do usuário e gerencia autenticação | Específico do usuário (resolvido via PLC) |
| Serviço de Chat | Gerencia roteamento e armazenamento de mensagens diretas | api.bsky.chat |
| Diretório PLC | Resolve DIDs para endpoints de serviço | plc.directory |
| AppView | Agregação de feed público | bsky.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() };
}
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