@kavchat/react-native 0.0.0-stage → 0.0.2

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,419 @@
1
+ import { aiQuestion, answerWithAi, configureEngine, eventsPayload, isRtl, pendingQuestion, pressChip, sendText, startConversation, transcriptOf, uiTexts, } from '@kavchat/core';
2
+ import { fetchRelease, postAiAnswer, postEvents, postScreens, postTicket } from './api';
3
+ import { SCREENS_MAX_AGE_MS, normalizeScreens, screensFingerprint, screensKey } from './screens';
4
+ import { memoryStorage, randomId, readJson, releaseKey, sessionKey, userFingerprint, writeJson, } from './storage';
5
+ import { pickLocale, safeUrl, ticketError, validateTicket } from './text';
6
+ /**
7
+ * הבקר: טעינת הגרסה עם ETag ומטמון, שמירת השיחה, דיווח לשרת ופניות
8
+ * קוד TypeScript רגיל בלי React Native, כדי שאפשר יהיה לבדוק אותו ב-Node.
9
+ * לוגיקת ההתאמה ומכונת המצבים נמצאות ב-@kavchat/core ולא כאן
10
+ */
11
+ export const DEFAULT_API = 'https://api.kavchat.com';
12
+ export const BRANDING_URL = 'https://kavchat.com';
13
+ export class KavchatController {
14
+ constructor(options) {
15
+ this.listeners = new Set();
16
+ this.session = null;
17
+ this.loadSeq = 0;
18
+ this.reportTimer = null;
19
+ this.dirty = false;
20
+ this.disposed = false;
21
+ this.screens = [];
22
+ /** הגרסה כבר נטענה פעם אחת מהשרת, ולכן אפשר לרשום מסכים */
23
+ this.releaseFetched = false;
24
+ /** הטביעה שכבר נוסתה בהפעלה הזו, בהצלחה או בכישלון. אחרי כישלון מנסים רק בהפעלה הבאה */
25
+ this.screensTried = null;
26
+ /** מתממש כשהטעינה האחרונה הסתיימה */
27
+ this.ready = Promise.resolve();
28
+ /** מתממש כשניסיון רישום המסכים האחרון הסתיים. בשביל בדיקות */
29
+ this.screensSync = Promise.resolve();
30
+ // ---------- מנוי, בשביל useSyncExternalStore ----------
31
+ this.subscribe = (listener) => {
32
+ this.listeners.add(listener);
33
+ return () => {
34
+ this.listeners.delete(listener);
35
+ };
36
+ };
37
+ this.getSnapshot = () => this.snap;
38
+ if (!options || !options.publicKey)
39
+ throw new Error('Kavchat: publicKey is required');
40
+ this.opts = { ...options };
41
+ this.store = options.storage ?? memoryStorage();
42
+ this.api = {
43
+ apiBase: options.apiBase || DEFAULT_API,
44
+ publicKey: options.publicKey,
45
+ getToken: () => this.opts.userToken || undefined,
46
+ fetch: options.fetch,
47
+ };
48
+ this.screens = normalizeScreens(options.screens);
49
+ const locale = pickLocale(options.locale, options.deviceLocale, 'en');
50
+ this.snap = {
51
+ status: 'loading',
52
+ release: null,
53
+ offline: false,
54
+ locale,
55
+ rtl: isRtl(locale),
56
+ texts: uiTexts(locale),
57
+ conversation: null,
58
+ typing: false,
59
+ view: 'chat',
60
+ contact: null,
61
+ ticketNumber: null,
62
+ identified: !!options.userToken,
63
+ userName: options.userName,
64
+ };
65
+ }
66
+ update(patch) {
67
+ if (this.disposed)
68
+ return;
69
+ this.snap = { ...this.snap, ...patch };
70
+ this.listeners.forEach((l) => {
71
+ try {
72
+ l();
73
+ }
74
+ catch (e) {
75
+ console.error('Kavchat: listener failed', e);
76
+ }
77
+ });
78
+ }
79
+ /** callbacks שמשתנים בכל רינדור (onScreen וכו') מתעדכנים בלי לבנות בקר חדש */
80
+ setCallbacks(cb) {
81
+ this.opts.onScreen = cb.onScreen;
82
+ this.opts.onLink = cb.onLink;
83
+ this.opts.onEvent = cb.onEvent;
84
+ }
85
+ // ---------- טעינה ----------
86
+ load() {
87
+ const seq = ++this.loadSeq;
88
+ this.ready = this.doLoad(seq);
89
+ return this.ready;
90
+ }
91
+ async doLoad(seq) {
92
+ const pk = this.opts.publicKey;
93
+ const cached = await readJson(this.store, releaseKey(pk));
94
+ const result = await fetchRelease(this.api, cached?.etag || undefined);
95
+ // טעינה חדשה יותר (למשל אחרי setUser) כבר בדרך: מתעלמים מהתוצאה הישנה
96
+ if (seq !== this.loadSeq || this.disposed)
97
+ return;
98
+ if (result.kind === 'ok' || result.kind === 'not-modified') {
99
+ this.releaseFetched = true;
100
+ this.syncScreens();
101
+ }
102
+ let release = null;
103
+ let offline = false;
104
+ if (result.kind === 'ok') {
105
+ await writeJson(this.store, releaseKey(pk), { etag: result.etag, release: result.release });
106
+ release = result.release;
107
+ }
108
+ else if (result.kind === 'not-modified') {
109
+ release = cached?.release ?? null;
110
+ }
111
+ else if (result.kind === 'offline') {
112
+ release = cached?.release ?? null;
113
+ offline = !!release;
114
+ }
115
+ await this.apply(release, offline);
116
+ }
117
+ async apply(release, offline) {
118
+ if (!release || !release.enabled) {
119
+ // הבוט כבוי, מוגבל למשתמשי בדיקה או שנגמרה המכסה: לא מציגים כלום
120
+ this.session = null;
121
+ const reason = release && 'reason' in release ? release.reason : undefined;
122
+ this.update({ status: 'unavailable', release: null, conversation: null, offline: false, view: 'chat', contact: null, reason });
123
+ this.emit({ type: 'unavailable', reason });
124
+ return;
125
+ }
126
+ const prev = this.snap.release;
127
+ const user = userFingerprint(this.opts.userToken);
128
+ const changed = !prev || prev.version !== release.version || (this.session && this.session.user !== user);
129
+ const locale = pickLocale(this.opts.locale, this.opts.deviceLocale, release.locale);
130
+ let conversation = this.snap.conversation;
131
+ let view = this.snap.view;
132
+ if (changed || !conversation) {
133
+ conversation = await this.restoreSession(release, user);
134
+ view = 'chat';
135
+ }
136
+ this.update({
137
+ status: 'ready',
138
+ release,
139
+ offline,
140
+ locale,
141
+ rtl: isRtl(locale),
142
+ texts: uiTexts(locale),
143
+ conversation,
144
+ view,
145
+ contact: view === 'chat' ? null : this.snap.contact,
146
+ reason: undefined,
147
+ identified: !!this.opts.userToken,
148
+ userName: this.opts.userName,
149
+ });
150
+ this.emit({ type: 'ready', version: release.version, offline });
151
+ }
152
+ async restoreSession(release, user) {
153
+ const saved = await readJson(this.store, sessionKey(this.opts.publicKey));
154
+ const maxAge = this.opts.sessionMaxAgeMs ?? 24 * 60 * 60 * 1000;
155
+ const fresh = !saved?.savedAt || this.now() - saved.savedAt <= maxAge;
156
+ if (saved && saved.state && saved.version === release.version && (saved.user ?? '') === user && fresh) {
157
+ const scripts = {};
158
+ for (const sc of release.scripts)
159
+ scripts[sc.id] = sc;
160
+ this.session = saved;
161
+ return { ...saved.state, scripts };
162
+ }
163
+ this.session = null;
164
+ return null;
165
+ }
166
+ /** החלפת משתמש (התחברות או התנתקות). הגרסה נטענת מחדש, כי הקהל testers תלוי בזהות */
167
+ setUser(user) {
168
+ const token = user?.token || undefined;
169
+ const name = user?.name || undefined;
170
+ if (token === this.opts.userToken && name === this.opts.userName)
171
+ return this.ready;
172
+ this.flush();
173
+ this.opts.userToken = token;
174
+ this.opts.userName = name;
175
+ return this.load();
176
+ }
177
+ /** הצהרה או עדכון של המסכים אחרי היצירה. נשלח רק אם הרשימה השתנתה */
178
+ registerScreens(screens) {
179
+ this.screens = normalizeScreens(screens);
180
+ if (this.releaseFetched)
181
+ this.syncScreens();
182
+ }
183
+ /** לא מחכים לזה בטעינה: רישום המסכים לא אמור לעכב או להפיל את הבוט */
184
+ syncScreens() {
185
+ const screens = this.screens;
186
+ if (!screens.length || this.disposed)
187
+ return;
188
+ const fingerprint = screensFingerprint(screens);
189
+ if (this.screensTried === fingerprint)
190
+ return;
191
+ this.screensTried = fingerprint;
192
+ const key = screensKey(this.opts.publicKey);
193
+ this.screensSync = (async () => {
194
+ try {
195
+ const stored = await readJson(this.store, key);
196
+ if (stored && stored.fingerprint === fingerprint && this.now() - stored.savedAt < SCREENS_MAX_AGE_MS)
197
+ return;
198
+ const ok = await postScreens(this.api, { platform: 'react-native', screens });
199
+ const next = { fingerprint, savedAt: this.now() };
200
+ // שומרים רק אחרי 200, כדי שכישלון ינוסה שוב בהפעלה הבאה
201
+ if (ok)
202
+ await writeJson(this.store, key, next);
203
+ }
204
+ catch {
205
+ // לעולם לא מפילים את האפליקציה בגלל הרישום
206
+ }
207
+ })();
208
+ }
209
+ setLocale(locale) {
210
+ this.opts.locale = locale;
211
+ const releaseLocale = this.snap.release?.locale ?? 'en';
212
+ const next = pickLocale(locale, this.opts.deviceLocale, releaseLocale);
213
+ if (next !== this.snap.locale)
214
+ this.update({ locale: next, rtl: isRtl(next), texts: uiTexts(next) });
215
+ }
216
+ // ---------- שיחה ----------
217
+ /** מתחיל שיחה אם עוד אין. קוראים לזה כשהצ'אט נפתח */
218
+ ensureConversation() {
219
+ if (this.snap.status === 'ready' && !this.snap.conversation)
220
+ this.startFresh();
221
+ }
222
+ startFresh() {
223
+ const release = this.snap.release;
224
+ if (!release)
225
+ return;
226
+ this.flush();
227
+ this.session = {
228
+ id: randomId(),
229
+ startedAt: new Date(this.now()).toISOString(),
230
+ version: release.version,
231
+ user: userFingerprint(this.opts.userToken),
232
+ state: null,
233
+ };
234
+ const state = this.run(() => ({
235
+ state: startConversation({
236
+ scripts: release.scripts,
237
+ openingScriptId: release.openingScriptId,
238
+ locale: release.locale,
239
+ userName: this.opts.userName,
240
+ }),
241
+ })).state;
242
+ this.setConversation(state, { view: 'chat', contact: null, ticketNumber: null });
243
+ }
244
+ pressChip(chip) {
245
+ const state = this.snap.conversation;
246
+ if (!state || this.snap.typing)
247
+ return;
248
+ this.afterAction(this.run(() => pressChip(state, chip)));
249
+ }
250
+ /** מתממש כשהתשובה הוצגה. עם תשובות AI זה אחרי הקריאה לשרת */
251
+ sendText(text) {
252
+ const state = this.snap.conversation;
253
+ if (!state || !String(text ?? '').trim() || this.snap.typing)
254
+ return Promise.resolve();
255
+ // תשובות AI רק כשהן דלוקות בגרסה ואין תסריט שעונה על השאלה
256
+ const question = this.snap.release?.settings.aiAnswers === true && this.session ? aiQuestion(state, text) : null;
257
+ if (!question) {
258
+ this.afterAction(this.run(() => sendText(state, text)));
259
+ return Promise.resolve();
260
+ }
261
+ return this.askAi(state, text, question);
262
+ }
263
+ async askAi(before, text, question) {
264
+ const session = this.session;
265
+ const pending = this.run(() => ({ state: pendingQuestion(before, text) })).state;
266
+ this.update({ conversation: pending, typing: true });
267
+ let ai = null;
268
+ try {
269
+ ai = await postAiAnswer(this.api, session.id, question);
270
+ }
271
+ catch {
272
+ ai = null;
273
+ }
274
+ if (this.disposed)
275
+ return;
276
+ // השיחה התחלפה בזמן ההמתנה (שיחה חדשה, משתמש אחר, גרסה חדשה): לא נוגעים בה
277
+ if (this.session !== session || this.snap.conversation !== pending) {
278
+ this.update({ typing: false });
279
+ return;
280
+ }
281
+ // תשובה ריקה או כישלון: answerWithAi מחזיר בדיוק את "לא נמצא" הרגיל
282
+ this.update({ typing: false });
283
+ this.afterAction(this.run(() => answerWithAi(before, text, ai?.answer ?? null, ai?.scriptId ?? null)));
284
+ }
285
+ /**
286
+ * התסריט נבדק בשפת הבוט, והטקסטים הקבועים של הצ'אט מוצגים בשפת הממשק.
287
+ * ההגדרה של המנוע גלובלית, ולכן קובעים אותה לפני כל קריאה
288
+ */
289
+ run(fn) {
290
+ configureEngine({ texts: this.snap.texts });
291
+ return fn();
292
+ }
293
+ setConversation(state, extra = {}) {
294
+ if (this.session) {
295
+ const { scripts: _omit, ...rest } = state;
296
+ this.session.state = rest;
297
+ this.session.savedAt = this.now();
298
+ void writeJson(this.store, sessionKey(this.opts.publicKey), this.session);
299
+ }
300
+ this.update({ conversation: state, ...extra });
301
+ }
302
+ afterAction(r) {
303
+ this.setConversation(r.state);
304
+ this.scheduleReport();
305
+ if (r.effect)
306
+ this.handleEffect(r.effect);
307
+ }
308
+ handleEffect(effect) {
309
+ this.emit({ type: 'effect', effect });
310
+ if (effect.type === 'contact') {
311
+ this.update({ view: 'form', contact: effect, ticketNumber: null });
312
+ }
313
+ else if (effect.type === 'link') {
314
+ // רק https ו-tel. כל פרוטוקול אחר מהתסריט נחסם
315
+ const href = safeUrl(effect.url, ['https', 'tel']);
316
+ if (href)
317
+ this.safeCall(() => this.opts.onLink?.(href), 'onLink');
318
+ }
319
+ else if (effect.type === 'screen') {
320
+ this.safeCall(() => this.opts.onScreen?.(effect.screen, effect.targetId), 'onScreen');
321
+ }
322
+ }
323
+ /** פתיחת הטופס מכפתור מחוץ לתסריט */
324
+ openContactForm(prefill = '') {
325
+ const state = this.snap.conversation;
326
+ this.update({
327
+ view: 'form',
328
+ ticketNumber: null,
329
+ contact: {
330
+ type: 'contact',
331
+ contactTopicId: null,
332
+ scriptId: state?.lastScriptId ?? null,
333
+ prefill,
334
+ transcript: state ? transcriptOf(state) : [],
335
+ },
336
+ });
337
+ }
338
+ backToChat() {
339
+ this.update({ view: 'chat', contact: null, ticketNumber: null });
340
+ }
341
+ /** שולח פנייה. מחזיר הודעת שגיאה בשפת הממשק, או null אם נשלחה */
342
+ async submitTicket(fields) {
343
+ const t = this.snap.texts;
344
+ const effect = this.snap.contact;
345
+ const trim = (v) => (v ?? '').trim() || undefined;
346
+ const input = {
347
+ message: (fields.message ?? '').trim(),
348
+ name: trim(fields.name),
349
+ email: trim(fields.email),
350
+ phone: trim(fields.phone),
351
+ topicId: trim(fields.topicId),
352
+ scriptId: effect?.scriptId || undefined,
353
+ sessionId: this.session?.id,
354
+ transcript: (effect?.transcript ?? []).slice(-200).map((x) => ({ ...x, text: x.text.slice(0, 1000) })),
355
+ };
356
+ const problem = validateTicket(input, !!this.opts.userToken, t);
357
+ if (problem)
358
+ return problem;
359
+ const res = await postTicket(this.api, input);
360
+ if (this.disposed)
361
+ return null;
362
+ if (!res.ok)
363
+ return ticketError(res.code, res.message, t);
364
+ this.flush();
365
+ this.emit({ type: 'ticket', id: res.id, number: res.number });
366
+ this.update({ view: 'sent', ticketNumber: res.number });
367
+ return null;
368
+ }
369
+ // ---------- דיווח ----------
370
+ scheduleReport() {
371
+ this.dirty = true;
372
+ if (this.reportTimer)
373
+ clearTimeout(this.reportTimer);
374
+ this.reportTimer = setTimeout(() => this.flush(), this.opts.reportDelayMs ?? 2000);
375
+ }
376
+ /** שולח את המסלול עכשיו. קוראים לזה גם כשהאפליקציה עוברת לרקע */
377
+ flush() {
378
+ if (this.reportTimer) {
379
+ clearTimeout(this.reportTimer);
380
+ this.reportTimer = null;
381
+ }
382
+ const state = this.snap.conversation;
383
+ const release = this.snap.release;
384
+ const session = this.session;
385
+ if (!this.dirty || !state || !release || !session || this.disposed)
386
+ return Promise.resolve(false);
387
+ this.dirty = false;
388
+ const payload = eventsPayload(state, session.id, release.version, session.startedAt);
389
+ return postEvents(this.api, payload).then((ok) => {
390
+ // כישלון: ינסה שוב בפעולה הבאה
391
+ if (!ok)
392
+ this.dirty = true;
393
+ return ok;
394
+ });
395
+ }
396
+ dispose() {
397
+ if (this.disposed)
398
+ return;
399
+ void this.flush();
400
+ this.disposed = true;
401
+ this.listeners.clear();
402
+ }
403
+ now() {
404
+ return this.opts.now ? this.opts.now() : Date.now();
405
+ }
406
+ emit(event) {
407
+ this.safeCall(() => this.opts.onEvent?.(event), 'onEvent');
408
+ }
409
+ safeCall(fn, name) {
410
+ try {
411
+ const r = fn();
412
+ if (r && typeof r.catch === 'function')
413
+ r.catch((e) => console.error(`Kavchat: ${name} failed`, e));
414
+ }
415
+ catch (e) {
416
+ console.error(`Kavchat: ${name} failed`, e);
417
+ }
418
+ }
419
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { KavchatProvider, useKavchat } from './ui/KavchatProvider';
2
+ export type { KavchatProviderProps } from './ui/KavchatProvider';
3
+ export { KavchatChat } from './ui/KavchatChat';
4
+ export type { KavchatChatProps } from './ui/KavchatChat';
5
+ export { KavchatLauncher } from './ui/KavchatLauncher';
6
+ export type { KavchatLauncherProps } from './ui/KavchatLauncher';
7
+ export { KavchatController, DEFAULT_API, BRANDING_URL } from './controller';
8
+ export type { ControllerOptions, ContactFields, KavchatEvent, KavchatSnapshot, KavchatStatus, KavchatView } from './controller';
9
+ export { memoryStorage, defaultStorage } from './storage';
10
+ export type { StorageAdapter } from './storage';
11
+ export { themeFor } from './theme';
12
+ export type { KavchatTheme } from './theme';
13
+ export type { KavchatScreen } from './screens';
package/lib/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { KavchatProvider, useKavchat } from './ui/KavchatProvider';
2
+ export { KavchatChat } from './ui/KavchatChat';
3
+ export { KavchatLauncher } from './ui/KavchatLauncher';
4
+ export { KavchatController, DEFAULT_API, BRANDING_URL } from './controller';
5
+ export { memoryStorage, defaultStorage } from './storage';
6
+ export { themeFor } from './theme';
@@ -0,0 +1,30 @@
1
+ /**
2
+ * רישום מסכי האפליקציה בשרת, כדי שהעורך בלוח הניהול יציע רשימה במקום שם חופשי עם שגיאות כתיב.
3
+ * הכללים זהים לשרת, ולכן מסך לא תקין נזרק כבר כאן ולא מכשיל את כל הבקשה
4
+ */
5
+ export interface KavchatScreen {
6
+ /** השם שמגיע ל-onScreen. אותיות לטיניות, ספרות, _ . - עד 40 תווים */
7
+ id: string;
8
+ /** השם שמנהל הבוט רואה בעורך, עד 60 תווים */
9
+ label: string;
10
+ /** true כשהמסך צריך targetId, למשל מזהה מוצר */
11
+ needsTarget?: boolean;
12
+ }
13
+ export type NormalizedScreen = {
14
+ id: string;
15
+ label: string;
16
+ needsTarget: boolean;
17
+ };
18
+ export declare const MAX_SCREENS = 100;
19
+ /** גם בלי שינוי שולחים פעם בשבוע, כדי שהשרת ידע שהאפליקציה עדיין משתמשת במסכים */
20
+ export declare const SCREENS_MAX_AGE_MS: number;
21
+ export declare const screensKey: (pk: string) => string;
22
+ export interface StoredScreens {
23
+ fingerprint: string;
24
+ savedAt: number;
25
+ }
26
+ /** מסנן לפי כללי השרת: מזהה ותווית תקינים, בלי כפולים, 100 הראשונים */
27
+ export declare function normalizeScreens(list: readonly KavchatScreen[] | null | undefined, log?: (msg: string) => void): NormalizedScreen[];
28
+ /** טביעה יציבה: שורות id|label|needsTarget ממוינות לפי id, ואז SHA-256. סדר ההצהרה לא משנה */
29
+ export declare function screensFingerprint(list: readonly NormalizedScreen[]): string;
30
+ export declare function sha256Hex(text: string): string;
package/lib/screens.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * רישום מסכי האפליקציה בשרת, כדי שהעורך בלוח הניהול יציע רשימה במקום שם חופשי עם שגיאות כתיב.
3
+ * הכללים זהים לשרת, ולכן מסך לא תקין נזרק כבר כאן ולא מכשיל את כל הבקשה
4
+ */
5
+ export const MAX_SCREENS = 100;
6
+ /** גם בלי שינוי שולחים פעם בשבוע, כדי שהשרת ידע שהאפליקציה עדיין משתמשת במסכים */
7
+ export const SCREENS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
8
+ const ID_RE = /^[A-Za-z0-9_.-]{1,40}$/;
9
+ export const screensKey = (pk) => `kavchat:screens:${pk}`;
10
+ const isDev = () => typeof __DEV__ !== 'undefined' && !!__DEV__;
11
+ /** מסנן לפי כללי השרת: מזהה ותווית תקינים, בלי כפולים, 100 הראשונים */
12
+ export function normalizeScreens(list, log = defaultLog) {
13
+ const out = [];
14
+ const seen = new Set();
15
+ for (const s of Array.isArray(list) ? list : []) {
16
+ const id = typeof s?.id === 'string' ? s.id : '';
17
+ const label = typeof s?.label === 'string' ? s.label.trim() : '';
18
+ if (!ID_RE.test(id)) {
19
+ log(`Kavchat: screen dropped, invalid id ${JSON.stringify(s?.id)}`);
20
+ continue;
21
+ }
22
+ if (label.length < 1 || label.length > 60) {
23
+ log(`Kavchat: screen "${id}" dropped, label must be 1-60 characters`);
24
+ continue;
25
+ }
26
+ if (seen.has(id)) {
27
+ log(`Kavchat: screen "${id}" dropped, duplicate id`);
28
+ continue;
29
+ }
30
+ if (out.length >= MAX_SCREENS) {
31
+ log(`Kavchat: screen "${id}" dropped, more than ${MAX_SCREENS} screens`);
32
+ continue;
33
+ }
34
+ seen.add(id);
35
+ out.push({ id, label, needsTarget: s.needsTarget === true });
36
+ }
37
+ return out;
38
+ }
39
+ function defaultLog(msg) {
40
+ // רק בפיתוח: באפליקציה שפורסמה אין טעם להציף את הלוג
41
+ if (isDev())
42
+ console.warn(msg);
43
+ }
44
+ /** טביעה יציבה: שורות id|label|needsTarget ממוינות לפי id, ואז SHA-256. סדר ההצהרה לא משנה */
45
+ export function screensFingerprint(list) {
46
+ const lines = [...list]
47
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
48
+ .map((s) => `${s.id}|${s.label}|${s.needsTarget}`)
49
+ .join('\n');
50
+ return sha256Hex(lines);
51
+ }
52
+ // ---------- SHA-256 ב-JS טהור: ב-Hermes אין crypto.subtle, ולא רוצים להוסיף תלות נייטיב ----------
53
+ const K = new Uint32Array([
54
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be,
55
+ 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa,
56
+ 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85,
57
+ 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
58
+ 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f,
59
+ 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
60
+ ]);
61
+ function utf8(text) {
62
+ const out = [];
63
+ for (const ch of text) {
64
+ let c = ch.codePointAt(0);
65
+ if (c < 0x80)
66
+ out.push(c);
67
+ else if (c < 0x800)
68
+ out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
69
+ else if (c < 0x10000)
70
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
71
+ else
72
+ out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
73
+ }
74
+ return out;
75
+ }
76
+ export function sha256Hex(text) {
77
+ const bytes = utf8(text);
78
+ const bitLen = bytes.length * 8;
79
+ bytes.push(0x80);
80
+ while (bytes.length % 64 !== 56)
81
+ bytes.push(0);
82
+ // אורך ב-64 ביט. מחרוזות כאן קטנות מ-2^32 ביט, ולכן החצי העליון אפס
83
+ bytes.push(0, 0, 0, 0, (bitLen >>> 24) & 255, (bitLen >>> 16) & 255, (bitLen >>> 8) & 255, bitLen & 255);
84
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
85
+ const w = new Uint32Array(64);
86
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
87
+ for (let off = 0; off < bytes.length; off += 64) {
88
+ for (let i = 0; i < 16; i++) {
89
+ const j = off + i * 4;
90
+ w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3];
91
+ }
92
+ for (let i = 16; i < 64; i++) {
93
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
94
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
95
+ w[i] = w[i - 16] + s0 + w[i - 7] + s1;
96
+ }
97
+ let [a, b, c, d, e, f, g, hh] = h;
98
+ for (let i = 0; i < 64; i++) {
99
+ const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
100
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
101
+ hh = g;
102
+ g = f;
103
+ f = e;
104
+ e = (d + t1) >>> 0;
105
+ d = c;
106
+ c = b;
107
+ b = a;
108
+ a = (t1 + t2) >>> 0;
109
+ }
110
+ h[0] += a;
111
+ h[1] += b;
112
+ h[2] += c;
113
+ h[3] += d;
114
+ h[4] += e;
115
+ h[5] += f;
116
+ h[6] += g;
117
+ h[7] += hh;
118
+ }
119
+ return Array.from(h, (x) => x.toString(16).padStart(8, '0')).join('');
120
+ }
@@ -0,0 +1,35 @@
1
+ import type { BotState } from '@kavchat/core';
2
+ import type { AnyRelease } from './api';
3
+ /**
4
+ * אחסון במכשיר דרך מתאם, כדי שאפליקציה בלי AsyncStorage עדיין תעבוד (רק בלי מטמון בין הפעלות)
5
+ * המפתחות זהים ל-Flutter, כדי שההתנהגות תהיה אחידה בכל ה-SDK
6
+ */
7
+ export interface StorageAdapter {
8
+ getItem(key: string): Promise<string | null> | string | null;
9
+ setItem(key: string, value: string): Promise<void> | void;
10
+ removeItem?(key: string): Promise<void> | void;
11
+ }
12
+ export declare function memoryStorage(): StorageAdapter;
13
+ export declare function defaultStorage(): StorageAdapter;
14
+ export declare const releaseKey: (pk: string) => string;
15
+ export declare const sessionKey: (pk: string) => string;
16
+ export declare function readJson<T>(store: StorageAdapter, key: string): Promise<T | null>;
17
+ export declare function writeJson(store: StorageAdapter, key: string, value: unknown): Promise<void>;
18
+ export interface CachedRelease {
19
+ etag: string;
20
+ release: AnyRelease;
21
+ }
22
+ /** השיחה השמורה. בלי התסריטים עצמם, הם מגיעים מהגרסה השמורה */
23
+ export interface StoredSession {
24
+ id: string;
25
+ startedAt: string;
26
+ version: number;
27
+ /** טביעה של המשתמש, לא הטוקן עצמו, כדי לא להציג שיחה של משתמש אחר אחרי החלפת חשבון */
28
+ user: string;
29
+ savedAt?: number;
30
+ state: Omit<BotState, 'scripts'> | null;
31
+ }
32
+ /** מזהה שיחה אקראי של 22 תווים מתוך [A-Za-z0-9_-], כפי שהשרת מצפה */
33
+ export declare function randomId(length?: number, random?: () => number): string;
34
+ /** טביעה קצרה של הטוקן (FNV-1a, כמו ב-Flutter), כדי לא לשמור את הטוקן בדיסק */
35
+ export declare function userFingerprint(token: string | null | undefined): string;