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ó @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
Quer testar Tokia com R$ 10 via PIX?
Criar conta grátis →