Conversas
Threads cross-channel no Hub: como conversas são formadas a partir de adapters de canal, identidade do contato, inbox operacional e auditoria.
Conversas
Conversas são a unidade central de relacionamento no Hub. Cada thread agrupa mensagens trocadas entre um contato e um agente, independentemente do canal de origem (WhatsApp, email, web). O módulo de conversas é provider-agnostic: adapters de canal traduzem payloads externos em uma CanonicalMessage, e um único endpoint de ingestão persiste a mensagem, resolve a identidade do contato e devolve um conversationId estável.
Esta página descreve a anatomia da conversa, o contrato dos adapters, o inbox operacional, a identidade do contato e as garantias de auditoria.
Anatomia de uma conversa
Uma conversa no Hub é uma linha em Conversation com a chave única (organizationId, agentId, crmLeadId). Isso garante exatamente uma thread por triplo organização + agente + lead, independente de quantos canais o contato use.
Cada mensagem persistida em Message carrega:
providereexternalIdpara idempotência via@@unique([provider, externalId]).direction(INBOUNDouOUTBOUND) eauthorType(AGENT,OPERATOR,SYSTEM).contentType(texto, mídia, etc.) erawProviderPayloadpara auditoria.conversationIdopcional, permitindo late-binding de mensagens órfãs antes da resolução do lead.sentAtquando o payload provê timestamp; nunca usar o relógio local como fallback.
A ordenação dentro da thread é determinística: sentAt ASC, provider ASC, id ASC. Esse critério é usado tanto pelo context-builder.ts (snippet de contexto para o LLM) quanto pelo endpoint GET /api/conversations/[id]/messages que alimenta o inbox.
O write path canônico é único. Adapters NUNCA escrevem direto em Message ou Conversation. Eles produzem CanonicalMessage e fazem POST para /api/internal/conversations/ingest. Isso preserva a idempotência, a resolução de identidade e a auditoria.
Canais (WhatsApp, email, web)
O Hub trata canais via o pattern de adapter declarado em src/lib/channels/adapters/. Cada adapter exporta uma função pura:
function <provider>ToCanonical(
payload: unknown,
channelId: string,
): CanonicalMessage | nullContrato obrigatório:
Sem I/O. O adapter não pode chamar banco, HTTP nem usar o relógio. Se o payload não tem timestamp, sentAt fica null. Testes rodam só com fixtures empíricas.
Identidade vem da foundation. Para WhatsApp, a classificação de sufixo JID/LID/PHONE precisa passar por extractContactHint(key) e identifierTriplet({ jid, lid }) de src/lib/crm/followize-contact-mapper.ts. Reimplementar isso causa drift e quebra a deduplicação de ContactIdentifier.
Idempotência. externalId deve ser o id nativo do provider (no Evolution: key.id). A ingestão deduplica em (provider, externalId). Sintetizar ids quebra dedup.
Retornar null para tipos não suportados. Status updates, reações soltas e payloads malformados retornam null. O ingester ignora silenciosamente. Nunca lançar exceção.
Self-message e grupo. fromMe=true mapeia para direction='OUTBOUND' e authorType='AGENT'. Grupos retornam canonical com rawProviderPayload.isGroup=true e quem decide skip é o caller. Self-message em grupo: identifiers vazio, rawSenderType='SYSTEM', mas rawSenderId preservado para rastreabilidade.
O adapter de referência é src/lib/channels/adapters/evolution.ts (WhatsApp via Evolution API). Novos canais (Twilio, Telegram, email transacional) seguem o mesmo template, com switch exaustivo messageType para contentType, extração de body e mídia por tipo e default null.
Fixtures de cada provider vivem versionadas em apps/web/__tests__/fixtures/<provider>/*.json e nunca em tmp/, garantindo reprodutibilidade entre worktrees e CI.
A coluna ChannelType do Prisma exige cuidado ao adicionar valores novos: o route de listagem do inbox faz cast para never por causa de mismatch de enum. Após prisma generate, revisar o cast em Prisma.ChannelWhereInput.
Inbox
O Inbox é a UI operacional cross-channel, montada em /inbox com layout de 2 panes (filtros à esquerda, lista virtualizada à direita) e thread view em /inbox/[id].
Listagem
A fonte de dados é a tabela Conversation joinada a CrmLead, lastUsedChannel e a última Message. Como @@unique([organizationId, agentId, crmLeadId]) garante unicidade, cada linha é um triplo distinto.
Endpoint:
GET /api/conversations
?channel=EVOLUTION,WABA
&agent=<agentId>,<agentId2>
&status=unread|all
&search=<termo>
&cursor=<opaque>Resposta:
{
"items": [
{
"id": "conv_...",
"lead": { "id": "...", "name": "...", "identifiers": [] },
"lastMessageAt": "2026-05-29T12:34:56.000Z",
"lastDirection": "INBOUND",
"channels": ["EVOLUTION"]
}
],
"nextCursor": null
}A query é escopada por organização via cookie x-current-org-id. O hook useConversations(filters) (src/hooks/use-conversations.ts) usa useInfiniteQuery e dispara o fetch da próxima página quando o virtualizer chega a 3 linhas do fim. Filtros persistem na URL via nuqs (arrays serializados como CSV).
Thread view
A página /inbox/[id] carrega meta da conversa, mensagens paginadas via cursor e um painel de contexto do lead à direita.
GET /api/conversations/[id]
GET /api/conversations/[id]/messages?limit=50&cursor=<msg-id>
POST /api/conversations/[id]/mark-readComportamento:
- O cursor é resolvido lendo o
createdAtdoidinformado e aplicandocreatedAt > cursor.createdAt. UsarcreatedAt(NOT NULL) ao invés desentAtmantém a desigualdade total. mark-readfazupdateManyem mensagensINBOUNDcomreadAt: null. A contagem de não lidas é derivada (COUNT(*) WHERE direction='INBOUND' AND readAt IS NULL), não denormalizada.- O response inclui
Cache-Control: no-storeeX-Invalidate: conversations,conversation:<id>para invalidação de cache no cliente. - Mismatch de organização retorna 404 (não 403), evitando enumeration por id.
- Mensagens com
conversationId IS NULL(órfãs) não aparecem no thread porque o filtro do endpoint exige o id da conversa.
A flag unreadCount exposta no listing hoje deriva de lastDirection === 'INBOUND' (0 ou 1). Per-message read tracking entra com a próxima onda de inbox.
Identidade do contato
Toda mensagem ingerida passa por findOrLinkLeadByContact (@/lib/crm/contact-resolver). Esse é o único caminho legítimo para resolver lead a partir de identidade — o módulo de ingestão não reimplementa triplet logic.
O resolver implementa 8 etapas:
Monta o triplet de identificadores a partir do CanonicalMessage (PHONE, WHATSAPP_JID, WHATSAPP_LID, EMAIL, etc.). Retorna null se vazio.
Lookup em ContactIdentifier pelo triplet.
Fallback legado em CrmLead.contactPhone IN phoneVariantsBr(raw) para cobrir Followize antigo, importações Pipefy e bulk imports sem rows em ContactIdentifier.
Multi-match aciona merge oldest-wins via mergeLeadsRuntime.
Single-match reutiliza o lead.
No-match cria o lead com externalId sintético via crypto.randomUUID().
Upsert aditivo dos identificadores (idempotente).
Retorna { lead, created, identifiersAdded }.
A conversa é então upserted em @@unique([organizationId, agentId, crmLeadId]). Quando o resolver retorna null (mensagem sem identificadores resolvíveis), a mensagem é persistida com conversationId=null e uma chamada posterior pode fazer o late-binding.
Calls diretos a prisma.crmLead.upsert ou prisma.crmLead.create a partir de adapters, webhooks ou rotas de ingestão são proibidos. Qualquer write-site novo deve passar pelo resolver e ser coberto por testes E2E com pelo menos 3 formatos de telefone. A reimplementação de canonicalização ou parse de JID/LID em call-sites causa lead duplicado e thread fragmentada.
LeadContext
O builder de contexto (format-lead.ts) deriva phone_known: boolean exclusivamente de rows ContactIdentifier do tipo PHONE. O legado lead.contactPhone não conta. Essa convenção mantém o flag alinhado com a flag crm_phone_known consumida pelo loop agentic do consumer.
Transcripts e auditoria
Cada conversa tem dois consumidores principais de transcript:
Snippet para o LLM
buildContextSnippet(prisma, conversationId, opts) em src/lib/conversations/context-builder.ts retorna { summary, summary_at, lead_context, contact, messages, total_messages, truncated }. Orçamento de tokens reserva 1500 para summary + lead block; o restante é alocado ao tail de mensagens via trimTailByTokens, sempre preservando o último turno do usuário.
Thread view operacional
GET /api/conversations/[id]/messages paginado por cursor, com componentes <ThreadView>, <MessageBubble> e <MessageChannelBadge> no inbox. Cor da bubble depende de direction × authorType.
Garantias de auditoria
- Idempotência. Toda mensagem é deduplicada em
(provider, externalId). Reentregas de webhooks são absorvidas sem duplicação. - Raw payload. O payload original é persistido em
rawProviderPayload, permitindo reanálise mesmo se o adapter mudar. - Histórico de identidade. Cada criação, link ou merge de lead gera linha em
CrmLeadHistory. Merges são também marcados emCrmLeadMerge(self-relation commergedIntoId). - Cross-org isolation. Endpoints retornam 404 ao invés de 403 em mismatch de org, evitando que ids sejam usados para descobrir conversas de outra organização.
- Ingestão internal-only.
POST /api/internal/conversations/ingestexigeAuthorization: Bearer ${MCP_INTERNAL_SECRET}. O header legadox-internal-admin: truefoi removido.
Smoke test
Para validar o caminho completo de ingestão e contexto:
MCP_INTERNAL_SECRET=... \
ORG_ID=... \
AGENT_ID=... \
PIPELINE_ID=... \
./apps/web/scripts/smoke-conversations.shO script exercita ingestão, reingestão idempotente e leitura de contexto, retornando 201 no primeiro POST, 200 no segundo e 200 no GET.
A persistência canônica de conversas depende da flag ENABLE_CANONICAL_HISTORY=true nos pods simplafy-hub-langgraph e simplafy-hub-consumer. As duas devem estar alinhadas — desalinhamento gera estado inconsistente entre o agente e o middleware de histórico.
Provedores e modelos
Provedores de LLM suportados pelo Hub (OpenAI, Anthropic, OpenRouter, xAI, ZhipuAI), seleção de modelo no UI, comportamento em caso de falha, custos e configuração de credenciais.
Integrações
Visão geral das integrações nativas do Hub: WhatsApp via Evolution API, email via SMTP, workflows n8n e ferramentas MCP, com instruções para adicionar novas integrações.