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
This commit is contained in:
Wallace Martins
2025-12-07 10:14:40 -03:00
commit 3bf496e8a9
62 changed files with 6626 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
/vendor/
/.phpunit.cache/
/.idea/
/.vscode/
.DS_Store
composer.lock
.phpunit.result.cache
coverage/
.php-cs-fixer.cache
phpstan.neon

2340
ARCHITECTURE.md Normal file

File diff suppressed because it is too large Load Diff

55
CHANGELOG.md Normal file
View File

@@ -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
```

21
LICENSE.md Normal file
View File

@@ -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.

200
README.md Normal file
View File

@@ -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.

74
composer.json Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Evolution API Connection
|--------------------------------------------------------------------------
|
| These settings are required and must be defined in your .env file.
| NEVER store these credentials in the database!
|
*/
'api' => [
'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'),
],
];

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use WallaceMartinss\FilamentEvolution\Database\Migrations\Concerns\HasTenantColumn;
return new class extends Migration
{
use HasTenantColumn;
public function up(): void
{
Schema::create('whatsapp_instances', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use WallaceMartinss\FilamentEvolution\Database\Migrations\Concerns\HasTenantColumn;
return new class extends Migration
{
use HasTenantColumn;
public function up(): void
{
Schema::create('whatsapp_messages', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use WallaceMartinss\FilamentEvolution\Database\Migrations\Concerns\HasTenantColumn;
return new class extends Migration
{
use HasTenantColumn;
public function up(): void
{
Schema::create('whatsapp_webhooks', function (Blueprint $table) {
$table->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');
}
};

25
phpunit.xml Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
failOnWarning="true"
failOnRisky="true"
failOnEmptyTestSuite="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
return [
'status_connection' => [
'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',
],
];

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
return [
'modal_title' => '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',
];

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
return [
'navigation_label' => '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',
],
];

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
return [
'status_connection' => [
'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',
],
];

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
return [
'modal_title' => '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',
];

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
return [
'navigation_label' => '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',
],
];

View File

@@ -0,0 +1,3 @@
<div>
@livewire('filament-evolution::qr-code-display', ['instance' => $instance])
</div>

View File

@@ -0,0 +1,5 @@
<x-filament-panels::page>
<div class="max-w-md mx-auto">
<livewire:filament-evolution::qr-code-display :instance="$this->record" />
</div>
</x-filament-panels::page>

View File

@@ -0,0 +1,19 @@
<x-filament-panels::page>
{{ $this->table }}
{{-- QR Code Modal --}}
<x-filament::modal id="qr-code-modal" width="md" :close-by-clicking-away="false">
<x-slot name="heading">
@if($this->connectInstance)
{{ __('filament-evolution::qrcode.modal_title', ['instance' => $this->connectInstance->name]) }}
@endif
</x-slot>
@if($this->connectInstance && $this->showQrCodeModal)
<livewire:filament-evolution::qr-code-display
:instance="$this->connectInstance"
:key="'qr-' . $this->connectInstance->id"
/>
@endif
</x-filament::modal>
</x-filament-panels::page>

View File

@@ -0,0 +1,162 @@
<div
x-data="{
countdown: {{ $qrCodeTtl }},
ttl: {{ $qrCodeTtl }},
timer: null,
init() {
this.startCountdown();
// Listen for QR code refresh to reset countdown
Livewire.on('qrCodeRefreshed', () => {
this.countdown = this.ttl;
this.startCountdown();
});
},
startCountdown() {
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => {
if (this.countdown > 0) {
this.countdown--;
} else {
clearInterval(this.timer);
$wire.fetchQrCode();
}
}, 1000);
}
}"
x-init="init()"
@instance-connected.window="clearInterval(timer)"
wire:poll.5s="checkConnection"
class="w-full"
>
{{-- Loading State --}}
@if($isLoading)
<div class="flex flex-col items-center justify-center py-16">
<div class="relative">
<div class="w-16 h-16 border-4 border-gray-200 dark:border-gray-700 rounded-full"></div>
<div class="absolute top-0 left-0 w-16 h-16 border-4 border-primary-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<p class="mt-6 text-sm font-medium text-gray-500 dark:text-gray-400">
{{ __('filament-evolution::qrcode.loading') }}
</p>
</div>
@elseif($isConnected)
{{-- Connected State --}}
<div class="flex flex-col items-center justify-center py-12">
<div class="relative">
<div class="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
<h3 class="mt-6 text-lg font-semibold text-gray-900 dark:text-white">
{{ __('filament-evolution::qrcode.connected_title') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400 text-center">
{{ __('filament-evolution::qrcode.connected_description') }}
</p>
</div>
@elseif($error)
{{-- Error State --}}
<div class="flex flex-col items-center justify-center py-12">
<div class="w-20 h-20 bg-red-500 rounded-full flex items-center justify-center">
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<h3 class="mt-6 text-lg font-semibold text-gray-900 dark:text-white">
{{ __('filament-evolution::qrcode.error_title') }}
</h3>
<p class="mt-2 text-sm text-red-500 text-center">
{{ $error }}
</p>
<button
wire:click="fetchQrCode"
class="mt-4 px-4 py-2 bg-primary-600 text-white text-sm font-medium rounded-lg hover:bg-primary-700"
>
{{ __('filament-evolution::qrcode.try_again') }}
</button>
</div>
@elseif($qrCode)
{{-- QR Code Display --}}
<div class="flex flex-col items-center">
{{-- Status --}}
<div class="flex items-center gap-2 mb-4">
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-amber-500"></span>
</span>
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{{ __('filament-evolution::qrcode.waiting_scan') }}
</span>
</div>
{{-- QR Code --}}
<div class="bg-white p-3 rounded-xl shadow-lg">
<img src="{{ $qrCode }}" alt="QR Code" class="w-56 h-56" />
</div>
{{-- Countdown Timer with Alpine --}}
<div class="mt-3 flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>
{{ __('filament-evolution::qrcode.expires_in') }}:
<strong class="text-gray-700 dark:text-gray-200" x-text="countdown + 's'"></strong>
</span>
</div>
{{-- Instructions --}}
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400 text-center max-w-xs">
{{ __('filament-evolution::qrcode.scan_instructions') }}
</p>
{{-- Pairing Code --}}
@if($pairingCode)
<div class="mt-4 text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
{{ __('filament-evolution::qrcode.or_use_code') }}
</p>
<div class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 rounded-lg">
<span class="font-mono text-xl font-bold tracking-widest text-gray-900 dark:text-white">
{{ $pairingCode }}
</span>
</div>
</div>
@endif
{{-- Refresh Button --}}
<button
wire:click="refreshQrCode"
wire:loading.attr="disabled"
class="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700"
>
<svg wire:loading.class="animate-spin" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
{{ __('filament-evolution::qrcode.refresh') }}
</button>
</div>
@else
{{-- No QR Code - Generate --}}
<div class="flex flex-col items-center justify-center py-12">
<button
wire:click="fetchQrCode"
wire:loading.attr="disabled"
class="px-6 py-3 bg-primary-600 text-white text-sm font-semibold rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"></path>
</svg>
{{ __('filament-evolution::qrcode.generate') }}
</button>
</div>
@endif
</div>

View File

@@ -0,0 +1,3 @@
<div>
<livewire:filament-evolution::qr-code-display :instance="$instance" :key="'qr-code-' . $instance->id" />
</div>

11
routes/api.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
use WallaceMartinss\FilamentEvolution\Http\Controllers\WebhookController;
// Webhook route - responds to Evolution API callbacks
Route::post('/api/webhooks/evolution', WebhookController::class)
->name('filament-evolution.webhook')
->withoutMiddleware(['auth', 'web']);

39
src/Data/ContactData.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Data;
use Spatie\LaravelData\Data;
class ContactData extends Data
{
public function __construct(
public string $jid,
public ?string $name = null,
public ?string $number = null,
public ?string $profilePictureUrl = null,
public bool $exists = true,
) {}
public static function fromApiResponse(array $data): self
{
return new self(
jid: $data['jid'] ?? $data['id'] ?? '',
name: $data['name'] ?? $data['pushName'] ?? null,
number: $data['number'] ?? self::extractNumberFromJid($data['jid'] ?? $data['id'] ?? ''),
profilePictureUrl: $data['profilePictureUrl'] ?? $data['picture'] ?? null,
exists: $data['exists'] ?? true,
);
}
protected static function extractNumberFromJid(string $jid): string
{
return str_replace(['@s.whatsapp.net', '@g.us'], '', $jid);
}
public function isGroup(): bool
{
return str_contains($this->jid, '@g.us');
}
}

66
src/Data/InstanceData.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Data;
use Spatie\LaravelData\Data;
class InstanceData extends Data
{
public function __construct(
public string $instanceName,
public ?string $number = null,
public bool $qrcode = true,
public bool $rejectCall = false,
public ?string $msgCall = null,
public bool $groupsIgnore = false,
public bool $alwaysOnline = false,
public bool $readMessages = false,
public bool $readStatus = false,
public bool $syncFullHistory = false,
public ?array $webhook = null,
) {}
public static function fromApiResponse(array $data): self
{
return new self(
instanceName: $data['instance']['instanceName'] ?? $data['instanceName'] ?? '',
number: $data['instance']['number'] ?? $data['number'] ?? null,
qrcode: $data['qrcode'] ?? true,
);
}
public function toApiPayload(): array
{
$payload = [
'instanceName' => $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;
}
}

94
src/Data/MessageData.php Normal file
View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Data;
use Spatie\LaravelData\Data;
use WallaceMartinss\FilamentEvolution\Enums\MessageDirectionEnum;
use WallaceMartinss\FilamentEvolution\Enums\MessageStatusEnum;
use WallaceMartinss\FilamentEvolution\Enums\MessageTypeEnum;
class MessageData extends Data
{
public function __construct(
public string $messageId,
public string $phone,
public MessageTypeEnum $type,
public MessageDirectionEnum $direction,
public MessageStatusEnum $status,
public ?string $text = null,
public ?string $mediaUrl = null,
public ?string $mediaCaption = null,
public ?string $mimetype = null,
public ?float $latitude = null,
public ?float $longitude = null,
public ?string $contactName = null,
public ?string $contactNumber = null,
public ?array $rawData = null,
) {}
public static function fromApiResponse(array $data, MessageDirectionEnum $direction): self
{
$key = $data['key'] ?? [];
$message = $data['message'] ?? [];
return new self(
messageId: $key['id'] ?? $data['id'] ?? '',
phone: $key['remoteJid'] ?? $data['remoteJid'] ?? '',
type: self::detectMessageType($message),
direction: $direction,
status: MessageStatusEnum::SENT,
text: $message['conversation'] ?? $message['extendedTextMessage']['text'] ?? null,
mediaUrl: $message['imageMessage']['url'] ?? $message['audioMessage']['url'] ?? $message['documentMessage']['url'] ?? null,
mediaCaption: $message['imageMessage']['caption'] ?? $message['documentMessage']['caption'] ?? null,
mimetype: $message['imageMessage']['mimetype'] ?? $message['audioMessage']['mimetype'] ?? $message['documentMessage']['mimetype'] ?? null,
latitude: isset($message['locationMessage']) ? ($message['locationMessage']['degreesLatitude'] ?? null) : null,
longitude: isset($message['locationMessage']) ? ($message['locationMessage']['degreesLongitude'] ?? null) : null,
rawData: $data,
);
}
protected static function detectMessageType(array $message): MessageTypeEnum
{
if (isset($message['imageMessage'])) {
return MessageTypeEnum::IMAGE;
}
if (isset($message['audioMessage'])) {
return MessageTypeEnum::AUDIO;
}
if (isset($message['videoMessage'])) {
return MessageTypeEnum::VIDEO;
}
if (isset($message['documentMessage'])) {
return MessageTypeEnum::DOCUMENT;
}
if (isset($message['stickerMessage'])) {
return MessageTypeEnum::STICKER;
}
if (isset($message['locationMessage'])) {
return MessageTypeEnum::LOCATION;
}
if (isset($message['contactMessage']) || isset($message['contactsArrayMessage'])) {
return MessageTypeEnum::CONTACT;
}
return MessageTypeEnum::TEXT;
}
public function isMedia(): bool
{
return in_array($this->type, [
MessageTypeEnum::IMAGE,
MessageTypeEnum::AUDIO,
MessageTypeEnum::VIDEO,
MessageTypeEnum::DOCUMENT,
]);
}
}

37
src/Data/QrCodeData.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Data;
use Spatie\LaravelData\Data;
class QrCodeData extends Data
{
public function __construct(
public ?string $code = null,
public ?string $base64 = null,
public ?string $pairingCode = null,
public int $count = 0,
) {}
public static function fromApiResponse(array $data): self
{
return new self(
code: $data['code'] ?? null,
base64: $data['base64'] ?? null,
pairingCode: $data['pairingCode'] ?? null,
count: $data['count'] ?? 0,
);
}
public function hasQrCode(): bool
{
return ! empty($this->base64) || ! empty($this->code);
}
public function hasPairingCode(): bool
{
return ! empty($this->pairingCode);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Data\Webhooks;
use Spatie\LaravelData\Data;
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
class ConnectionUpdateData extends Data
{
public function __construct(
public string $instanceName,
public string $state,
public ?StatusConnectionEnum $status = null,
) {}
public static function fromWebhook(array $data): self
{
$state = $data['state'] ?? $data['data']['state'] ?? 'close';
return new self(
instanceName: $data['instance'] ?? $data['instanceName'] ?? '',
state: $state,
status: self::mapStateToStatus($state),
);
}
protected static function mapStateToStatus(string $state): StatusConnectionEnum
{
return match (strtolower($state)) {
'open', 'connected' => StatusConnectionEnum::OPEN,
'connecting' => StatusConnectionEnum::CONNECTING,
'close', 'closed', 'disconnected' => StatusConnectionEnum::CLOSE,
default => StatusConnectionEnum::REFUSED,
};
}
public function isConnected(): bool
{
return $this->status === StatusConnectionEnum::OPEN;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Data\Webhooks;
use Spatie\LaravelData\Data;
use WallaceMartinss\FilamentEvolution\Data\MessageData;
use WallaceMartinss\FilamentEvolution\Enums\MessageDirectionEnum;
class MessageUpsertData extends Data
{
public function __construct(
public string $instanceName,
public MessageData $message,
public array $rawData = [],
) {}
public static function fromWebhook(array $data): self
{
$messageData = $data['data'] ?? $data;
return new self(
instanceName: $data['instance'] ?? $data['instanceName'] ?? '',
message: MessageData::fromApiResponse(
$messageData,
self::detectDirection($messageData)
),
rawData: $data,
);
}
protected static function detectDirection(array $data): MessageDirectionEnum
{
$key = $data['key'] ?? [];
if (isset($key['fromMe']) && $key['fromMe'] === true) {
return MessageDirectionEnum::OUTGOING;
}
return MessageDirectionEnum::INCOMING;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Data\Webhooks;
use Spatie\LaravelData\Data;
class QrCodeUpdatedData extends Data
{
public function __construct(
public string $instanceName,
public ?string $code = null,
public ?string $base64 = null,
public ?string $pairingCode = null,
) {}
public static function fromWebhook(array $data): self
{
$qrData = $data['data'] ?? $data;
return new self(
instanceName: $data['instance'] ?? $data['instanceName'] ?? '',
code: $qrData['qrcode']['code'] ?? $qrData['code'] ?? null,
base64: $qrData['qrcode']['base64'] ?? $qrData['base64'] ?? null,
pairingCode: $qrData['qrcode']['pairingCode'] ?? $qrData['pairingCode'] ?? null,
);
}
public function hasQrCode(): bool
{
return ! empty($this->base64) || ! empty($this->code);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Database\Migrations\Concerns;
use Illuminate\Database\Schema\Blueprint;
trait HasTenantColumn
{
/**
* Add tenant column to the table based on config.
*/
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);
}
/**
* 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');
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum MessageDirectionEnum: string implements HasColor, HasIcon, HasLabel
{
case INCOMING = 'incoming';
case OUTGOING = 'outgoing';
public function getLabel(): string
{
return match ($this) {
self::INCOMING => __('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;
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum MessageStatusEnum: string implements HasColor, HasIcon, HasLabel
{
case PENDING = 'pending';
case SENT = 'sent';
case DELIVERED = 'delivered';
case READ = 'read';
case FAILED = 'failed';
public function getLabel(): string
{
return match ($this) {
self::PENDING => __('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;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum MessageTypeEnum: string implements HasColor, HasIcon, HasLabel
{
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 => __('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;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum StatusConnectionEnum: string implements HasColor, HasIcon, HasLabel
{
case CLOSE = 'close';
case OPEN = 'open';
case CONNECTING = 'connecting';
case REFUSED = 'refused';
public function getLabel(): string
{
return match ($this) {
self::OPEN => __('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;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Enums;
use Filament\Support\Contracts\HasLabel;
enum WebhookEventEnum: string implements HasLabel
{
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 getLabel(): string
{
return match ($this) {
self::APPLICATION_STARTUP => __('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;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
class InstanceConnected
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public WhatsappInstance $instance,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
class InstanceDisconnected
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public WhatsappInstance $instance,
public ?string $reason = null,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use WallaceMartinss\FilamentEvolution\Data\MessageData;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
class MessageReceived
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public WhatsappInstance $instance,
public MessageData $message,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use WallaceMartinss\FilamentEvolution\Data\QrCodeData;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
class QrCodeUpdated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public WhatsappInstance $instance,
public QrCodeData $qrCodeData,
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Exceptions;
use Exception;
class EvolutionApiException extends Exception
{
public function __construct(
string $message = 'Evolution API Error',
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource\Pages;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
class WhatsappInstanceResource extends Resource
{
protected static ?string $model = WhatsappInstance::class;
public static function getNavigationSort(): ?int
{
return config('filament-evolution.filament.navigation_sort', 100);
}
public static function getNavigationIcon(): string|Heroicon|null
{
return Heroicon::ChatBubbleLeftRight;
}
public static function getNavigationGroup(): ?string
{
return __('filament-evolution::resource.navigation_group');
}
public static function getNavigationLabel(): string
{
return __('filament-evolution::resource.navigation_label');
}
public static function getModelLabel(): string
{
return __('filament-evolution::resource.model_label');
}
public static function getPluralModelLabel(): string
{
return __('filament-evolution::resource.plural_model_label');
}
public static function form(Schema $form): Schema
{
return $form
->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'),
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource\Pages;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
use WallaceMartinss\FilamentEvolution\Exceptions\EvolutionApiException;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource;
use WallaceMartinss\FilamentEvolution\Services\EvolutionClient;
class CreateWhatsappInstance extends CreateRecord
{
protected static string $resource = WhatsappInstanceResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['status'] = StatusConnectionEnum::CLOSE;
return $data;
}
protected function afterCreate(): void
{
try {
$client = app(EvolutionClient::class);
// Create instance in Evolution API
$response = $client->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
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource;
class EditWhatsappInstance extends EditRecord
{
protected static string $resource = WhatsappInstanceResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
protected function getSavedNotification(): ?Notification
{
return Notification::make()
->success()
->title(__('filament-evolution::resource.messages.updated'));
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
class ListWhatsappInstances extends ListRecords
{
protected static string $resource = WhatsappInstanceResource::class;
protected string $view = 'filament-evolution::filament.pages.list-whatsapp-instances';
#[Url(except: '')]
public ?string $connectInstanceId = null;
public ?WhatsappInstance $connectInstance = null;
public bool $showQrCodeModal = false;
public function mount(): void
{
parent::mount();
if ($this->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(),
];
}
}

View File

@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource\Pages;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Icons\Heroicon;
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
use WallaceMartinss\FilamentEvolution\Exceptions\EvolutionApiException;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource;
use WallaceMartinss\FilamentEvolution\Services\EvolutionClient;
class ViewWhatsappInstance extends ViewRecord
{
protected static string $resource = WhatsappInstanceResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('connect')
->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(),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution;
use Filament\Contracts\Plugin;
use Filament\Panel;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource;
class FilamentEvolutionPlugin implements Plugin
{
protected bool $hasWhatsappInstanceResource = true;
public static function make(): static
{
return app(static::class);
}
public static function get(): static
{
/** @var static $plugin */
$plugin = filament(app(static::class)->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;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution;
use Livewire\Livewire;
use Spatie\LaravelPackageTools\Commands\InstallCommand;
use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider;
use WallaceMartinss\FilamentEvolution\Livewire\QrCodeDisplay;
use WallaceMartinss\FilamentEvolution\Services\EvolutionClient;
class FilamentEvolutionServiceProvider extends PackageServiceProvider
{
public static string $name = 'filament-evolution';
public function configurePackage(Package $package): void
{
$package
->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);
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Log;
use WallaceMartinss\FilamentEvolution\Jobs\ProcessWebhookJob;
use WallaceMartinss\FilamentEvolution\Models\WhatsappWebhook;
class WebhookController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
// Verify webhook secret if configured
if (! $this->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,
]);
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use WallaceMartinss\FilamentEvolution\Data\Webhooks\ConnectionUpdateData;
use WallaceMartinss\FilamentEvolution\Data\Webhooks\MessageUpsertData;
use WallaceMartinss\FilamentEvolution\Data\Webhooks\QrCodeUpdatedData;
use WallaceMartinss\FilamentEvolution\Enums\WebhookEventEnum;
use WallaceMartinss\FilamentEvolution\Events\InstanceConnected;
use WallaceMartinss\FilamentEvolution\Events\InstanceDisconnected;
use WallaceMartinss\FilamentEvolution\Events\MessageReceived;
use WallaceMartinss\FilamentEvolution\Events\QrCodeUpdated;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
use WallaceMartinss\FilamentEvolution\Models\WhatsappWebhook;
class ProcessWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(
public string $event,
public array $payload,
public ?int $webhookId = null,
) {
$this->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,
]);
}
}
}

121
src/Jobs/SendMessageJob.php Normal file
View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use WallaceMartinss\FilamentEvolution\Enums\MessageStatusEnum;
use WallaceMartinss\FilamentEvolution\Enums\MessageTypeEnum;
use WallaceMartinss\FilamentEvolution\Exceptions\EvolutionApiException;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
use WallaceMartinss\FilamentEvolution\Models\WhatsappMessage;
use WallaceMartinss\FilamentEvolution\Services\EvolutionClient;
class SendMessageJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 3;
public int $backoff = 10;
public function __construct(
public WhatsappInstance $instance,
public string $phone,
public MessageTypeEnum $type,
public array $content,
public ?int $messageId = null,
) {
$this->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);
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Livewire;
use Livewire\Attributes\Computed;
use Livewire\Component;
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
use WallaceMartinss\FilamentEvolution\Exceptions\EvolutionApiException;
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
use WallaceMartinss\FilamentEvolution\Services\EvolutionClient;
class QrCodeDisplay extends Component
{
public WhatsappInstance $instance;
public ?string $qrCode = null;
public ?string $pairingCode = null;
public bool $isConnected = false;
public bool $isLoading = true;
public ?string $error = null;
public int $qrCodeExpiresIn = 30;
public int $qrCodeTtl = 30;
public function mount(WhatsappInstance $instance): void
{
$this->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');
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
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 to filter by tenant
static::addGlobalScope('tenant', function (Builder $query) {
$tenantColumn = config('filament-evolution.tenancy.column', 'team_id');
if (function_exists('filament') && filament()->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');
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
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 HasFactory;
use HasTenant;
use HasUuids;
use SoftDeletes;
protected $table = 'whatsapp_instances';
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,
], 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 ?? '');
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
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 HasFactory;
use HasTenant;
use HasUuids;
protected $table = 'whatsapp_messages';
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;
}
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,
]);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use WallaceMartinss\FilamentEvolution\Enums\WebhookEventEnum;
use WallaceMartinss\FilamentEvolution\Models\Concerns\HasTenant;
class WhatsappWebhook extends Model
{
use HasFactory;
use HasTenant;
protected $table = 'whatsapp_webhooks';
protected $fillable = [
'instance_id',
'event',
'payload',
'processed',
'error',
'processing_time_ms',
];
protected function casts(): array
{
return [
'event' => 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);
}
}

View File

@@ -0,0 +1,446 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use WallaceMartinss\FilamentEvolution\Exceptions\EvolutionApiException;
class EvolutionClient
{
protected string $baseUrl;
protected string $apiKey;
protected int $timeout;
public function __construct()
{
$this->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);
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Tests\Feature;
use WallaceMartinss\FilamentEvolution\Tests\TestCase;
class WebhookControllerTest extends TestCase
{
public function test_webhook_endpoint_returns_success(): void
{
$payload = [
'event' => '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();
}
}

42
tests/TestCase.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase as Orchestra;
use WallaceMartinss\FilamentEvolution\FilamentEvolutionServiceProvider;
abstract class TestCase extends Orchestra
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Tests\Unit;
use WallaceMartinss\FilamentEvolution\Services\EvolutionClient;
use WallaceMartinss\FilamentEvolution\Tests\TestCase;
class EvolutionClientTest extends TestCase
{
public function test_client_can_be_instantiated(): void
{
$client = new EvolutionClient();
$this->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());
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Tests\Unit;
use WallaceMartinss\FilamentEvolution\Data\InstanceData;
use WallaceMartinss\FilamentEvolution\Tests\TestCase;
class InstanceDataTest extends TestCase
{
public function test_can_create_instance_data(): void
{
$data = new InstanceData(
instanceName: 'test-instance',
number: '5511999999999',
qrcode: true,
);
$this->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);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Tests\Unit;
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
use WallaceMartinss\FilamentEvolution\Tests\TestCase;
class StatusConnectionEnumTest extends TestCase
{
public function test_enum_has_correct_values(): void
{
$this->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());
}
}