@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/README.md
CHANGED
|
@@ -147,6 +147,44 @@ If your app is Hebrew or Arabic, set RTL once in your app as usual (`I18nManager
|
|
|
147
147
|
|
|
148
148
|
The free plan shows "Powered by Kavchat" under the chat (linking to kavchat.com); paid plans hide it automatically.
|
|
149
149
|
|
|
150
|
+
## Live chat with a representative
|
|
151
|
+
|
|
152
|
+
Nothing to set up for the basic flow. When live chat is on in the dashboard (`liveChat.enabled` in the release), the chat
|
|
153
|
+
behaves like the website widget (contract: `docs/live-chat-protocol.md`):
|
|
154
|
+
|
|
155
|
+
- **Option:** "Talk to a representative" is added before "Talk to our team" only after `GET /live/availability` says open
|
|
156
|
+
(cached 30 s, checked again on tap). Otherwise only the contact form, with a line for `closed` (and when the team is back)
|
|
157
|
+
or `no_agents`; `quota` and `disabled` show the form without explanation.
|
|
158
|
+
- **Request form** (`view: 'liveForm'`): guests leave an email or a phone, name and first message optional, department choice when
|
|
159
|
+
more than one is open; identified users (`userToken`) skip contact details. Not accepted: the contact form opens with what was typed.
|
|
160
|
+
- **Waiting / chat** (`view: 'live'`): countdown to `queueDeadline`, "Leave a message instead", "End chat", agent name, typing
|
|
161
|
+
(sent at most every 3 s), transfers, plain text only. When the wait ends, the chat becomes a request (a sync after the deadline
|
|
162
|
+
catches it even if the stream missed it).
|
|
163
|
+
- **Transport:** SSE with a single-use stream ticket, read through `XMLHttpRequest` progress events (or `fetch` with
|
|
164
|
+
`body.getReader()`), long-poll fallback (`POST /live/sync` with `wait`) when no streaming transport exists, after `live_busy`,
|
|
165
|
+
or after 3 failed streams. Reconnect with 1-30 s backoff and +-30% jitter, then sync after `lastSeq`. Order and dedupe by `seq`
|
|
166
|
+
(the shared reducer in `@kavchat/core`), client message ids for idempotent retries, pending and failed bubbles (tap to retry).
|
|
167
|
+
- **Lifecycle:** the stream closes when `AppState` is `background` and catches up on `active`. The credential (`lvc_...`, up to
|
|
168
|
+
12 hours) is kept in `secureStorage` (default: `storage`) and restores an ongoing chat after an app restart. It is never put in a
|
|
169
|
+
URL or a log. Switching user drops it.
|
|
170
|
+
- **No push notifications in v1:** messages arrive only while the app is open.
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import * as SecureStore from 'expo-secure-store';
|
|
174
|
+
const safe = (k: string) => k.replace(/[^A-Za-z0-9._-]/g, '_'); // SecureStore key charset
|
|
175
|
+
const secureStorage = {
|
|
176
|
+
getItem: (k: string) => SecureStore.getItemAsync(safe(k)),
|
|
177
|
+
setItem: (k: string, v: string) => SecureStore.setItemAsync(safe(k), v),
|
|
178
|
+
removeItem: (k: string) => SecureStore.deleteItemAsync(safe(k)),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
<KavchatProvider publicKey="pk_live_..." secureStorage={secureStorage} livePage="checkout" onEvent={(e) => e.type === 'live' && console.log(e.phase)}>
|
|
182
|
+
|
|
183
|
+
const { openLive, livePhase } = useKavchat(); // your own "chat with us" button
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
UI texts are the `live.*` keys of `@kavchat/core` (11 languages), the same as the website widget.
|
|
187
|
+
|
|
150
188
|
## AI answers (Premium)
|
|
151
189
|
|
|
152
190
|
Nothing to configure in the app. When the workspace owner turns on AI answers in the dashboard (Premium, with the
|
package/lib/controller.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import type { BotChip, BotEffect, BotState, UiTexts } from '@kavchat/core';
|
|
|
2
2
|
import { EnabledRelease, FetchLike } from './api';
|
|
3
3
|
import { KavchatScreen } from './screens';
|
|
4
4
|
import { StorageAdapter } from './storage';
|
|
5
|
+
import { LiveDepartmentAvailability, LiveUnavailableReason } from './live/api';
|
|
6
|
+
import { LivePhase, LiveSnapshot, LiveTiming } from './live/session';
|
|
7
|
+
import type { StreamOpener } from './live/sse';
|
|
5
8
|
/**
|
|
6
9
|
* הבקר: טעינת הגרסה עם ETag ומטמון, שמירת השיחה, דיווח לשרת ופניות
|
|
7
10
|
* קוד TypeScript רגיל בלי React Native, כדי שאפשר יהיה לבדוק אותו ב-Node.
|
|
@@ -9,11 +12,13 @@ import { StorageAdapter } from './storage';
|
|
|
9
12
|
*/
|
|
10
13
|
export declare const DEFAULT_API = "https://api.kavchat.com";
|
|
11
14
|
export declare const BRANDING_URL = "https://kavchat.com";
|
|
15
|
+
/** טופס פנייה (contact) או טופס הבקשה לנציג (live) */
|
|
12
16
|
export type ContactEffect = Extract<BotEffect, {
|
|
13
|
-
type: 'contact';
|
|
17
|
+
type: 'contact' | 'live';
|
|
14
18
|
}>;
|
|
15
19
|
export type KavchatStatus = 'loading' | 'ready' | 'unavailable';
|
|
16
|
-
|
|
20
|
+
/** live: צ'אט עם נציג (המתנה, שיחה, או המצב הסופי) */
|
|
21
|
+
export type KavchatView = 'chat' | 'form' | 'sent' | 'live' | 'liveForm';
|
|
17
22
|
export type KavchatEvent = {
|
|
18
23
|
type: 'ready';
|
|
19
24
|
version: number;
|
|
@@ -28,7 +33,25 @@ export type KavchatEvent = {
|
|
|
28
33
|
type: 'ticket';
|
|
29
34
|
id: string;
|
|
30
35
|
number: number;
|
|
36
|
+
}
|
|
37
|
+
/** צ'אט עם נציג: queued, active, closed, ticket, replaced או expired */
|
|
38
|
+
| {
|
|
39
|
+
type: 'live';
|
|
40
|
+
phase: LivePhase;
|
|
31
41
|
};
|
|
42
|
+
/** האם להציע "צ'אט עם נציג" בטופס הפנייה, ואם לא, למה */
|
|
43
|
+
export interface LiveOffer {
|
|
44
|
+
/** בודקים עכשיו את /live/availability */
|
|
45
|
+
checking: boolean;
|
|
46
|
+
open: boolean;
|
|
47
|
+
/** closed ו-no_agents מוצגים כשורת הסבר. quota ו-disabled: רק הטופס, בלי הסבר */
|
|
48
|
+
reason: LiveUnavailableReason | null;
|
|
49
|
+
/** המחלקות הפתוחות. יותר מאחת: המשתמש בוחר */
|
|
50
|
+
departments: LiveDepartmentAvailability[];
|
|
51
|
+
/** כש-reason הוא closed: מתי הצוות זמין שוב */
|
|
52
|
+
nextOpenAt: string | null;
|
|
53
|
+
}
|
|
54
|
+
export declare const AVAILABILITY_CACHE_MS = 30000;
|
|
32
55
|
export interface KavchatSnapshot {
|
|
33
56
|
status: KavchatStatus;
|
|
34
57
|
release: EnabledRelease | null;
|
|
@@ -48,6 +71,18 @@ export interface KavchatSnapshot {
|
|
|
48
71
|
reason?: string;
|
|
49
72
|
identified: boolean;
|
|
50
73
|
userName?: string;
|
|
74
|
+
/** liveChat.enabled בגרסה: רק אז מציעים צ'אט עם נציג */
|
|
75
|
+
liveEnabled: boolean;
|
|
76
|
+
liveOffer: LiveOffer | null;
|
|
77
|
+
/** הצ'אט עם הנציג, אם התחיל */
|
|
78
|
+
live: LiveSnapshot | null;
|
|
79
|
+
/** מה שהמשתמש כבר כתב בטופס הבקשה לנציג, כשאין מי שיענה וחוזרים לטופס הפנייה */
|
|
80
|
+
contactPrefill: {
|
|
81
|
+
name?: string;
|
|
82
|
+
email?: string;
|
|
83
|
+
phone?: string;
|
|
84
|
+
message?: string;
|
|
85
|
+
} | null;
|
|
51
86
|
}
|
|
52
87
|
export interface ControllerOptions {
|
|
53
88
|
publicKey: string;
|
|
@@ -69,6 +104,21 @@ export interface ControllerOptions {
|
|
|
69
104
|
reportDelayMs?: number;
|
|
70
105
|
sessionMaxAgeMs?: number;
|
|
71
106
|
now?: () => number;
|
|
107
|
+
/**
|
|
108
|
+
* אחסון לקרדנשל של הצ'אט עם נציג (lvc_...). מומלץ אחסון מאובטח, למשל expo-secure-store.
|
|
109
|
+
* ברירת מחדל: storage
|
|
110
|
+
*/
|
|
111
|
+
secureStorage?: StorageAdapter;
|
|
112
|
+
/** שם המסך שנשלח עם הבקשה לנציג (page), למשל 'checkout' */
|
|
113
|
+
livePage?: string;
|
|
114
|
+
/** להחלפה בבדיקות */
|
|
115
|
+
liveTiming?: Partial<LiveTiming>;
|
|
116
|
+
/** להחלפה בבדיקות. null: רק long-poll */
|
|
117
|
+
streamOpener?: StreamOpener | null;
|
|
118
|
+
random?: () => number;
|
|
119
|
+
}
|
|
120
|
+
export interface LiveRequestFields extends Partial<ContactFields> {
|
|
121
|
+
departmentId?: string;
|
|
72
122
|
}
|
|
73
123
|
export interface ContactFields {
|
|
74
124
|
name?: string;
|
|
@@ -81,6 +131,16 @@ export declare class KavchatController {
|
|
|
81
131
|
private opts;
|
|
82
132
|
private api;
|
|
83
133
|
private store;
|
|
134
|
+
private secure;
|
|
135
|
+
private liveCfg;
|
|
136
|
+
private liveSession;
|
|
137
|
+
private liveTried;
|
|
138
|
+
private livePhase;
|
|
139
|
+
/** נוצרים פעם אחת לכל ניסיון של המשתמש, ומשמשים לכל ניסיון חוזר */
|
|
140
|
+
private liveRequestId;
|
|
141
|
+
private liveMessageClientId;
|
|
142
|
+
private availability;
|
|
143
|
+
private foreground;
|
|
84
144
|
private snap;
|
|
85
145
|
private listeners;
|
|
86
146
|
private session;
|
|
@@ -97,6 +157,8 @@ export declare class KavchatController {
|
|
|
97
157
|
ready: Promise<void>;
|
|
98
158
|
/** מתממש כשניסיון רישום המסכים האחרון הסתיים. בשביל בדיקות */
|
|
99
159
|
screensSync: Promise<void>;
|
|
160
|
+
/** מתממש כשניסיון שחזור הצ'אט עם הנציג הסתיים. בשביל בדיקות */
|
|
161
|
+
liveRestore: Promise<void>;
|
|
100
162
|
constructor(options: ControllerOptions);
|
|
101
163
|
subscribe: (listener: () => void) => () => void;
|
|
102
164
|
getSnapshot: () => KavchatSnapshot;
|
|
@@ -130,6 +192,11 @@ export declare class KavchatController {
|
|
|
130
192
|
*/
|
|
131
193
|
private run;
|
|
132
194
|
private setConversation;
|
|
195
|
+
/**
|
|
196
|
+
* הכפתורים להצגה: כמו במנוע, ועם "שיחה עם נציג" לפני "פנייה לצוות" כשיש נציג זמין עכשיו
|
|
197
|
+
* (liveChat.enabled בגרסה, ו-/live/availability אמר open)
|
|
198
|
+
*/
|
|
199
|
+
chips(): BotChip[];
|
|
133
200
|
private afterAction;
|
|
134
201
|
private handleEffect;
|
|
135
202
|
/** פתיחת הטופס מכפתור מחוץ לתסריט */
|
|
@@ -140,6 +207,38 @@ export declare class KavchatController {
|
|
|
140
207
|
private scheduleReport;
|
|
141
208
|
/** שולח את המסלול עכשיו. קוראים לזה גם כשהאפליקציה עוברת לרקע */
|
|
142
209
|
flush(): Promise<boolean>;
|
|
210
|
+
private liveOptions;
|
|
211
|
+
private onLive;
|
|
212
|
+
/** אחרי הפעלה מחדש: חוזרים לשיחה שעוד פתוחה, אם הקרדנשל בתוקף */
|
|
213
|
+
private restoreLive;
|
|
214
|
+
/**
|
|
215
|
+
* בודק אם אפשר להציע צ'אט עם נציג. נקרא כשטופס הפנייה נפתח. התשובה נשמרת 30 שניות.
|
|
216
|
+
* בלי liveChat.enabled בגרסה לא שואלים בכלל
|
|
217
|
+
*/
|
|
218
|
+
checkLiveAvailability(force?: boolean): Promise<void>;
|
|
219
|
+
private availabilityInflight;
|
|
220
|
+
private fetchAvailability;
|
|
221
|
+
/**
|
|
222
|
+
* לחיצה על "שיחה עם נציג" (הכפתור שלפני "פנייה לצוות", או openLive מהאפליקציה):
|
|
223
|
+
* בודקים זמינות שוב. יש נציג: טופס הבקשה (view liveForm). אין: טופס הפנייה עם שורת הסבר
|
|
224
|
+
*/
|
|
225
|
+
openLive(effect?: ContactEffect): Promise<void>;
|
|
226
|
+
/**
|
|
227
|
+
* שליחת טופס הבקשה לנציג. אורח חייב להשאיר מייל או טלפון, ההודעה אופציונלית.
|
|
228
|
+
* מחזיר הודעת שגיאה בשפת הממשק, או null. כשאין מי שיענה: טופס הפנייה עם מה שנכתב ושורת הסבר (liveOffer.reason)
|
|
229
|
+
*/
|
|
230
|
+
requestLive(fields?: LiveRequestFields): Promise<string | null>;
|
|
231
|
+
/** מחזיר הודעת שגיאה בשפת הממשק (ואז הטקסט נשאר בשדה), או null */
|
|
232
|
+
liveSend(text: string): string | null;
|
|
233
|
+
liveRetry(clientId: string): void;
|
|
234
|
+
/** נקרא בכל שינוי בשדה הטקסט של הצ'אט עם הנציג */
|
|
235
|
+
liveTyping(text: string): void;
|
|
236
|
+
liveLeaveMessage(text?: string): Promise<boolean>;
|
|
237
|
+
liveEnd(): Promise<boolean>;
|
|
238
|
+
/** "חזרה לעוזר" אחרי שהצ'אט עם הנציג הסתיים. שיחה פתוחה לא נסגרת מכאן */
|
|
239
|
+
closeLive(): boolean;
|
|
240
|
+
/** האפליקציה ברקע: הזרם נסגר. בחזרה: השלמה לפי seq וזרם חדש. אין התראות push בגרסה הזו */
|
|
241
|
+
setForeground(active: boolean): void;
|
|
143
242
|
dispose(): void;
|
|
144
243
|
private now;
|
|
145
244
|
private emit;
|
package/lib/controller.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { aiQuestion, answerWithAi, configureEngine, eventsPayload, isRtl, pendingQuestion, pressChip, sendText, startConversation, transcriptOf, uiTexts, } from '@kavchat/core';
|
|
1
|
+
import { aiQuestion, answerWithAi, configureEngine, eventsPayload, isRtl, pendingQuestion, pressChip, sendText, startConversation, transcriptOf, uiTexts, withLiveChip, LIVE_AVAILABILITY_TTL_MS, } from '@kavchat/core';
|
|
2
2
|
import { fetchRelease, postAiAnswer, postEvents, postScreens, postTicket } from './api';
|
|
3
3
|
import { SCREENS_MAX_AGE_MS, normalizeScreens, screensFingerprint, screensKey } from './screens';
|
|
4
4
|
import { memoryStorage, randomId, readJson, releaseKey, sessionKey, userFingerprint, writeJson, } from './storage';
|
|
5
5
|
import { pickLocale, safeUrl, ticketError, validateTicket } from './text';
|
|
6
|
+
import { liveApi } from './live/api';
|
|
7
|
+
import { LiveSession, liveKey, requestHandoff } from './live/session';
|
|
6
8
|
/**
|
|
7
9
|
* הבקר: טעינת הגרסה עם ETag ומטמון, שמירת השיחה, דיווח לשרת ופניות
|
|
8
10
|
* קוד TypeScript רגיל בלי React Native, כדי שאפשר יהיה לבדוק אותו ב-Node.
|
|
@@ -10,8 +12,41 @@ import { pickLocale, safeUrl, ticketError, validateTicket } from './text';
|
|
|
10
12
|
*/
|
|
11
13
|
export const DEFAULT_API = 'https://api.kavchat.com';
|
|
12
14
|
export const BRANDING_URL = 'https://kavchat.com';
|
|
15
|
+
export const AVAILABILITY_CACHE_MS = LIVE_AVAILABILITY_TTL_MS;
|
|
16
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
17
|
+
function liveConfig(release) {
|
|
18
|
+
const lc = release ? release.liveChat : undefined;
|
|
19
|
+
return lc && typeof lc === 'object' && lc.enabled === true ? lc : null;
|
|
20
|
+
}
|
|
21
|
+
function offerFrom(a) {
|
|
22
|
+
const open = a.enabled === true && a.open === true;
|
|
23
|
+
const reason = open ? null : a.reason ?? null;
|
|
24
|
+
return {
|
|
25
|
+
checking: false,
|
|
26
|
+
open,
|
|
27
|
+
reason,
|
|
28
|
+
departments: open ? (a.departments ?? []).filter((d) => d.open) : [],
|
|
29
|
+
nextOpenAt: reason === 'closed' ? earliest(a.departments) : null,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function earliest(deps) {
|
|
33
|
+
let best = null;
|
|
34
|
+
for (const d of deps ?? []) {
|
|
35
|
+
if (d.nextOpenAt && (!best || Date.parse(d.nextOpenAt) < Date.parse(best)))
|
|
36
|
+
best = d.nextOpenAt;
|
|
37
|
+
}
|
|
38
|
+
return best;
|
|
39
|
+
}
|
|
13
40
|
export class KavchatController {
|
|
14
41
|
constructor(options) {
|
|
42
|
+
this.liveSession = null;
|
|
43
|
+
this.liveTried = false;
|
|
44
|
+
this.livePhase = null;
|
|
45
|
+
/** נוצרים פעם אחת לכל ניסיון של המשתמש, ומשמשים לכל ניסיון חוזר */
|
|
46
|
+
this.liveRequestId = null;
|
|
47
|
+
this.liveMessageClientId = null;
|
|
48
|
+
this.availability = null;
|
|
49
|
+
this.foreground = true;
|
|
15
50
|
this.listeners = new Set();
|
|
16
51
|
this.session = null;
|
|
17
52
|
this.loadSeq = 0;
|
|
@@ -27,6 +62,8 @@ export class KavchatController {
|
|
|
27
62
|
this.ready = Promise.resolve();
|
|
28
63
|
/** מתממש כשניסיון רישום המסכים האחרון הסתיים. בשביל בדיקות */
|
|
29
64
|
this.screensSync = Promise.resolve();
|
|
65
|
+
/** מתממש כשניסיון שחזור הצ'אט עם הנציג הסתיים. בשביל בדיקות */
|
|
66
|
+
this.liveRestore = Promise.resolve();
|
|
30
67
|
// ---------- מנוי, בשביל useSyncExternalStore ----------
|
|
31
68
|
this.subscribe = (listener) => {
|
|
32
69
|
this.listeners.add(listener);
|
|
@@ -35,6 +72,7 @@ export class KavchatController {
|
|
|
35
72
|
};
|
|
36
73
|
};
|
|
37
74
|
this.getSnapshot = () => this.snap;
|
|
75
|
+
this.availabilityInflight = null;
|
|
38
76
|
if (!options || !options.publicKey)
|
|
39
77
|
throw new Error('Kavchat: publicKey is required');
|
|
40
78
|
this.opts = { ...options };
|
|
@@ -45,6 +83,8 @@ export class KavchatController {
|
|
|
45
83
|
getToken: () => this.opts.userToken || undefined,
|
|
46
84
|
fetch: options.fetch,
|
|
47
85
|
};
|
|
86
|
+
this.secure = options.secureStorage ?? this.store;
|
|
87
|
+
this.liveCfg = { ...this.api, getLocale: () => this.snap.locale };
|
|
48
88
|
this.screens = normalizeScreens(options.screens);
|
|
49
89
|
const locale = pickLocale(options.locale, options.deviceLocale, 'en');
|
|
50
90
|
this.snap = {
|
|
@@ -61,6 +101,10 @@ export class KavchatController {
|
|
|
61
101
|
ticketNumber: null,
|
|
62
102
|
identified: !!options.userToken,
|
|
63
103
|
userName: options.userName,
|
|
104
|
+
liveEnabled: false,
|
|
105
|
+
liveOffer: null,
|
|
106
|
+
live: null,
|
|
107
|
+
contactPrefill: null,
|
|
64
108
|
};
|
|
65
109
|
}
|
|
66
110
|
update(patch) {
|
|
@@ -131,7 +175,8 @@ export class KavchatController {
|
|
|
131
175
|
let view = this.snap.view;
|
|
132
176
|
if (changed || !conversation) {
|
|
133
177
|
conversation = await this.restoreSession(release, user);
|
|
134
|
-
|
|
178
|
+
// צ'אט פתוח עם נציג נשאר על המסך גם כשהגרסה מתחלפת
|
|
179
|
+
view = this.liveSession ? 'live' : 'chat';
|
|
135
180
|
}
|
|
136
181
|
this.update({
|
|
137
182
|
status: 'ready',
|
|
@@ -146,8 +191,11 @@ export class KavchatController {
|
|
|
146
191
|
reason: undefined,
|
|
147
192
|
identified: !!this.opts.userToken,
|
|
148
193
|
userName: this.opts.userName,
|
|
194
|
+
liveEnabled: !!liveConfig(release),
|
|
149
195
|
});
|
|
150
196
|
this.emit({ type: 'ready', version: release.version, offline });
|
|
197
|
+
if (!this.liveTried)
|
|
198
|
+
this.liveRestore = this.restoreLive();
|
|
151
199
|
}
|
|
152
200
|
async restoreSession(release, user) {
|
|
153
201
|
const saved = await readJson(this.store, sessionKey(this.opts.publicKey));
|
|
@@ -170,6 +218,17 @@ export class KavchatController {
|
|
|
170
218
|
if (token === this.opts.userToken && name === this.opts.userName)
|
|
171
219
|
return this.ready;
|
|
172
220
|
this.flush();
|
|
221
|
+
if (userFingerprint(token) !== userFingerprint(this.opts.userToken)) {
|
|
222
|
+
// שיחה עם נציג של משתמש אחד לא ממשיכה אצל אחר
|
|
223
|
+
this.liveSession?.forget();
|
|
224
|
+
this.liveSession = null;
|
|
225
|
+
this.liveTried = false;
|
|
226
|
+
this.livePhase = null;
|
|
227
|
+
this.liveRequestId = null;
|
|
228
|
+
this.liveMessageClientId = null;
|
|
229
|
+
this.availability = null;
|
|
230
|
+
this.update({ live: null, liveOffer: null, view: this.snap.view === 'live' ? 'chat' : this.snap.view });
|
|
231
|
+
}
|
|
173
232
|
this.opts.userToken = token;
|
|
174
233
|
this.opts.userName = name;
|
|
175
234
|
return this.load();
|
|
@@ -298,6 +357,17 @@ export class KavchatController {
|
|
|
298
357
|
void writeJson(this.store, sessionKey(this.opts.publicKey), this.session);
|
|
299
358
|
}
|
|
300
359
|
this.update({ conversation: state, ...extra });
|
|
360
|
+
// כפתור "פנייה לצוות" על המסך: בודקים (עם מטמון של 30 שניות) אם להוסיף לפניו "שיחה עם נציג"
|
|
361
|
+
if (state.chips.some((c) => c.kind === 'contact'))
|
|
362
|
+
void this.checkLiveAvailability();
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* הכפתורים להצגה: כמו במנוע, ועם "שיחה עם נציג" לפני "פנייה לצוות" כשיש נציג זמין עכשיו
|
|
366
|
+
* (liveChat.enabled בגרסה, ו-/live/availability אמר open)
|
|
367
|
+
*/
|
|
368
|
+
chips() {
|
|
369
|
+
const chips = this.snap.conversation?.chips ?? [];
|
|
370
|
+
return this.snap.liveEnabled && this.snap.liveOffer?.open ? withLiveChip(chips, this.snap.texts['live.talk']) : chips;
|
|
301
371
|
}
|
|
302
372
|
afterAction(r) {
|
|
303
373
|
this.setConversation(r.state);
|
|
@@ -307,8 +377,12 @@ export class KavchatController {
|
|
|
307
377
|
}
|
|
308
378
|
handleEffect(effect) {
|
|
309
379
|
this.emit({ type: 'effect', effect });
|
|
310
|
-
if (effect.type === '
|
|
311
|
-
this.
|
|
380
|
+
if (effect.type === 'live') {
|
|
381
|
+
void this.openLive(effect);
|
|
382
|
+
}
|
|
383
|
+
else if (effect.type === 'contact') {
|
|
384
|
+
this.update({ view: 'form', contact: effect, ticketNumber: null, contactPrefill: null });
|
|
385
|
+
void this.checkLiveAvailability();
|
|
312
386
|
}
|
|
313
387
|
else if (effect.type === 'link') {
|
|
314
388
|
// רק https ו-tel. כל פרוטוקול אחר מהתסריט נחסם
|
|
@@ -326,6 +400,7 @@ export class KavchatController {
|
|
|
326
400
|
this.update({
|
|
327
401
|
view: 'form',
|
|
328
402
|
ticketNumber: null,
|
|
403
|
+
contactPrefill: null,
|
|
329
404
|
contact: {
|
|
330
405
|
type: 'contact',
|
|
331
406
|
contactTopicId: null,
|
|
@@ -334,6 +409,7 @@ export class KavchatController {
|
|
|
334
409
|
transcript: state ? transcriptOf(state) : [],
|
|
335
410
|
},
|
|
336
411
|
});
|
|
412
|
+
void this.checkLiveAvailability();
|
|
337
413
|
}
|
|
338
414
|
backToChat() {
|
|
339
415
|
this.update({ view: 'chat', contact: null, ticketNumber: null });
|
|
@@ -393,10 +469,263 @@ export class KavchatController {
|
|
|
393
469
|
return ok;
|
|
394
470
|
});
|
|
395
471
|
}
|
|
472
|
+
// ---------- צ'אט עם נציג ----------
|
|
473
|
+
liveOptions() {
|
|
474
|
+
return {
|
|
475
|
+
api: this.liveCfg,
|
|
476
|
+
store: this.secure,
|
|
477
|
+
storageKey: liveKey(this.opts.publicKey),
|
|
478
|
+
user: userFingerprint(this.opts.userToken),
|
|
479
|
+
opener: this.opts.streamOpener,
|
|
480
|
+
timing: this.opts.liveTiming,
|
|
481
|
+
random: this.opts.random,
|
|
482
|
+
now: this.opts.now,
|
|
483
|
+
onChange: (snap) => {
|
|
484
|
+
// רק הסשן הנוכחי מעדכן את המסך
|
|
485
|
+
if (this.liveSession && this.liveSession.getSnapshot() === snap)
|
|
486
|
+
this.onLive(snap);
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
onLive(snap) {
|
|
491
|
+
this.update({ live: snap });
|
|
492
|
+
if (snap.phase !== this.livePhase) {
|
|
493
|
+
this.livePhase = snap.phase;
|
|
494
|
+
this.emit({ type: 'live', phase: snap.phase });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/** אחרי הפעלה מחדש: חוזרים לשיחה שעוד פתוחה, אם הקרדנשל בתוקף */
|
|
498
|
+
async restoreLive() {
|
|
499
|
+
if (this.liveTried || this.liveSession)
|
|
500
|
+
return;
|
|
501
|
+
this.liveTried = true;
|
|
502
|
+
const user = userFingerprint(this.opts.userToken);
|
|
503
|
+
const s = await LiveSession.restore(this.liveOptions());
|
|
504
|
+
if (!s)
|
|
505
|
+
return;
|
|
506
|
+
if (this.disposed || this.liveSession || user !== userFingerprint(this.opts.userToken)) {
|
|
507
|
+
s.dispose();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.liveSession = s;
|
|
511
|
+
if (!this.foreground)
|
|
512
|
+
s.pause();
|
|
513
|
+
this.livePhase = null;
|
|
514
|
+
this.update({ view: 'live' });
|
|
515
|
+
this.onLive(s.getSnapshot());
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* בודק אם אפשר להציע צ'אט עם נציג. נקרא כשטופס הפנייה נפתח. התשובה נשמרת 30 שניות.
|
|
519
|
+
* בלי liveChat.enabled בגרסה לא שואלים בכלל
|
|
520
|
+
*/
|
|
521
|
+
checkLiveAvailability(force = false) {
|
|
522
|
+
if (!liveConfig(this.snap.release)) {
|
|
523
|
+
if (this.snap.liveOffer)
|
|
524
|
+
this.update({ liveOffer: null });
|
|
525
|
+
return Promise.resolve();
|
|
526
|
+
}
|
|
527
|
+
const cached = this.availability;
|
|
528
|
+
if (!force && cached && this.now() - cached.at < AVAILABILITY_CACHE_MS) {
|
|
529
|
+
if (this.snap.liveOffer?.checking || !this.snap.liveOffer)
|
|
530
|
+
this.update({ liveOffer: offerFrom(cached.value) });
|
|
531
|
+
return Promise.resolve();
|
|
532
|
+
}
|
|
533
|
+
// בקשה אחת בכל פעם: הכפתורים, הטופס והלחיצה על הכפתור מחכים לאותה תשובה
|
|
534
|
+
if (!this.availabilityInflight || force) {
|
|
535
|
+
this.availabilityInflight = this.fetchAvailability().finally(() => {
|
|
536
|
+
this.availabilityInflight = null;
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return this.availabilityInflight;
|
|
540
|
+
}
|
|
541
|
+
async fetchAvailability() {
|
|
542
|
+
// עד שהתשובה מגיעה נשארת ההצעה הקודמת, כדי שהכפתור לא יהבהב
|
|
543
|
+
if (!this.snap.liveOffer)
|
|
544
|
+
this.update({ liveOffer: { checking: true, open: false, reason: null, departments: [], nextOpenAt: null } });
|
|
545
|
+
const r = await liveApi.availability(this.liveCfg);
|
|
546
|
+
if (this.disposed)
|
|
547
|
+
return;
|
|
548
|
+
if (!r.ok) {
|
|
549
|
+
// לא ידוע: מציגים רק את הטופס, בלי הסבר
|
|
550
|
+
this.update({ liveOffer: { checking: false, open: false, reason: null, departments: [], nextOpenAt: null } });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
this.availability = { at: this.now(), value: r.data };
|
|
554
|
+
this.update({ liveOffer: offerFrom(r.data) });
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* לחיצה על "שיחה עם נציג" (הכפתור שלפני "פנייה לצוות", או openLive מהאפליקציה):
|
|
558
|
+
* בודקים זמינות שוב. יש נציג: טופס הבקשה (view liveForm). אין: טופס הפנייה עם שורת הסבר
|
|
559
|
+
*/
|
|
560
|
+
async openLive(effect) {
|
|
561
|
+
const state = this.snap.conversation;
|
|
562
|
+
const e = effect ?? {
|
|
563
|
+
type: 'live',
|
|
564
|
+
contactTopicId: null,
|
|
565
|
+
scriptId: state?.lastScriptId ?? null,
|
|
566
|
+
prefill: '',
|
|
567
|
+
transcript: state ? transcriptOf(state) : [],
|
|
568
|
+
};
|
|
569
|
+
if (this.liveSession && !this.liveSession.isFinal()) {
|
|
570
|
+
this.update({ view: 'live' });
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
await this.checkLiveAvailability();
|
|
574
|
+
if (this.disposed)
|
|
575
|
+
return;
|
|
576
|
+
const open = !!this.snap.liveOffer?.open;
|
|
577
|
+
this.update({ view: open ? 'liveForm' : 'form', contact: e, ticketNumber: null, contactPrefill: null });
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* שליחת טופס הבקשה לנציג. אורח חייב להשאיר מייל או טלפון, ההודעה אופציונלית.
|
|
581
|
+
* מחזיר הודעת שגיאה בשפת הממשק, או null. כשאין מי שיענה: טופס הפנייה עם מה שנכתב ושורת הסבר (liveOffer.reason)
|
|
582
|
+
*/
|
|
583
|
+
async requestLive(fields = {}) {
|
|
584
|
+
const t = this.snap.texts;
|
|
585
|
+
if (this.liveSession && !this.liveSession.isFinal()) {
|
|
586
|
+
this.update({ view: 'live' });
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
const trim = (v) => (v ?? '').trim() || undefined;
|
|
590
|
+
const email = trim(fields.email);
|
|
591
|
+
const phone = trim(fields.phone);
|
|
592
|
+
const message = trim(fields.message);
|
|
593
|
+
const identified = !!this.opts.userToken;
|
|
594
|
+
if (!email && !phone && !identified)
|
|
595
|
+
return t['form.contactRequired'];
|
|
596
|
+
if (email && !EMAIL_RE.test(email))
|
|
597
|
+
return t['form.emailInvalid'];
|
|
598
|
+
if (message && message.length > 2000)
|
|
599
|
+
return this.snap.texts['live.tooLong'];
|
|
600
|
+
this.liveRequestId ?? (this.liveRequestId = randomId(22));
|
|
601
|
+
if (message)
|
|
602
|
+
this.liveMessageClientId ?? (this.liveMessageClientId = randomId(22));
|
|
603
|
+
const effect = this.snap.contact;
|
|
604
|
+
const bot = this.snap.conversation;
|
|
605
|
+
const transcript = (effect?.transcript ?? (bot ? transcriptOf(bot) : []))
|
|
606
|
+
.slice(-200)
|
|
607
|
+
.map((x) => ({ from: x.from, text: String(x.text).slice(0, 1000), at: x.at }));
|
|
608
|
+
const departments = this.snap.liveOffer?.departments ?? [];
|
|
609
|
+
const body = {
|
|
610
|
+
requestId: this.liveRequestId,
|
|
611
|
+
sessionId: this.session?.id,
|
|
612
|
+
identityToken: this.opts.userToken || undefined,
|
|
613
|
+
name: trim(fields.name),
|
|
614
|
+
email,
|
|
615
|
+
phone,
|
|
616
|
+
// ברירת מחדל: המחלקה הפתוחה הראשונה
|
|
617
|
+
departmentId: trim(fields.departmentId) ?? departments[0]?.id,
|
|
618
|
+
topicId: trim(fields.topicId) ?? (effect?.contactTopicId || undefined),
|
|
619
|
+
message,
|
|
620
|
+
messageClientId: message ? this.liveMessageClientId ?? undefined : undefined,
|
|
621
|
+
page: this.opts.livePage || undefined,
|
|
622
|
+
transcript,
|
|
623
|
+
};
|
|
624
|
+
const r = await requestHandoff(this.liveCfg, body, this.opts.liveTiming, this.opts.random);
|
|
625
|
+
if (this.disposed)
|
|
626
|
+
return null;
|
|
627
|
+
if (!r.ok) {
|
|
628
|
+
if (r.code === 'ticket_contact_required')
|
|
629
|
+
return t['form.contactRequired'];
|
|
630
|
+
if (r.code === 'email_invalid')
|
|
631
|
+
return t['form.emailInvalid'];
|
|
632
|
+
if (r.code === 'live_message_too_long')
|
|
633
|
+
return this.snap.texts['live.tooLong'];
|
|
634
|
+
if (r.code === 'live_conversation_open')
|
|
635
|
+
return t['live.elsewhere'];
|
|
636
|
+
if (r.code === 'network')
|
|
637
|
+
return t['form.error'];
|
|
638
|
+
return r.message || t['form.error'];
|
|
639
|
+
}
|
|
640
|
+
this.liveRequestId = null;
|
|
641
|
+
this.liveMessageClientId = null;
|
|
642
|
+
if (!r.data.accepted) {
|
|
643
|
+
// אין מי שיענה עכשיו: טופס הפנייה הרגיל עם מה שכבר נכתב ושורת הסבר
|
|
644
|
+
const cached = this.availability?.value;
|
|
645
|
+
this.availability = null;
|
|
646
|
+
this.update({
|
|
647
|
+
view: 'form',
|
|
648
|
+
contact: effect ? { ...effect, type: 'contact' } : effect,
|
|
649
|
+
contactPrefill: { name: trim(fields.name), email, phone, message },
|
|
650
|
+
liveOffer: {
|
|
651
|
+
checking: false,
|
|
652
|
+
open: false,
|
|
653
|
+
reason: r.data.reason,
|
|
654
|
+
departments: [],
|
|
655
|
+
nextOpenAt: r.data.reason === 'closed' ? earliest(cached?.departments) : null,
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
void this.flush();
|
|
661
|
+
const s = await LiveSession.start(this.liveOptions(), r.data);
|
|
662
|
+
if (this.disposed) {
|
|
663
|
+
s.dispose();
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
this.liveSession?.dispose();
|
|
667
|
+
this.liveSession = s;
|
|
668
|
+
this.liveTried = true;
|
|
669
|
+
if (!this.foreground)
|
|
670
|
+
s.pause();
|
|
671
|
+
this.livePhase = null;
|
|
672
|
+
this.update({ view: 'live' });
|
|
673
|
+
this.onLive(s.getSnapshot());
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
/** מחזיר הודעת שגיאה בשפת הממשק (ואז הטקסט נשאר בשדה), או null */
|
|
677
|
+
liveSend(text) {
|
|
678
|
+
const s = this.liveSession;
|
|
679
|
+
if (!s)
|
|
680
|
+
return null;
|
|
681
|
+
const code = s.send(text);
|
|
682
|
+
if (code === 'live_message_too_long')
|
|
683
|
+
return this.snap.texts['live.tooLong'];
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
liveRetry(clientId) {
|
|
687
|
+
this.liveSession?.retry(clientId);
|
|
688
|
+
}
|
|
689
|
+
/** נקרא בכל שינוי בשדה הטקסט של הצ'אט עם הנציג */
|
|
690
|
+
liveTyping(text) {
|
|
691
|
+
this.liveSession?.typing(text);
|
|
692
|
+
}
|
|
693
|
+
liveLeaveMessage(text) {
|
|
694
|
+
return this.liveSession ? this.liveSession.leaveMessage(text) : Promise.resolve(false);
|
|
695
|
+
}
|
|
696
|
+
liveEnd() {
|
|
697
|
+
return this.liveSession ? this.liveSession.endChat() : Promise.resolve(false);
|
|
698
|
+
}
|
|
699
|
+
/** "חזרה לעוזר" אחרי שהצ'אט עם הנציג הסתיים. שיחה פתוחה לא נסגרת מכאן */
|
|
700
|
+
closeLive() {
|
|
701
|
+
const s = this.liveSession;
|
|
702
|
+
if (s && !s.isFinal())
|
|
703
|
+
return false;
|
|
704
|
+
s?.dispose();
|
|
705
|
+
this.liveSession = null;
|
|
706
|
+
this.livePhase = null;
|
|
707
|
+
this.update({ live: null, view: 'chat', contact: null, liveOffer: null });
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
/** האפליקציה ברקע: הזרם נסגר. בחזרה: השלמה לפי seq וזרם חדש. אין התראות push בגרסה הזו */
|
|
711
|
+
setForeground(active) {
|
|
712
|
+
if (this.foreground === active)
|
|
713
|
+
return;
|
|
714
|
+
this.foreground = active;
|
|
715
|
+
const s = this.liveSession;
|
|
716
|
+
if (!s)
|
|
717
|
+
return;
|
|
718
|
+
if (active)
|
|
719
|
+
void s.resume();
|
|
720
|
+
else
|
|
721
|
+
s.pause();
|
|
722
|
+
}
|
|
396
723
|
dispose() {
|
|
397
724
|
if (this.disposed)
|
|
398
725
|
return;
|
|
399
726
|
void this.flush();
|
|
727
|
+
this.liveSession?.dispose();
|
|
728
|
+
this.liveSession = null;
|
|
400
729
|
this.disposed = true;
|
|
401
730
|
this.listeners.clear();
|
|
402
731
|
}
|
package/lib/index.d.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';
|