Node.js: ligação telefônica com IA da OpenAI
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.
🎧 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
truncateconserta 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
clearconserta 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 oclear, 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
É 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
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.