4
Th4lissonD4miao
  • Patrocinado
    Patrocinado
12 min de leitura ·

Construí uma alternativa open source ao Buffer com Bun, Next.js e MCP - por dentro da arquitetura do manypost

Depois de muito tempo entre código, migrations, OAuth, filas e APIs que mudam sem pedir licença, chegou o momento de apresentar o Manypost.

O Manypost é uma plataforma open source para criar, aprovar, agendar e publicar conteúdo em várias redes sociais. Ele pode ser usado pela interface web, por uma API REST ou diretamente por agentes de IA através do Model Context Protocol, o MCP.

A descrição curta seria “uma alternativa self-hosted ao Buffer, Hootsuite, Hypefury e Later”. Mas por trás do calendário existe um sistema multi-tenant lidando com filas, OAuth, rate limits, idempotência, mídia, falhas distribuídas e contratos diferentes para cada rede.

Neste artigo, apresento o problema, as decisões arquiteturais e o que aprendi construindo um publicador multicanal com Bun, Hono, Next.js, PostgreSQL, Drizzle, pg-boss e Redis.

O código está no GitHub, sob a licença AGPL-3.0:

github.com/manypost/manypost-app

Por que construir outro agendador de redes sociais?

Existem ferramentas maduras para gestão de conteúdo, mas em muitas o código é fechado, o self-hosting é limitado, automações dependem de planos caros e a API cobre apenas parte do produto. Recursos para agentes de IA e versões comunitárias também costumam viver em camadas ou bases separadas.

Eu queria explorar outra direção: uma aplicação cujo código completo pudesse ser inspecionado e executado, em que interface, API e agentes compartilhassem a mesma regra de negócio.

Isso levou a alguns princípios:

  1. o self-hosted não seria uma demonstração reduzida;
  2. automações não teriam um domínio paralelo ao da interface;
  3. adicionar uma rede social não deveria espalhar condicionais pelo sistema;
  4. o banco seria a fonte de verdade das publicações;
  5. em caso de dúvida após uma chamada externa, o sistema não repostaria automaticamente;
  6. limitações e origem do projeto seriam documentadas.

O resultado ainda é um MVP, mas já deixou de ser apenas um experimento de calendário.

O que o Manypost faz hoje

No compositor multicanal, a pessoa escreve, escolhe canais, adiciona mídia e configura cada destino.

A partir daí, o sistema oferece:

  • agendamento, texto e configurações próprias por canal;
  • calendário e kanban de publicações;
  • threads com atraso entre os itens;
  • biblioteca de mídia e aprovação por link público;
  • conexão de canais por OAuth ou credenciais;
  • retry, rate limit, semáforos de concorrência e recovery;
  • webhooks assinados e atualizações em tempo real por SSE;
  • API REST com OpenAPI 3.1 e servidor MCP;
  • execução self-hosted e serviço gerenciado a partir do mesmo monorepo.

O registry atual possui adapters para Mastodon, Telegram, Bluesky, Discord, LinkedIn, X, TikTok, Threads, Instagram, Facebook, Twitch, Kick, Dev.to e YouTube. A disponibilidade depende das credenciais e aprovações de cada plataforma.

O objetivo não é fingir que todas essas APIs são iguais. É justamente modelar suas diferenças sem transformar o core em uma sequência interminável de if (provider === ...).

Arquitetura em uma imagem

Web, API pública e MCP são três portas de entrada para os mesmos casos de uso:

Web ──┐
REST ─┼──► API/Hono ──► core/casos de uso
MCP ──┘                    │
                  ┌────────┼─────────┐
                  ▼        ▼         ▼
              PostgreSQL  filas  providers

O monorepo está dividido assim:

MóduloResponsabilidade
apps/apiHono, auth, OpenAPI, webhooks, SSE e MCP
apps/webinterface Next.js
apps/workerconsumidores de jobs
packages/coredomínio, máquina de estados, casos de uso e ports
packages/dbDrizzle/PostgreSQL e repositories
packages/providersadapters das redes sociais
packages/queuepg-boss, Redis e coordenação
packages/contractstipos, schemas e eventos
packages/configambiente e hosts

O packages/core não importa as aplicações, o banco ou os providers. O domínio de publicação não depende de framework. Essas fronteiras são verificadas no CI com dependency-cruiser; não são apenas caixas bonitas em um diagrama.

Decisão 1: uma regra de negócio para pessoas, APIs e agentes

Quando uma aplicação começa pela interface e ganha uma API depois, é comum duplicar validações e autorização.

No Manypost, as entradas são adapters:

Pessoa ─────────► Web ─────┐
Automação ──────► REST ────┼──► schedulePost() ──► domínio
Agente de IA ───► MCP ─────┘

O caso de uso recebe ator, organização, canais, conteúdo e configurações, sem saber se a intenção veio de um clique, n8n ou MCP.

A API pública usa chaves com scopes, rate limit e Idempotency-Key. O OpenAPI gera o cliente tipado do frontend.

O MCP usa Streamable HTTP para listar canais, consultar posts, agendar, editar, cancelar e importar mídia. O agente não acessa o banco diretamente: passa pelas mesmas regras de autorização, plano, canal e auditoria.

Essa decisão reduz duplicação e torna as superfícies comparáveis. Se uma regra existe apenas na interface, provavelmente está na camada errada.

Decisão 2: providers são contratos de capacidade

“Publicar em uma rede social” parece uma interface simples. Na prática, cada plataforma muda limites de texto e mídia, formatos, threads, autenticação, campos obrigatórios, subcontas, concorrência e rotação de credenciais.

Por isso, cada adapter implementa comportamento e descreve capabilities:

                    ChannelProvider
                          │
          ┌───────────────┼────────────────┐
          ▼               ▼                ▼
     autenticação     capabilities      publicação
     OAuth/campos     texto/mídia       post/reply
     refresh token    limites/schema    resultado/erro

O compositor usa essas informações para montar os controles, o core valida o conteúdo e o worker executa o efeito externo.

Uma configuração do YouTube não precisa existir no domínio do Mastodon, nem um artigo do Dev.to caber no formato de um post curto.

Cada provider fica isolado e passa por um test-kit de contrato. A complexidade externa continua existindo, mas não se espalha pelo monorepo.

Decisão 3: PostgreSQL guarda a intenção; a fila realiza o trabalho

Ao agendar conteúdo, uma transação cria:

  • o grupo que representa a criação multicanal;
  • uma publicação por canal;
  • os itens individuais de cada thread;
  • estados, cursores, horário e versão do job.

Somente depois do commit o trabalho é enviado ao pg-boss.

Web / REST / MCP
       │
       ▼
validar ator, organização, canais, mídia e capabilities
       │
       ▼
┌──────────────────── transação PostgreSQL ────────────────────┐
│ post_group                                                   │
│   ├── publication (canal A)                                  │
│   │      ├── item 0                                          │
│   │      └── item 1                                          │
│   └── publication (canal B)                                  │
└───────────────────────────────────────────────────────────────┘
       │ commit
       ▼
enfileirar publicação no pg-boss
       │
       ▼
worker chama o provider

Enfileirar antes do commit permitiria que o worker procurasse algo que ainda não existe. Mas fazer o enqueue depois também abre uma janela: o processo pode cair após persistir e antes de criar o job.

Por isso existe um scanner de recuperação. Uma publicação SCHEDULED vencida continua representando uma intenção não cumprida, mesmo se o job desaparecer. O scanner pode recriá-lo.

Essa escolha torna o PostgreSQL a fonte de verdade e a fila um mecanismo de execução. O pg-boss também usa PostgreSQL, o que reduz a infraestrutura necessária para self-hosting.

Redis possui outro papel: rate limit, semáforos, idempotência da API e pub/sub para realtime. Ele coordena; não decide se um post foi publicado.

Decisão 4: publicação precisa de uma máquina de estados

Um booleano published não explica o que está acontecendo. Uma publicação pode estar aguardando, executando, renovando credencial, esperando retry, cancelada, concluída ou em estado incerto.

O domínio trabalha com transições explícitas:

                    ┌───────────────┐
                    │ TOKEN_REFRESH │
                    └───────┬───────┘
                            │
                            ▼
DRAFT ──► SCHEDULED ──► PUBLISHING ──► PUBLISHED
  │            │             │
  │            │             ├────────► RETRYING ──┐
  │            │             │                     │
  ▼            ▼             ├────────► FAILED     │
CANCELLED ◄────┘             │                     │
                             └────────► NEEDS_REVIEW

As mudanças são condicionais. Um worker não pede simplesmente “grave PUBLISHED”. Ele precisa comprovar o estado, a versão do job e a propriedade da tentativa.

O caso difícil: a rede publicou, mas a resposta se perdeu

Agora chegamos a um dos problemas mais interessantes do projeto.

Manypost                         Rede social
   │                                  │
   │  POST /publicar                  │
   ├─────────────────────────────────►│
   │                                  │ cria o post
   │              conexão interrompida│
   │◄─────────────── X ───────────────┤
   │                                  │
   │ resultado local: desconhecido    │ resultado externo: sucesso

Uma transação PostgreSQL não inclui atomicamente um efeito no LinkedIn, no Mastodon ou no YouTube. Também não existe um protocolo de commit comum a essas APIs.

Logo, “exactly once” de ponta a ponta não é uma promessa realista.

O Manypost separa algumas garantias:

  • apenas um worker recebe o direito de executar um item por vez;
  • jobs antigos não podem alterar versões novas;
  • erros seguramente transitórios podem usar retry;
  • resultados indeterminados não são publicados novamente de forma automática;
  • providers que oferecem idempotência recebem uma chave estável por item lógico.

Quando a rede pode ter aceitado a publicação, mas a confirmação local falha, o estado vai para NEEDS_REVIEW. Isso troca disponibilidade automática por segurança contra duplicação.

É uma decisão de produto: uma publicação esperando revisão é recuperável; uma campanha duplicada na conta de um cliente pode não ser.

Decisão 5: fencing e leases contra workers concorrentes

Editar, reagendar, cancelar ou repetir manualmente uma publicação incrementa seu job_version. Todo job carrega a versão conhecida no momento em que foi criado.

publicação atual: job_version = 8
job atrasado:     job_version = 7

7 != 8  →  no-op, sem chamar o provider

Esse número é um fencing token. Mesmo se a remoção do job antigo perder uma corrida, ele não pode ressuscitar conteúdo cancelado ou sobrescrever uma edição recente.

Existe ainda a concorrência entre dois workers válidos. Conferir o estado e atualizá-lo depois não basta, pois ambos podem ler o mesmo valor.

Cada item lógico possui uma tentativa durável identificada por:

(publication_id, job_version, position)

O repository concede atomicamente um lease com um owner_token:

Worker A ──┐
           ├──► claim atômico ──► A recebe owner_token ──► chama o provider
Worker B ──┘                     B recebe conflito      ──► não publica

Somente o proprietário atual pode confirmar o resultado e avançar o cursor da thread. Se o lease expirar e outro worker assumir, uma conclusão tardia do proprietário anterior é rejeitada.

Isso não torna a rede externa transacional. Resolve, porém, uma importante fonte local de duplicações.

Decisão 6: multi-tenancy não pode depender da rota

O Manypost organiza usuários por memberships. Canais, posts, mídia, webhooks, API keys, billing e auditoria pertencem a uma organização.

O orgId enviado pelo browser não é tratado como autoridade. A autenticação resolve a identidade e a membership persistida para construir um Principal:

credencial
    │
    ▼
identidade + membership
    │
    ▼
Principal { actor, userId, orgId, role, scopes }
    │
    ├──► autorização do caso de uso
    └──► filtro/join obrigatório no repository

O caso de uso verifica se a ação é permitida, mas o repository também precisa preservar o escopo. Uma rota correta não compensa uma consulta capaz de retornar dados de outra organização.

Nem toda tabela filha precisa repetir org_id. Entretanto, toda leitura ou mutação deve provar o tenant diretamente ou por um join com um pai já escopado.

Essa regra aparece no código, nos testes e nas revisões de migrations.

Decisão 7: segurança influencia o desenho

Uma integração social armazena credenciais capazes de publicar em nome de terceiros. Isso muda a arquitetura.

Entre as medidas presentes no projeto estão:

  • tokens de acesso, refresh tokens e segredos de webhook cifrados com AES-256-GCM;
  • AAD associado ao canal e à organização;
  • API keys persistidas somente como hash e exibidas uma única vez;
  • webhooks assinados com HMAC;
  • validação de destinos remotos contra SSRF;
  • correlation IDs, métricas e logs estruturados sem credenciais;
  • scopes separados para pessoas, API pública e MCP;
  • testes de isolamento usando organizações diferentes;
  • autenticação humana e autenticação de máquina em middlewares distintos.

Segurança também aparece em decisões menos óbvias. Mídia referenciada precisa pertencer à organização. Um callback OAuth valida state e origem. Um agente MCP não pode reutilizar uma sessão humana para contornar scopes.

Essas preocupações não ficam restritas a um middleware chamado security.

Community e Cloud usam a mesma base

O Manypost mantém aplicação, billing, workspaces e recursos operacionais no mesmo monorepo open source.

                    mesmo código AGPL-3.0
                             │
                  ┌──────────┴──────────┐
                  ▼                     ▼
           Community/self-host      serviço gerenciado
           infraestrutura própria   infraestrutura operada
           sem cobrança interna      planos e billing ativos

A diferença é de configuração e operação, não a existência de um repositório privado com as funcionalidades “de verdade”.

Isso tem um custo: as fronteiras entre política de plano e regra do produto precisam ser claras. Por outro lado, qualquer pessoa pode estudar como a aplicação funciona, executar sua própria instância e verificar o que está ou não implementado.

Por que essa stack?

As escolhas atuais tentam manter uma linha TypeScript curta entre contrato e execução:

TecnologiaPapel
Bunruntime, workspaces, scripts e testes
Hono + Zod OpenAPIAPI, validação e contrato OpenAPI
Next.js + Reactaplicação web
PostgreSQLfonte de verdade multi-tenant
Drizzleschema, migrations e repositories tipados
pg-bossjobs duráveis apoiados em PostgreSQL
Rediscoordenação distribuída e realtime

Não acredito que essa seja uma stack universal.

Temporal poderia modelar workflows mais sofisticados. Kafka seria adequado para outro volume e outra topologia. SQS reduziria responsabilidades operacionais em uma aplicação presa à AWS. Uma outbox dedicada poderia ser preferível em outros cenários.

Para um produto self-hosted em estágio de MVP, PostgreSQL com pg-boss oferece uma relação interessante entre durabilidade e quantidade de serviços. Bun e Hono mantêm a API pequena. Drizzle permite que schema e queries continuem próximos do SQL sem abandonar tipos.

A tecnologia específica importa menos que as invariantes:

  • persistir a intenção antes do efeito;
  • conseguir recuperar trabalho perdido;
  • invalidar workers antigos;
  • classificar falhas externas;
  • não transformar cache na fonte de verdade;
  • preservar tenant e autorização em todas as camadas.

Transparência sobre a origem: Manypost e Postiz

O Manypost é uma reimplementação derivada conceitualmente do Postiz, também licenciado sob AGPL-3.0.

O contrato de providers, a taxonomia de erros, o pipeline de publicação e partes do modelo de dados seguem ideias estudadas no projeto original. A implementação usa outra stack — Bun, Hono e Drizzle no lugar de NestJS e Prisma —, mas trocar a tecnologia não apaga a origem das decisões.

Por isso, o repositório mantém NOTICE, ATTRIBUTION.md, a análise técnica da derivação e a licença AGPL-3.0.

Faço questão de incluir isso na primeira apresentação pública. Open source também significa preservar atribuição e permitir que as pessoas distingam implementação original, influência técnica e trabalho derivado.

O que ainda está em construção

O projeto está em fase de MVP.

Isso significa que há funcionalidades implementadas e testadas, mas também integrações sujeitas a gates externos, fluxos operacionais a amadurecer e casos de borda que precisam de mais uso real.

Não quero apresentar o Manypost como se anos de maturidade de produção já existissem. Quero apresentá-lo como um projeto aberto, com uma base arquitetural deliberada e espaço real para colaboração.

As APIs sociais tornam esse domínio especialmente desafiador:

  • escopos e processos de aprovação mudam;
  • algumas plataformas não oferecem idempotência;
  • limites variam por usuário, aplicação e endpoint;
  • formatos de mídia são incompatíveis;
  • um refresh token pode ser rotacionado durante concorrência;
  • uma publicação externa nunca participa da transação local.

Esses problemas são parte do produto, não detalhes que desaparecem com mais uma biblioteca.

Como explorar o projeto

Se quiser conhecer a implementação, estes são bons pontos de entrada:

  1. README e instruções de instalação;
  2. visão canônica da arquitetura;
  3. fluxos ponta a ponta;
  4. mapa do monorepo;
  5. atribuição e origem técnica.

O conjunto principal de validações inclui:

bun install --frozen-lockfile
bun run check
bun run db:check
bun run build:web
bun run spec:validate

Contribuições podem assumir várias formas: testar o self-hosting, revisar uma decisão, documentar um caso de borda, melhorar a experiência, implementar um provider ou relatar uma falha de integração com contexto suficiente para reproduzi-la.

Por que estou publicando agora

Construir um projeto dessa dimensão sozinho ou com poucos colaboradores tem uma armadilha: sempre existe mais uma melhoria que parece necessária antes de mostrar o trabalho.

Mais uma integração. Mais um teste. Mais uma tela. Mais um caso de falha. Mais uma revisão da documentação.

Em algum momento, porém, o projeto precisa encontrar usuários e desenvolvedores reais. Arquitetura sem uso vira apenas uma hipótese bem organizada.

O Manypost é a minha tentativa de construir uma alternativa open source e self-hosted para publicação em redes sociais sem tratar API, automação e agentes como acessórios.

Se a proposta fizer sentido para você, visite o repositório:

github.com/manypost/manypost-app

Uma estrela ajuda o projeto a ser encontrado. Uma issue bem descrita ajuda ainda mais.

E quero terminar com uma pergunta para quem também constrói produtos open source: qual foi o momento em que você decidiu parar de “preparar o lançamento” e finalmente mostrar o projeto para outras pessoas?

Carregando publicação patrocinada...