🧩 Interface SocialProvider
O coração da arquitetura é um contrato TypeScript que toda rede social precisa cumprir. Bluesky, X, Mastodon, LinkedIn — todos implementam a mesma interface, e o código que agenda posts nunca conhece os detalhes de cada API.
Três métodos bastam para 95% dos casos: authenticate (OAuth/login), refreshToken (renovar credencial expirada) e post (publicar conteúdo).
// src/providers/types.ts
export type Platform = 'bluesky' | 'x' | 'mastodon' | 'linkedin';
export interface AuthCredentials {
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
metadata?: Record<string, unknown>;
}
export interface PostPayload {
text: string;
media?: MediaAttachment[];
replyTo?: string; // ID do post pai (threads)
scheduledAt?: Date;
}
export interface MediaAttachment {
buffer: Buffer;
mimeType: string; // image/png, image/jpeg, video/mp4
altText?: string;
}
export interface PostResult {
platformPostId: string; // ID retornado pela API da rede
url: string; // URL pública do post
postedAt: Date;
}
export interface SocialProvider {
readonly platform: Platform;
authenticate(code: string): Promise<AuthCredentials>;
refreshToken(creds: AuthCredentials): Promise<AuthCredentials>;
post(creds: AuthCredentials, payload: PostPayload): Promise<PostResult>;
}
💡 Dica prática
Mantenha a interface mínima. Toda vez que aparecer um método "só pro X" ou "só pro Bluesky", resista: ou ele cabe via metadata, ou vira um método opcional. Interface inchada vira código morto em 3 das 4 implementações.
Conceitos-chave
Contrato puro, sem implementação.
Tipos isolam payloads de cada rede.
Platform habilita exhaustive checks.
Toda I/O é assíncrona — sem exceção.
🏭 Factory de providers
Com o contrato pronto, falta o despachante: dada uma Platform, devolva a instância certa. Um switch faz o serviço, mas um registry com Map escala melhor — basta registrar novos providers sem editar a factory.
// src/providers/factory.ts
import { SocialProvider, Platform } from './types';
import { BlueskyProvider } from './bluesky.provider';
import { XProvider } from './x.provider';
const registry = new Map<Platform, () => SocialProvider>();
export function registerProvider(
platform: Platform,
factory: () => SocialProvider,
): void {
registry.set(platform, factory);
}
export function getProvider(platform: Platform): SocialProvider {
const factory = registry.get(platform);
if (!factory) {
throw new Error(`No provider registered for platform: ${platform}`);
}
return factory();
}
// Bootstrap — chamar uma vez no boot da app
export function registerAllProviders(): void {
registerProvider('bluesky', () => new BlueskyProvider());
registerProvider('x', () => new XProvider());
// mastodon e linkedin entram aqui sem tocar em getProvider()
}
Uso típico no serviço de publicação:
// src/services/publish.service.ts
import { getProvider } from '../providers/factory';
import { Platform, AuthCredentials, PostPayload } from '../providers/types';
export class PublishService {
async publish(
platform: Platform,
creds: AuthCredentials,
payload: PostPayload,
) {
const provider = getProvider(platform);
return provider.post(creds, payload);
}
}
🔁 Dica prática
A factory devolve uma nova instância por chamada. Se a sua implementação for stateless (e deve ser), isso é grátis. Se carregar SDK pesado, troque por singleton com lazy init — mas só depois de medir.
Conceitos-chave
Centraliza a criação de instâncias.
Map plataforma → construtor.
Aberto para extensão, fechado p/ modificação.
Registro de providers no boot da app.
🦋 Implementar BlueskyProvider
Bluesky usa AT Protocol e expõe o SDK oficial @atproto/api. A autenticação é por app password (não OAuth) — o usuário gera em bsky.app/settings/app-passwords. Upload de mídia exige etapa extra (uploadBlob) antes de anexar ao post.
// src/providers/bluesky.provider.ts
import { BskyAgent, RichText } from '@atproto/api';
import {
SocialProvider, AuthCredentials,
PostPayload, PostResult, Platform,
} from './types';
export class BlueskyProvider implements SocialProvider {
readonly platform: Platform = 'bluesky';
private agent(): BskyAgent {
return new BskyAgent({ service: 'https://bsky.social' });
}
async authenticate(code: string): Promise<AuthCredentials> {
// No Bluesky, "code" = "handle:app-password" (formato definido pelo app)
const [identifier, password] = code.split(':');
const agent = this.agent();
await agent.login({ identifier, password });
return {
accessToken: agent.session!.accessJwt,
refreshToken: agent.session!.refreshJwt,
metadata: { did: agent.session!.did, handle: agent.session!.handle },
};
}
async refreshToken(creds: AuthCredentials): Promise<AuthCredentials> {
const agent = this.agent();
await agent.resumeSession({
accessJwt: creds.accessToken,
refreshJwt: creds.refreshToken!,
did: creds.metadata!.did as string,
handle: creds.metadata!.handle as string,
active: true,
});
return {
accessToken: agent.session!.accessJwt,
refreshToken: agent.session!.refreshJwt,
metadata: creds.metadata,
};
}
async post(
creds: AuthCredentials,
payload: PostPayload,
): Promise<PostResult> {
const agent = this.agent();
await agent.resumeSession({
accessJwt: creds.accessToken,
refreshJwt: creds.refreshToken!,
did: creds.metadata!.did as string,
handle: creds.metadata!.handle as string,
active: true,
});
// Detecta menções e links automaticamente
const rt = new RichText({ text: payload.text });
await rt.detectFacets(agent);
// Upload de mídia (se houver)
const embed = payload.media?.length
? await this.uploadImages(agent, payload.media)
: undefined;
const res = await agent.post({
text: rt.text,
facets: rt.facets,
embed,
createdAt: new Date().toISOString(),
});
return {
platformPostId: res.uri,
url: `https://bsky.app/profile/${creds.metadata!.handle}/post/${res.uri.split('/').pop()}`,
postedAt: new Date(),
};
}
private async uploadImages(agent: BskyAgent, media: PostPayload['media']) {
const images = await Promise.all(
media!.map(async (m) => {
const upload = await agent.uploadBlob(m.buffer, { encoding: m.mimeType });
return { image: upload.data.blob, alt: m.altText ?? '' };
}),
);
return { $type: 'app.bsky.embed.images', images };
}
}
📎 Dica prática
RichText.detectFacets() é o que transforma @user.bsky.social em menção clicável e URL em link. Sem isso seus posts viram texto puro — e a API ainda aceita, mas o usuário acha que sua app está bugada.
Conceitos-chave
Client oficial do AT Protocol.
Credencial escopada, sem expor a senha real.
Sobe binário e retorna ref para o post.
Detecta menções, hashtags e links.
🐦 Implementar XProvider
O X (ex-Twitter) usa OAuth 2.0 com PKCE e o SDK twitter-api-v2. Diferente do Bluesky, threads são feitas encadeando posts via reply.in_reply_to_tweet_id. Upload de mídia passa pelo endpoint v1.1 — limitação histórica da API.
// src/providers/x.provider.ts
import { TwitterApi, TwitterApiTokens } from 'twitter-api-v2';
import {
SocialProvider, AuthCredentials,
PostPayload, PostResult, Platform,
} from './types';
interface XOAuthState {
codeVerifier: string;
redirectUri: string;
}
export class XProvider implements SocialProvider {
readonly platform: Platform = 'x';
private clientId = process.env.X_CLIENT_ID!;
private clientSecret = process.env.X_CLIENT_SECRET!;
async authenticate(code: string): Promise<AuthCredentials> {
// "code" carrega { authCode, codeVerifier, redirectUri } serializado
const { authCode, codeVerifier, redirectUri } = JSON.parse(code) as
{ authCode: string } & XOAuthState;
const tmp = new TwitterApi({ clientId: this.clientId, clientSecret: this.clientSecret });
const { accessToken, refreshToken, expiresIn } = await tmp.loginWithOAuth2({
code: authCode,
codeVerifier,
redirectUri,
});
return {
accessToken,
refreshToken,
expiresAt: new Date(Date.now() + expiresIn * 1000),
};
}
async refreshToken(creds: AuthCredentials): Promise<AuthCredentials> {
if (!creds.refreshToken) {
throw new Error('Missing refresh token for X provider');
}
const tmp = new TwitterApi({ clientId: this.clientId, clientSecret: this.clientSecret });
const { accessToken, refreshToken, expiresIn } =
await tmp.refreshOAuth2Token(creds.refreshToken);
return {
accessToken,
refreshToken,
expiresAt: new Date(Date.now() + expiresIn * 1000),
};
}
async post(
creds: AuthCredentials,
payload: PostPayload,
): Promise<PostResult> {
const client = new TwitterApi(creds.accessToken);
const v2 = client.v2;
// Upload via v1.1 (limitação da API)
const mediaIds: string[] = [];
if (payload.media?.length) {
for (const m of payload.media) {
const id = await client.v1.uploadMedia(m.buffer, { mimeType: m.mimeType });
mediaIds.push(id);
}
}
const res = await v2.tweet({
text: payload.text,
media: mediaIds.length ? { media_ids: mediaIds as [string] } : undefined,
reply: payload.replyTo ? { in_reply_to_tweet_id: payload.replyTo } : undefined,
});
const me = await v2.me();
return {
platformPostId: res.data.id,
url: `https://x.com/${me.data.username}/status/${res.data.id}`,
postedAt: new Date(),
};
}
// Helper: publica thread em sequência
async postThread(
creds: AuthCredentials,
tweets: string[],
): Promise<PostResult[]> {
const out: PostResult[] = [];
let parentId: string | undefined;
for (const text of tweets) {
const r = await this.post(creds, { text, replyTo: parentId });
out.push(r);
parentId = r.platformPostId;
}
return out;
}
}
⚠️ Dica prática
O tier gratuito do X tem limite agressivo (1.500 posts/mês) e rate limits que mordem em testes. Crie um XProviderMock para CI/local e injete via DI — você não vai querer queimar quota em test runs.
Conceitos-chave
Authorization code + PKCE.
Proof Key for Code Exchange — protege o code.
Posts encadeados via reply.
Headers x-rate-limit-* guiam backoff.
⚠️ Tratamento de erro e RefreshTokenError
APIs sociais erram por quatro motivos principais: token expirado, rate limit, payload inválido e indisponibilidade. Cada um exige resposta diferente — e try/catch genérico esconde mais do que resolve. Crie classes de erro tipadas e trate cada caso na camada certa.
// src/providers/errors.ts
export class ProviderError extends Error {
constructor(
message: string,
public readonly platform: string,
public readonly cause?: unknown,
) {
super(message);
this.name = this.constructor.name;
}
}
export class RefreshTokenError extends ProviderError {}
export class RateLimitError extends ProviderError {
constructor(message: string, platform: string, public retryAfterSec: number, cause?: unknown) {
super(message, platform, cause);
}
}
export class InvalidPayloadError extends ProviderError {}
export class ProviderUnavailableError extends ProviderError {}
Wrapper que renova o token e re-tenta uma única vez quando o erro é de expiração. Mais de uma retry vira loop infinito — token quebrado é estado terminal.
// src/services/publish-with-retry.ts
import { SocialProvider, AuthCredentials, PostPayload, PostResult } from '../providers/types';
import { RefreshTokenError, RateLimitError } from '../providers/errors';
import { CredentialsRepo } from '../repos/credentials.repo';
export class PublishWithRetry {
constructor(private readonly repo: CredentialsRepo) {}
async run(
provider: SocialProvider,
accountId: string,
creds: AuthCredentials,
payload: PostPayload,
): Promise<PostResult> {
try {
return await provider.post(creds, payload);
} catch (err) {
if (err instanceof RefreshTokenError) {
const fresh = await provider.refreshToken(creds);
await this.repo.update(accountId, fresh);
return provider.post(fresh, payload); // retry ÚNICO
}
if (err instanceof RateLimitError) {
// Reagenda na fila — não bloqueia o worker
throw err;
}
throw err;
}
}
}
E o ponto onde o SDK joga erro cru, traduzimos para a hierarquia tipada:
// dentro de XProvider.post(), envolvendo a chamada
try {
return await v2.tweet({ /* ... */ });
} catch (err: any) {
if (err?.code === 401) throw new RefreshTokenError('X token expired', 'x', err);
if (err?.code === 429) {
const retry = Number(err?.headers?.['x-rate-limit-reset']) - Math.floor(Date.now() / 1000);
throw new RateLimitError('X rate limit', 'x', Math.max(retry, 1), err);
}
if (err?.code >= 400 && err?.code < 500) {
throw new InvalidPayloadError(err?.data?.detail ?? 'Bad request', 'x', err);
}
throw new ProviderUnavailableError('X unavailable', 'x', err);
}
✓ O que FAZER
- ✓Traduzir erros do SDK para classes próprias na borda do provider.
- ✓Persistir o token novo antes da retry.
- ✓Respeitar
retryAfterSecna fila de jobs. - ✓Logar com
platformecausepara debug.
✗ O que NÃO fazer
- ✗Retry em loop sem limite — token expirado de verdade não volta.
- ✗Engolir erro com
catch {}vazio. - ✗Tratar 4xx como falha temporária — payload errado não melhora.
- ✗Vazar instância do SDK no
causesem sanitizar.
Conceitos-chave
Classes tipadas habilitam instanceof.
Renovar e persistir antes de retry.
Respeitar Retry-After ou exponencial.
Retry seguro só se o post não foi publicado.
🧪 Testes unitários por provider
Não dá pra bater na API real em CI: rate limit, custo, posts vazando para timeline pública. Use Vitest com mock do SDK e fixtures que reproduzem respostas reais capturadas uma vez. Cada provider tem seu arquivo de teste, cobrindo: happy path, refresh, rate limit e payload inválido.
// tests/fixtures/x.fixtures.ts
export const X_TWEET_OK = {
data: { id: '1789012345678901234', text: 'Olá mundo' },
};
export const X_TWEET_429 = {
code: 429,
headers: { 'x-rate-limit-reset': String(Math.floor(Date.now() / 1000) + 60) },
data: { detail: 'Too Many Requests' },
};
export const X_TWEET_401 = { code: 401, data: { detail: 'Unauthorized' } };
// tests/providers/x.provider.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TwitterApi } from 'twitter-api-v2';
import { XProvider } from '../../src/providers/x.provider';
import { RateLimitError, RefreshTokenError } from '../../src/providers/errors';
import { X_TWEET_OK, X_TWEET_429, X_TWEET_401 } from '../fixtures/x.fixtures';
vi.mock('twitter-api-v2');
describe('XProvider.post', () => {
const creds = { accessToken: 'access-abc', refreshToken: 'refresh-xyz' };
const payload = { text: 'Olá mundo' };
let tweetSpy: ReturnType<typeof vi.fn>;
let meSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
tweetSpy = vi.fn();
meSpy = vi.fn().mockResolvedValue({ data: { username: 'mkblogs' } });
(TwitterApi as unknown as vi.Mock).mockImplementation(() => ({
v2: { tweet: tweetSpy, me: meSpy },
v1: { uploadMedia: vi.fn() },
}));
});
it('publica tweet e retorna PostResult', async () => {
tweetSpy.mockResolvedValueOnce(X_TWEET_OK);
const provider = new XProvider();
const res = await provider.post(creds, payload);
expect(res.platformPostId).toBe('1789012345678901234');
expect(res.url).toBe('https://x.com/mkblogs/status/1789012345678901234');
expect(tweetSpy).toHaveBeenCalledOnce();
});
it('lança RefreshTokenError em 401', async () => {
tweetSpy.mockRejectedValueOnce(X_TWEET_401);
const provider = new XProvider();
await expect(provider.post(creds, payload)).rejects.toBeInstanceOf(RefreshTokenError);
});
it('lança RateLimitError com retryAfterSec em 429', async () => {
tweetSpy.mockRejectedValueOnce(X_TWEET_429);
const provider = new XProvider();
try {
await provider.post(creds, payload);
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(RateLimitError);
expect((err as RateLimitError).retryAfterSec).toBeGreaterThan(0);
}
});
});
// tests/providers/bluesky.provider.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { BskyAgent } from '@atproto/api';
import { BlueskyProvider } from '../../src/providers/bluesky.provider';
vi.mock('@atproto/api', () => ({
BskyAgent: vi.fn().mockImplementation(() => ({
login: vi.fn().mockResolvedValue(undefined),
session: {
accessJwt: 'jwt-access',
refreshJwt: 'jwt-refresh',
did: 'did:plc:abc',
handle: 'mkblogs.bsky.social',
},
resumeSession: vi.fn(),
post: vi.fn().mockResolvedValue({ uri: 'at://did:plc:abc/app.bsky.feed.post/3kabc' }),
uploadBlob: vi.fn(),
})),
RichText: vi.fn().mockImplementation(() => ({
text: 'oi',
facets: [],
detectFacets: vi.fn(),
})),
}));
describe('BlueskyProvider.authenticate', () => {
it('retorna credenciais a partir de handle:password', async () => {
const provider = new BlueskyProvider();
const creds = await provider.authenticate('mkblogs.bsky.social:app-pw-1234');
expect(creds.accessToken).toBe('jwt-access');
expect(creds.metadata?.handle).toBe('mkblogs.bsky.social');
});
});
🎯 Dica prática
Capture fixtures uma vez contra a API real e versione no repo (sanitize tokens!). É infinitamente mais confiável que mocks inventados — quando a API muda formato, seu teste quebra de propósito.
Conceitos-chave
Runner moderno, compatível com Jest.
Substitui módulo inteiro do SDK.
Respostas reais capturadas e versionadas.
Happy + refresh + rate + payload inválido.
🎯 Resumo do Módulo
authenticate, refreshToken e post.getProvider(platform) despacha sem acoplar o serviço de publicação.BskyAgent, login por app-password, uploadBlob e RichText.twitter-api-v2, OAuth2 com PKCE, refresh e helper de thread.RefreshTokenError, RateLimitError e retry única após renovar token.Próximo Módulo:
5.4 — Agendamento, filas BullMQ e workers de publicação