Appearance
Integração WhatsApp na Arquitetura Multi-tenant
A integração com WhatsApp no Cidade Gov é projetada para ser um canal de comunicação limpo e escalável, mantendo a separação de responsabilidades na arquitetura multi-tenant. O WhatsApp não contém lógica de negócio; ele orquestra chamadas para APIs dos módulos existentes, garantindo que a funcionalidade seja reutilizável e testável.
Princípios de Design
- Separação de responsabilidades: WhatsApp é apenas um canal; a lógica fica nos módulos (Farmácia, Agendamento, etc.).
- Multi-tenancy aware: Cada mensagem identifica automaticamente o tenant correto.
- APIs first: Todas as funcionalidades do bot são expostas como APIs internas, permitindo testes e reutilização.
- Privacidade: Logs mínimos, sem armazenamento desnecessário de dados sensíveis.
Arquitetura Geral
Webhook WhatsApp → Serviço de Roteamento (Central) → Troca Contexto Tenant → API Interna (Tenant) → RespostaFluxo de Mensagem
- Recebimento: Webhook do provedor WhatsApp chega na central.
- Identificação: Serviço encontra o canal e tenant correspondente.
- Roteamento: Troca para o banco do tenant.
- Processamento: Chama API interna do módulo relevante.
- Resposta: Envia resposta via WhatsApp.
Central: Configuração e Roteamento
Configuração de Canais
Tabela
whatsapp_channels: Configurações globais dos provedores.provider(Meta Cloud API, Zenvia, Twilio)api_key,webhook_secret,base_url- Status ativo/inativo
Tabela
bot_tenants_channels: Mapeamento tenant-canal.tenant_id: Referência ao tenantphone_number: Número oficial do municípiochannel_id: Referência à configuração do provedorconfig: JSON com configurações específicas (ex: horário de atendimento, mensagens automáticas)is_active: Permite desativar por tenant
Serviço de Roteamento
Classe responsável por:
- Receber webhooks: Endpoint único
/webhooks/whatsapp - Autenticar: Verificar assinatura do provedor
- Encontrar tenant: Buscar em
bot_tenants_channelspelophone_number - Trocar contexto: Usar
stancl/tenancypara carregar o tenant - Encaminhar: Chamar serviço interno do tenant com payload limpo
php
// Exemplo conceitual
class WhatsAppRouter
{
public function handleWebhook(Request $request)
{
$channel = BotTenantsChannel::where('phone_number', $request->from)->first();
if (!$channel) {
return response('Tenant not found', 404);
}
tenancy()->initialize($channel->tenant);
// Chama serviço interno
return app(TenantWhatsAppService::class)->processMessage($request->message);
}
}Tenant: Serviços de Domínio
APIs Internas
Cada módulo expõe endpoints específicos para o bot, sem conhecimento de WhatsApp:
- Farmácia:
/api/bot/drug-availability?drug_name=X&unit=Y - Agendamento:
/api/bot/next-slot?service=odontologia - Informações gerais:
/api/bot/citizen-info?cns=123
Estrutura de Respostas
APIs retornam dados estruturados, não mensagens prontas:
json
{
"available": true,
"quantity": 150,
"units": ["UBS Centro", "UBS Norte"],
"expires_at": "2026-12-31"
}Serviço de Processamento
Classe que:
- Interpreta intent: Analisa mensagem do cidadão
- Chama APIs: Consulta módulos relevantes
- Formata resposta: Converte dados em mensagem amigável
- Log opcional: Registra interação (sem dados sensíveis)
Logs de Interação (Facultativo)
- Tabela
bot_interactions: Para analytics do municípiocitizen_phone: Hash do número (privacidade)intent: Tipo de pergunta (ex: "drug_availability")response_status: Sucesso/errotimestamp: Data/horatenant_id: Para isolamento
Nota: Evitar armazenar mensagens completas ou dados identificáveis desnecessariamente.
Benefícios da Abordagem
🧹 Manutenção Limpa
- Lógica de negócio isolada nos módulos
- WhatsApp como "plugin" removível
- Fácil testar APIs separadamente
📈 Escalabilidade
- Adicionar novos provedores (Telegram, SMS) reutilizando APIs
- Balanceamento de carga por tenant
- Cache de respostas frequentes
🔒 Segurança
- Isolamento tenant-aware
- Logs controlados
- Sem exposição direta de dados
🚀 Desenvolvimento Ágil
- Iterar no bot sem tocar módulos core
- A/B testing de mensagens
- Analytics de uso por município
Implementação Prática
Provedores Recomendados
- Meta Cloud API: Direto com WhatsApp Business
- Zenvia/Twilio: Para múltiplos canais
- Escolher baseado em volume e custo
Intents Iniciais
- Disponibilidade de medicamento: "Tem dipirona na UBS Centro?"
- Próxima vaga: "Quando tem consulta com dentista?"
- Informações gerais: "Horário da UBS Norte?"
Evolução
- Começar com comandos estruturados ou menções simples
- Evoluir para NLU (Natural Language Understanding) com ferramentas como Dialogflow
- Integrar com chatbots avançados (ex: Rasa, Botpress)
Monitoramento
- Métricas por tenant: Taxa de resposta, satisfação
- Alertas: Webhooks falhando, tenants sem configuração
- Logs centralizados: Para debugging sem violar isolamento
Essa arquitetura garante que o WhatsApp seja uma extensão natural dos módulos existentes, mantendo a integridade da arquitetura multi-tenant e facilitando futuras expansões.
Implementação Completa com WhatsApp Cloud API
1. Fluxo Geral da Arquitetura
Cidadão envia msg → WhatsApp Cloud API → Webhook na CENTRAL
↓
CENTRAL: Identifica número → Mapeia para tenant → Troca DB → Chama APIs internas do tenant
↓
TENANT: Responde consulta (estoque, vagas...) → CENTRAL envia resposta via WhatsApp APIResponsabilidades:
- Central: Gerencia webhook único, mapeamento número → tenant, envio de respostas
- Tenant: Fornece APIs internas (ex:
/api/internal/bot/drug-stock?query=paracetamol) que consultam dados locais
Isso mantém isolamento: cada município tem seus dados, mas o bot é centralizado para simplicidade.
2. Passos de Setup Inicial (Central)
Criar Conta WhatsApp Business API
- Acesse developers.facebook.com
- Crie app do tipo "Business"
- Adicione produto "WhatsApp"
- Gere um número de teste (ou conecte um oficial da prefeitura)
- Pegue as credenciais:
PHONE_NUMBER_IDWHATSAPP_BUSINESS_ACCOUNT_IDACCESS_TOKENpermanente
Instalar Pacote Laravel-Friendly
bash
composer require joemunapo/whatsapp-php
# ou
composer require missaelanda/laravel-whatsappPublique config e adicione no .env da central:
bash
WHATSAPP_TOKEN=seu_access_token_aqui
WHATSAPP_PHONE_ID=seu_phone_number_id_aqui
WHATSAPP_VERIFY_TOKEN=token_secreto_para_webhookConfigurar Mapeamento Número → Tenant
Crie model WhatsappChannel:
php
<?php
// app/Models/WhatsappChannel.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WhatsappChannel extends Model
{
protected $fillable = ['phone_number', 'tenant_id', 'is_active', 'config'];
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
}Migration:
php
Schema::create('whatsapp_channels', function (Blueprint $table) {
$table->id();
$table->string('phone_number'); // Número da prefeitura
$table->foreignId('tenant_id')->constrained();
$table->json('config')->nullable(); // Templates locais, horários
$table->boolean('is_active')->default(true);
$table->timestamps();
});3. Webhook na Central (Recebe Mensagens)
Crie app/Http/Controllers/WhatsappWebhookController.php:
php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\WhatsappChannel;
use App\Events\WhatsappMessageReceived;
class WhatsappWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->all();
// Verificação inicial da Meta
if (isset($payload['hub_challenge'])) {
return $payload['hub_challenge'];
}
$entry = $payload['entry'][0]['changes'][0]['value'];
$phone = $entry['from']; // Número do cidadão
$message = $entry['text']['body'] ?? '';
// Identificar tenant pelo número da prefeitura (quem recebe)
$fromPhoneId = $entry['phone_number_id'];
$channel = WhatsappChannel::where('phone_number_id', $fromPhoneId)->first();
if (!$channel) {
return response('OK', 200); // Ignora se não mapeado
}
// Trocar para DB do tenant (stancl/tenancy)
tenancy()->initialize($channel->tenant);
// Disparar evento com contexto
event(new WhatsappMessageReceived($phone, $message, $channel->tenant_id));
return response('OK', 200);
}
}Rota em routes/web.php:
php
Route::post('/whatsapp/webhook', [WhatsappWebhookController::class, 'handle']);Configure o webhook na Meta para apontar para https://seudominio.com/whatsapp/webhook com WHATSAPP_VERIFY_TOKEN.
4. Processar Mensagem no Tenant (Event + Services)
Crie app/Events/WhatsappMessageReceived.php:
php
<?php
namespace App\Events;
class WhatsappMessageReceived
{
public $citizenPhone;
public $message;
public $tenantId;
public function __construct($citizenPhone, $message, $tenantId)
{
$this->citizenPhone = $citizenPhone;
$this->message = $message;
$this->tenantId = $tenantId;
}
}Listener app/Listeners/WhatsappBotHandler.php:
php
<?php
namespace App\Listeners;
use App\Events\WhatsappMessageReceived;
use App\Services\WhatsappSender;
class WhatsappBotHandler
{
public function handle(WhatsappMessageReceived $event)
{
$intent = $this->detectIntent($event->message);
$response = match($intent) {
'drug_stock' => $this->getDrugStock($this->extractDrugName($event->message)),
'next_appointment' => $this->getNextSlot($this->extractService($event->message)),
default => 'Olá! Pergunte sobre remédios disponíveis ou vagas. Ex: "paracetamol disponível?"'
};
app(WhatsappSender::class)->send($event->citizenPhone, $response, $event->tenantId);
}
private function detectIntent($message)
{
if (str_contains(strtolower($message), 'disponível') || str_contains(strtolower($message), 'tem')) {
return 'drug_stock';
}
if (str_contains(strtolower($message), 'vaga') || str_contains(strtolower($message), 'consulta')) {
return 'next_appointment';
}
return 'unknown';
}
private function extractDrugName($message)
{
// Simples: assume que o nome vem após "disponível"
return trim(str_replace(['disponível', 'tem'], '', strtolower($message)));
}
private function extractService($message)
{
// Simples: assume que o serviço vem após "vaga para"
return trim(str_replace(['vaga para', 'consulta'], '', strtolower($message)));
}
private function getDrugStock($drugName)
{
return app(DrugStockService::class)->available($drugName);
}
private function getNextSlot($service)
{
return app(AppointmentService::class)->nextAvailable($service);
}
}Registre no EventServiceProvider.php:
php
protected $listen = [
WhatsappMessageReceived::class => [
WhatsappBotHandler::class,
],
];5. Envio de Respostas (Serviço na Central)
app/Services/WhatsappSender.php:
php
<?php
namespace App\Services;
use App\Models\WhatsappChannel;
use Illuminate\Support\Facades\Log;
class WhatsappSender
{
public function send($toPhone, $message, $tenantId)
{
$channel = WhatsappChannel::where('tenant_id', $tenantId)->first();
if (!$channel) {
Log::error("No WhatsApp channel for tenant {$tenantId}");
return;
}
// Usa pacote ou HTTP direto para Meta API
Whatsapp::to($toPhone)
->from($channel->phone_number)
->message($message)
->send();
// Log opcional no tenant
Log::info("Bot response sent", [
'tenant_id' => $tenantId,
'to' => $toPhone,
'message' => $message
]);
}
}6. APIs Internas por Tenant
Crie rotas prefixadas /api/internal/bot no tenant (middleware auth interno):
php
// routes/tenant.php ou routes/api.php (com middleware tenant)
Route::prefix('api/internal/bot')->middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
Route::get('/drug-stock', [BotController::class, 'drugStock']);
Route::get('/appointments/next', [BotController::class, 'nextAppointment']);
});Controller app/Http/Controllers/BotController.php:
php
<?php
namespace App\Http\Controllers;
use App\Services\DrugStockService;
use App\Services\AppointmentService;
class BotController extends Controller
{
public function drugStock(Request $request)
{
$query = $request->query('q');
$result = app(DrugStockService::class)->search($query);
return response()->json([
'available' => $result['available'],
'quantity' => $result['quantity'],
'units' => $result['units']
]);
}
public function nextAppointment(Request $request)
{
$service = $request->query('service');
$slot = app(AppointmentService::class)->findNext($service);
return response()->json([
'date' => $slot['date'],
'time' => $slot['time'],
'unit' => $slot['unit']
]);
}
}7. Evoluções e Dicas Práticas
Intents Simples Primeiro
- Use regex para detectar:
"disponível paracetamol","vaga odontologia" - Depois integre OpenAI/Gemini para NLU natural
Templates Meta
- Para mensagens proativas (ex: lembrete de vacina), use templates aprovados na Meta
- Requer aprovação prévia
Offline/Estoque Real-time
- Use Laravel Reverb para push de estoque atualizado → bot responde com dados frescos
Rate Limits
- Meta limita 1000 msgs/dia no teste; produza com plano pago
- Monitore com queues e rate limiting
Logs e Métricas
- Tabela
whatsapp_interactionsno tenant:phpSchema::create('whatsapp_interactions', function (Blueprint $table) { $table->id(); $table->string('citizen_phone_hash'); // Hash para privacidade $table->string('intent'); $table->string('response_status'); $table->timestamp('created_at'); });
Pacotes Alternativos BR
- Zenvia: Para Brasil (integra com WhatsApp oficial)
- Twilio Sandbox: Para testes rápidos
Testes
- Ngrok: Para webhook local
- Postman: Simular payloads da Meta
- Testes unitários para intents e APIs
Essa implementação completa garante uma integração robusta e escalável com WhatsApp Cloud API, mantendo a arquitetura multi-tenant intacta.
