From 3bf496e8a9fd91d8a12c42a6f0070d707366d2bb Mon Sep 17 00:00:00 2001 From: Wallace Martins Date: Sun, 7 Dec 2025 10:14:40 -0300 Subject: [PATCH] feat: initial release v0.1.0 - 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 --- .gitignore | 10 + ARCHITECTURE.md | 2340 +++++++++++++++++ CHANGELOG.md | 55 + LICENSE.md | 21 + README.md | 200 ++ composer.json | 74 + config/filament-evolution.php | 144 + .../create_whatsapp_instances_table.php.stub | 49 + .../create_whatsapp_messages_table.php.stub | 51 + .../create_whatsapp_webhooks_table.php.stub | 43 + phpunit.xml | 25 + resources/lang/en/enums.php | 51 + resources/lang/en/qrcode.php | 21 + resources/lang/en/resource.php | 60 + resources/lang/pt_BR/enums.php | 51 + resources/lang/pt_BR/qrcode.php | 21 + resources/lang/pt_BR/resource.php | 60 + .../views/components/qr-code-modal.blade.php | 3 + .../pages/connect-whatsapp-instance.blade.php | 5 + .../pages/list-whatsapp-instances.blade.php | 19 + .../views/livewire/qr-code-display.blade.php | 162 ++ .../views/livewire/qr-code-modal.blade.php | 3 + routes/api.php | 11 + src/Data/ContactData.php | 39 + src/Data/InstanceData.php | 66 + src/Data/MessageData.php | 94 + src/Data/QrCodeData.php | 37 + src/Data/Webhooks/ConnectionUpdateData.php | 43 + src/Data/Webhooks/MessageUpsertData.php | 43 + src/Data/Webhooks/QrCodeUpdatedData.php | 34 + .../Migrations/Concerns/HasTenantColumn.php | 56 + src/Enums/MessageDirectionEnum.php | 49 + src/Enums/MessageStatusEnum.php | 66 + src/Enums/MessageTypeEnum.php | 73 + src/Enums/StatusConnectionEnum.php | 62 + src/Enums/WebhookEventEnum.php | 77 + src/Events/InstanceConnected.php | 21 + src/Events/InstanceDisconnected.php | 22 + src/Events/MessageReceived.php | 23 + src/Events/QrCodeUpdated.php | 23 + src/Exceptions/EvolutionApiException.php | 18 + .../Resources/WhatsappInstanceResource.php | 211 ++ .../Pages/CreateWhatsappInstance.php | 82 + .../Pages/EditWhatsappInstance.php | 31 + .../Pages/ListWhatsappInstances.php | 63 + .../Pages/ViewWhatsappInstance.php | 151 ++ src/FilamentEvolutionPlugin.php | 53 + src/FilamentEvolutionServiceProvider.php | 51 + src/Http/Controllers/WebhookController.php | 84 + src/Jobs/ProcessWebhookJob.php | 206 ++ src/Jobs/SendMessageJob.php | 121 + src/Livewire/QrCodeDisplay.php | 143 + src/Models/Concerns/HasTenant.php | 93 + src/Models/WhatsappInstance.php | 105 + src/Models/WhatsappMessage.php | 124 + src/Models/WhatsappWebhook.php | 95 + src/Services/EvolutionClient.php | 446 ++++ tests/Feature/WebhookControllerTest.php | 67 + tests/TestCase.php | 42 + tests/Unit/EvolutionClientTest.php | 32 + tests/Unit/InstanceDataTest.php | 58 + tests/Unit/StatusConnectionEnumTest.php | 43 + 62 files changed, 6626 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/filament-evolution.php create mode 100644 database/migrations/create_whatsapp_instances_table.php.stub create mode 100644 database/migrations/create_whatsapp_messages_table.php.stub create mode 100644 database/migrations/create_whatsapp_webhooks_table.php.stub create mode 100644 phpunit.xml create mode 100644 resources/lang/en/enums.php create mode 100644 resources/lang/en/qrcode.php create mode 100644 resources/lang/en/resource.php create mode 100644 resources/lang/pt_BR/enums.php create mode 100644 resources/lang/pt_BR/qrcode.php create mode 100644 resources/lang/pt_BR/resource.php create mode 100644 resources/views/components/qr-code-modal.blade.php create mode 100644 resources/views/filament/pages/connect-whatsapp-instance.blade.php create mode 100644 resources/views/filament/pages/list-whatsapp-instances.blade.php create mode 100644 resources/views/livewire/qr-code-display.blade.php create mode 100644 resources/views/livewire/qr-code-modal.blade.php create mode 100644 routes/api.php create mode 100644 src/Data/ContactData.php create mode 100644 src/Data/InstanceData.php create mode 100644 src/Data/MessageData.php create mode 100644 src/Data/QrCodeData.php create mode 100644 src/Data/Webhooks/ConnectionUpdateData.php create mode 100644 src/Data/Webhooks/MessageUpsertData.php create mode 100644 src/Data/Webhooks/QrCodeUpdatedData.php create mode 100644 src/Database/Migrations/Concerns/HasTenantColumn.php create mode 100644 src/Enums/MessageDirectionEnum.php create mode 100644 src/Enums/MessageStatusEnum.php create mode 100644 src/Enums/MessageTypeEnum.php create mode 100644 src/Enums/StatusConnectionEnum.php create mode 100644 src/Enums/WebhookEventEnum.php create mode 100644 src/Events/InstanceConnected.php create mode 100644 src/Events/InstanceDisconnected.php create mode 100644 src/Events/MessageReceived.php create mode 100644 src/Events/QrCodeUpdated.php create mode 100644 src/Exceptions/EvolutionApiException.php create mode 100644 src/Filament/Resources/WhatsappInstanceResource.php create mode 100644 src/Filament/Resources/WhatsappInstanceResource/Pages/CreateWhatsappInstance.php create mode 100644 src/Filament/Resources/WhatsappInstanceResource/Pages/EditWhatsappInstance.php create mode 100644 src/Filament/Resources/WhatsappInstanceResource/Pages/ListWhatsappInstances.php create mode 100644 src/Filament/Resources/WhatsappInstanceResource/Pages/ViewWhatsappInstance.php create mode 100644 src/FilamentEvolutionPlugin.php create mode 100644 src/FilamentEvolutionServiceProvider.php create mode 100644 src/Http/Controllers/WebhookController.php create mode 100644 src/Jobs/ProcessWebhookJob.php create mode 100644 src/Jobs/SendMessageJob.php create mode 100644 src/Livewire/QrCodeDisplay.php create mode 100644 src/Models/Concerns/HasTenant.php create mode 100644 src/Models/WhatsappInstance.php create mode 100644 src/Models/WhatsappMessage.php create mode 100644 src/Models/WhatsappWebhook.php create mode 100644 src/Services/EvolutionClient.php create mode 100644 tests/Feature/WebhookControllerTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/EvolutionClientTest.php create mode 100644 tests/Unit/InstanceDataTest.php create mode 100644 tests/Unit/StatusConnectionEnumTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..582069b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/vendor/ +/.phpunit.cache/ +/.idea/ +/.vscode/ +.DS_Store +composer.lock +.phpunit.result.cache +coverage/ +.php-cs-fixer.cache +phpstan.neon diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..1068879 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,2340 @@ +# 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 +# .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) + +```php +// 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) + +```php +// 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 + +```php +// 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 + +```php +// 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 + +```php +// 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 + +```php +// 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](https://spatie.be/docs/laravel-data) para tipagem forte e validaΓ§Γ£o. + +### InstanceData + +```php +// 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 + +```php +// 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 + +```php +// 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 + +```php +// 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']); + } +} +``` + +```php +// 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, + ), + ); + } +} +``` + +```php +// 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!** + +```php +// 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` + +```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. + +```php +// 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 + +```php +// 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) + +```php +// 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) + +```php +// 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)); + } + } +} +``` + +```php +// 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 + +```php +// 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!) + +```php +// 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) + +```php +// 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) + +```php +// 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) + +```php +// 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) + +```php +// 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 + +```php +// 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 + +```php +// 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 + +```php +// 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 +``` + +```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.json` do package +- [ ] Criar `FilamentEvolutionServiceProvider.php` +- [ ] Criar `FilamentEvolutionPlugin.php` (Filament Panel Plugin) +- [ ] Criar `config/filament-evolution.php` com todas as configuraΓ§Γ΅es +- [ ] Publicar config via Service Provider + +### Fase 1 - Multi-Tenancy e Infraestrutura Base +- [ ] Criar Traits + - [ ] `src/Models/Concerns/HasTenant.php` + - [ ] `src/Database/Migrations/Concerns/HasTenantColumn.php` +- [ ] Criar Migrations com tenant dinΓ’mico + - [ ] `create_whatsapp_instances_table.php` + - [ ] `create_whatsapp_messages_table.php` + - [ ] `create_whatsapp_webhooks_table.php` +- [ ] Criar Models com HasTenant + - [ ] `WhatsappInstance.php` + - [ ] `WhatsappMessage.php` + - [ ] `WhatsappWebhook.php` +- [ ] Testes de Multi-Tenancy + +### Fase 2 - DTOs (Data Transfer Objects) +- [ ] Instalar `spatie/laravel-data` +- [ ] Criar DTOs (`src/Data/`) + - [ ] `InstanceData.php` + - [ ] `MessageData.php` + - [ ] `QrCodeData.php` + - [ ] `ContactData.php` + - [ ] `Webhook/ConnectionUpdateData.php` + - [ ] `Webhook/QrCodeUpdatedData.php` + - [ ] `Webhook/MessageUpsertData.php` +- [ ] Testes unitΓ‘rios de DTOs + +### Fase 3 - Eventos, Jobs e Listeners +- [ ] Criar Events (`src/Events/`) + - [ ] `InstanceConnected.php` + - [ ] `InstanceDisconnected.php` + - [ ] `MessageReceived.php` + - [ ] `MessageSent.php` + - [ ] `QrCodeUpdated.php` +- [ ] Criar Jobs (`src/Jobs/`) + - [ ] `ProcessWebhookJob.php` + - [ ] `SendMessageJob.php` + - [ ] `SendBulkMessagesJob.php` +- [ ] Criar Listeners (`src/Listeners/`) + - [ ] `UpdateInstanceStatus.php` + - [ ] `LogMessageReceived.php` + - [ ] `NotifyMessageReceived.php` +- [ ] Registrar Events/Listeners no Service Provider +- [ ] Testes de Jobs e Listeners + +### Fase 4 - Enums +- [ ] Criar novos Enums (`src/Enums/`) + - [ ] `StatusConnectionEnum.php` + - [ ] `WebhookEventEnum.php` + - [ ] `MessageTypeEnum.php` + - [ ] `MessageDirectionEnum.php` + - [ ] `MessageStatusEnum.php` +- [ ] Criar Migrations + - [ ] `create_whatsapp_messages_table.php` + - [ ] `create_whatsapp_webhooks_table.php` +- [ ] Criar Models + - [ ] `WhatsappMessage.php` + - [ ] `WhatsappWebhook.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 `WhatsappInstanceResource` no plugin +- [ ] Criar `EvolutionQrCode` Livewire 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 + +1. **SeguranΓ§a do Webhook**: Sempre validar a origem do webhook usando secret/signature +2. **Rate Limiting**: A Evolution API pode ter limites, implementar retry com backoff +3. **Queue**: Webhooks e envios devem ser processados em background +4. **Soft Deletes**: InstΓ’ncias usam soft delete para manter histΓ³rico +5. **Multi-tenancy**: βœ… Suporte completo via `config('filament-evolution.tenancy')` +6. **Config PrΓ³pria**: βœ… Plugin usa `config/filament-evolution.php` (nΓ£o `services.php`) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..face4e4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +All notable changes to `filament-whatsapp-conector` will be documented in this file. + +## [0.1.0] - 2025-12-07 + +### Added + +#### Core Features +- Evolution API v2 integration with full HTTP client +- WhatsApp instance management (Create, Connect, Delete, Fetch, 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, sync history, etc. + +#### Filament Integration +- Filament v4 Resource for WhatsApp instances +- Modern QR Code modal with auto-open after instance creation +- Table actions for Connect, View, and Edit +- Status badges with Filament's native components +- Full translations support (English and Portuguese) + +#### Multi-Tenancy +- Native Filament multi-tenancy support +- Dynamic tenant column configuration +- Automatic query scoping by tenant +- Auto-assignment of tenant on record creation + +#### Architecture +- DTOs with Spatie Laravel Data for type safety +- Laravel Events for extensibility (InstanceConnected, MessageReceived, etc.) +- Background job processing for webhooks and messages +- Configurable webhook events +- Secure credential storage via environment variables + +#### Developer Experience +- Comprehensive configuration file +- Migration stubs with tenancy support +- Livewire components for real-time updates +- PHPStan and Pint ready + +### Configuration Options + +```env +# Required +EVOLUTION_URL=https://your-evolution-api.com +EVOLUTION_API_KEY=your_api_key +EVOLUTION_URL_WEBHOOK=https://your-app.com/api/webhooks/evolution + +# Optional +EVOLUTION_QRCODE_EXPIRES=30 +EVOLUTION_TENANCY_ENABLED=true +EVOLUTION_TENANT_COLUMN=team_id +``` diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ef0db0c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Wallace Martins + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..504e5cc --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# Filament Evolution - WhatsApp Connector + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/wallacemartinss/filament-whatsapp-conector.svg?style=flat-square)](https://packagist.org/packages/wallacemartinss/filament-whatsapp-conector) +[![Total Downloads](https://img.shields.io/packagist/dt/wallacemartinss/filament-whatsapp-conector.svg?style=flat-square)](https://packagist.org/packages/wallacemartinss/filament-whatsapp-conector) + +A Filament v4 plugin for WhatsApp integration using [Evolution API v2](https://doc.evolution-api.com/). + +## Features + +- πŸ”Œ **Easy Integration** - Connect your WhatsApp with Evolution API v2 +- 🏒 **Multi-Tenancy** - Full support for Filament's native multi-tenancy +- πŸ“± **QR Code Connection** - Real-time QR code display with countdown timer +- πŸ“¨ **Webhook Support** - Receive events from Evolution API (messages, connection updates, etc.) +- πŸ” **Secure** - Credentials stored in config/env, never in database +- 🎨 **Filament v4 Native** - Beautiful UI with Filament components and Heroicons +- 🌍 **Translations** - Full i18n support (English and Portuguese included) +- ⚑ **Real-time** - Livewire-powered components with Alpine.js countdown + +## Requirements + +- PHP 8.2+ +- Laravel 11+ +- Filament v4 +- Evolution API v2 instance + +## Installation + +```bash +composer require wallacemartinss/filament-whatsapp-conector +``` + +Publish the config file: + +```bash +php artisan vendor:publish --tag="filament-evolution-config" +``` + +Run the migrations: + +```bash +php artisan migrate +``` + +## Configuration + +Add to your `.env`: + +```env +# Evolution API (Required) +EVOLUTION_URL=https://your-evolution-api.com +EVOLUTION_API_KEY=your_api_key + +# Webhook (Required for receiving events) +EVOLUTION_URL_WEBHOOK=https://your-app.com/api/webhooks/evolution +EVOLUTION_WEBHOOK_ENABLED=true + +# QR Code Settings +EVOLUTION_QRCODE_EXPIRES=30 + +# Instance Defaults (Optional) +EVOLUTION_REJECT_CALL=false +EVOLUTION_MSG_CALL="I can't answer calls right now" +EVOLUTION_GROUPS_IGNORE=false +EVOLUTION_ALWAYS_ONLINE=false +EVOLUTION_READ_MESSAGES=false +EVOLUTION_READ_STATUS=false +EVOLUTION_SYNC_HISTORY=false + +# Multi-Tenancy (Optional) +EVOLUTION_TENANCY_ENABLED=true +EVOLUTION_TENANT_COLUMN=team_id +EVOLUTION_TENANT_TABLE=teams +EVOLUTION_TENANT_MODEL=App\Models\Team +EVOLUTION_TENANT_COLUMN_TYPE=uuid +``` + +## Usage + +### Register the Plugin + +Add to your Filament Panel Provider: + +```php +use WallaceMartinss\FilamentEvolution\FilamentEvolutionPlugin; + +public function panel(Panel $panel): Panel +{ + return $panel + ->plugins([ + FilamentEvolutionPlugin::make(), + ]); +} +``` + +### Creating an Instance + +1. Navigate to **WhatsApp > Instances** +2. Click **New Instance** +3. Fill in the instance name and phone number +4. Configure settings (reject calls, always online, etc.) +5. Click **Save** - the QR Code modal will open automatically +6. Scan the QR Code with your WhatsApp + +### Instance Settings + +When creating an instance, you can configure: + +| Setting | Description | +|---------|-------------| +| **Reject Calls** | Automatically reject incoming calls | +| **Message on Call** | Message sent when rejecting calls | +| **Ignore Groups** | Don't process messages from groups | +| **Always Online** | Keep WhatsApp status as online | +| **Read Messages** | Automatically mark messages as read | +| **Read Status** | Automatically view status updates | +| **Sync Full History** | Sync all message history on connection | + +### Webhook Events + +The following events are sent to your webhook: + +- `APPLICATION_STARTUP` - API started +- `QRCODE_UPDATED` - New QR code generated +- `CONNECTION_UPDATE` - Connection status changed +- `NEW_TOKEN` - New authentication token +- `SEND_MESSAGE` - Message sent +- `PRESENCE_UPDATE` - Contact online/offline +- `MESSAGES_UPSERT` - New message received + +## Multi-Tenancy + +The plugin supports Filament's native multi-tenancy. When enabled: + +- All tables include the tenant foreign key +- Models automatically scope queries by tenant +- Records are auto-assigned to current tenant on creation + +### Enable Multi-Tenancy + +```env +EVOLUTION_TENANCY_ENABLED=true +EVOLUTION_TENANT_COLUMN=team_id +EVOLUTION_TENANT_TABLE=teams +EVOLUTION_TENANT_MODEL=App\Models\Team +``` + +## API + +### Evolution Client + +You can use the Evolution client directly: + +```php +use WallaceMartinss\FilamentEvolution\Services\EvolutionClient; + +$client = app(EvolutionClient::class); + +// Create instance +$response = $client->createInstance('my-instance', '5511999999999', true, [ + 'reject_call' => true, + 'always_online' => true, +]); + +// Get connection state +$state = $client->getConnectionState('my-instance'); + +// Send text message +$client->sendText('my-instance', '5511999999999', 'Hello World!'); +``` + +## Testing + +```bash +composer test +``` + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Security Vulnerabilities + +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. + +## Credits + +- [Wallace Martins](https://github.com/wallacemartinss) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. +- [All Contributors](../../contributors) + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3ab9db4 --- /dev/null +++ b/composer.json @@ -0,0 +1,74 @@ +{ + "name": "wallacemartinss/filament-whatsapp-conector", + "description": "Filament plugin for WhatsApp integration with Evolution API v2", + "keywords": [ + "laravel", + "filament", + "whatsapp", + "evolution-api", + "baileys", + "multi-tenancy" + ], + "homepage": "https://github.com/wallacemartinss/filament-whatsapp-conector", + "license": "MIT", + "authors": [ + { + "name": "Wallace Martins", + "email": "wallacemartinss@gmail.com", + "role": "Developer" + } + ], + "require": { + "php": "^8.2", + "filament/filament": "^3.0|^4.0", + "illuminate/contracts": "^11.0|^12.0", + "spatie/laravel-data": "^4.0", + "spatie/laravel-package-tools": "^1.16" + }, + "require-dev": { + "laravel/pint": "^1.14", + "nunomaduro/collision": "^8.1", + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-arch": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0" + }, + "autoload": { + "psr-4": { + "WallaceMartinss\\FilamentEvolution\\": "src/", + "WallaceMartinss\\FilamentEvolution\\Database\\Factories\\": "database/factories/" + } + }, + "autoload-dev": { + "psr-4": { + "WallaceMartinss\\FilamentEvolution\\Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": "@composer run prepare", + "prepare": "vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "WallaceMartinss\\FilamentEvolution\\FilamentEvolutionServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/filament-evolution.php b/config/filament-evolution.php new file mode 100644 index 0000000..2b10a96 --- /dev/null +++ b/config/filament-evolution.php @@ -0,0 +1,144 @@ + [ + 'base_url' => env('EVOLUTION_URL', env('EVOLUTION_API_URL', 'http://localhost:8080')), + 'api_key' => env('EVOLUTION_API_KEY'), + 'timeout' => env('EVOLUTION_TIMEOUT', 30), + 'retry' => [ + 'times' => 3, + 'sleep' => 100, // ms + ], + ], + + /* + |-------------------------------------------------------------------------- + | Webhook Configuration + |-------------------------------------------------------------------------- + | + | The secret is used to validate that webhooks come from Evolution API. + | + */ + 'webhook' => [ + 'enabled' => env('EVOLUTION_WEBHOOK_ENABLED', true), + 'url' => env('EVOLUTION_URL_WEBHOOK'), + 'path' => env('EVOLUTION_WEBHOOK_PATH', 'api/evolution/webhook'), + 'secret' => env('EVOLUTION_WEBHOOK_SECRET'), + 'verify_signature' => env('EVOLUTION_VERIFY_SIGNATURE', true), + 'by_events' => env('EVOLUTION_WEBHOOK_BY_EVENTS', false), + 'base64' => env('EVOLUTION_WEBHOOK_BASE64', false), + 'queue' => env('EVOLUTION_WEBHOOK_QUEUE', 'default'), + 'events' => [ + 'APPLICATION_STARTUP', + 'QRCODE_UPDATED', + 'CONNECTION_UPDATE', + 'NEW_TOKEN', + 'SEND_MESSAGE', + 'PRESENCE_UPDATE', + 'MESSAGES_UPSERT', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Instance Defaults + |-------------------------------------------------------------------------- + | + | Default settings for new WhatsApp instances. + | + */ + 'instance' => [ + 'integration' => env('EVOLUTION_INTEGRATION', 'WHATSAPP-BAILEYS'), + 'qrcode_expires_in' => env('EVOLUTION_QRCODE_EXPIRES', 30), // seconds + '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 + |-------------------------------------------------------------------------- + | + | UI settings for Filament panel integration. + | Labels and translations are handled via lang files. + | + */ + 'filament' => [ + 'navigation_sort' => 100, + ], + + /* + |-------------------------------------------------------------------------- + | Cache Configuration + |-------------------------------------------------------------------------- + */ + 'cache' => [ + 'enabled' => env('EVOLUTION_CACHE_ENABLED', true), + 'ttl' => env('EVOLUTION_CACHE_TTL', 60), // seconds + 'prefix' => 'evolution_', + ], + + /* + |-------------------------------------------------------------------------- + | Queue Configuration + |-------------------------------------------------------------------------- + */ + 'queue' => [ + 'connection' => env('EVOLUTION_QUEUE_CONNECTION'), + 'messages' => env('EVOLUTION_QUEUE_MESSAGES', 'whatsapp'), + 'webhooks' => env('EVOLUTION_QUEUE_WEBHOOKS', 'default'), + ], + + /* + |-------------------------------------------------------------------------- + | Logging + |-------------------------------------------------------------------------- + */ + 'logging' => [ + 'enabled' => env('EVOLUTION_LOGGING', true), + 'channel' => env('EVOLUTION_LOG_CHANNEL'), + 'log_payloads' => env('EVOLUTION_LOG_PAYLOADS', false), + ], + + /* + |-------------------------------------------------------------------------- + | Multi-Tenancy Configuration + |-------------------------------------------------------------------------- + | + | Configuration for Filament multi-tenancy support. + | When enabled, migrations will include the tenant foreign key + | and models will automatically scope queries by tenant. + | + */ + 'tenancy' => [ + 'enabled' => env('EVOLUTION_TENANCY_ENABLED', false), + + // Tenant column name (e.g., 'team_id', 'company_id', 'tenant_id') + 'column' => env('EVOLUTION_TENANT_COLUMN', 'team_id'), + + // Tenant table for foreign key (e.g., 'teams', 'companies', 'tenants') + 'table' => env('EVOLUTION_TENANT_TABLE', 'teams'), + + // Tenant model class (e.g., App\Models\Team::class) + 'model' => env('EVOLUTION_TENANT_MODEL', 'App\\Models\\Team'), + + // Tenant column type ('uuid' or 'id') + 'column_type' => env('EVOLUTION_TENANT_COLUMN_TYPE', 'uuid'), + ], +]; diff --git a/database/migrations/create_whatsapp_instances_table.php.stub b/database/migrations/create_whatsapp_instances_table.php.stub new file mode 100644 index 0000000..7fd7472 --- /dev/null +++ b/database/migrations/create_whatsapp_instances_table.php.stub @@ -0,0 +1,49 @@ +uuid('id')->primary(); + + // Dynamic tenant column based on 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(false); + $table->string('msg_call')->nullable(); + $table->boolean('groups_ignore')->default(false); + $table->boolean('always_online')->default(false); + $table->boolean('read_messages')->default(false); + $table->boolean('read_status')->default(false); + $table->boolean('sync_full_history')->default(false); + $table->string('count')->nullable(); + $table->string('pairing_code')->nullable(); + $table->longText('qr_code')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('name'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_instances'); + } +}; diff --git a/database/migrations/create_whatsapp_messages_table.php.stub b/database/migrations/create_whatsapp_messages_table.php.stub new file mode 100644 index 0000000..7e3e419 --- /dev/null +++ b/database/migrations/create_whatsapp_messages_table.php.stub @@ -0,0 +1,51 @@ +uuid('id')->primary(); + + // Dynamic tenant column based on config + $this->addTenantColumn($table); + + $table->foreignUuid('instance_id') + ->constrained('whatsapp_instances') + ->cascadeOnDelete(); + + $table->string('message_id')->index(); + $table->string('remote_jid'); + $table->string('phone'); + $table->string('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']); + $table->index('direction'); + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_messages'); + } +}; diff --git a/database/migrations/create_whatsapp_webhooks_table.php.stub b/database/migrations/create_whatsapp_webhooks_table.php.stub new file mode 100644 index 0000000..7d793a5 --- /dev/null +++ b/database/migrations/create_whatsapp_webhooks_table.php.stub @@ -0,0 +1,43 @@ +id(); + + // Dynamic tenant column based on config + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('whatsapp_webhooks'); + } +}; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9396818 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,25 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + ./src + + + diff --git a/resources/lang/en/enums.php b/resources/lang/en/enums.php new file mode 100644 index 0000000..a10f9fc --- /dev/null +++ b/resources/lang/en/enums.php @@ -0,0 +1,51 @@ + [ + 'open' => 'Connected', + 'connecting' => 'Connecting', + 'close' => 'Disconnected', + 'refused' => 'Refused', + ], + + 'message_type' => [ + 'text' => 'Text', + 'image' => 'Image', + 'audio' => 'Audio', + 'video' => 'Video', + 'document' => 'Document', + 'location' => 'Location', + 'contact' => 'Contact', + 'sticker' => 'Sticker', + ], + + 'message_direction' => [ + 'incoming' => 'Incoming', + 'outgoing' => 'Outgoing', + ], + + 'message_status' => [ + 'pending' => 'Pending', + 'sent' => 'Sent', + 'delivered' => 'Delivered', + 'read' => 'Read', + 'failed' => 'Failed', + ], + + 'webhook_event' => [ + 'application_startup' => 'Application Startup', + 'qrcode_updated' => 'QR Code Updated', + 'connection_update' => 'Connection Update', + 'messages_set' => 'Messages Set', + 'messages_upsert' => 'Message Received', + 'messages_update' => 'Message Updated', + 'messages_delete' => 'Message Deleted', + 'send_message' => 'Message Sent', + 'presence_update' => 'Presence Update', + 'new_token' => 'New Token', + 'logout_instance' => 'Instance Logout', + 'remove_instance' => 'Instance Removed', + ], +]; diff --git a/resources/lang/en/qrcode.php b/resources/lang/en/qrcode.php new file mode 100644 index 0000000..f2c235d --- /dev/null +++ b/resources/lang/en/qrcode.php @@ -0,0 +1,21 @@ + 'Connect :instance', + 'loading' => 'Loading...', + 'connected' => 'Connected', + 'waiting_scan' => 'Waiting for scan', + 'error' => 'Connection error', + 'expires_in' => 'Expires in', + 'connected_title' => 'WhatsApp Connected!', + 'connected_description' => 'Your WhatsApp instance is connected and ready to send and receive messages.', + 'error_title' => 'Connection Error', + 'try_again' => 'Try Again', + 'scan_instructions' => 'Open WhatsApp on your phone, go to Settings > Linked Devices > Link a Device, and scan this QR code.', + 'or_use_code' => 'Or enter this code on your phone:', + 'copied' => 'Copied!', + 'refresh' => 'Refresh QR Code', + 'generate' => 'Generate QR Code', +]; diff --git a/resources/lang/en/resource.php b/resources/lang/en/resource.php new file mode 100644 index 0000000..e22c5e0 --- /dev/null +++ b/resources/lang/en/resource.php @@ -0,0 +1,60 @@ + 'Instances', + 'navigation_group' => 'WhatsApp', + 'model_label' => 'Instance', + 'plural_model_label' => 'Instances', + + 'sections' => [ + 'instance_info' => 'Instance Information', + 'settings' => 'Settings', + 'connection' => 'Connection', + ], + + 'fields' => [ + 'name' => 'Instance Name', + 'name_helper' => 'A unique name to identify this instance', + 'number' => 'Phone Number', + 'number_helper' => 'The WhatsApp phone number with country code', + 'status' => 'Status', + 'profile_picture' => 'Profile Picture', + 'reject_call' => 'Reject Calls', + 'reject_call_helper' => 'Automatically reject incoming calls', + 'msg_call' => 'Rejection Message', + 'msg_call_helper' => 'Message sent when rejecting a call', + 'groups_ignore' => 'Ignore Groups', + 'groups_ignore_helper' => 'Do not process messages from groups', + 'always_online' => 'Always Online', + 'always_online_helper' => 'Keep the status as online', + 'read_messages' => 'Read Messages', + 'read_messages_helper' => 'Automatically mark messages as read', + 'read_status' => 'Read Status', + 'read_status_helper' => 'Automatically view status updates', + 'sync_full_history' => 'Sync Full History', + 'sync_full_history_helper' => 'Synchronize all message history on connection', + 'created_at' => 'Created At', + 'updated_at' => 'Updated At', + ], + + 'actions' => [ + 'connect' => 'Connect', + 'disconnect' => 'Disconnect', + 'delete' => 'Delete', + 'refresh' => 'Refresh', + 'view_qrcode' => 'View QR Code', + 'close' => 'Close', + 'back' => 'Back to List', + ], + + 'messages' => [ + 'created' => 'Instance created successfully', + 'updated' => 'Instance updated successfully', + 'deleted' => 'Instance deleted successfully', + 'connected' => 'Instance connected successfully', + 'disconnected' => 'Instance disconnected successfully', + 'connection_failed' => 'Failed to connect instance', + ], +]; diff --git a/resources/lang/pt_BR/enums.php b/resources/lang/pt_BR/enums.php new file mode 100644 index 0000000..5b07fe6 --- /dev/null +++ b/resources/lang/pt_BR/enums.php @@ -0,0 +1,51 @@ + [ + 'open' => 'Conectado', + 'connecting' => 'Conectando', + 'close' => 'Desconectado', + 'refused' => 'Recusado', + ], + + 'message_type' => [ + 'text' => 'Texto', + 'image' => 'Imagem', + 'audio' => 'Áudio', + 'video' => 'VΓ­deo', + 'document' => 'Documento', + 'location' => 'LocalizaΓ§Γ£o', + 'contact' => 'Contato', + 'sticker' => 'Figurinha', + ], + + 'message_direction' => [ + 'incoming' => 'Recebida', + 'outgoing' => 'Enviada', + ], + + 'message_status' => [ + 'pending' => 'Pendente', + 'sent' => 'Enviado', + 'delivered' => 'Entregue', + 'read' => 'Lido', + 'failed' => 'Falhou', + ], + + 'webhook_event' => [ + 'application_startup' => 'InicializaΓ§Γ£o do Aplicativo', + 'qrcode_updated' => 'QR Code Atualizado', + 'connection_update' => 'AtualizaΓ§Γ£o de ConexΓ£o', + 'messages_set' => 'Mensagens Definidas', + 'messages_upsert' => 'Mensagem Recebida', + 'messages_update' => 'Mensagem Atualizada', + 'messages_delete' => 'Mensagem ExcluΓ­da', + 'send_message' => 'Mensagem Enviada', + 'presence_update' => 'AtualizaΓ§Γ£o de PresenΓ§a', + 'new_token' => 'Novo Token', + 'logout_instance' => 'Logout da InstΓ’ncia', + 'remove_instance' => 'InstΓ’ncia Removida', + ], +]; diff --git a/resources/lang/pt_BR/qrcode.php b/resources/lang/pt_BR/qrcode.php new file mode 100644 index 0000000..e99a240 --- /dev/null +++ b/resources/lang/pt_BR/qrcode.php @@ -0,0 +1,21 @@ + 'Conectar :instance', + 'loading' => 'Carregando...', + 'connected' => 'Conectado', + 'waiting_scan' => 'Aguardando leitura', + 'error' => 'Erro de conexΓ£o', + 'expires_in' => 'Expira em', + 'connected_title' => 'WhatsApp Conectado!', + 'connected_description' => 'Sua instΓ’ncia do WhatsApp estΓ‘ conectada e pronta para enviar e receber mensagens.', + 'error_title' => 'Erro de ConexΓ£o', + 'try_again' => 'Tentar Novamente', + 'scan_instructions' => 'Abra o WhatsApp no seu celular, vΓ‘ em ConfiguraΓ§Γ΅es > Dispositivos Conectados > Conectar um Dispositivo, e escaneie este QR code.', + 'or_use_code' => 'Ou digite este cΓ³digo no seu celular:', + 'copied' => 'Copiado!', + 'refresh' => 'Atualizar QR Code', + 'generate' => 'Gerar QR Code', +]; diff --git a/resources/lang/pt_BR/resource.php b/resources/lang/pt_BR/resource.php new file mode 100644 index 0000000..06a0efa --- /dev/null +++ b/resources/lang/pt_BR/resource.php @@ -0,0 +1,60 @@ + 'InstΓ’ncias', + 'navigation_group' => 'WhatsApp', + 'model_label' => 'InstΓ’ncia', + 'plural_model_label' => 'InstΓ’ncias', + + 'sections' => [ + 'instance_info' => 'InformaΓ§Γ΅es da InstΓ’ncia', + 'settings' => 'ConfiguraΓ§Γ΅es', + 'connection' => 'ConexΓ£o', + ], + + 'fields' => [ + 'name' => 'Nome da InstΓ’ncia', + 'name_helper' => 'Um nome ΓΊnico para identificar esta instΓ’ncia', + 'number' => 'NΓΊmero de Telefone', + 'number_helper' => 'O nΓΊmero do WhatsApp com cΓ³digo do paΓ­s', + 'status' => 'Status', + 'profile_picture' => 'Foto de Perfil', + 'reject_call' => 'Rejeitar Chamadas', + 'reject_call_helper' => 'Rejeitar automaticamente chamadas recebidas', + 'msg_call' => 'Mensagem de RejeiΓ§Γ£o', + 'msg_call_helper' => 'Mensagem enviada ao rejeitar uma chamada', + 'groups_ignore' => 'Ignorar Grupos', + 'groups_ignore_helper' => 'NΓ£o processar mensagens de grupos', + 'always_online' => 'Sempre Online', + 'always_online_helper' => 'Manter o status como online', + 'read_messages' => 'Ler Mensagens', + 'read_messages_helper' => 'Marcar mensagens como lidas automaticamente', + 'read_status' => 'Ler Status', + 'read_status_helper' => 'Visualizar atualizaΓ§Γ΅es de status automaticamente', + 'sync_full_history' => 'Sincronizar HistΓ³rico Completo', + 'sync_full_history_helper' => 'Sincronizar todo o histΓ³rico de mensagens ao conectar', + 'created_at' => 'Criado em', + 'updated_at' => 'Atualizado em', + ], + + 'actions' => [ + 'connect' => 'Conectar', + 'disconnect' => 'Desconectar', + 'delete' => 'Excluir', + 'refresh' => 'Atualizar', + 'view_qrcode' => 'Ver QR Code', + 'close' => 'Fechar', + 'back' => 'Voltar para Lista', + ], + + 'messages' => [ + 'created' => 'InstΓ’ncia criada com sucesso', + 'updated' => 'InstΓ’ncia atualizada com sucesso', + 'deleted' => 'InstΓ’ncia excluΓ­da com sucesso', + 'connected' => 'InstΓ’ncia conectada com sucesso', + 'disconnected' => 'InstΓ’ncia desconectada com sucesso', + 'connection_failed' => 'Falha ao conectar a instΓ’ncia', + ], +]; diff --git a/resources/views/components/qr-code-modal.blade.php b/resources/views/components/qr-code-modal.blade.php new file mode 100644 index 0000000..f94d0c8 --- /dev/null +++ b/resources/views/components/qr-code-modal.blade.php @@ -0,0 +1,3 @@ +
+ @livewire('filament-evolution::qr-code-display', ['instance' => $instance]) +
diff --git a/resources/views/filament/pages/connect-whatsapp-instance.blade.php b/resources/views/filament/pages/connect-whatsapp-instance.blade.php new file mode 100644 index 0000000..4129059 --- /dev/null +++ b/resources/views/filament/pages/connect-whatsapp-instance.blade.php @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/resources/views/filament/pages/list-whatsapp-instances.blade.php b/resources/views/filament/pages/list-whatsapp-instances.blade.php new file mode 100644 index 0000000..1119979 --- /dev/null +++ b/resources/views/filament/pages/list-whatsapp-instances.blade.php @@ -0,0 +1,19 @@ + + {{ $this->table }} + + {{-- QR Code Modal --}} + + + @if($this->connectInstance) + {{ __('filament-evolution::qrcode.modal_title', ['instance' => $this->connectInstance->name]) }} + @endif + + + @if($this->connectInstance && $this->showQrCodeModal) + + @endif + + diff --git a/resources/views/livewire/qr-code-display.blade.php b/resources/views/livewire/qr-code-display.blade.php new file mode 100644 index 0000000..d8480dd --- /dev/null +++ b/resources/views/livewire/qr-code-display.blade.php @@ -0,0 +1,162 @@ +
+ {{-- Loading State --}} + @if($isLoading) +
+
+
+
+
+

+ {{ __('filament-evolution::qrcode.loading') }} +

+
+ + @elseif($isConnected) + {{-- Connected State --}} +
+
+
+ + + +
+
+

+ {{ __('filament-evolution::qrcode.connected_title') }} +

+

+ {{ __('filament-evolution::qrcode.connected_description') }} +

+
+ + @elseif($error) + {{-- Error State --}} +
+
+ + + +
+

+ {{ __('filament-evolution::qrcode.error_title') }} +

+

+ {{ $error }} +

+ +
+ + @elseif($qrCode) + {{-- QR Code Display --}} +
+ {{-- Status --}} +
+ + + + + + {{ __('filament-evolution::qrcode.waiting_scan') }} + +
+ + {{-- QR Code --}} +
+ QR Code +
+ + {{-- Countdown Timer with Alpine --}} +
+ + + + + {{ __('filament-evolution::qrcode.expires_in') }}: + + +
+ + {{-- Instructions --}} +

+ {{ __('filament-evolution::qrcode.scan_instructions') }} +

+ + {{-- Pairing Code --}} + @if($pairingCode) +
+

+ {{ __('filament-evolution::qrcode.or_use_code') }} +

+
+ + {{ $pairingCode }} + +
+
+ @endif + + {{-- Refresh Button --}} + +
+ + @else + {{-- No QR Code - Generate --}} +
+ +
+ @endif +
diff --git a/resources/views/livewire/qr-code-modal.blade.php b/resources/views/livewire/qr-code-modal.blade.php new file mode 100644 index 0000000..3da8075 --- /dev/null +++ b/resources/views/livewire/qr-code-modal.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..4740460 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,11 @@ +name('filament-evolution.webhook') + ->withoutMiddleware(['auth', 'web']); diff --git a/src/Data/ContactData.php b/src/Data/ContactData.php new file mode 100644 index 0000000..cf02191 --- /dev/null +++ b/src/Data/ContactData.php @@ -0,0 +1,39 @@ +jid, '@g.us'); + } +} diff --git a/src/Data/InstanceData.php b/src/Data/InstanceData.php new file mode 100644 index 0000000..67b85bd --- /dev/null +++ b/src/Data/InstanceData.php @@ -0,0 +1,66 @@ + $this->instanceName, + 'qrcode' => $this->qrcode, + 'integration' => 'WHATSAPP-BAILEYS', + ]; + + if ($this->number) { + $payload['number'] = $this->number; + } + + $settings = array_filter([ + 'reject_call' => $this->rejectCall, + 'msg_call' => $this->msgCall, + 'groups_ignore' => $this->groupsIgnore, + 'always_online' => $this->alwaysOnline, + 'read_messages' => $this->readMessages, + 'read_status' => $this->readStatus, + 'sync_full_history' => $this->syncFullHistory, + ], fn ($value) => $value !== null && $value !== false && $value !== ''); + + if (! empty($settings)) { + $payload = array_merge($payload, $settings); + } + + if ($this->webhook) { + $payload['webhook'] = $this->webhook; + } + + return $payload; + } +} diff --git a/src/Data/MessageData.php b/src/Data/MessageData.php new file mode 100644 index 0000000..d0a166f --- /dev/null +++ b/src/Data/MessageData.php @@ -0,0 +1,94 @@ +type, [ + MessageTypeEnum::IMAGE, + MessageTypeEnum::AUDIO, + MessageTypeEnum::VIDEO, + MessageTypeEnum::DOCUMENT, + ]); + } +} diff --git a/src/Data/QrCodeData.php b/src/Data/QrCodeData.php new file mode 100644 index 0000000..21a2086 --- /dev/null +++ b/src/Data/QrCodeData.php @@ -0,0 +1,37 @@ +base64) || ! empty($this->code); + } + + public function hasPairingCode(): bool + { + return ! empty($this->pairingCode); + } +} diff --git a/src/Data/Webhooks/ConnectionUpdateData.php b/src/Data/Webhooks/ConnectionUpdateData.php new file mode 100644 index 0000000..7c96b25 --- /dev/null +++ b/src/Data/Webhooks/ConnectionUpdateData.php @@ -0,0 +1,43 @@ + StatusConnectionEnum::OPEN, + 'connecting' => StatusConnectionEnum::CONNECTING, + 'close', 'closed', 'disconnected' => StatusConnectionEnum::CLOSE, + default => StatusConnectionEnum::REFUSED, + }; + } + + public function isConnected(): bool + { + return $this->status === StatusConnectionEnum::OPEN; + } +} diff --git a/src/Data/Webhooks/MessageUpsertData.php b/src/Data/Webhooks/MessageUpsertData.php new file mode 100644 index 0000000..98837fe --- /dev/null +++ b/src/Data/Webhooks/MessageUpsertData.php @@ -0,0 +1,43 @@ +base64) || ! empty($this->code); + } +} diff --git a/src/Database/Migrations/Concerns/HasTenantColumn.php b/src/Database/Migrations/Concerns/HasTenantColumn.php new file mode 100644 index 0000000..7c1e204 --- /dev/null +++ b/src/Database/Migrations/Concerns/HasTenantColumn.php @@ -0,0 +1,56 @@ +foreignUuid($column) + ->constrained($tenantTable) + ->cascadeOnDelete(); + } else { + $table->foreignId($column) + ->constrained($tenantTable) + ->cascadeOnDelete(); + } + + $table->index($column); + } + + /** + * Check if tenancy is enabled. + */ + protected function hasTenancy(): bool + { + return config('filament-evolution.tenancy.enabled', false); + } + + /** + * Get the tenant column name. + */ + protected function getTenantColumn(): ?string + { + if (! $this->hasTenancy()) { + return null; + } + + return config('filament-evolution.tenancy.column', 'team_id'); + } +} diff --git a/src/Enums/MessageDirectionEnum.php b/src/Enums/MessageDirectionEnum.php new file mode 100644 index 0000000..8729f79 --- /dev/null +++ b/src/Enums/MessageDirectionEnum.php @@ -0,0 +1,49 @@ + __('filament-evolution::enums.message_direction.incoming'), + self::OUTGOING => __('filament-evolution::enums.message_direction.outgoing'), + }; + } + + 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', + }; + } + + public function isIncoming(): bool + { + return $this === self::INCOMING; + } + + public function isOutgoing(): bool + { + return $this === self::OUTGOING; + } +} diff --git a/src/Enums/MessageStatusEnum.php b/src/Enums/MessageStatusEnum.php new file mode 100644 index 0000000..e8d880a --- /dev/null +++ b/src/Enums/MessageStatusEnum.php @@ -0,0 +1,66 @@ + __('filament-evolution::enums.message_status.pending'), + self::SENT => __('filament-evolution::enums.message_status.sent'), + self::DELIVERED => __('filament-evolution::enums.message_status.delivered'), + self::READ => __('filament-evolution::enums.message_status.read'), + self::FAILED => __('filament-evolution::enums.message_status.failed'), + }; + } + + 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-badge', + self::READ => 'heroicon-o-eye', + self::FAILED => 'heroicon-o-x-circle', + }; + } + + public function isFinal(): bool + { + return in_array($this, [self::READ, self::FAILED], true); + } + + public function isSuccess(): bool + { + return in_array($this, [self::SENT, self::DELIVERED, self::READ], true); + } + + public function isFailed(): bool + { + return $this === self::FAILED; + } +} diff --git a/src/Enums/MessageTypeEnum.php b/src/Enums/MessageTypeEnum.php new file mode 100644 index 0000000..3a13820 --- /dev/null +++ b/src/Enums/MessageTypeEnum.php @@ -0,0 +1,73 @@ + __('filament-evolution::enums.message_type.text'), + self::IMAGE => __('filament-evolution::enums.message_type.image'), + self::AUDIO => __('filament-evolution::enums.message_type.audio'), + self::VIDEO => __('filament-evolution::enums.message_type.video'), + self::DOCUMENT => __('filament-evolution::enums.message_type.document'), + self::LOCATION => __('filament-evolution::enums.message_type.location'), + self::CONTACT => __('filament-evolution::enums.message_type.contact'), + self::STICKER => __('filament-evolution::enums.message_type.sticker'), + }; + } + + 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 => 'gray', + 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', + }; + } + + public function isMedia(): bool + { + return in_array($this, [self::IMAGE, self::AUDIO, self::VIDEO, self::DOCUMENT], true); + } + + public function isText(): bool + { + return $this === self::TEXT; + } +} diff --git a/src/Enums/StatusConnectionEnum.php b/src/Enums/StatusConnectionEnum.php new file mode 100644 index 0000000..7a8a441 --- /dev/null +++ b/src/Enums/StatusConnectionEnum.php @@ -0,0 +1,62 @@ + __('filament-evolution::enums.status_connection.open'), + self::CONNECTING => __('filament-evolution::enums.status_connection.connecting'), + self::CLOSE => __('filament-evolution::enums.status_connection.close'), + self::REFUSED => __('filament-evolution::enums.status_connection.refused'), + }; + } + + public function getColor(): string|array|null + { + return match ($this) { + self::OPEN => 'success', + self::CONNECTING => 'warning', + self::CLOSE => 'danger', + self::REFUSED => 'danger', + }; + } + + public function getIcon(): ?string + { + return match ($this) { + self::OPEN => 'heroicon-o-check-circle', + self::CONNECTING => 'heroicon-o-arrow-path', + self::CLOSE => 'heroicon-o-x-circle', + self::REFUSED => 'heroicon-o-no-symbol', + }; + } + + public function isConnected(): bool + { + return $this === self::OPEN; + } + + public function isDisconnected(): bool + { + return in_array($this, [self::CLOSE, self::REFUSED], true); + } + + public function isConnecting(): bool + { + return $this === self::CONNECTING; + } +} diff --git a/src/Enums/WebhookEventEnum.php b/src/Enums/WebhookEventEnum.php new file mode 100644 index 0000000..bf94199 --- /dev/null +++ b/src/Enums/WebhookEventEnum.php @@ -0,0 +1,77 @@ + __('filament-evolution::enums.webhook_event.application_startup'), + self::QRCODE_UPDATED => __('filament-evolution::enums.webhook_event.qrcode_updated'), + self::CONNECTION_UPDATE => __('filament-evolution::enums.webhook_event.connection_update'), + self::MESSAGES_SET => __('filament-evolution::enums.webhook_event.messages_set'), + self::MESSAGES_UPSERT => __('filament-evolution::enums.webhook_event.messages_upsert'), + self::MESSAGES_UPDATE => __('filament-evolution::enums.webhook_event.messages_update'), + self::MESSAGES_DELETE => __('filament-evolution::enums.webhook_event.messages_delete'), + self::SEND_MESSAGE => __('filament-evolution::enums.webhook_event.send_message'), + self::PRESENCE_UPDATE => __('filament-evolution::enums.webhook_event.presence_update'), + self::NEW_TOKEN => __('filament-evolution::enums.webhook_event.new_token'), + self::LOGOUT_INSTANCE => __('filament-evolution::enums.webhook_event.logout_instance'), + self::REMOVE_INSTANCE => __('filament-evolution::enums.webhook_event.remove_instance'), + }; + } + + public function shouldProcess(): bool + { + return in_array($this, [ + self::QRCODE_UPDATED, + self::CONNECTION_UPDATE, + self::MESSAGES_UPSERT, + self::MESSAGES_UPDATE, + self::SEND_MESSAGE, + ], true); + } + + public function isConnectionEvent(): bool + { + return in_array($this, [ + self::CONNECTION_UPDATE, + self::LOGOUT_INSTANCE, + self::REMOVE_INSTANCE, + ], true); + } + + public function isMessageEvent(): bool + { + return in_array($this, [ + self::MESSAGES_SET, + self::MESSAGES_UPSERT, + self::MESSAGES_UPDATE, + self::MESSAGES_DELETE, + self::SEND_MESSAGE, + ], true); + } + + public function isQrCodeEvent(): bool + { + return $this === self::QRCODE_UPDATED; + } +} diff --git a/src/Events/InstanceConnected.php b/src/Events/InstanceConnected.php new file mode 100644 index 0000000..4ffaf17 --- /dev/null +++ b/src/Events/InstanceConnected.php @@ -0,0 +1,21 @@ +schema([ + Tabs::make('Instance') + ->tabs([ + Tabs\Tab::make(__('filament-evolution::resource.sections.instance_info')) + ->icon(Heroicon::InformationCircle) + ->schema([ + Section::make() + ->schema([ + TextInput::make('name') + ->label(__('filament-evolution::resource.fields.name')) + ->helperText(__('filament-evolution::resource.fields.name_helper')) + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->columnSpan(1), + + TextInput::make('number') + ->label(__('filament-evolution::resource.fields.number')) + ->helperText(__('filament-evolution::resource.fields.number_helper')) + ->required() + ->tel() + ->maxLength(20) + ->columnSpan(1), + ]) + ->columns(2), + ]), + + Tabs\Tab::make(__('filament-evolution::resource.sections.settings')) + ->icon(Heroicon::Cog6Tooth) + ->schema([ + Section::make() + ->schema([ + Toggle::make('reject_call') + ->label(__('filament-evolution::resource.fields.reject_call')) + ->helperText(__('filament-evolution::resource.fields.reject_call_helper')) + ->default(config('filament-evolution.instance.reject_call', false)), + + TextInput::make('msg_call') + ->label(__('filament-evolution::resource.fields.msg_call')) + ->helperText(__('filament-evolution::resource.fields.msg_call_helper')) + ->maxLength(255) + ->default(config('filament-evolution.instance.msg_call', '')), + + Toggle::make('groups_ignore') + ->label(__('filament-evolution::resource.fields.groups_ignore')) + ->helperText(__('filament-evolution::resource.fields.groups_ignore_helper')) + ->default(config('filament-evolution.instance.groups_ignore', false)), + + Toggle::make('always_online') + ->label(__('filament-evolution::resource.fields.always_online')) + ->helperText(__('filament-evolution::resource.fields.always_online_helper')) + ->default(config('filament-evolution.instance.always_online', false)), + + Toggle::make('read_messages') + ->label(__('filament-evolution::resource.fields.read_messages')) + ->helperText(__('filament-evolution::resource.fields.read_messages_helper')) + ->default(config('filament-evolution.instance.read_messages', false)), + + Toggle::make('read_status') + ->label(__('filament-evolution::resource.fields.read_status')) + ->helperText(__('filament-evolution::resource.fields.read_status_helper')) + ->default(config('filament-evolution.instance.read_status', false)), + + Toggle::make('sync_full_history') + ->label(__('filament-evolution::resource.fields.sync_full_history')) + ->helperText(__('filament-evolution::resource.fields.sync_full_history_helper')) + ->default(config('filament-evolution.instance.sync_full_history', false)), + ]) + ->columns(2), + ]), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + ImageColumn::make('profile_picture_url') + ->label('') + ->circular() + ->defaultImageUrl(fn () => 'https://ui-avatars.com/api/?name=WA&color=7F9CF5&background=EBF4FF'), + + TextColumn::make('name') + ->label(__('filament-evolution::resource.fields.name')) + ->searchable() + ->sortable(), + + TextColumn::make('number') + ->label(__('filament-evolution::resource.fields.number')) + ->searchable(), + + TextColumn::make('status') + ->label(__('filament-evolution::resource.fields.status')) + ->badge() + ->sortable(), + + TextColumn::make('created_at') + ->label(__('filament-evolution::resource.fields.created_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + + TextColumn::make('updated_at') + ->label(__('filament-evolution::resource.fields.updated_at')) + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->options(StatusConnectionEnum::class), + ]) + ->actions([ + Action::make('connect') + ->label(__('filament-evolution::resource.actions.connect')) + ->icon(Heroicon::QrCode) + ->color('success') + ->action(fn ($record, $livewire) => $livewire->openConnectModal((string) $record->id)) + ->hidden(fn ($record): bool => $record->status === StatusConnectionEnum::OPEN), + ViewAction::make(), + EditAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWhatsappInstances::route('/'), + 'create' => Pages\CreateWhatsappInstance::route('/create'), + 'view' => Pages\ViewWhatsappInstance::route('/{record}'), + 'edit' => Pages\EditWhatsappInstance::route('/{record}/edit'), + ]; + } +} diff --git a/src/Filament/Resources/WhatsappInstanceResource/Pages/CreateWhatsappInstance.php b/src/Filament/Resources/WhatsappInstanceResource/Pages/CreateWhatsappInstance.php new file mode 100644 index 0000000..69640e3 --- /dev/null +++ b/src/Filament/Resources/WhatsappInstanceResource/Pages/CreateWhatsappInstance.php @@ -0,0 +1,82 @@ +createInstance( + instanceName: $this->record->name, + number: $this->record->number, + qrcode: false, + options: $this->getInstanceOptions() + ); + + // Update with API response data if available + if (isset($response['instance'])) { + $this->record->update([ + 'status' => StatusConnectionEnum::CLOSE, + ]); + } + + Notification::make() + ->success() + ->title(__('filament-evolution::resource.messages.created')) + ->body('Instance created in Evolution API') + ->send(); + + } catch (EvolutionApiException $e) { + Notification::make() + ->warning() + ->title(__('filament-evolution::resource.messages.created')) + ->body('Instance saved locally. API sync failed: ' . $e->getMessage()) + ->send(); + } + } + + protected function getInstanceOptions(): array + { + return [ + 'reject_call' => (bool) $this->record->reject_call, + 'msg_call' => $this->record->msg_call ?? '', + 'groups_ignore' => (bool) $this->record->groups_ignore, + 'always_online' => (bool) $this->record->always_online, + 'read_messages' => (bool) $this->record->read_messages, + 'read_status' => (bool) $this->record->read_status, + 'sync_full_history' => (bool) $this->record->sync_full_history, + ]; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index', ['connectInstanceId' => (string) $this->record->id]); + } + + protected function getCreatedNotification(): ?Notification + { + return null; // We handle notifications in afterCreate + } +} diff --git a/src/Filament/Resources/WhatsappInstanceResource/Pages/EditWhatsappInstance.php b/src/Filament/Resources/WhatsappInstanceResource/Pages/EditWhatsappInstance.php new file mode 100644 index 0000000..2492ad6 --- /dev/null +++ b/src/Filament/Resources/WhatsappInstanceResource/Pages/EditWhatsappInstance.php @@ -0,0 +1,31 @@ +success() + ->title(__('filament-evolution::resource.messages.updated')); + } +} diff --git a/src/Filament/Resources/WhatsappInstanceResource/Pages/ListWhatsappInstances.php b/src/Filament/Resources/WhatsappInstanceResource/Pages/ListWhatsappInstances.php new file mode 100644 index 0000000..978ff19 --- /dev/null +++ b/src/Filament/Resources/WhatsappInstanceResource/Pages/ListWhatsappInstances.php @@ -0,0 +1,63 @@ +connectInstanceId) { + $this->openConnectModal($this->connectInstanceId); + } + } + + public function openConnectModal(string $instanceId): void + { + $this->connectInstance = WhatsappInstance::find($instanceId); + $this->showQrCodeModal = true; + $this->dispatch('open-modal', id: 'qr-code-modal'); + } + + public function closeConnectModal(): void + { + $this->showQrCodeModal = false; + $this->connectInstance = null; + $this->connectInstanceId = null; + } + + #[On('instance-connected')] + public function handleInstanceConnected(): void + { + $this->closeConnectModal(); + $this->dispatch('close-modal', id: 'qr-code-modal'); + } + + protected function getHeaderActions(): array + { + return [ + CreateAction::make(), + ]; + } +} diff --git a/src/Filament/Resources/WhatsappInstanceResource/Pages/ViewWhatsappInstance.php b/src/Filament/Resources/WhatsappInstanceResource/Pages/ViewWhatsappInstance.php new file mode 100644 index 0000000..98a114b --- /dev/null +++ b/src/Filament/Resources/WhatsappInstanceResource/Pages/ViewWhatsappInstance.php @@ -0,0 +1,151 @@ +label(__('filament-evolution::resource.actions.connect')) + ->icon(Heroicon::QrCode) + ->color('success') + ->visible(fn () => $this->record->status !== StatusConnectionEnum::OPEN) + ->modalHeading(__('filament-evolution::resource.actions.view_qrcode')) + ->modalContent(fn () => view('filament-evolution::components.qr-code-modal', [ + 'instance' => $this->record, + ])) + ->modalWidth('md') + ->modalSubmitAction(false) + ->modalCancelActionLabel(__('filament-evolution::resource.actions.close')), + + Action::make('disconnect') + ->label(__('filament-evolution::resource.actions.disconnect')) + ->icon(Heroicon::XCircle) + ->color('danger') + ->visible(fn () => $this->record->status === StatusConnectionEnum::OPEN) + ->requiresConfirmation() + ->action(function () { + try { + $client = app(EvolutionClient::class); + $client->logoutInstance($this->record->name); + + $this->record->update([ + 'status' => StatusConnectionEnum::CLOSE, + ]); + + Notification::make() + ->success() + ->title(__('filament-evolution::resource.messages.disconnected')) + ->send(); + + } catch (EvolutionApiException $e) { + Notification::make() + ->danger() + ->title(__('filament-evolution::resource.messages.connection_failed')) + ->body($e->getMessage()) + ->send(); + } + }), + + Action::make('refresh') + ->label(__('filament-evolution::resource.actions.refresh')) + ->icon(Heroicon::ArrowPath) + ->color('gray') + ->action(function () { + try { + $client = app(EvolutionClient::class); + + // First try to fetch instance to check if it exists + $instances = $client->fetchInstance($this->record->name); + + if (empty($instances)) { + // Instance doesn't exist in API, try to create it + $client->createInstance( + instanceName: $this->record->name, + number: $this->record->number, + qrcode: false + ); + + Notification::make() + ->success() + ->title('Instance created in Evolution API') + ->send(); + + return; + } + + // Instance exists, check connection state + $state = $client->getConnectionState($this->record->name); + + $connectionState = $state['state'] ?? $state['instance']['state'] ?? 'close'; + $status = match (strtolower($connectionState)) { + 'open', 'connected' => StatusConnectionEnum::OPEN, + 'connecting' => StatusConnectionEnum::CONNECTING, + default => StatusConnectionEnum::CLOSE, + }; + + $this->record->update(['status' => $status]); + + Notification::make() + ->success() + ->title(__('filament-evolution::resource.fields.status') . ': ' . $status->getLabel()) + ->send(); + + } catch (EvolutionApiException $e) { + // If 404, instance doesn't exist - try to create it + if (str_contains($e->getMessage(), 'Not Found') || $e->getCode() === 404) { + try { + $client = app(EvolutionClient::class); + $client->createInstance( + instanceName: $this->record->name, + number: $this->record->number, + qrcode: false + ); + + Notification::make() + ->success() + ->title('Instance created in Evolution API') + ->send(); + + return; + } catch (EvolutionApiException $createError) { + Notification::make() + ->danger() + ->title('Failed to create instance') + ->body($createError->getMessage()) + ->send(); + + return; + } + } + + Notification::make() + ->danger() + ->title(__('filament-evolution::resource.messages.connection_failed')) + ->body($e->getMessage()) + ->send(); + } + }), + + EditAction::make(), + DeleteAction::make(), + ]; + } +} diff --git a/src/FilamentEvolutionPlugin.php b/src/FilamentEvolutionPlugin.php new file mode 100644 index 0000000..5dc156c --- /dev/null +++ b/src/FilamentEvolutionPlugin.php @@ -0,0 +1,53 @@ +getId()); + + return $plugin; + } + + public function getId(): string + { + return 'filament-evolution'; + } + + public function register(Panel $panel): void + { + if ($this->hasWhatsappInstanceResource) { + $panel->resources([ + WhatsappInstanceResource::class, + ]); + } + } + + public function boot(Panel $panel): void + { + // + } + + public function whatsappInstanceResource(bool $condition = true): static + { + $this->hasWhatsappInstanceResource = $condition; + + return $this; + } +} diff --git a/src/FilamentEvolutionServiceProvider.php b/src/FilamentEvolutionServiceProvider.php new file mode 100644 index 0000000..e393fce --- /dev/null +++ b/src/FilamentEvolutionServiceProvider.php @@ -0,0 +1,51 @@ +name(static::$name) + ->hasConfigFile() + ->hasMigrations([ + 'create_whatsapp_instances_table', + 'create_whatsapp_messages_table', + 'create_whatsapp_webhooks_table', + ]) + ->hasViews() + ->hasTranslations() + ->hasRoutes(['api']) + ->hasInstallCommand(function (InstallCommand $command) { + $command + ->publishConfigFile() + ->publishMigrations() + ->askToRunMigrations() + ->askToStarRepoOnGitHub('wallacemartinss/filament-whatsapp-conector'); + }); + } + + public function packageRegistered(): void + { + $this->app->singleton(EvolutionClient::class, function () { + return new EvolutionClient(); + }); + } + + public function packageBooted(): void + { + Livewire::component('filament-evolution::qr-code-display', QrCodeDisplay::class); + } +} diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php new file mode 100644 index 0000000..b17e378 --- /dev/null +++ b/src/Http/Controllers/WebhookController.php @@ -0,0 +1,84 @@ +verifyWebhookSecret($request)) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $payload = $request->all(); + $event = $payload['event'] ?? $request->header('X-Evolution-Event') ?? 'unknown'; + + // Log incoming webhook if enabled + if (config('filament-evolution.logging.webhook_events', false)) { + Log::channel(config('filament-evolution.logging.channel', 'stack')) + ->info('Webhook received', [ + 'event' => $event, + 'instance' => $payload['instance'] ?? $payload['instanceName'] ?? 'unknown', + ]); + } + + // Store webhook in database + $webhook = null; + if (config('filament-evolution.webhook.store_logs', true)) { + $webhook = $this->storeWebhook($event, $payload); + } + + // Dispatch job to process webhook + if (config('filament-evolution.queue.enabled', true)) { + ProcessWebhookJob::dispatch($event, $payload, $webhook?->id); + } else { + ProcessWebhookJob::dispatchSync($event, $payload, $webhook?->id); + } + + return response()->json(['success' => true]); + } + + protected function verifyWebhookSecret(Request $request): bool + { + $secret = config('filament-evolution.webhook.secret'); + + if (empty($secret)) { + return true; + } + + $headerSecret = $request->header('X-Webhook-Secret') + ?? $request->header('Authorization'); + + if ($headerSecret && str_starts_with($headerSecret, 'Bearer ')) { + $headerSecret = substr($headerSecret, 7); + } + + return $headerSecret === $secret; + } + + protected function storeWebhook(string $event, array $payload): WhatsappWebhook + { + $instanceName = $payload['instance'] ?? $payload['instanceName'] ?? null; + $instance = null; + + if ($instanceName) { + $instance = \WallaceMartinss\FilamentEvolution\Models\WhatsappInstance::where('name', $instanceName)->first(); + } + + return WhatsappWebhook::create([ + 'whatsapp_instance_id' => $instance?->id, + 'event' => $event, + 'payload' => $payload, + ]); + } +} diff --git a/src/Jobs/ProcessWebhookJob.php b/src/Jobs/ProcessWebhookJob.php new file mode 100644 index 0000000..037ca3a --- /dev/null +++ b/src/Jobs/ProcessWebhookJob.php @@ -0,0 +1,206 @@ +onQueue(config('filament-evolution.queue.name', 'default')); + } + + public function handle(): void + { + try { + $instanceName = $this->payload['instance'] ?? $this->payload['instanceName'] ?? null; + + if (! $instanceName) { + $this->markWebhookFailed('No instance name in payload'); + + return; + } + + $instance = WhatsappInstance::where('name', $instanceName)->first(); + + if (! $instance) { + $this->markWebhookFailed("Instance not found: {$instanceName}"); + + return; + } + + $this->processEvent($instance); + $this->markWebhookProcessed(); + + } catch (\Throwable $e) { + $this->markWebhookFailed($e->getMessage()); + + if (config('filament-evolution.logging.webhook_errors', true)) { + Log::channel(config('filament-evolution.logging.channel', 'stack')) + ->error('Webhook processing failed', [ + 'event' => $this->event, + 'error' => $e->getMessage(), + 'payload' => $this->payload, + ]); + } + + throw $e; + } + } + + protected function processEvent(WhatsappInstance $instance): void + { + $eventEnum = WebhookEventEnum::tryFrom($this->event); + + match ($eventEnum) { + WebhookEventEnum::CONNECTION_UPDATE => $this->handleConnectionUpdate($instance), + WebhookEventEnum::QRCODE_UPDATED => $this->handleQrCodeUpdated($instance), + WebhookEventEnum::MESSAGES_UPSERT => $this->handleMessageUpsert($instance), + WebhookEventEnum::MESSAGES_UPDATE => $this->handleMessageUpdate($instance), + default => $this->handleUnknownEvent($instance), + }; + } + + protected function handleConnectionUpdate(WhatsappInstance $instance): void + { + $data = ConnectionUpdateData::fromWebhook($this->payload); + + $instance->update([ + 'status' => $data->status, + ]); + + if ($data->isConnected()) { + event(new InstanceConnected($instance)); + } else { + event(new InstanceDisconnected($instance, $data->state)); + } + } + + protected function handleQrCodeUpdated(WhatsappInstance $instance): void + { + $data = QrCodeUpdatedData::fromWebhook($this->payload); + + $instance->update([ + 'qr_code' => $data->base64, + 'pairing_code' => $data->pairingCode, + 'qr_code_updated_at' => now(), + ]); + + event(new QrCodeUpdated( + $instance, + new \WallaceMartinss\FilamentEvolution\Data\QrCodeData( + code: $data->code, + base64: $data->base64, + pairingCode: $data->pairingCode, + ) + )); + } + + protected function handleMessageUpsert(WhatsappInstance $instance): void + { + $data = MessageUpsertData::fromWebhook($this->payload); + + // Store message in database + $instance->messages()->create([ + 'message_id' => $data->message->messageId, + 'phone' => $data->message->phone, + 'direction' => $data->message->direction, + 'type' => $data->message->type, + 'content' => json_encode([ + 'text' => $data->message->text, + 'media_url' => $data->message->mediaUrl, + 'media_caption' => $data->message->mediaCaption, + 'latitude' => $data->message->latitude, + 'longitude' => $data->message->longitude, + ]), + 'status' => $data->message->status, + ]); + + event(new MessageReceived($instance, $data->message)); + } + + protected function handleMessageUpdate(WhatsappInstance $instance): void + { + $messageData = $this->payload['data'] ?? $this->payload; + $key = $messageData['key'] ?? []; + $update = $messageData['update'] ?? []; + + if (isset($key['id']) && isset($update['status'])) { + $instance->messages() + ->where('message_id', $key['id']) + ->update([ + 'status' => $this->mapMessageStatus($update['status']), + ]); + } + } + + protected function handleUnknownEvent(WhatsappInstance $instance): void + { + if (config('filament-evolution.logging.webhook_events', false)) { + Log::channel(config('filament-evolution.logging.channel', 'stack')) + ->info('Unknown webhook event received', [ + 'event' => $this->event, + 'instance' => $instance->name, + ]); + } + } + + protected function mapMessageStatus(int $status): string + { + return match ($status) { + 0 => 'pending', + 1 => 'sent', + 2 => 'delivered', + 3, 4 => 'read', + default => 'pending', + }; + } + + protected function markWebhookProcessed(): void + { + if ($this->webhookId) { + WhatsappWebhook::where('id', $this->webhookId)->update([ + 'processed_at' => now(), + ]); + } + } + + protected function markWebhookFailed(string $error): void + { + if ($this->webhookId) { + WhatsappWebhook::where('id', $this->webhookId)->update([ + 'error' => $error, + ]); + } + } +} diff --git a/src/Jobs/SendMessageJob.php b/src/Jobs/SendMessageJob.php new file mode 100644 index 0000000..08c1f45 --- /dev/null +++ b/src/Jobs/SendMessageJob.php @@ -0,0 +1,121 @@ +onQueue(config('filament-evolution.queue.name', 'default')); + } + + public function handle(EvolutionClient $client): void + { + try { + $response = $this->sendMessage($client); + + if ($this->messageId) { + $this->updateMessageStatus(MessageStatusEnum::SENT, $response); + } + + } catch (EvolutionApiException $e) { + if ($this->messageId) { + $this->updateMessageStatus(MessageStatusEnum::FAILED); + } + + Log::channel(config('filament-evolution.logging.channel', 'stack')) + ->error('Failed to send WhatsApp message', [ + 'instance' => $this->instance->name, + 'phone' => $this->phone, + 'type' => $this->type->value, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + protected function sendMessage(EvolutionClient $client): array + { + return match ($this->type) { + MessageTypeEnum::TEXT => $client->sendText( + $this->instance->name, + $this->phone, + $this->content['text'] ?? '', + ), + MessageTypeEnum::IMAGE => $client->sendImage( + $this->instance->name, + $this->phone, + $this->content['url'] ?? '', + $this->content['caption'] ?? null, + ), + MessageTypeEnum::AUDIO => $client->sendAudio( + $this->instance->name, + $this->phone, + $this->content['url'] ?? '', + ), + MessageTypeEnum::DOCUMENT => $client->sendDocument( + $this->instance->name, + $this->phone, + $this->content['url'] ?? '', + $this->content['fileName'] ?? 'document', + $this->content['caption'] ?? null, + ), + MessageTypeEnum::LOCATION => $client->sendLocation( + $this->instance->name, + $this->phone, + $this->content['latitude'] ?? 0.0, + $this->content['longitude'] ?? 0.0, + $this->content['name'] ?? null, + $this->content['address'] ?? null, + ), + MessageTypeEnum::CONTACT => $client->sendContact( + $this->instance->name, + $this->phone, + $this->content['contactName'] ?? '', + $this->content['contactNumber'] ?? '', + ), + default => throw new EvolutionApiException("Unsupported message type: {$this->type->value}"), + }; + } + + protected function updateMessageStatus(MessageStatusEnum $status, ?array $response = null): void + { + $update = ['status' => $status]; + + if ($response && isset($response['key']['id'])) { + $update['message_id'] = $response['key']['id']; + } + + WhatsappMessage::where('id', $this->messageId)->update($update); + } +} diff --git a/src/Livewire/QrCodeDisplay.php b/src/Livewire/QrCodeDisplay.php new file mode 100644 index 0000000..66793b4 --- /dev/null +++ b/src/Livewire/QrCodeDisplay.php @@ -0,0 +1,143 @@ +instance = $instance; + $this->qrCodeTtl = (int) config('filament-evolution.instance.qrcode_expires_in', 30); + $this->qrCodeExpiresIn = $this->qrCodeTtl; + $this->fetchQrCode(); + } + + public function checkConnection(): void + { + $this->error = null; + + try { + $client = app(EvolutionClient::class); + + // Check current connection state + $state = $client->getConnectionState($this->instance->name); + + $connectionState = $state['state'] ?? $state['instance']['state'] ?? 'close'; + + if ($connectionState === 'open') { + $this->isConnected = true; + $this->qrCode = null; + $this->pairingCode = null; + $this->instance->update(['status' => StatusConnectionEnum::OPEN]); + $this->dispatch('instance-connected'); + } + + } catch (EvolutionApiException $e) { + // Don't show error during poll - instance might not exist yet + if (! $this->qrCode) { + $this->fetchQrCode(); + } + } + } + + public function fetchQrCode(): void + { + try { + $client = app(EvolutionClient::class); + $response = $client->connectInstance($this->instance->name); + + $this->qrCode = $response['base64'] ?? $response['qrcode']['base64'] ?? null; + $this->pairingCode = $response['pairingCode'] ?? $response['qrcode']['pairingCode'] ?? null; + + // Update instance with QR code data + $this->instance->update([ + 'qr_code' => $this->qrCode, + 'pairing_code' => $this->pairingCode, + 'qr_code_updated_at' => now(), + 'status' => StatusConnectionEnum::CONNECTING, + ]); + + $this->qrCodeExpiresIn = $this->qrCodeTtl; + $this->isLoading = false; + + // Dispatch event to reset Alpine countdown + $this->dispatch('qrCodeRefreshed'); + + } catch (EvolutionApiException $e) { + $this->error = $e->getMessage(); + $this->isLoading = false; + } + } + + public function refreshQrCode(): void + { + $this->isLoading = true; + $this->fetchQrCode(); + } + + #[Computed] + public function statusColor(): string + { + if ($this->isConnected) { + return 'success'; + } + + if ($this->error) { + return 'danger'; + } + + if ($this->qrCode) { + return 'warning'; + } + + return 'gray'; + } + + #[Computed] + public function statusLabel(): string + { + if ($this->isConnected) { + return __('filament-evolution::qrcode.connected'); + } + + if ($this->error) { + return __('filament-evolution::qrcode.error'); + } + + if ($this->qrCode) { + return __('filament-evolution::qrcode.waiting_scan'); + } + + return __('filament-evolution::qrcode.loading'); + } + + public function render() + { + return view('filament-evolution::livewire.qr-code-display'); + } +} diff --git a/src/Models/Concerns/HasTenant.php b/src/Models/Concerns/HasTenant.php new file mode 100644 index 0000000..55d4be1 --- /dev/null +++ b/src/Models/Concerns/HasTenant.php @@ -0,0 +1,93 @@ +getTenant()) { + $query->where($tenantColumn, filament()->getTenant()->getKey()); + } + }); + + // Auto-fill tenant on create + static::creating(function ($model) { + $tenantColumn = config('filament-evolution.tenancy.column', 'team_id'); + + if (function_exists('filament') && filament()->getTenant() && empty($model->{$tenantColumn})) { + $model->{$tenantColumn} = filament()->getTenant()->getKey(); + } + }); + } + + /** + * Dynamic relationship with the Tenant model. + */ + 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); + } + + /** + * Get the tenant column name. + */ + public function getTenantColumn(): ?string + { + if (! config('filament-evolution.tenancy.enabled', false)) { + return null; + } + + return config('filament-evolution.tenancy.column', 'team_id'); + } + + /** + * Check if tenancy is enabled. + */ + public static function hasTenancy(): bool + { + return config('filament-evolution.tenancy.enabled', false); + } + + /** + * Scope to filter by specific tenant. + */ + public function scopeForTenant(Builder $query, $tenantId): Builder + { + if (! config('filament-evolution.tenancy.enabled', false)) { + return $query; + } + + $tenantColumn = config('filament-evolution.tenancy.column', 'team_id'); + + return $query->where($tenantColumn, $tenantId); + } + + /** + * Scope to bypass tenant filter. + */ + public function scopeWithoutTenantScope(Builder $query): Builder + { + return $query->withoutGlobalScope('tenant'); + } +} diff --git a/src/Models/WhatsappInstance.php b/src/Models/WhatsappInstance.php new file mode 100644 index 0000000..99a4e57 --- /dev/null +++ b/src/Models/WhatsappInstance.php @@ -0,0 +1,105 @@ + 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, + ], true); + } + + public function isConnecting(): bool + { + return $this->status === StatusConnectionEnum::CONNECTING; + } + + public function hasQrCode(): bool + { + return ! empty($this->qr_code); + } + + public function hasPairingCode(): bool + { + return ! empty($this->pairing_code); + } + + public function clearQrCode(): void + { + $this->update([ + 'qr_code' => null, + 'pairing_code' => null, + ]); + } + + public function getFormattedNumber(): string + { + return preg_replace('/\D/', '', $this->number ?? ''); + } +} diff --git a/src/Models/WhatsappMessage.php b/src/Models/WhatsappMessage.php new file mode 100644 index 0000000..eb04655 --- /dev/null +++ b/src/Models/WhatsappMessage.php @@ -0,0 +1,124 @@ + 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; + } + + public function isMedia(): bool + { + return $this->type?->isMedia() ?? false; + } + + public function isText(): bool + { + return $this->type === MessageTypeEnum::TEXT; + } + + public function isSent(): bool + { + return $this->status?->isSuccess() ?? false; + } + + public function isFailed(): bool + { + return $this->status === MessageStatusEnum::FAILED; + } + + public function getFormattedPhone(): string + { + return preg_replace('/\D/', '', $this->phone ?? ''); + } + + public function markAsSent(): void + { + $this->update([ + 'status' => MessageStatusEnum::SENT, + 'sent_at' => now(), + ]); + } + + public function markAsDelivered(): void + { + $this->update([ + 'status' => MessageStatusEnum::DELIVERED, + 'delivered_at' => now(), + ]); + } + + public function markAsRead(): void + { + $this->update([ + 'status' => MessageStatusEnum::READ, + 'read_at' => now(), + ]); + } + + public function markAsFailed(): void + { + $this->update([ + 'status' => MessageStatusEnum::FAILED, + ]); + } +} diff --git a/src/Models/WhatsappWebhook.php b/src/Models/WhatsappWebhook.php new file mode 100644 index 0000000..94ae624 --- /dev/null +++ b/src/Models/WhatsappWebhook.php @@ -0,0 +1,95 @@ + WebhookEventEnum::class, + 'payload' => 'array', + 'processed' => 'boolean', + 'processing_time_ms' => 'integer', + ]; + } + + public function instance(): BelongsTo + { + return $this->belongsTo(WhatsappInstance::class, 'instance_id'); + } + + public function isProcessed(): bool + { + return $this->processed; + } + + public function hasError(): bool + { + return ! empty($this->error); + } + + public function markAsProcessed(int $processingTimeMs = null): void + { + $this->update([ + 'processed' => true, + 'processing_time_ms' => $processingTimeMs, + ]); + } + + public function markAsFailed(string $error, int $processingTimeMs = null): void + { + $this->update([ + 'processed' => false, + 'error' => $error, + 'processing_time_ms' => $processingTimeMs, + ]); + } + + public function getEventLabel(): string + { + return $this->event?->getLabel() ?? $this->getRawOriginal('event') ?? 'Unknown'; + } + + public function scopePending($query) + { + return $query->where('processed', false)->whereNull('error'); + } + + public function scopeFailed($query) + { + return $query->where('processed', false)->whereNotNull('error'); + } + + public function scopeProcessed($query) + { + return $query->where('processed', true); + } + + public function scopeByEvent($query, WebhookEventEnum $event) + { + return $query->where('event', $event->value); + } +} diff --git a/src/Services/EvolutionClient.php b/src/Services/EvolutionClient.php new file mode 100644 index 0000000..d92b669 --- /dev/null +++ b/src/Services/EvolutionClient.php @@ -0,0 +1,446 @@ +baseUrl = rtrim(config('filament-evolution.api.base_url', ''), '/'); + $this->apiKey = config('filament-evolution.api.api_key', ''); + $this->timeout = config('filament-evolution.api.timeout', 30); + } + + /** + * Create base HTTP client with authentication headers. + */ + protected function client(): PendingRequest + { + return Http::baseUrl($this->baseUrl) + ->timeout($this->timeout) + ->withHeaders([ + 'apikey' => $this->apiKey, + 'Content-Type' => 'application/json', + ]) + ->acceptJson(); + } + + /** + * Make a request to Evolution API. + * + * @throws EvolutionApiException + */ + protected function request(string $method, string $endpoint, array $data = []): array + { + try { + $response = match (strtoupper($method)) { + 'GET' => $this->client()->get($endpoint, $data), + 'POST' => $this->client()->post($endpoint, $data), + 'PUT' => $this->client()->put($endpoint, $data), + 'DELETE' => $this->client()->delete($endpoint, $data), + default => throw new EvolutionApiException("Unsupported HTTP method: {$method}"), + }; + + return $this->handleResponse($response); + } catch (EvolutionApiException $e) { + throw $e; + } catch (\Exception $e) { + throw new EvolutionApiException( + message: "Failed to connect to Evolution API: {$e->getMessage()}", + previous: $e + ); + } + } + + /** + * Handle API response. + * + * @throws EvolutionApiException + */ + protected function handleResponse(Response $response): array + { + $body = $response->json() ?? []; + + if ($response->failed()) { + $message = $body['message'] ?? $body['error'] ?? 'Unknown API error'; + + throw new EvolutionApiException( + message: "Evolution API error: {$message}", + code: $response->status() + ); + } + + return $body; + } + + /** + * Create a new WhatsApp instance. + * + * @throws EvolutionApiException + */ + public function createInstance( + string $instanceName, + ?string $number = null, + bool $qrcode = true, + array $options = [] + ): array { + // Format number (remove all non-digits) + $formattedNumber = $number ? preg_replace('/\D/', '', $number) : null; + + // Build payload with all instance settings + $data = [ + 'instanceName' => $instanceName, + 'qrcode' => $qrcode, + 'integration' => config('filament-evolution.instance.integration', 'WHATSAPP-BAILEYS'), + 'rejectCall' => (bool) ($options['reject_call'] ?? config('filament-evolution.instance.reject_call', false)), + 'msgCall' => $options['msg_call'] ?? config('filament-evolution.instance.msg_call', ''), + 'groupsIgnore' => (bool) ($options['groups_ignore'] ?? config('filament-evolution.instance.groups_ignore', false)), + 'alwaysOnline' => (bool) ($options['always_online'] ?? config('filament-evolution.instance.always_online', false)), + 'readMessages' => (bool) ($options['read_messages'] ?? config('filament-evolution.instance.read_messages', false)), + 'readStatus' => (bool) ($options['read_status'] ?? config('filament-evolution.instance.read_status', false)), + 'syncFullHistory' => (bool) ($options['sync_full_history'] ?? config('filament-evolution.instance.sync_full_history', false)), + ]; + + if ($formattedNumber) { + $data['number'] = $formattedNumber; + } + + // Add webhook configuration + if (config('filament-evolution.webhook.enabled', true)) { + $webhookUrl = config('filament-evolution.webhook.url'); + + if ($webhookUrl) { + $data['webhook'] = [ + 'url' => $webhookUrl, + 'byEvents' => (bool) config('filament-evolution.webhook.by_events', false), + 'base64' => (bool) config('filament-evolution.webhook.base64', false), + 'events' => config('filament-evolution.webhook.events', []), + ]; + } + } + + return $this->request('POST', '/instance/create', $data); + } + + /** + * Connect an existing instance and get QR code. + * + * @throws EvolutionApiException + */ + public function connectInstance(string $instanceName): array + { + return $this->request('GET', "/instance/connect/{$instanceName}"); + } + + /** + * Fetch current QR code for an instance. + * + * @throws EvolutionApiException + */ + public function fetchQrCode(string $instanceName): array + { + return $this->request('GET', "/instance/connect/{$instanceName}"); + } + + /** + * Get instance connection state. + * + * @throws EvolutionApiException + */ + public function getConnectionState(string $instanceName): array + { + return $this->request('GET', "/instance/connectionState/{$instanceName}"); + } + + /** + * Get instance information. + * + * @throws EvolutionApiException + */ + public function fetchInstance(string $instanceName): array + { + return $this->request('GET', "/instance/fetchInstances", [ + 'instanceName' => $instanceName, + ]); + } + + /** + * Logout from WhatsApp (disconnect but keep instance). + * + * @throws EvolutionApiException + */ + public function logoutInstance(string $instanceName): array + { + return $this->request('DELETE', "/instance/logout/{$instanceName}"); + } + + /** + * Delete an instance completely. + * + * @throws EvolutionApiException + */ + public function deleteInstance(string $instanceName): array + { + return $this->request('DELETE', "/instance/delete/{$instanceName}"); + } + + /** + * Restart an instance. + * + * @throws EvolutionApiException + */ + public function restartInstance(string $instanceName): array + { + return $this->request('PUT', "/instance/restart/{$instanceName}"); + } + + /** + * Set instance settings. + * + * @throws EvolutionApiException + */ + public function setSettings(string $instanceName, array $settings): array + { + return $this->request('POST', "/settings/set/{$instanceName}", $settings); + } + + /** + * Send a text message. + * + * @throws EvolutionApiException + */ + public function sendText(string $instanceName, string $number, string $text, array $options = []): array + { + return $this->request('POST', "/message/sendText/{$instanceName}", array_merge([ + 'number' => $number, + 'text' => $text, + ], $options)); + } + + /** + * Send an image message. + * + * @throws EvolutionApiException + */ + public function sendImage( + string $instanceName, + string $number, + string $imageUrl, + ?string $caption = null, + array $options = [] + ): array { + $data = array_merge([ + 'number' => $number, + 'media' => $imageUrl, + 'mediatype' => 'image', + ], $options); + + if ($caption) { + $data['caption'] = $caption; + } + + return $this->request('POST', "/message/sendMedia/{$instanceName}", $data); + } + + /** + * Send an audio message. + * + * @throws EvolutionApiException + */ + public function sendAudio(string $instanceName, string $number, string $audioUrl, array $options = []): array + { + return $this->request('POST', "/message/sendWhatsAppAudio/{$instanceName}", array_merge([ + 'number' => $number, + 'audio' => $audioUrl, + ], $options)); + } + + /** + * Send a document. + * + * @throws EvolutionApiException + */ + public function sendDocument( + string $instanceName, + string $number, + string $documentUrl, + string $fileName, + ?string $caption = null, + array $options = [] + ): array { + $data = array_merge([ + 'number' => $number, + 'media' => $documentUrl, + 'mediatype' => 'document', + 'fileName' => $fileName, + ], $options); + + if ($caption) { + $data['caption'] = $caption; + } + + return $this->request('POST', "/message/sendMedia/{$instanceName}", $data); + } + + /** + * Send a location. + * + * @throws EvolutionApiException + */ + public function sendLocation( + string $instanceName, + string $number, + float $latitude, + float $longitude, + ?string $name = null, + ?string $address = null, + array $options = [] + ): array { + $data = array_merge([ + 'number' => $number, + 'latitude' => $latitude, + 'longitude' => $longitude, + ], $options); + + if ($name) { + $data['name'] = $name; + } + + if ($address) { + $data['address'] = $address; + } + + return $this->request('POST', "/message/sendLocation/{$instanceName}", $data); + } + + /** + * Send a contact card. + * + * @throws EvolutionApiException + */ + public function sendContact( + string $instanceName, + string $number, + string $contactName, + string $contactNumber, + array $options = [] + ): array { + return $this->request('POST', "/message/sendContact/{$instanceName}", array_merge([ + 'number' => $number, + 'contact' => [ + [ + 'fullName' => $contactName, + 'wuid' => $contactNumber, + 'phoneNumber' => $contactNumber, + ], + ], + ], $options)); + } + + /** + * Set webhook for an instance. + * + * @throws EvolutionApiException + */ + public function setWebhook(string $instanceName, string $url, array $events = []): array + { + return $this->request('POST', "/webhook/set/{$instanceName}", [ + 'url' => $url, + 'webhook_by_events' => config('filament-evolution.webhook.by_events', false), + 'webhook_base64' => config('filament-evolution.webhook.base64', false), + 'events' => $events ?: config('filament-evolution.webhook.events', []), + ]); + } + + /** + * Get webhook configuration for an instance. + * + * @throws EvolutionApiException + */ + public function getWebhook(string $instanceName): array + { + return $this->request('GET', "/webhook/find/{$instanceName}"); + } + + /** + * Find contacts. + * + * @throws EvolutionApiException + */ + public function findContacts(string $instanceName, array $numbers): array + { + return $this->request('POST', "/chat/whatsappNumbers/{$instanceName}", [ + 'numbers' => $numbers, + ]); + } + + /** + * Check if numbers are registered on WhatsApp. + * + * @throws EvolutionApiException + */ + public function checkNumbers(string $instanceName, array $numbers): array + { + return $this->request('POST', "/chat/whatsappNumbers/{$instanceName}", [ + 'numbers' => $numbers, + ]); + } + + /** + * Get profile picture URL. + * + * @throws EvolutionApiException + */ + public function getProfilePicture(string $instanceName, string $number): array + { + return $this->request('POST', "/chat/fetchProfilePictureUrl/{$instanceName}", [ + 'number' => $number, + ]); + } + + /** + * Fetch all messages from a chat. + * + * @throws EvolutionApiException + */ + public function fetchMessages(string $instanceName, string $remoteJid, int $limit = 20): array + { + return $this->request('POST', "/chat/findMessages/{$instanceName}", [ + 'where' => [ + 'key' => [ + 'remoteJid' => $remoteJid, + ], + ], + 'limit' => $limit, + ]); + } + + /** + * Get the configured base URL. + */ + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + /** + * Check if the client is properly configured. + */ + public function isConfigured(): bool + { + return ! empty($this->baseUrl) && ! empty($this->apiKey); + } +} diff --git a/tests/Feature/WebhookControllerTest.php b/tests/Feature/WebhookControllerTest.php new file mode 100644 index 0000000..d713017 --- /dev/null +++ b/tests/Feature/WebhookControllerTest.php @@ -0,0 +1,67 @@ + 'connection.update', + 'instance' => 'test-instance', + 'data' => [ + 'state' => 'open', + ], + ]; + + $response = $this->postJson( + route('filament-evolution.webhook'), + $payload + ); + + $response->assertOk(); + $response->assertJson(['success' => true]); + } + + public function test_webhook_endpoint_rejects_unauthorized_request_when_secret_configured(): void + { + config(['filament-evolution.webhook.secret' => 'super-secret']); + + $payload = [ + 'event' => 'connection.update', + 'instance' => 'test-instance', + ]; + + $response = $this->postJson( + route('filament-evolution.webhook'), + $payload + ); + + $response->assertUnauthorized(); + } + + public function test_webhook_endpoint_accepts_authorized_request_with_secret(): void + { + config(['filament-evolution.webhook.secret' => 'super-secret']); + + $payload = [ + 'event' => 'connection.update', + 'instance' => 'test-instance', + 'data' => [ + 'state' => 'open', + ], + ]; + + $response = $this->postJson( + route('filament-evolution.webhook'), + $payload, + ['X-Webhook-Secret' => 'super-secret'] + ); + + $response->assertOk(); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..1c1ae1f --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,42 @@ +loadMigrationsFrom(__DIR__.'/../database/migrations'); + } + + protected function getPackageProviders($app): array + { + return [ + FilamentEvolutionServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $app['config']->set('filament-evolution.api.base_url', 'https://api.evolution.test'); + $app['config']->set('filament-evolution.api.api_key', 'test-api-key'); + $app['config']->set('filament-evolution.tenancy.enabled', false); + } +} diff --git a/tests/Unit/EvolutionClientTest.php b/tests/Unit/EvolutionClientTest.php new file mode 100644 index 0000000..527562a --- /dev/null +++ b/tests/Unit/EvolutionClientTest.php @@ -0,0 +1,32 @@ +assertInstanceOf(EvolutionClient::class, $client); + } + + public function test_client_is_configured_when_has_url_and_key(): void + { + $client = new EvolutionClient(); + + $this->assertTrue($client->isConfigured()); + } + + public function test_client_returns_configured_base_url(): void + { + $client = new EvolutionClient(); + + $this->assertSame('https://api.evolution.test', $client->getBaseUrl()); + } +} diff --git a/tests/Unit/InstanceDataTest.php b/tests/Unit/InstanceDataTest.php new file mode 100644 index 0000000..1b8a440 --- /dev/null +++ b/tests/Unit/InstanceDataTest.php @@ -0,0 +1,58 @@ +assertSame('test-instance', $data->instanceName); + $this->assertSame('5511999999999', $data->number); + $this->assertTrue($data->qrcode); + } + + public function test_can_convert_to_api_payload(): void + { + $data = new InstanceData( + instanceName: 'test-instance', + number: '5511999999999', + rejectCall: true, + msgCall: 'Busy, call later', + ); + + $payload = $data->toApiPayload(); + + $this->assertSame('test-instance', $payload['instanceName']); + $this->assertSame('5511999999999', $payload['number']); + $this->assertTrue($payload['reject_call']); + $this->assertSame('Busy, call later', $payload['msg_call']); + $this->assertSame('WHATSAPP-BAILEYS', $payload['integration']); + } + + public function test_can_create_from_api_response(): void + { + $response = [ + 'instance' => [ + 'instanceName' => 'test-instance', + 'number' => '5511999999999', + ], + 'qrcode' => true, + ]; + + $data = InstanceData::fromApiResponse($response); + + $this->assertSame('test-instance', $data->instanceName); + $this->assertSame('5511999999999', $data->number); + } +} diff --git a/tests/Unit/StatusConnectionEnumTest.php b/tests/Unit/StatusConnectionEnumTest.php new file mode 100644 index 0000000..4b5c7a5 --- /dev/null +++ b/tests/Unit/StatusConnectionEnumTest.php @@ -0,0 +1,43 @@ +assertSame('open', StatusConnectionEnum::OPEN->value); + $this->assertSame('connecting', StatusConnectionEnum::CONNECTING->value); + $this->assertSame('close', StatusConnectionEnum::CLOSE->value); + $this->assertSame('refused', StatusConnectionEnum::REFUSED->value); + } + + public function test_enum_has_labels(): void + { + $this->assertNotEmpty(StatusConnectionEnum::OPEN->getLabel()); + $this->assertNotEmpty(StatusConnectionEnum::CONNECTING->getLabel()); + $this->assertNotEmpty(StatusConnectionEnum::CLOSE->getLabel()); + $this->assertNotEmpty(StatusConnectionEnum::REFUSED->getLabel()); + } + + public function test_enum_has_colors(): void + { + $this->assertSame('success', StatusConnectionEnum::OPEN->getColor()); + $this->assertSame('warning', StatusConnectionEnum::CONNECTING->getColor()); + $this->assertSame('gray', StatusConnectionEnum::CLOSE->getColor()); + $this->assertSame('danger', StatusConnectionEnum::REFUSED->getColor()); + } + + public function test_enum_has_icons(): void + { + $this->assertNotEmpty(StatusConnectionEnum::OPEN->getIcon()); + $this->assertNotEmpty(StatusConnectionEnum::CONNECTING->getIcon()); + $this->assertNotEmpty(StatusConnectionEnum::CLOSE->getIcon()); + $this->assertNotEmpty(StatusConnectionEnum::REFUSED->getIcon()); + } +}