Pular para o conteúdo
Node.js

Node.js: ligação telefônica com IA da OpenAI

Paloma Macetko
Ilustração de um telefone antigo de disco ligado por um cabo luminoso a uma esfera mágica, com um unicórnio segurando o fone e ouvindo, e um castelo com velas flutuantes ao fundo

Olá meus Unicórnios! 🦄✨

Você já parou para pensar no que acontece entre o "alô" e a resposta? Eu parei — e descobri que dá para colocar um prompt da OpenAI exatamente ali no meio. 😅 O telefone toca, a pessoa atende, e quem fala do outro lado é um modelo de linguagem, com voz, ouvindo e respondendo ao vivo.

E o mais surpreendente: não tem conversão de áudio nenhuma. Isso mesmo, nenhuma! 🤯 Eu esperava ter que mexer com codec, buffer de PCM, taxa de amostragem... e não precisa. Vou te contar por quê.

Neste artigo eu monto a ponte inteira em Node.js: a ligação sai, a pessoa atende, e a conversa vira uma sessão da Realtime API. Com o código que rodou e as três armadilhas que me pegaram.

Exemplos_TelefoneOpenAI no GitHubO código completo deste artigo: servidor, script de ligação e uma página para testar sem escrever código.github.com

🎧 Por que não precisa converter áudio

Essa foi a parte que me deixou de queixo caído. Telefonia usa um codec velhíssimo chamado G.711 u-law — 8000 amostras por segundo, 8 bits cada. É de 1972, e é o que a sua operadora usa até hoje.

O Twilio entrega o áudio da ligação nesse formato, empacotado em base64. E a Realtime API da OpenAI aceita exatamente esse formato, tanto na entrada quanto na saída. Basta pedir:

        // Configura a sessao. O formato g711_ulaw nos dois lados e o que
        // dispensa qualquer transcodificacao de audio nesta ponte.
        const iniciarSessao = () => {
            openai.send(JSON.stringify({
                type: 'session.update',
                session: {
                    turn_detection: { type: 'server_vad' },
                    input_audio_format: 'g711_ulaw',
                    output_audio_format: 'g711_ulaw',
                    voice: VOZ,
                    instructions: promptDaVez,
                    modalities: ['text', 'audio'],
                    temperature: 0.8
                }
            }));

            // Sem isto, os dois lados ficam esperando o outro falar primeiro.
            openai.send(JSON.stringify({
                type: 'conversation.item.create',
                item: {
                    type: 'message',
                    role: 'user',
                    content: [{
                        type: 'input_text',
                        text: 'Cumprimente a pessoa e diga a que veio, em portugues.'
                    }]
                }
            }));
            openai.send(JSON.stringify({ type: 'response.create' }));
        };

Repare nas duas linhas do meio: input_audio_format e output_audio_format, as duas em g711_ulaw. É isso que transforma o servidor num encanador: ele pega o pacote que veio do Twilio e joga na OpenAI sem abrir. Depois pega o que a OpenAI devolve e joga no Twilio, também sem abrir.

📞 O TwiML: o que segura a ligação de pé

Quando a pessoa atende, o Twilio faz uma requisição HTTP ao seu servidor perguntando "e agora, o que eu faço?". A resposta é um XML chamado TwiML.

/**
 * O TwiML: a resposta que diz ao Twilio o que fazer com a ligacao atendida.
 *
 * <Connect><Stream> e o pulo do gato — ele abre um WebSocket de volta para
 * este servidor com o audio ao vivo. Sem <Connect>, o Twilio executaria os
 * verbos e desligaria.
 */
app.all('/chamada', async (requisicao, resposta) => {
    const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="wss://${requisicao.headers.host}/audio" />
  </Connect>
</Response>`;

    resposta.type('text/xml').send(twiml);
});

Aqui mora a primeira armadilha, e ela me custou um bom tempo. 😳 Meu primeiro TwiML tinha um <Say> antes do <Connect>, só para eu ouvir alguma coisa e saber que tinha funcionado. Funcionou: a frase tocou. E a ligação caiu logo depois.

É que o Twilio executa os verbos do TwiML em ordem e, quando acaba a lista, ele desliga — o trabalho terminou. O <Connect><Stream> é diferente dos outros: ele não "executa e passa adiante", ele abre um WebSocket de volta para o seu servidor e mantém a chamada viva enquanto esse socket estiver aberto.

Repare também no wss:// e no request.headers.host: o Twilio precisa alcançar o seu servidor pela internet. Em desenvolvimento isso quer dizer um túnel:

ngrok http 3009

🗣️ A armadilha da interrupção (essa é a boa)

Se você só for ler um pedaço deste artigo, leia este. 🙏

Conversa por telefone tem uma característica que chat não tem: a pessoa fala por cima. Ela ouve as primeiras palavras, entende onde a conversa vai, e interrompe. Isso se chama barge-in, e é o que separa uma ligação que parece natural de uma que parece uma secretária eletrônica.

A parte fácil é detectar: a Realtime API avisa com o evento input_audio_buffer.speech_started. A parte difícil é o que fazer depois — e eu achei que fosse uma coisa. São duas.

        /**
         * A pessoa voltou a falar por cima da IA (barge-in).
         *
         * Dois descartes sao necessarios, e esquecer um deles e o bug classico:
         * o "truncate" corta o que a IA acha que ja falou (senao ela responde
         * como se tivesse dito o texto inteiro), e o "clear" joga fora o audio
         * que ja esta na fila do Twilio (senao a voz continua saindo por
         * segundos depois de a pessoa interromper).
         */
        const aoInterromper = () => {
            if (marcadores.length === 0 || inicioDaResposta === null) return;

            if (ultimaFala) {
                openai.send(JSON.stringify({
                    type: 'conversation.item.truncate',
                    item_id: ultimaFala,
                    content_index: 0,
                    audio_end_ms: tempoAtual - inicioDaResposta
                }));
            }

            telefone.send(JSON.stringify({ event: 'clear', streamSid }));

            marcadores = [];
            ultimaFala = null;
            inicioDaResposta = null;
        };

Os dois descartes resolvem problemas diferentes, e é por isso que fazer só um deixa a impressão de que "quase funciona":

  • O truncate conserta a memória da IA. Sem ele, o modelo segue acreditando que falou o parágrafo inteiro, e a próxima resposta vem em cima de algo que a pessoa nunca ouviu. A conversa fica sutilmente sem sentido — o pior tipo de defeito, porque parece falta de inteligência do modelo, não bug seu.
  • O clear conserta o som. A OpenAI gera a resposta muito mais rápido do que o tempo real de fala, então quando a pessoa interrompe já existem segundos de áudio enfileirados no Twilio, esperando para tocar. Sem o clear, a voz simplesmente continua saindo depois da interrupção.

Foi esse segundo que me pegou. Eu implementei o truncate, achei que estava pronto, e a voz continuava falando por cima da pessoa. Passei um bom tempo procurando erro no meu cálculo de tempo — o erro era não existir a segunda linha. 😅

E o audio_end_ms merece uma nota: ele não é o relógio do seu servidor. É o timestamp que vem em cada pacote media do Twilio, que conta os milissegundos de áudio realmente entregues à ligação. É o único relógio que sabe quanto a pessoa de fato ouviu.

☎️ Originando a ligação

Até aqui a ponte atende. Agora ela precisa ligar. São poucas linhas, e a única sutileza está na validação:

/**
 * Origina a ligacao: manda o Twilio discar e apontar a chamada atendida
 * para o /chamada do servidor.
 *
 * Uso:  node ligar.js +5511999999999
 */

import twilio from 'twilio';
import dotenv from 'dotenv';

dotenv.config();

const { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, DOMINIO } = process.env;

const destino = process.argv[2];

if (!destino) {
    console.error('Uso: node ligar.js +5511999999999');
    process.exit(1);
}

// O E.164 nao e frescura do Twilio: sem o "+" e o codigo do pais a API
// recusa o numero, e o erro que volta fala de "numero invalido" sem dizer
// que o que faltou foi o formato.
if (!/^\+[1-9]\d{7,14}$/.test(destino)) {
    console.error(`Numero fora do padrao E.164: ${destino}`);
    console.error('Use +55 + DDD + numero, tudo junto. Ex.: +5511999999999');
    process.exit(1);
}

if (!TWILIO_ACCOUNT_SID || !TWILIO_AUTH_TOKEN || !TWILIO_PHONE_NUMBER || !DOMINIO) {
    console.error('Faltam variaveis no .env (veja o .env.exemplo)');
    process.exit(1);
}

const cliente = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);

const chamada = await cliente.calls.create({
    to: destino,
    from: TWILIO_PHONE_NUMBER,
    // Quando a pessoa atender, o Twilio busca o TwiML nesta URL.
    url: `https://${DOMINIO}/chamada`
});

console.log(`Ligando para ${destino}`);
console.log(`SID da chamada: ${chamada.sid}`);

Terceira armadilha, e a mais boba de todas: o formato do número. O Twilio exige E.164+, código do país, DDD e número, tudo junto e sem pontuação: +5511999999999.

O detalhe cruel é a mensagem de erro. Mandando 11999999999 (sem o +55), o que volta é um erro dizendo que o número é inválido — o que faz você conferir o número, que está certo. Nada na mensagem diz "faltou o código do país". Por isso a validação está no script: uma expressão regular de uma linha economiza a dúvida, e ainda evita gastar uma chamada de API para descobrir o óbvio.

🧪 O que eu consegui testar (e o que não)

Aqui eu preciso ser honesta com você, porque isto é um sistema com três partes e eu não tinha as três à mão. 🙏

Testei de verdade: subi o servidor, abri um WebSocket fingindo ser o Twilio e mandei os eventos start e media que ele mandaria — com quadros de 160 bytes de silêncio u-law em base64, que é exatamente o que trafega numa ligação. A ponte aceitou a conexão, guardou o streamSid, repassou o áudio e abriu o WebSocket da OpenAI:

Servidor ouvindo em http://localhost:3009
[twilio] ligacao conectada
[twilio] stream iniciado MZteste123
[openai] conectado
[openai] erro: {
  type: 'invalid_request_error',
  code: 'invalid_api_key',
  message: 'Incorrect API key provided: sk-proj-*******aqui.',
  param: null,
  event_id: null
}
[twilio] ligacao encerrada

Esse erro no fim não é uma falha do teste — ele é a prova de que deu certo até ali. Para a OpenAI responder "chave inválida", o WebSocket precisou ser aceito, o handshake precisou passar e a sessão precisou ser aberta. Uma URL errada ou um cabeçalho faltando falhariam antes, com outra cara. Eu estava usando a chave de exemplo do .env.exemplo, que é literalmente sk-proj-troque-aqui. 😄

Testei também as duas rotas HTTP, incluindo os caminhos de erro:

=== pagina ===
HTTP 200  3670 bytes

=== TwiML em /chamada ===
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Connect>
    <Stream url="wss://127.0.0.1:3009/audio" />
  </Connect>
</Response>

=== numero fora do E.164 ===
{"erro":"Numero fora do padrao E.164. Ex.: +5511999999999"}

=== numero valido, credencial falsa ===
{"erro":"Authentication Error - invalid username"}

Não testei: a ligação real. Para isso é preciso um número Twilio com voz habilitada, uma chave da OpenAI com acesso à Realtime API e um telefone tocando de verdade. O áudio de volta e a interrupção ao vivo são as duas coisas que só a chamada de verdade prova — e eu não vou dizer que provei o que não provei.

⚖️ Uma palavra sobre ligar para pessoas

Esta parte não é técnica, mas é a mais importante. Ligação automatizada mexe com o telefone de alguém que não pediu nada. 🙏

Por isso o prompt de exemplo diz, com todas as letras, que ele é um assistente virtual e admite isso se perguntarem. Não é descuido meu nem falta de capricho no personagem: é de propósito. Fazer uma voz sintética se passar por pessoa, num telefonema que a pessoa não pediu, é o caminho curto para o golpe — e escrever o tutorial assim seria ensinar isso junto.

Então: avise que é um assistente, respeite na hora quem pedir para não ligar mais, e não use para telemarketing não solicitado. A tecnologia é a mesma; o que muda é o que você faz com ela. 💜

🎁 O código completo

Exemplos_TelefoneOpenAI no GitHubServidor, script de linha de comando e uma página web com formulário de número e prompt, para testar sem escrever código. O README traz as armadilhas resumidas e como reproduzir o erro de E.164 de propósito.github.com

É só clonar, rodar npm install, copiar o .env.exemplo para .env e abrir o localhost:3009. A página tem os dois campos que interessam: o número e o prompt de quem atende. 🪄

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também