voxnix 1.0.24 → 2.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.
package/README.md CHANGED
@@ -1,20 +1,30 @@
1
1
  # Voxnix
2
2
 
3
- Universal Voice Widget SDK — SIP-based floating phone widget. Works in React, Vue, Angular, plain HTML, or any stack.
3
+ Headless voice SDK for the Omnix CPaaS platform. Voxnix connects the browser to your PBX over
4
+ SIP/WebSocket, manages the agent status (Ready / Not Ready / AUX)
5
+ through the CPaaS API, and gives you **functions + events** to build your own calling UI in any stack:
6
+ React, Vue, Angular, or plain HTML.
4
7
 
5
- **Current version: `1.0.12`**
8
+ **Current version: `2.1.0`**. Voxnix ships no UI. Upgrading from 1.x? See [Migrating from 1.x](#migrating-from-1x).
6
9
 
7
10
  ---
8
11
 
9
- ## What it does
10
-
11
- - Floating phone button (fixed position, configurable placement)
12
- - Auto-connects to your SIP/PABX server on init
13
- - Shows dialer numpad immediately after registration
14
- - Full call UI: incoming call, outgoing dialing, in-call controls (mute, hold, DTMF keypad)
15
- - **Agent status bar** — Ready / Not Ready / AUX with optional custom AUX codes
16
- - Fires callbacks for all call and agent-status events so your app can react
17
- - Keyboard shortcuts: digits to dial, `Enter` to call, `Backspace` to delete, `0–9`/`*`/`#` for DTMF during a call
12
+ ## Contents
13
+
14
+ - [Installation](#installation)
15
+ - [Quick start](#quick-start)
16
+ - [How it works](#how-it-works)
17
+ - [Config reference](#config-reference)
18
+ - [Instance API](#instance-api)
19
+ - [State](#state)
20
+ - [Events & callbacks](#events--callbacks)
21
+ - [Recipes](#recipes)
22
+ - [Browser requirements](#browser-requirements)
23
+ - [Security](#security)
24
+ - [Sample page](#sample-page)
25
+ - [Build](#build)
26
+ - [Migrating from 1.x](#migrating-from-1x)
27
+ - [Changelog](#changelog)
18
28
 
19
29
  ---
20
30
 
@@ -26,341 +36,437 @@ npm install voxnix
26
36
  yarn add voxnix
27
37
  ```
28
38
 
39
+ Or use a script tag (no bundler):
40
+
41
+ ```html
42
+ <script src="https://unpkg.com/voxnix/dist/voxnix.iife.js"></script>
43
+ <script>
44
+ const voice = OmnixWidget.init({ /* config */ });
45
+ </script>
46
+ ```
47
+
48
+ | File | Format | Use |
49
+ |-------------------|--------|------------------------------|
50
+ | `voxnix.js` | ESM | `import` in bundlers |
51
+ | `voxnix.umd.cjs` | UMD | `require()` / legacy loaders |
52
+ | `voxnix.iife.js` | IIFE | `<script>` tag → `window.OmnixWidget` |
53
+
29
54
  ---
30
55
 
31
- ## Quick start (any framework)
56
+ ## Quick start
32
57
 
33
58
  ```js
34
59
  import { OmnixWidget } from 'voxnix';
35
60
 
36
- const widget = OmnixWidget.init({
61
+ const voice = OmnixWidget.init({
37
62
  auth: {
38
- username: '1001',
39
- pwd_pbx: 'secret',
40
- pabx_host: 'sip.example.com',
41
- port: 8089, // optional, default 8089
63
+ username: '1001', // SIP extension
64
+ pwd_pbx: 'secret', // SIP password
65
+ pabx_host: 'sip.example.com', // PBX host
66
+ port: 8089, // WSS port (default 8089)
42
67
  },
68
+ 'X-CPAAS-Key-Fingerprint': 'sfp_v1_...', // CPaaS API key (agent status)
69
+ cpaas_base_url: 'https://api-cpaas.omnix.co.id',
43
70
 
44
- // Required for agent status API (Ready / AUX / Not Ready)
45
- 'X-CPAAS-Key-Fingerprint': 'your-cpaas-key-fingerprint-here',
71
+ onRegisterStatus: (s) => console.log('register:', s),
72
+ onCallStatus: (s) => console.log('call:', s),
73
+ onIncomingCall: (call) => console.log('incoming from', call.number),
74
+ });
46
75
 
47
- placement: 'bottom-left', // 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'
76
+ voice.dial('1002'); // outgoing call
77
+ voice.hangup(); // end it
78
+ ```
48
79
 
49
- // Agent status options (optional)
50
- autoReady: true, // auto-set agent to Ready after SIP register (default: true)
51
- showAuxButton: false, // show AUX button in status bar (default: false)
52
- auxOptions: [], // array of { code, label } — custom AUX codes
80
+ ---
53
81
 
54
- // Callbacks
55
- onRegisterStatus: (status) => {
56
- // status: 'REGISTERED' | { status: 'FAILED', reason: string }
57
- console.log('SIP status:', status);
58
- },
59
- onAgentStatus: (status) => console.log('Agent:', status), // 'READY' | 'NOT_READY' | 'AUX'
60
- onIncomingCall: (data) => console.log('Incoming:', data),
61
- onCallAnswered: (data) => console.log('Answered:', data),
62
- onCallEnded: (data) => console.log('Ended:', data),
63
- onCallUnanswered: () => console.log('Missed call'),
64
- });
82
+ ## How it works
83
+
84
+ ### Registration flow
65
85
 
66
- // Imperative controls (optional — widget has its own UI)
67
- widget.answer();
68
- widget.hangup();
69
- widget.reject();
70
- widget.mute(true); // mute
71
- widget.mute(false); // unmute
72
- widget.hold(true); // hold
73
- widget.hold(false); // resume
74
- widget.dial('1002'); // programmatic dial
75
- widget.destroy(); // unmount widget
86
+ ```
87
+ init()
88
+ └─ WebSocket connect wss://<sip_url | pabx_host>:<port>
89
+ └─ SIP REGISTER sip:<username>@<pbx_url | pabx_host>
90
+ ├─ autoReady: true (default)
91
+ │ └─ POST Ready to CPaaS ──ok──► register_status 'REGISTERED' + agent_status 'READY'
92
+ │ └─fail─► register_status { status: 'FAILED', reason }
93
+ └─ autoReady: false
94
+ └─ GET extension status from CPaaS
95
+ ├─ already Ready ──► 'REGISTERED' + 'READY'
96
+ └─ otherwise ──► 'SIP_CONNECTED' + 'NOT_READY' | 'AUX'
97
+ (call setReady() to go to 'REGISTERED')
76
98
  ```
77
99
 
78
- ---
100
+ Before every agent-status change, Voxnix checks the extension on CPaaS. If CPaaS reports
101
+ `is_register = 0`, Voxnix sends a SIP re-REGISTER and polls CPaaS (3 × 1s) until it reports
102
+ the extension as registered. If it still isn't registered, the status call returns `false`.
79
103
 
80
- ## Config reference
104
+ ### `register_status` values
81
105
 
82
- ### Top-level options
83
-
84
- | Option | Type | Default | Description |
85
- |-----------------------------|--------------------------------------------------------------------|-----------------|-----------------------------------------------------------------|
86
- | `auth` | `object` | — | SIP credentials (see [auth object](#auth-object)) |
87
- | `pabxConfig` | `{ auth: object }` | — | Alternative config shape — use `pabxConfig.auth` instead of `auth` |
88
- | `X-CPAAS-Key-Fingerprint` | `string` | — | **Required** for agent status API calls. See [section below](#x-cpaas-key-fingerprint) |
89
- | `fingerprint` | `string` | — | Alias for `X-CPAAS-Key-Fingerprint` |
90
- | `cpaasKeyFingerprint` | `string` | — | Alias for `X-CPAAS-Key-Fingerprint` |
91
- | `placement` | `'bottom-left'` \| `'bottom-right'` \| `'top-left'` \| `'top-right'` | `'bottom-left'` | Fixed position of the floating widget |
92
- | `offset` | `{ bottom?, top?, left?, right? }` | — | CSS pixel coords — overrides `placement` |
93
- | `container` | `string` \| `HTMLElement` | — | Mount target. Auto-creates `<div id="omnix-widget-root">` if omitted |
94
- | `autoReady` | `boolean` | `true` | Auto-call the Ready API right after SIP registration |
95
- | `showAuxButton` | `boolean` | `false` | Show the AUX menu in the agent status bar |
96
- | `auxOptions` | `Array<{ code: string, label: string }>` | `[]` | Custom AUX reason codes shown in the status dropdown |
97
- | `onRegisterStatus` | `(status) => void` | — | `'REGISTERED'` or `{ status: 'FAILED', reason: string }` |
98
- | `onAgentStatus` | `(status: string) => void` | — | `'READY'` \| `'NOT_READY'` \| `'AUX'` |
99
- | `onIncomingCall` | `(data) => void` | — | Fired on incoming SIP INVITE |
100
- | `onCallAnswered` | `(data) => void` | — | Fired when call is established |
101
- | `onCallEnded` | `(data) => void` | — | Fired on BYE / hangup |
102
- | `onCallUnanswered` | `() => void` | — | Fired when incoming call is not answered (missed) |
103
-
104
- ### `auth` object
105
-
106
- | Field | Type | Description |
107
- |-------------|----------|------------------------------------------|
108
- | `username` | `string` | SIP username / extension number |
109
- | `user_pbx` | `string` | Alias for `username` |
110
- | `pwd_pbx` | `string` | SIP password |
111
- | `secret` | `string` | Alias for `pwd_pbx` |
112
- | `pabx_host` | `string` | SIP domain / WSS host (strips `http(s)://`) |
113
- | `pbxurl` | `string` | Alias for `pabx_host` |
114
- | `port` | `number` | WSS port (default `8089`) |
106
+ | Value | Meaning |
107
+ |-------------------------------|---------|
108
+ | `'CONNECTING'` | Initial state (only visible via `getState()`) |
109
+ | `'SIP_CONNECTED'` | SIP registered, agent not Ready yet (`autoReady: false`, or after `setNotReady()`) |
110
+ | `'REGISTERED'` | SIP registered **and** agent Ready. Calls can be made and received |
111
+ | `{ status: 'FAILED', reason }`| See reasons below |
115
112
 
116
- ---
113
+ Typical `reason` values:
117
114
 
118
- ## `X-CPAAS-Key-Fingerprint`
115
+ - `Missing SIP credentials (username, password, host)`
116
+ - `WebSocket disconnected, retrying...`: Voxnix keeps reconnecting on its own. When the socket comes back, the registration flow runs again.
117
+ - `SIP registration failed: <cause>`: for example, a wrong password.
118
+ - `SIP Unregistered`
119
+ - `Failed to hit extension ready API`: the fingerprint is missing or wrong, or the CPaaS API rejected the request.
120
+ - `Extension not registered after SIP re-register`
119
121
 
120
- This key is the **API authentication credential** used when the widget calls the CPaaS extension status endpoint:
122
+ ### Call status flow
121
123
 
122
124
  ```
123
- POST https://api-cpaas.omnix.co.id/voice/extensions/status
124
- Header: X-CPAAS-Key-Fingerprint: <your-fingerprint>
125
+ outgoing: Idle ─dial()─► Calling... ─answered─► InCall ─hangup / remote BYE─► Idle
126
+ └─rejected / no answer─► Idle (+ call_failed)
127
+ incoming: Idle ─INVITE─► Ringing ─answer()─► InCall ─► Idle
128
+ └─reject() / caller cancels─► Idle (+ callUnanswered)
125
129
  ```
126
130
 
127
- The endpoint is hit automatically whenever the agent status changes (Ready / AUX / Not Ready). Without this key, status updates will silently fail and — if `autoReady: true` (default) — the widget will report `FAILED` registration even if the SIP connection itself succeeded.
131
+ Only one call at a time. A second incoming call while one is active is automatically rejected
132
+ with `486 Busy Here`.
128
133
 
129
- ### Accepted config locations
134
+ ---
130
135
 
131
- The widget looks for the fingerprint in the following order, using the first match:
136
+ ## Config reference
132
137
 
133
- | Priority | Config key | Example |
134
- |----------|------------|---------|
135
- | 1 | `'X-CPAAS-Key-Fingerprint'` (top-level) | `{ 'X-CPAAS-Key-Fingerprint': 'abc123', auth: { ... } }` |
136
- | 2 | `fingerprint` (top-level) | `{ fingerprint: 'abc123', auth: { ... } }` |
137
- | 3 | `cpaasKeyFingerprint` (top-level) | `{ cpaasKeyFingerprint: 'abc123', auth: { ... } }` |
138
- | 4 | `env.fingerprint` | `{ env: { fingerprint: 'abc123' }, auth: { ... } }` |
139
- | 5 | `env.X_CPAAS_Key_Fingerprint` | `{ env: { X_CPAAS_Key_Fingerprint: 'abc123' }, auth: { ... } }` |
140
- | 6 | `pabxConfig['X-CPAAS-Key-Fingerprint']` | `{ pabxConfig: { 'X-CPAAS-Key-Fingerprint': 'abc123', auth: { ... } } }` |
138
+ `OmnixWidget.init(config)`. There is one engine per page. Calling `init()` again destroys the previous instance first.
141
139
 
142
- ### Recommended usage
140
+ ### SIP credentials: `auth`
143
141
 
144
- ```js
145
- // Option A — top-level key (simplest)
146
- OmnixWidget.init({
147
- 'X-CPAAS-Key-Fingerprint': 'your-fingerprint-here',
148
- auth: { username: '1001', pwd_pbx: 'secret', pabx_host: 'sip.example.com' },
149
- autoReady: true,
150
- });
142
+ | Field | Type | Required | Description |
143
+ |-------------|----------|----------|-------------|
144
+ | `username` | `string` | ✔ | SIP extension (alias: `user_pbx`) |
145
+ | `pwd_pbx` | `string` | ✔ | SIP password (alias: `secret`) |
146
+ | `pabx_host` | `string` | ✔* | PBX host (aliases: `pbxurl`, `host`). `http(s)://` is stripped |
147
+ | `port` | `number` | | WSS port, default `8089` |
151
148
 
152
- // Option B — inside pabxConfig (if your backend returns this shape)
153
- OmnixWidget.init({
154
- pabxConfig: {
155
- 'X-CPAAS-Key-Fingerprint': 'your-fingerprint-here',
156
- auth: { username: '1001', pwd_pbx: 'secret', pabx_host: 'sip.example.com' },
157
- },
158
- });
149
+ \* Not required when `pbx_url` is given.
159
150
 
160
- // Option C — via env object
161
- OmnixWidget.init({
162
- env: { fingerprint: 'your-fingerprint-here' },
163
- auth: { username: '1001', pwd_pbx: 'secret', pabx_host: 'sip.example.com' },
164
- });
165
- ```
151
+ `pabxConfig: { auth: {...} }` is accepted as an alternative to `auth`.
152
+
153
+ ### Hosts & endpoints
166
154
 
167
- > **Important:** If the fingerprint is missing and `autoReady: true` (default), the widget will emit `{ status: 'FAILED', reason: 'Failed to hit extension ready API' }` via `onRegisterStatus` even though the SIP connection itself was successful. Set `autoReady: false` if you do not have a fingerprint yet.
155
+ | Option | Default | Description |
156
+ |------------------|----------------------------------|-------------|
157
+ | `pbx_url` | `auth.pabx_host` | SIP domain used in the SIP URI `sip:<username>@<pbx_url>` (alias: `pabxConfig.pbx_url`) |
158
+ | `sip_url` | SIP domain | WebSocket host: `wss://<sip_url>:<port>`. `ws(s)://` and a trailing `/` are stripped (alias: `pabxConfig.sip_url`) |
159
+ | `cpaas_base_url` | `https://api-cpaas.omnix.co.id` | CPaaS API base URL, must be `https://`. A trailing `/` is stripped (aliases: `cpaasBaseUrl`, `env.CPAAS_BASE_URL`, `pabxConfig.cpaas_base_url`) |
168
160
 
169
- ### Custom container / offset
161
+ Use these when the SIP domain, the WebSocket host and the API live on different hosts:
170
162
 
171
163
  ```js
172
164
  OmnixWidget.init({
173
- container: '#my-widget-slot', // CSS selector or DOM element
174
- // — or —
175
- offset: { bottom: 80, left: 10 }, // exact pixel position (bypasses placement)
176
- // ... rest of config
165
+ auth: { username: '1001', pwd_pbx: '***', pabx_host: 'sip.example.com' },
166
+ 'X-CPAAS-Key-Fingerprint': 'sfp_v1_...',
167
+ cpaas_base_url: 'https://api.example.com/',
168
+ pbx_url: 'pbx.example.com', // SIP domain
169
+ sip_url: 'wss.example.com', // WSS host
177
170
  });
178
171
  ```
179
172
 
173
+ ### `X-CPAAS-Key-Fingerprint`
174
+
175
+ The API key Voxnix sends as a header to the CPaaS endpoints:
176
+
177
+ ```
178
+ GET {cpaas_base_url}/voice/portal/extension?extension=<username> → read status / is_register
179
+ POST {cpaas_base_url}/voice/extensions/status → { extension, status, reason }
180
+ ```
181
+
182
+ Voxnix uses the first of these keys that is present:
183
+
184
+ | Priority | Key |
185
+ |----------|-----|
186
+ | 1 | `'X-CPAAS-Key-Fingerprint'` |
187
+ | 2 | `fingerprint` |
188
+ | 3 | `cpaasKeyFingerprint` |
189
+ | 4 | `env.fingerprint` |
190
+ | 5 | `env.X_CPAAS_Key_Fingerprint` |
191
+ | 6 | `pabxConfig['X-CPAAS-Key-Fingerprint']` |
192
+
193
+ > Without a fingerprint, every agent-status call returns `false`. With `autoReady: true` the
194
+ > engine then reports `FAILED` ("Failed to hit extension ready API") even though SIP registered fine.
195
+
196
+ ### Behaviour
197
+
198
+ | Option | Type | Default | Description |
199
+ |-------------|--------------------------|---------|-------------|
200
+ | `autoReady` | `boolean` | `true` | Set the agent Ready right after SIP registration. If `false`, the engine restores the current CPaaS status and waits for `setReady()` |
201
+ | `ringtone` | `boolean` | `true` | Play the built-in ringtone on an incoming call. Set `false` to play your own |
202
+ | `contacts` | `{ [number]: string }` | — | Name lookup used for `name` when the caller sends no display name |
203
+
204
+ ### Callbacks
205
+
206
+ All callbacks are optional. Payloads are described in [Events & callbacks](#events--callbacks).
207
+
208
+ | Option | Called when |
209
+ |--------------------|-------------|
210
+ | `onRegisterStatus` | Registration becomes `'REGISTERED'` or `FAILED`. `SIP_CONNECTED` is **not** reported here; use `on('register_status')` to see it |
211
+ | `onAgentStatus` | Agent status changes |
212
+ | `onCallStatus` | Call status changes |
213
+ | `onCallFailed` | An outgoing call fails (rejected, busy, unreachable, …) |
214
+ | `onIncomingCall` | An incoming call starts ringing |
215
+ | `onCallAnswered` | A call (either direction) is connected |
216
+ | `onCallEnded` | A connected call ends |
217
+ | `onCallUnanswered` | An incoming call ends without being answered (missed, rejected, caller cancelled) |
218
+
180
219
  ---
181
220
 
182
221
  ## Instance API
183
222
 
184
- `OmnixWidget.init()` returns an instance with these methods:
223
+ `init()` returns:
224
+
225
+ | Method | Returns | Description |
226
+ |--------|---------|-------------|
227
+ | `dial(number)` | — | Start an outgoing call to `sip:<number>@<SIP domain>`. Ignored if another call is active. Invalid characters → `call_failed` `'Invalid number'` |
228
+ | `answer()` | — | Answer the ringing incoming call |
229
+ | `reject()` | — | Reject the ringing incoming call (same as `hangup()`) |
230
+ | `hangup()` | — | End the current call. Sends CANCEL while dialing, rejects while ringing, BYE when connected |
231
+ | `mute(muted = true)` | — | `true` mutes the microphone, `false` unmutes it. Only during a connected call |
232
+ | `hold(onHold = true)` | — | `true` holds the call, `false` resumes it. Only during a connected call |
233
+ | `sendDtmf(tones)` | — | Send DTMF during a connected call. Accepts one tone or a string: `'5'`, `'123#'` |
234
+ | `transfer(target, { attended = false })` | `Promise<boolean>` | *(2.1+)* Transfer the connected call to `target`. Blind: REFER straight away. Attended: hold the caller, call `target`, and REFER (Replaces) once it answers. On success our leg is hung up (`onCallEnded` fires) |
235
+ | `setReady()` | `Promise<boolean>` | Agent → Ready. Emits `'REGISTERED'` + `'READY'` on success |
236
+ | `setNotReady()` | `Promise<boolean>` | Agent → Not Ready (CPaaS `Logout`). Emits `'SIP_CONNECTED'` + `'NOT_READY'` |
237
+ | `setAux(code = 'AUX', label = code)` | `Promise<boolean>` | Agent → AUX. `code` is sent to CPaaS as the reason; `label` is for your UI |
238
+ | `getState()` | `object` | Snapshot of the current state, see [State](#state) |
239
+ | `on(event, cb)` | `() => void` | Subscribe to an event. Returns an unsubscribe function |
240
+ | `destroy()` | — | End any call, unregister, close the WebSocket, and drop all listeners |
241
+
242
+ The call controls are safe to call at any time. Outside a valid state they do nothing.
243
+
244
+ ---
245
+
246
+ ## State
247
+
248
+ `getState()` returns a fresh copy each time:
249
+
250
+ ```js
251
+ {
252
+ registerStatus: 'REGISTERED', // 'CONNECTING' | 'SIP_CONNECTED' | 'REGISTERED' | 'FAILED'
253
+ agentStatus: 'READY', // 'NOT_READY' | 'READY' | 'AUX'
254
+ currentAux: null, // { code, label } while AUX
255
+ callStatus: 'InCall', // 'Idle' | 'Calling...' | 'Ringing' | 'InCall'
256
+ callDuration: '01:23', // 'MM:SS' or 'HH:MM:SS'
257
+ isMuted: false,
258
+ isOnHold: false,
259
+ remote: { number: '1002', name: 'Jane', direction: 'outgoing', headers: {} }, // null when Idle
260
+ }
261
+ ```
185
262
 
186
- | Method | Description |
187
- |----------------|------------------------------------------------------------|
188
- | `answer()` | Answer the current incoming call |
189
- | `hangup()` | End the active / outgoing call |
190
- | `reject()` | Reject the current incoming call |
191
- | `mute(bool)` | `true` = mute microphone, `false` = unmute |
192
- | `hold(bool)` | `true` = put call on hold, `false` = resume |
193
- | `dial(number)` | Programmatically dial a number string |
194
- | `destroy()` | Unmount the widget and clean up the DOM container |
263
+ Use it to draw your UI when it first mounts, then keep it in sync with events.
195
264
 
196
265
  ---
197
266
 
198
- ## Agent status bar
267
+ ## Events & callbacks
268
+
269
+ Subscribe with `voice.on(event, cb)`. Events that have a callback option fire that callback too.
270
+
271
+ | Event | Payload | Callback |
272
+ |------------------------|---------|----------|
273
+ | `register_status` | `'SIP_CONNECTED'` \| `'REGISTERED'` \| `{ status: 'FAILED', reason }` | `onRegisterStatus` (skips `SIP_CONNECTED`) |
274
+ | `agent_status` | `'READY'` \| `'NOT_READY'` \| `'AUX'` (`getState().currentAux` holds the AUX code) | `onAgentStatus` |
275
+ | `call_status` | `'Idle'` \| `'Calling...'` \| `'Ringing'` \| `'InCall'` | `onCallStatus` |
276
+ | `call_duration` | `'00:42'`, every second while connected, `'00:00'` on end | — |
277
+ | `call_failed` | `cause` string, e.g. `'Busy'`, `'Rejected'`, `'Unavailable'` | `onCallFailed` |
278
+ | `incoming_call` | `{ number, name, direction: 'incoming', headers }` | `onIncomingCall` |
279
+ | `sync_call_answered` | `{ number, name, direction, headers }` | `onCallAnswered` |
280
+ | `sync_call_ended` | `{ number, name, direction, headers, cause, originator }` | `onCallEnded` |
281
+ | `sync_call_unanswered` | `{ number, name, direction, headers, cause, originator }` | `onCallUnanswered` |
282
+ | `mute_status` | `boolean` | — |
283
+ | `hold_status` | `boolean` | — |
284
+
285
+ - `direction`: `'incoming'` or `'outgoing'`.
286
+ - `headers` *(2.1+)*: custom `X-*` headers of an incoming INVITE, e.g. `{ 'X-Call-Uuid': '…', 'X-Source-Type': 'whatsapp-call', 'X-Queue-Name': '…' }`. Always `{}` for outgoing calls. These values come from the caller side, so treat them as untrusted.
287
+ - `originator`: who ended the call. `'local'` means you, `'remote'` means the other side, `'system'` means a network or SIP error.
288
+ - `cause`: the end reason, such as `'Terminated'`, `'Canceled'`, `'Rejected'`, `'Busy'` or `'No Answer'`.
289
+
290
+ ---
199
291
 
200
- When `autoReady: false` or `showAuxButton: true` a status bar appears at the bottom of the dialer.
292
+ ## Recipes
201
293
 
202
- > **Note:** The agent status bar calls the CPaaS extension status API. You **must** provide `X-CPAAS-Key-Fingerprint` (or one of its aliases) for these calls to succeed.
294
+ ### Outgoing call
203
295
 
204
296
  ```js
205
- OmnixWidget.init({
206
- auth: { ... },
207
- 'X-CPAAS-Key-Fingerprint': 'your-fingerprint-here',
208
- autoReady: false, // agent starts as NOT_READY; must go Ready manually
209
- showAuxButton: true, // show AUX dropdown
210
- auxOptions: [
211
- { code: 'BRK', label: 'Break' },
212
- { code: 'LCH', label: 'Lunch' },
213
- { code: 'TRN', label: 'Training' },
214
- ],
215
- onAgentStatus: (status) => console.log('Agent status changed:', status),
297
+ voice.on('call_status', (s) => render(s));
298
+ voice.on('call_duration', (d) => (timer.textContent = d));
299
+ voice.on('call_failed', (cause) => toast(`Call failed: ${cause}`));
300
+
301
+ callBtn.onclick = () => voice.dial(input.value);
302
+ hangupBtn.onclick = () => voice.hangup();
303
+ muteBtn.onclick = () => voice.mute(!voice.getState().isMuted);
304
+ holdBtn.onclick = () => voice.hold(!voice.getState().isOnHold);
305
+ keypad.onclick = (e) => voice.sendDtmf(e.target.dataset.key);
306
+ ```
307
+
308
+ ### Incoming call
309
+
310
+ ```js
311
+ const voice = OmnixWidget.init({
312
+ ...config,
313
+ onIncomingCall: ({ number, name }) => showRinging(name || number),
314
+ onCallAnswered: () => showInCall(),
315
+ onCallEnded: ({ cause }) => showIdle(),
316
+ onCallUnanswered: ({ number }) => addMissedCall(number),
216
317
  });
318
+
319
+ answerBtn.onclick = () => voice.answer();
320
+ rejectBtn.onclick = () => voice.reject();
217
321
  ```
218
322
 
219
- | Status | Badge colour | Meaning |
220
- |-------------|--------------|--------------------------------------|
221
- | `READY` | 🟢 Green | Agent is available for calls |
222
- | `NOT_READY` | ⚪ Gray | Agent is unavailable |
223
- | `AUX` | 🟡 Amber | Agent is on a break / custom reason |
323
+ ### Agent status (manual Ready)
224
324
 
225
- ---
325
+ ```js
326
+ const voice = OmnixWidget.init({ ...config, autoReady: false });
226
327
 
227
- ## React integration
328
+ voice.on('register_status', (s) => {
329
+ if (s === 'SIP_CONNECTED') showSetReadyButton(); // connected, agent not Ready
330
+ if (s === 'REGISTERED') showDialer();
331
+ });
332
+
333
+ readyBtn.onclick = async () => { if (!(await voice.setReady())) toast('Failed to set Ready'); };
334
+ notReadyBtn.onclick = () => voice.setNotReady();
335
+ breakBtn.onclick = () => voice.setAux('BRK', 'Break');
336
+ ```
337
+
338
+ ### React hook
228
339
 
229
340
  ```jsx
230
- import { useEffect, useRef } from 'react';
341
+ import { useEffect, useRef, useState } from 'react';
231
342
  import { OmnixWidget } from 'voxnix';
232
343
 
233
- function App() {
234
- const widgetRef = useRef(null);
344
+ export function useVoice(config) {
345
+ const ref = useRef(null);
346
+ const [state, setState] = useState(null);
235
347
 
236
348
  useEffect(() => {
237
- widgetRef.current = OmnixWidget.init({
238
- auth: {
239
- username: '1001',
240
- pwd_pbx: 'secret',
241
- pabx_host: 'sip.example.com',
242
- },
243
- 'X-CPAAS-Key-Fingerprint': 'your-fingerprint-here',
244
- placement: 'bottom-right',
245
- autoReady: true,
246
- showAuxButton: true,
247
- auxOptions: [{ code: 'BRK', label: 'Break' }],
248
-
249
- onIncomingCall: (data) => { /* navigate, show notification, etc. */ },
250
- onCallAnswered: (data) => { /* routing logic */ },
251
- onCallEnded: () => { /* cleanup */ },
252
- onRegisterStatus: (s) => { /* update UI */ },
253
- onAgentStatus: (s) => { /* s: 'READY' | 'NOT_READY' | 'AUX' */ },
254
- });
255
-
256
- return () => widgetRef.current?.destroy();
257
- }, []);
258
-
259
- return <div id="app">...</div>;
349
+ const voice = OmnixWidget.init(config);
350
+ ref.current = voice;
351
+ const sync = () => setState(voice.getState());
352
+ const events = ['register_status', 'agent_status', 'call_status', 'call_duration', 'mute_status', 'hold_status'];
353
+ events.forEach((e) => voice.on(e, sync));
354
+ sync();
355
+ return () => voice.destroy();
356
+ }, []); // init once
357
+
358
+ return [state, ref];
260
359
  }
261
- ```
262
360
 
263
- ---
361
+ // const [state, voice] = useVoice(config);
362
+ // <button onClick={() => voice.current.dial('1002')}>Call</button>
363
+ ```
264
364
 
265
- ## Vue integration
365
+ ### Vue 3
266
366
 
267
367
  ```js
368
+ import { onMounted, onUnmounted, ref } from 'vue';
268
369
  import { OmnixWidget } from 'voxnix';
269
370
 
270
- // inside setup() or options API
271
- onMounted(() => {
272
- const widget = OmnixWidget.init({
273
- auth: { username: '1001', pwd_pbx: 'secret', pabx_host: 'sip.example.com' },
274
- 'X-CPAAS-Key-Fingerprint': 'your-fingerprint-here',
275
- autoReady: false,
276
- showAuxButton: true,
277
- auxOptions: [{ code: 'BRK', label: 'Break' }],
371
+ export function useVoice(config) {
372
+ const state = ref(null);
373
+ let voice;
374
+ onMounted(() => {
375
+ voice = OmnixWidget.init(config);
376
+ const sync = () => (state.value = voice.getState());
377
+ ['register_status', 'agent_status', 'call_status', 'call_duration', 'mute_status', 'hold_status']
378
+ .forEach((e) => voice.on(e, sync));
379
+ sync();
278
380
  });
279
- onUnmounted(() => widget.destroy());
280
- });
381
+ onUnmounted(() => voice?.destroy());
382
+ return { state, dial: (n) => voice.dial(n), hangup: () => voice.hangup() };
383
+ }
281
384
  ```
282
385
 
283
386
  ---
284
387
 
285
- ## Via CDN (plain HTML, no bundler)
388
+ ## Browser requirements
286
389
 
287
- ```html
288
- <!-- Self-contained IIFE build (bundles React) -->
289
- <script src="https://unpkg.com/voxnix/dist/voxnix.iife.js"></script>
290
- <script>
291
- OmnixWidget.init({
292
- auth: {
293
- username: '1001',
294
- pwd_pbx: 'secret',
295
- pabx_host: 'sip.example.com',
296
- },
297
- 'X-CPAAS-Key-Fingerprint': 'your-fingerprint-here',
298
- placement: 'bottom-right',
299
- autoReady: true,
300
- showAuxButton: true,
301
- auxOptions: [
302
- { code: 'BRK', label: 'Break' },
303
- { code: 'LCH', label: 'Lunch' },
304
- ],
305
- onRegisterStatus: (s) => console.log('SIP:', s),
306
- onIncomingCall: (d) => console.log('Incoming:', d),
307
- onAgentStatus: (s) => console.log('Agent:', s),
308
- });
309
- </script>
310
- ```
311
-
312
- > If you need to use your own React instance, include `react@18` and `react-dom@18` UMD builds **before** the script tag and use `dist/voxnix.js` (ESM) or `dist/voxnix.umd.cjs` (CJS) instead.
390
+ - **HTTPS (or `localhost`)**: browsers only give microphone access on secure origins.
391
+ - **Microphone permission**: the browser asks for it on the first `dial()` or `answer()`. If the user denies it, the call fails (`call_failed`, or `onCallUnanswered` for an incoming call).
392
+ - **Remote audio**: Voxnix creates a hidden `<audio id="voxnix-remote-audio">` in `document.body`. Autoplay rules may block it until the user has clicked something on the page. Answering or dialing from a click handler counts as that interaction.
393
+ - **Ringtone**: generated with WebAudio, which has the same user-interaction rule. Set `ringtone: false` to use your own sound.
313
394
 
314
395
  ---
315
396
 
316
- ## Keyboard shortcuts
317
-
318
- These shortcuts are active whenever the dialer widget is open:
397
+ ## Security
319
398
 
320
- | Key | State | Action |
321
- |------------------|---------|-------------------------------|
322
- | `0–9`, `*`, `#` | Idle | Append digit to dial input |
323
- | `Backspace` | Idle | Delete last digit |
324
- | `Enter` | Idle | Dial the current number |
325
- | `0–9`, `*`, `#` | In-call | Send DTMF tone |
399
+ - **Treat caller data as untrusted.** `name` comes from the caller's SIP display name and `number`
400
+ from their SIP URI. Render them with `textContent` (or your framework's default escaping),
401
+ never `innerHTML`.
402
+ - **Credentials live in the browser.** The SIP password and `X-CPAAS-Key-Fingerprint` are readable
403
+ by anyone with devtools on that page. Fetch them from your backend after the agent logs in,
404
+ use per-agent SIP credentials, and never hardcode them in frontend code.
405
+ - **HTTPS only.** `cpaas_base_url` must be `https://` (`http://localhost` is allowed for development).
406
+ Any other URL disables CPaaS API calls, and agent-status methods return `false`.
407
+ - **Input validation.** `dial()` only accepts `0-9 A-Z a-z + * # . _ -`. Anything else emits
408
+ `call_failed` with `'Invalid number'`.
409
+ - Voxnix never logs credentials or API responses to the console.
326
410
 
327
411
  ---
328
412
 
329
- ## Build
330
-
331
- ```bash
332
- # ESM + UMD (peer React — for npm consumers)
333
- yarn build:lib
413
+ ## Sample page
334
414
 
335
- # Self-contained IIFE (bundles React — for CDN / script tag)
336
- yarn build:iife
415
+ [`examples/index.html`](examples/index.html) is a plain-HTML page that loads the script-tag
416
+ build. It calls `OmnixWidget.init()` with a config object, has buttons for every API method, and
417
+ shows a live `getState()` view and an event log.
337
418
 
338
- # Both outputs
339
- yarn build
419
+ ```bash
420
+ npm run build
421
+ python3 -m http.server 5599 # from the repo root
422
+ # open http://localhost:5599/examples/
340
423
  ```
341
424
 
342
- Output files in `dist/`:
343
-
344
- | File | Format | React | Use case |
345
- |--------------------|--------|----------|-----------------------|
346
- | `voxnix.js` | ESM | peer dep | `import` / bundlers |
347
- | `voxnix.umd.cjs` | UMD | peer dep | CommonJS / `require` |
348
- | `voxnix.iife.js` | IIFE | bundled | `<script>` / CDN |
425
+ Edit the `CONFIG` object at the top of the script to point it at your PBX.
349
426
 
350
427
  ---
351
428
 
352
- ## Development
429
+ ## Build
353
430
 
354
431
  ```bash
355
- yarn install
356
- yarn dev # Vite dev server with hot reload
357
- yarn watch # Vite library watch mode
432
+ npm install
433
+ npm run build # ESM + UMD + IIFE into dist/
434
+ npm run build:lib # ESM + UMD only
435
+ npm run build:iife # IIFE only
436
+ npm run watch # rebuild ESM + UMD on change
358
437
  ```
359
438
 
360
439
  ---
361
440
 
441
+ ## Migrating from 1.x
442
+
443
+ - **The UI is gone.** There is no floating button or dialer panel. The options `placement`, `offset`, `container`, `showAuxButton` and `auxOptions` are ignored. Build the UI with the [Instance API](#instance-api) and [events](#events--callbacks).
444
+ - **New SIP engine.** Call callbacks now receive `{ number, name, direction, ... }` instead of a raw SIP session object.
445
+ - `hold(true)` now holds and `hold(false)` resumes (1.x had them reversed).
446
+ - `onCallEnded` fires once per call. Before, it could fire twice for incoming calls.
447
+ - Call payloads include `headers` (2.1+), the `X-*` headers of an incoming INVITE. Use it instead of reading headers from the raw SIP session.
448
+ - Call transfer is `transfer(target, { attended })` (2.1+) instead of `refer()` on the raw session.
449
+ - New in 2.0: `sendDtmf`, `setReady`, `setNotReady`, `setAux`, `getState`, `on`, plus the options `onCallStatus`, `onCallFailed`, `ringtone` and `contacts`.
450
+ - `contacts` has no built-in demo entries any more. Pass your own map.
451
+
452
+ ---
453
+
362
454
  ## Changelog
363
455
 
456
+ ### v2.1.0
457
+ - `transfer(target, { attended })`: blind transfer (REFER), or attended (hold caller, consult call, REFER with Replaces)
458
+ - Call payloads and `getState().remote` include `headers`: the `X-*` headers of an incoming INVITE (`X-Call-Uuid`, `X-Source-Type`, `X-Queue-Name`, …)
459
+ - Build runs on `prepack`, so `npm pack` / `npm publish` always ship a fresh `dist`
460
+
461
+ ### v2.0.0
462
+ - Removed the built-in React UI; Voxnix is now a headless SDK (no React / Tailwind dependencies)
463
+ - New internal SIP engine
464
+ - New API: `sendDtmf`, `setReady`, `setNotReady`, `setAux`, `getState`, `on`; options `onCallStatus`, `onCallFailed`, `ringtone`, `contacts`
465
+ - Configurable `cpaas_base_url`, `pbx_url` (SIP domain), `sip_url` (WSS host)
466
+ - Re-REGISTER + CPaaS poll when the extension reports `is_register = 0`
467
+ - Security: credentials and API responses are no longer logged (1.0.23–1.0.24 logged the full config); HTTPS-only CPaaS URL; `dial()` input validation; build before publish
468
+ - Fixes: cancel while dialing, double `onCallEnded`, inverted `hold()`, outgoing call duration and callbacks, busy second incoming call, `destroy()` not stopping SIP, IIFE global `OmnixWidget.init`
469
+
364
470
  ### v1.0.12
365
471
  - Rollback DevToast / console interceptor feature
366
472