Capítulo 11 de 14
Os capítulos 4 a 10 em um arquivo só, na ordem certa, sem trecho comentado para você adivinhar onde encaixa.
Manual gratuito de Certu · Atualizado em 31 de agosto de 2026 · ver o manual inteiro
Resposta curta
Os capítulos anteriores mostram cada peça separada, porque cada uma se entende melhor sozinha. Este capítulo traz as peças já encaixadas: um arquivo único que recebe a mensagem, confere a assinatura, entende áudio e foto, lembra da conversa, trava preço inventado, chama o humano e responde. Copie, publique e funciona.
Do capítulo 4 ao 10, cada peça aparece sozinha, e várias delas terminam com um trecho comentado dizendo onde encaixar. Isso ensina bem e monta mal: juntar sete pedaços na ordem errada é onde a maioria das pessoas desiste.
Aqui está o arquivo já montado, com as decisões que só aparecem quando as peças se encontram: quem responde primeiro, o que acontece quando o modelo cai, e por que a mensagem repetida precisa ser descartada antes de tudo.
Sete perguntas, sempre nesta sequência. Trocar a ordem é o que gera resposta duplicada e robô falando por cima do dono.
Cole inteiro em src/index.js, por cima do que estava lá.
// ─────────────────────────────────────────────────────────────────────
// O que voce edita: o CEREBRO e a lista de precos permitidos.
// O resto funciona igual para qualquer negocio.
// ─────────────────────────────────────────────────────────────────────
const CEREBRO = `Voce e o atendente da Padaria do Ze, em Florianopolis.
Horario: segunda a sabado, das 6h as 20h. Domingo ate as 12h.
Bolo de fuba: R$ 80. Torta salgada: R$ 120. Bolo de festa: R$ 250.
Encomenda de bolo: com 48 horas de antecedencia.
Portugues do Brasil, no maximo 3 linhas por mensagem, uma pergunta por vez.
Nunca invente preco, prazo ou horario. Nunca prometa desconto.
Se nao souber, diga "Vou confirmar isso e ja te respondo." e escreva
<<CHAMAR HUMANO>> no fim da resposta.`;
const PRECOS_PERMITIDOS = ['80', '120', '250'];
const MARCA = '<<CHAMAR HUMANO>>';
const MAX_TROCAS = 10; // 10 do cliente + 10 do atendente
const VALIDADE = 60 * 60 * 24; // historico: 24 horas
const SILENCIO = 60 * 60 * 2; // robo calado: 2 horas
const JA_VISTA = 60 * 60 * 6; // id de mensagem ja tratada: 6 horas
const RECADO_DE_ESPERA = 'Vou confirmar isso e ja te respondo.';
// ─────────────────────────────────────────────────────────────────────
// 1. A porta de entrada
// ─────────────────────────────────────────────────────────────────────
export default {
async fetch(request, env, context) {
const url = new URL(request.url);
if (url.pathname !== '/webhook') {
return new Response('no ar', { status: 200 });
}
// Verificacao. A Meta chama uma vez, quando voce cadastra o endereco.
if (request.method === 'GET') {
const modo = url.searchParams.get('hub.mode');
const token = url.searchParams.get('hub.verify_token');
const desafio = url.searchParams.get('hub.challenge');
if (modo === 'subscribe' && token === env.VERIFY_TOKEN) {
return new Response(desafio, { status: 200 });
}
return new Response('token errado', { status: 403 });
}
if (request.method !== 'POST') {
return new Response('metodo nao permitido', { status: 405 });
}
const corpo = await request.text();
const assinatura = request.headers.get('x-hub-signature-256');
if (!(await assinaturaConfere(corpo, assinatura, env.APP_SECRET))) {
return new Response('assinatura invalida', { status: 401 });
}
const dados = JSON.parse(corpo);
const valor = dados?.entry?.[0]?.changes?.[0]?.value;
const mensagem = valor?.messages?.[0];
// Confirmacao de entrega e de leitura chegam no mesmo endereco.
if (!mensagem) return new Response('ok', { status: 200 });
const de = mensagem.from;
// A Meta reenvia quando voce demora. Trate cada id uma vez so.
if (await jaTratada(env, mensagem.id)) {
return new Response('ok', { status: 200 });
}
// Uma pessoa assumiu esta conversa: o robo nao fala por cima.
if (await estaEmSilencio(env, de)) {
return new Response('ok', { status: 200 });
}
// Responde 200 primeiro, trabalha depois.
context.waitUntil(atender(env, de, mensagem));
return new Response('ok', { status: 200 });
},
};
// ─────────────────────────────────────────────────────────────────────
// 2. O atendimento
// ─────────────────────────────────────────────────────────────────────
async function atender(env, de, mensagem) {
await marcarComoLida(env, mensagem.id);
const texto = await extrairTexto(env, mensagem);
if (!texto) {
await enviarWhatsApp(env, de, 'Recebi aqui. Me conta em uma frase o que voce precisa?');
return;
}
const historico = await lerHistorico(env, de);
const resposta = await pensarResposta(env, CEREBRO, historico, texto);
// Modelo fora do ar. Nao invente uma resposta generica.
if (!resposta) return await chamarHumano(env, de, texto);
// O proprio modelo pediu ajuda.
if (resposta.includes(MARCA)) {
const limpa = resposta.replace(MARCA, '').trim();
if (limpa) await enviarWhatsApp(env, de, limpa);
return await chamarHumano(env, de, texto, false);
}
// Preco fora da tabela ou desconto prometido nao chega no cliente.
if (!respostaSegura(resposta)) return await chamarHumano(env, de, texto);
await enviarWhatsApp(env, de, resposta);
historico.push({ papel: 'user', texto });
historico.push({ papel: 'model', texto: resposta });
await salvarHistorico(env, de, historico);
}
async function chamarHumano(env, de, ultimaMensagem, avisarCliente = true) {
if (avisarCliente) await enviarWhatsApp(env, de, RECADO_DE_ESPERA);
await calarRobo(env, de);
await avisarDono(env, de, ultimaMensagem);
}
// ─────────────────────────────────────────────────────────────────────
// 3. Texto, audio e foto viram a mesma coisa: uma frase
// ─────────────────────────────────────────────────────────────────────
async function extrairTexto(env, mensagem) {
if (mensagem.type === 'text') return mensagem.text.body;
if (mensagem.type === 'audio') {
const midia = await baixarMidia(env, mensagem.audio.id);
if (!midia) return '';
return (await lerMidia(env, midia, 'Transcreva este audio em portugues do Brasil.')) ?? '';
}
if (mensagem.type === 'image') {
const midia = await baixarMidia(env, mensagem.image.id);
if (!midia) return '';
const visto = await lerMidia(env, midia, 'Descreva o que aparece nesta imagem em uma frase.');
const legenda = mensagem.image.caption ? ' O cliente escreveu: ' + mensagem.image.caption : '';
return visto ? '[o cliente mandou uma foto de: ' + visto + ']' + legenda : '';
}
return '';
}
async function baixarMidia(env, idDaMidia) {
const info = await fetch('https://graph.facebook.com/v26.0/' + idDaMidia, {
headers: { Authorization: 'Bearer ' + env.WHATSAPP_TOKEN },
});
if (!info.ok) return null;
const { url, mime_type } = await info.json();
const arquivo = await fetch(url, {
headers: { Authorization: 'Bearer ' + env.WHATSAPP_TOKEN },
});
if (!arquivo.ok) return null;
const bytes = new Uint8Array(await arquivo.arrayBuffer());
let binario = '';
for (const b of bytes) binario += String.fromCharCode(b);
return { base64: btoa(binario), mime: mime_type };
}
async function lerMidia(env, midia, instrucao) {
const resposta = await chamarModelo(env, {
contents: [{
role: 'user',
parts: [
{ text: instrucao },
{ inline_data: { mime_type: midia.mime, data: midia.base64 } },
],
}],
});
return resposta;
}
// ─────────────────────────────────────────────────────────────────────
// 4. O modelo
// ─────────────────────────────────────────────────────────────────────
async function chamarModelo(env, corpo) {
const resposta = await fetch(
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent',
{
method: 'POST',
headers: {
'x-goog-api-key': env.GEMINI_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(corpo),
},
);
if (!resposta.ok) {
console.log('modelo falhou', resposta.status, await resposta.text());
return null;
}
const dados = await resposta.json();
return dados?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? null;
}
async function pensarResposta(env, cerebro, historico, mensagemDoCliente) {
return await chamarModelo(env, {
system_instruction: { parts: [{ text: cerebro }] },
contents: [
...historico.map((m) => ({ role: m.papel, parts: [{ text: m.texto }] })),
{ role: 'user', parts: [{ text: mensagemDoCliente }] },
],
generationConfig: { temperature: 0.4, maxOutputTokens: 300 },
});
}
// ─────────────────────────────────────────────────────────────────────
// 5. A trava que nao deixa preco inventado sair
// ─────────────────────────────────────────────────────────────────────
function respostaSegura(texto) {
const valores = texto.match(/R\$\s?([\d.]+)/g) ?? [];
for (const bruto of valores) {
const numero = bruto.replace(/[^\d]/g, '');
if (!PRECOS_PERMITIDOS.includes(numero)) return false;
}
if (/desconto|abatimento|fa[cç]o por|deixo por/i.test(texto)) return false;
return true;
}
// ─────────────────────────────────────────────────────────────────────
// 6. Memoria, silencio e mensagem repetida
// ─────────────────────────────────────────────────────────────────────
async function lerHistorico(env, telefone) {
const bruto = await env.CONVERSAS.get('hist:' + telefone);
return bruto ? JSON.parse(bruto) : [];
}
async function salvarHistorico(env, telefone, historico) {
const cortado = historico.slice(-MAX_TROCAS * 2);
await env.CONVERSAS.put('hist:' + telefone, JSON.stringify(cortado), {
expirationTtl: VALIDADE,
});
}
async function estaEmSilencio(env, telefone) {
return (await env.CONVERSAS.get('humano:' + telefone)) !== null;
}
async function calarRobo(env, telefone) {
await env.CONVERSAS.put('humano:' + telefone, '1', { expirationTtl: SILENCIO });
}
async function jaTratada(env, idDaMensagem) {
const chave = 'msg:' + idDaMensagem;
if (await env.CONVERSAS.get(chave)) return true;
await env.CONVERSAS.put(chave, '1', { expirationTtl: JA_VISTA });
return false;
}
// ─────────────────────────────────────────────────────────────────────
// 7. Falar com o WhatsApp
// ─────────────────────────────────────────────────────────────────────
async function enviarWhatsApp(env, para, texto) {
const resposta = await fetch(
'https://graph.facebook.com/v26.0/' + env.PHONE_NUMBER_ID + '/messages',
{
method: 'POST',
headers: {
Authorization: 'Bearer ' + env.WHATSAPP_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
to: para,
type: 'text',
text: { body: texto },
}),
},
);
if (!resposta.ok) {
console.log('envio falhou', resposta.status, await resposta.text());
}
}
async function marcarComoLida(env, idDaMensagem) {
await fetch('https://graph.facebook.com/v26.0/' + env.PHONE_NUMBER_ID + '/messages', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + env.WHATSAPP_TOKEN,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
status: 'read',
message_id: idDaMensagem,
}),
}).catch(() => {});
}
// O dono so recebe este aviso se ELE tiver escrito para o numero da empresa
// nas ultimas 24 horas. Fora da janela, este envio falha calado. Leia o
// aviso logo abaixo do arquivo: e o erro mais chato deste capitulo.
async function avisarDono(env, telefone, ultimaMensagem) {
await enviarWhatsApp(
env,
env.TELEFONE_DO_DONO,
'Cliente ' + telefone + ' precisa de voce.\n\nUltima mensagem: ' + ultimaMensagem,
);
}
// ─────────────────────────────────────────────────────────────────────
// 8. A assinatura da Meta
// ─────────────────────────────────────────────────────────────────────
async function assinaturaConfere(corpo, cabecalho, segredo) {
if (!cabecalho || !segredo) return false;
const chave = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(segredo),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const assinado = await crypto.subtle.sign('HMAC', chave, new TextEncoder().encode(corpo));
const hex = [...new Uint8Array(assinado)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const esperado = 'sha256=' + hex;
// Comparacao de tempo constante.
if (esperado.length !== cabecalho.length) return false;
let diferenca = 0;
for (let i = 0; i < esperado.length; i += 1) {
diferenca |= esperado.charCodeAt(i) ^ cabecalho.charCodeAt(i);
}
return diferenca === 0;
}Onde as pessoas se queimam
O aviso para o dono sai por mensagem comum, e mensagem comum só passa dentro da janela de 24 horas do capítulo 9. Se o dono nunca escrever para o número da empresa, o aviso falha calado e a conversa fica parada com o robô mudo. Peça para ele mandar um "oi" para o número uma vez por dia, ou aprove um modelo de mensagem de utilidade só para esse aviso.
O arquivo de configuração que liga o armazenamento e as variáveis. É o passo que falta em quase todo tutorial.
{
"name": "atendente",
"main": "src/index.js",
"compatibility_date": "2026-08-01",
// Crie com: npx wrangler kv namespace create CONVERSAS
// O comando imprime o id. Cole ele aqui.
"kv_namespaces": [
{ "binding": "CONVERSAS", "id": "COLE_O_ID_QUE_O_COMANDO_IMPRIMIU" }
],
// Isto nao e segredo: pode ficar no arquivo e no repositorio.
"vars": {
"PHONE_NUMBER_ID": "106540352242922",
"TELEFONE_DO_DONO": "5548999999999"
}
}Estes nunca entram no arquivo. Cada linha é um comando no terminal.
Depois dos quatro, publique com npx wrangler deploy e mande uma mensagem do seu celular para o número. Se nada acontecer, rode npx wrangler tail em outra janela e mande de novo: o registro mostra onde parou.
| Segredo | Comando | De onde ele sai |
|---|---|---|
| VERIFY_TOKEN | npx wrangler secret put VERIFY_TOKEN | Uma senha inventada por você. A mesma que você digita na tela da Meta. |
| APP_SECRET | npx wrangler secret put APP_SECRET | Portal de desenvolvedores, dentro do aplicativo, em Configurações e Básico. |
| WHATSAPP_TOKEN | npx wrangler secret put WHATSAPP_TOKEN | A chave permanente do usuário do sistema, passo 7 do capítulo 3. Não a que expira em 24 horas. |
| GEMINI_API_KEY | npx wrangler secret put GEMINI_API_KEY | aistudio.google.com, em Get API key. O nível gratuito basta para testar. |
Arraste a tabela para o lado para ver todas as colunas.
O arquivo montado grava três vezes por mensagem respondida: o identificador já visto, o histórico e, quando chama humano, a trava de silêncio.
O nível gratuito do armazenamento permite mil escritas por dia. Com três por resposta, o teto grátis cai para cerca de trezentas mensagens respondidas por dia, e não mil como parecia no capítulo 7.
Se isso apertar antes de você querer pagar, o primeiro corte é o registro de mensagem já vista: guarde só os identificadores das últimas dez em memória, dentro da mesma execução, e aceite uma resposta repetida de vez em quando.
Pode, e ele funciona. Mas você não vai saber o que ajustar quando o atendente responder errado, e é isso que decide se ele fica bom. Leia pelo menos o capítulo 6, o texto do negócio.
Precisa, porque três coisas dependem dele: memória, trava de silêncio e mensagem repetida. Sem ele o atendente responde do zero toda vez, fala por cima do dono e responde duas vezes quando a Meta reenvia.
Roda, com duas trocas: o context.waitUntil vira responder 200 e continuar processando, e o env.CONVERSAS vira qualquer banco de chave e valor, como Redis. O resto é fetch e Web Crypto, que Node moderno tem.
Porque uma ensina o atendente e a outra o impede de errar. Mantenha as duas no mesmo lugar do seu projeto, senão elas divergem em uma semana e a trava começa a barrar preço certo.
E onde entra Certu
Este arquivo é a versão mínima honesta do que Certu roda: o mesmo desenho, sem a fila para pico, sem a troca de modelo quando um cai, sem o painel para a equipe e sem alguém acordando quando quebra às duas da manhã. Se você publicou e funcionou, o manual cumpriu o que prometeu. Falta só o texto do capítulo 6, e esse dá para montar de graça numa conversa.
Montar o texto do meu negócio de graça