@softomnitel/omnicall-kit 0.1.0-rc.0 → 0.1.0

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.
Files changed (2) hide show
  1. package/README.md +765 -23
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -1,34 +1,776 @@
1
1
  # @softomnitel/omnicall-kit
2
2
 
3
- Browser SDK client for OmniCall Desktop.
3
+ Browser SDK client for OmniCall Desktop (local protocol).
4
+ Каноническая документация для интеграторов — ниже (русский гайд). Полная копия в репозитории: `docs/guide/RU-DEVELOPER-GUIDE.md`. EN pages: `docs/guide/`.
4
5
 
5
- **Status:** product namespaces through SDK-08 + official browser WebSocket transport defaults.
6
- Call/operator/account mutations and lifecycle are on `OmniCallClient`.
6
+ ```bash
7
+ npm install @softomnitel/omnicall-kit
8
+ ```
7
9
 
8
- Depends on `@softomnitel/omnicall-protocol` only. Must never import OmniCall Desktop Domain,
9
- Application, Electron, JsSIP, React, or Zustand.
10
+ ---
10
11
 
11
- Public surface (highlights):
12
+ # OmniCall Kit — руководство для разработчиков
12
13
 
13
- - `createAuthClient`pairing / PoP / capabilities
14
- - `createOmniCallClient` — product API on top of auth
15
- - `createBrowserWebSocketTransport` — official `TransportPort` over browser `WebSocket`
16
- - `createBrowserScheduler` / `createBrowserJitterSource` — production timer/jitter defaults
17
- - `createIndexedDbPopKeyStore` / `createMemoryPopKeyStore` — PoP persistence
18
- - Type helpers: `OmniCallEventOf`, snapshot/capability re-exports, typed error readers
19
- (`readInteractionRequiredDetails`, …) — see `docs/guide/typescript.md`
14
+ Канонический русскоязычный гайд для интеграторов CRM: один файл от установки до продакшена. Источники правды по символам API: [`etc/api/sdk.api.md`](../../etc/api/sdk.api.md) и EN pages в `docs/guide/`. Если формулировка здесь расходится с API report — побеждает report.
20
15
 
21
- Constructor options `transportFactory`, `scheduler`, and `jitter` are **optional** in
22
- browsers (defaults above). Unit tests should still inject FakeTransport + fake scheduler.
16
+ ## Статус пакета
23
17
 
24
- Internal production modules (not all exported as helpers):
18
+ Пакет `@softomnitel/omnicall-kit` тонкий браузерный клиент к локальному gateway OmniCall Desktop. SIP, OCP и Call Engine остаются на desktop; в браузере нет второго softphone и нет SIP-стека.
25
19
 
26
- - explicit connection state machine
27
- - request correlation, timeouts, abort/disconnect cleanup
28
- - heartbeat and bounded jittered reconnect
29
- - snapshot cache, sequence-gap resync, redaction-safe diagnostics
20
+ | Поле | Значение |
21
+ | --- | --- |
22
+ | npm | `@softomnitel/omnicall-kit` (+ транзитивно `@softomnitel/omnicall-protocol`) |
23
+ | Stable | `0.1.0` (`latest`) |
24
+ | RC | `0.1.0-rc.0` (`rc`) |
25
+ | Feature Registry | F-011 — **implemented** (DI-10 closed) |
26
+ | Браузеры | Chromium / Edge (Chromium). Firefox/Safari — не заявлены |
27
+ | Модуль | ESM only, Node `>=20.19.0` |
28
+ | SDK **не** делает | SIP/OCP auth secrets, telephony FSM, `window.Softphone`, fetch-fallback к desktop HTTP |
30
29
 
31
- `FakeTransport` / test helpers live under `src/internal/` for unit tests only and are
32
- excluded from the published `dist/` tarball.
30
+ ---
33
31
 
34
- See `docs/guide/transport.md` for the transport contract.
32
+ ## Кому и зачем
33
+
34
+ Аудитория: frontend/CRM-разработчики (JS/TS), которые встраивают SDK в HTTPS-страницу CRM при уже установленном OmniCall Desktop.
35
+
36
+ Цель интеграции:
37
+
38
+ 1. Pairing + PoP-сессия к loopback WebSocket gateway.
39
+ 2. UI CRM = redacted **snapshot** + публичные **events**.
40
+ 3. Мутации звонков / оператора / окна / logout / activate — только через namespaced API с `expectedRevision` и server-issued capabilities.
41
+
42
+ Не цель: зеркалировать Domain Events desktop, хранить секреты в браузере, изобретать второй Call Engine.
43
+
44
+ ---
45
+
46
+ ## Быстрый старт (5 минут)
47
+
48
+ Конструктор **без** сетевых side effects. Сеть начинается только на `connect()`.
49
+
50
+ <details>
51
+ <summary>Пример: минимальный connect → ready → snapshot</summary>
52
+
53
+ ```ts
54
+ import {
55
+ createOmniCallClient,
56
+ createIndexedDbPopKeyStore,
57
+ createMemoryPopKeyStore,
58
+ isOmniCallClientError
59
+ } from '@softomnitel/omnicall-kit';
60
+
61
+ const keyStore =
62
+ typeof indexedDB !== 'undefined'
63
+ ? createIndexedDbPopKeyStore({ installId: 'crm-install-1' })
64
+ : createMemoryPopKeyStore();
65
+
66
+ const client = createOmniCallClient({
67
+ url: 'ws://127.0.0.1:17341/omnicall/v1/ws',
68
+ origin: 'https://crm.example', // exact Origin
69
+ application: { name: 'my-crm', version: '1.2.0' },
70
+ sdkVersion: '0.1.0',
71
+ requestedProfile: 'call_controller',
72
+ requestedCapabilities: [
73
+ 'session.read.redacted',
74
+ 'window.show',
75
+ 'call.originate',
76
+ 'call.control',
77
+ 'session.logout',
78
+ 'operator.status.write'
79
+ ],
80
+ keyStore
81
+ // transportFactory / scheduler / jitter опущены → browser defaults
82
+ });
83
+
84
+ client.onPairingRequired((info) => {
85
+ // Пользователь должен Allow Origin (TOFU) и Approve pairing в OmniCall
86
+ void info.origin;
87
+ void info.requestedProfile;
88
+ });
89
+
90
+ client.onStateChange((state) => {
91
+ void state;
92
+ });
93
+
94
+ await client.connect();
95
+ await client.waitUntil((s) => s === 'ready');
96
+
97
+ const snapshot = await client.getSnapshot();
98
+ const revision = snapshot.revision;
99
+
100
+ client.subscribe('call:incoming', (event) => {
101
+ void event.type; // не логируйте полный payload в проде
102
+ });
103
+
104
+ void revision;
105
+ void isOmniCallClientError;
106
+ ```
107
+
108
+ </details>
109
+
110
+ <details>
111
+ <summary>Почему так / на что смотреть</summary>
112
+
113
+ - PoP: IndexedDB в браузере, memory в тестах — никогда Web Storage.
114
+ - Privileged caps (`account.activate`, `window.hide`) **не** запрашивайте на pairing — SDK strips.
115
+ - После `ready` берите revision из snapshot / `getRevision()` перед мутациями.
116
+ - Runnable (fake peer): [`examples/crm-pairing-lite/`](../../examples/crm-pairing-lite/).
117
+
118
+ </details>
119
+
120
+ Краткая лестница состояний:
121
+
122
+ | Состояние | UI хоста |
123
+ | --- | --- |
124
+ | `idle` | Кнопка Connect |
125
+ | `connecting` / `handshaking` / `authenticating` | Spinner |
126
+ | `pairing_required` | «Подтвердите в OmniCall» |
127
+ | `ready` | Включить product UI |
128
+ | `reconnecting` | Non-blocking banner |
129
+ | `revoked` / `incompatible` / `failed` / `closed` | Очистить сессию; re-pair при необходимости |
130
+
131
+ EN: [pairing-quick-start.md](../../docs/guide/pairing-quick-start.md).
132
+
133
+ ---
134
+
135
+ ## Модель системы (как это устроено)
136
+
137
+ ```text
138
+ CRM (HTTPS)
139
+ → @softomnitel/omnicall-kit (OmniCallClient)
140
+ → TransportPort (createBrowserWebSocketTransport)
141
+ → ws://127.0.0.1:…/omnicall/v1/ws
142
+ → OmniCall Desktop gateway (Electron main)
143
+ → Application / Call Engine / SIP / OCP
144
+ ```
145
+
146
+ | Роль | Владелец правды |
147
+ | --- | --- |
148
+ | Telephony / operator FSM / credentials | Desktop |
149
+ | Session auth (pairing, PoP, capabilities) | Desktop + SDK auth orchestrator |
150
+ | UI CRM state | Snapshot + public events (клиент — thin reader/writer) |
151
+ | Transport bytes | Тонкий `TransportPort` — без JSON parse и без reconnect |
152
+
153
+ Discovery / Local Network Access (HTTPS → loopback): см. [installation.md](../../docs/guide/installation.md) и [transport.md](../../docs/guide/transport.md). Ошибки LNA: `local_network_permission_required` / `_denied`.
154
+
155
+ Официальный транспорт: `createBrowserWebSocketTransport`. Не парсите JSON сами. Defaults: `createBrowserScheduler` / `createBrowserJitterSource`. В unit-тестах inject FakeTransport + fake scheduler/jitter.
156
+
157
+ Shared desk (ADR-0021): любой авторизованный paired-клиент с grant в Origin matrix может управлять тем же call state. Ownership на wire — информационный; transfer/conference на SDK **не** экспонированы.
158
+
159
+ ---
160
+
161
+ ## Установка и окружение
162
+
163
+ ```bash
164
+ npm install @softomnitel/omnicall-kit
165
+ # pin: npm install @softomnitel/omnicall-kit@0.1.0
166
+ # RC: npm install @softomnitel/omnicall-kit@rc
167
+ ```
168
+
169
+ | Требование | Значение |
170
+ | --- | --- |
171
+ | Node (tooling) | `>=20.19.0` |
172
+ | Module | ESM only |
173
+ | Web Crypto | ECDSA P-256, non-extractable (PoP) |
174
+ | IndexedDB | Durable PoP в браузере |
175
+ | Desktop | OmniCall с включённым SDK gateway |
176
+
177
+ Импорты для CRM — из одного пакета:
178
+
179
+ ```ts
180
+ import {
181
+ createOmniCallClient,
182
+ createIndexedDbPopKeyStore,
183
+ isOmniCallClientError,
184
+ type OmniCallClient,
185
+ type OmniCallEventOf,
186
+ type SnapshotMessage,
187
+ type CapabilityId
188
+ } from '@softomnitel/omnicall-kit';
189
+ ```
190
+
191
+ `@softomnitel/omnicall-protocol` нужен только для Zod schemas / fixtures — не для day-to-day CRM. EN: [typescript.md](../../docs/guide/typescript.md), [installation.md](../../docs/guide/installation.md).
192
+
193
+ ---
194
+
195
+ ## Жизненный цикл клиента и состояния
196
+
197
+ ### Создание
198
+
199
+ `createOmniCallClient(options)` / `createAuthClient(options)` — `OmniCallClientOptions` = `AuthClientOptions`.
200
+
201
+ | Опция | Назначение |
202
+ | --- | --- |
203
+ | `url` | Loopback WS endpoint |
204
+ | `origin` | Exact Origin страницы CRM |
205
+ | `application` | `{ name, version }` |
206
+ | `sdkVersion` | Версия SDK-строки на wire |
207
+ | `requestedProfile` | `presentation` \| `operator` \| `call_controller` |
208
+ | `requestedCapabilities?` | Non-privileged only; sanitize + strip privileged |
209
+ | `keyStore` | `PopKeyStore` (IndexedDB / memory) |
210
+ | `transportFactory?` | Default: browser WS |
211
+ | `scheduler?` / `jitter?` | Default: browser; inject в тестах |
212
+ | `reconnect?` | `ReconnectPolicy` |
213
+ | `heartbeat?` | `HeartbeatPolicy` |
214
+ | `diagnostics?` | Redaction-safe sink |
215
+ | `defaultRequestTimeoutMs?` | Таймаут обычных команд |
216
+
217
+ `createAuthClient` — только lifecycle/auth, без `calls` / `operator` / `account` / `window`.
218
+
219
+ ### Состояния `CONNECTION_STATES`
220
+
221
+ | State | Смысл | Действие хоста |
222
+ | --- | --- | --- |
223
+ | `idle` | Сконструирован | Показать Connect |
224
+ | `connecting` | Открытие транспорта | Spinner |
225
+ | `handshaking` | Hello / protocol | Spinner |
226
+ | `pairing_required` | Нужен Approve в desktop | Инструкция оператору |
227
+ | `authenticating` | PoP challenge | Spinner |
228
+ | `ready` | Сессия + snapshot path | Product UI |
229
+ | `reconnecting` | Bounded retry | Banner; не replay мутаций |
230
+ | `incompatible` | Protocol mismatch | Upgrade; стоп |
231
+ | `revoked` | Сессия отозвана | Clear + re-pair |
232
+ | `failed` | Невосстановимый сбой | Показать ошибку (`getConnectError`) |
233
+ | `closed` | Закрыто | Connect заново при необходимости |
234
+
235
+ Типичный happy path: `idle` → `connecting` → `handshaking` → (`pairing_required` →) `authenticating` → `ready`.
236
+
237
+ ### Подписки и методы lifecycle
238
+
239
+ | API | Назначение |
240
+ | --- | --- |
241
+ | `onStateChange(listener)` | Смена `ConnectionState`; возвращает unsubscribe |
242
+ | `onPairingRequired(listener)` | Origin/profile/clientId для UX pairing |
243
+ | `subscribe(type, listener)` | Публичные product events (`PUBLIC_EVENT_TYPES`) |
244
+ | `connect()` | Старт сети |
245
+ | `disconnect()` | Закрыть WS; **не** hangup/logout/activate/hide |
246
+ | `waitUntil(predicate, timeoutMs?)` | Дождаться состояния |
247
+ | `getState()` / `getSession()` / `getGrantedCapabilities()` | Интроспекция |
248
+ | `getConnectError()` | Последняя ошибка connect |
249
+ | `getSnapshot()` / `getCachedSnapshot()` / `getRevision()` | Snapshot / revision |
250
+ | `preauthDropCount()` | Диагностика preauth drops |
251
+
252
+ <details>
253
+ <summary>Пример: waitUntil ready + обработка connect error</summary>
254
+
255
+ ```ts
256
+ client.onStateChange((state) => {
257
+ if (state === 'failed') {
258
+ const err = client.getConnectError();
259
+ void err?.code;
260
+ }
261
+ });
262
+
263
+ try {
264
+ await client.connect();
265
+ await client.waitUntil((s) => s === 'ready', 60_000);
266
+ } catch (error: unknown) {
267
+ if (isOmniCallClientError(error)) {
268
+ void error.code;
269
+ }
270
+ throw error;
271
+ }
272
+ ```
273
+
274
+ </details>
275
+
276
+ ---
277
+
278
+ ## Pairing, Origin и capabilities
279
+
280
+ ### Profiles
281
+
282
+ | Profile | Default caps (non-privileged) |
283
+ | --- | --- |
284
+ | `presentation` | `session.read.redacted`, `window.show` |
285
+ | `operator` | presentation + `operator.status.write`, `operator.campaign.read`, `ocp.acd_context.read`, `session.logout` |
286
+ | `call_controller` | operator + `call.originate`, `call.control`, granular `call.answer\|reject\|hangup\|hold\|mute` |
287
+
288
+ Источник: protocol `DEFAULT_CAPABILITY_PROFILES`. `account.activate` и `window.hide` **никогда** не в defaults.
289
+
290
+ ### Origin
291
+
292
+ - Exact match строки Origin — без wildcard / substring.
293
+ - Первый контакт: TOFU Allow/Deny в OmniCall (ADR-0018).
294
+ - Blacklist → `origin_blocked` (сокет не поднимается) — Unblock в Settings → OmniCall Kit.
295
+ - Deny → `forbidden` + `origin_denied`, затем close.
296
+
297
+ ### Capabilities: request vs grant
298
+
299
+ | Правило | Деталь |
300
+ | --- | --- |
301
+ | Client request | Только non-privileged; SDK `sanitizeRequestedCapabilities` |
302
+ | Strip always | `account.activate`, `window.hide` |
303
+ | Desktop grant | Intersection: pairing session ∩ per-Origin matrix |
304
+ | Host видит | Только `getGrantedCapabilities()` |
305
+ | Privileged elevation | Только Settings → Origin matrix (оператор) |
306
+
307
+ | Capability | Privileged? | Типичный метод |
308
+ | --- | --- | --- |
309
+ | `session.read.redacted` | Нет | snapshot / events |
310
+ | `window.show` | Нет | `window.show` |
311
+ | `window.hide` | **Да** | `window.hide` |
312
+ | `call.originate` | Нет | `calls.originate` |
313
+ | `call.control` | Нет | umbrella + DTMF |
314
+ | `call.answer` / `reject` / `hangup` / `hold` / `mute` | Нет | соответствующие методы |
315
+ | `operator.status.write` | Нет | `changeStatus` / `finishAppeal` |
316
+ | `operator.campaign.read` | Нет | campaign events + snapshot |
317
+ | `ocp.acd_context.read` | Нет | `call:acd-context` |
318
+ | `session.logout` | Нет | `account.logout` |
319
+ | `account.activate` | **Да** | `account.activateProfile` |
320
+
321
+ Revoke / matrix shrink mid-session → `forbidden` / state `revoked`. Хост: очистить UI, предложить re-pair / открыть Settings.
322
+
323
+ EN: [capabilities.md](../../docs/guide/capabilities.md), [pairing-quick-start.md](../../docs/guide/pairing-quick-start.md).
324
+
325
+ ---
326
+
327
+ ## Snapshot и revision
328
+
329
+ Snapshot — источник UI state после `ready` и после каждого успешного reconnect.
330
+
331
+ | API | Смысл |
332
+ | --- | --- |
333
+ | `getSnapshot()` | Свежий snapshot с desktop |
334
+ | `getCachedSnapshot()` | Последний кэш или `undefined` |
335
+ | `getRevision()` | Текущий cached revision или `undefined` |
336
+ | Mutation `expectedRevision` | Обязателен; иначе риск `stale_state` |
337
+
338
+ Правила:
339
+
340
+ 1. Перед мутацией: `client.getRevision() ?? (await client.getSnapshot()).revision`.
341
+ 2. После успеха: используйте `revision` из результата / обновите snapshot.
342
+ 3. После `stale_state`: новый snapshot; **не** слепой retry со старым числом.
343
+ 4. Events **не** патчат snapshot cache — при gap SDK сам тянет snapshot.
344
+ 5. Desktop coarse-advances revision на смене coarse operator status, reasonId, connected, reservation booking — не на каждом talking↔hold внутри `unknown`.
345
+
346
+ <details>
347
+ <summary>Пример: безопасная мутация с revision</summary>
348
+
349
+ ```ts
350
+ async function withFreshRevision<T>(
351
+ client: {
352
+ getRevision: () => number | undefined;
353
+ getSnapshot: () => Promise<{ revision: number }>;
354
+ },
355
+ run: (expectedRevision: number) => Promise<T>
356
+ ): Promise<T> {
357
+ const expectedRevision =
358
+ client.getRevision() ?? (await client.getSnapshot()).revision;
359
+ return run(expectedRevision);
360
+ }
361
+ ```
362
+
363
+ </details>
364
+
365
+ ---
366
+
367
+ ## API (namespaces)
368
+
369
+ Публичный клиент: `OmniCallClient`. Ниже — только символы из API report.
370
+
371
+ ### `client.calls`
372
+
373
+ Все мутации требуют `expectedRevision`. Результат: `{ callId, revision }`.
374
+
375
+ | Метод | Capability | Типичные ошибки |
376
+ | --- | --- | --- |
377
+ | `originate({ destination, expectedRevision })` | `call.originate` | `forbidden`, `not_ready`, `stale_state`, `conflict`, `operation_failed` (`sip_not_registered`) |
378
+ | `answer` / `reject` / `hangup` | `call.answer` / `reject` / `hangup` или `call.control` | `forbidden`, `not_found`, `stale_state`, `conflict` |
379
+ | `hold` / `resume` | `call.hold` или `call.control` | то же |
380
+ | `mute` / `unmute` | `call.mute` или `call.control` | то же |
381
+ | `sendDtmf({ callId, digits, expectedRevision })` | **только** `call.control` | `forbidden`, `stale_state`, `not_found` |
382
+
383
+ Shared desk: см. ADR-0021 — ownership не блокирует control. Transfer/conference на SDK нет.
384
+
385
+ <details>
386
+ <summary>Пример: originate с обработкой ошибок</summary>
387
+
388
+ ```ts
389
+ import {
390
+ isOmniCallClientError,
391
+ isConflictError,
392
+ isOperationFailedError,
393
+ readOperationFailedDetails
394
+ } from '@softomnitel/omnicall-kit';
395
+
396
+ async function originateSafe(
397
+ client: OmniCallClient,
398
+ destination: string
399
+ ): Promise<void> {
400
+ const revision =
401
+ client.getRevision() ?? (await client.getSnapshot()).revision;
402
+ try {
403
+ await client.calls.originate({ destination, expectedRevision: revision });
404
+ } catch (error: unknown) {
405
+ if (isOperationFailedError(error)) {
406
+ const details = readOperationFailedDetails(error.details);
407
+ if (details?.failure_kind === 'sip_not_registered') {
408
+ return; // preflight deny — события call:failed не будет
409
+ }
410
+ }
411
+ if (isConflictError(error)) return;
412
+ if (!isOmniCallClientError(error)) throw error;
413
+ if (error.code === 'stale_state') {
414
+ await client.getSnapshot();
415
+ }
416
+ }
417
+ }
418
+ ```
419
+
420
+ </details>
421
+
422
+ ### `client.window`
423
+
424
+ | Метод | Capability / заметка |
425
+ | --- | --- |
426
+ | `show()` | `window.show` — focus softphone |
427
+ | `hide({ expectedRevision })` | Privileged `window.hide`; busy telephony → `conflict`; recovery: tray Show / `show()` |
428
+ | `getState()` | `{ visible, revision }` |
429
+
430
+ `hide` не запрашивайте на pairing. Grant: Settings → Origin matrix.
431
+
432
+ ### `client.account`
433
+
434
+ | Метод | Capability | Заметки |
435
+ | --- | --- | --- |
436
+ | `logout({ reasonId?, expectedRevision })` | `session.logout` | Single-shot; может `interaction_required` |
437
+ | `activateProfile({ login, expectedRevision, mode? })` | **`account.activate` (server-granted)** | `mode?: 'sip_only' \| 'ocp'`; никогда passwords |
438
+
439
+ Константы таймаутов activate (re-export из protocol):
440
+
441
+ | Константа | Смысл |
442
+ | --- | --- |
443
+ | `SDK_ACTIVATE_CONSENT_TTL_MS` | Consent modal (~120 s) |
444
+ | `SDK_ACTIVATE_SIP_ONLY_AUTH_BUDGET_MS` | SIP-only auth budget |
445
+ | `SDK_ACTIVATE_OCP_AUTH_BUDGET_MS` | OCP auth budget |
446
+ | `SDK_ACTIVATE_CLIENT_TIMEOUT_MS` | Client wait ceiling (~420 s) |
447
+
448
+ EN: [saved-profile-activation.md](../../docs/guide/saved-profile-activation.md), [logout-workflow.md](../../docs/guide/logout-workflow.md).
449
+
450
+ <details>
451
+ <summary>Пример: activate только после feature-detect</summary>
452
+
453
+ ```ts
454
+ if (!client.getGrantedCapabilities().includes('account.activate')) {
455
+ // Попросите оператора включить account.activate для Origin
456
+ return;
457
+ }
458
+
459
+ await client.account.activateProfile({
460
+ login: 'agent42',
461
+ expectedRevision,
462
+ mode: 'sip_only'
463
+ });
464
+ ```
465
+
466
+ </details>
467
+
468
+ ### `client.operator`
469
+
470
+ | Метод | Capability | Результат |
471
+ | --- | --- | --- |
472
+ | `getReasons()` | session read path | `{ reasons, revision }` |
473
+ | `changeStatus({ target, reasonId?, expectedRevision })` | `operator.status.write` | `kind: 'applied' \| 'reserved'` |
474
+ | `finishAppeal({ expectedRevision })` | `operator.status.write` | только при `post_call_processing` |
475
+
476
+ **Не** изобретайте отдельный reserve API. Всегда `changeStatus`; читайте `kind`. Booking виден в snapshot/event: `reservedTarget` / `reservedReasonId`.
477
+
478
+ | Ситуация | `changeStatus` |
479
+ | --- | --- |
480
+ | Idle Ready/Break | `applied` |
481
+ | Busy / PCP | `reserved` (chip не сразу Break/Ready) |
482
+
483
+ EN: [operator-status-reservation.md](../../docs/guide/operator-status-reservation.md).
484
+
485
+ ### Logout (single-shot)
486
+
487
+ Нет prepare/confirm handshake и нет `logoutToken`.
488
+
489
+ 1. При необходимости: `getReasons()` → `kind === 'logout'`.
490
+ 2. `account.logout({ expectedRevision })` или с `reasonId`.
491
+ 3. При `interaction_required` — modal с reasons; повторный `logout` со свежим revision.
492
+ 4. Cancel = просто не вызывать снова. `disconnect()` ≠ logout.
493
+
494
+ <details>
495
+ <summary>Пример: logout с interaction_required</summary>
496
+
497
+ ```ts
498
+ import {
499
+ isOmniCallClientError,
500
+ isInteractionRequiredError,
501
+ readInteractionRequiredDetails
502
+ } from '@softomnitel/omnicall-kit';
503
+
504
+ const { reasons } = await client.operator.getReasons();
505
+ const logoutReasons = reasons.filter((r) => r.kind === 'logout');
506
+
507
+ try {
508
+ await client.account.logout({ expectedRevision });
509
+ } catch (error: unknown) {
510
+ if (!isInteractionRequiredError(error)) {
511
+ if (isOmniCallClientError(error)) throw error;
512
+ throw error;
513
+ }
514
+ const details = readInteractionRequiredDetails(error.details);
515
+ const reasonId = details?.reasons[0]?.id ?? logoutReasons[0]?.id;
516
+ if (reasonId === undefined) return;
517
+ const next =
518
+ client.getRevision() ?? (await client.getSnapshot()).revision;
519
+ await client.account.logout({ reasonId, expectedRevision: next });
520
+ }
521
+ ```
522
+
523
+ </details>
524
+
525
+ Полный перечень публичных символов (77): [api-reference.md](../../docs/guide/api-reference.md).
526
+
527
+ ---
528
+
529
+ ## События
530
+
531
+ Подписка только на имена из `PUBLIC_EVENT_TYPES`. Domain Event names desktop — запрещены.
532
+
533
+ | Событие | Зачем хосту | Snapshot-участок |
534
+ | --- | --- | --- |
535
+ | `call:incoming` | Answer/reject UI; optional `queueLabel` | `sections.calls` |
536
+ | `call:outgoing` / `call:ringing` / `call:answered` | Dial/ring/active | `sections.calls` |
537
+ | `call:ended` / `call:failed` | Tear down / toast | `sections.calls` |
538
+ | `call:held` / `call:resumed` | Hold | `sections.calls` |
539
+ | `call:muted` / `call:unmuted` | Mute | `sections.calls` |
540
+ | `call:acd-context` | OCP MainCallIDInfo (`ocp.acd_context.read`) | `calls[].acdContext` |
541
+ | `registration:changed` | SIP badge | registration section |
542
+ | `account:session-activated` / `account:session-ended` | Signed-in projection | account |
543
+ | `operator:session-changed` | `connected` | `sections.operator` |
544
+ | `operator:status-changed` | Coarse status + booking fields | `sections.operator` |
545
+ | `operator:campaign-offered` / `cleared` | Campaign UI (`operator.campaign.read`) | `operator.campaign` |
546
+ | `window:visibility-changed` | Softphone visibility | window |
547
+ | `sdk:server-shutdown` | Desktop stopping — wait/reconnect | — |
548
+
549
+ Redaction: телефоны маскированы; никаких OCP wire objects в логах. Sequence gap → SDK делает fresh `getSnapshot()`.
550
+
551
+ Не через `subscribe` (by design): `sdk:permission-changed` → перечитайте `getGrantedCapabilities()`; `sdk:revoked` → state `revoked`.
552
+
553
+ Типизация: `OmniCallEventOf<'call:incoming'>`. EN: [events.md](../../docs/guide/events.md), [typescript.md](../../docs/guide/typescript.md).
554
+
555
+ <details>
556
+ <summary>Пример: subscribe + campaign recovery</summary>
557
+
558
+ ```ts
559
+ const stop = client.subscribe('operator:status-changed', (event) => {
560
+ void event.payload.status;
561
+ void event.payload.reservedTarget;
562
+ });
563
+
564
+ client.subscribe('call:acd-context', (event) => {
565
+ void event.payload.acallid;
566
+ void event.payload.queue;
567
+ });
568
+
569
+ // После reconnect — snapshot, не replay events
570
+ const snap = await client.getSnapshot();
571
+ void snap.sections.operator?.campaign;
572
+ void snap.sections.calls;
573
+
574
+ stop();
575
+ ```
576
+
577
+ </details>
578
+
579
+ `queueLabel` на `call:*` — additive desktop-safe title; повтор с тем же `callId` — enrichment, не второй lead.
580
+
581
+ ---
582
+
583
+ ## Ошибки
584
+
585
+ Класс: `OmniCallClientError` (`code`, `retryable`, `currentRevision?`, `details?`).
586
+
587
+ | Guard / reader | Когда |
588
+ | --- | --- |
589
+ | `isOmniCallClientError` | Базовая проверка |
590
+ | `isConflictError` + `readConflictErrorDetails` | `conflict` |
591
+ | `isInteractionRequiredError` + `readInteractionRequiredDetails` | logout reason / UI step |
592
+ | `isOperationFailedError` + `readOperationFailedDetails` | e.g. `sip_not_registered` |
593
+ | `isOriginBlockedError` | Blacklisted Origin |
594
+
595
+ | Code | Смысл | Next step |
596
+ | --- | --- | --- |
597
+ | `forbidden` | Нет cap / Origin policy / consent Deny | Settings; не tight-loop |
598
+ | `not_ready` | Не `ready` / broker | Wait / connecting UI |
599
+ | `timeout` | Нет ответа | Retry UI; не auto-replay non-idempotent |
600
+ | `stale_state` | Revision mismatch | `getSnapshot()`; retry once |
601
+ | `conflict` | Busy / race / consent pending / hide while busy | Показать конфликт; logout-first для activate |
602
+ | `not_found` | Unknown call/login/reason | Refresh / correct login |
603
+ | `invalid_payload` | Wire fail-closed | Bug / mismatch |
604
+ | `interaction_required` | Human step | Modal / Account UI |
605
+ | `revoked` | Session revoked | Clear; re-pair |
606
+ | `incompatible_version` | Protocol mismatch | Upgrade |
607
+ | `unauthenticated` | Нужна auth | Reconnect / pair |
608
+ | `rate_limited` | Back off | Jitter |
609
+ | `operation_failed` | Generic | Log code; check `failure_kind` |
610
+ | `local_network_permission_*` | LNA | Объяснить Allow local network |
611
+ | `discovery_unreachable` | Desktop down | Запустить OmniCall |
612
+ | `origin_blocked` | Blacklist | Unblock в Settings |
613
+ | `not_owner` | Reserved; не для shared-desk call control | Unexpected — refresh |
614
+ | `invalid_message` / `unsupported_command` | Protocol mismatch | Fail closed |
615
+
616
+ Логируйте: `code`, `retryable`, `requestId` / command type. Не логируйте: полный `details`, токены, destinations сверх политики, wire frames.
617
+
618
+ EN: [errors.md](../../docs/guide/errors.md).
619
+
620
+ ---
621
+
622
+ ## Reconnect и несколько вкладок
623
+
624
+ | Свойство | Поведение |
625
+ | --- | --- |
626
+ | Policy | Bounded (`maxAttempts`), jittered, cancellable |
627
+ | После успеха | Новый auth + **fresh snapshot** |
628
+ | In-flight mutations | Rejected typed; **не** auto-resent |
629
+ | Host intent | Переиздать после нового `expectedRevision` |
630
+
631
+ `disconnect()` закрывает транспорт и чистит timers/pending — **без** hangup / logout / activate / hide и без teardown desktop SIP/account.
632
+
633
+ | Multi-tab сценарий | Ожидание | UX |
634
+ | --- | --- | --- |
635
+ | Две вкладки originate | `conflict` / `stale_state` | Одна «ведущая» вкладка |
636
+ | A hold, B hangup | Обе могут успеть; later → stale | Sync из snapshot/events |
637
+ | Новый браузер после pairing | Тот же call state | `getSnapshot()` + subscribe |
638
+ | Общий PoP `installId` | Та же client identity | Один активный controller |
639
+
640
+ Никогда не делайте «retry all failed mutations on reconnect». EN: [reconnect-multi-tab.md](../../docs/guide/reconnect-multi-tab.md).
641
+
642
+ <details>
643
+ <summary>Пример: banner на reconnecting</summary>
644
+
645
+ ```ts
646
+ client.onStateChange((state) => {
647
+ if (state === 'reconnecting') {
648
+ // Banner only — не resend originate/hangup/logout/activate
649
+ }
650
+ if (state === 'ready') {
651
+ void client.getSnapshot();
652
+ }
653
+ });
654
+ ```
655
+
656
+ </details>
657
+
658
+ ---
659
+
660
+ ## Типовые сценарии CRM
661
+
662
+ ### 1. Softphone control panel (presentation / operator)
663
+
664
+ Pair с `presentation` или `operator` → snapshot registration/operator → `window.show` → status UI → logout при необходимости.
665
+
666
+ ### 2. Dial from CRM card (`call_controller`)
667
+
668
+ `ready` → проверка `call.originate` → `originate({ destination, expectedRevision })` → UI из `call:*` events + snapshot calls.
669
+
670
+ ### 3. Incoming queue agent
671
+
672
+ Subscribe `call:incoming` (+ optional `queueLabel` / `call:acd-context`) → `answer` / `reject` → hold/mute/hangup через `call.control`.
673
+
674
+ ### 4. Post-call processing
675
+
676
+ `changeStatus({ target: 'break', … })` во время разговора → `kind: 'reserved'` → UI booking из `reservedTarget` → при `post_call_processing` → `finishAppeal`.
677
+
678
+ ### 5. Saved-account activate (privileged)
679
+
680
+ Оператор включает `account.activate` в Origin matrix → feature-detect → `activateProfile({ login, mode? })` → consent modal на desktop → без паролей в CRM.
681
+
682
+ ### 6. Hide softphone (privileged)
683
+
684
+ Grant `window.hide` → `hide({ expectedRevision })` только когда telephony idle; при `conflict` — tray / `show()`.
685
+
686
+ ---
687
+
688
+ ## Best practices
689
+
690
+ 1. Делайте один `OmniCallClient` на вкладку/сессию CRM.
691
+ 2. Держите UI state = snapshot + events; не дублируйте telephony FSM в CRM.
692
+ 3. Вызывайте мутации только после `ready` и проверки capability.
693
+ 4. Всегда обновляйте revision из последнего успешного результата или fresh snapshot.
694
+ 5. Логируйте только `code` / `requestId` / command type — не payload.
695
+ 6. Privileged flows — только через `getGrantedCapabilities()` feature-detect.
696
+ 7. В тестах: `createMemoryPopKeyStore` + fake transport/scheduler; browser E2E отдельно.
697
+ 8. На HTTPS CRM заранее объясняйте пользователю LNA / Local Network permission.
698
+ 9. Не оборачивайте `TransportPort` собственной reconnect-логикой.
699
+ 10. Pin версию `@softomnitel/omnicall-kit`; читайте [upgrade-deprecation.md](../../docs/guide/upgrade-deprecation.md).
700
+ 11. При `stale_state` / `conflict` — один осознанный retry после snapshot, не tight-loop.
701
+ 12. Key CRM cards по `callId`; `queueLabel` enrichment — update, не второй lead.
702
+
703
+ ---
704
+
705
+ ## Частые ошибки и anti-patterns
706
+
707
+ | Нельзя | Почему | Как правильно |
708
+ | --- | --- | --- |
709
+ | `requestedCapabilities: ['account.activate']` | Strip; не elevates | Grant в Settings Origin matrix |
710
+ | Request `window.hide` на pairing | Strip | Matrix grant → `window.hide` |
711
+ | SIP password / OCP apiKey в SDK | Secrets только на desktop | `activateProfile({ login })` |
712
+ | PoP в `localStorage` / `sessionStorage` | XSS | IndexedDB / memory key store |
713
+ | Логировать phones / tokens / payloads | PII / leak | code + retryable + requestId |
714
+ | Auto-replay мутаций после reconnect | Double-originate | Fresh revision + user re-issue |
715
+ | Hangup/logout/activate на `disconnect()` | Desktop session должна жить | `disconnect` = transport only |
716
+ | Custom reconnect в TransportPort | Session owns policy | Thin browser WS adapter |
717
+ | Binary frames / свой JSON parser в port | Validation выше порта | Text frames via `onMessage` |
718
+ | `account:list-profiles` | Нет в protocol v1 | Передайте известный `login` |
719
+ | Auto-logout on disconnect | Destructive | Logout только после confirm |
720
+ | Origin substring / wildcard | Exact only | Exact Origin string |
721
+ | Отдельный CRM `reserveStatus` | OCP FSM leak | Всегда `changeStatus` → `kind` |
722
+ | Mid-call `unknown` как полный OCP enum | Anti-corruption | Coarse + `reservedTarget` |
723
+ | `fetch` fallback к desktop HTTP | Forbidden | Только SDK WS protocol |
724
+ | Patch call graph только из events | Gaps / drift | Snapshot SSoT + events hints |
725
+ | Слепой retry со старым revision | `stale_state` loop | `getSnapshot()` first |
726
+ | Два «ведущих» dialer на вкладках | Races | Single-writer UX |
727
+
728
+ EN: [security-anti-patterns.md](../../docs/guide/security-anti-patterns.md).
729
+
730
+ ---
731
+
732
+ ## Чеклист перед продом
733
+
734
+ - [ ] Установлен `@softomnitel/omnicall-kit@0.1.0` (или осознанный pin/`rc`)
735
+ - [ ] OmniCall Desktop запущен; SDK gateway / Integrations включены
736
+ - [ ] Origin CRM exact match; TOFU Allow проверен; blacklist path понятен
737
+ - [ ] Pairing UX: инструкция при `pairing_required`; revoke → clear + re-pair
738
+ - [ ] PoP в IndexedDB (`installId` стабилен); нет Web Storage для ключей
739
+ - [ ] `requestedCapabilities` без privileged ids
740
+ - [ ] UI строится из snapshot + `PUBLIC_EVENT_TYPES`
741
+ - [ ] Все мутации с fresh `expectedRevision`; обработка `stale_state`
742
+ - [ ] Calls: originate/answer/reject/hangup/hold/mute/dtmf покрыты UX + errors
743
+ - [ ] Operator: `changeStatus`/`finishAppeal`/`getReasons` без самодельного reserve
744
+ - [ ] Logout single-shot + `interaction_required` modal
745
+ - [ ] Privileged `window.hide` / `account.activate` — только после matrix grant + feature-detect
746
+ - [ ] Reconnect: banner, fresh snapshot, no mutation replay
747
+ - [ ] Multi-tab: single-writer или явный user retry
748
+ - [ ] LNA permission объяснена на HTTPS
749
+ - [ ] Логи без PII/secrets; ошибки по `code`
750
+ - [ ] Нет SIP/OCP secrets в CRM bundle
751
+ - [ ] Версии SDK/desktop совместимы ([compatibility-matrix.md](../../docs/guide/compatibility-matrix.md))
752
+ - [ ] Upgrade notes прочитаны ([upgrade-deprecation.md](../../docs/guide/upgrade-deprecation.md))
753
+
754
+ ---
755
+
756
+ ## Куда смотреть дальше
757
+
758
+ | Документ | Зачем |
759
+ | --- | --- |
760
+ | [README гайда (EN index)](../../docs/guide/README.md) | Оглавление EN pages |
761
+ | [API reference](../../docs/guide/api-reference.md) | Namespaces + inventory 77 |
762
+ | [API report](../../etc/api/sdk.api.md) | Канон публичных символов |
763
+ | [Events](../../docs/guide/events.md) | Каталог событий |
764
+ | [Errors](../../docs/guide/errors.md) | Коды + next steps |
765
+ | [Capabilities](../../docs/guide/capabilities.md) | Profiles / privileged |
766
+ | [Pairing quick start](../../docs/guide/pairing-quick-start.md) | Connect ladder |
767
+ | [Transport](../../docs/guide/transport.md) | WebSocket port |
768
+ | [Installation](../../docs/guide/installation.md) | Engines / LNA |
769
+ | [TypeScript](../../docs/guide/typescript.md) | Imports / `OmniCallEventOf` |
770
+ | [Reconnect & multi-tab](../../docs/guide/reconnect-multi-tab.md) | Fresh snapshot policy |
771
+ | [Logout](../../docs/guide/logout-workflow.md) | Single-shot logout |
772
+ | [Saved-account activation](../../docs/guide/saved-profile-activation.md) | activateProfile |
773
+ | [Operator status & reservation](../../docs/guide/operator-status-reservation.md) | applied \| reserved |
774
+ | [Security anti-patterns](../../docs/guide/security-anti-patterns.md) | Forbidden table |
775
+ | [Release & support](../../docs/guide/release-and-support.md) | RC / stable / support |
776
+ | [Example crm-pairing-lite](../../examples/crm-pairing-lite/) | Fake-peer demo |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softomnitel/omnicall-kit",
3
- "version": "0.1.0-rc.0",
3
+ "version": "0.1.0",
4
4
  "description": "Browser client for OmniCall Desktop local protocol (OmniCallClient read path + call control)",
5
5
  "type": "module",
6
6
  "private": false,
@@ -28,7 +28,7 @@
28
28
  "clean": "node -e \"import('node:fs').then((fs)=>fs.promises.rm('dist',{recursive:true,force:true}))\""
29
29
  },
30
30
  "dependencies": {
31
- "@softomnitel/omnicall-protocol": "0.1.0-rc.0"
31
+ "@softomnitel/omnicall-protocol": "0.1.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public",