# @sautipbx/voice-sdk

Browser SDK for placing and receiving **web calls** on the SautiPBX voice platform.
Drop in a `<script>` tag (or `npm install` in a bundler app), authenticate with an
ephemeral token minted by **your** backend, and you have a working softphone —
call, answer, hold, mute, DTMF, device selection, and rich local call events.

> Status: **0.2.x, under test.** API may change before 1.0.

## Install

**Script tag** (no build step — exposes the `VoiceSDK` global):

```html
<script src="https://cdn.jsdelivr.net/npm/@sautipbx/voice-sdk@0.2.0/dist/voice.iife.js"></script>
```

**Bundler** (React / Vue / Vite / webpack):

```bash
npm install @sautipbx/voice-sdk
```

```js
import { Phone } from '@sautipbx/voice-sdk';
```

## The token flow (read this first)

Your **secret API key never touches the browser.** The browser only ever holds a
short-lived, single-extension, revocable **phone token**:

1. Your backend calls `POST /api/phone-tokens/mint` with your secret API key,
   passing the end-user's uuid. It gets back a token and an `iceServers` config
   (STUN/TURN for NAT traversal).
2. Your backend hands **only the token and `iceServers`** to the browser.
3. The browser passes them to `phone.authenticate({ token, iceServers })`.

If a token leaks it expires within minutes, works for a single extension, and can
be revoked instantly with `POST /api/phone-tokens/revoke`.

## Quickstart

```html
<script src="https://cdn.jsdelivr.net/npm/@sautipbx/voice-sdk@0.2.0/dist/voice.iife.js"></script>
<script>
  const phone = new VoiceSDK.Phone({ logLevel: 'info' });

  // { token, iceServers } came from YOUR backend's mint call (see above).
  await phone.authenticate({ token, iceServers });

  // Outbound
  const call = phone.call('+254711111111', { customPayload: '{"caseId":"CASE-0042"}' });
  call.on('ringing', () => console.log('ringing…'));
  call.on('accepted', () => console.log('connected'));
  call.on('ended', (r) => console.log('ended', r));

  // Inbound
  phone.on('incoming', (incoming) => {
    console.log('call from', incoming.remoteIdentity);
    incoming.answer();   // or incoming.reject()
  });
</script>
```

## API

### `new Phone(options?)`

| Option       | Default            | Notes |
| ------------ | ------------------ | ----- |
| `logLevel`   | `'none'`           | Silent by default. Set `'error'` \| `'info'` \| `'debug'` to log to the browser console (prefixed `[voice-sdk]`); `'debug'` also enables JsSIP wire tracing. |
| `onLog`      | —                  | Custom log sink `(level, ...args) => void`. When set, emitted lines (still gated by `logLevel`) go here **instead of** the console — route them into your own UI/telemetry. |
| `iceGatheringTimeout` | `3000` | **Fallback** cap (ms) on ICE gathering. Normally the call is sent the instant a TURN `relay` candidate is gathered (sub-second), so this only bites if no relay ever arrives — avoiding the ~39.5s stall on UDP-restricted networks. `0` disables early-send and waits for full gathering. |
| `iceServers` | public STUN        | Fallback ICE config. In production pass the per-session config from your mint response to `authenticate` instead. |
| `wssUrl`     | production FQDN    | Override only for testing. |
| `realm`      | production realm   | SIP domain. |

**Methods**

- `authenticate(token | { token, iceServers })` → `Promise<void>` — registers; resolves on success.
- `call(destination, { customPayload? })` → `Call` — `destination` is a bare extension/number or full SIP URI.
- `unregister()`
- `listDevices()` → `{ inputs, outputs }`
- `setInputDevice(id)` / `setOutputDevice(id)` / `setVolume(0..1)`
- Getters: `extension`, `account`, `isRegistered`

**Events:** `registered`, `unregistered`, `registrationFailed`, `connected`, `disconnected`, `incoming`.

**Capturing logs** — route SDK logs into your own handler instead of the console:

```js
const phone = new VoiceSDK.Phone({
  logLevel: 'debug',
  onLog: (level, ...args) => myLogStore.push({ level, args, at: Date.now() }),
});
```

### Other exports

- `describeFailure(reason)` — turns a `Call`'s `ended` / `failed` reason into a
  one-line human summary, e.g. `"402 Insufficient balance · cause=Rejected"` —
  handy for surfacing exactly why a call was refused in your UI.
- `decodeToken(token)` / `isExpired(token)` — inspect a phone token's claims and
  expiry client-side, without a network round-trip.
- `DEFAULT_ICE_SERVERS` — the built-in public-STUN fallback `Phone` uses when you
  don't pass `iceServers`.

### `Call`

- `answer()` / `reject()` / `hangup()`
- `hold()` / `unhold()`
- `mute()` / `unmute()`
- `sendDigit(tone)` — DTMF
- Getters: `direction`, `remoteIdentity`, `isOnHold`, `isMuted`
- **Events:** `ringing`, `accepted`, `ended`, `failed`, `hold`, `unhold`, `muted`, `unmuted`.

### Events: what you get here, and what lives on your backend

The SDK surfaces **local** call events (the ones above) — everything a phone UI
needs, observed directly in the browser. Platform-truth events that the browser
*can't* know — **call cost**, **recording ready + URL**, **billing**, and
**bridged/far-leg** state — are delivered to **your backend** via webhooks or a
backend `/api/stream` subscription, where your CDR/billing logic lives. That split
is deliberate: platform-truth events belong on your backend, where your own
records live.

## Requirements & gotchas

- **Secure context required.** WebRTC mic capture only works over **HTTPS** (or
  `http://localhost`). On a plain `http://<LAN-IP>` origin, registration can
  succeed but calls silently fail — the SDK throws a clear error when you try to
  call from an insecure context.
- **NAT / TURN.** The default is a public STUN server, which is enough on
  cooperative NATs but **not** for symmetric-NAT / mobile. Reliable traversal
  needs TURN — the mint response's `iceServers` includes ephemeral, per-session
  TURN credentials, so pass it straight to `authenticate()`.
- **`customPayload`** is sent as the `X-Sauti-Custom-Payload` INVITE header and
  is echoed back on every platform event and webhook for that call, so you can
  correlate a call with your own records automatically.

## Local development

```bash
npm install
npm run typecheck   # tsc --noEmit
npm run build       # ESM + CJS + IIFE + .d.ts into dist/
npm run smoke       # verify the build artifacts + pure logic
```
