Të gjithë artikujt

Nga chatbot-i te një person i vërtetë: si funksionon kalimi në bisedën live

Në faqe ka një asistent të bazuar në IA që u përgjigjet pyetjeve për shërbimet, projektet dhe mjetet. Për pyetjet e shpeshta funksionon mirë, por ndonjëherë kush shkruan dëshiron të flasë me një person: ka në mendje një projekt të caktuar, ose një pyetje së cilës një model gjuhësor nuk duhet t'i përgjigjet me hamendje. Më parë rruga e vetme ishte formulari i kontaktit. Tani, ndërsa shkruan në bisedë, mund të shfaqet një ftesë: "Dëshiron të flasësh drejtpërdrejt me Gent-in?". Nëse përgjigjesh po, unë marr një email, hap një link dhe hyj në të njëjtën bisedë, në kohë reale.

Në këtë artikull tregoj si funksionon, me kodin (Angular, NestJS, Socket.IO, MongoDB) dhe gabimet që në lokal nuk dukeshin. Pjesët e kodit janë të thjeshtuara krahasuar me ato në prodhim, por logjika është e njëjta.

Rasti i përdorimit

Një vizitor e pyet asistentin sa mund të kushtojë një sistem menaxhimi i personalizuar. Merr një përgjigje të përgjithshme, bën një pyetje tjetër, pastaj nis të përshkruajë rastin e vet. Ndërsa shkruan, shfaqet banneri. Klikon "Po, kontaktoje" dhe lexon "Duke pritur Gent-in…".

Unë marr një email me subjektin "🔴 Un visitatore vuole parlare con te ora" ("Një vizitor do të flasë me ty tani"), mesazhet e fundit të bisedës dhe një buton. E hap edhe nga telefoni, hyj në bisedë dhe vizitori sheh "Gent-i po hyn…", pastaj "Po flet me Gent-in". Nga ai moment mesazhet e tij vijnë te unë, jo te IA-ja.

Nëse nuk përgjigjem brenda 15 minutave, kërkesa skadon dhe vizitori e merr vesh: "Gent-i nuk është i disponueshëm tani, por e ka marrë kërkesën tënde". Mund të vazhdojë me asistentin, dhe askush nuk mbetet duke pritur përpara një kursori që pulson.

Rrjedha, nga fillimi në fund

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

Zgjedhja themelore: kërkesa kalon përmes REST, biseda përmes WebSocket. Kërkesa është një ngjarje e vetme që duhet validuar, kufizuar dhe regjistruar; biseda është një rrjedhë dykahëshe ku latenca ka rëndësi.

1. Banneri: të pyesësh në momentin e duhur

Një popup që shfaqet sapo hapet faqja është i bezdisshëm. Ky shfaqet vetëm kur plotësohen tre kushte:

  • ka pasur tashmë të paktën një shkëmbim me asistentin;
  • vizitori po shkruan: çdo tast ushqen një stream RxJS me debounceTime 1,2 sekonda, kështu që banneri vjen gjatë një pauze, jo në çdo shkronjë;
  • nuk e ka refuzuar tashmë në 10 minutat e fundit.
// 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
}

Pas 8 sekondash pa përgjigje banneri zvogëlohet në një pilulë "Fol me Gent-in": mbetet i disponueshëm, por nuk e mbulon bisedën. Refuzimi mbahet mend në sessionStorage, pra vlen për skedën e hapur dhe nuk e ndjek vizitorin përgjithmonë. Dhe një çelës në dashboard e fik të gjithë funksionin kur nuk mund të përgjigjem.

2. Kërkesa: REST, me kufizime

// 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);
}

Çdo rresht ka një arsye:

  • Idempotenca: një klikim i dyfishtë, ose një përpjekje e dytë pas një gabimi rrjeti, kthen kërkesën që është tashmë e hapur në vend që të krijojë një tjetër dhe të më dërgojë dy email-e.
  • Tri kërkesa çdo dhjetë minuta dhe një tavan ditor: një endpoint publik që nis email-e është ftesë për abuzime.
  • IP-ja nuk ruhet: ruaj vetëm një hash të shkurtuar, të dobishëm për të njohur një abuzim të përsëritur, të padobishëm për të identifikuar dikë.
  • Email-i niset pa await: vizitori merr menjëherë përgjigjen, dhe nëse dërgimi dështon, mbetet gjurmë në log-e.

3. Email-i: i gjithë konteksti me një shikim

Kur e hap email-in duhet të kuptoj brenda pak sekondash kush më kërkon dhe pse. Për këtë arsye përmban gjashtë mesazhet e fundit të bisedës, gjuhën e vizitorit, orën e skadimit dhe një link të drejtpërdrejtë te faqja e administrimit e asaj bisede:

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}`;

Linku të çon te një faqe e mbrojtur, e arritshme vetëm si administrator. Dhe email-i nuk është kanali i vetëm: kërkesa shfaqet edhe në dashboard, me një njoftim në kohë reale.

4. WebSocket-i: dhoma dhe role

Gateway-i Socket.IO jeton në një namespace të dedikuar, /live-chat. Çdo bisedë është një dhomë: vizitori dhe administratori hyjnë në të njëjtën, dhe çdo mesazh fillimisht ruhet e pastaj u transmetohet të gjithëve në dhomë.

@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,
    });
  }
}

Mesazhet e mia përfundojnë në të njëjtin sesion me bisedën me IA-në, me një rol të tretë pranë user dhe assistant: agent. E zgjerova skemën ekzistuese në vend që të krijoja një të re, kështu biseda mbetet një e vetme.

Vizitori nuk ka llogari: hyn në dhomë me identifikuesin e sesionit, një UUID të rastësishëm që e njeh vetëm shfletuesi i tij. Ajo është leja e tij. Administratori përkundrazi autentikohet në hapjen e socket-it, me token-in e vet:

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';
}

Në versionin e parë mjaftonte çfarëdo JWT i vlefshëm. Me regjistrimin të hapur për të gjithë, një përdorues i zakonshëm mund të kishte hyrë në një bisedë duke u paraqitur si "Gent", të shkruante në vendin tim dhe ta mbyllte. Tani roli lexohet nga baza e të dhënave në çdo lidhje: një administrator që ulet në rang e humb qasjen menjëherë, pa pritur që token-i të skadojë.

5. Një makinë gjendjesh, me afate

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

Gjendjet i çojnë përpara ngjarjet, por edhe koha. Për këtë ka tri punë të planifikuara dhe një indeks TTL të MongoDB-së:

// 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 });

Pa mbylljen për mosaktivitet, dy skeda të lëna hapur pa i shkruar njëra-tjetrës do të mbeteshin "live" për orë të tëra, duke zënë dashboard-in sikur të ishte një bisedë në vazhdim.

6. Rrjeta e sigurisë: polling-u

Një WebSocket mund të mos lidhet për shumë arsye: një proxy korporativ, një rrjet celular i paqëndrueshëm, një konfigurim i gabuar. Frontend-i mbrohet në dy mënyra:

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);
}

Së pari, nuk e detyron transportin websocket: Socket.IO nis me long polling mbi HTTP dhe kalon në WebSocket nëse ia del, kështu që nëse WebSocket-i është i bllokuar, mesazhet udhëtojnë gjithsesi. Së dyti, nëse Socket.IO nuk arrin të lidhet fare, polling-u REST vazhdon të kapë ndryshimet e gjendjes: vizitori e merr vesh njësoj që hyra në bisedë, ose që kërkesa skadoi, në vend që të presë përgjithmonë.

Gabimet që në lokal nuk dukeshin

Administratori i bllokuar te "Duke hyrë në bisedë…"

Ditën e parë, duke hapur linkun nga email-i, mbetesha i bllokuar te "Duke hyrë në bisedë…" përgjithmonë. Arsyeja: nëse kërkesa kishte skaduar tashmë, shërbimi hidhte një përjashtim, por gateway-i nuk e kapte. Asnjë ngjarje nuk kthehej te klienti, as e suksesit, as e gabimit. Dhe një kërkesë e skaduar ishte rasti më i zakonshëm: mjafton ta lexosh email-in me një çerek ore vonesë.

// 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;
}

Korrigjimi ka tri pjesë. Një kërkesë e skaduar tani mund të rihapet: nëse vizitori e ka ende faqen të hapur, biseda rinis. Gateway-i e kthen çdo përjashtim në një ngjarje error të lexueshme. Dhe një mesazh i dërguar në një bisedë jo më aktive prodhon një gabim në vend që të zhduket. Mësimi: në një funksion me afate, rasti për t'u testuar nuk është kërkesa e sapokrijuar. Është ajo e një çerek ore më parë.

Socket-i që në prodhim nuk lidhej

Në lokal gjithçka funksiononte; në prodhim faqja e administrimit mbetej te "Duke u lidhur…". E gabova diagnozën dy herë. Fillimisht mendova për CORS, por header-at ishin të saktë. Pastaj për proxy-n e shërbimit cloud që atëherë mbante backend-in: një handshake WebSocket i bërë me curl përgjigjej me 400. Por ishte curl që negocionte HTTP/2, ku ai upgrade nuk ekziston; një shfletues i vërtetë, në të njëjtin host, lidhej pa probleme.

Shkaku i vërtetë ishte Content Security Policy. connect-src e listonte backend-in si https://, dhe për CSP-në skema ka rëndësi: https://host nuk autorizon wss://host. Shfletuesi e bllokonte socket-in dhe linte vetëm një rresht në konsolë.

# 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"

E provova duke dëgjuar shkeljet në shfletues, në faqen reale, në vend që të mbështetesha te mjete që nuk sillen si një shfletues:

// 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

Tani ka një test automatik që lexon CSP-në dhe dështon nëse mungon hyrja wss://. Dhe edhe tani që backend-i qëndron në të njëjtin domen me faqen, rregulli mbetet i shprehur qartë: wss:// i domenit të vet pranë 'self', sepse jo të gjithë shfletuesit e konsiderojnë të përfshirë tashmë.

Mesazhi i hedhur poshtë në heshtje

Gabimi i tretë ishte më dinaku: përgjigjesha, dhe vizitori nuk merrte asgjë. Asnjë gabim, askund. Një sesion mund të ketë disa kërkesa (një të skaduar dhe një të re, për shembull), dhe funksioni që e çon gjendjen në live e kërkonte dokumentin pa e renditur: ndonjëherë përditësonte atë të vjetrin. Më i fundit mbetej në vend, gateway-i e konsideronte joaktiv dhe hidhte poshtë mesazhet e mia.

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);
}

Që atëherë çdo mesazh i refuzuar prodhon një gabim të qartë. Një mesazh që zhduket në heshtje është gabimi më i vështirë për t'u diagnostikuar: nuk lë gjurmë.

Privatësia dhe kufizimet

  • Asnjë IP në formë të qartë, vetëm një hash i shkurtuar.
  • Kërkesat fshihen vetë pas 24 orësh, falë indeksit TTL.
  • Kufizime për klient dhe për ditë, dhe një çelës për ta fikur gjithçka.
  • Një kufi i deklaruar: në anën tjetër jam unë, jo një shërbim klientësh aktiv 24 orë në ditë. Për këtë arsye afati është i shkurtër dhe mesazhi që e njofton është i qartë.

Në përmbledhje

Kalimi nga një chatbot te një person kërkon pak kod, por shumë vendime: kur të pyesësh, si të njoftosh, si ta autentikosh atë që përgjigjet dhe çfarë të bësh kur nuk përgjigjet askush. REST për kërkesën, WebSocket për bisedën, një polling si rrjetë sigurie dhe një afat i qartë për çdo gjendje. Kam mbledhur shënime të tjera për cookies, cache dhe WebSocket në këtë udhëzues. Nëse do ta provosh, hap bisedën e faqes: nëse jam i disponueshëm, përgjigjem unë. Dhe nëse projekti yt ka nevojë për diçka të ngjashme, më shkruaj.

💬 Shënime nga lexuesit

0 shënime

Shkruaj një shënim

Ndaj mendimin tënd, një sugjerim ose një kompliment

Shënimet e fundit

Ende asnjë shënim. Bëhu i pari që komenton!