Tema
Módulo Chatwoot
Localização: backend/src/modules/integrations/whatsapp/chatwoot/Serviço auxiliar: backend/src/common/services/chatwoot-db.service.ts
O Chatwoot é a plataforma de atendimento onde os agentes respondem às conversas dos clientes. O CRM se integra com o Chatwoot por dois caminhos: via API REST (operações convencionais de mensagens, contatos e conversas) e via banco de dados direto com PostgreSQL (operações privilegiadas sem suporte na API, como controle de sessão e invalidação de tokens).
Arquivos e Responsabilidades
| Arquivo | Classe | Responsabilidade |
|---|---|---|
chatwoot-api.service.ts | ChatwootApiService | HTTP client para a API REST do Chatwoot — todos os métodos de contato, conversa, mensagem, agente e inbox |
chatwoot-config.service.ts | ChatwootConfigService | Gerenciamento da configuração por organização: salva, recupera, valida e resolve a API key com suporte a Azure Key Vault |
chatwoot-kv.service.ts | ChatwootKvService | Client Azure Key Vault para armazenamento seguro das API keys da organização, com cache in-memory e retry |
conversation-reassign.service.ts | ConversationReassignService | Cron a cada 5 minutos: detecta conversas aguardando agente além do timeout e reatribui para a caixa e-commerce |
common/services/chatwoot-db.service.ts | ChatwootDbService | Acesso direto ao banco PostgreSQL do Chatwoot via pool de conexões (sem Prisma) para operações que a API REST não expõe |
Configuração
Cada organização armazena sua própria configuração do Chatwoot na tabela ChatwootConfig:
typescript
{
instanceUrl: string; // ex: "https://chatwoot.empresa.com.br"
apiKey: string; // ponteiro KV ("kv://chatwoot-apikey-{orgId}") ou AES-256-GCM criptografado
accountId: number; // ID da conta no Chatwoot
whatsappMode: 'evolution' | 'waba_official';
waitTimeoutEnabled: boolean;
waitTimeoutMinutes: number; // 1–1440
reassignmentMessage: string | null;
}Endpoints de configuração (permissão: app:integrations):
| Método | Path | Descrição |
|---|---|---|
GET | /whatsapp/chatwoot/config | Retorna configuração atual (sem expor a API key) |
POST | /whatsapp/chatwoot/config | Salva ou atualiza a configuração |
GET | /whatsapp/chatwoot/health | Verifica conectividade com o Chatwoot |
GET | /whatsapp/chatwoot/mode | Retorna o modo WhatsApp atual |
POST | /whatsapp/chatwoot/mode | Define o modo (evolution ou waba_official) |
GET | /whatsapp/chatwoot/wait-timeout | Retorna configuração de timeout de reatribuição |
POST | /whatsapp/chatwoot/wait-timeout | Define timeout e mensagem de reatribuição |
ChatwootConfigService
Classe: ChatwootConfigServiceImplements: OnApplicationBootstrap
Responsável por gerenciar o ciclo de vida da API key de cada organização. Suporta dois modelos de armazenamento: ponteiro para Azure Key Vault (preferido) ou criptografia AES-256-GCM no banco (fallback).
Inicialização (onApplicationBootstrap)
Executado na subida do backend. Percorre todas as ChatwootConfig existentes e:
- Para cada organização, chama
healConfig()para detectar e corrigir API keys em formato inválido. - Identifica quais organizações usam ponteiros KV.
- Aquece o cache KV em paralelo com
warmAll()— elimina cold-start de 2–3s na primeira requisição pós-deploy.
healConfig(orgId, storedValue): void
Corrige automaticamente a API key se estiver em formato inválido:
- Se já for ponteiro KV (
kv://...) → ignora. - Se for plain text (sem dois-pontos duplos) → persiste via
persistApiKey()(grava no KV se disponível, senão criptografa). - Se for criptografado mas com KV disponível → migra para KV e atualiza o ponteiro no banco.
- Se estiver corrompido (descriptografia falha) → loga erro crítico com instrução de ação manual.
saveConfig(organizationId, dto): Promise<ChatwootConfig>
Salva a configuração completa de uma organização. A API key é processada via persistApiKey() antes de ser gravada.
Parâmetros:
organizationId: stringdto: SaveChatwootConfigDto—{ instanceUrl, apiKey, accountId }
getConfig(organizationId): Promise<object | null>
Retorna a configuração pública (sem API key) para exibição no frontend.
getConfigInternal(organizationId): Promise<object | null>
Retorna a configuração completa com a API key em texto claro. Usado internamente pelo ChatwootApiService. Resolve o valor via resolveApiKey().
resolveApiKey(orgId, stored): Promise<string>
Resolve o valor real da API key a partir do valor armazenado:
- Se for ponteiro KV → busca via
ChatwootKvService.get(). Se retornar null → lança erro crítico. - Se for plain text → lança erro (instrução de reiniciar backend).
- Se for criptografado → decripta com
EncryptionService. Se falhar → lança erro (instrução de re-salvar).
persistApiKey(orgId, plainText): Promise<string>
Persiste a API key no formato mais seguro disponível:
- Se KV disponível → escreve no Key Vault, atualiza DB com ponteiro, retorna ponteiro.
- Se KV indisponível → criptografa com AES-256-GCM, retorna o blob criptografado.
getWhatsappMode / setWhatsappMode
Lê e escreve o campo whatsappMode (evolution | waba_official) na ChatwootConfig.
getWaitTimeout / setWaitTimeout
Lê e escreve os campos waitTimeoutEnabled, waitTimeoutMinutes (clamp 1–1440) e reassignmentMessage.
ChatwootKvService
Classe: ChatwootKvService
Client para o Azure Key Vault responsável por armazenar as API keys das organizações fora do banco de dados. Mantém cache in-memory para evitar latência em requisições recorrentes.
Constantes e Convenções
typescript
const KV_PREFIX = 'chatwoot-apikey-';
const POINTER_PREFIX = 'kv://';
// Nome do secret no KV: "chatwoot-apikey-{orgId}"
// Ponteiro no DB: "kv://chatwoot-apikey-{orgId}"Inicialização
Lê AZURE_KEYVAULT_URL do ambiente. Se presente, instancia SecretClient com DefaultAzureCredential. Se ausente ou se a inicialização falhar → client = null (fallback para DB-encrypted).
isAvailable(): boolean
Retorna true se o client KV foi inicializado com sucesso.
isPointer(value): boolean
Retorna true se o valor armazenado no DB começa com kv://.
pointer(orgId): string
Retorna o valor de ponteiro a ser gravado no banco: kv://chatwoot-apikey-{orgId}.
get(orgId): Promise<string | null>
Busca a API key do KV com cache in-memory e retry com backoff:
- Verifica cache in-memory (
Map<string, { value }>). Se presente → retorna imediatamente. - Tenta
secretClient.getSecret(secretName)com delays[0ms, 500ms, 1500ms]. - Se bem-sucedido → popula cache e retorna valor.
- Se todas as tentativas falharem → loga
[CRITICAL]e retornanull.
set(orgId, apiKey): Promise<boolean>
Grava a API key no KV e invalida o cache para forçar re-fetch na próxima leitura.
warmAll(orgIds): Promise<void>
Aquece o cache para uma lista de organizações em paralelo (Promise.allSettled). Chamado no bootstrap do ChatwootConfigService.
ChatwootApiService
Classe: ChatwootApiService
HTTP client completo para a API REST do Chatwoot. Cada método resolve internamente a configuração da organização via ChatwootConfigService.getConfigInternal(). Todos os erros são capturados e logados sem propagar exceção — retornam null, false ou [] conforme o tipo de retorno.
Header de autenticação: api_access_token: {apiKey} (header específico do Chatwoot, não Bearer).
Timeout padrão: CRM_CONFIG.HTTP.TIMEOUT_SHORT_MS para a maioria das operações. TIMEOUT_UPLOAD_MS para uploads com FormData.
Gestão de Contatos
findOrCreateContact(organizationId, phone, name?): Promise<ChatwootContact | null>
Busca contato por telefone e cria se não existir.
Fluxo:
GET /api/v1/accounts/{accountId}/contacts/search?q={phone}- Busca match exato em
phone_number(com ou sem+). - Se encontrado e
namefornecido → chamaupdateContact()em fire-and-forget. - Se não encontrado →
POST /api/v1/accounts/{accountId}/contactscom{ phone_number: "+{phone}", name }. - Retorna
ChatwootContact | null.
updateContact(organizationId, contactId, name): Promise<void>
Atualiza o nome de um contato. Fire-and-forget — erros são logados como warning, nunca propagados.
PATCH /api/v1/accounts/{accountId}/contacts/{contactId} com { name }.
syncContactName(organizationId, phone, name): Promise<boolean>
Busca contato por telefone e atualiza o nome se encontrado. Não cria novo contato. Retorna true se atualizado, false se não encontrado.
Gestão de Conversas
findOrCreateConversation(organizationId, contactId, inboxId, initialMessage?, attributionWindowDays?): Promise<{ conversation, isNew } | null>
Busca ou cria conversa para um contato em uma inbox. Suporta reabrir conversa dentro da janela de atribuição.
Fluxo:
GET /api/v1/accounts/{accountId}/contacts/{contactId}/conversations- Procura conversa
openoupendingna inbox especificada → retorna{ isNew: false }. - Se
attributionWindowDays > 0→ procura a conversaresolvedmais recente na janela de tempo. Se encontrada → reabre viaupdateConversationStatus('open')→ retorna{ isNew: false }. - Se nenhuma encontrada →
POST /api/v1/accounts/{accountId}/conversationscom{ source_id: "crm_campaign_{timestamp}", inbox_id, contact_id, status: "open", message? }→ retorna{ isNew: true }.
findOpenConversation(organizationId, contactId, inboxId): Promise<ChatwootConversation | null>
Busca conversa open ou pending de um contato em uma inbox. Não cria. Retorna null se não existir.
findAnyOpenConversation(organizationId, contactId, excludeInboxId?): Promise<ChatwootConversation | null>
Busca qualquer conversa open de um contato em qualquer inbox. Usado para decidir se deve abrir seletor de loja. Pode excluir uma inbox específica.
findConversationWithLabel(organizationId, contactId, inboxId, label): Promise<ChatwootConversation | null>
Busca qualquer conversa (qualquer status) de um contato em uma inbox que possua o label especificado. Usado para detectar cliente_em_atendimento mesmo em conversas resolvidas.
updateConversationStatus(organizationId, conversationId, status): Promise<boolean>
Muda o status de uma conversa. Usa withRetry com as configurações de CRM_CONFIG.CHATWOOT.
POST /api/v1/accounts/{accountId}/conversations/{conversationId}/toggle_status com { status }.
Status válidos: 'open' | 'pending' | 'resolved'.
createConversation(organizationId, inboxId, contactId): Promise<number | null>
Cria uma nova conversa vazia para um contato em uma inbox. Usa withRetry. Retorna o ID da conversa criada.
POST /api/v1/accounts/{accountId}/conversations com { inbox_id, contact_id }.
getConversationsByLabel(organizationId, label): Promise<Array<...>>
Retorna todas as conversas abertas com um label específico, paginando automaticamente (máximo 20 páginas = 500 conversas).
GET /api/v1/accounts/{accountId}/conversations?status=open&labels[]={label}&page={n}
Retorna array com: { id, createdAt, lastActivityAt, inboxId, contactId, customerName, customerPhone }.
markConversationUnread(organizationId, conversationId): Promise<boolean>
Marca todas as mensagens de uma conversa como não lidas (dispara badge de não lido para o agente atribuído).
POST /api/v1/accounts/{accountId}/conversations/{conversationId}/unread
getInboxOpenConversationIds(organizationId, inboxId): Promise<number[]>
Retorna IDs de todas as conversas abertas de uma inbox, percorrendo todas as páginas (25 por página).
GET /api/v1/accounts/{accountId}/conversations?inbox_id={inboxId}&status=open&page={n}
getConversationContact(organizationId, conversationId): Promise<{ id, phone, name } | null>
Retorna dados do contato de uma conversa via meta.sender do payload.
GET /api/v1/accounts/{accountId}/conversations/{conversationId}
getConversationPhone(organizationId, conversationId): Promise<string | null>
Atalho para getConversationContact().phone.
Mensagens
sendOutboundMessage(organizationId, conversationId, content, messageType?, agentId?, contentAttributes?): Promise<number | null>
Envia mensagem visível ao cliente. Usa withRetry. Retorna o ID da mensagem criada.
POST /api/v1/accounts/{accountId}/conversations/{conversationId}/messages
typescript
// Payload enviado
{
content: string,
message_type: 'outgoing' | 'activity', // padrão: 'outgoing'
private: false,
sender_id?: number, // agentId se fornecido
content_attributes?: object // ex: { automated: true }
}sendIncomingMessage(organizationId, conversationId, content): Promise<number | null>
Envia mensagem como se fosse o cliente (tipo incoming). Aparece no Chatwoot como mensagem recebida do cliente. Usado em canais API para espelhar mensagens WhatsApp.
sendIncomingMessageWithAttachment(organizationId, conversationId, content, buffer, mimeType, fileName): Promise<number | null>
Envia mensagem com anexo via multipart/form-data. Usa timeout extendido (TIMEOUT_UPLOAD_MS) e maxBodyLength: Infinity para suportar arquivos grandes.
sendPrivateNote(organizationId, conversationId, content, important?): Promise<number | null>
Envia nota privada visível apenas para agentes (private: true).
typescript
// Payload enviado
{ content, message_type: 'outgoing', private: true }Se important = true → chama toggleMessageImportant() após criação para marcar a mensagem como importante no Chatwoot.
toggleMessageImportant(organizationId, conversationId, messageId): Promise<void>
Marca/desmarca uma mensagem como importante.
POST /api/v1/accounts/{accountId}/conversations/{conversationId}/messages/{messageId}/toggle_important
getConversationMessages(organizationId, conversationId, limit?): Promise<Array<...>>
Retorna as últimas N mensagens de uma conversa (padrão: 4). Filtra mensagens de atividade (message_type = 2) e vazias.
GET /api/v1/accounts/{accountId}/conversations/{conversationId}/messages
Retorna: { senderName, content, type: 'in' | 'out' }[].
Labels de Conversas
setConversationLabels(organizationId, conversationId, labels): Promise<boolean>
Substitui todos os labels de uma conversa pela lista fornecida.
POST /api/v1/accounts/{accountId}/conversations/{conversationId}/labels com { labels }.
addConversationLabel(organizationId, conversationId, label): Promise<boolean>
Adiciona um label sem remover os existentes. Primeiro faz GET para buscar os labels atuais, depois POST com a lista atualizada. Idempotente: não duplica se o label já existir.
swapConversationLabel(organizationId, conversationId, remove, add): Promise<boolean>
Troca um label por outro preservando os demais. Combina GET para buscar lista atual + POST com lista modificada.
Agentes
findOrCreateAgent(organizationId, email, name): Promise<number | null>
Busca agente por email no Chatwoot e cria se não existir.
GET /api/v1/accounts/{accountId}/agents— lista completa de agentes.- Filtra por
email(case-insensitive). - Se não encontrado →
POST /api/v1/accounts/{accountId}/agentscom{ name, email, role: 'agent' }.
findAgentByEmail(organizationId, email): Promise<number | null>
Busca agente por email sem criar. Retorna apenas o ID.
assignConversation(organizationId, conversationId, agentId): Promise<boolean>
Atribui uma conversa a um agente. Usa withRetry.
POST /api/v1/accounts/{accountId}/conversations/{conversationId}/assignments com { assignee_id: agentId }.
listInboxAgents(organizationId, inboxId): Promise<Array<{ id, name, email }>>
Lista agentes atribuídos a uma inbox.
GET /api/v1/accounts/{accountId}/inbox_members/{inboxId}
addAgentToInbox(organizationId, inboxId, agentIds): Promise<boolean>
Adiciona um ou mais agentes a uma inbox.
POST /api/v1/accounts/{accountId}/inbox_members com { inbox_id, user_ids }.
removeAgentFromInbox(organizationId, inboxId, agentIds): Promise<boolean>
Remove agentes de uma inbox.
DELETE /api/v1/accounts/{accountId}/inbox_members com body { inbox_id, user_ids }.
setAgentAvailability(organizationId, agentId, availability): Promise<boolean>
Define o status de disponibilidade de um agente.
PUT /api/v1/accounts/{accountId}/agents/{agentId} com { availability }.
Status válidos: 'online' | 'offline' | 'busy'.
deleteAgent(organizationId, agentId): Promise<boolean>
Remove um agente da conta do Chatwoot permanentemente.
DELETE /api/v1/accounts/{accountId}/agents/{agentId}
hasOpenConversations(organizationId, contactId): Promise<boolean>
Retorna true se o contato tiver pelo menos uma conversa open ou pending.
Gestão de Inboxes
createApiChannelInbox(organizationId, name): Promise<{ id, webhookUrl } | null>
Cria uma inbox do tipo api (canal customizado) no Chatwoot.
POST /api/v1/accounts/{accountId}/inboxes com { name, channel: { type: 'api', webhook_url: '' } }.
deleteInbox(organizationId, inboxId): Promise<boolean>
Remove uma inbox.
DELETE /api/v1/accounts/{accountId}/inboxes/{inboxId}
listInboxes(organizationId): Promise<Array<{ id, name, channel_type }>>
Lista todas as inboxes da conta.
GET /api/v1/accounts/{accountId}/inboxes
findInboxByName(organizationId, name): Promise<{ id, name } | null>
Busca inbox por nome exato. Atalho para listInboxes() com filtro em memória.
updateInboxWebhookUrl(organizationId, inboxId, webhookUrl): Promise<boolean>
Atualiza a URL de webhook de uma inbox do tipo API Channel. Usado ao configurar o modo WABA oficial.
PATCH /api/v1/accounts/{accountId}/inboxes/{inboxId} com { channel: { webhook_url } }.
ChatwootDbService
Classe: ChatwootDbServiceImplements: OnModuleDestroy
Acesso direto ao banco PostgreSQL do Chatwoot via pg.Pool. Conecta ao database chatwoot_primicia derivando a string de conexão de DATABASE_URL. Opera sem Prisma — queries SQL nativas.
Atenção
Este serviço acessa diretamente o schema interno do Chatwoot. Upgrades do Chatwoot podem renomear colunas ou tabelas e quebrar silenciosamente estas queries. Sempre testar após upgrade.
Gestão de Sessão de Agentes
blockAgentLogins(emails): Promise<number>
Bloqueia o login de agentes para as próximas 24h. Escreve login_blocked_until no campo JSONB custom_attributes dos usuários. O initializer Ruby login_blocker.rb do Chatwoot lê este campo e rejeita sign-in enquanto o timestamp estiver no futuro.
sql
UPDATE users
SET custom_attributes = custom_attributes || jsonb_build_object('login_blocked_until', $2::text)
WHERE email = ANY($1)unblockAgentLogins(emails): Promise<number>
Remove a flag de bloqueio. Idempotente — seguro chamar quando o agente não está bloqueado.
sql
UPDATE users
SET custom_attributes = custom_attributes - 'login_blocked_until'
WHERE email = ANY($1)unblockInboxAgents(inboxId): Promise<number>
Desbloqueia todos os agentes de uma inbox pelo ID da inbox (sem precisar de emails).
sql
UPDATE users u
SET custom_attributes = custom_attributes - 'login_blocked_until'
FROM inbox_members im
WHERE im.user_id = u.id AND im.inbox_id = $1
AND u.custom_attributes ? 'login_blocked_until'rotateAgentsTokens(emails): Promise<number>
Invalida todas as sessões ativas dos agentes zerando o campo tokens (DeviseTokenAuth). Agentes com aba aberta perdem a sessão imediatamente via ActionCable/Redis pubsub (se combinado com getAgentPubsubTokens).
sql
UPDATE users SET tokens = NULL WHERE email = ANY($1)getAgentPubsubTokens(emails): Promise<string[]>
Retorna os pubsub_token de agentes pelo email. Usado para fazer broadcast de user:logout via Redis ActionCable e expulsar abas abertas.
sql
SELECT pubsub_token FROM users WHERE email = ANY($1) AND pubsub_token IS NOT NULLconfirmAgentWithPassword(email, password): Promise<boolean>
Confirma o cadastro de um agente no Chatwoot e define sua senha. Gera hash bcrypt (custo 11) e atualiza encrypted_password, confirmed_at, confirmation_token e confirmation_sent_at.
Gestão de Conta e Inbox
removeFromAccount(chatwootUserId, accountId): Promise<boolean>
Remove o vínculo de um usuário com uma conta Chatwoot (account_users). Preserva histórico de mensagens (referenciadas por users.id, não account_users.id).
sql
DELETE FROM account_users WHERE user_id = $1 AND account_id = $2addAgentToInbox(userId, inboxId): Promise<boolean>
Adiciona agente a uma inbox. ON CONFLICT DO NOTHING — idempotente.
removeAgentFromAllInboxes(chatwootUserId): Promise<number>
Remove agente de todas as inboxes de uma vez. Usado ao desativar vendedor no CRM.
getInboxAgentIds(inboxId): Promise<number[]>
Retorna user_id de todos os agentes de uma inbox.
sql
SELECT user_id FROM inbox_members WHERE inbox_id = $1Busca de Dados
findChatwootUserId(email, name): Promise<number | null>
Busca o ID de usuário do Chatwoot por email (preferencial) ou nome (fallback, ILIKE com suporte ao padrão NOME - LOJA).
findAgentInInbox(sellerName, inboxId): Promise<number | null>
Busca agente em uma inbox por nome do vendedor. Aceita nome exato ou no padrão NOME - SUFIXO_LOJA (convenção de nomenclatura do Chatwoot).
sql
SELECT u.id FROM users u
JOIN inbox_members im ON im.user_id = u.id
WHERE im.inbox_id = $1
AND (LOWER(u.name) = $2 OR LOWER(u.name) LIKE $3)
LIMIT 1getContactPhone(contactId): Promise<string | null>
Busca phone_number de um contato Chatwoot pelo ID.
getOpenConversationsForAgent(agentId, inboxIds): Promise<Array<...>>
Retorna conversas abertas (status = 0) atribuídas a um agente em um conjunto de inboxes.
sql
SELECT id, contact_id, inbox_id, account_id
FROM conversations
WHERE assignee_id = $1 AND status = 0 AND inbox_id = ANY($2)Auditoria de Ações
getLastReopenActor(conversationId): Promise<'agent' | 'administrator' | null>
Determina o papel do usuário que reabriu uma conversa pela última vez. Extrai o nome do ator a partir do texto da mensagem de atividade (message_type = 2) e faz JOIN com account_users para obter o role.
Retorna null quando a ação foi feita pelo sistema/API (cron, campanha) — nesses casos a reabertura é permitida.
Usado para aplicar a regra: apenas administradores podem reabrir conversas resolvidas.
getLastResolveActor(conversationId): Promise<'agent' | 'administrator' | null>
Mesmo padrão de getLastReopenActor, mas para resoluções (content LIKE '%resolvida por%').
Operações de Banco Especializado
assignAndNoteConversation(convId, accountId, assigneeId, note): Promise<void>
Atribui conversa e insere nota privada em uma única operação. Usado pelo sistema de reatribuição de conversas. Faz dois UPDATEs SQL sequenciais.
setMessageSourceId(messageId, sourceId): Promise<void>
Grava o source_id (= wamId do Meta) em uma mensagem do Chatwoot. Permite correlacionar notificações de status da Meta Graph API com mensagens no Chatwoot.
sql
UPDATE messages SET source_id = $1 WHERE id = $2updateMessageStatusBySourceId(sourceId, status): Promise<boolean>
Atualiza o status de entrega de uma mensagem identificada pelo source_id. Prefere o caminho via API REST (que aciona ActionCable/broadcast em tempo real para atualizar os checkmarks na tela); faz fallback para SQL direto se a API falhar.
Mapeamento de status:
sent→ 0delivered→ 1read→ 2failed→ 3
Fluxo:
SELECT m.id, c.display_id FROM messages m JOIN conversations c WHERE m.source_id = $1- Tenta
PATCH {chatwootUrl}/api/v1/accounts/1/conversations/{displayId}/messages/{messageId}com timeout 5s. - Se API retornar não-OK ou falhar →
UPDATE messages SET status = $1 WHERE id = $2.
getWaitingLabelTimestamps(convIds): Promise<Map<number, number>>
Retorna o timestamp Unix (segundos) de quando o label cliente_aguardando foi adicionado pela última vez a cada conversa. Usado pelo ConversationReassignService para determinar desde quando o cliente aguarda. Conversas sem registro de atividade são omitidas (caller usa lastActivityAt como fallback).
sql
SELECT conversation_id, EXTRACT(EPOCH FROM MAX(created_at))::bigint AS ts
FROM messages
WHERE conversation_id = ANY($1)
AND message_type = 2
AND content LIKE '%adicionou cliente_aguardando%'
GROUP BY conversation_idgetExpiringConversations(inboxIds, minutesBefore): Promise<Array<...>>
Retorna conversas abertas com assignee cuja última mensagem inbound (message_type = 0) foi recebida há pelo menos (24*60 - minutesBefore) minutos. Usado para alertar sobre a janela de 24h do Meta expirando.
ConversationReassignService
Classe: ConversationReassignService
Cron que roda a cada 5 minutos (@Cron(CronExpression.EVERY_5_MINUTES)). Detecta conversas com o label cliente_aguardando que ultrapassaram o timeout configurado e as reatribui automaticamente para a inbox da loja e-commerce.
Guard anti-sobreposição: flag isRunning previne que duas execuções simultâneas do cron se sobreponham.
Labels gerenciados:
cliente_aguardando— label que indica cliente aguardando atendimentoatendimento_redirecionado— label aplicado à conversa nova e à original após reatribuição
checkWaitingConversations(): Promise<void>
Ponto de entrada do cron. Verifica isRunning, delega para run().
run(): Promise<void>
Busca todas as organizações com whatsappMode = 'waba_official' e waitTimeoutEnabled = true. Para cada uma, chama processOrg() com tratamento de erro isolado.
processOrg(org): Promise<void>
Processa uma organização:
- Busca loja e-commerce (
isEcommerce = true) da organização. - Busca
chatwootInboxIddo número WhatsApp da loja e-commerce. - Busca agentes da inbox e-commerce + conversas com label
cliente_aguardandoem paralelo. - Busca timestamps do label via
ChatwootDbService.getWaitingLabelTimestamps(). - Para cada conversa que ultrapassou o
cutoffde tempo:- Ignora se já está na inbox e-commerce.
- Ignora se a loja e-commerce está fechada (via
BusinessHoursService.isOpen()). - Chama
reassign().
Se não houver agentes disponíveis → aplica label sem_agente_disponivel nas conversas aguardando além do timeout.
reassign(organizationId, oldConversationId, contactId, inboxId, agents, ...): Promise<void>
Reatribui uma conversa para a inbox e-commerce:
- Seleciona agente aleatoriamente da lista disponível.
- Busca o primeiro nome do agente e do cliente (para personalização da mensagem).
- Aplica template de mensagem com variáveis
,,,. - Conversa original:
- Aplica labels
[atendimento_redirecionado, cliente_aguardando]. - Muda status para
resolved.
- Aplica labels
- Nova conversa:
- Cria conversa vazia na inbox e-commerce para o contato.
- Envia nota privada com contexto do cliente (
CustomerContextService) + últimas 4 mensagens da conversa anterior. - Atribui o agente selecionado.
- Envia mensagem outbound (tipo
outgoing,content_attributes: { automated: true }). - Aplica labels
[atendimento_redirecionado, cliente_aguardando].
- Persiste evento
ConversationRedirectno banco para monitoramento.
Template de mensagem padrão:
Olá! Seu atendimento foi transferido para nossa equipe. Em breve entraremos em contato.KV Store (Armazenamento de API Keys)
O armazenamento da API key segue uma hierarquia:
Azure Key Vault (preferido)
↓ fallback se KV indisponível
AES-256-GCM criptografado no banco (EncryptionService)
↓ detectado e curado automaticamente
Plain text (estado temporário — auto-heal no bootstrap)Fluxo de resolução em runtime:
ChatwootApiService.getConfig(orgId)
→ ChatwootConfigService.getConfigInternal(orgId)
→ prisma.chatwootConfig.findUnique()
→ resolveApiKey(orgId, stored)
→ se "kv://..." → ChatwootKvService.get(orgId) → SecretClient.getSecret()
→ se criptografado → EncryptionService.decrypt()
→ se plain text → throw (instrução de ação manual)Tabelas do Banco
| Tabela | Uso |
|---|---|
ChatwootConfig | Configuração por organização (instanceUrl, apiKey pointer/encrypted, accountId, whatsappMode, waitTimeout) |
StoreWhatsappNumber | Vínculo entre loja e chatwootInboxId |
ConversationRedirect | Log de reatribuições automáticas (para monitoramento) |
Tabelas do banco do Chatwoot (acesso direto via ChatwootDbService):
| Tabela | Operações realizadas |
|---|---|
users | blockAgentLogins, unblockAgentLogins, rotateAgentsTokens, confirmAgentWithPassword, getAgentPubsubTokens, findChatwootUserId |
account_users | removeFromAccount, getLastReopenActor, getLastResolveActor |
inbox_members | addAgentToInbox, removeAgentFromAllInboxes, getInboxAgentIds, findAgentInInbox, unblockInboxAgents |
conversations | getOpenConversationsForAgent, updateMessageStatusBySourceId (lookup), getExpiringConversations |
messages | setMessageSourceId, updateMessageStatusBySourceId, getWaitingLabelTimestamps, assignAndNoteConversation, getLastReopenActor, getLastResolveActor |
contacts | getContactPhone |
Integração com Outros Módulos
| Módulo | Tipo de Integração |
|---|---|
EvolutionApiService | ConversationReassignService usa para encaminhar mensagens de saudação no modo Evolution |
MetaGraphService | Mensagens outbound via WABA oficial |
CustomerContextService | Constrói nota de contexto do cliente na reatribuição |
BusinessHoursService | Verifica se loja e-commerce está aberta antes de reatribuir |
WhatsappRouterService | Cria conversa Chatwoot assíncrona no fluxo de redirect WABA |
EncryptionService | AES-256-GCM para API keys sem KV disponível |
PrismaService | Banco principal do CRM |