# @growhats/webphone

Biblioteca para realizar chamadas VoIP WhatsApp via GroWHats. Disponibiliza uma interface customizavel e isolada do projeto onde esta instalada, com gravacao full-duplex automatica e persistencia contra fechamento de pagina.

Drop-in replacement para [@wavoip/wavoip-webphone](https://www.npmjs.com/package/@wavoip/wavoip-webphone) — API 100% compativel.

## Modos de uso

O SDK pode ser usado de duas formas:

### 1. Widget completo (padrao)

Botao flutuante com discador, modal de chamada recebida e painel de chamada ativa. Tudo pronto, sem codigo extra:

```js
await GrowhatsWebphone.render({
  serverUrl: 'https://sua-api.com',
  accountId: 1,
  inboxId: 1,
  apiKey: 'TOKEN',
  recording: { enabled: true },
});
```

### 2. Modo discreto (apenas chamadas recebidas)

Sem botao flutuante — so aparece notificacao centralizada quando chega chamada. Permite que voce construa **seu proprio discador** e use as funcoes do SDK para fazer chamadas. Ideal pra integrar no seu CRM/ERP existente:

```js
const api = await GrowhatsWebphone.render({
  serverUrl: 'https://sua-api.com',
  accountId: 1,
  inboxId: 1,
  apiKey: 'TOKEN',
  recording: { enabled: true },
  showWidgetButton: false,  // esconde o FAB
});

// Seu botao customizado para ligar:
document.getElementById('meu-botao-ligar').onclick = async () => {
  const numero = document.getElementById('meu-input').value;
  await api.call.start(numero);  // widget cuida do resto (UI, audio, gravacao)
};
```

**Notificacao de chamada recebida**: aparece centralizada no topo da tela, **sem bloquear cliques no resto da pagina**. Usuario pode:
- **Atender** — abre painel de chamada ativa
- **Recusar** — fecha notificacao
- **Minimizar** — vira uma pill flutuante pulsante no canto superior direito, com telefone + timer

## Atendentes (multi-agente na mesma inbox)

Varios usuarios do seu CRM podem estar conectados ao mesmo numero WhatsApp. O SDK coordena isso para voce: basta passar o `agent` no `render()`.

```js
const api = await GrowhatsWebphone.render({
  serverUrl: 'https://api.empresa.com',
  accountId: 1,
  inboxId: 81,
  apiKey,
  agent: {
    idExternal: 'user-123',           // obrigatorio: ID do usuario no SEU sistema
    name: 'Joao Silva',
    email: 'joao@empresa.com',
    role: 'sales',
    metadata: { team: 'BR-NE' },      // campo livre
  },
});
```

### O que o SDK faz com isso

- **Atribuicao** — cada `CallLog` salva `agentIdExternal` (quem originou) e `answeredByAgentId` (quem atendeu). Chamadas outbound, aceites inbound e gravacoes carregam essa info.
- **Primeiro-a-atender ganha** — quando a mesma inbox e compartilhada, o primeiro `accept()` reivindica a chamada. Os outros webphones recebem `call:taken` e o modal some sozinho. Se o seu `accept()` perdeu a corrida, voce recebe `call:claim:denied` com quem ganhou.
- **Presenca** — `agent:joined` / `agent:left` disparam quando outro atendente conecta/desconecta na mesma inbox. Use `api.agent.getOnlineAgents()` para listar quem esta online.
- **Gravacoes** — sao gravadas em `call-recordings/{accountId}/{AAAA}/{MM}/{DD}/{agentIdExternal}/{callId}.webm`, facilitando auditoria por atendente.

### API

```js
api.agent.get();                 // AgentInfo | null — agente ativo
api.agent.set({ idExternal: 'user-456', name: 'Maria' });  // troca em tempo real
api.agent.set(null);             // desativa atribuicao
await api.agent.getOnlineAgents(); // lista atendentes online na inbox
```

### Eventos multi-agente

```js
api.on('call:taken', ({ callId, takenBy }) => {
  // Outro atendente atendeu. Atualiza seu CRM — o widget ja fechou sozinho.
  console.log(`${takenBy.name || takenBy.idExternal} atendeu ${callId}`);
});

api.on('call:claim:denied', ({ takenBy }) => {
  // Voce tentou atender mas perdeu a corrida.
  alert(`Ja atendido por ${takenBy.name || takenBy.idExternal}`);
});

api.on('agent:joined', (a) => console.log('entrou:', a.name));
api.on('agent:left',   (a) => console.log('saiu:',   a.name));
```

### Quando `agent` e opcional

Se voce **nao** passar `agent`, tudo continua funcionando igual — sem atribuicao, sem coordenacao entre peers, gravacao sob `agent-anonymous/`. Use quando houver apenas um atendente por inbox ou nao precisar identificar quem atendeu.

## API para construir seu proprio discador

Quando `showWidgetButton: false`, voce controla as chamadas via API:

### Iniciar chamada (outgoing)

```js
const call = await api.call.start('5511999999999');
// Retorna CallOutgoing — widget mostra painel de chamada automaticamente
```

### Encerrar chamada

```js
// Pegar chamada ativa (atendida)
const active = api.call.getCallActive();
if (active) await active.end();

// Pegar chamada de saida (ainda chamando)
const outgoing = api.call.getCallOutgoing();
if (outgoing) await outgoing.end();
```

### Mutar/Desmutar

```js
const call = api.call.getCallActive();
if (call) {
  await call.mute();    // muta
  await call.unmute();  // desmuta
  call.muted;           // true/false
}
```

### Escutar eventos

```js
api.on('call:incoming', (offer) => {
  console.log('Chamada recebida de:', offer.from);
  // O widget ja mostra a notificacao automaticamente.
  // Mas voce pode fazer logica extra aqui (tocar seu proprio som, abrir outra tela, etc)
});

api.on('call:started', (call) => {
  console.log('Chamada iniciada:', call.id, call.direction, call.peer.phone);
});

api.on('call:ended', (callId, reason) => {
  console.log('Chamada encerrada:', callId, reason);
});

api.on('recording:started', (callId) => {
  console.log('Gravacao iniciada:', callId);
});

api.on('recording:stopped', (callId, result) => {
  if (result) {
    console.log('Gravacao salva:', result.url, result.durationSeconds + 's');
  }
});

api.on('connection:status', (connected) => {
  console.log('Servidor:', connected ? 'online' : 'offline');
});

api.on('error', (err) => {
  console.error('Erro:', err.message);
});
```

### Atender/Recusar programaticamente

Normalmente o widget ja mostra os botoes, mas se quiser controle total:

```js
api.on('call:incoming', async (offer) => {
  // Sua logica customizada
  const shouldAccept = window.confirm(`Aceitar chamada de ${offer.from}?`);
  if (shouldAccept) {
    await offer.accept();  // audio conecta automaticamente
  } else {
    await offer.reject();
  }
});
```

### Buscar gravacao depois

```js
const recordingUrl = await api.recording.getUrl(callId);
// Retorna URL (relativa) onde pode baixar/reproduzir o audio .webm
// Exemplo: /api/v1/accounts/1/calls/abc123/recording
```

### Destruir o SDK

```js
GrowhatsWebphone.destroy();
// Limpa widget, socket, audio, gravacoes pendentes
```

## Instalacao

```bash
# pnpm
pnpm add @growhats/webphone

# npm
npm install @growhats/webphone

# yarn
yarn add @growhats/webphone
```

Via CDN:

```html
<script src="https://cdn.jsdelivr.net/npm/@growhats/webphone@latest/dist/growhats-webphone.umd.js"></script>
```

## Getting Started

**Pacote instalado:**

```typescript
import { GrowhatsWebphone } from "@growhats/webphone";

const api = await GrowhatsWebphone.render({
  serverUrl: "https://sua-api.com",
  accountId: 1,
  inboxId: 81,
  apiKey: "sua-api-key",
});

GrowhatsWebphone.destroy(); // remover widget
```

**Via CDN:**

```typescript
const api = await GrowhatsWebphone.render({
  serverUrl: "https://sua-api.com",
  accountId: 1,
  inboxId: 81,
  apiKey: "sua-api-key",
});

// Tambem acessivel via window
window.growhatsWebphone.render(config);
window.wavoip.call.start("5511999999999"); // compatibilidade Wavoip
```

> **Importante:** Sempre use `await` ao chamar `render()` para evitar comportamento inesperado.

## WebphoneAPI

`render()` retorna uma `Promise<WebphoneAPI>` e tambem expoe `window.wavoip` e `window.growhatsWebphone`:

```typescript
type WebphoneAPI = {
  call: CallAPI;
  device: DeviceAPI;
  notifications: NotificationsAPI;
  widget: WidgetAPI;
  theme: ThemeAPI;
  position: PositionAPI;
  settings: SettingsAPI;
  recording: RecordingAPI;
  on<K extends keyof WebphoneEvents>(event: K, cb: WebphoneEvents[K]): void;
  off<K extends keyof WebphoneEvents>(event: K, cb: WebphoneEvents[K]): void;
  destroy(): void;
};
```

---

## CallAPI

```typescript
type CallAPI = {
  start(
    to: string,
    config?: { fromTokens?: string[]; displayName?: string }
  ): Promise<CallOutgoing>;

  startCall(to: string, fromTokens?: string[]): Promise<CallOutgoing>; // Deprecated

  getCallActive(): CallActive | undefined;

  getCallOutgoing(): CallOutgoing | undefined;

  getOffers(): CallOffer[];

  setInput(to: string): void;

  onOffer(cb: (offer: CallOffer) => void): void;
};
```

### CallOffer

Representa uma chamada recebida aguardando acao:

```typescript
type CallOffer = {
  call_id: string;
  from: string;
  displayName?: string;

  accept(): Promise<CallActive>;
  reject(): Promise<{ err: string | null }>;

  onAcceptedElsewhere(cb: () => void): void;
  onRejectedElsewhere(cb: () => void): void;
  onUnanswered(cb: () => void): void;
  onEnd(cb: () => void): void;
  onStatus(cb: (status: CallStatus) => void): void;
};
```

### CallActive

Chamada em andamento (atendida):

```typescript
type CallActive = {
  id: string;
  type: "official" | "unofficial";
  device_token: string;
  direction: "INCOMING" | "OUTGOING";
  status: CallStatus;
  connection_status: "disconnected" | "connected" | "connecting";
  muted: boolean;
  peer: {
    phone: string;
    displayName: string | null;
    profilePicture: string | null;
    muted: boolean;
  };

  mute(): Promise<{ err: string | null }>;
  unmute(): Promise<{ err: string | null }>;
  end(): Promise<{ err: string | null }>;

  onError(cb: (err: string) => void): void;
  onPeerMute(cb: () => void): void;
  onPeerUnmute(cb: () => void): void;
  onEnd(cb: () => void): void;
  onStats(cb: (stats: CallStats) => void): void;
  onConnectionStatus(cb: (status: string) => void): void;
  onStatus(cb: (status: CallStatus) => void): void;
};
```

### CallOutgoing

Chamada de saida (aguardando atendimento):

```typescript
type CallOutgoing = {
  id: string;
  type: "official" | "unofficial";
  device_token: string;
  direction: "OUTGOING";
  status: CallStatus;
  muted: boolean;
  peer: {
    phone: string;
    displayName: string | null;
    profilePicture: string | null;
    muted: boolean;
  };

  mute(): Promise<{ err: string | null }>;
  unmute(): Promise<{ err: string | null }>;
  end(): Promise<{ err: string | null }>;

  onPeerAccept(cb: () => void): void;
  onPeerReject(cb: () => void): void;
  onUnanswered(cb: () => void): void;
  onEnd(cb: () => void): void;
  onStatus(cb: (status: CallStatus) => void): void;
};
```

### CallStatus

```typescript
type CallStatus =
  | "RINGING"
  | "CALLING"
  | "NOT_ANSWERED"
  | "ACTIVE"
  | "ENDED"
  | "REJECTED"
  | "FAILED"
  | "DISCONNECTED"
  | "DEVICE_RESTARTING";
```

### CallStats

```typescript
type CallStats = {
  rtt: {
    client: { min: number; max: number; avg: number };
    whatsapp: { min: number; max: number; avg: number };
  };
  tx: { total: number; total_bytes: number; loss: number };
  rx: { total: number; total_bytes: number; loss: number };
};
```

---

## DeviceAPI

```typescript
type DeviceAPI = {
  get(): Device[];
  getDevices(): Device[];                               // Deprecated

  add(token: string, persist?: boolean): void;
  addDevice(token: string, persist?: boolean): void;    // Deprecated

  remove(token: string): void;
  removeDevice(token: string): void;                    // Deprecated

  enable(token: string): void;
  enableDevice(token: string): void;                    // Deprecated

  disable(token: string): void;
  disableDevice(token: string): void;                   // Deprecated
};
```

`persist: true` salva o dispositivo no Local Storage do navegador entre sessoes. `disable()` encerra a conexao do webphone com o dispositivo enquanto o dispositivo continua rodando.

### Device

```typescript
type Device = {
  token: string;
  status: DeviceStatus;
  qrcode?: string;
  contact?: { phone?: string; name?: string };

  onStatus(cb: (status: DeviceStatus) => void): void;
  onQRCode(cb: (qr: string) => void): void;
  onContact(cb: (contact: { phone?: string; name?: string }) => void): void;
};

type DeviceStatus =
  | "disconnected"
  | "close"
  | "connecting"
  | "open"
  | "restarting"
  | "hibernating"
  | "BUILDING"
  | "EXTERNAL_INTEGRATION_ERROR";
```

---

## NotificationsAPI

```typescript
type NotificationsAPI = {
  get(): Notification[];
  getNotifications(): Notification[];                               // Deprecated

  add(notification: Partial<Notification>): void;
  addNotification(notification: Partial<Notification>): void;       // Deprecated

  remove(id: Date): void;
  removeNotification(id: Date): void;                               // Deprecated

  clear(): void;
  clearNotifications(): void;                                       // Deprecated

  read(): void;
  readNotifications(): void;                                        // Deprecated
};

type Notification = {
  id: Date;
  type: "INFO" | "CALL_FAILED";
  message: string;
  detail?: string;
  token?: string;
  isRead: boolean;
  isHidden: boolean;
  created_at: Date;
};
```

---

## RecordingAPI

Gravacao automatica full-duplex (ambos os lados da conversa). Quando habilitada via config, grava automaticamente do momento que a chamada e atendida ate quando e encerrada. Suporta multiplas chamadas simultaneas isoladas.

Se o usuario fechar a pagina (F5 / fechar aba), a gravacao e salva automaticamente via `navigator.sendBeacon`.

```typescript
type RecordingAPI = {
  /** Se a gravacao esta habilitada via config */
  enabled: boolean;

  /** Verifica se uma chamada especifica esta sendo gravada */
  isRecording(callId?: string): boolean;

  /** Retorna IDs de todas as chamadas sendo gravadas */
  getActiveRecordings(): string[];

  /** Busca a URL de uma gravacao finalizada no servidor */
  getUrl(callId: string): Promise<string | null>;
};

type RecordingResult = {
  url: string;
  callId: string;
  durationSeconds: number;
  startedAt: string;
  endedAt: string;
};
```

As gravacoes sao armazenadas no servidor organizadas por empresa e data:

```
call-recordings/{accountId}/{YYYY}/{MM}/{DD}/{callId}.webm
```

---

## WidgetAPI

```typescript
type WidgetAPI = {
  isOpen: boolean;

  open(): void;

  close(): void;

  toggle(): void;

  buttonPosition: {
    value: { x: number; y: number };
    set(
      position:
        | "top-left"
        | "top-right"
        | "bottom-left"
        | "bottom-right"
        | "center"
        | { x: number; y: number }
    ): void;
  };
};
```

---

## ThemeAPI

```typescript
type ThemeAPI = {
  value: "light" | "dark" | "system";
  set(theme: "light" | "dark" | "system"): void;
  setTheme(theme: "light" | "dark" | "system"): void;  // Deprecated
};
```

---

## PositionAPI

```typescript
type PositionAPI = {
  value: { x: number; y: number };
  set(
    position:
      | "top-left"
      | "top-right"
      | "bottom-left"
      | "bottom-right"
      | "center"
      | { x: number; y: number }
  ): void;
};
```

---

## Configuracao Inicial (WebphoneConfig)

```typescript
GrowhatsWebphone.render(config?: WebphoneConfig);

type WebphoneConfig = {
  /** URL base do servidor (obrigatorio) */
  serverUrl: string;

  /** ID da conta/empresa (obrigatorio) */
  accountId: number;

  /** ID do inbox WhatsApp para chamadas (obrigatorio) */
  inboxId: number;

  /** Chave de autenticacao (enviada como X-Api-Key) */
  apiKey?: string;

  /** Gravacao automatica de chamadas */
  recording?: {
    /** Habilitar gravacao automatica full-duplex (default: false) */
    enabled: boolean;
  };

  /** Tema: "light" | "dark" | "system" (default: "dark") */
  theme?: "light" | "dark" | "system";

  /** Posicao do botao flutuante (default: "bottom-right") */
  position?:
    | "top-left"
    | "top-right"
    | "bottom-left"
    | "bottom-right"
    | "center"
    | { x: number; y: number };

  /** Exibir botao flutuante do widget (default: true) */
  showWidgetButton?: boolean;

  /** Iniciar com o painel aberto (default: false) */
  startOpen?: boolean;

  /** Reconectar automaticamente em caso de queda (default: true) */
  autoReconnect?: boolean;

  /** Elemento container para montar o widget (default: document.body) */
  container?: HTMLElement;

  /** Configuracao da barra de status */
  statusBar?: {
    showNotificationsIcon?: boolean;
    showSettingsIcon?: boolean;
  };

  /** Configuracao do menu de configuracoes */
  settingsMenu?: {
    showDevices?: boolean;
    showAddDevices?: boolean;
    showEnableDevices?: boolean;
    showRemoveDevices?: boolean;
  };

  /**
   * Atendente que esta operando o webphone. Opcional — quando presente, chamadas sao
   * registradas com o agente, a gravacao e organizada por agente e varios
   * webphones no mesmo inbox se coordenam: o primeiro a atender ganha, os outros
   * recebem `call:taken` e o modal some sozinho. Veja a secao "Atendentes".
   */
  agent?: {
    idExternal: string;                       // obrigatorio: ID do atendente no seu sistema
    name?: string;
    email?: string;
    role?: string;                            // ex: "sales", "support"
    metadata?: Record<string, unknown>;
  };
};
```

---

## SettingsAPI (Runtime)

Apos o `render()`, controle as configuracoes em tempo real via `api.settings`:

```typescript
type SettingsAPI = {
  showNotifications: boolean;
  setShowNotifications(show: boolean): void;

  showSettings: boolean;
  setShowSettings(show: boolean): void;

  showDevices: boolean;
  setShowDevices(show: boolean): void;

  showAddDevices: boolean;
  setShowAddDevices(show: boolean): void;

  showEnableDevices: boolean;
  setShowEnableDevices(show: boolean): void;

  showRemoveDevices: boolean;
  setShowRemoveDevices(show: boolean): void;

  showWidgetButton: boolean;
  setShowWidgetButton(show: boolean): void;
};
```

---

## Eventos

```typescript
type WebphoneEvents = {
  /** Chamada recebida */
  "call:incoming": (offer: CallOffer) => void;

  /** Chamada iniciada (atendida ou discando) */
  "call:started": (call: CallActive) => void;

  /** Chamada encerrada */
  "call:ended": (callId: string, reason: string) => void;

  /** Status da chamada alterado */
  "call:status": (callId: string, status: CallStatus) => void;

  /** Outro atendente da mesma inbox atendeu a chamada (multi-agente) */
  "call:taken": (evt: { callId: string; takenBy: { idExternal: string; name?: string; email?: string; claimedAt: number } }) => void;

  /** Este cliente tentou atender, mas outro ja tinha reivindicado */
  "call:claim:denied": (evt: { callId: string; takenBy: { idExternal: string; name?: string; email?: string; claimedAt: number } }) => void;

  /** Um atendente conectou na mesma inbox */
  "agent:joined": (agent: { idExternal: string; name?: string; email?: string; role?: string; socketId: string; inboxId?: number; since: number }) => void;

  /** Um atendente desconectou da mesma inbox */
  "agent:left": (agent: { idExternal: string; name?: string; email?: string; role?: string; socketId: string; inboxId?: number; since: number }) => void;

  /** Status da conexao com servidor */
  "connection:status": (connected: boolean) => void;

  /** Gravacao iniciada automaticamente */
  "recording:started": (callId: string) => void;

  /** Gravacao finalizada e salva no servidor */
  "recording:stopped": (callId: string, result: RecordingResult | null) => void;

  /** Erro */
  error: (error: Error) => void;
};
```

### Exemplo completo:

```typescript
const api = await GrowhatsWebphone.render({
  serverUrl: "https://sua-api.com",
  accountId: 1,
  inboxId: 81,
  recording: { enabled: true },
});

api.on("connection:status", (connected) => {
  console.log("Conexao:", connected ? "online" : "offline");
});

api.on("call:incoming", (offer) => {
  console.log("Chamada recebida de:", offer.from);
  offer.accept(); // ou offer.reject()
});

api.on("call:started", (call) => {
  console.log("Em chamada:", call.peer.phone, call.direction);
});

api.on("call:ended", (callId, reason) => {
  console.log("Chamada encerrada:", callId, reason);
});

api.on("recording:started", (callId) => {
  console.log("Gravando chamada:", callId);
});

api.on("recording:stopped", (callId, result) => {
  if (result) {
    console.log("Gravacao salva:", result.url, result.durationSeconds + "s");
  }
});

api.on("error", (err) => {
  console.error("Erro:", err.message);
});
```

---

## Compatibilidade com Wavoip

A API e 100% compativel com `@wavoip/wavoip-webphone`. O global `window.wavoip` e exposto automaticamente apos `render()`:

```typescript
// Todos os metodos da Wavoip funcionam:
window.wavoip.call.startCall("5511999999999", [tokenDispositivo]);
window.wavoip.device.addDevice(token);
window.wavoip.device.enableDevice(token);
window.wavoip.device.disableDevice(token);
window.wavoip.device.removeDevice(token);
window.wavoip.notifications.getNotifications();
window.wavoip.notifications.addNotification({ type: "INFO", message: "Teste" });
window.wavoip.settings.setShowWidgetButton(false);
window.wavoip.settings.setShowNotifications(false);
window.wavoip.settings.setShowSettings(false);
window.wavoip.theme.setTheme("dark");
window.wavoip.widget.toggle();
```

### Guia de migracao

Para migrar de `@wavoip/wavoip-webphone` para `@growhats/webphone`:

1. Substitua o import/script:

```diff
- import WavoipWebphone from "@wavoip/wavoip-webphone"
+ import { GrowhatsWebphone } from "@growhats/webphone"
```

```diff
- <script src="https://cdn.jsdelivr.net/npm/@wavoip/wavoip-webphone/dist/index.umd.min.js"></script>
+ <script src="https://cdn.jsdelivr.net/npm/@growhats/webphone/dist/growhats-webphone.umd.js"></script>
```

2. Adicione os parametros obrigatorios no `render()`:

```diff
- WavoipWebphone.render()
+ GrowhatsWebphone.render({
+   serverUrl: "https://sua-api.com",
+   accountId: 1,
+   inboxId: 81,
+ })
```

3. Todo o restante (`window.wavoip.*`) continua funcionando sem alteracao.

## Licenca

MIT
