⚡ AutomationsAI|Portal de Cursos →

Verificando acesso...

MÓDULO 5.3

🔌 Padrão Provider

Arquitetura plug-and-play para múltiplas redes sociais: contrato, factory, implementações reais (Bluesky e X), erros e testes.

6
Tópicos
50
Minutos
Avançado
Nível
Prático
Tipo
1

🧩 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

Interface

Contrato puro, sem implementação.

DTOs

Tipos isolam payloads de cada rede.

Discriminated union

Platform habilita exhaustive checks.

Promise

Toda I/O é assíncrona — sem exceção.

2

🏭 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

Factory pattern

Centraliza a criação de instâncias.

Registry

Map plataforma → construtor.

Open/Closed

Aberto para extensão, fechado p/ modificação.

Bootstrap

Registro de providers no boot da app.

3

🦋 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

BskyAgent

Client oficial do AT Protocol.

App password

Credencial escopada, sem expor a senha real.

uploadBlob

Sobe binário e retorna ref para o post.

RichText

Detecta menções, hashtags e links.

4

🐦 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

OAuth 2.0

Authorization code + PKCE.

PKCE

Proof Key for Code Exchange — protege o code.

Thread

Posts encadeados via reply.

Rate limit

Headers x-rate-limit-* guiam backoff.

5

⚠️ 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 retryAfterSec na fila de jobs.
  • Logar com platform e cause para 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 cause sem sanitizar.

Conceitos-chave

Error hierarchy

Classes tipadas habilitam instanceof.

Token refresh

Renovar e persistir antes de retry.

Backoff

Respeitar Retry-After ou exponencial.

Idempotência

Retry seguro só se o post não foi publicado.

6

🧪 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

Vitest

Runner moderno, compatível com Jest.

vi.mock

Substitui módulo inteiro do SDK.

Fixtures

Respostas reais capturadas e versionadas.

Cobertura

Happy + refresh + rate + payload inválido.

🎯 Resumo do Módulo

Interface SocialProvider definida — contrato mínimo com authenticate, refreshToken e post.
Factory com registrygetProvider(platform) despacha sem acoplar o serviço de publicação.
BlueskyProvider implementadoBskyAgent, login por app-password, uploadBlob e RichText.
XProvider implementadotwitter-api-v2, OAuth2 com PKCE, refresh e helper de thread.
Erros tipadosRefreshTokenError, RateLimitError e retry única após renovar token.
Testes com Vitest — mocks de SDK, fixtures versionadas e cobertura dos quatro cenários críticos.

Próximo Módulo:

5.4 — Agendamento, filas BullMQ e workers de publicação