Skip to content

Modelo de Tickets e Helpdesk

O helpdesk é o subsistema de atendimento humano. Ele entra em cena quando um flow chega num bloco HumanAttendanceBlock e entrega o controle da conversa a um agente real — mantendo o histórico, gerenciando filas e devolvendo a sessão ao engine quando o atendimento termina.


Ticket: unidade de atendimento

Um Ticket representa um período de atendimento (humano ou de IA) dentro de um Chat. Um Chat pode ter vários tickets ao longo do tempo — cada handoff, transferência ou nova fila cria um novo ticket encadeado.

Ticket {
  id
  kind              ← "human" (default) | "ai" — atendimento humano ou sessão de agente de IA
  chatId            ← Chat a que pertence
  sessionId         ← referência à SessionState no Redis (WhatsApp: wa-{phoneNumberId}-{phone};
                       WebChat: wc-{channelId}-{userId}; Instagram: ig-{igUserId}). Nullable:
                       tickets legados (criados antes deste campo) fecham sem retomar o flow.
  previousTicketId  ← ticket anterior na cadeia (self-relation "TicketLineage"; nextTickets é o inverso)

  queueId           ← fila de destino
  status            ← "waiting" | "assigned" | "closed"
  assignedUserId    ← agente atribuído (null se waiting)
  assignedAt

  openedByKind   ← "flow" | "agent" | "system" | "ai_agent"
  openReason     ← "human_handoff" | "agent_transfer" | "queue_transfer" | "ai_session"
  openedByUserId ← quem abriu manualmente (null se flow)

  closedAt
  closedByUserId
  closeKind      ← "resolved" | "agent_transfer" | "queue_transfer" | "handoff_replaced"
                    "system" | "meta_window_expired" | "abandoned"
  closeReason

  summary          ← resumo do atendimento gerado por LLM ao fechar (via stream:summary)
  sentimentResult  ← último resultado de análise de sentimento do cliente (JSON)
  sentimentHistory ← série histórica de sentimento por turno — salva ao encerrar ticket de IA (JSON)
  tags             ← TicketTag[] (relação N:N com Tag)
}

Por que um novo ticket a cada transferência? Porque cada período de atendimento tem responsabilidades distintas — agente diferente, fila diferente, duração própria. Reutilizar o mesmo registro inviabilizaria métricas de performance por atendimento e o histórico de quem fez o quê. O encadeamento via previousTicketId preserva a linha do tempo completa da conversa.


Ciclo de vida de um ticket

1. Abertura por handoff do flow

O engine publica em stream:helpdesk quando entra num HumanAttendanceBlock:

typescript
// HelpdeskHandoffEvent
{
  kind: "handoff",
  sessionId, chatId, phoneNumberId, routerId,
  contact: { id, name, phoneNumber, variables },
  contextVariables,  // snapshot das variáveis do flow no momento do handoff
  timestamp
}

O core consome e executa (handlers/handoff.ts::handleHelpdeskHandoffEvent):

handleHelpdeskHandoffEvent()
├─ cacheChatRouting()           ← guarda em memória: chatId → { sessionId, phoneNumberId, to }
├─ queueId = event.presetQueueId ?? resolveQueue(event)  ← preset (bypassa regras) ou QueueDistributionRules
├─ ticketService.openTicketForHandoff(chatId, queueId, sessionId, event.presetUserId)
│  ├─ Se existir ticket anterior não fechado:
│  │  └─ fecha com closeKind: "handoff_replaced"  ← evita tickets órfãos
│  └─ Cria novo Ticket { openReason: "human_handoff", openedByKind: "flow" }
│     ├─ presetUserId presente → já nasce { status: "assigned", assignedUserId }
│     └─ sem preset            → { status: "waiting" }
├─ setCurrentHelpdeskTicket(sessionId, { ticketId, queueId })  ← grava no Redis
├─ Análise de sentimento onHandoff (fire-and-forget) → emite ticket:sentiment se houver agente
└─ Notificação:
   ├─ status "waiting"  → Socket.IO emite helpdesk:waiting aos agentes da fila (pickup manual)
   └─ status "assigned" → Socket.IO emite ticket:assigned ao agente do presetUserId

Nota: o handoff não roda o dispatcher automaticamente. Sem presetUserId o ticket fica em waiting até um agente reivindicá-lo (agent:pickup) ou até uma atribuição explícita via POST /tickets/:id/assign (Kanban). Ver a seção Dispatcher.

O closeKind: "handoff_replaced" ocorre quando um usuário que está em atendimento humano manda outra mensagem que volta ao flow e dispara um novo handoff. O ticket anterior é fechado pelo sistema sem interação de agente.

2. Pickup manual

O pickup manual é o caminho normal para os tickets deixados em waiting pelo handoff. O agente reivindica o próximo ticket:

Socket evento: agent:pickup
└─ ticketService.pickupNextWaitingForAgent()
   ├─ Busca o waiting mais antigo das filas do agente
   ├─ Atribui: Ticket { status: "assigned", assignedUserId }
   └─ Socket.IO emite ticket:assigned ao agente

3. Troca de mensagens

Enquanto status: "assigned", a conversa flui bidirecionalmente:

Mensagem do usuário:

IncomingStreamEvent → orchestrator
  ├─ session.mode = "human" → publica em stream:helpdesk como HelpdeskMessageEvent
  └─ core consome:
     ├─ getCurrentHelpdeskTicketId(sessionId) ← Redis
     └─ Socket.IO emite ticket:message ao agente assignedUserId

Resposta do agente:

Socket evento: agent:reply
└─ helpdesk/outbound.ts::publishReply()
   ├─ routing = cache em memória por chatId ({ sessionId, phoneNumberId, to })
   │  └─ fallback via DB reconstrói a partir do Ticket.sessionId após restart
   └─ xadd(stream de saída, OutgoingStreamEvent { source: "agent" })
      ← stream escolhida pelo prefixo do sessionId:
        wc-* → stream:outgoing-chat | ig-* → stream:outgoing-ig | demais → stream:outgoing-meta

O agente envia pela mesma stream de saída que o engine usa para aquele canal. A distinção é o campo source: "agent" no evento, que o consumer de status usa para saber a quem emitir confirmações de entrega via Socket.IO.

4. Transferência

POST /tickets/:id/transfer  { toUserId? | toQueueId? }
└─ ticketActions.ts::transferOrHandoff()  ← roteia conforme ticket.kind
   ├─ kind "human" → transferTicketWithEffects() → ticketService.transfer()
   │  ├─ Fecha ticket atual { closeKind: "agent_transfer" | "queue_transfer" }
   │  ├─ Abre novo Ticket encadeado { previousTicketId: id, openedByKind: "agent",
   │  │    openReason: "agent_transfer" | "queue_transfer" }
   │  ├─ Atualiza session.helpdesk no Redis com novo ticketId/queueId
   │  ├─ Se toUserId: Ticket status: "assigned"
   │  │  ├─ Socket.IO emite ticket:assigned ao novo agente
   │  │  └─ Socket.IO emite ticket:transferred ao agente anterior
   │  └─ Se toQueueId: Ticket status: "waiting"
   │     └─ Socket.IO emite helpdesk:waiting a agentes da fila destino
   └─ kind "ai" → publishManualHandoff() (assíncrono, via stream:agent)
      └─ o flow-ai-agent encerra a sessão de IA e abre o ticket humano na fila/agente
         escolhidos — não há ticket humano de retorno síncrono

Nota: o socket agent:transfer chama ticketService.transfer() diretamente e só cobre tickets humanos (sem o desvio de IA). O roteamento por kind (humano vs. IA) vive na rota HTTP e nas rotas em massa (/tickets/bulk/transfer).

5. Encerramento

POST /tickets/:id/close  { reason?, tagIds?, jumpTarget? }
└─ ticketActions.ts::closeTicketWithEffects()
   ├─ Fecha Ticket { closeKind: "resolved", status: "closed" }  (via ticketService.close)
   │  └─ publica pedido de resumo em stream:summary
   ├─ Se ticket.kind === "ai": publishAgentSessionEnded() → xadd(stream:flow, { kind: "agentSessionEnded" })
   └─ Senão:                   publishHumanSessionEnded()  → xadd(stream:flow, { kind: "humanSessionEnded" })
      └─ engine retoma o flow (opcionalmente reposicionado por jumpTarget)

Após o engine consumir humanSessionEnded (ou agentSessionEnded para IA), a sessão retorna ao modo "flow" e session.helpdesk é limpo. Tickets sem sessionId (legado) são fechados sem retomar o flow.

Nota: o socket agent:close sempre publica humanSessionEnded (path humano, sem jumpTarget). O tratamento de tickets de IA e o jumpTarget (finalizar-com-jump) vivem apenas nas rotas HTTP via closeTicketWithEffects.


Resolução de fila

Quando um handoff chega, o sistema precisa decidir para qual fila enviá-lo. Essa decisão é feita por resolveQueue() avaliando QueueDistributionRule:

typescript
QueueDistributionRule {
  routerId      // rules são escopadas por router
  name
  priority      // menor número = maior prioridade
  conditions    // array de Condition (do flow-ai-types) em JSON (AND implícito)
  targetQueueId
  flowId?       // opcional: limita a regra a um Flow específico
  isActive
}

Cada Condition tem o shape do flow-ai-types: { source, comparator, values }, onde source é um objeto discriminado por type. As condições são avaliadas contra dois dicionários:

  • event.contextVariables — variáveis do flow no momento do handoff (source.type: "context")
  • event.contact.variables — variáveis do contato, persistidas no banco (source.type: "contact")

Sources aplicáveis no handoff: context, contact, static. O source input nunca casa (não há input do usuário no momento da escolha de fila).

A primeira rule ativa cuja condição casa define a fila. Se nenhuma casar, usa a primeira fila ativa do Router como fallback. Se não existir nenhuma fila ativa, resolveQueue lança AppError(500) — o evento fica pendente na stream até haver fila.

Exemplo: roteamento por estado do usuário:

json
[
  { "priority": 1, "conditions": [{ "source": { "type": "contact", "variable": "estado" }, "comparator": "equals", "values": ["SP"] }], "targetQueueId": "queue_sp" },
  { "priority": 2, "conditions": [{ "source": { "type": "contact", "variable": "plano" }, "comparator": "equals", "values": ["premium"] }], "targetQueueId": "queue_vip" },
  { "priority": 999, "conditions": [], "targetQueueId": "queue_geral" }
]

A rule com conditions: [] funciona como catch-all (sempre casa). Para comparadores de valor (equals, contains, etc.), qualquer item de values que casar satisfaz a condição (any-of).


Dispatcher: atribuição de agente

O dispatcher (dispatcher.ts::pickAgentForQueue) escolhe o agente de um ticket em espera. Ele não roda no handoff; seu único gatilho ativo é POST /tickets/:id/assign (atribuição explícita via Kanban):

pickAgentForQueue(queueId)
├─ Fila inativa ou sem agentes → retorna null
├─ Filtra agentes da fila (QueueAgent) com status "online" (Redis, TTL 60s)
├─ Filtra por countOpenByAgent < queue.maxConcurrentTicketsPerAgent
└─ Aplica estratégia (queue.distributionStrategy):
   └─ "least_loaded" → agente com menos tickets abertos (tie-break aleatório)

Nota: o enum DistributionStrategy também declara round_robin e manual_pickup, mas ambos ainda caem no mesmo leastLoaded() no applyStrategy — as estratégias distintas não estão implementadas. O modelo real de fila é o pickup manual: o agente reivindica o próximo ticket em espera via agent:pickup (ver seção Pickup manual). O re-dispatch automático é desabilitado por design (ticketService.tryAssignWaitingForQueue existe mas não é chamado no core).

O status do agente é efêmero no Redis (chave status:user:{userId}) com TTL de 60 segundos, renovado por agent:heartbeat do desk (cadência de ~30s no cliente). Se o agente fechar o browser sem logout, o status expira automaticamente e ele para de receber tickets.


Eventos Socket.IO

O desk opera em tempo real via Socket.IO. O servidor agrupa cada agente numa room user:{userId}.

Servidor → Agente

EventoQuando é emitido
ticket:assignedNovo ticket foi atribuído ao agente
ticket:messageUsuário enviou mensagem durante atendimento
ticket:message-statusStatus de entrega de mensagem do agente (delivered, read, played, failed)
ticket:transferredTicket que estava com o agente foi transferido
ticket:closedTicket foi fechado
ticket:window_expiredJanela Meta de 24h expirou — ticket fechado automaticamente
ticket:sentimentResultado de análise de sentimento do cliente (onHandoff / bucketHuman)
helpdesk:waitingContagem de tickets em espera nas filas do agente
helpdesk:sync-requiredAgente deve reidratar seu estado (mudança de elegibilidade)

Agente → Servidor

EventoAção
agent:statusAtualiza status (online/away/busy/offline) no Redis
agent:heartbeatRenova TTL do status (evita expiração)
agent:replyEnvia mensagem ao usuário (publica na stream de saída do canal: stream:outgoing-{meta,chat,ig})
agent:pickupReivindica próximo ticket em espera (manual pickup)
agent:transferTransfere ticket para agente ou fila
agent:closeFecha ticket e retorna sessão ao engine
helpdesk:refreshForça reemissão de helpdesk:waiting com contagem atual

Por que helpdesk:sync-required?

Quando a elegibilidade de um agente muda (ex: adicionado a uma nova fila, sua fila fica vazia, outro agente entra online), o servidor emite helpdesk:sync-required. O desk reage fazendo uma nova requisição HTTP para reidratar todo o estado — filas, tickets em espera, contagens. Isso evita manter estado sincronizado de forma incremental e garante consistência após qualquer mudança no sistema.


Expiração da janela Meta de 24h

A Meta permite enviar mensagens livres para um usuário por 24h após o último contato. Após esse período, apenas templates são permitidos. O sistema detecta essa expiração automaticamente:

flow-ai-meta-api tenta enviar mensagem do agente
  → Meta retorna erro 131026 (janela expirada)
  → Consumer publica StatusStreamEvent { status: "send_failed", error.code: 131026 }

core status-consumer detecta código 131026:
  → handleWindowExpired()
     ├─ Fecha ticket { closeKind: "meta_window_expired" }
     ├─ publishHumanSessionEnded() → engine retoma
     └─ Socket.IO emite ticket:window_expired ao agente

O agente recebe o evento no desk e sabe que não pode mais enviar mensagens livres — apenas templates.


Monitoramento em tempo real

O serviço de monitoramento agrega métricas operacionais com cache de 2 segundos no Redis:

typescript
MonitoringSnapshot {
  realtime: {
    inQueue: number,              // tickets waiting
    inService: number,            // tickets assigned
    maxQueueWaitMs: number,       // tempo de espera do ticket mais antigo
    avgFirstResponseMs: number,   // tempo médio até primeiro reply do agente
    avgTicketsPerAgent: number,
  },
  agentStatus: { online, away, offline },
  todayMetrics: { avgQueueTimeMs, avgServiceTimeMs },
  todayTicketStatus: { waiting, assigned, closedToday, abandonedToday },
  detailed: { inProgress[], waiting[], byAgent[], byQueue[], aiTickets[] }
}

O snapshot é emitido via Socket.IO para qualquer cliente na room de monitoramento (monitoring:join). Pode ser filtrado por routerId para exibições multi-tenant.


Cache de roteamento em memória

O core mantém um Map<chatId, ChatRouting> em memória com { sessionId, phoneNumberId, to } de cada chat em atendimento. Isso evita consultar o banco a cada agent:reply para saber para qual número enviar (o sessionId também determina a stream de saída por canal):

typescript
cacheChatRouting(chatId, { sessionId, phoneNumberId, to })  // gravado no handoff e a cada mensagem
getChatRouting(chatId)                                       // lido no reply
clearChatRouting(chatId)                                     // limpo no close/window_expired

Se o processo reiniciar e o cache em memória sumir, o publishReply faz fallback reconstruindo o routing a partir do Ticket.sessionId (o phoneNumberId é derivado do próprio sessionId, exceto para sessões wc-*). A consistência é garantida — só a latência de uma operação muda.


Respostas prontas (quick replies)

O core suporta templates de resposta rápida com interpolação de variáveis:

{{contact.nome}}     → contact.variables.nome
{{context.plano}}    → session contextVariables.plano
{{system.now}}       → data/hora atual

GET /tickets/:id/quick-replies retorna as respostas prontas visíveis para o agente naquele contexto, com os placeholders já resolvidos. Placeholders sem valor são preservados como {{chave}} para o agente completar manualmente.


Arquivos relevantes

ArquivoPapel
packages/flow-ai-database/prisma/schema.prismaModelos Ticket (com kind, previousTicketId, sentiment), Queue, QueueDistributionRule, QueueAgent, Tag, TicketTag
services/flow-ai-core/src/helpdesk/consumer.tsLoop de consumo de stream:helpdesk (kinds handoff / message / ai_ticket)
services/flow-ai-core/src/helpdesk/handlers/handoff.tsAbertura de ticket, resolução de fila, sentimento onHandoff
services/flow-ai-core/src/helpdesk/handlers/message.tsEntrega de mensagem ao agente via Socket.IO
services/flow-ai-core/src/helpdesk/handlers/window-expired.tsFechamento por expiração da janela Meta
services/flow-ai-core/src/helpdesk/ticketActions.tstransferOrHandoff / closeTicketWithEffects (roteamento humano vs. IA, jumpTarget)
services/flow-ai-core/src/helpdesk/publishManualHandoff.tsHandoff manual de ticket de IA → stream:agent
services/flow-ai-core/src/helpdesk/resolveQueue.tsAvaliação de QueueDistributionRule
services/flow-ai-core/src/helpdesk/dispatcher.tspickAgentForQueue (estratégia least_loaded)
services/flow-ai-core/src/helpdesk/agentStatus.tsStatus efêmero do agente no Redis (TTL 60s)
services/flow-ai-core/src/helpdesk/outbound.tsPublicação de reply do agente na stream de saída por canal (stream:outgoing-{meta,chat,ig})
services/flow-ai-core/src/helpdesk/publishHumanSessionEnded.tsSinal de retomada para o engine (humanSessionEnded)
services/flow-ai-core/src/helpdesk/publishAgentSessionEnded.tsSinal de retomada para tickets de IA (agentSessionEnded)
services/flow-ai-core/src/helpdesk/socket.tsPlugin Socket.IO, todos os eventos bidirecionais
services/flow-ai-core/src/helpdesk/waitingNotifications.tshelpdesk:waiting e helpdesk:sync-required
services/flow-ai-core/src/helpdesk/status-consumer.tsConsumer de stream:statusticket:message-status + janela expirada
services/flow-ai-core/src/services/ticket.service.tsCiclo de vida do ticket + board Kanban
services/flow-ai-core/src/services/monitoring.service.tsSnapshot de monitoramento com cache de 2s
services/flow-ai-core/src/http/routes/ticket.routes.tsEndpoints REST de ticket
services/flow-ai-engine/src/runtime/publish-helpdesk.tsPublicação do handoff pelo engine

flow-ai — plataforma proprietária de atendimento via WhatsApp