- Evolution API v2 integration with full HTTP client - WhatsApp instance management (Create, Connect, Delete, LogOut, Restart) - Real-time QR Code display with Alpine.js countdown timer - Pairing code support for WhatsApp Web linking - Webhook endpoint for receiving Evolution API events - Complete instance settings (reject calls, always online, read messages, etc.) - Filament v4 Resource with modal QR Code after instance creation - Table actions for Connect, View, and Edit - Status badges with Filament's native components - Full translations support (English and Portuguese) - Native Filament multi-tenancy support - DTOs with Spatie Laravel Data for type safety - Laravel Events for extensibility - Background job processing for webhooks and messages - Comprehensive configuration file
71 KiB
Filament Evolution - Arquitetura do Plugin
Plugin Filament para integração com Evolution API v2 (WhatsApp).
🏢 Multi-Tenancy
O plugin suporta multi-tenancy nativo do Filament v4 através de configuração dinâmica.
Características
| Feature | Descrição |
|---|---|
| Habilitação | Via config('filament-evolution.tenancy.enabled') |
| Coluna Dinâmica | Configurável: team_id, company_id, tenant_id, etc. |
| Tipo de Coluna | Suporta uuid ou id (bigint) |
| Scope Automático | Models aplicam filtro por tenant automaticamente |
| Auto-fill | Tenant ID é preenchido automaticamente ao criar registros |
Configuração Rápida
# .env
EVOLUTION_TENANCY_ENABLED=true
EVOLUTION_TENANT_COLUMN=team_id
EVOLUTION_TENANT_TABLE=teams
EVOLUTION_TENANT_MODEL=App\Models\Team
EVOLUTION_TENANT_COLUMN_TYPE=uuid
📖 Veja a seção Models com Multi-Tenancy para detalhes de implementação.
---
## 📊 Análise da Estrutura Atual
### ✅ O que você JÁ TEM (e funciona bem):
| Componente | Arquivo | Status |
|------------|---------|--------|
| **Config** | `config/filament-evolution.php` | 🔵 NOVO - Config própria do plugin |
| **Model** | `app/Models/WhatsappInstance.php` | ✅ Com UUID, casts, fillable |
| **Migration** | `create_whatsapp_instances_table.php` | ✅ Campos completos |
| **Enum** | `StatusConnectionEnum.php` | ✅ Com HasLabel, HasColor |
| **HTTP Client** | `EvolutionClientTrait.php` | ✅ Trait reutilizável |
| **Services Instance** | 6 services (Create, Connect, Delete, etc.) | ✅ Funcionais |
| **Services Message** | `SendMessageEvolutionService.php` | ✅ Funcional |
| **Webhook Controller** | `EvolutionWebhookController.php` | ✅ Switch por evento |
| **Filament Resource** | `WhatsappInstanceResource.php` | ✅ Com Form/Table/Infolist separados |
| **Livewire QR Code** | `EvolutionQrCode.php` | ✅ Com polling e countdown |
| **Views** | `evolution-qr-code.blade.php` | ✅ UI completa |
### ❌ O que FALTA (para melhorar qualidade e manutenibilidade):
| Componente | Benefício |
|------------|-----------|
| **DTOs** | Tipagem forte, autocomplete, validação |
| **Events** | Desacoplamento, extensibilidade |
| **Listeners** | Reação a eventos de forma organizada |
| **Jobs** | Processamento em background |
| **Exceptions** | Tratamento de erro consistente |
| **Contracts/Interfaces** | Testabilidade, injeção de dependência |
| **Testes** | Confiabilidade, refatoração segura |
---
## 🔐 Filosofia de Segurança
### Credenciais vs Dados Operacionais
| Tipo | Onde Armazenar | Exemplo |
|------|----------------|---------|
| **Credenciais** | `.env` / Config | API Key, Webhook Secret, URL do servidor |
| **Dados Operacionais** | Banco de Dados | Nome da instância, status, número conectado |
### Por que essa separação?
1. **Segurança**: Credenciais no `.env` não vazam em backups de banco
2. **Reutilização**: Mesma API Key para múltiplas instâncias
3. **Deploy**: Credenciais diferentes por ambiente (dev/staging/prod)
4. **Compliance**: Facilita auditoria e rotação de chaves
### Fluxo de Autenticação
┌─────────────────┐ ┌───────────────────────────┐ ┌─────────────────┐ │ Sua App │────▶│ Plugin │────▶│ Evolution API │ │ │ │ │ │ │ │ Cria instância │ │ config/filament-evolution │ │ Valida API Key │ │ "minha-loja" │ │ └── api.global_token │ │ Retorna dados │ └─────────────────┘ └───────────────────────────┘ └─────────────────┘ │ ▼ ┌──────────────────┐ │ Banco de Dados │ │ │ │ Salva apenas: │ │ - team_id 🏢 │ │ - name │ │ - status │ │ - number │ │ - settings │ └──────────────────┘
---
## 📁 Estrutura de Diretórios
> 🟢 = Já existe | 🟡 = Modificar | 🔵 = Criar novo
app/ ├── Enums/ │ └── Evolution/ │ ├── 🟢 StatusConnectionEnum.php # Já existe - manter │ ├── 🔵 MessageStatusEnum.php # NOVO: pending, sent, delivered, read, failed │ ├── 🔵 MessageTypeEnum.php # NOVO: text, image, audio, video, document │ └── 🔵 WebhookEventEnum.php # NOVO: Todos os eventos suportados │ ├── Data/ # 🔵 NOVO: DTOs com Spatie Data │ └── Evolution/ │ ├── InstanceData.php # Dados para criar instância │ ├── MessageData.php # Dados de mensagem │ ├── ContactData.php # Dados de contato │ ├── QrCodeData.php # QR Code + pairing code │ ├── ConnectionStatusData.php # Status de conexão │ └── Webhook/ │ ├── WebhookPayloadData.php # Payload base │ ├── QrCodeUpdatedData.php # Evento QRCODE_UPDATED │ ├── ConnectionUpdateData.php # Evento CONNECTION_UPDATE │ └── MessageUpsertData.php # Evento MESSAGES_UPSERT │ ├── Events/ # 🔵 NOVO: Eventos Laravel │ └── Evolution/ │ ├── InstanceCreated.php │ ├── InstanceConnected.php │ ├── InstanceDisconnected.php │ ├── QrCodeUpdated.php │ ├── MessageReceived.php │ ├── MessageSent.php │ └── MessageStatusUpdated.php │ ├── Exceptions/ # 🔵 NOVO: Exceções customizadas │ └── Evolution/ │ ├── EvolutionException.php # Base │ ├── ConnectionException.php │ ├── InstanceNotFoundException.php │ └── MessageFailedException.php │ ├── Filament/ │ └── Resources/ │ └── WhatsappInstances/ # 🟢 Já existe - manter estrutura │ ├── Pages/ │ ├── Schemas/ │ ├── Tables/ │ └── WhatsappInstanceResource.php │ ├── Http/ │ └── Controllers/ │ └── Webhook/ │ └── 🟡 EvolutionWebhookController.php # MODIFICAR: usar DTOs e Jobs │ ├── Jobs/ # 🔵 NOVO: Jobs para background │ └── Evolution/ │ ├── ProcessWebhookJob.php # Processa webhook │ ├── SendMessageJob.php # Envia mensagem │ ├── SendBulkMessagesJob.php # Envio em massa │ └── SyncInstanceStatusJob.php # Sincroniza status │ ├── Listeners/ # 🔵 NOVO: Listeners │ └── Evolution/ │ ├── LogMessageReceived.php │ ├── UpdateInstanceStatus.php │ ├── ClearQrCodeOnConnection.php │ └── NotifyOnDisconnection.php │ ├── Livewire/ │ └── Evolution/ │ └── 🟢 EvolutionQrCode.php # Já existe - manter │ ├── Models/ │ ├── 🟢 WhatsappInstance.php # Já existe - manter │ └── 🔵 WhatsappMessage.php # NOVO: Histórico de mensagens │ ├── Services/ │ ├── Traits/ │ │ └── 🟡 EvolutionClientTrait.php # MODIFICAR: adicionar retry, exceptions │ └── Evolution/ │ ├── Instance/ │ │ ├── 🟢 CreateEvolutionInstanceService.php # Manter │ │ ├── 🟢 ConnectEvolutionInstanceService.php # Manter │ │ ├── 🟢 DeleteEvolutionInstanceService.php # Manter │ │ ├── 🟢 FetchEvolutionInstanceService.php # Manter │ │ ├── 🟢 LogOutEvolutionInstanceService.php # Manter │ │ └── 🟢 RestartEvolutionInstanceService.php # Manter │ ├── Message/ │ │ ├── 🟢 SendMessageEvolutionService.php # Manter │ │ ├── 🔵 SendMediaEvolutionService.php # NOVO │ │ ├── 🔵 SendAudioEvolutionService.php # NOVO │ │ └── 🔵 SendDocumentEvolutionService.php # NOVO │ └── 🔵 WebhookService.php # NOVO: Configurar webhook │ ├── Contracts/ # 🔵 NOVO: Interfaces │ └── Evolution/ │ ├── EvolutionClientInterface.php │ └── MessageServiceInterface.php │ config/ │ └── 🟢 services.php # Já existe com evolution key │ database/ │ └── migrations/ │ ├── 🟢 create_whatsapp_instances_table.php # Já existe │ ├── 🔵 create_whatsapp_messages_table.php # NOVO │ └── 🔵 create_whatsapp_webhooks_table.php # NOVO (log) │ resources/ │ └── views/ │ ├── filament/app/pages/evolution/ │ │ └── 🟢 qr-code-modal.blade.php # Já existe │ └── livewire/evolution/ │ └── 🟢 evolution-qr-code.blade.php # Já existe │ routes/ │ └── 🟡 api.php ou web.php # Verificar rota do webhook │ tests/ │ ├── Feature/ │ │ └── Evolution/ │ │ ├── 🔵 InstanceResourceTest.php │ │ ├── 🔵 WebhookControllerTest.php │ │ └── 🔵 SendMessageTest.php │ └── Unit/ │ └── Evolution/ │ ├── 🔵 EvolutionClientTest.php │ ├── 🔵 InstanceServiceTest.php │ ├── 🔵 WebhookPayloadDataTest.php │ └── 🔵 StatusConnectionEnumTest.php
---
## 🗄️ Models e Migrations
### WhatsappInstance (🟡 MODIFICAR - Adicionar suporte a tenant)
```php
// database/migrations/xxxx_create_whatsapp_instances_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_instances', function (Blueprint $table) {
$table->uuid('id')->primary();
// 🏢 Tenant column dinâmico baseado na config
$this->addTenantColumn($table);
$table->string('name');
$table->string('number');
$table->string('instance_id')->nullable();
$table->string('profile_picture_url')->nullable();
$table->string('status')->nullable();
$table->boolean('reject_call')->default(true);
$table->string('msg_call')->nullable();
$table->boolean('groups_ignore')->default(true);
$table->boolean('always_online')->default(true);
$table->boolean('read_messages')->default(true);
$table->boolean('read_status')->default(true);
$table->boolean('sync_full_history')->default(true);
$table->string('count')->nullable();
$table->string('pairing_code')->nullable();
$table->longText('qr_code')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
/**
* Adiciona coluna de tenant dinamicamente baseado na config
*/
private function addTenantColumn(Blueprint $table): void
{
if (!config('filament-evolution.tenancy.enabled', false)) {
return;
}
$column = config('filament-evolution.tenancy.column', 'team_id');
$tenantTable = config('filament-evolution.tenancy.table', 'teams');
$columnType = config('filament-evolution.tenancy.column_type', 'uuid');
if ($columnType === 'uuid') {
$table->foreignUuid($column)
->constrained($tenantTable)
->cascadeOnDelete();
} else {
$table->foreignId($column)
->constrained($tenantTable)
->cascadeOnDelete();
}
$table->index($column);
}
};
WhatsappMessage (🔵 NOVO - Com tenant dinâmico)
// database/migrations/xxxx_create_whatsapp_messages_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_messages', function (Blueprint $table) {
$table->uuid('id')->primary();
// 🏢 Tenant column dinâmico
$this->addTenantColumn($table);
$table->foreignUuid('instance_id')->constrained('whatsapp_instances')->cascadeOnDelete();
$table->string('message_id')->index();
$table->string('remote_jid');
$table->string('phone');
$table->enum('direction', ['incoming', 'outgoing']);
$table->string('type')->default('text');
$table->text('content')->nullable();
$table->json('media')->nullable();
$table->string('status')->default('pending');
$table->json('raw_payload')->nullable();
$table->timestamp('sent_at')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index(['instance_id', 'phone']);
$table->index(['instance_id', 'created_at']);
});
}
private function addTenantColumn(Blueprint $table): void
{
// Mesmo método do WhatsappInstance
if (!config('filament-evolution.tenancy.enabled', false)) {
return;
}
$column = config('filament-evolution.tenancy.column', 'team_id');
$tenantTable = config('filament-evolution.tenancy.table', 'teams');
$columnType = config('filament-evolution.tenancy.column_type', 'uuid');
if ($columnType === 'uuid') {
$table->foreignUuid($column)->constrained($tenantTable)->cascadeOnDelete();
} else {
$table->foreignId($column)->constrained($tenantTable)->cascadeOnDelete();
}
$table->index($column);
}
};
WhatsappWebhook (🔵 NOVO - Log de webhooks com tenant)
// database/migrations/xxxx_create_whatsapp_webhooks_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('whatsapp_webhooks', function (Blueprint $table) {
$table->id();
// 🏢 Tenant column dinâmico
$this->addTenantColumn($table);
$table->foreignUuid('instance_id')->nullable()->constrained('whatsapp_instances')->nullOnDelete();
$table->string('event');
$table->json('payload');
$table->boolean('processed')->default(false);
$table->text('error')->nullable();
$table->integer('processing_time_ms')->nullable();
$table->timestamps();
$table->index(['event', 'processed']);
$table->index('created_at');
});
}
private function addTenantColumn(Blueprint $table): void
{
// Mesmo método
if (!config('filament-evolution.tenancy.enabled', false)) {
return;
}
$column = config('filament-evolution.tenancy.column', 'team_id');
$tenantTable = config('filament-evolution.tenancy.table', 'teams');
$columnType = config('filament-evolution.tenancy.column_type', 'uuid');
if ($columnType === 'uuid') {
$table->foreignUuid($column)->constrained($tenantTable)->cascadeOnDelete();
} else {
$table->foreignId($column)->constrained($tenantTable)->cascadeOnDelete();
}
$table->index($column);
}
};
Trait para Migrations com Tenant
// src/Database/Migrations/Concerns/HasTenantColumn.php
namespace WallaceMartinss\FilamentEvolution\Database\Migrations\Concerns;
use Illuminate\Database\Schema\Blueprint;
trait HasTenantColumn
{
protected function addTenantColumn(Blueprint $table): void
{
if (!config('filament-evolution.tenancy.enabled', false)) {
return;
}
$column = config('filament-evolution.tenancy.column', 'team_id');
$tenantTable = config('filament-evolution.tenancy.table', 'teams');
$columnType = config('filament-evolution.tenancy.column_type', 'uuid');
if ($columnType === 'uuid') {
$table->foreignUuid($column)
->constrained($tenantTable)
->cascadeOnDelete();
} else {
$table->foreignId($column)
->constrained($tenantTable)
->cascadeOnDelete();
}
$table->index($column);
}
}
🏢 Models com Multi-Tenancy
Trait para Models com Tenant
// src/Models/Concerns/HasTenant.php
namespace WallaceMartinss\FilamentEvolution\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait HasTenant
{
public static function bootHasTenant(): void
{
if (!config('filament-evolution.tenancy.enabled', false)) {
return;
}
// Global scope para filtrar por tenant
static::addGlobalScope('tenant', function (Builder $query) {
$tenantColumn = config('filament-evolution.tenancy.column', 'team_id');
$tenant = filament()->getTenant();
if ($tenant) {
$query->where($tenantColumn, $tenant->getKey());
}
});
// Auto-preenche tenant ao criar
static::creating(function ($model) {
$tenantColumn = config('filament-evolution.tenancy.column', 'team_id');
$tenant = filament()->getTenant();
if ($tenant && empty($model->{$tenantColumn})) {
$model->{$tenantColumn} = $tenant->getKey();
}
});
}
/**
* Relacionamento dinâmico com o Tenant
*/
public function tenant(): ?BelongsTo
{
if (!config('filament-evolution.tenancy.enabled', false)) {
return null;
}
$tenantColumn = config('filament-evolution.tenancy.column', 'team_id');
$tenantModel = config('filament-evolution.tenancy.model', 'App\\Models\\Team');
return $this->belongsTo($tenantModel, $tenantColumn);
}
/**
* Retorna o nome da coluna do tenant
*/
public function getTenantColumn(): ?string
{
if (!config('filament-evolution.tenancy.enabled', false)) {
return null;
}
return config('filament-evolution.tenancy.column', 'team_id');
}
}
WhatsappInstance Model
// src/Models/WhatsappInstance.php
namespace WallaceMartinss\FilamentEvolution\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
use WallaceMartinss\FilamentEvolution\Models\Concerns\HasTenant;
class WhatsappInstance extends Model
{
use HasUuids;
use HasTenant;
use SoftDeletes;
protected $fillable = [
'name',
'number',
'instance_id',
'profile_picture_url',
'status',
'reject_call',
'msg_call',
'groups_ignore',
'always_online',
'read_messages',
'read_status',
'sync_full_history',
'count',
'pairing_code',
'qr_code',
];
protected function casts(): array
{
return [
'status' => StatusConnectionEnum::class,
'reject_call' => 'boolean',
'groups_ignore' => 'boolean',
'always_online' => 'boolean',
'read_messages' => 'boolean',
'read_status' => 'boolean',
'sync_full_history' => 'boolean',
];
}
public function messages(): HasMany
{
return $this->hasMany(WhatsappMessage::class, 'instance_id');
}
public function webhooks(): HasMany
{
return $this->hasMany(WhatsappWebhook::class, 'instance_id');
}
public function isConnected(): bool
{
return $this->status === StatusConnectionEnum::OPEN;
}
public function isDisconnected(): bool
{
return in_array($this->status, [
StatusConnectionEnum::CLOSE,
StatusConnectionEnum::REFUSED,
]);
}
}
WhatsappMessage Model
// src/Models/WhatsappMessage.php
namespace WallaceMartinss\FilamentEvolution\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use WallaceMartinss\FilamentEvolution\Enums\MessageDirectionEnum;
use WallaceMartinss\FilamentEvolution\Enums\MessageStatusEnum;
use WallaceMartinss\FilamentEvolution\Enums\MessageTypeEnum;
use WallaceMartinss\FilamentEvolution\Models\Concerns\HasTenant;
class WhatsappMessage extends Model
{
use HasUuids;
use HasTenant;
protected $fillable = [
'instance_id',
'message_id',
'remote_jid',
'phone',
'direction',
'type',
'content',
'media',
'status',
'raw_payload',
'sent_at',
'delivered_at',
'read_at',
];
protected function casts(): array
{
return [
'direction' => MessageDirectionEnum::class,
'type' => MessageTypeEnum::class,
'status' => MessageStatusEnum::class,
'media' => 'array',
'raw_payload' => 'array',
'sent_at' => 'datetime',
'delivered_at' => 'datetime',
'read_at' => 'datetime',
];
}
public function instance(): BelongsTo
{
return $this->belongsTo(WhatsappInstance::class, 'instance_id');
}
public function isIncoming(): bool
{
return $this->direction === MessageDirectionEnum::INCOMING;
}
public function isOutgoing(): bool
{
return $this->direction === MessageDirectionEnum::OUTGOING;
}
}
📦 DTOs (Data Transfer Objects)
Usando Spatie Laravel Data para tipagem forte e validação.
InstanceData
// app/Data/Evolution/InstanceData.php
namespace App\Data\Evolution;
use Spatie\LaravelData\Data;
class InstanceData extends Data
{
public function __construct(
public string $name,
public string $number,
public bool $rejectCall = true,
public ?string $msgCall = null,
public bool $groupsIgnore = true,
public bool $alwaysOnline = true,
public bool $readMessages = true,
public bool $readStatus = true,
public bool $syncFullHistory = true,
) {}
public function toApiPayload(): array
{
return [
'instanceName' => $this->name,
'number' => preg_replace('/\D/', '', $this->number),
'qrcode' => true,
'integration' => config('filament-evolution.instance.integration', 'WHATSAPP-BAILEYS'),
'rejectCall' => $this->rejectCall,
'msgCall' => $this->msgCall ?? '',
'groupsIgnore' => $this->groupsIgnore,
'alwaysOnline' => $this->alwaysOnline,
'readMessages' => $this->readMessages,
'readStatus' => $this->readStatus,
'syncFullHistory' => $this->syncFullHistory,
'webhook' => [
'url' => url(config('filament-evolution.webhook.path')),
'byEvents' => false,
'base64' => false,
'events' => config('filament-evolution.webhook.events', [
'QRCODE_UPDATED',
'CONNECTION_UPDATE',
'MESSAGES_UPSERT',
]),
],
];
}
}
QrCodeData
// app/Data/Evolution/QrCodeData.php
namespace App\Data\Evolution;
use Spatie\LaravelData\Data;
class QrCodeData extends Data
{
public function __construct(
public ?string $base64 = null,
public ?string $pairingCode = null,
public ?string $code = null,
public ?int $count = null,
) {}
public static function fromApiResponse(array $response): self
{
return new self(
base64: $response['base64'] ?? $response['qrcode']['base64'] ?? null,
pairingCode: $response['pairingCode'] ?? $response['qrcode']['pairingCode'] ?? null,
code: $response['code'] ?? null,
count: $response['count'] ?? null,
);
}
public function isValid(): bool
{
return $this->base64 !== null;
}
}
MessageData
// app/Data/Evolution/MessageData.php
namespace App\Data\Evolution;
use App\Enums\Evolution\MessageTypeEnum;
use Spatie\LaravelData\Data;
class MessageData extends Data
{
public function __construct(
public string $number,
public string $content,
public MessageTypeEnum $type = MessageTypeEnum::TEXT,
public ?string $caption = null,
public ?string $mediaUrl = null,
public ?string $filename = null,
public ?int $delay = null,
) {}
public function toApiPayload(): array
{
$number = preg_replace('/\D/', '', $this->number);
return match($this->type) {
MessageTypeEnum::TEXT => [
'number' => $number,
'text' => $this->content,
],
MessageTypeEnum::IMAGE, MessageTypeEnum::VIDEO => [
'number' => $number,
'media' => $this->mediaUrl ?? $this->content,
'caption' => $this->caption ?? '',
],
MessageTypeEnum::AUDIO => [
'number' => $number,
'audio' => $this->mediaUrl ?? $this->content,
],
MessageTypeEnum::DOCUMENT => [
'number' => $number,
'document' => $this->mediaUrl ?? $this->content,
'filename' => $this->filename ?? 'document',
],
default => ['number' => $number, 'text' => $this->content],
};
}
}
Webhook DTOs
// app/Data/Evolution/Webhook/ConnectionUpdateData.php
namespace App\Data\Evolution\Webhook;
use App\Enums\Evolution\StatusConnectionEnum;
use Spatie\LaravelData\Data;
class ConnectionUpdateData extends Data
{
public function __construct(
public string $instance,
public string $state,
public ?int $statusReason = null,
) {}
public static function fromWebhook(array $payload): self
{
return new self(
instance: $payload['instance'],
state: $payload['data']['state'],
statusReason: $payload['data']['statusReason'] ?? null,
);
}
public function getStatus(): StatusConnectionEnum
{
return StatusConnectionEnum::tryFrom($this->state)
?? StatusConnectionEnum::CLOSE;
}
public function isConnected(): bool
{
return $this->state === 'open';
}
public function isDisconnected(): bool
{
return in_array($this->state, ['close', 'refused']);
}
}
// app/Data/Evolution/Webhook/QrCodeUpdatedData.php
namespace App\Data\Evolution\Webhook;
use App\Data\Evolution\QrCodeData;
use Spatie\LaravelData\Data;
class QrCodeUpdatedData extends Data
{
public function __construct(
public string $instance,
public QrCodeData $qrCode,
) {}
public static function fromWebhook(array $payload): self
{
return new self(
instance: $payload['instance'],
qrCode: new QrCodeData(
base64: $payload['data']['qrcode']['base64'] ?? null,
pairingCode: $payload['data']['qrcode']['pairingCode'] ?? null,
),
);
}
}
// app/Data/Evolution/Webhook/MessageUpsertData.php
namespace App\Data\Evolution\Webhook;
use Spatie\LaravelData\Data;
use Illuminate\Support\Str;
class MessageUpsertData extends Data
{
public function __construct(
public string $instance,
public string $messageId,
public string $remoteJid,
public string $phone,
public bool $fromMe,
public string $messageType,
public ?string $text = null,
public ?string $caption = null,
public ?array $media = null,
public ?int $timestamp = null,
) {}
public static function fromWebhook(array $payload): self
{
$data = $payload['data'] ?? [];
$key = $data['key'] ?? [];
$message = $data['message'] ?? [];
return new self(
instance: $payload['instance'],
messageId: $key['id'] ?? '',
remoteJid: $key['remoteJid'] ?? '',
phone: Str::before($key['remoteJid'] ?? '', '@'),
fromMe: $key['fromMe'] ?? false,
messageType: $data['messageType'] ?? 'unknown',
text: $message['conversation']
?? $message['extendedTextMessage']['text']
?? null,
caption: $message['imageMessage']['caption']
?? $message['videoMessage']['caption']
?? null,
media: self::extractMedia($message),
timestamp: isset($data['messageTimestamp'])
? (int) $data['messageTimestamp']
: null,
);
}
private static function extractMedia(array $message): ?array
{
$mediaTypes = ['imageMessage', 'audioMessage', 'videoMessage', 'documentMessage'];
foreach ($mediaTypes as $type) {
if (isset($message[$type])) {
return [
'type' => $type,
'mimetype' => $message[$type]['mimetype'] ?? null,
'url' => $message[$type]['url'] ?? null,
];
}
}
return null;
}
public function isIncoming(): bool
{
return !$this->fromMe;
}
public function getContent(): ?string
{
return $this->text ?? $this->caption;
}
}
⚙️ Arquivo de Configuração
🔐 Todas as credenciais ficam aqui, nunca no banco de dados!
// config/filament-evolution.php
return [
/*
|--------------------------------------------------------------------------
| Evolution API Connection (CREDENCIAIS SENSÍVEIS)
|--------------------------------------------------------------------------
|
| Estas configurações são obrigatórias e devem ser definidas no .env
| NUNCA armazene estas credenciais no banco de dados!
|
*/
'api' => [
'base_url' => env('EVOLUTION_API_URL', 'http://localhost:8080'),
'global_token' => env('EVOLUTION_API_KEY'), // 🔐 API Key global
'timeout' => env('EVOLUTION_TIMEOUT', 30),
'retry' => [
'times' => 3,
'sleep' => 100, // ms
],
],
/*
|--------------------------------------------------------------------------
| Webhook Configuration (SEGURANÇA)
|--------------------------------------------------------------------------
|
| O secret é usado para validar que os webhooks vêm da Evolution API
|
*/
'webhook' => [
'path' => env('EVOLUTION_WEBHOOK_PATH', 'api/evolution/webhook'),
'secret' => env('EVOLUTION_WEBHOOK_SECRET'), // 🔐 Para validar origem
'verify_signature' => env('EVOLUTION_VERIFY_SIGNATURE', true),
'queue' => env('EVOLUTION_WEBHOOK_QUEUE', 'default'),
'events' => [
'QRCODE_UPDATED',
'CONNECTION_UPDATE',
'MESSAGES_UPSERT',
'MESSAGES_UPDATE',
'MESSAGES_DELETE',
'SEND_MESSAGE',
],
],
/*
|--------------------------------------------------------------------------
| Instance Defaults (Configurações padrão para novas instâncias)
|--------------------------------------------------------------------------
*/
'instance' => [
'integration' => env('EVOLUTION_INTEGRATION', 'WHATSAPP-BAILEYS'),
'reject_call' => env('EVOLUTION_REJECT_CALL', false),
'msg_call' => env('EVOLUTION_MSG_CALL', ''),
'groups_ignore' => env('EVOLUTION_GROUPS_IGNORE', false),
'always_online' => env('EVOLUTION_ALWAYS_ONLINE', false),
'read_messages' => env('EVOLUTION_READ_MESSAGES', false),
'read_status' => env('EVOLUTION_READ_STATUS', false),
'sync_full_history' => env('EVOLUTION_SYNC_HISTORY', false),
],
/*
|--------------------------------------------------------------------------
| Filament Configuration
|--------------------------------------------------------------------------
*/
'filament' => [
'navigation_group' => 'WhatsApp',
'navigation_icon' => 'fab-whatsapp',
'navigation_sort' => 100,
'resource_label' => 'Instância',
'resource_plural_label' => 'Instâncias',
],
/*
|--------------------------------------------------------------------------
| Storage & Cache
|--------------------------------------------------------------------------
*/
'cache' => [
'enabled' => true,
'ttl' => 60, // segundos
'prefix' => 'evolution_',
],
/*
|--------------------------------------------------------------------------
| Queue Configuration
|--------------------------------------------------------------------------
*/
'queue' => [
'connection' => env('EVOLUTION_QUEUE_CONNECTION', 'redis'),
'messages' => env('EVOLUTION_QUEUE_MESSAGES', 'whatsapp'),
'webhooks' => env('EVOLUTION_QUEUE_WEBHOOKS', 'default'),
],
/*
|--------------------------------------------------------------------------
| Logging
|--------------------------------------------------------------------------
*/
'logging' => [
'enabled' => env('EVOLUTION_LOGGING', true),
'channel' => env('EVOLUTION_LOG_CHANNEL', 'stack'),
'log_payloads' => env('EVOLUTION_LOG_PAYLOADS', false), // Cuidado: pode conter dados sensíveis
],
/*
|--------------------------------------------------------------------------
| Multi-Tenancy Configuration
|--------------------------------------------------------------------------
|
| Configuração para suporte a múltiplos tenants no Filament.
| Se habilitado, as migrations incluirão a foreign key do tenant
| e os models aplicarão scope automático.
|
*/
'tenancy' => [
'enabled' => env('EVOLUTION_TENANCY_ENABLED', false),
// Nome da coluna de tenant nas tabelas (ex: 'team_id', 'company_id', 'tenant_id')
'column' => env('EVOLUTION_TENANT_COLUMN', 'team_id'),
// Tabela do tenant para a foreign key (ex: 'teams', 'companies', 'tenants')
'table' => env('EVOLUTION_TENANT_TABLE', 'teams'),
// Model do tenant (ex: App\Models\Team::class)
'model' => env('EVOLUTION_TENANT_MODEL', 'App\\Models\\Team'),
// Tipo da coluna do tenant ('uuid' ou 'id')
'column_type' => env('EVOLUTION_TENANT_COLUMN_TYPE', 'uuid'),
],
];
Exemplo de .env
# Evolution API - Credenciais (OBRIGATÓRIO)
EVOLUTION_API_URL=https://evolution.meudominio.com
EVOLUTION_API_KEY=seu_token_secreto_aqui
# Webhook
EVOLUTION_WEBHOOK_SECRET=meu_secret_para_validar_webhooks
EVOLUTION_WEBHOOK_PATH=api/evolution/webhook
# Configurações opcionais
EVOLUTION_INTEGRATION=WHATSAPP-BAILEYS
EVOLUTION_TIMEOUT=30
EVOLUTION_REJECT_CALL=false
EVOLUTION_GROUPS_IGNORE=false
EVOLUTION_ALWAYS_ONLINE=false
# Queue
EVOLUTION_QUEUE_CONNECTION=redis
EVOLUTION_QUEUE_MESSAGES=whatsapp
# Logging
EVOLUTION_LOGGING=true
EVOLUTION_LOG_PAYLOADS=false
# Multi-Tenancy (opcional)
EVOLUTION_TENANCY_ENABLED=true
EVOLUTION_TENANT_COLUMN=team_id
EVOLUTION_TENANT_TABLE=teams
EVOLUTION_TENANT_MODEL=App\Models\Team
EVOLUTION_TENANT_COLUMN_TYPE=uuid
📡 Events (Eventos Laravel)
Eventos permitem desacoplar a lógica e facilitar extensões.
// app/Events/Evolution/InstanceConnected.php
namespace App\Events\Evolution;
use App\Models\WhatsappInstance;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InstanceConnected
{
use Dispatchable, SerializesModels;
public function __construct(
public WhatsappInstance $instance,
) {}
}
// app/Events/Evolution/InstanceDisconnected.php
class InstanceDisconnected
{
use Dispatchable, SerializesModels;
public function __construct(
public WhatsappInstance $instance,
public string $reason = 'unknown',
) {}
}
// app/Events/Evolution/QrCodeUpdated.php
use App\Data\Evolution\QrCodeData;
class QrCodeUpdated
{
use Dispatchable, SerializesModels;
public function __construct(
public WhatsappInstance $instance,
public QrCodeData $qrCode,
) {}
}
// app/Events/Evolution/MessageReceived.php
use App\Data\Evolution\Webhook\MessageUpsertData;
class MessageReceived
{
use Dispatchable, SerializesModels;
public function __construct(
public WhatsappInstance $instance,
public MessageUpsertData $message,
) {}
}
// app/Events/Evolution/MessageSent.php
class MessageSent
{
use Dispatchable, SerializesModels;
public function __construct(
public WhatsappInstance $instance,
public string $phone,
public string $messageId,
public string $content,
) {}
}
👂 Listeners
// app/Listeners/Evolution/UpdateInstanceStatus.php
namespace App\Listeners\Evolution;
use App\Events\Evolution\{InstanceConnected, InstanceDisconnected};
use App\Services\Evolution\Instance\FetchEvolutionInstanceService;
class UpdateInstanceStatus
{
public function handleConnected(InstanceConnected $event): void
{
$event->instance->update([
'status' => 'open',
'qr_code' => null,
'pairing_code' => null,
]);
// Buscar foto de perfil
app(FetchEvolutionInstanceService::class)->fetchInstance($event->instance->name);
}
public function handleDisconnected(InstanceDisconnected $event): void
{
$event->instance->update([
'status' => 'close',
]);
}
}
// app/Listeners/Evolution/ClearQrCodeOnConnection.php
class ClearQrCodeOnConnection
{
public function handle(InstanceConnected $event): void
{
$event->instance->update([
'qr_code' => null,
'pairing_code' => null,
]);
}
}
// app/Listeners/Evolution/LogMessageReceived.php
use App\Events\Evolution\MessageReceived;
use App\Models\WhatsappMessage;
class LogMessageReceived
{
public function handle(MessageReceived $event): void
{
WhatsappMessage::create([
'instance_id' => $event->instance->id,
'message_id' => $event->message->messageId,
'remote_jid' => $event->message->remoteJid,
'phone' => $event->message->phone,
'direction' => 'incoming',
'type' => $event->message->messageType,
'content' => $event->message->getContent(),
'media' => $event->message->media,
'status' => 'received',
]);
}
}
Registrar Listeners (EventServiceProvider)
// app/Providers/EventServiceProvider.php
protected $listen = [
\App\Events\Evolution\InstanceConnected::class => [
\App\Listeners\Evolution\UpdateInstanceStatus::class . '@handleConnected',
\App\Listeners\Evolution\ClearQrCodeOnConnection::class,
],
\App\Events\Evolution\InstanceDisconnected::class => [
\App\Listeners\Evolution\UpdateInstanceStatus::class . '@handleDisconnected',
],
\App\Events\Evolution\MessageReceived::class => [
\App\Listeners\Evolution\LogMessageReceived::class,
],
];
⚡ Jobs (Processamento em Background)
// app/Jobs/Evolution/ProcessWebhookJob.php
namespace App\Jobs\Evolution;
use App\Data\Evolution\Webhook\{ConnectionUpdateData, MessageUpsertData, QrCodeUpdatedData};
use App\Enums\Evolution\WebhookEventEnum;
use App\Events\Evolution\{InstanceConnected, InstanceDisconnected, MessageReceived, QrCodeUpdated};
use App\Models\{WhatsappInstance, WhatsappWebhook};
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\{InteractsWithQueue, SerializesModels};
class ProcessWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public string $event,
public array $payload,
) {}
public function handle(): void
{
$startTime = microtime(true);
$webhookLog = null;
try {
$instance = WhatsappInstance::where('name', $this->payload['instance'] ?? null)->first();
// Log do webhook
$webhookLog = WhatsappWebhook::create([
'instance_id' => $instance?->id,
'event' => $this->event,
'payload' => $this->payload,
'processed' => false,
]);
match($this->event) {
'connection.update' => $this->handleConnectionUpdate($instance),
'qrcode.updated' => $this->handleQrCodeUpdated($instance),
'messages.upsert' => $this->handleMessageUpsert($instance),
default => null,
};
$webhookLog->update([
'processed' => true,
'processing_time_ms' => (int) ((microtime(true) - $startTime) * 1000),
]);
} catch (\Throwable $e) {
$webhookLog?->update([
'error' => $e->getMessage(),
'processing_time_ms' => (int) ((microtime(true) - $startTime) * 1000),
]);
throw $e;
}
}
private function handleConnectionUpdate(?WhatsappInstance $instance): void
{
if (!$instance) return;
$data = ConnectionUpdateData::fromWebhook($this->payload);
if ($data->isConnected()) {
event(new InstanceConnected($instance));
} elseif ($data->isDisconnected()) {
event(new InstanceDisconnected($instance, $data->state));
}
$instance->update(['status' => $data->state]);
}
private function handleQrCodeUpdated(?WhatsappInstance $instance): void
{
if (!$instance) return;
$data = QrCodeUpdatedData::fromWebhook($this->payload);
$instance->update([
'qr_code' => $data->qrCode->base64,
'pairing_code' => $data->qrCode->pairingCode,
]);
event(new QrCodeUpdated($instance, $data->qrCode));
}
private function handleMessageUpsert(?WhatsappInstance $instance): void
{
if (!$instance) return;
$data = MessageUpsertData::fromWebhook($this->payload);
if ($data->isIncoming()) {
event(new MessageReceived($instance, $data));
}
}
}
// app/Jobs/Evolution/SendMessageJob.php
namespace App\Jobs\Evolution;
use App\Data\Evolution\MessageData;
use App\Events\Evolution\MessageSent;
use App\Models\{WhatsappInstance, WhatsappMessage};
use App\Services\Evolution\Message\SendMessageEvolutionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\{InteractsWithQueue, SerializesModels};
class SendMessageJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 5;
public function __construct(
public WhatsappInstance $instance,
public MessageData $message,
) {}
public function handle(SendMessageEvolutionService $service): void
{
$response = $service->sendMessage($this->instance->name, [
'number_whatsapp' => $this->message->number,
'message' => $this->message->content,
]);
if (isset($response['error'])) {
throw new \Exception($response['error']);
}
// Salvar mensagem enviada
WhatsappMessage::create([
'instance_id' => $this->instance->id,
'message_id' => $response['key']['id'] ?? '',
'remote_jid' => $response['key']['remoteJid'] ?? '',
'phone' => $this->message->number,
'direction' => 'outgoing',
'type' => 'text',
'content' => $this->message->content,
'status' => $response['status'] ?? 'pending',
'sent_at' => now(),
]);
event(new MessageSent(
$this->instance,
$this->message->number,
$response['key']['id'] ?? '',
$this->message->content,
));
}
}
🔄 Webhook Controller Refatorado
// app/Http/Controllers/Webhook/EvolutionWebhookController.php
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Jobs\Evolution\ProcessWebhookJob;
use Illuminate\Http\{JsonResponse, Request};
class EvolutionWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$event = $request->input('event');
$payload = $request->all();
// Despacha para processamento em background
ProcessWebhookJob::dispatch($event, $payload)
->onQueue(config('services.evolution.webhook_queue', 'default'));
return response()->json(['status' => 'queued']);
}
}
🎯 Enums
StatusConnectionEnum (🟢 JÁ EXISTE - Perfeito!)
// Seu enum atual - PERFEITO, não precisa mudar
namespace App\Enums\Evolution;
use Filament\Support\Contracts\{HasColor, HasLabel};
enum StatusConnectionEnum: string implements HasLabel, HasColor
{
case CLOSE = 'close';
case OPEN = 'open';
case CONNECTING = 'connecting';
case REFUSED = 'refused';
public function getLabel(): string
{
return match ($this) {
self::OPEN => 'Conectado',
self::CONNECTING => 'Conectando',
self::CLOSE => 'Desconectado',
self::REFUSED => 'Recusado',
};
}
public function getColor(): string|array|null
{
return match ($this) {
self::OPEN => 'success',
self::CONNECTING => 'warning',
self::CLOSE => 'danger',
self::REFUSED => 'danger',
};
}
}
MessageTypeEnum (🔵 NOVO)
// app/Enums/Evolution/MessageTypeEnum.php
namespace App\Enums\Evolution;
use Filament\Support\Contracts\{HasColor, HasIcon, HasLabel};
enum MessageTypeEnum: string implements HasLabel, HasColor, HasIcon
{
case TEXT = 'text';
case IMAGE = 'image';
case AUDIO = 'audio';
case VIDEO = 'video';
case DOCUMENT = 'document';
case LOCATION = 'location';
case CONTACT = 'contact';
case STICKER = 'sticker';
public function getLabel(): string
{
return match ($this) {
self::TEXT => 'Texto',
self::IMAGE => 'Imagem',
self::AUDIO => 'Áudio',
self::VIDEO => 'Vídeo',
self::DOCUMENT => 'Documento',
self::LOCATION => 'Localização',
self::CONTACT => 'Contato',
self::STICKER => 'Figurinha',
};
}
public function getColor(): string|array|null
{
return match ($this) {
self::TEXT => 'gray',
self::IMAGE => 'success',
self::AUDIO => 'warning',
self::VIDEO => 'info',
self::DOCUMENT => 'primary',
self::LOCATION => 'danger',
self::CONTACT => 'secondary',
self::STICKER => 'warning',
};
}
public function getIcon(): ?string
{
return match ($this) {
self::TEXT => 'heroicon-o-chat-bubble-left',
self::IMAGE => 'heroicon-o-photo',
self::AUDIO => 'heroicon-o-microphone',
self::VIDEO => 'heroicon-o-video-camera',
self::DOCUMENT => 'heroicon-o-document',
self::LOCATION => 'heroicon-o-map-pin',
self::CONTACT => 'heroicon-o-user',
self::STICKER => 'heroicon-o-face-smile',
};
}
}
MessageDirectionEnum (🔵 NOVO)
// src/Enums/MessageDirectionEnum.php
namespace WallaceMartinss\FilamentEvolution\Enums;
use Filament\Support\Contracts\{HasColor, HasIcon, HasLabel};
enum MessageDirectionEnum: string implements HasLabel, HasColor, HasIcon
{
case INCOMING = 'incoming';
case OUTGOING = 'outgoing';
public function getLabel(): string
{
return match ($this) {
self::INCOMING => 'Recebida',
self::OUTGOING => 'Enviada',
};
}
public function getColor(): string|array|null
{
return match ($this) {
self::INCOMING => 'info',
self::OUTGOING => 'success',
};
}
public function getIcon(): ?string
{
return match ($this) {
self::INCOMING => 'heroicon-o-arrow-down-left',
self::OUTGOING => 'heroicon-o-arrow-up-right',
};
}
}
MessageStatusEnum (🔵 NOVO)
// app/Enums/Evolution/MessageStatusEnum.php
namespace App\Enums\Evolution;
use Filament\Support\Contracts\{HasColor, HasIcon, HasLabel};
enum MessageStatusEnum: string implements HasLabel, HasColor, HasIcon
{
case PENDING = 'pending';
case SENT = 'sent';
case DELIVERED = 'delivered';
case READ = 'read';
case FAILED = 'failed';
public function getLabel(): string
{
return match ($this) {
self::PENDING => 'Pendente',
self::SENT => 'Enviado',
self::DELIVERED => 'Entregue',
self::READ => 'Lido',
self::FAILED => 'Falhou',
};
}
public function getColor(): string|array|null
{
return match ($this) {
self::PENDING => 'gray',
self::SENT => 'info',
self::DELIVERED => 'warning',
self::READ => 'success',
self::FAILED => 'danger',
};
}
public function getIcon(): ?string
{
return match ($this) {
self::PENDING => 'heroicon-o-clock',
self::SENT => 'heroicon-o-check',
self::DELIVERED => 'heroicon-o-check', // ✓✓
self::READ => 'heroicon-o-check', // ✓✓ azul
self::FAILED => 'heroicon-o-x-circle',
};
}
}
WebhookEventEnum (🔵 NOVO)
// app/Enums/Evolution/WebhookEventEnum.php
namespace App\Enums\Evolution;
enum WebhookEventEnum: string
{
case APPLICATION_STARTUP = 'application.startup';
case QRCODE_UPDATED = 'qrcode.updated';
case CONNECTION_UPDATE = 'connection.update';
case MESSAGES_SET = 'messages.set';
case MESSAGES_UPSERT = 'messages.upsert';
case MESSAGES_UPDATE = 'messages.update';
case MESSAGES_DELETE = 'messages.delete';
case SEND_MESSAGE = 'send.message';
case PRESENCE_UPDATE = 'presence.update';
case NEW_TOKEN = 'new.token';
case LOGOUT_INSTANCE = 'logout.instance';
case REMOVE_INSTANCE = 'remove.instance';
public function label(): string
{
return match($this) {
self::QRCODE_UPDATED => 'QR Code Atualizado',
self::CONNECTION_UPDATE => 'Status de Conexão',
self::MESSAGES_UPSERT => 'Mensagem Recebida',
self::MESSAGES_UPDATE => 'Mensagem Atualizada',
self::SEND_MESSAGE => 'Mensagem Enviada',
self::LOGOUT_INSTANCE => 'Logout da Instância',
self::REMOVE_INSTANCE => 'Instância Removida',
default => $this->value,
};
}
public function shouldProcess(): bool
{
return in_array($this, [
self::QRCODE_UPDATED,
self::CONNECTION_UPDATE,
self::MESSAGES_UPSERT,
self::SEND_MESSAGE,
]);
}
}
🔌 Services (🟢 MANTER os existentes)
Seus services atuais estão funcionais. Sugestão: adicionar tipagem com DTOs.
Melhoria no EvolutionClientTrait
// app/Services/Traits/EvolutionClientTrait.php
namespace App\Services\Traits;
use App\Exceptions\Evolution\{ConnectionException, EvolutionException};
use Illuminate\Support\Facades\Http;
trait EvolutionClientTrait
{
protected string $apiKey;
protected string $apiUrl;
public function __construct()
{
$this->apiKey = config('services.evolution.key');
$this->apiUrl = config('services.evolution.url');
if (empty($this->apiKey) || empty($this->apiUrl)) {
throw new EvolutionException('Evolution API não configurada. Verifique EVOLUTION_API_KEY e EVOLUTION_URL no .env');
}
}
protected function makeRequest(string $endpoint, string $method = 'GET', array $data = []): array
{
try {
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'apikey' => $this->apiKey,
])
->timeout(config('services.evolution.timeout', 30))
->retry(3, 100)
->{strtolower($method)}($this->apiUrl . $endpoint, $data);
if ($response->failed()) {
throw new ConnectionException(
"Erro na API Evolution: " . ($response->json()['message'] ?? $response->status())
);
}
return $response->json();
} catch (\Illuminate\Http\Client\ConnectionException $e) {
throw new ConnectionException("Não foi possível conectar à API Evolution: " . $e->getMessage());
}
}
}
🧪 Estrutura de Testes
Testes Unitários
// tests/Unit/Evolution/StatusConnectionEnumTest.php
namespace Tests\Unit\Evolution;
use App\Enums\Evolution\StatusConnectionEnum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class StatusConnectionEnumTest extends TestCase
{
#[Test]
public function it_returns_correct_labels(): void
{
$this->assertEquals('Conectado', StatusConnectionEnum::OPEN->getLabel());
$this->assertEquals('Desconectado', StatusConnectionEnum::CLOSE->getLabel());
$this->assertEquals('Conectando', StatusConnectionEnum::CONNECTING->getLabel());
}
#[Test]
public function it_returns_correct_colors(): void
{
$this->assertEquals('success', StatusConnectionEnum::OPEN->getColor());
$this->assertEquals('danger', StatusConnectionEnum::CLOSE->getColor());
$this->assertEquals('warning', StatusConnectionEnum::CONNECTING->getColor());
}
#[Test]
public function it_can_be_created_from_string(): void
{
$this->assertEquals(StatusConnectionEnum::OPEN, StatusConnectionEnum::from('open'));
$this->assertEquals(StatusConnectionEnum::CLOSE, StatusConnectionEnum::from('close'));
}
}
// tests/Unit/Evolution/InstanceDataTest.php
namespace Tests\Unit\Evolution;
use App\Data\Evolution\InstanceData;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class InstanceDataTest extends TestCase
{
#[Test]
public function it_creates_instance_data(): void
{
$data = new InstanceData(
name: 'test-instance',
number: '5511999999999',
);
$this->assertEquals('test-instance', $data->name);
$this->assertEquals('5511999999999', $data->number);
}
#[Test]
public function it_converts_to_api_payload(): void
{
$data = new InstanceData(
name: 'test-instance',
number: '+55 (11) 99999-9999',
rejectCall: true,
);
$payload = $data->toApiPayload();
$this->assertEquals('test-instance', $payload['instanceName']);
$this->assertEquals('5511999999999', $payload['number']); // Formatado
$this->assertTrue($payload['rejectCall']);
$this->assertArrayHasKey('webhook', $payload);
}
}
// tests/Unit/Evolution/ConnectionUpdateDataTest.php
namespace Tests\Unit\Evolution;
use App\Data\Evolution\Webhook\ConnectionUpdateData;
use App\Enums\Evolution\StatusConnectionEnum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class ConnectionUpdateDataTest extends TestCase
{
#[Test]
public function it_parses_webhook_payload(): void
{
$payload = [
'instance' => 'test-instance',
'data' => [
'state' => 'open',
'statusReason' => 200,
],
];
$data = ConnectionUpdateData::fromWebhook($payload);
$this->assertEquals('test-instance', $data->instance);
$this->assertEquals('open', $data->state);
$this->assertTrue($data->isConnected());
$this->assertFalse($data->isDisconnected());
}
#[Test]
public function it_detects_disconnection(): void
{
$payload = [
'instance' => 'test-instance',
'data' => ['state' => 'close'],
];
$data = ConnectionUpdateData::fromWebhook($payload);
$this->assertTrue($data->isDisconnected());
$this->assertEquals(StatusConnectionEnum::CLOSE, $data->getStatus());
}
}
// tests/Unit/Evolution/MessageUpsertDataTest.php
namespace Tests\Unit\Evolution;
use App\Data\Evolution\Webhook\MessageUpsertData;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class MessageUpsertDataTest extends TestCase
{
#[Test]
public function it_parses_text_message(): void
{
$payload = $this->getFixture('messages-upsert.json');
$data = MessageUpsertData::fromWebhook($payload);
$this->assertEquals('test-instance', $data->instance);
$this->assertEquals('5511999999999', $data->phone);
$this->assertEquals('Olá, tudo bem?', $data->text);
$this->assertTrue($data->isIncoming());
}
#[Test]
public function it_extracts_caption_from_image(): void
{
$payload = [
'instance' => 'test',
'data' => [
'key' => ['id' => '123', 'remoteJid' => '5511@s.whatsapp.net', 'fromMe' => false],
'messageType' => 'imageMessage',
'message' => [
'imageMessage' => [
'caption' => 'Foto do produto',
'mimetype' => 'image/jpeg',
],
],
],
];
$data = MessageUpsertData::fromWebhook($payload);
$this->assertEquals('Foto do produto', $data->caption);
$this->assertEquals('Foto do produto', $data->getContent());
}
private function getFixture(string $name): array
{
return json_decode(
file_get_contents(base_path("tests/Fixtures/webhook-payloads/{$name}")),
true
);
}
}
Testes de Feature
// tests/Feature/Evolution/WhatsappInstanceResourceTest.php
namespace Tests\Feature\Evolution;
use App\Filament\Resources\WhatsappInstances\WhatsappInstanceResource;
use App\Filament\Resources\WhatsappInstances\Pages\ListWhatsappInstances;
use App\Models\WhatsappInstance;
use Filament\Actions\DeleteAction;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class WhatsappInstanceResourceTest extends TestCase
{
#[Test]
public function it_can_render_list_page(): void
{
$this->get(WhatsappInstanceResource::getUrl('index'))
->assertSuccessful();
}
#[Test]
public function it_can_list_instances(): void
{
$instances = WhatsappInstance::factory()->count(3)->create();
Livewire::test(ListWhatsappInstances::class)
->assertCanSeeTableRecords($instances);
}
#[Test]
public function it_can_create_instance(): void
{
// Mock da API Evolution
Http::fake([
'*/instance/create' => Http::response([
'instance' => ['instanceId' => 'uuid-123', 'status' => 'created'],
'hash' => 'abc123',
'qrcode' => ['base64' => 'data:image/png;base64,...'],
]),
]);
Livewire::test(CreateWhatsappInstance::class)
->fillForm([
'name' => 'nova-instancia',
'number' => '5511999999999',
])
->call('create')
->assertHasNoFormErrors();
$this->assertDatabaseHas('whatsapp_instances', [
'name' => 'nova-instancia',
]);
}
}
// tests/Feature/Evolution/WebhookControllerTest.php
namespace Tests\Feature\Evolution;
use App\Jobs\Evolution\ProcessWebhookJob;
use App\Models\WhatsappInstance;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class WebhookControllerTest extends TestCase
{
#[Test]
public function it_queues_webhook_for_processing(): void
{
Queue::fake();
$response = $this->postJson('/api/evolution/webhook', [
'event' => 'connection.update',
'instance' => 'test-instance',
'data' => ['state' => 'open'],
]);
$response->assertOk();
Queue::assertPushed(ProcessWebhookJob::class);
}
#[Test]
public function it_processes_connection_update(): void
{
$instance = WhatsappInstance::factory()->create([
'name' => 'test-instance',
'status' => 'connecting',
]);
$job = new ProcessWebhookJob('connection.update', [
'instance' => 'test-instance',
'data' => ['state' => 'open'],
]);
$job->handle();
$this->assertEquals('open', $instance->fresh()->status->value);
}
#[Test]
public function it_processes_qrcode_updated(): void
{
$instance = WhatsappInstance::factory()->create([
'name' => 'test-instance',
]);
$job = new ProcessWebhookJob('qrcode.updated', [
'instance' => 'test-instance',
'data' => [
'qrcode' => [
'base64' => 'data:image/png;base64,abc123',
'pairingCode' => 'ABCD1234',
],
],
]);
$job->handle();
$instance->refresh();
$this->assertNotNull($instance->qr_code);
$this->assertEquals('ABCD1234', $instance->pairing_code);
}
#[Test]
public function it_logs_webhook_to_database(): void
{
$instance = WhatsappInstance::factory()->create(['name' => 'test']);
$job = new ProcessWebhookJob('connection.update', [
'instance' => 'test',
'data' => ['state' => 'open'],
]);
$job->handle();
$this->assertDatabaseHas('whatsapp_webhooks', [
'event' => 'connection.update',
'processed' => true,
]);
}
}
// tests/Feature/Evolution/SendMessageTest.php
namespace Tests\Feature\Evolution;
use App\Data\Evolution\MessageData;
use App\Jobs\Evolution\SendMessageJob;
use App\Models\WhatsappInstance;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SendMessageTest extends TestCase
{
#[Test]
public function it_sends_text_message(): void
{
Http::fake([
'*/message/sendText/*' => Http::response([
'key' => ['id' => 'msg-123', 'remoteJid' => '5511@s.whatsapp.net'],
'status' => 'PENDING',
]),
]);
$instance = WhatsappInstance::factory()->create(['status' => 'open']);
$message = new MessageData(
number: '5511999999999',
content: 'Olá, teste!',
);
$job = new SendMessageJob($instance, $message);
$job->handle(app(\App\Services\Evolution\Message\SendMessageEvolutionService::class));
$this->assertDatabaseHas('whatsapp_messages', [
'instance_id' => $instance->id,
'phone' => '5511999999999',
'content' => 'Olá, teste!',
'direction' => 'outgoing',
]);
}
#[Test]
public function it_retries_on_failure(): void
{
Http::fake([
'*/message/sendText/*' => Http::response(['error' => 'timeout'], 500),
]);
$instance = WhatsappInstance::factory()->create();
$message = new MessageData(number: '5511999999999', content: 'Test');
$job = new SendMessageJob($instance, $message);
$this->expectException(\Exception::class);
$job->handle(app(\App\Services\Evolution\Message\SendMessageEvolutionService::class));
}
}
Fixtures
tests/
└── Fixtures/
└── webhook-payloads/
├── connection-update-open.json
├── connection-update-close.json
├── qrcode-updated.json
├── messages-upsert-text.json
├── messages-upsert-image.json
└── send-message.json
// tests/Fixtures/webhook-payloads/messages-upsert-text.json
{
"event": "messages.upsert",
"instance": "test-instance",
"data": {
"key": {
"remoteJid": "5511999999999@s.whatsapp.net",
"fromMe": false,
"id": "BAE594145F4C59B4"
},
"messageType": "conversation",
"message": {
"conversation": "Olá, tudo bem?"
},
"messageTimestamp": "1717689097"
}
}
📋 Checklist de Implementação
Fase 0 - Setup do Package
- Criar estrutura do package (
packages/wallacemartinss/filament-evolution/) - Criar
composer.jsondo package - Criar
FilamentEvolutionServiceProvider.php - Criar
FilamentEvolutionPlugin.php(Filament Panel Plugin) - Criar
config/filament-evolution.phpcom todas as configurações - Publicar config via Service Provider
Fase 1 - Multi-Tenancy e Infraestrutura Base
- Criar Traits
src/Models/Concerns/HasTenant.phpsrc/Database/Migrations/Concerns/HasTenantColumn.php
- Criar Migrations com tenant dinâmico
create_whatsapp_instances_table.phpcreate_whatsapp_messages_table.phpcreate_whatsapp_webhooks_table.php
- Criar Models com HasTenant
WhatsappInstance.phpWhatsappMessage.phpWhatsappWebhook.php
- Testes de Multi-Tenancy
Fase 2 - DTOs (Data Transfer Objects)
- Instalar
spatie/laravel-data - Criar DTOs (
src/Data/)InstanceData.phpMessageData.phpQrCodeData.phpContactData.phpWebhook/ConnectionUpdateData.phpWebhook/QrCodeUpdatedData.phpWebhook/MessageUpsertData.php
- Testes unitários de DTOs
Fase 3 - Eventos, Jobs e Listeners
- Criar Events (
src/Events/)InstanceConnected.phpInstanceDisconnected.phpMessageReceived.phpMessageSent.phpQrCodeUpdated.php
- Criar Jobs (
src/Jobs/)ProcessWebhookJob.phpSendMessageJob.phpSendBulkMessagesJob.php
- Criar Listeners (
src/Listeners/)UpdateInstanceStatus.phpLogMessageReceived.phpNotifyMessageReceived.php
- Registrar Events/Listeners no Service Provider
- Testes de Jobs e Listeners
Fase 4 - Enums
- Criar novos Enums (
src/Enums/)StatusConnectionEnum.phpWebhookEventEnum.phpMessageTypeEnum.phpMessageDirectionEnum.phpMessageStatusEnum.php
- Criar Migrations
create_whatsapp_messages_table.phpcreate_whatsapp_webhooks_table.php
- Criar Models
WhatsappMessage.phpWhatsappWebhook.php
- Factories para os novos Models
- Testes dos Models
Fase 5 - Refatorar WebhookController
- Usar DTOs em vez de arrays raw
- Dispatch Job ao invés de processar inline
- Disparar Events para extensibilidade
- Adicionar logging estruturado
- Testes de integração de webhooks
Fase 6 - Filament UI
- Criar
WhatsappInstanceResourceno plugin - Criar
EvolutionQrCodeLivewire Component - Adicionar Widget de estatísticas
- Adicionar página de logs de webhooks
- Testes de Feature do Resource
Fase 7 - Services
- Criar
EvolutionClient(HTTP Client) - Criar Instance Services (Create, Connect, Delete, etc.)
- Criar Message Services (SendText, SendMedia, etc.)
- Testes de envio de mensagens
Fase 8 - Polimento
- Traduções (pt_BR, en)
- Documentação (README.md)
- Cache de status de instâncias
- Logging estruturado
- GitHub Actions CI/CD
- Publicar no Packagist
📊 Resumo de Arquivos
Código Existente (🟢 Referência do seu projeto)
| Arquivo | Descrição |
|---|---|
WhatsappInstance Model |
UUID, name, number, status, settings |
StatusConnectionEnum |
CLOSE/OPEN/CONNECTING/REFUSED |
EvolutionClientTrait |
HTTP client com makeRequest() |
| 6 Instance Services | Create, Connect, Delete, Fetch, LogOut, Restart |
SendMessageEvolutionService |
Envio de mensagens texto |
EvolutionWebhookController |
Handler de webhooks |
EvolutionQrCode Livewire |
Componente QR com polling |
WhatsappInstanceResource |
Filament Resource completo |
Novos Arquivos do Plugin (🔵 Criar)
| Arquivo | Descrição |
|---|---|
config/filament-evolution.php |
Config completa (API, webhook, tenancy, etc.) |
| 4 DTOs Core | InstanceData, MessageData, QrCodeData, ContactData |
| 3 DTOs Webhook | ConnectionUpdateData, QrCodeUpdatedData, MessageUpsertData |
| 5 Events | InstanceConnected, InstanceDisconnected, MessageReceived, MessageSent, QrCodeUpdated |
| 3 Jobs | ProcessWebhookJob, SendMessageJob, SendBulkMessagesJob |
| 3 Listeners | UpdateInstanceStatus, LogMessageReceived, NotifyMessageReceived |
| 4 Enums | WebhookEventEnum, MessageTypeEnum, MessageDirectionEnum, MessageStatusEnum |
| 3 Migrations | whatsapp_instances, whatsapp_messages, whatsapp_webhooks (com tenant dinâmico) |
| 2 Models | WhatsappMessage, WhatsappWebhook (com HasTenant trait) |
| 2 Traits | HasTenant (model), HasTenantColumn (migration) |
| 1 Config | config/filament-evolution.php |
| 47+ Testes | Unit + Feature com fixtures |
Total Estimado
- Arquivos existentes: ~15 arquivos
- Novos arquivos: ~35 arquivos
- Total de testes: 47+ casos de teste
📝 Notas Importantes
- Segurança do Webhook: Sempre validar a origem do webhook usando secret/signature
- Rate Limiting: A Evolution API pode ter limites, implementar retry com backoff
- Queue: Webhooks e envios devem ser processados em background
- Soft Deletes: Instâncias usam soft delete para manter histórico
- Multi-tenancy: ✅ Suporte completo via
config('filament-evolution.tenancy') - Config Própria: ✅ Plugin usa
config/filament-evolution.php(nãoservices.php)