# Messaging Service API

Documentação do contrato implantado. O CRM deve consumir esta API no backend;
tokens internos nunca devem ser enviados ao frontend.

## Endereços

- público: `https://messaging.driveparts.virtuaserver.com.br`;
- rede Docker: `http://messaging-api:8080`;
- WAHA e rotas `/internal/*` permanecem privados.

## Autenticação e tenant

Todas as rotas `/v1/*` exigem os headers:

```text
x-internal-token: <credencial do CRM>
x-store-id: <identificador da loja>
```

O `store_id` vem exclusivamente do header autenticado. Toda leitura e mutação
de conta ou mensagem aplica esse tenant. Credencial ausente ou inválida retorna
`401`; tenant ausente ou inválido retorna `400`.

## Health e ajuda

### `GET /help`

Retorna este documento em `text/markdown`. Não exige autenticação.

### `GET /health/live`

Confirma que o processo HTTP está vivo e informa a revisão da imagem.

### `GET /health/ready`

Retorna `200` quando MongoDB e os dois clientes Redis estão acessíveis; caso
contrário retorna `503`.

## Contas e números

### Conectar conta: QR Code primeiro

`POST /v1/connections` reserva uma tentativa temporária no worker saudável com
menor carga e enfileira criação e início da sessão. O backend autenticado informa
o usuário e as contas da mesma loja que ele tem autorização para reconectar:

```json
{
  "actor_id": "identificador-do-usuario",
  "reusable_account_ids": ["00000000-0000-4000-8000-000000000001"]
}
```

Retorna `202` com `connection_id`, `state: pending`, `status` e `expires_at`.
Não cria uma integração no DriveParts nem pede nome nessa etapa.

- `GET /v1/connections/{connection_id}`: consulta estado. A confirmação do
  número vem do provider, nunca de um telefone enviado pelo navegador.
- `GET /v1/connections/{connection_id}/qr`: QR da tentativa pendente.
- Mesmo número já cadastrado: `state: completed`, `reused_account: true`,
  `account_id` original. Preserva nome, histórico e responsável; aposenta a sessão
  física anterior sem mover sessões entre workers.
- Número novo: `state: needs_name`, `phone_number` verificado. Somente agora
  chamar `POST /v1/connections/{connection_id}/complete` com `display_name`
  (1–120 caracteres). A conclusão é idempotente e retorna `state: completed`.
- `DELETE /v1/connections/{connection_id}`: cancela somente uma tentativa ainda
  pendente. Nunca apaga uma conta identificada nem suas mensagens.

Tentativas não identificadas expiram em 10 minutos. O health worker agenda o
encerramento e libera capacidade uma única vez. Uma conta identificada aguardando
nome conserva suas mensagens; nova conexão pelo mesmo usuário recupera essa conta.
Duplicidades históricas retornam `duplicate_accounts_require_consolidation`;
execute a consolidação auditada descrita em `QR-FIRST-DEPLOYMENT.md`.

Erros de conexão incluem `error` (código estável), `message` em português,
`action` e `retryable` quando há orientação disponível. O cliente deve exibir
`message` e seguir `action`; não repetir automaticamente a criação da conexão.
Uma sessão com falha confirmada retorna HTTP 409, em vez de `pending` com
`status: failed`. A rota de QR consulta o estado atual antes de retornar o QR.

```json
{
  "error": "connection_pairing_failed",
  "message": "Não foi possível concluir o pareamento com o WhatsApp. Inicie uma nova conexão e escaneie o novo QR Code em Aparelhos conectados no celular.",
  "action": "start_new_connection",
  "retryable": false
}
```

- `connection_expired` (409): prazo da tentativa expirou; iniciar nova conexão.
- `connection_cancelled` (409): tentativa cancelada; iniciar nova conexão.
- `connection_pairing_failed` (409): falha confirmada pelo provedor; iniciar nova
  conexão. Não implica expiração do QR, pois o provedor pode omitir a causa.
- `connection_not_waiting_for_qr` (409): consultar o estado da conexão.
- `connection_qr_not_ready` e falhas transitórias do provedor (503): aguardar e
  repetir a consulta na mesma tentativa, com backoff; não criar outra sessão.

`retryable: false` significa que repetir a mesma operação não é a solução;
`start_new_connection` exige uma nova tentativa iniciada pelo usuário.

### `POST /v1/accounts`

Desativado: retorna `409 connect_with_qr_before_creating_account`. Use o fluxo acima.

### `GET /v1/accounts/{account_id}`

Retorna a conta pertencente ao tenant autenticado.

### `POST /v1/accounts/{account_id}/connect`

Desativado: retorna `409 connect_with_qr_before_creating_account`. Reconexões
também devem confirmar o número no fluxo `/v1/connections`.

### `POST /v1/accounts/{account_id}/disconnect`

Para a sessão, preservando credenciais e histórico para reconexão. Retorna
`202`; não remove permanentemente o número.

### `GET /v1/accounts/{account_id}/status`

Consulta o provider e sincroniza o estado. Estados usuais: `provisioning`,
`connecting`, `qr_required`, `connected`, `disconnecting`, `disconnected`,
`degraded` e `failed`.

### `GET /v1/accounts/{account_id}/qr`

Retorna o QR Code da sessão em `{ "value": "..." }` quando o provider o
disponibiliza.

### `DELETE /v1/accounts/{account_id}` — ainda não disponível

A remoção permanente será implementada como desprovisionamento assíncrono,
preservando histórico/auditoria, removendo a sessão no provider e liberando a
capacidade do worker. Até lá, use `disconnect`.

## Mensagens

### `GET /v1/conversations`

Lista conversas da conta em ordem decrescente da última mensagem. Exige
`account_id` UUID na query; `limit` aceita de 1 a 100 e usa 50 por padrão.
`cursor`, quando presente, é o valor opaco retornado na página anterior.

```json
{
  "conversations": [
    {
      "id": "conversation-projection-uuid",
      "store_id": "store-id",
      "account_id": "account-uuid",
      "conversation_id": "5511999999999@c.us",
      "status": "pending",
      "unread_count": 1,
      "contact": {
        "id": "5511999999999@c.us",
        "phone_number": "+5511999999999",
        "provider_contact_id": "5511999999999@c.us",
        "provider_chat_id": "5511999999999@c.us",
        "provider_lid": null,
        "provider_phone_id": "5511999999999@c.us",
        "provider_chat_name": "Nome do chat",
        "provider_contact_name": "Nome salvo",
        "provider_contact_push_name": "Nome público",
        "provider_contact_short_name": "Nome curto",
        "provider_contact_is_me": false,
        "provider_contact_is_group": false,
        "provider_contact_is_wa_contact": true,
        "provider_contact_is_my_contact": true,
        "provider_contact_is_blocked": false,
        "provider_chat_picture_url": "https://example.invalid/chat.jpg",
        "provider_profile_picture_url": "https://example.invalid/profile.jpg",
        "fetched_at": "2026-08-31T12:00:00.000Z"
      },
      "last_message": {
        "id": "message-uuid",
        "direction": "inbound",
        "kind": "text",
        "status": "delivered",
        "preview": "Olá",
        "has_media": false,
        "created_at": "2026-08-31T12:00:00.000Z"
      },
      "created_at": "2026-08-31T12:00:00.000Z",
      "updated_at": "2026-08-31T12:00:00.000Z"
    }
  ],
  "limit": 50,
  "has_more": false,
  "next_cursor": null
}
```

Uma conversa criada pela primeira mensagem recebida começa em `pending`. Uma
conversa iniciada por envio começa em `open`. A projeção é atualizada na mesma
transação MongoDB da mensagem e da outbox; a rota não agrupa nem percorre toda a
coleção de mensagens.

IDs, telefone E.164, nomes, indicadores do contato e fotos são mantidos em
campos separados, sem escolher um nome de exibição definitivo. Para conversas
identificadas por `@lid`, o enriquecimento usa a associação LID/PN do provider
quando ela estiver disponível. A consulta ocorre de forma assíncrona quando a
conversa é criada e, no máximo, uma vez a cada 24 horas por conversa. Ausência
por privacidade do contato é representada por `null`; URLs são aceitas somente
com esquema HTTP(S).

### `GET /v1/conversations/{conversation_id}`

Retorna a projeção atual de uma conversa. Exige `account_id` na query e o
`x-store-id` autenticado; a busca usa a identidade completa
`store_id`/`account_id`/`conversation_id`. Retorna o mesmo objeto de conversa da
listagem e responde `404` quando a conversa não pertence ao escopo informado.

### `GET /v1/conversations/{conversation_id}/messages`

Lista o histórico mais recente da conversa. Também exige `account_id` na query
e aceita os mesmos `limit` e `cursor`. Retorna:

```json
{
  "messages": [],
  "limit": 50,
  "has_more": false,
  "next_cursor": null
}
```

Cada mensagem contém `id`, `store_id`, `account_id`, `conversation_id`,
`client_message_id`, `provider_message_id`, `reply_to_message_id`,
`reply_context`, `direction`, `kind`, `status`, `payload`, `created_at` e
`updated_at`. A ordem é da mais nova para a mais
antiga. `client_message_id` é `null` para mensagens enviadas diretamente pelo
aplicativo WhatsApp. O `conversation_id` no path deve estar codificado para URL.

`reply_to_message_id` contém o UUID interno da mensagem respondida quando ela
está disponível no mesmo tenant, conta e conversa. `reply_context` é um snapshot
limitado com `direction`, `kind`, `preview` e `has_media`, permitindo renderizar
o contexto mesmo quando a mensagem original está fora da página atual.

Uma mídia usa `kind` igual a `image`, `video`, `audio` ou `document` e
expõe no `payload` os campos canônicos `media_type`, `mime_type`, `file_name`,
`file_size`, `caption`, `media_available` e `download_url`. Imagens disponíveis
também expõem `public_url` e `public_url_expires_at`; a URL assinada vale por
uma hora e pode ser usada diretamente em `<img src>`, sem headers.

`media_available=true` informa que há uma referência de mídia fornecida pelo
provider ou um upload temporário disponível. A disponibilidade atual do cache é
confirmada no download. `media_storage=provider` indica mídia mantida no WAHA;
o Messaging preserva a autorização por loja/conta e os links assinados.

Desde a consolidação de 2026-10-07, as mídias recebidas permanecem somente nos
arquivos do WAHA. Webhooks gravam referências e metadados, sem baixar e duplicar
os arquivos no Messaging. Os chunks históricos do PostgreSQL foram removidos a
pedido do usuário. Mensagens e identificadores do provider são preservados.

Uploads de saída ficam temporariamente em arquivo no Messaging enquanto aguardam
envio ou reconciliação. **Assim que o envio é confirmado**, o arquivo fica
elegível para remoção, sem a antiga espera de 10 minutos. O download passa a
consultar o WAHA e o health worker remove o arquivo temporário em lotes limitados,
verificados a cada 10 segundos. Uploads sem confirmação ainda
não têm prazo de exclusão. Mantém tamanho,
SHA-256 e metadados originais para validar reenvios do mesmo `client_message_id`;
isso não provoca um novo envio externo.

Mídias recebidas são consultadas diretamente no WAHA. Mídias de saída podem usar
o arquivo temporário enquanto aguardam confirmação do envio. Depois disso,
downloads consultam o cache de arquivos do WAHA e, se necessário, solicitam novo
download pelo WhatsApp. A recuperação não é garantida. O cache do WAHA possui
retenção de 7 dias; não existe mais uma cópia separada de 14 dias no Messaging.
Essa consulta não recria chunks no banco
nem envia mensagem ao destinatário. A API mantém quatro downloads ativos e
compartilha solicitações simultâneas da mesma mídia. Pedidos excedentes aguardam
por até 10 segundos numa fila limitada a 32 pedidos, permitindo consultar vários
arquivos em cache sem rejeitar imediatamente o quinto. Fila cheia ou espera
expirada retorna `503 media_recovery_busy`. Falhas transitórias retornam
`503`; ausência definitiva retorna `404`. `media_recoverable=true` e URLs de download
podem existir mesmo com `media_available=false`, sem garantir sucesso da recuperação.

### `POST /v1/accounts/{account_id}/messages`

Enfileira texto com idempotência por `client_message_id` dentro do tenant e da
conta.

```json
{
  "conversation_id": "5511999999999@c.us",
  "client_message_id": "pedido-123-mensagem-1",
  "reply_to_message_id": "00000000-0000-4000-8000-000000000099",
  "text": "Olá!"
}
```

`reply_to_message_id` é opcional. Quando informado, precisa identificar uma
mensagem da mesma conta e conversa e que já possua identidade confirmada no
provider. O serviço resolve internamente o ID remoto e envia uma resposta
nativa; IDs do provider nunca são aceitos do cliente como alvo da resposta.

Retorna `202` quando cria a mensagem e `200` quando a mesma chave idempotente já
existe.

### `POST /v1/conversations/{conversation_id}/messages`

Enfileira texto em uma conversa existente. O serviço valida a conversa pelo
tenant autenticado, pela conta informada e pelo identificador no path antes de
criar a mensagem.

```json
{
  "account_id": "00000000-0000-4000-8000-000000000001",
  "client_message_id": "crm-mensagem-123",
  "reply_to_message_id": "00000000-0000-4000-8000-000000000099",
  "text": "Olá!"
}
```

O `conversation_id` no path deve estar codificado para URL. A idempotência e os
códigos `202`/`200` seguem o contrato de envio por conta.

### `POST /v1/conversations/{conversation_id}/read`

Zera de forma idempotente o contador de mensagens não lidas da conversa e, na
mesma transação, enfileira a confirmação de leitura das mensagens inbound no
provider. A identidade é validada pelo tenant autenticado, pela conta e pela
conversa.

```json
{
  "account_id": "00000000-0000-4000-8000-000000000001"
}
```

Retorna `200` com `status: "read"`. A consulta usa o índice da conversa e lê no
máximo 100 IDs inbound, limitada também pelo contador anterior de não lidas.
Chamadas repetidas para o mesmo último inbound não criam outro comando. Uma
conversa já zerada antes desta versão gera um único recibo na primeira
reabertura e passa a usar o mesmo watermark idempotente.
Falhas transitórias e commits ambíguos de `sendSeen` usam o retry durável da
fila; falhas permanentes são encerradas sem loop.

### `POST /v1/accounts/{account_id}/media`

Enfileira imagem, vídeo, documento ou áudio já publicado em uma URL HTTPS
permitida. Esse é o contrato de upload prévio do serviço.

```json
{
  "conversation_id": "5511999999999@c.us",
  "client_message_id": "pedido-123-anexo-1",
  "media_type": "document",
  "media_url": "https://driveparts.com.br/arquivo.pdf",
  "mime_type": "application/pdf",
  "file_name": "arquivo.pdf",
  "file_size": 12345,
  "caption": "Documento"
}
```

`media_type` aceita `image`, `video`, `document`, `audio` ou `sticker`. `file_size` deve ser
o tamanho exato do arquivo em bytes. Downloads aplicam allowlist de host,
proteção SSRF/DNS rebinding, timeout, limite de tamanho e zero redirects.

O histórico `GET /v1/conversations/{conversation_id}/messages` informa
`supports_stickers=true` para o CRM habilitar o envio após esta versão ser implantada.

Figurinhas (`sticker`) exigem WebP de 512 × 512 pixels, até 500 KiB e não
recebem legenda. WebP animado é preservado. O adapter valida os bytes antes do
envio e usa `/api/sendSticker`; requer WAHA com suporte a esse endpoint
(2026.8.2 ou posterior). Fotos WebP com `media_type=image` continuam sendo fotos.
Veja o [contrato WAHA](https://waha.devlike.pro/docs/how-to/send-messages/).

### `POST /v1/accounts/{account_id}/media/upload`

Enfileira arquivo enviado diretamente, sem publicar uma URL. Exige
`x-internal-token` e `x-store-id`; o corpo não aceita `store_id` nem `media_url`.

```json
{
  "conversation_id": "5511999999999@c.us",
  "client_message_id": "relatorio-2026-10-04-csv",
  "media_type": "document",
  "mime_type": "text/csv",
  "file_name": "relatorio.csv",
  "file_size": 8,
  "data_base64": "c2t1CjEyMwo=",
  "caption": "Relatório completo"
}
```

Limite: o menor valor entre `MEDIA_MAX_BYTES` e **8 MiB** de bytes decodificados.
Somente essa rota permite o tamanho JSON necessário para base64; o limite global
permanece inalterado. Base64 deve ser canônico, sem prefixo `data:` nem espaços,
e o tamanho decodificado deve corresponder exatamente a `file_size`. MIME é
normalizado para minúsculas. Excesso de tamanho retorna `413`, codificação ou
metadados inválidos retornam `400`.

O arquivo é gravado e sincronizado em disco antes de confirmar, na mesma
transação do banco, mensagem, referência do arquivo, SHA-256 e comando de envio.
O worker envia a cópia durável pelo provider vinculado à conta.
`202` indica criação; repetir `client_message_id` com os mesmos bytes,
metadados, legenda e destinatário retorna a mesma mensagem com `200`. Alterações
nesses dados retornam `409 client_message_id_conflict`, inclusive em corridas
concorrentes. Tenant e conta fazem parte da identidade idempotente. Resultados
externos ambíguos continuam em reconciliação, sem reenvio automático.

### `POST /v1/conversations/{conversation_id}/media`

Enfileira mídia em uma conversa existente com o mesmo contrato de upload prévio.
O corpo troca `conversation_id` por `account_id`; tenant, conta e conversa são
validados antes da criação.

### `GET /v1/accounts/{account_id}/messages/{message_id}/media`

Baixa a cópia durável de uma mídia inbound ou outbound. A rota exige os mesmos
headers internos e valida `store_id`, conta e mensagem antes de consultar o
armazenamento. A credencial e a URL do provider nunca são retornadas ao cliente.
Responde com o MIME, tamanho e `Content-Disposition`; uma mídia ainda não
persistida retorna `404`.

### `GET /public/media?token={signed_token}`

Entrega imagens, áudios, vídeos e documentos persistidos, inbound ou outbound,
por uma URL bearer assinada, sem exigir headers internos. O histórico autenticado
emite `payload.public_url` para todos esses tipos quando os bytes estão disponíveis.
O token é vinculado a tenant, conta, mensagem e expiração de uma hora. Token
ausente, adulterado, expirado ou apontando para mídia indisponível responde 404.
A rota não expõe o locator nem a credencial do provider e responde com
`private, no-store`, `no-referrer` e `nosniff`. Consultar novamente a listagem
autenticada de mensagens emite uma URL renovada.

Aceita um único `Range: bytes=inicio-fim` (inclusive aberto ou sufixo): responde
`206` com `Content-Range`, ou `416` para intervalo inválido/insatisfazível.
`If-Range` sem validador correspondente retorna o arquivo completo (`200`).
Documentos e tipos com conteúdo ativo (ex.: SVG/HTML) são forçados a download,
com `application/octet-stream`; apenas formatos passivos permitidos são inline.
O PHP não precisa transferir os bytes para o navegador. O armazenamento ainda
reconstitui o arquivo no Node; isso não é leitura parcial dos chunks no banco.

O proxy público deve encaminhar apenas `/public/media` ao Messaging, preservar
o path integral e aceitar somente `GET`/`HEAD`. Como o token bearer faz parte da query,
os access logs do proxy são dados sensíveis: restrinja o acesso, use retenção
curta e omita a query do formato de log quando o proxy permitir. Não faça cache
compartilhado dessa rota.

## Rotas exclusivamente internas

Estas rotas não são encaminhadas pelo proxy público.

### `PUT /internal/workers/{worker_id}`

Registra ou atualiza um worker. Exige `x-admin-token`.

### `GET /internal/metrics`

Métricas Prometheus. Exige `x-admin-token`.

### `POST /internal/webhooks/waha/{worker_id}`

Recebe eventos privados do WAHA. Exige assinatura HMAC válida nos headers
`x-webhook-hmac` e `x-webhook-hmac-algorithm`.

## Respostas de erro

- `400`: payload, parâmetro ou tenant inválido;
- `401`: token ou assinatura ausente/inválida;
- `404`: conta/recurso não encontrado dentro do tenant;
- `409`: conflito de estado;
- `502`: rejeição permanente do provider;
- `503`: dependência/capacidade indisponível ou falha transitória;
- `500`: falha interna sem exposição de detalhes sensíveis.
