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