@kavchat/react-native 0.0.1 → 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
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { StorageAdapter, randomId, readJson, writeJson } from '../storage';
|
|
2
|
+
import {
|
|
3
|
+
LiveAccepted,
|
|
4
|
+
LiveApiConfig,
|
|
5
|
+
LiveConversation,
|
|
6
|
+
LiveEvent,
|
|
7
|
+
LiveRequestBody,
|
|
8
|
+
LiveResult,
|
|
9
|
+
LiveSync,
|
|
10
|
+
LiveUnavailable,
|
|
11
|
+
liveApi,
|
|
12
|
+
retryable,
|
|
13
|
+
streamUrl,
|
|
14
|
+
} from './api';
|
|
15
|
+
import { SseMessage, SseParser, StreamHandle, StreamOpener, defaultStreamOpener } from './sse';
|
|
16
|
+
import { isLiveFinal, liveAddPending, liveApplyConversation, liveApplyEvent, liveApplySync, liveSetPending, liveStart } from '@kavchat/core';
|
|
17
|
+
import type { LiveChat, LivePending } from '@kavchat/core';
|
|
18
|
+
export type { LivePending } from '@kavchat/core';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* צד המבקר בצ'אט עם נציג: הזרם, חיבור מחדש עם השלמה לפי seq, שליחה אידמפוטנטית,
|
|
22
|
+
* הקלדה, מעבר לרקע ושחזור אחרי הפעלה מחדש. בלי React Native, כדי שאפשר לבדוק ב-Node.
|
|
23
|
+
* הסדר, הסינון הכפול וההודעות שבדרך: המכונה הטהורה ב-@kavchat/core (live.ts), זהה בכל הלקוחות.
|
|
24
|
+
* החוזה: docs/live-chat-protocol.md. הקרדנשל לא נכתב ללוג ולא לכתובת
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type LivePhase = 'queued' | 'active' | 'closed' | 'ticket' | 'replaced' | 'expired';
|
|
28
|
+
export type LiveConnection = 'connecting' | 'live' | 'polling' | 'reconnecting' | 'paused' | 'stopped';
|
|
29
|
+
|
|
30
|
+
export interface LiveSnapshot {
|
|
31
|
+
phase: LivePhase;
|
|
32
|
+
/** המצב של המכונה המשותפת: מעבירים אותו ל-liveLines ו-liveStatus */
|
|
33
|
+
chat: LiveChat;
|
|
34
|
+
conversation: LiveConversation;
|
|
35
|
+
/** אירועים שהמבקר רואה, ממוינים לפי seq, בלי כפילויות */
|
|
36
|
+
events: LiveEvent[];
|
|
37
|
+
/** הודעות שעוד לא אושרו בשרת */
|
|
38
|
+
pending: LivePending[];
|
|
39
|
+
/** שם הנציג שמקליד, או null */
|
|
40
|
+
agentTyping: string | null;
|
|
41
|
+
connection: LiveConnection;
|
|
42
|
+
waitSeconds: number;
|
|
43
|
+
/** קוד השגיאה האחרונה שצריך להציג (למשל live_message_too_long), או null */
|
|
44
|
+
error: { code: string; message: string } | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LiveTiming {
|
|
48
|
+
/** השהיות חיבור מחדש וניסיון חוזר: 1, 2, 4, 8, 16, 30 שניות */
|
|
49
|
+
backoffMs: number[];
|
|
50
|
+
/** רעש אקראי של 30% לכל כיוון */
|
|
51
|
+
jitter: number;
|
|
52
|
+
/** בלי שום נתון (גם לא הערת ping) זמן כזה: הפרוקסי ניתק בשקט */
|
|
53
|
+
silenceMs: number;
|
|
54
|
+
typingThrottleMs: number;
|
|
55
|
+
typingHideMs: number;
|
|
56
|
+
pollWaitSeconds: number;
|
|
57
|
+
/** הפסקה מינימלית בין שתי בקשות long-poll שחזרו מיד */
|
|
58
|
+
pollGapMs: number;
|
|
59
|
+
/** כמה זמן להשתמש ב-long-poll אחרי live_busy או כישלונות חוזרים, לפני שמנסים זרם שוב */
|
|
60
|
+
pollModeMs: number;
|
|
61
|
+
/** אחרי queueDeadline: סנכרון אחד, למקרה שהעדכון לא הגיע בזרם */
|
|
62
|
+
deadlineGraceMs: number;
|
|
63
|
+
/** השיחה עדיין queued אחרי הסנכרון (משימת השרת מאחרת): בודקים שוב במרווח הזה, עד 6 פעמים */
|
|
64
|
+
deadlineRetryMs: number;
|
|
65
|
+
sendAttempts: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const DEFAULT_TIMING: LiveTiming = {
|
|
69
|
+
backoffMs: [1000, 2000, 4000, 8000, 16000, 30000],
|
|
70
|
+
jitter: 0.3,
|
|
71
|
+
silenceMs: 60_000,
|
|
72
|
+
typingThrottleMs: 3000,
|
|
73
|
+
typingHideMs: 6000,
|
|
74
|
+
pollWaitSeconds: 25,
|
|
75
|
+
pollGapMs: 1000,
|
|
76
|
+
pollModeMs: 120_000,
|
|
77
|
+
deadlineGraceMs: 3000,
|
|
78
|
+
deadlineRetryMs: 10_000,
|
|
79
|
+
sendAttempts: 6,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** מה נשמר במכשיר כדי לחזור לשיחה אחרי הפעלה מחדש. הקרדנשל רק כאן ובזיכרון */
|
|
83
|
+
export interface StoredLive {
|
|
84
|
+
credential: string;
|
|
85
|
+
expiresAt: string;
|
|
86
|
+
conversationId: string;
|
|
87
|
+
user: string;
|
|
88
|
+
waitSeconds?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const liveKey = (publicKey: string) => `kavchat:live:${publicKey}`;
|
|
92
|
+
|
|
93
|
+
export interface LiveSessionOptions {
|
|
94
|
+
api: LiveApiConfig;
|
|
95
|
+
/** אחסון לקרדנשל. עדיף אחסון מאובטח (Keychain / Keystore) */
|
|
96
|
+
store: StorageAdapter;
|
|
97
|
+
storageKey: string;
|
|
98
|
+
/** טביעה של המשתמש המחובר, כדי לא להמשיך שיחה של משתמש אחר */
|
|
99
|
+
user: string;
|
|
100
|
+
/** null: אין זרם, רק long-poll */
|
|
101
|
+
opener?: StreamOpener | null;
|
|
102
|
+
timing?: Partial<LiveTiming>;
|
|
103
|
+
random?: () => number;
|
|
104
|
+
now?: () => number;
|
|
105
|
+
onChange?: (snapshot: LiveSnapshot) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const FINAL: LivePhase[] = ['closed', 'ticket', 'replaced', 'expired'];
|
|
109
|
+
export const isFinalPhase = (p: LivePhase) => FINAL.includes(p);
|
|
110
|
+
const isOpenState = (p: LivePhase) => p === 'queued' || p === 'active';
|
|
111
|
+
|
|
112
|
+
export function backoffDelay(attempt: number, t: Pick<LiveTiming, 'backoffMs' | 'jitter'>, random: () => number = Math.random): number {
|
|
113
|
+
const list = t.backoffMs.length ? t.backoffMs : [1000];
|
|
114
|
+
const base = list[Math.min(Math.max(attempt, 0), list.length - 1)];
|
|
115
|
+
return Math.max(0, Math.round(base * (1 + (random() * 2 - 1) * t.jitter)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
119
|
+
|
|
120
|
+
/** בקשת נציג עם ניסיונות חוזרים על רשת, 5xx ו-429. ה-requestId זהה בכל הניסיונות */
|
|
121
|
+
export async function requestHandoff(
|
|
122
|
+
api: LiveApiConfig,
|
|
123
|
+
body: LiveRequestBody,
|
|
124
|
+
timing: Partial<LiveTiming> = {},
|
|
125
|
+
random: () => number = Math.random,
|
|
126
|
+
attempts = 4,
|
|
127
|
+
): Promise<LiveResult<LiveAccepted | LiveUnavailable>> {
|
|
128
|
+
const t = { ...DEFAULT_TIMING, ...timing };
|
|
129
|
+
for (let attempt = 0; ; attempt++) {
|
|
130
|
+
const r = await liveApi.request(api, body);
|
|
131
|
+
if (r.ok || !retryable(r) || attempt + 1 >= attempts) return r;
|
|
132
|
+
await sleep(backoffDelay(attempt, t, random));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function errorCode(body: string): string {
|
|
137
|
+
try {
|
|
138
|
+
const j = JSON.parse(body) as { code?: unknown };
|
|
139
|
+
return typeof j.code === 'string' ? j.code : '';
|
|
140
|
+
} catch {
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class LiveSession {
|
|
146
|
+
private o: LiveSessionOptions;
|
|
147
|
+
private t: LiveTiming;
|
|
148
|
+
private random: () => number;
|
|
149
|
+
private opener: StreamOpener | null;
|
|
150
|
+
private credential: string;
|
|
151
|
+
private expiresAt: string;
|
|
152
|
+
/** המצב עצמו (שיחה, אירועים, הודעות שבדרך) מנוהל על ידי המכונה הטהורה ב-@kavchat/core */
|
|
153
|
+
private chat: LiveChat;
|
|
154
|
+
/** מצבים סופיים שלא באים מהשרת: הקרדנשל הוחלף (bye replaced) או פג */
|
|
155
|
+
private override: 'replaced' | 'expired' | null = null;
|
|
156
|
+
private finished = false;
|
|
157
|
+
private agentTyping: string | null = null;
|
|
158
|
+
private connection: LiveConnection = 'connecting';
|
|
159
|
+
private waitSeconds: number;
|
|
160
|
+
private error: LiveSnapshot['error'] = null;
|
|
161
|
+
private snap: LiveSnapshot;
|
|
162
|
+
|
|
163
|
+
/** עולה בכל עצירה (רקע, סיום, dispose), כדי שלולאות ישנות ייעצרו אחרי await */
|
|
164
|
+
private gen = 0;
|
|
165
|
+
private conn: { closed: boolean; handle: StreamHandle | null } | null = null;
|
|
166
|
+
private attempt = 0;
|
|
167
|
+
private failuresWithoutReady = 0;
|
|
168
|
+
private pollUntil = 0;
|
|
169
|
+
private polling = false;
|
|
170
|
+
private paused = false;
|
|
171
|
+
private disposed = false;
|
|
172
|
+
private typingOn = false;
|
|
173
|
+
private lastTypingAt = 0;
|
|
174
|
+
private armedDeadline = '';
|
|
175
|
+
private timers: Record<'reconnect' | 'silence' | 'typing' | 'deadline' | 'expiry' | 'poll', ReturnType<typeof setTimeout> | null> = {
|
|
176
|
+
reconnect: null,
|
|
177
|
+
silence: null,
|
|
178
|
+
typing: null,
|
|
179
|
+
deadline: null,
|
|
180
|
+
expiry: null,
|
|
181
|
+
poll: null,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
private get phase(): LivePhase {
|
|
185
|
+
return this.override ?? this.chat.conversation.state;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private get lastSeq() {
|
|
189
|
+
return this.chat.lastSeq;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private constructor(o: LiveSessionOptions, credential: string, expiresAt: string, waitSeconds: number, conversation: LiveConversation) {
|
|
193
|
+
this.chat = liveStart(conversation);
|
|
194
|
+
this.o = o;
|
|
195
|
+
this.t = { ...DEFAULT_TIMING, ...(o.timing ?? {}) };
|
|
196
|
+
this.random = o.random ?? Math.random;
|
|
197
|
+
this.opener = o.opener === undefined ? defaultStreamOpener : o.opener;
|
|
198
|
+
this.credential = credential;
|
|
199
|
+
this.expiresAt = expiresAt;
|
|
200
|
+
this.waitSeconds = waitSeconds;
|
|
201
|
+
this.snap = this.build();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------- יצירה ----------
|
|
205
|
+
|
|
206
|
+
/** אחרי accepted: שומר את הקרדנשל, מציג המתנה ופותח את הזרם */
|
|
207
|
+
static async start(o: LiveSessionOptions, accepted: LiveAccepted): Promise<LiveSession> {
|
|
208
|
+
const s = new LiveSession(o, accepted.credential, accepted.credentialExpiresAt, accepted.waitSeconds, accepted.conversation);
|
|
209
|
+
await writeJson(o.store, o.storageKey, {
|
|
210
|
+
credential: accepted.credential,
|
|
211
|
+
expiresAt: accepted.credentialExpiresAt,
|
|
212
|
+
conversationId: accepted.conversation.id,
|
|
213
|
+
user: o.user,
|
|
214
|
+
waitSeconds: accepted.waitSeconds,
|
|
215
|
+
} satisfies StoredLive);
|
|
216
|
+
s.afterChange();
|
|
217
|
+
s.run();
|
|
218
|
+
return s;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* אחרי הפעלה מחדש: אם יש קרדנשל בתוקף של אותו משתמש, בונה את השיחה מ-sync after 0 וממשיך.
|
|
223
|
+
* שיחה שהסתיימה בינתיים או קרדנשל שפג: מוחק ומחזיר null
|
|
224
|
+
*/
|
|
225
|
+
static async restore(o: LiveSessionOptions): Promise<LiveSession | null> {
|
|
226
|
+
const saved = await readJson<StoredLive>(o.store, o.storageKey);
|
|
227
|
+
if (!saved || typeof saved.credential !== 'string' || !saved.credential) return null;
|
|
228
|
+
const now = o.now ? o.now() : Date.now();
|
|
229
|
+
const exp = Date.parse(saved.expiresAt);
|
|
230
|
+
if (saved.user !== o.user || !Number.isFinite(exp) || exp <= now) {
|
|
231
|
+
await removeKey(o.store, o.storageKey);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
// עד שהסנכרון עונה: שיחה בתור עם המזהה השמור. after: 0 בונה את כל השיחה מחדש
|
|
235
|
+
const placeholder: LiveConversation = { id: String(saved.conversationId ?? ''), state: 'queued', lastSeq: 0, credentialExpiresAt: saved.expiresAt };
|
|
236
|
+
const s = new LiveSession(o, saved.credential, saved.expiresAt, saved.waitSeconds ?? 0, placeholder);
|
|
237
|
+
const ok = await s.syncAll();
|
|
238
|
+
if (s.isFinal()) {
|
|
239
|
+
s.dispose();
|
|
240
|
+
await removeKey(o.store, o.storageKey);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
// רשת לא זמינה: ממשיכים עם מה שידוע, והחיבור מחדש ישלים
|
|
244
|
+
if (!ok) s.connection = 'reconnecting';
|
|
245
|
+
s.run();
|
|
246
|
+
return s;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------- מצב ----------
|
|
250
|
+
|
|
251
|
+
getSnapshot = (): LiveSnapshot => this.snap;
|
|
252
|
+
|
|
253
|
+
isFinal() {
|
|
254
|
+
return isFinalPhase(this.phase);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private build(): LiveSnapshot {
|
|
258
|
+
return {
|
|
259
|
+
phase: this.phase,
|
|
260
|
+
chat: this.chat,
|
|
261
|
+
conversation: this.chat.conversation,
|
|
262
|
+
events: this.chat.events,
|
|
263
|
+
pending: this.chat.pending,
|
|
264
|
+
agentTyping: this.agentTyping,
|
|
265
|
+
connection: this.connection,
|
|
266
|
+
waitSeconds: this.waitSeconds,
|
|
267
|
+
error: this.error,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private emit() {
|
|
272
|
+
if (this.disposed) return;
|
|
273
|
+
this.snap = this.build();
|
|
274
|
+
try {
|
|
275
|
+
this.o.onChange?.(this.snap);
|
|
276
|
+
} catch (e) {
|
|
277
|
+
console.error('Kavchat: live listener failed', e);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private now() {
|
|
282
|
+
return this.o.now ? this.o.now() : Date.now();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private canRun() {
|
|
286
|
+
return !this.disposed && !this.paused && !this.isFinal();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private setConnection(c: LiveConnection) {
|
|
290
|
+
if (this.connection === c) return;
|
|
291
|
+
this.connection = c;
|
|
292
|
+
this.emit();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------- החלת נתונים מהשרת ----------
|
|
296
|
+
|
|
297
|
+
/** מחליף את המצב בתוצאה של המכונה הטהורה, ומטפל בתוצאות הצדדיות: מועדים, הקלדה, סיום */
|
|
298
|
+
private setChat(next: LiveChat) {
|
|
299
|
+
if (next === this.chat || this.override) return;
|
|
300
|
+
this.chat = next;
|
|
301
|
+
this.afterChange();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private afterChange() {
|
|
305
|
+
const c = this.chat.conversation;
|
|
306
|
+
if (c.credentialExpiresAt) this.expiresAt = c.credentialExpiresAt;
|
|
307
|
+
if (c.state === 'queued') this.armDeadline(c.queueDeadline ?? undefined);
|
|
308
|
+
else this.clearTimer('deadline');
|
|
309
|
+
if (c.state !== 'active') this.agentTyping = null;
|
|
310
|
+
if (isLiveFinal(c.state) && !this.finished) this.finish();
|
|
311
|
+
this.emit();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** אובייקט השיחה הוא מקור האמת. closed ו-ticket סופיים: המכונה לא חוזרת מהם */
|
|
315
|
+
private applyConversation(c: LiveConversation | undefined | null) {
|
|
316
|
+
this.setChat(liveApplyConversation(this.chat, c));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** כפילות לפי seq מתעלמים, מכניסים לפי הסדר, ולא מחכים ל-seq חסר (liveApplyEvent) */
|
|
320
|
+
private applyEvent(e: LiveEvent | undefined | null) {
|
|
321
|
+
const next = liveApplyEvent(this.chat, e);
|
|
322
|
+
if (next !== this.chat && e?.type === 'message' && e.author?.kind === 'agent') this.agentTyping = null;
|
|
323
|
+
this.setChat(next);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private applySync(d: LiveSync) {
|
|
327
|
+
this.setChat(liveApplySync(this.chat, d));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** POST /sync עד hasMore=false. false כשנכשל */
|
|
331
|
+
private async syncAll(): Promise<boolean> {
|
|
332
|
+
for (let page = 0; page < 50; page++) {
|
|
333
|
+
if (this.disposed) return false;
|
|
334
|
+
const r = await liveApi.sync(this.o.api, this.credential, this.lastSeq);
|
|
335
|
+
if (this.disposed) return false;
|
|
336
|
+
if (!r.ok) {
|
|
337
|
+
if (r.status === 401) this.expire();
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
this.applySync(r.data);
|
|
341
|
+
if (!r.data.hasMore || !(r.data.events ?? []).length) return true;
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------- זרם ----------
|
|
347
|
+
|
|
348
|
+
private run() {
|
|
349
|
+
this.armExpiry();
|
|
350
|
+
void this.connect();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private async connect() {
|
|
354
|
+
if (!this.canRun() || this.conn || this.polling) return;
|
|
355
|
+
if (!this.opener || this.now() < this.pollUntil) return this.poll();
|
|
356
|
+
const gen = this.gen;
|
|
357
|
+
this.setConnection(this.attempt > 0 ? 'reconnecting' : 'connecting');
|
|
358
|
+
const t = await liveApi.streamTicket(this.o.api, this.credential);
|
|
359
|
+
if (gen !== this.gen || !this.canRun() || this.conn) return;
|
|
360
|
+
if (!t.ok) return this.onStreamFailure(t.status, t.code);
|
|
361
|
+
const conn: { closed: boolean; handle: StreamHandle | null } = { closed: false, handle: null };
|
|
362
|
+
this.conn = conn;
|
|
363
|
+
const alive = () => this.conn === conn && !conn.closed;
|
|
364
|
+
const parser = new SseParser((m) => alive() && this.onMessage(m));
|
|
365
|
+
const handle = this.opener(streamUrl(this.o.api, t.data.ticket, this.lastSeq), { 'Accept-Language': this.o.api.getLocale() }, {
|
|
366
|
+
onChunk: (text) => {
|
|
367
|
+
if (!alive()) return;
|
|
368
|
+
this.armSilence();
|
|
369
|
+
parser.feed(text);
|
|
370
|
+
},
|
|
371
|
+
onError: (status, body) => {
|
|
372
|
+
if (!alive()) return;
|
|
373
|
+
this.dropStream();
|
|
374
|
+
// -1: אין זרימה בסביבה הזו. עוברים ל-long-poll לצמיתות
|
|
375
|
+
if (status === -1) this.opener = null;
|
|
376
|
+
this.onStreamFailure(status, errorCode(body));
|
|
377
|
+
},
|
|
378
|
+
onEnd: () => {
|
|
379
|
+
if (!alive()) return;
|
|
380
|
+
// השרת סוגר אחרי שעה: כמו שגיאה, חיבור מחדש עם after
|
|
381
|
+
this.dropStream();
|
|
382
|
+
this.scheduleReconnect();
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
if (!handle) {
|
|
386
|
+
if (this.conn === conn) this.conn = null;
|
|
387
|
+
this.opener = null;
|
|
388
|
+
return this.poll();
|
|
389
|
+
}
|
|
390
|
+
conn.handle = handle;
|
|
391
|
+
if (conn.closed) handle.close();
|
|
392
|
+
else this.armSilence();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private onMessage(m: SseMessage) {
|
|
396
|
+
let data: unknown;
|
|
397
|
+
try {
|
|
398
|
+
data = JSON.parse(m.data);
|
|
399
|
+
} catch {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const d = data as Record<string, unknown>;
|
|
403
|
+
switch (m.event) {
|
|
404
|
+
case 'ready':
|
|
405
|
+
this.attempt = 0;
|
|
406
|
+
this.failuresWithoutReady = 0;
|
|
407
|
+
this.applyConversation(d.conversation as LiveConversation);
|
|
408
|
+
if (this.canRun()) this.setConnection('live');
|
|
409
|
+
break;
|
|
410
|
+
case 'event':
|
|
411
|
+
this.applyEvent(data as LiveEvent);
|
|
412
|
+
break;
|
|
413
|
+
case 'state':
|
|
414
|
+
this.applyConversation(data as LiveConversation);
|
|
415
|
+
break;
|
|
416
|
+
case 'typing': {
|
|
417
|
+
const on = d.on !== false;
|
|
418
|
+
const author = (d.author ?? {}) as { name?: string };
|
|
419
|
+
this.setTyping(on ? author.name || '' : null);
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
case 'bye': {
|
|
423
|
+
const reason = String(d.reason ?? '');
|
|
424
|
+
this.dropStream();
|
|
425
|
+
if (reason === 'replaced') this.end('replaced');
|
|
426
|
+
else if (reason === 'expired' || reason === 'deleted') this.end('expired');
|
|
427
|
+
else this.scheduleReconnect();
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
default:
|
|
431
|
+
// סוגים לא מוכרים: מתעלמים
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private setTyping(name: string | null) {
|
|
436
|
+
this.clearTimer('typing');
|
|
437
|
+
this.agentTyping = this.phase === 'active' ? name : null;
|
|
438
|
+
if (this.agentTyping !== null) {
|
|
439
|
+
this.timers.typing = setTimeout(() => {
|
|
440
|
+
this.timers.typing = null;
|
|
441
|
+
this.agentTyping = null;
|
|
442
|
+
this.emit();
|
|
443
|
+
}, this.t.typingHideMs);
|
|
444
|
+
}
|
|
445
|
+
this.emit();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private onStreamFailure(status: number, code: string) {
|
|
449
|
+
if (status === 401 && code !== 'live_stream_ticket_invalid') return this.expire();
|
|
450
|
+
if (status === 503 || code === 'live_busy') {
|
|
451
|
+
this.pollUntil = this.now() + this.t.pollModeMs;
|
|
452
|
+
} else if (++this.failuresWithoutReady >= 3) {
|
|
453
|
+
// פרוקסי שחוסם זרמים: long-poll לזמן מה
|
|
454
|
+
this.failuresWithoutReady = 0;
|
|
455
|
+
this.pollUntil = this.now() + this.t.pollModeMs;
|
|
456
|
+
}
|
|
457
|
+
this.scheduleReconnect();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private scheduleReconnect() {
|
|
461
|
+
if (!this.canRun()) return;
|
|
462
|
+
this.clearTimer('reconnect');
|
|
463
|
+
this.setConnection('reconnecting');
|
|
464
|
+
const gen = this.gen;
|
|
465
|
+
const delay = backoffDelay(this.attempt++, this.t, this.random);
|
|
466
|
+
this.timers.reconnect = setTimeout(async () => {
|
|
467
|
+
this.timers.reconnect = null;
|
|
468
|
+
if (gen !== this.gen) return;
|
|
469
|
+
await this.syncAll();
|
|
470
|
+
if (gen !== this.gen) return;
|
|
471
|
+
void this.connect();
|
|
472
|
+
}, delay);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
private dropStream() {
|
|
476
|
+
this.clearTimer('silence');
|
|
477
|
+
const c = this.conn;
|
|
478
|
+
this.conn = null;
|
|
479
|
+
if (!c) return;
|
|
480
|
+
c.closed = true;
|
|
481
|
+
try {
|
|
482
|
+
c.handle?.close();
|
|
483
|
+
} catch {
|
|
484
|
+
// כבר נסגר
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private armSilence() {
|
|
489
|
+
this.clearTimer('silence');
|
|
490
|
+
this.timers.silence = setTimeout(() => {
|
|
491
|
+
this.timers.silence = null;
|
|
492
|
+
if (!this.conn) return;
|
|
493
|
+
this.dropStream();
|
|
494
|
+
this.scheduleReconnect();
|
|
495
|
+
}, this.t.silenceMs);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** long-poll: כשאין זרם בסביבה, אחרי live_busy, או כשהזרם נכשל שוב ושוב. בלי הקלדה */
|
|
499
|
+
private async poll() {
|
|
500
|
+
if (this.polling) return;
|
|
501
|
+
this.polling = true;
|
|
502
|
+
const gen = this.gen;
|
|
503
|
+
this.setConnection('polling');
|
|
504
|
+
try {
|
|
505
|
+
while (gen === this.gen && this.canRun()) {
|
|
506
|
+
if (this.opener && this.now() >= this.pollUntil) break;
|
|
507
|
+
const started = this.now();
|
|
508
|
+
const r = await liveApi.sync(this.o.api, this.credential, this.lastSeq, this.t.pollWaitSeconds);
|
|
509
|
+
if (gen !== this.gen || this.disposed) return;
|
|
510
|
+
if (!r.ok) {
|
|
511
|
+
if (r.status === 401) return this.expire();
|
|
512
|
+
await this.wait(backoffDelay(this.attempt++, this.t, this.random));
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
this.attempt = 0;
|
|
516
|
+
this.applySync(r.data);
|
|
517
|
+
if (r.data.hasMore) continue;
|
|
518
|
+
const elapsed = this.now() - started;
|
|
519
|
+
if (elapsed < this.t.pollGapMs) await this.wait(this.t.pollGapMs - elapsed);
|
|
520
|
+
}
|
|
521
|
+
} finally {
|
|
522
|
+
this.polling = false;
|
|
523
|
+
}
|
|
524
|
+
if (gen === this.gen && this.canRun()) void this.connect();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private pollResolve: (() => void) | null = null;
|
|
528
|
+
|
|
529
|
+
private wait(ms: number) {
|
|
530
|
+
return new Promise<void>((resolve) => {
|
|
531
|
+
this.clearTimer('poll');
|
|
532
|
+
this.pollResolve = resolve;
|
|
533
|
+
this.timers.poll = setTimeout(() => {
|
|
534
|
+
this.timers.poll = null;
|
|
535
|
+
this.pollResolve = null;
|
|
536
|
+
resolve();
|
|
537
|
+
}, ms);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private armDeadline(iso: string | undefined) {
|
|
542
|
+
if (!iso || iso === this.armedDeadline) return;
|
|
543
|
+
const at = Date.parse(iso);
|
|
544
|
+
if (!Number.isFinite(at)) return;
|
|
545
|
+
this.armedDeadline = iso;
|
|
546
|
+
this.checkDeadline(Math.max(0, at - this.now()) + this.t.deadlineGraceMs, 0);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** השרת הופך את השיחה לפנייה בתום ההמתנה. אם העדכון לא הגיע בזרם (או ברקע), הסנכרון מביא אותו */
|
|
550
|
+
private checkDeadline(delay: number, round: number) {
|
|
551
|
+
this.clearTimer('deadline');
|
|
552
|
+
this.timers.deadline = setTimeout(async () => {
|
|
553
|
+
this.timers.deadline = null;
|
|
554
|
+
if (this.disposed || this.paused || this.phase !== 'queued') return;
|
|
555
|
+
await this.syncAll();
|
|
556
|
+
if (!this.disposed && this.phase === 'queued' && round < 5) this.checkDeadline(this.t.deadlineRetryMs, round + 1);
|
|
557
|
+
}, delay);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private armExpiry() {
|
|
561
|
+
this.clearTimer('expiry');
|
|
562
|
+
const at = Date.parse(this.expiresAt);
|
|
563
|
+
if (!Number.isFinite(at)) return;
|
|
564
|
+
const ms = at - this.now();
|
|
565
|
+
if (ms <= 0) return this.expire();
|
|
566
|
+
this.timers.expiry = setTimeout(() => this.expire(), Math.min(ms, 2 ** 31 - 1));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---------- סיום ----------
|
|
570
|
+
|
|
571
|
+
private expire() {
|
|
572
|
+
this.end('expired');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private end(phase: 'replaced' | 'expired') {
|
|
576
|
+
if (this.isFinal()) return;
|
|
577
|
+
this.override = phase;
|
|
578
|
+
this.finish();
|
|
579
|
+
this.emit();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** מצב סופי: עוצרים הכל ומוחקים את הקרדנשל מהמכשיר */
|
|
583
|
+
private finish() {
|
|
584
|
+
this.finished = true;
|
|
585
|
+
this.gen++;
|
|
586
|
+
this.dropStream();
|
|
587
|
+
for (const k of Object.keys(this.timers) as Array<keyof LiveSession['timers']>) this.clearTimer(k);
|
|
588
|
+
this.agentTyping = null;
|
|
589
|
+
this.connection = 'stopped';
|
|
590
|
+
void removeKey(this.o.store, this.o.storageKey);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private clearTimer(k: keyof LiveSession['timers']) {
|
|
594
|
+
const t = this.timers[k];
|
|
595
|
+
if (t) clearTimeout(t);
|
|
596
|
+
this.timers[k] = null;
|
|
597
|
+
if (k === 'poll' && this.pollResolve) {
|
|
598
|
+
// מסיימים את ההמתנה מיד, כדי שהלולאה תראה שה-gen השתנה ותשחרר את polling
|
|
599
|
+
const resolve = this.pollResolve;
|
|
600
|
+
this.pollResolve = null;
|
|
601
|
+
resolve();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ---------- מחזור חיים ----------
|
|
606
|
+
|
|
607
|
+
/** מעבר לרקע: סוגרים את הזרם. ההודעות שבדרך ממשיכות לנסות */
|
|
608
|
+
pause() {
|
|
609
|
+
if (this.paused || this.disposed || this.isFinal()) return;
|
|
610
|
+
this.paused = true;
|
|
611
|
+
this.gen++;
|
|
612
|
+
this.dropStream();
|
|
613
|
+
this.clearTimer('reconnect');
|
|
614
|
+
this.clearTimer('poll');
|
|
615
|
+
this.clearTimer('typing');
|
|
616
|
+
this.agentTyping = null;
|
|
617
|
+
this.connection = 'paused';
|
|
618
|
+
this.emit();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** חזרה לקדמה: השלמה של מה שהוחמץ ואז זרם חדש */
|
|
622
|
+
async resume() {
|
|
623
|
+
if (!this.paused || this.disposed) return;
|
|
624
|
+
this.paused = false;
|
|
625
|
+
if (this.isFinal()) return;
|
|
626
|
+
if (Date.parse(this.expiresAt) <= this.now()) return this.expire();
|
|
627
|
+
this.attempt = 0;
|
|
628
|
+
this.pollUntil = 0;
|
|
629
|
+
const gen = this.gen;
|
|
630
|
+
this.setConnection('reconnecting');
|
|
631
|
+
await this.syncAll();
|
|
632
|
+
if (gen === this.gen && this.canRun()) await this.connect();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** עוצר בלי למחוק את הקרדנשל (למשל כשהאפליקציה נסגרת), כדי שאפשר יהיה לשחזר */
|
|
636
|
+
dispose() {
|
|
637
|
+
if (this.disposed) return;
|
|
638
|
+
this.gen++;
|
|
639
|
+
this.dropStream();
|
|
640
|
+
for (const k of Object.keys(this.timers) as Array<keyof LiveSession['timers']>) this.clearTimer(k);
|
|
641
|
+
this.disposed = true;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** עוצר ומוחק את הקרדנשל (החלפת משתמש) */
|
|
645
|
+
forget() {
|
|
646
|
+
this.dispose();
|
|
647
|
+
void removeKey(this.o.store, this.o.storageKey);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ---------- פקודות ----------
|
|
651
|
+
|
|
652
|
+
/** מחזיר קוד שגיאה, או null כשההודעה נוספה לתור השליחה */
|
|
653
|
+
send(text: string): string | null {
|
|
654
|
+
const value = String(text ?? '').trim();
|
|
655
|
+
if (!value) return null;
|
|
656
|
+
if (value.length > 2000) return 'live_message_too_long';
|
|
657
|
+
if (this.disposed || !isOpenState(this.phase)) return 'live_conversation_closed';
|
|
658
|
+
// נוצר פעם אחת, כשהמשתמש שולח, ומשמש לכל ניסיון חוזר של אותה הודעה
|
|
659
|
+
const clientId = randomId(22);
|
|
660
|
+
this.error = null;
|
|
661
|
+
this.chat = liveAddPending(this.chat, clientId, value, new Date(this.now()).toISOString());
|
|
662
|
+
this.stopTyping();
|
|
663
|
+
this.emit();
|
|
664
|
+
void this.deliver(clientId);
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** שליחה חוזרת של הודעה שנכשלה, עם אותו clientId */
|
|
669
|
+
retry(clientId: string) {
|
|
670
|
+
const p = this.chat.pending.find((x) => x.clientId === clientId);
|
|
671
|
+
if (!p || p.status !== 'failed' || this.disposed || !isOpenState(this.phase)) return;
|
|
672
|
+
this.setPending(clientId, 'sending');
|
|
673
|
+
void this.deliver(clientId);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** null: מסיר את הבועה */
|
|
677
|
+
private setPending(clientId: string, status: LivePending['status'] | null) {
|
|
678
|
+
const next = liveSetPending(this.chat, clientId, status);
|
|
679
|
+
if (next === this.chat) return;
|
|
680
|
+
this.chat = next;
|
|
681
|
+
this.emit();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private async deliver(clientId: string) {
|
|
685
|
+
for (let attempt = 0; ; attempt++) {
|
|
686
|
+
const p = this.chat.pending.find((x) => x.clientId === clientId);
|
|
687
|
+
if (!p || this.disposed) return;
|
|
688
|
+
const r = await liveApi.message(this.o.api, this.credential, clientId, p.text);
|
|
689
|
+
if (this.disposed) return;
|
|
690
|
+
if (r.ok) {
|
|
691
|
+
// duplicate: true הוא הצלחה. האירוע המקורי מחליף את הבועה הממתינה
|
|
692
|
+
this.applyEvent(r.data.event);
|
|
693
|
+
this.setPending(clientId, null);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (retryable(r) && attempt + 1 < this.t.sendAttempts) {
|
|
697
|
+
await sleep(backoffDelay(attempt, this.t, this.random));
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (r.code === 'live_message_too_long') {
|
|
701
|
+
this.error = { code: r.code, message: r.message };
|
|
702
|
+
this.setPending(clientId, null);
|
|
703
|
+
this.emit();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
this.setPending(clientId, 'failed');
|
|
707
|
+
if (r.status === 401) return this.expire();
|
|
708
|
+
if (r.code === 'live_conversation_closed') void this.syncAll();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** נקרא בכל שינוי בשדה. on: true לכל היותר כל 3 שניות, on: false כשהשדה מתרוקן. בלי ניסיונות חוזרים */
|
|
714
|
+
typing(text: string) {
|
|
715
|
+
if (this.disposed || this.phase !== 'active') return;
|
|
716
|
+
if (!String(text ?? '').trim()) return this.stopTyping();
|
|
717
|
+
const now = this.now();
|
|
718
|
+
if (this.typingOn && now - this.lastTypingAt < this.t.typingThrottleMs) return;
|
|
719
|
+
this.typingOn = true;
|
|
720
|
+
this.lastTypingAt = now;
|
|
721
|
+
void liveApi.typing(this.o.api, this.credential, true);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
private stopTyping() {
|
|
725
|
+
if (!this.typingOn) return;
|
|
726
|
+
this.typingOn = false;
|
|
727
|
+
this.lastTypingAt = 0;
|
|
728
|
+
if (this.phase === 'active') void liveApi.typing(this.o.api, this.credential, false);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** "להשאיר הודעה במקום": השיחה הופכת לפנייה */
|
|
732
|
+
leaveMessage(text?: string): Promise<boolean> {
|
|
733
|
+
const value = String(text ?? '').trim().slice(0, 2000);
|
|
734
|
+
const clientId = value ? randomId(22) : undefined;
|
|
735
|
+
return this.command(() => liveApi.leaveMessage(this.o.api, this.credential, value || undefined, clientId));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** סיום השיחה. בטוח לחזור עליו */
|
|
739
|
+
endChat(): Promise<boolean> {
|
|
740
|
+
return this.command(() => liveApi.end(this.o.api, this.credential));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private async command(fn: () => Promise<LiveResult<{ conversation: LiveConversation }>>): Promise<boolean> {
|
|
744
|
+
if (this.disposed || !isOpenState(this.phase)) return false;
|
|
745
|
+
this.stopTyping();
|
|
746
|
+
for (let attempt = 0; ; attempt++) {
|
|
747
|
+
const r = await fn();
|
|
748
|
+
if (this.disposed) return false;
|
|
749
|
+
if (r.ok) {
|
|
750
|
+
this.applyConversation(r.data.conversation);
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
if (retryable(r) && attempt + 1 < 4) {
|
|
754
|
+
await sleep(backoffDelay(attempt, this.t, this.random));
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (r.status === 401) {
|
|
758
|
+
this.expire();
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
if (r.code === 'live_conversation_closed') await this.syncAll();
|
|
762
|
+
else {
|
|
763
|
+
this.error = { code: r.code, message: r.message };
|
|
764
|
+
this.emit();
|
|
765
|
+
}
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
clearError() {
|
|
771
|
+
if (!this.error) return;
|
|
772
|
+
this.error = null;
|
|
773
|
+
this.emit();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function removeKey(store: StorageAdapter, key: string) {
|
|
778
|
+
try {
|
|
779
|
+
if (store.removeItem) await store.removeItem(key);
|
|
780
|
+
else await store.setItem(key, '');
|
|
781
|
+
} catch {
|
|
782
|
+
// אין גישה לאחסון. הקרדנשל יפוג בכל מקרה אחרי 12 שעות
|
|
783
|
+
}
|
|
784
|
+
}
|