Todos os artigos

Do chatbot a uma pessoa real: como funciona a passagem para o chat ao vivo

No site há um assistente baseado em IA que responde a perguntas sobre serviços, projetos e ferramentas. Para as perguntas frequentes funciona bem, mas às vezes quem escreve quer falar com uma pessoa: tem um projeto concreto em mente, ou uma pergunta a que um modelo de linguagem não deveria responder a adivinhar. Antes, o único caminho era o formulário de contacto. Agora, enquanto escreves no chat, pode aparecer um convite: "Quer falar diretamente com o Gent?". Se responderes que sim, recebo um email, abro um link e entro na mesma conversa, em tempo real.

Neste artigo conto como funciona, com o código (Angular, NestJS, Socket.IO, MongoDB) e os bugs que em local não se viam. Os excertos estão simplificados em relação aos que estão em produção, mas a lógica é a mesma.

O caso de uso

Um visitante pergunta ao assistente quanto pode custar um software de gestão à medida. Recebe uma resposta geral, faz outra pergunta e depois começa a descrever o seu caso. Enquanto escreve, aparece o banner. Clica em "Sim, contatá-lo" e lê "Aguardando o Gent…".

Eu recebo um email com o assunto "🔴 Un visitatore vuole parlare con te ora" ("Um visitante quer falar contigo agora"), as últimas mensagens da conversa e um botão. Abro-o até no telemóvel, entro no chat e o visitante vê "O Gent está entrando…", depois "Você está falando com o Gent". A partir desse momento, as mensagens dele chegam a mim, não à IA.

Se eu não responder em 15 minutos, o pedido expira e o visitante fica a saber: "O Gent não está disponível no momento, mas recebeu seu pedido". Pode continuar com o assistente, e ninguém fica à espera diante de um cursor a piscar.

O fluxo, do início ao fim

Visitor (Angular)                  Backend (NestJS)                     Gent
─────────────────                  ────────────────                     ────
types... (debounce 1.2 s)
→ banner "Talk to Gent?"
clicks "Yes"
  POST /chatbot/:id/live-handoff → saves request   [requested]
                                 → sends email ─────────────────────────→ 📧
  io('/live-chat')
  join_session ─────────────────→ room live-handoff:<id>                [notified]
                                                         ←── admin_join (JWT)
  ←── agent_joined, status: live                                        [live]
  visitor_message ══════════════ chat_message (both ways) ══════════════ admin_message

A escolha de fundo: o pedido passa por REST, a conversa por WebSocket. O pedido é um evento único que tem de ser validado, limitado e registado; a conversa é um fluxo bidirecional em que a latência conta.

1. O banner: perguntar no momento certo

Um popup que aparece logo ao abrir a página é irritante. Este só aparece quando três condições são verdadeiras:

  • já houve pelo menos uma troca com o assistente;
  • o visitante está a escrever: cada tecla alimenta um stream RxJS com debounceTime de 1,2 segundos, por isso o banner chega durante uma pausa, não a cada letra;
  • não o recusou já nos últimos 10 minutos.
// chatbot.component.ts: every keystroke feeds a stream, debounced
private readonly typing$ = new Subject<void>();

ngOnInit(): void {
  this.typing$.pipe(debounceTime(1200)).subscribe(() => {
    const sessionId = this.chatbot.currentSessionId;
    if (sessionId && this.flags.liveHandoffEnabled()) {
      this.liveHandoff.notifyTyping(sessionId, this.chatbot.hasMessages);
    }
  });
}

onInputChange(): void {
  if (this.liveState === 'idle' || this.liveState === 'prompt_shown') {
    this.typing$.next();
  }
}

// live-handoff.service.ts
notifyTyping(sessionId: string, hasExchangedMessages: boolean): void {
  if (!hasExchangedMessages) return;              // never on the very first message
  if (this.state !== 'idle') return;              // already asked, or already live
  if (this.isDismissedRecently(sessionId)) return; // "no" means no, for 10 minutes

  this._state.next('prompt_shown');
  this.armAutoMinimize();                         // shrinks to a pill after 8 s
}

Ao fim de 8 segundos sem resposta, o banner reduz-se a uma pílula "Falar com o Gent": continua disponível, mas não tapa a conversa. A recusa fica guardada em sessionStorage, por isso vale para o separador aberto e não persegue o visitante para sempre. E um interruptor no dashboard desliga toda a funcionalidade quando não posso responder.

2. O pedido: REST, com limites

// live-handoff.controller.ts
@Post(':sessionId/live-handoff')
@RequireFeature('liveHandoffEnabled')              // the switch in the dashboard
@Throttle({ default: { limit: 3, ttl: 600_000 } }) // 3 requests every 10 minutes
create(
  @Req() req: Request,
  @Param('sessionId', ParseUUIDPipe) sessionId: string,
  @Body() dto: CreateLiveHandoffDto,
) {
  return this.liveHandoffService.createRequest(sessionId, dto, req.ip ?? '');
}

// live-handoff.service.ts
async createRequest(sessionId: string, dto: CreateLiveHandoffDto, ip: string) {
  // Idempotent: a double click returns the request that is already open
  const existing = await this.model.findOne({ sessionId, status: { $in: ACTIVE_STATUSES } });
  if (existing) return this.toStatusDto(existing);

  const todayCount = await this.model.countDocuments({ createdAt: { $gte: startOfToday() } });
  if (todayCount >= this.config.get<number>('LIVE_HANDOFF_MAX_PER_DAY', 20)) {
    throw new ConflictException('Live handoff request limit reached for today.');
  }

  const doc = await this.model.create({
    sessionId,
    status: 'requested',
    lastUserMessage: dto.lastUserMessage?.slice(0, 500),
    locale: dto.locale,
    // Never the raw IP: a truncated hash is enough to spot repeated abuse
    ipHash: createHash('sha256').update(ip).digest('hex').slice(0, 16),
    expiresAt: new Date(Date.now() + 15 * 60_000),
  });

  // Fire-and-forget: a slow mail provider must not keep the visitor waiting
  this.notifyGent(doc).catch((err) => this.logger.error('Live handoff email failed', err));

  return this.toStatusDto(doc);
}

Cada linha tem uma razão:

  • Idempotência: um duplo clique, ou uma segunda tentativa depois de um erro de rede, devolve o pedido já aberto em vez de criar outro e de me enviar dois emails.
  • Três pedidos a cada dez minutos e um teto diário: um endpoint público que dispara emails é um convite ao abuso.
  • O IP não é guardado: guardo apenas um hash truncado, útil para reconhecer um abuso repetido, inútil para identificar alguém.
  • O email sai sem await: o visitante recebe a resposta de imediato e, se o envio falhar, fica registo nos logs.

3. O email: todo o contexto num relance

Quando abro o email, tenho de perceber em poucos segundos quem me procura e porquê. Por isso contém as últimas seis mensagens da conversa, a língua do visitante, a hora de expiração e um link direto para a página de administração desse chat:

private async notifyGent(doc: LiveHandoffRequestDocument): Promise<void> {
  const session = await this.chatbotService.getSession(doc.sessionId).catch(() => null);

  await this.mailService.sendLiveHandoffRequest({
    sessionId: doc.sessionId,
    lastUserMessage: doc.lastUserMessage,
    locale: doc.locale,
    recentMessages: session?.messages?.slice(-6) ?? [], // the context at a glance
    expiresAt: doc.expiresAt,
  });

  doc.status = 'notified';
  doc.notifiedAt = new Date();
  await doc.save();
  this.gateway.emitStatusChanged(doc.sessionId, doc.status); // the visitor's UI follows
}

// mail.service.ts: the button in the email
const joinUrl = `${frontendUrl}/dashboard/live-chat/${opts.sessionId}`;

O link leva a uma página protegida, acessível apenas como administrador. E o email não é o único canal: o pedido aparece também no dashboard, com uma notificação em tempo real.

4. O WebSocket: salas e papéis

O gateway Socket.IO vive num namespace dedicado, /live-chat. Cada conversa é uma sala: visitante e administrador entram na mesma, e cada mensagem é primeiro guardada e depois transmitida a toda a sala.

@WebSocketGateway({ namespace: '/live-chat', cors: { origin: corsOriginValidator } })
export class LiveHandoffGateway {
  @WebSocketServer() server: Server;

  private room(sessionId: string): string {
    return `live-handoff:${sessionId}`;
  }

  @SubscribeMessage('join_session')
  async onJoinSession(@ConnectedSocket() client: Socket, @MessageBody() body: { sessionId?: string }) {
    if (body?.sessionId) await client.join(this.room(body.sessionId));
  }

  @SubscribeMessage('visitor_message')
  async onVisitorMessage(
    @ConnectedSocket() client: Socket,
    @MessageBody() body: { sessionId?: string; text?: string },
  ) {
    const text = body?.text?.trim().slice(0, 1000);
    if (!body?.sessionId || !text) return;

    const { status } = await this.liveHandoffService.getStatus(body.sessionId);
    if (!CHATTABLE_STATUSES.includes(status)) return;

    // Stored in the same session as the AI chat (roles: user, assistant, agent)
    const message = await this.chatbotService.appendLiveMessage(body.sessionId, 'user', text);
    await this.liveHandoffService.touchActivity(body.sessionId);
    this.server.to(this.room(body.sessionId)).emit('chat_message', {
      from: 'visitor',
      text,
      sentAt: message.timestamp,
    });
  }
}

As minhas mensagens ficam na mesma sessão do chat com a IA, com um terceiro papel ao lado de user e assistant: agent. Estendi o schema existente em vez de criar um novo, para que a conversa continue a ser uma só.

O visitante não tem conta: entra na sala com o identificador da sessão, um UUID aleatório que só o browser dele conhece. É essa a sua permissão. Já o administrador autentica-se na abertura do socket, com o seu token:

async handleConnection(client: Socket): Promise<void> {
  const token = client.handshake.auth?.token;
  if (typeof token === 'string' && (await this.isAdminToken(token))) {
    client.data.isAdmin = true;
    await client.join(ADMINS_ROOM); // also receives the dashboard notifications
  }
  // Visitors connect without a token: their sessionId is their capability
}

private async isAdminToken(token: string): Promise<boolean> {
  let sub: string | undefined;
  try {
    sub = this.jwtService.verify<{ sub?: string }>(token).sub;
  } catch {
    return false;
  }
  if (!sub) return false;
  // A valid signature is not enough: read the role from the database,
  // so a demoted admin loses access immediately
  const user = await this.usersService.findById(sub);
  return user?.role === 'admin';
}

Na primeira versão bastava um JWT válido qualquer. Com o registo aberto a todos, um utilizador comum podia ter entrado num chat fazendo-se passar pelo "Gent", escrito no meu lugar e fechado o chat. Agora o papel é lido da base de dados a cada ligação: um administrador despromovido perde o acesso de imediato, sem esperar que o token expire.

5. Uma máquina de estados, com prazos

requested ──→ notified ──→ agent_joining ──→ live ──→ closed
    │             │                            │
    └─────────────┴──→ expired                 └──→ closed  (5 min without messages,
         (15 min without an answer)                          or 2 h at most)

Os estados avançam com os eventos, mas também com o tempo. Por isso há três tarefas agendadas e um índice TTL do MongoDB:

// Every minute: unanswered requests past their deadline become 'expired'
@Cron(CronExpression.EVERY_MINUTE)
async expireStaleRequests(): Promise<number> {
  const stale = await this.model.find({
    status: { $in: ['requested', 'notified'] },
    expiresAt: { $lt: new Date() },
  });
  for (const doc of stale) {
    doc.status = 'expired';
    await doc.save();
    this.gateway.emitStatusChanged(doc.sessionId, doc.status); // the visitor is told
  }
  return stale.length;
}

// Same pattern for the other two jobs:
// - every minute:     'live' with no messages for 5 minutes  → 'closed'
// - every 10 minutes: open for more than 2 hours (tab closed) → 'closed'

// Data retention: each request is deleted 24 h after creation, whatever its outcome
LiveHandoffRequestSchema.index({ createdAt: 1 }, { expireAfterSeconds: 60 * 60 * 24 });

Sem o fecho por inatividade, dois separadores deixados abertos sem trocarem mensagens ficariam "live" durante horas, a ocupar o dashboard como se fosse uma conversa em curso.

6. A rede de segurança: o polling

Um WebSocket pode não ligar por muitos motivos: um proxy empresarial, uma rede móvel instável, uma configuração errada. O frontend defende-se de duas formas:

private connectSocket(sessionId: string): void {
  // No `transports: ['websocket']`: start with HTTP long polling and
  // upgrade to WebSocket when possible, instead of failing outright
  this.socket = this.io(`${this.wsOrigin}/live-chat`);

  this.socket.on('connect', () => this.socket?.emit('join_session', { sessionId }));
  this.socket.on('handoff_status_changed', ({ status }) => this.applyStatus(status));
  this.socket.on('chat_message', (msg) => this.appendMessage(msg));

  // Independent safety net: even if the socket never connects,
  // a REST poll every 4 s still finds out when Gent joins
  this.pollTimer = setInterval(() => {
    this.http
      .get<{ status: string }>(`${this.apiUrl}/${sessionId}/live-handoff/status`)
      .subscribe({ next: (res) => this.applyStatus(res.status), error: () => undefined });
  }, 4000);
}

Primeiro, não força o transporte websocket: o Socket.IO começa com long polling sobre HTTP e passa para WebSocket se conseguir, por isso, se o WebSocket estiver bloqueado, as mensagens passam na mesma. Segundo, se o Socket.IO não conseguir ligar de todo, o polling REST continua a detetar as mudanças de estado: o visitante fica a saber na mesma que entrei no chat, ou que o pedido expirou, em vez de ficar à espera para sempre.

Os bugs que em local não se viam

O administrador preso em "Entrando no chat…"

No primeiro dia, ao abrir o link do email, ficava preso em "Entrando no chat…" para sempre. O motivo: se o pedido já tinha expirado, o serviço lançava uma exceção, mas o gateway não a apanhava. Nenhum evento voltava ao cliente, nem de sucesso nem de erro. E um pedido expirado era o caso mais comum: basta ler o email com um quarto de hora de atraso.

// Before (simplified): an expired request threw, and nobody caught it
@SubscribeMessage('admin_join')
async onAdminJoin(@ConnectedSocket() client: Socket, @MessageBody() body: { sessionId: string }) {
  const status = await this.liveHandoffService.markAgentJoining(body.sessionId);
  // ConflictException on 'expired' → no event ever reached the client
}

// After: every failure becomes an event the UI can show
let status: LiveHandoffStatusDto;
try {
  status = await this.liveHandoffService.markAgentJoining(body.sessionId);
} catch (err) {
  client.emit('error', {
    message: err instanceof Error ? err.message : 'Impossibile entrare in questa chat.',
  });
  return;
}

A correção tem três partes. Um pedido expirado pode agora ser reaberto: se o visitante ainda tiver a página aberta, a conversa recomeça. O gateway transforma cada exceção num evento error legível. E uma mensagem enviada para um chat que já não está ativo produz um erro em vez de desaparecer. A lição: numa funcionalidade com prazos, o caso a testar não é o pedido acabado de criar. É o de há um quarto de hora.

O socket que em produção não ligava

Em local tudo funcionava; em produção, a página de administração ficava em "Conectando…". Errei o diagnóstico duas vezes. Primeiro pensei em CORS, mas os headers estavam corretos. Depois no proxy do serviço cloud que na altura alojava o backend: um handshake WebSocket feito com curl respondia 400. Mas era o curl que negociava HTTP/2, onde esse upgrade não existe; um browser a sério, no mesmo host, ligava-se sem problemas.

A verdadeira causa era a Content Security Policy. O connect-src listava o backend como https://, e para a CSP o esquema conta: https://host não autoriza wss://host. O browser bloqueava o socket e deixava apenas uma linha na consola.

# Before: the WebSocket was silently blocked
Header always set Content-Security-Policy "... connect-src 'self' https://api.example.com"

# After: the same host, listed once per scheme
Header always set Content-Security-Policy "... connect-src 'self' https://api.example.com wss://api.example.com"

Demonstrei-o ouvindo as violações no browser, na página real, em vez de confiar em ferramentas que não se comportam como um browser:

// Paste it in the console of the real page, then open the chat
document.addEventListener('securitypolicyviolation', (e) => {
  console.warn(e.violatedDirective, e.blockedURI);
});
// → connect-src wss://api.example.com

Agora há um teste automático que lê a CSP e falha se faltar a entrada wss://. E mesmo agora que o backend está no mesmo domínio do site, a regra continua explícita: o wss:// do próprio domínio ao lado de 'self', porque nem todos os browsers o consideram já incluído.

A mensagem descartada em silêncio

O terceiro bug era o mais traiçoeiro: eu respondia e o visitante não recebia nada. Nenhum erro, em lado nenhum. Uma sessão pode ter vários pedidos (um expirado e um novo, por exemplo), e a função que passa o estado para live procurava o documento sem o ordenar: às vezes atualizava o antigo. O mais recente ficava parado, o gateway considerava-o inativo e descartava as minhas mensagens.

async markLive(sessionId: string): Promise<void> {
  // Must target the SAME document markAgentJoining updated: the most recent one.
  // Without sort(), findOne could pick an older, expired request.
  const doc = await this.model.findOne({ sessionId }).sort({ createdAt: -1 });
  if (!doc || !['agent_joining', 'notified'].includes(doc.status)) return;

  doc.status = 'live';
  doc.lastActivityAt = new Date(); // the inactivity clock starts from here
  await doc.save();
  this.gateway.emitStatusChanged(doc.sessionId, doc.status);
}

Desde então, cada mensagem recusada produz um erro explícito. Uma mensagem que desaparece em silêncio é o bug mais difícil de diagnosticar: não deixa pistas.

Privacidade e limites

  • Nenhum IP em claro, apenas um hash truncado.
  • Os pedidos apagam-se sozinhos ao fim de 24 horas, graças ao índice TTL.
  • Limites por cliente e por dia, e um interruptor para desligar tudo.
  • Um limite declarado: do outro lado estou eu, não um serviço de apoio ao cliente ativo 24 horas por dia. Por isso o prazo é curto e a mensagem que o comunica é clara.

Em resumo

Passar de um chatbot para uma pessoa exige pouco código, mas muitas decisões: quando perguntar, como avisar, como autenticar quem responde e o que fazer quando ninguém responde. REST para o pedido, WebSocket para a conversa, um polling como rede de segurança e um prazo explícito para cada estado. Reuni mais notas sobre cookies, cache e WebSockets neste guia. Se quiseres experimentar, abre o chat do site: se estiver disponível, respondo eu. E se o teu projeto precisa de algo parecido, escreve-me.

💬 Notas dos leitores

0 notas

Escreva uma nota

Partilhe a sua opinião, uma sugestão ou um elogio

Notas recentes

Ainda não há notas. Seja o primeiro a comentar!