@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/README.md +38 -0
- package/lib/controller.d.ts +101 -2
- package/lib/controller.js +333 -4
- package/lib/index.d.ts +4 -1
- package/lib/live/api.d.ts +93 -0
- package/lib/live/api.js +41 -0
- package/lib/live/session.d.ts +183 -0
- package/lib/live/session.js +702 -0
- package/lib/live/sse.d.ts +56 -0
- package/lib/live/sse.js +173 -0
- package/lib/live/texts.d.ts +8 -0
- package/lib/live/texts.js +25 -0
- package/lib/ui/KavchatChat.js +20 -8
- package/lib/ui/KavchatProvider.d.ts +11 -0
- package/lib/ui/KavchatProvider.js +16 -2
- package/lib/ui/LiveChatView.d.ts +8 -0
- package/lib/ui/LiveChatView.js +146 -0
- package/package.json +2 -2
- package/src/controller.ts +371 -6
- package/src/index.ts +4 -1
- package/src/live/api.ts +109 -0
- package/src/live/session.ts +784 -0
- package/src/live/sse.ts +196 -0
- package/src/live/texts.ts +25 -0
- package/src/ui/KavchatChat.tsx +37 -8
- package/src/ui/KavchatProvider.tsx +21 -2
- package/src/ui/LiveChatView.tsx +308 -0
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
|
-
|
|
45
|
+
/** טופס פנייה (contact) או טופס הבקשה לנציג (live) */
|
|
46
|
+
export type ContactEffect = Extract<BotEffect, { type: 'contact' | 'live' }>;
|
|
41
47
|
export type KavchatStatus = 'loading' | 'ready' | 'unavailable';
|
|
42
|
-
|
|
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
|
-
|
|
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 === '
|
|
406
|
-
this.
|
|
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';
|
package/src/live/api.ts
ADDED
|
@@ -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))}`;
|