Exemplos
Quatro integrações completas, em Node (Express) e Python (Flask). Os objetos db, agenda, financas e cardapio são o seu sistema: troque pelas suas consultas.
A base: uma URL para todas as ações
O corpo traz o nome da ação em action, então todas as ações do chatbot podem apontar para a mesma URL. A base confere a assinatura e chama a função certa; cada função devolve o status HTTP e o corpo.
// npm i express standardwebhooks
import express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const wh = new Webhook(process.env.BOAVOZ_ACTION_SECRET);
// uma URL para todas as ações do chatbot: o campo "action" diz qual é
const acoes = { segunda_via_boleto: segundaVia, consultar_horarios: consultarHorarios, agendar, resumo_do_mes: resumoDoMes, ver_cardapio: verCardapio, adicionar_ao_carrinho: adicionarAoCarrinho };
app.post("/boavoz/acoes", express.text({ type: "*/*" }), async (req, res) => {
let chamada;
try {
chamada = wh.verify(req.body, req.headers);
} catch {
return res.status(401).json({ error: "assinatura inválida" });
}
// botão Testar do painel: sem conversa nem contato
if (chamada.test) return res.json({ data: { teste: "ok", acao: chamada.action } });
const acao = acoes[chamada.action];
if (!acao) return res.status(404).json({ error: "ação desconhecida" });
const { status, body } = await acao(chamada, req.query);
res.status(status).json(body);
});
app.listen(3000);# pip install flask standardwebhooks
import os
from flask import Flask, request, jsonify
from standardwebhooks.webhooks import Webhook
app = Flask(__name__)
wh = Webhook(os.environ["BOAVOZ_ACTION_SECRET"])
@app.post("/boavoz/acoes")
def acoes():
try:
chamada = wh.verify(request.get_data(), dict(request.headers))
except Exception:
return jsonify(error="assinatura inválida"), 401
# botão Testar do painel: sem conversa nem contato
if chamada.get("test"):
return jsonify(data={"teste": "ok", "acao": chamada["action"]})
acao = ACOES.get(chamada["action"])
if not acao:
return jsonify(error="ação desconhecida"), 404
status, corpo = acao(chamada, request.args)
return jsonify(corpo), status
# no fim do arquivo, depois das funções:
# ACOES = {"segunda_via_boleto": segunda_via, "consultar_horarios": consultar_horarios, "agendar": agendar, ...}Segunda via de boleto
A pessoa entrou no site (token) ou pareou o WhatsApp. Com nível mínimo usuário, a ação só aparece para quem a empresa identificou, e a conta vem do external_id, nunca de um CPF digitado. O PDF vai como documento logo depois do texto.
// segunda_via_boleto: consulta · nível mínimo "usuário" · sem parâmetros
async function segundaVia(chamada) {
const conta = await db.contas.porId(chamada.contact.external_id);
const boleto = conta && (await db.boletos.emAberto(conta.id));
if (!boleto) return { status: 404, body: { error: "nenhum boleto em aberto" } };
return {
status: 200,
body: {
data: { vencimento: boleto.vencimento, valor: boleto.valorFormatado },
// link HTTPS público e de curta duração para o PDF: o BoaVoz baixa e manda como documento
attachments: [{ url: boleto.linkAssinadoDoPdf, filename: "boleto.pdf" }],
},
};
}# segunda_via_boleto: consulta · nível mínimo "usuário" · sem parâmetros
def segunda_via(chamada, args):
conta = db.contas.por_id(chamada["contact"]["external_id"])
boleto = conta and db.boletos.em_aberto(conta.id)
if not boleto:
return 404, {"error": "nenhum boleto em aberto"}
return 200, {
"data": {"vencimento": boleto.vencimento, "valor": boleto.valor_formatado},
"attachments": [{"url": boleto.link_assinado_do_pdf, "filename": "boleto.pdf"}],
}Agenda
Duas ações. A consulta traz os horários livres, e a IA mostra como opções. Ao escolher, a IA chama agendar, que é executa: o contato vê o resumo da prévia e só no Confirmar o agendamento é criado.
// consultar_horarios: consulta · nível "canal" · parâmetro data (AAAA-MM-DD)
async function consultarHorarios(chamada) {
const livres = await agenda.livres(chamada.params.data);
return { status: 200, body: { data: { data: chamada.params.data, horarios: livres } } };
}
// agendar: executa · nível "canal" · parâmetros data e horario · aceita prévia
async function agendar(chamada) {
const { data, horario } = chamada.params;
// o mesmo call_id volta no "Tentar de novo": devolva o que já foi feito, sem agendar de novo
const feito = await agenda.porCallId(chamada.call_id);
if (feito) return { status: 200, body: { data: { protocolo: feito.protocolo, data, horario } } };
if (!(await agenda.estaLivre(data, horario))) return { status: 404, body: { error: "esse horário não está mais livre" } };
if (chamada.mode === "preview") {
// a prévia não cria nada: só o resumo para o Confirmar
return { status: 200, body: { confirmation: { summary: "Corte de cabelo em " + data + " às " + horario, total_cents: 6000 } } };
}
const novo = await agenda.criar({ data, horario, telefone: chamada.contact.phone, callId: chamada.call_id });
return { status: 200, body: { data: { protocolo: novo.protocolo, data, horario } } };
}# consultar_horarios: consulta · nível "canal" · parâmetro data (AAAA-MM-DD)
def consultar_horarios(chamada, args):
data = chamada["params"]["data"]
return 200, {"data": {"data": data, "horarios": agenda.livres(data)}}
# agendar: executa · nível "canal" · parâmetros data e horario · aceita prévia
def agendar(chamada, args):
data, horario = chamada["params"]["data"], chamada["params"]["horario"]
feito = agenda.por_call_id(chamada["call_id"]) # "Tentar de novo" traz o mesmo call_id
if feito:
return 200, {"data": {"protocolo": feito.protocolo, "data": data, "horario": horario}}
if not agenda.esta_livre(data, horario):
return 404, {"error": "esse horário não está mais livre"}
if chamada.get("mode") == "preview":
return 200, {"confirmation": {"summary": "Corte de cabelo em " + data + " às " + horario, "total_cents": 6000}}
novo = agenda.criar(data=data, horario=horario, telefone=chamada["contact"]["phone"], call_id=chamada["call_id"])
return 200, {"data": {"protocolo": novo.protocolo, "data": data, "horario": horario}}call_id junto do agendamento. Se a resposta demorar mais de 20 segundos, o contato vê “Tentar de novo”, e a chamada volta com o mesmo id: devolva o mesmo protocolo.Faturamento por workspace
Uma pessoa com mais de uma conta (a família e a empresa). O token ou o pareamento diz a conta ativa em context. Com “exige contexto assinado”, a ação só aparece em conversas com contexto. O que a IA precisa considerar sem dizer vai em internal.
// resumo_do_mes: consulta · nível "usuário" · exige contexto assinado · parâmetro mes (AAAA-MM)
async function resumoDoMes(chamada) {
const usuario = chamada.contact.external_id;
const workspace = chamada.context?.data?.workspace_id;
// a conta vem do vínculo assinado; ainda assim, confira que ela é membro do workspace
if (!(await workspaces.temMembro(workspace, usuario))) return { status: 404, body: { error: "workspace não encontrado" } };
const r = await financas.resumo(workspace, chamada.params.mes);
return {
status: 200,
body: {
data: { mes: r.mes, gasto: r.gasto, por_categoria: r.categorias, saldo_previsto: r.saldoPrevisto },
// a IA leva em conta, mas nunca escreve
internal: { limite_do_cartao: r.limiteDoCartao },
},
};
}# resumo_do_mes: consulta · nível "usuário" · exige contexto assinado · parâmetro mes (AAAA-MM)
def resumo_do_mes(chamada, args):
usuario = chamada["contact"]["external_id"]
workspace = ((chamada.get("context") or {}).get("data") or {}).get("workspace_id")
if not workspaces.tem_membro(workspace, usuario):
return 404, {"error": "workspace não encontrado"}
r = financas.resumo(workspace, chamada["params"]["mes"])
return 200, {
"data": {"mes": r.mes, "gasto": r.gasto, "por_categoria": r.categorias, "saldo_previsto": r.saldo_previsto},
"internal": {"limite_do_cartao": r.limite_do_cartao},
}Cardápio por loja
Um chatbot por loja, todos apontando para a mesma URL com a loja na query. O cardápio sai em carrossel; o botão “Pedir este” chama adicionar_ao_carrinho direto, sem a IA, e o reply dela traz o resumo com “Finalizar pedido”, que chama a ação de pedido (pedido só vai em botões, nunca no carrossel).
// ver_cardapio: consulta · nível "anônimo" · a URL de cada chatbot leva a loja: .../boavoz/acoes?loja=123
async function verCardapio(chamada, query) {
const itens = await cardapio.daLoja(query.loja);
return {
status: 200,
body: {
data: { itens: itens.map((i) => ({ id: i.id, nome: i.nome, preco: i.precoFormatado })) },
components: {
type: "carousel",
cards: itens.slice(0, 10).map((i) => ({
image: i.fotoUrl,
title: i.nome,
text: (i.precoFormatado + ". " + i.descricao).slice(0, 300),
buttons: [{ title: "Pedir este", action: { name: "adicionar_ao_carrinho", params: { item: i.id } } }],
})),
},
},
};
}
// adicionar_ao_carrinho: consulta (não cria pedido) · parâmetro item
async function adicionarAoCarrinho(chamada, query) {
const dono = chamada.contact?.phone ?? chamada.conversation.id;
const carrinho = await carrinhos.adicionar(query.loja, dono, chamada.params.item);
return {
status: 200,
body: {
reply: "Coloquei 1x " + carrinho.ultimoItem + " no carrinho. Total: " + carrinho.totalFormatado + ". Quer finalizar?",
components: {
type: "buttons",
buttons: [
{ title: "Finalizar pedido", action: { name: "criar_pedido", params: { carrinho: carrinho.id } } },
{ title: "Ver cardápio", id: "ver_cardapio" },
],
},
},
};
}# ver_cardapio: consulta · nível "anônimo" · a URL de cada chatbot leva a loja: .../boavoz/acoes?loja=123
def ver_cardapio(chamada, args):
itens = cardapio.da_loja(args["loja"])
return 200, {
"data": {"itens": [{"id": i.id, "nome": i.nome, "preco": i.preco_formatado} for i in itens]},
"components": {
"type": "carousel",
"cards": [
{
"image": i.foto_url,
"title": i.nome,
"text": (i.preco_formatado + ". " + i.descricao)[:300],
"buttons": [{"title": "Pedir este", "action": {"name": "adicionar_ao_carrinho", "params": {"item": i.id}}}],
}
for i in itens[:10]
],
},
}
# adicionar_ao_carrinho: consulta (não cria pedido) · parâmetro item
def adicionar_ao_carrinho(chamada, args):
dono = (chamada.get("contact") or {}).get("phone") or chamada["conversation"]["id"]
carrinho = carrinhos.adicionar(args["loja"], dono, chamada["params"]["item"])
return 200, {
"reply": "Coloquei 1x " + carrinho.ultimo_item + " no carrinho. Total: " + carrinho.total_formatado + ". Quer finalizar?",
"components": {
"type": "buttons",
"buttons": [
{"title": "Finalizar pedido", "action": {"name": "criar_pedido", "params": {"carrinho": carrinho.id}}},
{"title": "Ver cardápio", "id": "ver_cardapio"},
],
},
}| Ação | Tipo | Nível |
|---|---|---|
ver_cardapio | consulta | anônimo |
adicionar_ao_carrinho | consulta | anônimo |
criar_pedido | executa (o toque em Finalizar vale como confirmação) | anônimo ou canal |