Skip to content

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) → Resposta

Fluxo de Mensagem

  1. Recebimento: Webhook do provedor WhatsApp chega na central.
  2. Identificação: Serviço encontra o canal e tenant correspondente.
  3. Roteamento: Troca para o banco do tenant.
  4. Processamento: Chama API interna do módulo relevante.
  5. 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 tenant
    • phone_number: Número oficial do município
    • channel_id: Referência à configuração do provedor
    • config: 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_channels pelo phone_number
  • Trocar contexto: Usar stancl/tenancy para 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ípio
    • citizen_phone: Hash do número (privacidade)
    • intent: Tipo de pergunta (ex: "drug_availability")
    • response_status: Sucesso/erro
    • timestamp: Data/hora
    • tenant_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

  1. Disponibilidade de medicamento: "Tem dipirona na UBS Centro?"
  2. Próxima vaga: "Quando tem consulta com dentista?"
  3. 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 API

Responsabilidades:

  • 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

  1. Acesse developers.facebook.com
  2. Crie app do tipo "Business"
  3. Adicione produto "WhatsApp"
  4. Gere um número de teste (ou conecte um oficial da prefeitura)
  5. Pegue as credenciais:
    • PHONE_NUMBER_ID
    • WHATSAPP_BUSINESS_ACCOUNT_ID
    • ACCESS_TOKEN permanente

Instalar Pacote Laravel-Friendly

bash
composer require joemunapo/whatsapp-php
# ou
composer require missaelanda/laravel-whatsapp

Publique 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_webhook

Configurar 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_interactions no tenant:
    php
    Schema::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.