Primeira chamada em 15 minutos
Do zero até uma chamada de voz de verdade entre a portaria (Angular) e um morador (navegador), no sandbox. Você vai: subir o sandbox, gerar as chaves do seu backend, cadastrá-las, fazer as primeiras requisições e ligar.
https://sandbox.<host>) ainda não está no ar; o passo 1 sobe o mesmo código na sua máquina. Quando ele estiver, pule o passo 1, troque VOICE_API pelo endereço dele e cadastre as chaves no console em vez do passo 3.Antes de começar
- Acesso ao repositório
moraviq-voice(piloto: o convite vem com a conta de sandbox). - Docker, uv, Node.js 20.19 ou mais novo, Git e Chrome ou Edge.
- Portas livres em 127.0.0.1: 8221 (API), 8222 (seu backend de exemplo), 8228 (TURN) e 55442 (Postgres). Outras portas: veja
exemplos/sandbox-local/ambiente.sh.
-
Suba o sandbox (2 min)
git clone https://github.com/idosreisjunior/moraviq-voice.git cd moraviq-voice bash exemplos/sandbox-local/subir.shSobe o Postgres, o TURN (coturn) e a voice-api em modo sandbox em
http://127.0.0.1:8221. No fim ele mostra oaudque os seus JWT precisam ter:http://127.0.0.1:8221/v2. Para parar:bash exemplos/sandbox-local/subir.sh parar. -
Gere as chaves do seu backend (1 min)
O Voice não emite token para você: o seu backend assina os JWT com a chave privada dele, e o Voice confere com a pública. Uma chave para o servidor (EdDSA aqui) e outra para os tokens de aparelho (ES256 aqui). Em produção, guarde a privada no seu cofre de segredos.
export PARCEIRO_CHAVES="$PWD/exemplos/portaria-angular/backend/.chaves" node exemplos/portaria-angular/backend/chaves.mjs "$PARCEIRO_CHAVES"chaves.mjsno repositório // Gera as chaves do parceiro de teste: EdDSA para o servidor, ES256 para os // tokens de aparelho (como um backend em Java faria). As PRIVADAS ficam em // <dir>/*.priv.json (nunca entram no Git nem saem do backend); as PÚBLICAS em // <dir>/*.pub.json, para colar no console (ou no cadastrar-parceiro.sh). // // node chaves.mjs [dir] (padrão: ./.chaves; não sobrescreve) import { generateKeyPairSync } from 'node:crypto'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const dir = process.argv[2] || process.env.PARCEIRO_CHAVES || '.chaves'; mkdirSync(dir, { recursive: true, mode: 0o700 }); function par(nome, kid, tipo, opcoes) { const priv = join(dir, `${nome}.priv.json`); if (existsSync(priv)) { console.log(`${priv} já existe, mantido`); return; } const { publicKey, privateKey } = generateKeyPairSync(tipo, opcoes); writeFileSync(priv, JSON.stringify({ ...privateKey.export({ format: 'jwk' }), kid }), { mode: 0o600 }); writeFileSync(join(dir, `${nome}.pub.json`), JSON.stringify({ ...publicKey.export({ format: 'jwk' }), kid }, null, 2)); console.log(`${nome}: kid ${kid}, pública em ${join(dir, `${nome}.pub.json`)}`); } par('servidor', 'srv-1', 'ed25519'); par('cliente', 'cli-1', 'ec', { namedCurve: 'P-256' }); -
Cadastre o emissor e as chaves públicas (1 min)
No sandbox hospedado isto é o console (Chaves e webhooks). No local:
export PARCEIRO_ISS=https://condo-teste.exemplo bash exemplos/sandbox-local/cadastrar-parceiro.sh condo-teste "$PARCEIRO_ISS" \ "$PARCEIRO_CHAVES/servidor.pub.json" "$PARCEIRO_CHAVES/cliente.pub.json"O
issé o nome do seu emissor (qualquer URL sua); cada chave tem umkid, um uso (serverouclient) e um algoritmo. Só a chave pública sai do seu backend. -
Primeira requisição: o condomínio e a guarita (2 min)
Toda rota de servidor leva no
Authorizationuma asserção nova: um JWT de 50 s, de uso único, com os escopos da operação.export VOICE_API=http://127.0.0.1:8221 node docs-portal/exemplos/node/01-site-e-posto.mjsassinatura.mjsno repositório // Assinatura dos JWT do parceiro para o Moraviq Voice, só com node:crypto. // // - asserção do servidor (typ voice+server): vida até 60 s, jti de uso único, // scope em texto separado por espaço; vai no Authorization das rotas de servidor. // - token de aparelho (typ voice, ver 2): vida até 600 s (use 300), sub e site // são os seus ext_id, dev é o sha256 do installation_id do aparelho. // // EdDSA (Ed25519) e ES256 (P-256) são os dois algoritmos aceitos; um por kid. import { createHash, createPrivateKey, randomUUID, sign } from 'node:crypto'; const b64url = (buf) => Buffer.from(buf).toString('base64url'); /** Assina um JWT compacto com a chave privada em JWK (OKP Ed25519 ou EC P-256). */ export function assinarJwt(cabecalho, claims, jwkPrivada) { const chave = createPrivateKey({ key: jwkPrivada, format: 'jwk' }); const alg = jwkPrivada.kty === 'OKP' ? 'EdDSA' : 'ES256'; const h = b64url(JSON.stringify({ ...cabecalho, alg, kid: jwkPrivada.kid })); const c = b64url(JSON.stringify(claims)); const dados = Buffer.from(`${h}.${c}`); // ES256 em JWT é r||s (64 bytes), não DER: dsaEncoding ieee-p1363. const assinatura = alg === 'EdDSA' ? sign(null, dados, chave) : sign('sha256', dados, { key: chave, dsaEncoding: 'ieee-p1363' }); return `${h}.${c}.${b64url(assinatura)}`; } /** Asserção do servidor do parceiro: uma por requisição (o jti não se repete). */ export function assercaoDoServidor({ iss, aud, scope, chave, vidaS = 50 }) { const agora = Math.floor(Date.now() / 1000); return assinarJwt({ typ: 'voice+server' }, { iss, aud, iat: agora, exp: agora + vidaS, jti: randomUUID(), scope, }, chave); } /** Token de aparelho para a portaria ou o morador logado no seu sistema. */ export function tokenDoAparelho({ iss, aud, sub, site, installationId, scope, grupos = [], chave, vidaS = 300 }) { const agora = Math.floor(Date.now() / 1000); return assinarJwt({ typ: 'voice' }, { iss, aud, ver: 2, sub, site, dev: createHash('sha256').update(installationId).digest('hex'), scope, grp: grupos, jti: randomUUID(), iat: agora, nbf: agora, exp: agora + vidaS, }, chave); }01-site-e-posto.mjsno repositório // Passo 1: cadastra o condomínio (site) e a guarita (posto). Idempotente. import { assercaoDoServidor } from './assinatura.mjs'; import { API, AUD, ISS, chave } from './comum.mjs'; const servidor = chave('servidor'); const auth = () => ({ Authorization: `Bearer ${assercaoDoServidor({ iss: ISS, aud: AUD, scope: 'sites:write', chave: servidor })}`, 'Content-Type': 'application/json', }); let r = await fetch(`${API}/v2/sites/cond_jardins`, { method: 'PUT', headers: auth(), body: JSON.stringify({ enabled: true, ring_timeout_s: 30 }), }); if (!r.ok) throw new Error(`site: ${r.status} ${await r.text()}`); console.log('site', await r.json()); r = await fetch(`${API}/v2/sites/cond_jardins/stations/guarita_1`, { method: 'PUT', headers: auth(), body: JSON.stringify({ label: 'Guarita 1' }), }); if (!r.ok) throw new Error(`posto: ${r.status} ${await r.text()}`); console.log('posto', await r.json()); -
Token de aparelho (1 min)
Quando a pessoa entra no seu app (portaria ou morador), o seu backend entrega a ela um token de aparelho de 5 minutos. O SDK pede um novo pelo
getTokensozinho.node docs-portal/exemplos/node/02-token-de-aparelho.mjs02-token-de-aparelho.mjsno repositório // Passo 2: o token de aparelho que o SEU backend entrega ao app (portaria ou // morador), assinado com a chave client (aqui ES256). Prova: o Voice aceita e // devolve um ticket de WebSocket. import { randomUUID } from 'node:crypto'; import { tokenDoAparelho } from './assinatura.mjs'; import { API, AUD, ISS, chave } from './comum.mjs'; const token = tokenDoAparelho({ iss: ISS, aud: AUD, sub: 'res_77', // o ext_id da pessoa no seu sistema site: 'cond_jardins', // um site já cadastrado installationId: `inst-${randomUUID()}`, // um por app instalado; vai como sha256 no claim dev scope: ['call:receive', 'call:answer'], chave: chave('cliente'), }); const r = await fetch(`${API}/v2/ws-ticket`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }); if (r.status !== 200) throw new Error(`ws-ticket: ${r.status} ${await r.text()}`); const { expires_in: vida } = await r.json(); console.log(`token aceito; ticket de uso único válido por ${vida} s`); -
Suba a portaria Angular e o morador de exemplo (5 min)
(cd sdk/angular && npm ci && npm run build) cd exemplos/portaria-angular npm ci && npm run build VOICE_API=$VOICE_API PARCEIRO_ISS=$PARCEIRO_ISS PARCEIRO_CHAVES=$PARCEIRO_CHAVES npm run backendO backend fica rodando neste terminal. Para os comandos dos próximos passos, abra outro terminal na raiz do repositório e repita os três
export(PARCEIRO_CHAVES,PARCEIRO_ISS,VOICE_API).O backend de exemplo (
backend/servidor.mjs) é o seu backend em miniatura: emite os tokens e cria a chamada. A portaria é Angular com@moraviq/voice-angular; o morador é uma página com o SDK JS. -
Ligue (1 min)
- Abra
http://127.0.0.1:8222/morador/?usuario=res_77numa janela. Ela registra o aparelho e fica esperando (conexão: open). - Abra
http://127.0.0.1:8222/em outra janela (ou em outro navegador), deixe a unidadeA-304e o moradorres_77e clique em Ligar. Permita o microfone. - Na janela do morador aparece "Guarita 1 está chamando": Atender. Pronto: vocês estão falando.
O que aconteceu: o backend chamou
POST /v2/callscomcallees: ["res_77"]; a portaria entrou comattachOutgoing(call_id); o morador recebeuincomingCallpelo WebSocket e atendeu; a portaria mandou a offer depois do aceite; o áudio passou direto (ou pelo TURN, se a rede pedir; force com?relay=1nas duas janelas).servidor.mjsno repositório if (req.method === 'POST' && req.url === '/api/portaria/chamar') { const { porteiro, unidade, moradores, motivo } = await corpoJson(req); const view = await voz('POST', '/v2/calls', { site: SITE, target: { kind: 'unit', ref: unidade }, callees: moradores, caller: { user: porteiro, station: POSTO }, context: { reason: motivo || 'visitor' }, labels: { target: `Unidade ${unidade}` }, metadata: { origem: 'portaria-angular' }, }, { scope: 'calls:create', idempotencia: `chamar-${randomUUID()}` }); return json(res, 201, { call_id: view.id, status: view.status, devices_ringing: view.devices_ringing }); } - Abra
-
Depois da chamada
Na raiz do repositório: consulte as chamadas do site (
GET /v2/calls?site=cond_jardins, escopocalls:read), registre o desfecho (POST /v2/calls/{call_id}/disposition) e receba os webhooks. No sandbox local o webhook não sai paralocalhost(o Voice recusa endereço privado, regra de segurança); teste a sua conferência comdocs-portal/exemplos/node/04-webhook.mjs.03-ligar.mjsno repositório // Passo 3: o backend cria a chamada (modo prior: VOCÊ diz quem toca, em callees). // Com o morador da página de exemplo aberto, o aparelho dele toca; aqui o // exemplo desliga logo em seguida (sem portaria, ela terminaria em origin_gone). import { randomUUID } from 'node:crypto'; import { assercaoDoServidor } from './assinatura.mjs'; import { API, AUD, ISS, chave } from './comum.mjs'; const servidor = chave('servidor'); const h = (scope, extra = {}) => ({ Authorization: `Bearer ${assercaoDoServidor({ iss: ISS, aud: AUD, scope, chave: servidor })}`, 'Content-Type': 'application/json', ...extra, }); const idem = `pedido-${randomUUID()}`; // mesma chave = mesma chamada, mesmo se você repetir o POST const corpo = { site: 'cond_jardins', target: { kind: 'unit', ref: 'A-304' }, callees: ['res_77'], caller: { user: 'port_12', station: 'guarita_1' }, context: { reason: 'delivery', reason_label: 'Mercado' }, metadata: { pedido: 'ped_123' }, // volta no webhook; nunca vai a aparelho }; let r = await fetch(`${API}/v2/calls`, { method: 'POST', headers: h('calls:create', { 'Idempotency-Key': idem }), body: JSON.stringify(corpo) }); if (r.status !== 201) throw new Error(`criar: ${r.status} ${await r.text()}`); const chamada = await r.json(); console.log('criada', chamada.id, chamada.status, chamada.end_reason ?? ''); // Repetir com a mesma chave devolve a mesma chamada (200), sem criar outra. r = await fetch(`${API}/v2/calls`, { method: 'POST', headers: h('calls:create', { 'Idempotency-Key': idem }), body: JSON.stringify(corpo) }); if (r.status !== 200 || (await r.json()).id !== chamada.id) throw new Error('idempotência'); r = await fetch(`${API}/v2/calls/${chamada.id}`, { headers: h('calls:read') }); console.log('agora', (await r.json()).status); r = await fetch(`${API}/v2/calls/${chamada.id}/hangup`, { method: 'POST', headers: h('calls:end') }); const fim = await r.json(); console.log('fim', fim.status, fim.end_reason);
Ao terminar
Ctrl+C no terminal do backend e bash exemplos/sandbox-local/subir.sh parar.
Se algo der errado
| Sintoma | Causa provável |
|---|---|
401 unauthorized em tudo | aud diferente do que o subir.sh mostrou, iss diferente do cadastrado, relógio da máquina adiantado ou kid não cadastrado. O 401 é seco de propósito: confira um por um. |
401 só no token de aparelho | site ainda não cadastrado (passo 4) ou scope fora de call:receive call:answer call:place. |
| "Não foi possível ligar (mic_denied)" | o navegador negou o microfone; libere para 127.0.0.1:8222. |
Toca e cai sozinha com origin_gone | a portaria precisa entrar com attachOutgoing em até 10 s depois de criada. |
| Conecta mas sem som | o navegador bloqueou o autoplay: o componente mostra "Ativar o som". |
| Porta ocupada | SANDBOX_PORTA_API, SANDBOX_PORTA_TURN e SANDBOX_PORTA_APP mudam as portas (e o aud). |
Os mesmos passos em Python e cURL
"""O backend do parceiro em Python (PyJWT): asserção do servidor com EdDSA, site, chamada e fim.
pip install "pyjwt[crypto]" httpx
VOICE_API=... PARCEIRO_ISS=... PARCEIRO_CHAVES=.chaves python primeira_chamada.py
"""
import json
import os
import time
import uuid
from pathlib import Path
import httpx
import jwt
API = os.environ.get("VOICE_API", "http://127.0.0.1:8221").rstrip("/")
AUD = os.environ.get("VOICE_AUD", f"{API}/v2")
ISS = os.environ.get("PARCEIRO_ISS", "https://condo-teste.exemplo")
CHAVES = Path(os.environ.get("PARCEIRO_CHAVES", ".chaves"))
def chave_privada(nome: str):
jwk = json.loads((CHAVES / f"{nome}.priv.json").read_text())
alg = "EdDSA" if jwk["kty"] == "OKP" else "ES256"
return jwt.PyJWK(jwk, algorithm=alg), alg
SERVIDOR, ALG = chave_privada("servidor")
def assercao(scope: str) -> str:
"""Uma por requisição: vida até 60 s e jti de uso único."""
agora = int(time.time())
claims = {
"iss": ISS,
"aud": AUD,
"iat": agora,
"exp": agora + 50,
"jti": uuid.uuid4().hex,
"scope": scope,
}
return jwt.encode(
claims, SERVIDOR.key, algorithm=ALG, headers={"kid": SERVIDOR.key_id, "typ": "voice+server"}
)
def h(scope: str, **extra) -> dict:
return {"Authorization": f"Bearer {assercao(scope)}"} | extra
with httpx.Client(base_url=API, timeout=10) as c:
r = c.put("/v2/sites/cond_jardins", json={"enabled": True}, headers=h("sites:write"))
r.raise_for_status()
corpo = {
"site": "cond_jardins",
"target": {"kind": "unit", "ref": "A-304"},
"callees": ["res_77"],
"caller": {"user": "port_12", "station": "guarita_1"},
"context": {"reason": "visitor"},
}
chave = f"pedido-{uuid.uuid4()}"
r = c.post("/v2/calls", json=corpo, headers=h("calls:create", **{"Idempotency-Key": chave}))
assert r.status_code == 201, r.text
chamada = r.json()
print("criada", chamada["id"], chamada["status"], chamada.get("end_reason") or "")
r = c.post(f"/v2/calls/{chamada['id']}/hangup", headers=h("calls:end"))
print("fim", r.json()["status"], r.json()["end_reason"])
# Erro tem forma fixa: {"error", "detail"?, "request_id"} (o mesmo do X-Request-Id).
r = c.get(f"/v2/calls/{uuid.uuid4()}", headers=h("calls:read"))
erro = r.json()
assert r.status_code == 404 and erro["error"] == "call_not_found"
assert erro["request_id"] == r.headers["X-Request-Id"]
print("erro esperado", erro["error"], erro["request_id"])#!/usr/bin/env bash
# A mesma chamada com cURL. A asserção é um JWT que o SEU backend assina (aqui,
# o exemplo Node imprime uma); cada uma vale 50 s e serve para UMA requisição.
set -euo pipefail
API="${VOICE_API:-http://127.0.0.1:8221}"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../node" && pwd)"
assercao() { node "$DIR/assercao.mjs" "$1"; }
curl -fsS -X POST "$API/v2/calls" \
-H "Authorization: Bearer $(assercao calls:create)" \
-H "Idempotency-Key: pedido-$(date +%s%N)" \
-H "Content-Type: application/json" \
-d '{"site":"cond_jardins","target":{"kind":"unit","ref":"A-304"},"callees":["res_77"],
"caller":{"user":"port_12","station":"guarita_1"},"context":{"reason":"visitor"}}' \
-o chamada.json
ID=$(python3 -c 'import json; print(json.load(open("chamada.json"))["id"])')
echo "criada $ID"
curl -fsS -X POST "$API/v2/calls/$ID/hangup" -H "Authorization: Bearer $(assercao calls:end)"
echo
rm -f chamada.jsonTodos os exemplos desta página rodam no CI contra o sandbox, a cada mudança (docs-portal/exemplos/rodar.sh). Exemplo velho derruba o CI.