@kavchat/react-native 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/controller.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  startConversation,
11
11
  transcriptOf,
12
12
  uiTexts,
13
+ withLiveChip,
14
+ LIVE_AVAILABILITY_TTL_MS,
13
15
  } from '@kavchat/core';
14
16
  import type { BotChip, BotEffect, BotResult, BotState, UiTexts } from '@kavchat/core';
15
17
  import { AnyRelease, ApiConfig, EnabledRelease, FetchLike, TicketInput, fetchRelease, postAiAnswer, postEvents, postScreens, postTicket } from './api';
@@ -27,6 +29,9 @@ import {
27
29
  writeJson,
28
30
  } from './storage';
29
31
  import { pickLocale, safeUrl, ticketError, validateTicket } from './text';
32
+ import { LiveAvailability, LiveApiConfig, LiveChatRelease, LiveDepartmentAvailability, LiveRequestBody, LiveUnavailableReason, liveApi } from './live/api';
33
+ import { LivePhase, LiveSession, LiveSessionOptions, LiveSnapshot, LiveTiming, liveKey, requestHandoff } from './live/session';
34
+ import type { StreamOpener } from './live/sse';
30
35
 
31
36
  /**
32
37
  * הבקר: טעינת הגרסה עם ETag ומטמון, שמירת השיחה, דיווח לשרת ופניות
@@ -37,15 +42,34 @@ import { pickLocale, safeUrl, ticketError, validateTicket } from './text';
37
42
  export const DEFAULT_API = 'https://api.kavchat.com';
38
43
  export const BRANDING_URL = 'https://kavchat.com';
39
44
 
40
- export type ContactEffect = Extract<BotEffect, { type: 'contact' }>;
45
+ /** טופס פנייה (contact) או טופס הבקשה לנציג (live) */
46
+ export type ContactEffect = Extract<BotEffect, { type: 'contact' | 'live' }>;
41
47
  export type KavchatStatus = 'loading' | 'ready' | 'unavailable';
42
- export type KavchatView = 'chat' | 'form' | 'sent';
48
+ /** live: צ'אט עם נציג (המתנה, שיחה, או המצב הסופי) */
49
+ export type KavchatView = 'chat' | 'form' | 'sent' | 'live' | 'liveForm';
43
50
 
44
51
  export type KavchatEvent =
45
52
  | { type: 'ready'; version: number; offline: boolean }
46
53
  | { type: 'unavailable'; reason?: string }
47
54
  | { type: 'effect'; effect: BotEffect }
48
- | { type: 'ticket'; id: string; number: number };
55
+ | { type: 'ticket'; id: string; number: number }
56
+ /** צ'אט עם נציג: queued, active, closed, ticket, replaced או expired */
57
+ | { type: 'live'; phase: LivePhase };
58
+
59
+ /** האם להציע "צ'אט עם נציג" בטופס הפנייה, ואם לא, למה */
60
+ export interface LiveOffer {
61
+ /** בודקים עכשיו את /live/availability */
62
+ checking: boolean;
63
+ open: boolean;
64
+ /** closed ו-no_agents מוצגים כשורת הסבר. quota ו-disabled: רק הטופס, בלי הסבר */
65
+ reason: LiveUnavailableReason | null;
66
+ /** המחלקות הפתוחות. יותר מאחת: המשתמש בוחר */
67
+ departments: LiveDepartmentAvailability[];
68
+ /** כש-reason הוא closed: מתי הצוות זמין שוב */
69
+ nextOpenAt: string | null;
70
+ }
71
+
72
+ export const AVAILABILITY_CACHE_MS = LIVE_AVAILABILITY_TTL_MS;
49
73
 
50
74
  export interface KavchatSnapshot {
51
75
  status: KavchatStatus;
@@ -66,6 +90,13 @@ export interface KavchatSnapshot {
66
90
  reason?: string;
67
91
  identified: boolean;
68
92
  userName?: string;
93
+ /** liveChat.enabled בגרסה: רק אז מציעים צ'אט עם נציג */
94
+ liveEnabled: boolean;
95
+ liveOffer: LiveOffer | null;
96
+ /** הצ'אט עם הנציג, אם התחיל */
97
+ live: LiveSnapshot | null;
98
+ /** מה שהמשתמש כבר כתב בטופס הבקשה לנציג, כשאין מי שיענה וחוזרים לטופס הפנייה */
99
+ contactPrefill: { name?: string; email?: string; phone?: string; message?: string } | null;
69
100
  }
70
101
 
71
102
  export interface ControllerOptions {
@@ -88,6 +119,49 @@ export interface ControllerOptions {
88
119
  reportDelayMs?: number;
89
120
  sessionMaxAgeMs?: number;
90
121
  now?: () => number;
122
+ /**
123
+ * אחסון לקרדנשל של הצ'אט עם נציג (lvc_...). מומלץ אחסון מאובטח, למשל expo-secure-store.
124
+ * ברירת מחדל: storage
125
+ */
126
+ secureStorage?: StorageAdapter;
127
+ /** שם המסך שנשלח עם הבקשה לנציג (page), למשל 'checkout' */
128
+ livePage?: string;
129
+ /** להחלפה בבדיקות */
130
+ liveTiming?: Partial<LiveTiming>;
131
+ /** להחלפה בבדיקות. null: רק long-poll */
132
+ streamOpener?: StreamOpener | null;
133
+ random?: () => number;
134
+ }
135
+
136
+ export interface LiveRequestFields extends Partial<ContactFields> {
137
+ departmentId?: string;
138
+ }
139
+
140
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
141
+
142
+ function liveConfig(release: EnabledRelease | null): LiveChatRelease | null {
143
+ const lc = release ? (release as unknown as { liveChat?: LiveChatRelease }).liveChat : undefined;
144
+ return lc && typeof lc === 'object' && lc.enabled === true ? lc : null;
145
+ }
146
+
147
+ function offerFrom(a: LiveAvailability): LiveOffer {
148
+ const open = a.enabled === true && a.open === true;
149
+ const reason = open ? null : a.reason ?? null;
150
+ return {
151
+ checking: false,
152
+ open,
153
+ reason,
154
+ departments: open ? (a.departments ?? []).filter((d) => d.open) : [],
155
+ nextOpenAt: reason === 'closed' ? earliest(a.departments) : null,
156
+ };
157
+ }
158
+
159
+ function earliest(deps: LiveDepartmentAvailability[] | undefined): string | null {
160
+ let best: string | null = null;
161
+ for (const d of deps ?? []) {
162
+ if (d.nextOpenAt && (!best || Date.parse(d.nextOpenAt) < Date.parse(best))) best = d.nextOpenAt;
163
+ }
164
+ return best;
91
165
  }
92
166
 
93
167
  export interface ContactFields {
@@ -102,6 +176,16 @@ export class KavchatController {
102
176
  private opts: ControllerOptions;
103
177
  private api: ApiConfig;
104
178
  private store: StorageAdapter;
179
+ private secure: StorageAdapter;
180
+ private liveCfg: LiveApiConfig;
181
+ private liveSession: LiveSession | null = null;
182
+ private liveTried = false;
183
+ private livePhase: LivePhase | null = null;
184
+ /** נוצרים פעם אחת לכל ניסיון של המשתמש, ומשמשים לכל ניסיון חוזר */
185
+ private liveRequestId: string | null = null;
186
+ private liveMessageClientId: string | null = null;
187
+ private availability: { at: number; value: LiveAvailability } | null = null;
188
+ private foreground = true;
105
189
  private snap: KavchatSnapshot;
106
190
  private listeners = new Set<() => void>();
107
191
  private session: StoredSession | null = null;
@@ -118,6 +202,8 @@ export class KavchatController {
118
202
  ready: Promise<void> = Promise.resolve();
119
203
  /** מתממש כשניסיון רישום המסכים האחרון הסתיים. בשביל בדיקות */
120
204
  screensSync: Promise<void> = Promise.resolve();
205
+ /** מתממש כשניסיון שחזור הצ'אט עם הנציג הסתיים. בשביל בדיקות */
206
+ liveRestore: Promise<void> = Promise.resolve();
121
207
 
122
208
  constructor(options: ControllerOptions) {
123
209
  if (!options || !options.publicKey) throw new Error('Kavchat: publicKey is required');
@@ -129,6 +215,8 @@ export class KavchatController {
129
215
  getToken: () => this.opts.userToken || undefined,
130
216
  fetch: options.fetch,
131
217
  };
218
+ this.secure = options.secureStorage ?? this.store;
219
+ this.liveCfg = { ...this.api, getLocale: () => this.snap.locale };
132
220
  this.screens = normalizeScreens(options.screens);
133
221
  const locale = pickLocale(options.locale, options.deviceLocale, 'en');
134
222
  this.snap = {
@@ -145,6 +233,10 @@ export class KavchatController {
145
233
  ticketNumber: null,
146
234
  identified: !!options.userToken,
147
235
  userName: options.userName,
236
+ liveEnabled: false,
237
+ liveOffer: null,
238
+ live: null,
239
+ contactPrefill: null,
148
240
  };
149
241
  }
150
242
 
@@ -227,7 +319,8 @@ export class KavchatController {
227
319
  let view = this.snap.view;
228
320
  if (changed || !conversation) {
229
321
  conversation = await this.restoreSession(release, user);
230
- view = 'chat';
322
+ // צ'אט פתוח עם נציג נשאר על המסך גם כשהגרסה מתחלפת
323
+ view = this.liveSession ? 'live' : 'chat';
231
324
  }
232
325
  this.update({
233
326
  status: 'ready',
@@ -242,8 +335,10 @@ export class KavchatController {
242
335
  reason: undefined,
243
336
  identified: !!this.opts.userToken,
244
337
  userName: this.opts.userName,
338
+ liveEnabled: !!liveConfig(release),
245
339
  });
246
340
  this.emit({ type: 'ready', version: release.version, offline });
341
+ if (!this.liveTried) this.liveRestore = this.restoreLive();
247
342
  }
248
343
 
249
344
  private async restoreSession(release: EnabledRelease, user: string): Promise<BotState | null> {
@@ -266,6 +361,17 @@ export class KavchatController {
266
361
  const name = user?.name || undefined;
267
362
  if (token === this.opts.userToken && name === this.opts.userName) return this.ready;
268
363
  this.flush();
364
+ if (userFingerprint(token) !== userFingerprint(this.opts.userToken)) {
365
+ // שיחה עם נציג של משתמש אחד לא ממשיכה אצל אחר
366
+ this.liveSession?.forget();
367
+ this.liveSession = null;
368
+ this.liveTried = false;
369
+ this.livePhase = null;
370
+ this.liveRequestId = null;
371
+ this.liveMessageClientId = null;
372
+ this.availability = null;
373
+ this.update({ live: null, liveOffer: null, view: this.snap.view === 'live' ? 'chat' : this.snap.view });
374
+ }
269
375
  this.opts.userToken = token;
270
376
  this.opts.userName = name;
271
377
  return this.load();
@@ -392,6 +498,17 @@ export class KavchatController {
392
498
  void writeJson(this.store, sessionKey(this.opts.publicKey), this.session);
393
499
  }
394
500
  this.update({ conversation: state, ...extra });
501
+ // כפתור "פנייה לצוות" על המסך: בודקים (עם מטמון של 30 שניות) אם להוסיף לפניו "שיחה עם נציג"
502
+ if (state.chips.some((c) => c.kind === 'contact')) void this.checkLiveAvailability();
503
+ }
504
+
505
+ /**
506
+ * הכפתורים להצגה: כמו במנוע, ועם "שיחה עם נציג" לפני "פנייה לצוות" כשיש נציג זמין עכשיו
507
+ * (liveChat.enabled בגרסה, ו-/live/availability אמר open)
508
+ */
509
+ chips(): BotChip[] {
510
+ const chips = this.snap.conversation?.chips ?? [];
511
+ return this.snap.liveEnabled && this.snap.liveOffer?.open ? withLiveChip(chips, this.snap.texts['live.talk']) : chips;
395
512
  }
396
513
 
397
514
  private afterAction(r: BotResult) {
@@ -402,8 +519,11 @@ export class KavchatController {
402
519
 
403
520
  private handleEffect(effect: BotEffect) {
404
521
  this.emit({ type: 'effect', effect });
405
- if (effect.type === 'contact') {
406
- this.update({ view: 'form', contact: effect, ticketNumber: null });
522
+ if (effect.type === 'live') {
523
+ void this.openLive(effect);
524
+ } else if (effect.type === 'contact') {
525
+ this.update({ view: 'form', contact: effect, ticketNumber: null, contactPrefill: null });
526
+ void this.checkLiveAvailability();
407
527
  } else if (effect.type === 'link') {
408
528
  // רק https ו-tel. כל פרוטוקול אחר מהתסריט נחסם
409
529
  const href = safeUrl(effect.url, ['https', 'tel']);
@@ -419,6 +539,7 @@ export class KavchatController {
419
539
  this.update({
420
540
  view: 'form',
421
541
  ticketNumber: null,
542
+ contactPrefill: null,
422
543
  contact: {
423
544
  type: 'contact',
424
545
  contactTopicId: null,
@@ -427,6 +548,7 @@ export class KavchatController {
427
548
  transcript: state ? transcriptOf(state) : [],
428
549
  },
429
550
  });
551
+ void this.checkLiveAvailability();
430
552
  }
431
553
 
432
554
  backToChat() {
@@ -486,9 +608,252 @@ export class KavchatController {
486
608
  });
487
609
  }
488
610
 
611
+ // ---------- צ'אט עם נציג ----------
612
+
613
+ private liveOptions(): LiveSessionOptions {
614
+ return {
615
+ api: this.liveCfg,
616
+ store: this.secure,
617
+ storageKey: liveKey(this.opts.publicKey),
618
+ user: userFingerprint(this.opts.userToken),
619
+ opener: this.opts.streamOpener,
620
+ timing: this.opts.liveTiming,
621
+ random: this.opts.random,
622
+ now: this.opts.now,
623
+ onChange: (snap) => {
624
+ // רק הסשן הנוכחי מעדכן את המסך
625
+ if (this.liveSession && this.liveSession.getSnapshot() === snap) this.onLive(snap);
626
+ },
627
+ };
628
+ }
629
+
630
+ private onLive(snap: LiveSnapshot) {
631
+ this.update({ live: snap });
632
+ if (snap.phase !== this.livePhase) {
633
+ this.livePhase = snap.phase;
634
+ this.emit({ type: 'live', phase: snap.phase });
635
+ }
636
+ }
637
+
638
+ /** אחרי הפעלה מחדש: חוזרים לשיחה שעוד פתוחה, אם הקרדנשל בתוקף */
639
+ private async restoreLive() {
640
+ if (this.liveTried || this.liveSession) return;
641
+ this.liveTried = true;
642
+ const user = userFingerprint(this.opts.userToken);
643
+ const s = await LiveSession.restore(this.liveOptions());
644
+ if (!s) return;
645
+ if (this.disposed || this.liveSession || user !== userFingerprint(this.opts.userToken)) {
646
+ s.dispose();
647
+ return;
648
+ }
649
+ this.liveSession = s;
650
+ if (!this.foreground) s.pause();
651
+ this.livePhase = null;
652
+ this.update({ view: 'live' });
653
+ this.onLive(s.getSnapshot());
654
+ }
655
+
656
+ /**
657
+ * בודק אם אפשר להציע צ'אט עם נציג. נקרא כשטופס הפנייה נפתח. התשובה נשמרת 30 שניות.
658
+ * בלי liveChat.enabled בגרסה לא שואלים בכלל
659
+ */
660
+ checkLiveAvailability(force = false): Promise<void> {
661
+ if (!liveConfig(this.snap.release)) {
662
+ if (this.snap.liveOffer) this.update({ liveOffer: null });
663
+ return Promise.resolve();
664
+ }
665
+ const cached = this.availability;
666
+ if (!force && cached && this.now() - cached.at < AVAILABILITY_CACHE_MS) {
667
+ if (this.snap.liveOffer?.checking || !this.snap.liveOffer) this.update({ liveOffer: offerFrom(cached.value) });
668
+ return Promise.resolve();
669
+ }
670
+ // בקשה אחת בכל פעם: הכפתורים, הטופס והלחיצה על הכפתור מחכים לאותה תשובה
671
+ if (!this.availabilityInflight || force) {
672
+ this.availabilityInflight = this.fetchAvailability().finally(() => {
673
+ this.availabilityInflight = null;
674
+ });
675
+ }
676
+ return this.availabilityInflight;
677
+ }
678
+
679
+ private availabilityInflight: Promise<void> | null = null;
680
+
681
+ private async fetchAvailability(): Promise<void> {
682
+ // עד שהתשובה מגיעה נשארת ההצעה הקודמת, כדי שהכפתור לא יהבהב
683
+ if (!this.snap.liveOffer) this.update({ liveOffer: { checking: true, open: false, reason: null, departments: [], nextOpenAt: null } });
684
+ const r = await liveApi.availability(this.liveCfg);
685
+ if (this.disposed) return;
686
+ if (!r.ok) {
687
+ // לא ידוע: מציגים רק את הטופס, בלי הסבר
688
+ this.update({ liveOffer: { checking: false, open: false, reason: null, departments: [], nextOpenAt: null } });
689
+ return;
690
+ }
691
+ this.availability = { at: this.now(), value: r.data };
692
+ this.update({ liveOffer: offerFrom(r.data) });
693
+ }
694
+
695
+ /**
696
+ * לחיצה על "שיחה עם נציג" (הכפתור שלפני "פנייה לצוות", או openLive מהאפליקציה):
697
+ * בודקים זמינות שוב. יש נציג: טופס הבקשה (view liveForm). אין: טופס הפנייה עם שורת הסבר
698
+ */
699
+ async openLive(effect?: ContactEffect) {
700
+ const state = this.snap.conversation;
701
+ const e: ContactEffect = effect ?? {
702
+ type: 'live',
703
+ contactTopicId: null,
704
+ scriptId: state?.lastScriptId ?? null,
705
+ prefill: '',
706
+ transcript: state ? transcriptOf(state) : [],
707
+ };
708
+ if (this.liveSession && !this.liveSession.isFinal()) {
709
+ this.update({ view: 'live' });
710
+ return;
711
+ }
712
+ await this.checkLiveAvailability();
713
+ if (this.disposed) return;
714
+ const open = !!this.snap.liveOffer?.open;
715
+ this.update({ view: open ? 'liveForm' : 'form', contact: e, ticketNumber: null, contactPrefill: null });
716
+ }
717
+
718
+ /**
719
+ * שליחת טופס הבקשה לנציג. אורח חייב להשאיר מייל או טלפון, ההודעה אופציונלית.
720
+ * מחזיר הודעת שגיאה בשפת הממשק, או null. כשאין מי שיענה: טופס הפנייה עם מה שנכתב ושורת הסבר (liveOffer.reason)
721
+ */
722
+ async requestLive(fields: LiveRequestFields = {}): Promise<string | null> {
723
+ const t = this.snap.texts;
724
+ if (this.liveSession && !this.liveSession.isFinal()) {
725
+ this.update({ view: 'live' });
726
+ return null;
727
+ }
728
+ const trim = (v?: string) => (v ?? '').trim() || undefined;
729
+ const email = trim(fields.email);
730
+ const phone = trim(fields.phone);
731
+ const message = trim(fields.message);
732
+ const identified = !!this.opts.userToken;
733
+ if (!email && !phone && !identified) return t['form.contactRequired'];
734
+ if (email && !EMAIL_RE.test(email)) return t['form.emailInvalid'];
735
+ if (message && message.length > 2000) return this.snap.texts['live.tooLong'];
736
+ this.liveRequestId ??= randomId(22);
737
+ if (message) this.liveMessageClientId ??= randomId(22);
738
+ const effect = this.snap.contact;
739
+ const bot = this.snap.conversation;
740
+ const transcript = (effect?.transcript ?? (bot ? transcriptOf(bot) : []))
741
+ .slice(-200)
742
+ .map((x) => ({ from: x.from, text: String(x.text).slice(0, 1000), at: x.at }));
743
+ const departments = this.snap.liveOffer?.departments ?? [];
744
+ const body: LiveRequestBody = {
745
+ requestId: this.liveRequestId,
746
+ sessionId: this.session?.id,
747
+ identityToken: this.opts.userToken || undefined,
748
+ name: trim(fields.name),
749
+ email,
750
+ phone,
751
+ // ברירת מחדל: המחלקה הפתוחה הראשונה
752
+ departmentId: trim(fields.departmentId) ?? departments[0]?.id,
753
+ topicId: trim(fields.topicId) ?? (effect?.contactTopicId || undefined),
754
+ message,
755
+ messageClientId: message ? this.liveMessageClientId ?? undefined : undefined,
756
+ page: this.opts.livePage || undefined,
757
+ transcript,
758
+ };
759
+ const r = await requestHandoff(this.liveCfg, body, this.opts.liveTiming, this.opts.random);
760
+ if (this.disposed) return null;
761
+ if (!r.ok) {
762
+ if (r.code === 'ticket_contact_required') return t['form.contactRequired'];
763
+ if (r.code === 'email_invalid') return t['form.emailInvalid'];
764
+ if (r.code === 'live_message_too_long') return this.snap.texts['live.tooLong'];
765
+ if (r.code === 'live_conversation_open') return t['live.elsewhere'];
766
+ if (r.code === 'network') return t['form.error'];
767
+ return r.message || t['form.error'];
768
+ }
769
+ this.liveRequestId = null;
770
+ this.liveMessageClientId = null;
771
+ if (!r.data.accepted) {
772
+ // אין מי שיענה עכשיו: טופס הפנייה הרגיל עם מה שכבר נכתב ושורת הסבר
773
+ const cached = this.availability?.value;
774
+ this.availability = null;
775
+ this.update({
776
+ view: 'form',
777
+ contact: effect ? { ...effect, type: 'contact' } : effect,
778
+ contactPrefill: { name: trim(fields.name), email, phone, message },
779
+ liveOffer: {
780
+ checking: false,
781
+ open: false,
782
+ reason: r.data.reason,
783
+ departments: [],
784
+ nextOpenAt: r.data.reason === 'closed' ? earliest(cached?.departments) : null,
785
+ },
786
+ });
787
+ return null;
788
+ }
789
+ void this.flush();
790
+ const s = await LiveSession.start(this.liveOptions(), r.data);
791
+ if (this.disposed) {
792
+ s.dispose();
793
+ return null;
794
+ }
795
+ this.liveSession?.dispose();
796
+ this.liveSession = s;
797
+ this.liveTried = true;
798
+ if (!this.foreground) s.pause();
799
+ this.livePhase = null;
800
+ this.update({ view: 'live' });
801
+ this.onLive(s.getSnapshot());
802
+ return null;
803
+ }
804
+
805
+ /** מחזיר הודעת שגיאה בשפת הממשק (ואז הטקסט נשאר בשדה), או null */
806
+ liveSend(text: string): string | null {
807
+ const s = this.liveSession;
808
+ if (!s) return null;
809
+ const code = s.send(text);
810
+ if (code === 'live_message_too_long') return this.snap.texts['live.tooLong'];
811
+ return null;
812
+ }
813
+
814
+ liveRetry(clientId: string) {
815
+ this.liveSession?.retry(clientId);
816
+ }
817
+
818
+ /** נקרא בכל שינוי בשדה הטקסט של הצ'אט עם הנציג */
819
+ liveTyping(text: string) {
820
+ this.liveSession?.typing(text);
821
+ }
822
+
823
+ liveLeaveMessage(text?: string): Promise<boolean> {
824
+ return this.liveSession ? this.liveSession.leaveMessage(text) : Promise.resolve(false);
825
+ }
826
+
827
+ liveEnd(): Promise<boolean> {
828
+ return this.liveSession ? this.liveSession.endChat() : Promise.resolve(false);
829
+ }
830
+
831
+ /** "חזרה לעוזר" אחרי שהצ'אט עם הנציג הסתיים. שיחה פתוחה לא נסגרת מכאן */
832
+ closeLive(): boolean {
833
+ const s = this.liveSession;
834
+ if (s && !s.isFinal()) return false;
835
+ s?.dispose();
836
+ this.liveSession = null;
837
+ this.livePhase = null;
838
+ this.update({ live: null, view: 'chat', contact: null, liveOffer: null });
839
+ return true;
840
+ }
841
+
842
+ /** האפליקציה ברקע: הזרם נסגר. בחזרה: השלמה לפי seq וזרם חדש. אין התראות push בגרסה הזו */
843
+ setForeground(active: boolean) {
844
+ if (this.foreground === active) return;
845
+ this.foreground = active;
846
+ const s = this.liveSession;
847
+ if (!s) return;
848
+ if (active) void s.resume();
849
+ else s.pause();
850
+ }
851
+
489
852
  dispose() {
490
853
  if (this.disposed) return;
491
854
  void this.flush();
855
+ this.liveSession?.dispose();
856
+ this.liveSession = null;
492
857
  this.disposed = true;
493
858
  this.listeners.clear();
494
859
  }
package/src/index.ts CHANGED
@@ -5,7 +5,10 @@ export type { KavchatChatProps } from './ui/KavchatChat';
5
5
  export { KavchatLauncher } from './ui/KavchatLauncher';
6
6
  export type { KavchatLauncherProps } from './ui/KavchatLauncher';
7
7
  export { KavchatController, DEFAULT_API, BRANDING_URL } from './controller';
8
- export type { ControllerOptions, ContactFields, KavchatEvent, KavchatSnapshot, KavchatStatus, KavchatView } from './controller';
8
+ export type { ControllerOptions, ContactFields, KavchatEvent, KavchatSnapshot, KavchatStatus, KavchatView, LiveOffer, LiveRequestFields } from './controller';
9
+ export type { LiveConversation, LiveEvent, LiveState, LiveUnavailableReason } from './live/api';
10
+ export type { LiveConnection, LivePending, LivePhase, LiveSnapshot } from './live/session';
11
+ export type { StreamOpener } from './live/sse';
9
12
  export { memoryStorage, defaultStorage } from './storage';
10
13
  export type { StorageAdapter } from './storage';
11
14
  export { themeFor } from './theme';
@@ -0,0 +1,109 @@
1
+ import type { FetchLike } from '../api';
2
+
3
+ /**
4
+ * Client API של צ'אט עם נציג (/v1/client/{publicKey}/live), לפי docs/live-chat-protocol.md.
5
+ * אפליקציה לא צריכה להתחשב ב-CORS, ולכן הגוף נשלח כ-application/json והשפה ב-Accept-Language.
6
+ * הקרדנשל (lvc_...) עובר רק בגוף, אף פעם לא בכתובת, ולא נכתב ללוג
7
+ */
8
+
9
+ // הטיפוסים והמכונה הטהורה משותפים לכל הלקוחות ב-@kavchat/core (src/live.ts). כאן רק הרשת
10
+ import type { LiveAvailability, LiveConversation, LiveEvent, LiveUnavailableReason } from '@kavchat/core';
11
+ export type { LiveAvailability, LiveChatRelease, LiveConversation, LiveEvent, LiveState, LiveUnavailableReason } from '@kavchat/core';
12
+
13
+ export type LiveDepartmentAvailability = LiveAvailability['departments'][number];
14
+
15
+ export interface LiveSync {
16
+ conversation: LiveConversation;
17
+ events: LiveEvent[];
18
+ hasMore: boolean;
19
+ }
20
+
21
+ export interface LiveAccepted {
22
+ accepted: true;
23
+ conversation: LiveConversation;
24
+ credential: string;
25
+ credentialExpiresAt: string;
26
+ waitSeconds: number;
27
+ }
28
+
29
+ export interface LiveUnavailable {
30
+ accepted: false;
31
+ reason: LiveUnavailableReason;
32
+ fallback: 'ticket';
33
+ }
34
+
35
+ export interface LiveRequestBody {
36
+ requestId: string;
37
+ credential?: string;
38
+ identityToken?: string;
39
+ departmentId?: string;
40
+ sessionId?: string;
41
+ name?: string;
42
+ email?: string;
43
+ phone?: string;
44
+ topicId?: string;
45
+ message?: string;
46
+ messageClientId?: string;
47
+ page?: string;
48
+ transcript?: Array<{ from: 'bot' | 'user'; text: string; at: string }>;
49
+ }
50
+
51
+ /** תוצאה של קריאה: הצלחה עם data, או קוד שגיאה. status 0 = אין רשת */
52
+ export type LiveResult<T> = { ok: true; status: number; data: T } | { ok: false; status: number; code: string; message: string };
53
+
54
+ export interface LiveApiConfig {
55
+ apiBase: string;
56
+ publicKey: string;
57
+ getToken: () => string | undefined;
58
+ getLocale: () => string;
59
+ fetch?: FetchLike;
60
+ }
61
+
62
+ export const liveUrl = (c: LiveApiConfig, path: string) =>
63
+ `${c.apiBase.replace(/\/+$/, '')}/v1/client/${encodeURIComponent(c.publicKey)}/live/${path}`;
64
+
65
+ const doFetch = (c: LiveApiConfig): FetchLike => c.fetch ?? (globalThis.fetch as unknown as FetchLike);
66
+
67
+ /** האם כדאי לנסות שוב: אין רשת, 5xx או 429. שאר ה-4xx סופיים */
68
+ export const retryable = (r: { ok: boolean; status: number }) => !r.ok && (r.status === 0 || r.status === 429 || r.status >= 500);
69
+
70
+ async function call<T>(c: LiveApiConfig, method: 'GET' | 'POST', path: string, body?: unknown, auth = false): Promise<LiveResult<T>> {
71
+ const headers: Record<string, string> = { 'Accept-Language': c.getLocale() };
72
+ if (method === 'POST') headers['Content-Type'] = 'application/json';
73
+ const token = auth ? c.getToken() : undefined;
74
+ if (token) headers.Authorization = `Bearer ${token}`;
75
+ let res: Awaited<ReturnType<FetchLike>>;
76
+ try {
77
+ res = await doFetch(c)(liveUrl(c, path), {
78
+ method,
79
+ cache: 'no-store',
80
+ credentials: 'omit',
81
+ headers,
82
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
83
+ });
84
+ } catch {
85
+ return { ok: false, status: 0, code: 'network', message: '' };
86
+ }
87
+ const json = (await res.json().catch(() => null)) as { success?: boolean; data?: T; code?: string; message?: string } | null;
88
+ if (res.ok && json && json.success === true && json.data !== undefined) return { ok: true, status: res.status, data: json.data };
89
+ return { ok: false, status: res.ok ? 502 : res.status, code: String(json?.code || `http_${res.status}`), message: String(json?.message || '') };
90
+ }
91
+
92
+ export const liveApi = {
93
+ availability: (c: LiveApiConfig, departmentId?: string) =>
94
+ call<LiveAvailability>(c, 'GET', departmentId ? `availability?departmentId=${encodeURIComponent(departmentId)}` : 'availability', undefined, true),
95
+ request: (c: LiveApiConfig, body: LiveRequestBody) => call<LiveAccepted | LiveUnavailable>(c, 'POST', 'requests', body, true),
96
+ message: (c: LiveApiConfig, credential: string, clientId: string, text: string) =>
97
+ call<{ event: LiveEvent; duplicate?: boolean }>(c, 'POST', 'messages', { credential, clientId, text }),
98
+ typing: (c: LiveApiConfig, credential: string, on: boolean) => call<{ ok: true }>(c, 'POST', 'typing', { credential, on }),
99
+ sync: (c: LiveApiConfig, credential: string, after: number, wait = 0) =>
100
+ call<LiveSync>(c, 'POST', 'sync', wait > 0 ? { credential, after, wait } : { credential, after }),
101
+ streamTicket: (c: LiveApiConfig, credential: string) => call<{ ticket: string; expiresIn: number }>(c, 'POST', 'stream-ticket', { credential }),
102
+ leaveMessage: (c: LiveApiConfig, credential: string, text?: string, clientId?: string) =>
103
+ call<{ conversation: LiveConversation }>(c, 'POST', 'leave-message', text ? { credential, text, clientId } : { credential }),
104
+ end: (c: LiveApiConfig, credential: string) => call<{ conversation: LiveConversation }>(c, 'POST', 'end', { credential }),
105
+ };
106
+
107
+ /** כתובת הזרם. מכילה כרטיס חד-פעמי: לא לכתוב אותה ללוג */
108
+ export const streamUrl = (c: LiveApiConfig, ticket: string, after: number) =>
109
+ `${liveUrl(c, 'stream')}?ticket=${encodeURIComponent(ticket)}&after=${Math.max(0, Math.floor(after))}`;