Auf der Website gibt es einen KI-basierten Assistenten, der Fragen zu Leistungen, Projekten und Werkzeugen beantwortet. Bei häufigen Fragen funktioniert er gut, aber manchmal möchte jemand, der schreibt, mit einem Menschen sprechen: Er hat ein konkretes Projekt im Kopf oder eine Frage, die ein Sprachmodell nicht auf gut Glück beantworten sollte. Früher blieb nur das Kontaktformular. Jetzt kann, während du im Chat schreibst, eine Einladung erscheinen: "Möchten Sie direkt mit Gent sprechen?". Wenn du Ja sagst, bekomme ich eine E-Mail, öffne einen Link und steige in dieselbe Unterhaltung ein, in Echtzeit.
In diesem Artikel erkläre ich, wie das funktioniert, mit dem Code (Angular, NestJS, Socket.IO, MongoDB) und den Bugs, die lokal nie auftauchten. Die Auszüge sind gegenüber denen in Produktion vereinfacht, aber die Logik ist dieselbe.
Der Anwendungsfall
Ein Besucher fragt den Assistenten, was eine maßgeschneiderte Verwaltungssoftware kosten kann. Er bekommt eine allgemeine Antwort, stellt eine weitere Frage und beginnt dann, seinen Fall zu beschreiben. Während er tippt, erscheint das Banner. Er klickt auf "Ja, kontaktieren" und liest "Warte auf Gent…".
Ich bekomme eine E-Mail mit dem Betreff "🔴 Un visitatore vuole parlare con te ora" ("Ein Besucher möchte jetzt mit dir sprechen"), den letzten Nachrichten der Unterhaltung und einem Button. Ich öffne sie auch vom Handy aus, steige in den Chat ein, und der Besucher sieht "Gent tritt bei…", dann "Sie sprechen mit Gent". Ab diesem Moment kommen seine Nachrichten bei mir an, nicht bei der KI.
Wenn ich nicht innerhalb von 15 Minuten antworte, läuft die Anfrage ab, und der Besucher erfährt es: "Gent ist gerade nicht verfügbar, hat Ihre Anfrage aber erhalten". Er kann mit dem Assistenten weitermachen, und niemand wartet vor einem blinkenden Cursor.
Der Ablauf, von Anfang bis Ende
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
Die Grundentscheidung: Die Anfrage läuft über REST, die Unterhaltung über WebSocket. Die Anfrage ist ein einzelnes Ereignis, das validiert, begrenzt und protokolliert werden muss; die Unterhaltung ist ein bidirektionaler Datenstrom, bei dem die Latenz zählt.
1. Das Banner: im richtigen Moment fragen
Ein Popup, das gleich beim Öffnen der Seite erscheint, nervt. Dieses erscheint nur, wenn drei Bedingungen erfüllt sind:
- es gab bereits mindestens einen Austausch mit dem Assistenten;
- der Besucher tippt gerade: Jeder Tastendruck speist einen RxJS-Stream mit einem
debounceTimevon 1,2 Sekunden, also kommt das Banner in einer Pause, nicht bei jedem Buchstaben; - er hat es nicht schon in den letzten 10 Minuten abgelehnt.
// 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
}
Nach 8 Sekunden ohne Antwort schrumpft das Banner zu einer Pille "Mit Gent sprechen": weiterhin verfügbar, aber es verdeckt die Unterhaltung nicht. Die Ablehnung wird in sessionStorage gespeichert, gilt also für den offenen Tab und verfolgt den Besucher nicht für immer. Und ein Schalter im Dashboard schaltet die ganze Funktion ab, wenn ich nicht antworten kann.
2. Die Anfrage: REST, mit Grenzen
// 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);
}
Jede Zeile hat einen Grund:
- Idempotenz: Ein Doppelklick oder ein zweiter Versuch nach einem Netzwerkfehler liefert die bereits offene Anfrage zurück, statt eine neue anzulegen und mir zwei E-Mails zu schicken.
- Drei Anfragen alle zehn Minuten und eine Tagesobergrenze: Ein öffentlicher Endpoint, der E-Mails auslöst, ist eine Einladung zum Missbrauch.
- Die IP wird nicht gespeichert: Ich behalte nur einen gekürzten Hash, nützlich, um wiederholten Missbrauch zu erkennen, nutzlos, um jemanden zu identifizieren.
- Die E-Mail wird ohne
awaitverschickt: Der Besucher bekommt sofort eine Antwort, und wenn der Versand fehlschlägt, steht es in den Logs.
3. Die E-Mail: der ganze Kontext auf einen Blick
Wenn ich die E-Mail öffne, muss ich in wenigen Sekunden verstehen, wer mich sucht und warum. Deshalb enthält sie die letzten sechs Nachrichten der Unterhaltung, die Sprache des Besuchers, die Ablaufzeit und einen direkten Link zur Admin-Seite dieses Chats:
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}`;
Der Link führt zu einer geschützten Seite, die nur als Administrator erreichbar ist. Und die E-Mail ist nicht der einzige Kanal: Die Anfrage erscheint auch im Dashboard, mit einer Benachrichtigung in Echtzeit.
4. Der WebSocket: Räume und Rollen
Das Socket.IO-Gateway lebt in einem eigenen Namespace, /live-chat. Jede Unterhaltung ist ein Raum: Besucher und Administrator betreten denselben, und jede Nachricht wird zuerst gespeichert und dann an den ganzen Raum gesendet.
@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,
});
}
}
Meine Nachrichten landen in derselben Session wie der Chat mit der KI, mit einer dritten Rolle neben user und assistant: agent. Ich habe das bestehende Schema erweitert, statt ein neues anzulegen, damit die Unterhaltung eine einzige bleibt.
Der Besucher hat kein Konto: Er betritt den Raum mit der Session-ID, einer zufälligen UUID, die nur sein Browser kennt. Das ist seine Berechtigung. Der Administrator dagegen authentifiziert sich beim Öffnen des Sockets, mit seinem 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';
}
In der ersten Version reichte ein beliebiges gültiges JWT. Bei einer für alle offenen Registrierung hätte ein normaler Benutzer einem Chat als "Gent" beitreten, an meiner Stelle schreiben und ihn schließen können. Jetzt wird die Rolle bei jeder Verbindung aus der Datenbank gelesen: Ein herabgestufter Administrator verliert den Zugriff sofort, ohne zu warten, bis das Token abläuft.
5. Ein Zustandsautomat, mit Fristen
requested ──→ notified ──→ agent_joining ──→ live ──→ closed
│ │ │
└─────────────┴──→ expired └──→ closed (5 min without messages,
(15 min without an answer) or 2 h at most)
Die Zustände werden von Ereignissen vorangetrieben, aber auch von der Zeit. Deshalb gibt es drei geplante Jobs und einen TTL-Index von 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 });
Ohne das Schließen bei Inaktivität blieben zwei offen gelassene Tabs, die sich nicht schreiben, stundenlang "live" und würden das Dashboard belegen, als wäre es eine laufende Unterhaltung.
6. Das Sicherheitsnetz: Polling
Ein WebSocket kann aus vielen Gründen keine Verbindung aufbauen: ein Firmen-Proxy, ein instabiles Mobilnetz, eine falsche Konfiguration. Das Frontend schützt sich auf zwei Arten:
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);
}
Erstens erzwingt es nicht den Transport websocket: Socket.IO startet mit Long Polling über HTTP und wechselt zu WebSocket, wenn es klappt, sodass die Nachrichten auch bei blockiertem WebSocket ankommen. Zweitens, wenn Socket.IO überhaupt keine Verbindung aufbauen kann, erkennt das REST-Polling weiterhin die Zustandsänderungen: Der Besucher erfährt trotzdem, dass ich dem Chat beigetreten bin oder dass die Anfrage abgelaufen ist, statt ewig zu warten.
Die Bugs, die lokal nie auftauchten
Der Administrator, der bei "Chat wird betreten…" hängen blieb
Am ersten Tag blieb ich nach dem Öffnen des Links aus der E-Mail für immer bei "Chat wird betreten…" hängen. Der Grund: Wenn die Anfrage bereits abgelaufen war, warf der Service eine Exception, aber das Gateway fing sie nicht ab. Kein Ereignis kam beim Client an, weder Erfolg noch Fehler. Und eine abgelaufene Anfrage war der häufigste Fall: Es reicht, die E-Mail eine Viertelstunde zu spät zu lesen.
// 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;
}
Die Korrektur hat drei Teile. Eine abgelaufene Anfrage lässt sich jetzt wieder öffnen: Hat der Besucher die Seite noch offen, geht die Unterhaltung weiter. Das Gateway verwandelt jede Exception in ein lesbares error-Ereignis. Und eine Nachricht an einen nicht mehr aktiven Chat erzeugt einen Fehler, statt zu verschwinden. Die Lehre: Bei einer Funktion mit Fristen ist der Testfall nicht die gerade angelegte Anfrage, sondern die von vor einer Viertelstunde.
Der Socket, der sich in Produktion nicht verband
Lokal funktionierte alles; in Produktion blieb die Admin-Seite bei "Verbindung wird hergestellt…" stehen. Ich habe mich zweimal in der Diagnose geirrt. Zuerst dachte ich an CORS, aber die Header waren korrekt. Dann an den Proxy des Cloud-Dienstes, der das Backend damals hostete: Ein mit curl durchgeführter WebSocket-Handshake antwortete mit 400. Aber es war curl, das HTTP/2 aushandelte, wo es dieses Upgrade nicht gibt; ein echter Browser verband sich mit demselben Host problemlos.
Die wahre Ursache war die Content Security Policy. connect-src führte das Backend als https://, und für die CSP zählt das Schema: https://host autorisiert nicht wss://host. Der Browser blockierte den Socket und hinterließ nur eine Zeile in der Konsole.
# 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"
Bewiesen habe ich es, indem ich die Verstöße im Browser abgehört habe, auf der echten Seite, statt mich auf Werkzeuge zu verlassen, die sich nicht wie ein Browser verhalten:
// 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
Inzwischen gibt es einen automatischen Test, der die CSP liest und fehlschlägt, wenn der wss://-Eintrag fehlt. Und auch jetzt, wo das Backend auf derselben Domain wie die Website liegt, bleibt die Regel explizit: das wss:// der eigenen Domain neben 'self', weil nicht alle Browser es als bereits enthalten betrachten.
Die stillschweigend verworfene Nachricht
Der dritte Bug war der heimtückischste: Ich antwortete, und der Besucher bekam nichts. Kein Fehler, nirgends. Eine Session kann mehrere Anfragen haben (zum Beispiel eine abgelaufene und eine neue), und die Funktion, die den Zustand auf live setzt, suchte das Dokument ohne Sortierung: Manchmal aktualisierte sie das alte. Das neueste blieb stehen, das Gateway hielt es für inaktiv und verwarf meine Nachrichten.
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);
}
Seitdem erzeugt jede abgelehnte Nachricht einen expliziten Fehler. Eine Nachricht, die stillschweigend verschwindet, ist der am schwersten zu diagnostizierende Bug: Sie hinterlässt keine Spuren.
Datenschutz und Grenzen
- Keine IP im Klartext, nur ein gekürzter Hash.
- Anfragen löschen sich selbst nach 24 Stunden, dank des TTL-Index.
- Limits pro Client und pro Tag, und ein Schalter, um alles abzuschalten.
- Eine offen genannte Grenze: Auf der anderen Seite sitze ich, kein Kundenservice, der rund um die Uhr erreichbar ist. Deshalb ist die Frist kurz und die Nachricht, die sie mitteilt, klar.
Fazit
Vom Chatbot zu einem Menschen zu wechseln, braucht wenig Code, aber viele Entscheidungen: wann fragen, wie benachrichtigen, wie den Antwortenden authentifizieren und was tun, wenn niemand antwortet. REST für die Anfrage, WebSocket für die Unterhaltung, Polling als Sicherheitsnetz und eine explizite Frist für jeden Zustand. Weitere Notizen zu Cookies, Cache und WebSockets habe ich in diesem Leitfaden gesammelt. Wenn du es ausprobieren willst, öffne den Chat der Website: Wenn ich verfügbar bin, antworte ich selbst. Und wenn dein Projekt etwas Ähnliches braucht, schreib mir.
