@growhats/webphone
v1.3.18
Published
Webphone widget para chamadas VoIP WhatsApp via GroWHats — drop-in replacement para @wavoip/wavoip-webphone
Maintainers
Readme
@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 — 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:
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:
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().
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: '[email protected]',
role: 'sales',
metadata: { team: 'BR-NE' }, // campo livre
},
});O que o SDK faz com isso
- Atribuicao — cada
CallLogsalvaagentIdExternal(quem originou) eansweredByAgentId(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 recebemcall:takene o modal some sozinho. Se o seuaccept()perdeu a corrida, voce recebecall:claim:deniedcom quem ganhou. - Presenca —
agent:joined/agent:leftdisparam quando outro atendente conecta/desconecta na mesma inbox. Useapi.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
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 inboxEventos multi-agente
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)
const call = await api.call.start('5511999999999');
// Retorna CallOutgoing — widget mostra painel de chamada automaticamenteEncerrar chamada
// 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
const call = api.call.getCallActive();
if (call) {
await call.mute(); // muta
await call.unmute(); // desmuta
call.muted; // true/false
}Escutar eventos
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:
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
const recordingUrl = await api.recording.getUrl(callId);
// Retorna URL (relativa) onde pode baixar/reproduzir o audio .webm
// Exemplo: /api/v1/accounts/1/calls/abc123/recordingDestruir o SDK
GrowhatsWebphone.destroy();
// Limpa widget, socket, audio, gravacoes pendentesInstalacao
# pnpm
pnpm add @growhats/webphone
# npm
npm install @growhats/webphone
# yarn
yarn add @growhats/webphoneVia CDN:
<script src="https://cdn.jsdelivr.net/npm/@growhats/webphone@latest/dist/growhats-webphone.umd.js"></script>Getting Started
Pacote instalado:
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 widgetVia CDN:
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 WavoipImportante: Sempre use
awaitao chamarrender()para evitar comportamento inesperado.
WebphoneAPI
render() retorna uma Promise<WebphoneAPI> e tambem expoe window.wavoip e window.growhatsWebphone:
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
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:
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):
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):
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
type CallStatus =
| "RINGING"
| "CALLING"
| "NOT_ANSWERED"
| "ACTIVE"
| "ENDED"
| "REJECTED"
| "FAILED"
| "DISCONNECTED"
| "DEVICE_RESTARTING";CallStats
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
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
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
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.
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}.webmWidgetAPI
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
type ThemeAPI = {
value: "light" | "dark" | "system";
set(theme: "light" | "dark" | "system"): void;
setTheme(theme: "light" | "dark" | "system"): void; // Deprecated
};PositionAPI
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)
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:
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
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:
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():
// 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:
- Substitua o import/script:
- import WavoipWebphone from "@wavoip/wavoip-webphone"
+ import { GrowhatsWebphone } from "@growhats/webphone"- <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>- Adicione os parametros obrigatorios no
render():
- WavoipWebphone.render()
+ GrowhatsWebphone.render({
+ serverUrl: "https://sua-api.com",
+ accountId: 1,
+ inboxId: 81,
+ })- Todo o restante (
window.wavoip.*) continua funcionando sem alteracao.
Licenca
MIT
