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
debounceTimede 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.
