Moraviq Voice para desenvolvedoresAPI v2

SDK Angular

@moraviq/voice-angular: camada fina sobre o SDK JS para Angular 20 a 22 (standalone, com zone.js ou zoneless). provideMoraviqVoice, um VoiceService injetável com Signal e Observable, e um componente de chamada opcional.

npm install @moraviq/voice-web @moraviq/voice-angular

Configurar

main.tsno repositório
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideMoraviqVoice } from '@moraviq/voice-angular';
import { AppComponent } from './app/app.component';
import { instalacao } from './app/instalacao';

// O backend do parceiro diz onde está a API (sandbox ou produção) e emite o token.
const config = await (await fetch('/api/config')).json() as { baseUrl: string };

bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
    provideMoraviqVoice({
      baseUrl: config.baseUrl,
      // O SEU backend assina o token de aparelho da pessoa logada (aqui, o porteiro).
      getToken: async () => {
        const r = await fetch('/api/voz/token', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          cache: 'no-store',
          body: JSON.stringify({ papel: 'portaria', usuario: 'port_12', installation_id: instalacao() }),
        });
        if (!r.ok) throw new Error('token');
        return (await r.json()).token as string;
      },
    }),
  ],
}).catch((e) => console.error(e));

VoiceService

MembroTipo
connectionState, connectionState$Signal<ConnectionState>, Observable
calls, activeCallSignal com retratos imutáveis (status, endReason, answeredBy, devicesRinging, mediaConnected, muted)
state(id), call(id)Signal de uma chamada
incomingCall$, callEnded$, error$, lastErroreventos
attachOutgoing(id, opts), placeCall(opts)origem
accept(id), reject(id), hangup(id), setMuted(id, b), setDisposition(id, code)na chamada
connect(), disconnect(), registerDevice(installationId)conexão e aparelho
bindAudio(id, el), handle(id), clientpara montar a própria tela ou ir ao SDK JS

O socket e a RTCPeerConnection rodam fora da zona do Angular (o ping a cada 20 s e cada candidato ICE não disparam detecção de mudança); só as mudanças de estado voltam para dentro. Destruir o injetor desconecta.

Portaria de exemplo

A portaria inteira (exemplos/portaria-angular), com o componente opcional <moraviq-call>:

app.component.tsno repositório
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { MoraviqCallComponent, VoiceService } from '@moraviq/voice-angular';

interface Resultado {
  status: string;
  endReason?: string | null;
  [k: string]: unknown;
}

/** Contrato com o teste ponta a ponta (tests/e2e/chamada_v2_no_navegador.py). */
declare global {
  interface Window { __voiceResult?: Resultado | null }
}

/**
 * Portaria de exemplo: o porteiro escolhe a unidade e liga.
 *
 * 1. o backend do parceiro cria a chamada (`POST /v2/calls`, modo prior) e devolve o call_id;
 * 2. esta aba entra na chamada com o token do porteiro (`attachOutgoing`);
 * 3. o componente opcional `<moraviq-call>` mostra o estado, mudo e desligar.
 *
 * Modo automático para o CI: `?auto=1&secs=6&relay=1&unidade=A-304&morador=res_77`.
 */
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [MoraviqCallComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <main>
      <h1>Portaria</h1>
      <p class="conexao" [attr.data-estado]="voz.connectionState()">Conexão: {{ voz.connectionState() }}</p>
      <form (submit)="$event.preventDefault(); ligar()">
        <label>Unidade <input name="unidade" [value]="unidade()" (input)="unidade.set(valor($event))" /></label>
        <label>Morador (ext_id) <input name="morador" [value]="morador()" (input)="morador.set(valor($event))" /></label>
        <label>Motivo
          <select name="motivo" (change)="motivo.set(valor($event))">
            <option value="visitor">Visitante</option>
            <option value="delivery">Entrega</option>
            <option value="service">Serviço</option>
            <option value="other">Outro</option>
          </select>
        </label>
        <button type="submit" [disabled]="ocupado()">Ligar</button>
      </form>
      @if (erro()) { <p class="erro" role="alert">{{ erro() }}</p> }
      @if (callId(); as id) {
        <moraviq-call [callId]="id" (ended)="terminou($event)" />
        @if (estado(); as s) { <p class="detalhe">Estado: {{ s }}</p> }
      }
    </main>
  `,
  styles: [`
    main { max-width: 480px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px #0002; }
    h1 { margin: 0 0 8px; font-size: 22px; }
    form { display: grid; gap: 10px; margin: 16px 0; }
    label { display: grid; gap: 4px; font-size: 14px; }
    input, select, button { font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid #c8ced8; }
    button { background: #1f6feb; color: #fff; border: 0; cursor: pointer; }
    button[disabled] { opacity: .5; }
    .erro { color: #b42318; }
    .conexao, .detalhe { color: #4a5568; font-size: 14px; }
  `],
})
export class AppComponent {
  protected readonly voz = inject(VoiceService);
  private readonly q = new URLSearchParams(location.search);
  protected readonly unidade = signal(this.q.get('unidade') || 'A-304');
  protected readonly morador = signal(this.q.get('morador') || 'res_77');
  protected readonly motivo = signal('visitor');
  protected readonly callId = signal<string | null>(null);
  protected readonly erro = signal<string | null>(null);
  protected readonly ocupado = computed(() => !!this.voz.activeCall());
  protected readonly estado = computed(() => {
    const id = this.callId();
    return id ? this.voz.state(id)() : null;
  });
  private ultimas: Record<string, unknown> | null = null;

  constructor() {
    if (this.q.get('auto') === '1') {
      window.__voiceResult = null;
      void this.ligar().then(() => this.automatico());
    }
  }

  protected valor(ev: Event): string {
    return (ev.target as HTMLInputElement).value;
  }

  /** No clique: o microfone é pedido aqui, antes de qualquer chamada existir no Voice. */
  async ligar(): Promise<void> {
    this.erro.set(null);
    try {
      const r = await fetch('/api/portaria/chamar', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ porteiro: 'port_12', unidade: this.unidade(), moradores: [this.morador()], motivo: this.motivo() }),
      });
      const dado = await r.json();
      if (!r.ok) throw new Error(dado.error || 'falha ao criar a chamada');
      await this.voz.attachOutgoing(dado.call_id, { forceRelay: this.q.get('relay') === '1' });
      this.callId.set(dado.call_id);
    } catch (e) {
      const code = (e as { code?: string }).code;
      this.erro.set(code === 'mic_denied' ? 'Libere o microfone para ligar.' : `Não foi possível ligar (${code || (e as Error).message}).`);
      if (this.q.get('auto') === '1') window.__voiceResult = { status: `erro_${code || 'ligar'}` };
    }
  }

  protected terminou(fim: { status: string; endReason: string | null }): void {
    if (this.q.get('auto') === '1' && !window.__voiceResult) {
      window.__voiceResult = { ...(this.ultimas || {}), status: fim.status, endReason: fim.endReason };
    }
  }

  /** CI: fala `secs` segundos depois de conectar a mídia e desliga, guardando as métricas. */
  private automatico(): void {
    const id = this.callId();
    if (!id) return;
    const secs = Number(this.q.get('secs') || 6);
    let falando = 0;
    const t = setInterval(async () => {
      const h = this.voz.handle(id);
      if (!h) return;
      if (h.pc) this.ultimas = { ...(await h.stats()) };
      if (h.status === 'active' && h.mediaConnected) falando += 1;
      if (falando >= secs) {
        clearInterval(t);
        await this.voz.hangup(id);
      }
      if (['completed', 'missed', 'cancelled', 'rejected', 'failed', 'busy'].includes(h.status)) clearInterval(t);
    }, 1000);
  }
}

Componente opcional

<moraviq-call [callId]="id" (ended)="..."> mostra o estado, "Atendida por", Silenciar, Desligar e "Ativar o som" quando o navegador bloqueia o autoplay. Sem estilo de marca: herda a fonte e expõe as classes mv-call, mv-call__status, mv-call__mute, mv-call__hangup. Para a sua própria tela, use só o serviço.