Capítulo 11 de 14

O arquivo inteiro, montado e pronto para publicar

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

Como juntar todo o código do manual em um arquivo só?

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.

O que ter aberto antes de começar

Por que este capítulo existe?

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.

A ordem em que o código decide

Sete perguntas, sempre nesta sequência. Trocar a ordem é o que gera resposta duplicada e robô falando por cima do dono.

O arquivo

Cole inteiro em src/index.js, por cima do que estava lá.

src/index.jsjavascript
// ─────────────────────────────────────────────────────────────────────
// 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 wrangler.jsonc

O arquivo de configuração que liga o armazenamento e as variáveis. É o passo que falta em quase todo tutorial.

wrangler.jsoncjsonc
{
  "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"
  }
}

Os quatro segredos

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.

SegredoComandoDe onde ele sai
VERIFY_TOKENnpx wrangler secret put VERIFY_TOKENUma senha inventada por você. A mesma que você digita na tela da Meta.
APP_SECRETnpx wrangler secret put APP_SECRETPortal de desenvolvedores, dentro do aplicativo, em Configurações e Básico.
WHATSAPP_TOKENnpx wrangler secret put WHATSAPP_TOKENA chave permanente do usuário do sistema, passo 7 do capítulo 3. Não a que expira em 24 horas.
GEMINI_API_KEYnpx wrangler secret put GEMINI_API_KEYaistudio.google.com, em Get API key. O nível gratuito basta para testar.

Arraste a tabela para o lado para ver todas as colunas.

A conta de escritas, agora que tudo está junto

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.

O que ainda não está aqui, de propósito?

Perguntas frequentes

Posso publicar este arquivo sem ler os capítulos anteriores?

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.

Preciso mesmo do armazenamento de chave e valor?

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 em Node em vez de Cloudflare?

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.

Por que a lista de preços aparece duas vezes, no cérebro e no código?

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

Continuar o manual