tutorial

Chatbot WhatsApp com Baileys + Tokia: tutorial completo (Node.js + ~R$ 0,02 por conversa)

Setup técnico end-to-end: WhatsApp Web não-oficial via Baileys, contexto de conversa persistido, função handoff pra atendente humano. Código de produção testado, deploy Coolify, custo real BRL.

Aviso técnico: Baileys é cliente WhatsApp Web não-oficial. Para volume alto ou negócio crítico, considere a Cloud API oficial (precisa de Business Account verificada) — ou SimplesZap, SaaS irmão da Tokia que abstrai isso. Baileys é ótimo pra protótipo ou negócio pequeno (~até 1k mensagens/dia por número).

Esse post mostra stack mínima pra colocar bot WhatsApp + Tokia em produção. Sem framework gigante, sem dependência paga além do Tokia. Deploy em VPS de R$ 30/mês cobre até ~3k conversas/mês com sobra.

Stack final

  • Baileys (@whiskeysockets/baileys) — WhatsApp Web client TypeScript
  • Tokia — gateway LLM em real, NF-e brasileira, suporte PT
  • SQLite local — persistir contexto de conversa por número
  • PM2 ou Coolify — manter processo vivo

Total: 1 arquivo Node.js de ~200 linhas. Custa ~R$ 30/mês de VPS + ~R$ 0,02 por conversa em IA.

Passo 1 — Setup do projeto

mkdir bot-whatsapp-tokia && cd bot-whatsapp-tokia
npm init -y
npm install @whiskeysockets/baileys openai better-sqlite3 pino
npm install -D typescript @types/node tsx
npx tsc --init

Estrutura mínima:

bot-whatsapp-tokia/
├── src/
│   ├── index.ts      ← bootstrap + Baileys connect
│   ├── ai.ts         ← chamada Tokia + system prompt
│   ├── memory.ts     ← persistência SQLite contexto
│   └── handoff.ts    ← lógica de passar pra humano
├── auth/             ← session Baileys (gitignore)
├── data.db           ← SQLite (gitignore)
├── .env
└── package.json

Passo 2 — Auth Baileys (escaneia QR uma vez)

src/index.ts:

import {
  default as makeWASocket,
  useMultiFileAuthState,
  DisconnectReason,
  type WASocket,
} from "@whiskeysockets/baileys";
import pino from "pino";
import { onMessage } from "./ai.js";

const logger = pino({ level: "info" });

async function connect() {
  const { state, saveCreds } = await useMultiFileAuthState("./auth");

  const sock = makeWASocket({
    auth: state,
    logger,
    printQRInTerminal: true, // QR aparece no terminal pra escanear 1ª vez
  });

  sock.ev.on("creds.update", saveCreds);

  sock.ev.on("connection.update", ({ connection, lastDisconnect }) => {
    if (connection === "close") {
      const shouldReconnect =
        (lastDisconnect?.error as { output?: { statusCode?: number } })?.output?.statusCode !==
        DisconnectReason.loggedOut;
      logger.warn({ shouldReconnect }, "connection closed");
      if (shouldReconnect) connect();
    } else if (connection === "open") {
      logger.info("✓ conectado WhatsApp");
    }
  });

  sock.ev.on("messages.upsert", async ({ messages, type }) => {
    if (type !== "notify") return;
    for (const msg of messages) {
      if (msg.key.fromMe) continue;
      if (!msg.message) continue;
      await onMessage(sock, msg);
    }
  });
}

connect().catch((err) => {
  logger.error(err, "fatal");
  process.exit(1);
});

Primeira execução: npm run start → QR no terminal → escaneia com seu WhatsApp (Configurações → Aparelhos Conectados → Conectar Aparelho). Sessão fica salva em auth/.

Passo 3 — Tokia + system prompt

src/ai.ts:

import OpenAI from "openai";
import type { WASocket, proto } from "@whiskeysockets/baileys";
import { getHistory, addMessage, markHandoff } from "./memory.js";
import { detectHandoffNeeded } from "./handoff.js";

const client = new OpenAI({
  apiKey: process.env.TOKIA_KEY!,
  baseURL: "https://api.usetokia.com/v1",
});

const SYSTEM_PROMPT = `Você é assistente da Imobiliária Tal, atende
WhatsApp 24/7. Seu objetivo: qualificar lead pra corretor humano fechar.

REGRAS:
- Sempre PT-BR informal mas profissional
- Pergunte 1 coisa por vez (não enxurrada de perguntas)
- Capture: bairro de interesse, faixa de preço, quartos, garagem
- Se pessoa pedir falar com humano OU surgir tópico fora do escopo
  (jurídico, contrato detalhado), responda APENAS "HANDOFF" no fim
- Use o histórico pra não repetir perguntas

DADOS DA EMPRESA:
- Imobiliária Tal · Brasília-DF
- WhatsApp: você mesmo
- Horário corretor humano: seg-sex 9-18h, sáb 9-13h
`;

export async function onMessage(sock: WASocket, msg: proto.IWebMessageInfo) {
  const from = msg.key.remoteJid!;
  if (!from.endsWith("@s.whatsapp.net")) return; // só DM, não grupo

  const text =
    msg.message?.conversation ||
    msg.message?.extendedTextMessage?.text ||
    "";
  if (!text.trim()) return;

  await addMessage(from, "user", text);

  // Se já marcou pra humano, não responde automático
  const history = await getHistory(from);
  if (history.some((m) => m.role === "system" && m.content === "_handoff_")) {
    return;
  }

  // Detecção pré-LLM de termos óbvios pra economizar
  if (detectHandoffNeeded(text)) {
    await markHandoff(from);
    await sock.sendMessage(from, {
      text: "Vou te conectar com um corretor humano. Aguarda alguns minutos.",
    });
    await notifyTeam(from, "handoff");
    return;
  }

  const completion = await client.chat.completions.create({
    model: "gpt-4o-mini",
    temperature: 0.4,
    max_tokens: 300,
    messages: [
      { role: "system", content: SYSTEM_PROMPT },
      ...history.slice(-20), // só 20 últimas msgs no contexto
    ],
  });

  const aiReply = completion.choices[0]!.message.content!.trim();

  if (aiReply.endsWith("HANDOFF")) {
    await markHandoff(from);
    const cleanReply = aiReply.replace(/HANDOFF$/, "").trim();
    await sock.sendMessage(from, {
      text:
        cleanReply +
        "\n\nVou te conectar com um corretor humano. Em ~15min alguém te chama.",
    });
    await notifyTeam(from, "handoff");
    return;
  }

  await sock.sendMessage(from, { text: aiReply });
  await addMessage(from, "assistant", aiReply);
}

async function notifyTeam(clientNumber: string, reason: string) {
  // Manda email pro time / posta em Slack / etc
  console.log(`[handoff] ${clientNumber} → ${reason}`);
}

Passo 4 — Memória persistente em SQLite

src/memory.ts:

import Database from "better-sqlite3";

const db = new Database("./data.db");

db.exec(`
  CREATE TABLE IF NOT EXISTS messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    chat_id TEXT NOT NULL,
    role TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at INTEGER DEFAULT (strftime('%s','now'))
  );
  CREATE INDEX IF NOT EXISTS idx_chat ON messages(chat_id, created_at);
`);

const insertStmt = db.prepare(
  `INSERT INTO messages (chat_id, role, content) VALUES (?, ?, ?)`,
);
const historyStmt = db.prepare(
  `SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at ASC`,
);

export interface Msg {
  role: "user" | "assistant" | "system";
  content: string;
}

export async function addMessage(chatId: string, role: Msg["role"], content: string) {
  insertStmt.run(chatId, role, content);
}

export async function getHistory(chatId: string): Promise<Msg[]> {
  return historyStmt.all(chatId) as Msg[];
}

export async function markHandoff(chatId: string) {
  insertStmt.run(chatId, "system", "_handoff_");
}

Passo 5 — Detecção de handoff sem gastar token

src/handoff.ts:

const HANDOFF_PATTERNS = [
  /atendente/i,
  /humano/i,
  /pessoa real/i,
  /corretor/i,
  /quero falar com/i,
  /me liga/i,
  /(?:tel|telefone|whats)/i,
  /reclamação/i,
  /vou processar/i, // sinal de problema sério
];

export function detectHandoffNeeded(text: string): boolean {
  return HANDOFF_PATTERNS.some((pat) => pat.test(text));
}

Economiza ~30% de chamadas Tokia (não precisa LLM pra detectar "quero falar com atendente").

Passo 6 — Deploy Coolify

Dockerfile:

FROM node:22-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

COPY . .
RUN npm run build

VOLUME ["/app/auth", "/app/data.db"]

CMD ["node", "dist/index.js"]

.env no Coolify:

TOKIA_KEY=sk-tokia-…
NODE_ENV=production

Importante: persistent volume pra auth/ e data.db. Se perder, você desconecta o WhatsApp e perde histórico.

Custo real (caso imobiliária Brasília)

| Item | Custo mensal | |---|---| | VPS Hostinger 1vCPU/2GB | R$ 35 | | Tokia (300 conversas × ~R$ 0,12) | R$ 36 | | Total infra+IA | R$ 71 |

Comparativo: atendente PJ R$ 2.500-3.500/mês. ROI imediato.

Os 5 problemas que vão aparecer em produção

1. Baileys desconecta sozinho

WhatsApp detecta cliente não-oficial e força logout periodicamente. Solução: re-auth toda semana ou usar WAHA (self-hosted multi-session) ou Cloud API oficial.

2. Mensagem de áudio/imagem

Bot só responde texto. Implemente fallback:

if (msg.message?.audioMessage) {
  await sock.sendMessage(from, {
    text: "Recebi seu áudio! Mas só atendo por texto. Pode escrever?",
  });
  return;
}

Ou: baixe áudio com downloadMediaMessage + Whisper Tokia pra transcrever.

3. Spam e DDoS

Alguém pode mandar 1000 mensagens em 1 min. Rate limit:

const rateLimitMap = new Map<string, number[]>();

function isRateLimited(chatId: string): boolean {
  const now = Date.now();
  const window = 60_000; // 1 min
  const limit = 10;
  const hits = rateLimitMap.get(chatId) ?? [];
  const recent = hits.filter((t) => now - t < window);
  recent.push(now);
  rateLimitMap.set(chatId, recent);
  return recent.length > limit;
}

4. Conversas longas estouram contexto

Limita histórico em ~20 últimas mensagens (history.slice(-20)). Pra conversas que precisam memória longa, faça summary periódico: toda 20 mensagens, gera resumo da conversa via Tokia e substitui no histórico.

5. Bot responde no grupo por engano

Filtra @g.us:

if (!from.endsWith("@s.whatsapp.net")) return;

@s.whatsapp.net é DM. @g.us é grupo.

Tokia vs Cloud API vs SimplesZap (escolha rápida)

| Cenário | Use | |---|---| | Volume < 1k msg/dia, MVP, testando | Baileys + Tokia (este post) | | Volume > 5k msg/dia, negócio crítico | Cloud API + Tokia | | Não quer gerenciar Baileys/Coolify | SimplesZap + Tokia (SaaS irmão) | | Negócio regulado (banco, plano saúde) | Cloud API + Tokia + DPA |

Próximo passo

Cria sua key Tokia, testa o prompt no /playground mandando 5-10 mensagens simulando cliente. Quando o tom + estrutura estiverem alinhados, copia esse prompt pro SYSTEM_PROMPT e roda o bot localmente.

Pra deploy production-ready com webhook + ticketing system + handoff automatizado pro CRM, SimplesZap já tem tudo embutido (mesma família IT Booster).

Links

#whatsapp#baileys#chatbot#nodejs#tutorial#tokia

Quer testar Tokia com R$ 10 via PIX?

Criar conta grátis →