Appearance
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 presetUserIdNota: o handoff não roda o dispatcher automaticamente. Sem
presetUserIdo ticket fica emwaitingaté um agente reivindicá-lo (agent:pickup) ou até uma atribuição explícita viaPOST /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 agente3. 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 assignedUserIdResposta 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-metaO 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íncronoNota: o socket
agent:transferchamaticketService.transfer()diretamente e só cobre tickets humanos (sem o desvio de IA). O roteamento porkind(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:closesempre publicahumanSessionEnded(path humano, semjumpTarget). O tratamento de tickets de IA e ojumpTarget(finalizar-com-jump) vivem apenas nas rotas HTTP viacloseTicketWithEffects.
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
DistributionStrategytambém declararound_robinemanual_pickup, mas ambos ainda caem no mesmoleastLoaded()noapplyStrategy— 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 viaagent:pickup(ver seção Pickup manual). O re-dispatch automático é desabilitado por design (ticketService.tryAssignWaitingForQueueexiste 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
| Evento | Quando é emitido |
|---|---|
ticket:assigned | Novo ticket foi atribuído ao agente |
ticket:message | Usuário enviou mensagem durante atendimento |
ticket:message-status | Status de entrega de mensagem do agente (delivered, read, played, failed) |
ticket:transferred | Ticket que estava com o agente foi transferido |
ticket:closed | Ticket foi fechado |
ticket:window_expired | Janela Meta de 24h expirou — ticket fechado automaticamente |
ticket:sentiment | Resultado de análise de sentimento do cliente (onHandoff / bucketHuman) |
helpdesk:waiting | Contagem de tickets em espera nas filas do agente |
helpdesk:sync-required | Agente deve reidratar seu estado (mudança de elegibilidade) |
Agente → Servidor
| Evento | Ação |
|---|---|
agent:status | Atualiza status (online/away/busy/offline) no Redis |
agent:heartbeat | Renova TTL do status (evita expiração) |
agent:reply | Envia mensagem ao usuário (publica na stream de saída do canal: stream:outgoing-{meta,chat,ig}) |
agent:pickup | Reivindica próximo ticket em espera (manual pickup) |
agent:transfer | Transfere ticket para agente ou fila |
agent:close | Fecha ticket e retorna sessão ao engine |
helpdesk:refresh | Forç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 agenteO 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_expiredSe 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 atualGET /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
| Arquivo | Papel |
|---|---|
packages/flow-ai-database/prisma/schema.prisma | Modelos Ticket (com kind, previousTicketId, sentiment), Queue, QueueDistributionRule, QueueAgent, Tag, TicketTag |
services/flow-ai-core/src/helpdesk/consumer.ts | Loop de consumo de stream:helpdesk (kinds handoff / message / ai_ticket) |
services/flow-ai-core/src/helpdesk/handlers/handoff.ts | Abertura de ticket, resolução de fila, sentimento onHandoff |
services/flow-ai-core/src/helpdesk/handlers/message.ts | Entrega de mensagem ao agente via Socket.IO |
services/flow-ai-core/src/helpdesk/handlers/window-expired.ts | Fechamento por expiração da janela Meta |
services/flow-ai-core/src/helpdesk/ticketActions.ts | transferOrHandoff / closeTicketWithEffects (roteamento humano vs. IA, jumpTarget) |
services/flow-ai-core/src/helpdesk/publishManualHandoff.ts | Handoff manual de ticket de IA → stream:agent |
services/flow-ai-core/src/helpdesk/resolveQueue.ts | Avaliação de QueueDistributionRule |
services/flow-ai-core/src/helpdesk/dispatcher.ts | pickAgentForQueue (estratégia least_loaded) |
services/flow-ai-core/src/helpdesk/agentStatus.ts | Status efêmero do agente no Redis (TTL 60s) |
services/flow-ai-core/src/helpdesk/outbound.ts | Publicação de reply do agente na stream de saída por canal (stream:outgoing-{meta,chat,ig}) |
services/flow-ai-core/src/helpdesk/publishHumanSessionEnded.ts | Sinal de retomada para o engine (humanSessionEnded) |
services/flow-ai-core/src/helpdesk/publishAgentSessionEnded.ts | Sinal de retomada para tickets de IA (agentSessionEnded) |
services/flow-ai-core/src/helpdesk/socket.ts | Plugin Socket.IO, todos os eventos bidirecionais |
services/flow-ai-core/src/helpdesk/waitingNotifications.ts | helpdesk:waiting e helpdesk:sync-required |
services/flow-ai-core/src/helpdesk/status-consumer.ts | Consumer de stream:status → ticket:message-status + janela expirada |
services/flow-ai-core/src/services/ticket.service.ts | Ciclo de vida do ticket + board Kanban |
services/flow-ai-core/src/services/monitoring.service.ts | Snapshot de monitoramento com cache de 2s |
services/flow-ai-core/src/http/routes/ticket.routes.ts | Endpoints REST de ticket |
services/flow-ai-engine/src/runtime/publish-helpdesk.ts | Publicação do handoff pelo engine |