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:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
2340
ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
55
CHANGELOG.md
Normal file
55
CHANGELOG.md
Normal 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
21
LICENSE.md
Normal 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
200
README.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Filament Evolution - WhatsApp Connector
|
||||
|
||||
[](https://packagist.org/packages/wallacemartinss/filament-whatsapp-conector)
|
||||
[](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
74
composer.json
Normal 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
|
||||
}
|
||||
144
config/filament-evolution.php
Normal file
144
config/filament-evolution.php
Normal 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'),
|
||||
],
|
||||
];
|
||||
49
database/migrations/create_whatsapp_instances_table.php.stub
Normal file
49
database/migrations/create_whatsapp_instances_table.php.stub
Normal 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');
|
||||
}
|
||||
};
|
||||
51
database/migrations/create_whatsapp_messages_table.php.stub
Normal file
51
database/migrations/create_whatsapp_messages_table.php.stub
Normal 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');
|
||||
}
|
||||
};
|
||||
43
database/migrations/create_whatsapp_webhooks_table.php.stub
Normal file
43
database/migrations/create_whatsapp_webhooks_table.php.stub
Normal 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
25
phpunit.xml
Normal 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>
|
||||
51
resources/lang/en/enums.php
Normal file
51
resources/lang/en/enums.php
Normal 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',
|
||||
],
|
||||
];
|
||||
21
resources/lang/en/qrcode.php
Normal file
21
resources/lang/en/qrcode.php
Normal 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',
|
||||
];
|
||||
60
resources/lang/en/resource.php
Normal file
60
resources/lang/en/resource.php
Normal 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',
|
||||
],
|
||||
];
|
||||
51
resources/lang/pt_BR/enums.php
Normal file
51
resources/lang/pt_BR/enums.php
Normal 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',
|
||||
],
|
||||
];
|
||||
21
resources/lang/pt_BR/qrcode.php
Normal file
21
resources/lang/pt_BR/qrcode.php
Normal 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',
|
||||
];
|
||||
60
resources/lang/pt_BR/resource.php
Normal file
60
resources/lang/pt_BR/resource.php
Normal 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',
|
||||
],
|
||||
];
|
||||
3
resources/views/components/qr-code-modal.blade.php
Normal file
3
resources/views/components/qr-code-modal.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
@livewire('filament-evolution::qr-code-display', ['instance' => $instance])
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
162
resources/views/livewire/qr-code-display.blade.php
Normal file
162
resources/views/livewire/qr-code-display.blade.php
Normal 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>
|
||||
3
resources/views/livewire/qr-code-modal.blade.php
Normal file
3
resources/views/livewire/qr-code-modal.blade.php
Normal 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
11
routes/api.php
Normal 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
39
src/Data/ContactData.php
Normal 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
66
src/Data/InstanceData.php
Normal 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
94
src/Data/MessageData.php
Normal 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
37
src/Data/QrCodeData.php
Normal 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);
|
||||
}
|
||||
}
|
||||
43
src/Data/Webhooks/ConnectionUpdateData.php
Normal file
43
src/Data/Webhooks/ConnectionUpdateData.php
Normal 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;
|
||||
}
|
||||
}
|
||||
43
src/Data/Webhooks/MessageUpsertData.php
Normal file
43
src/Data/Webhooks/MessageUpsertData.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
src/Data/Webhooks/QrCodeUpdatedData.php
Normal file
34
src/Data/Webhooks/QrCodeUpdatedData.php
Normal 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);
|
||||
}
|
||||
}
|
||||
56
src/Database/Migrations/Concerns/HasTenantColumn.php
Normal file
56
src/Database/Migrations/Concerns/HasTenantColumn.php
Normal 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');
|
||||
}
|
||||
}
|
||||
49
src/Enums/MessageDirectionEnum.php
Normal file
49
src/Enums/MessageDirectionEnum.php
Normal 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;
|
||||
}
|
||||
}
|
||||
66
src/Enums/MessageStatusEnum.php
Normal file
66
src/Enums/MessageStatusEnum.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/Enums/MessageTypeEnum.php
Normal file
73
src/Enums/MessageTypeEnum.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/Enums/StatusConnectionEnum.php
Normal file
62
src/Enums/StatusConnectionEnum.php
Normal 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;
|
||||
}
|
||||
}
|
||||
77
src/Enums/WebhookEventEnum.php
Normal file
77
src/Enums/WebhookEventEnum.php
Normal 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;
|
||||
}
|
||||
}
|
||||
21
src/Events/InstanceConnected.php
Normal file
21
src/Events/InstanceConnected.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
22
src/Events/InstanceDisconnected.php
Normal file
22
src/Events/InstanceDisconnected.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
23
src/Events/MessageReceived.php
Normal file
23
src/Events/MessageReceived.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
23
src/Events/QrCodeUpdated.php
Normal file
23
src/Events/QrCodeUpdated.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
18
src/Exceptions/EvolutionApiException.php
Normal file
18
src/Exceptions/EvolutionApiException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
211
src/Filament/Resources/WhatsappInstanceResource.php
Normal file
211
src/Filament/Resources/WhatsappInstanceResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
src/FilamentEvolutionPlugin.php
Normal file
53
src/FilamentEvolutionPlugin.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
src/FilamentEvolutionServiceProvider.php
Normal file
51
src/FilamentEvolutionServiceProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
84
src/Http/Controllers/WebhookController.php
Normal file
84
src/Http/Controllers/WebhookController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
206
src/Jobs/ProcessWebhookJob.php
Normal file
206
src/Jobs/ProcessWebhookJob.php
Normal 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
121
src/Jobs/SendMessageJob.php
Normal 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);
|
||||
}
|
||||
}
|
||||
143
src/Livewire/QrCodeDisplay.php
Normal file
143
src/Livewire/QrCodeDisplay.php
Normal 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');
|
||||
}
|
||||
}
|
||||
93
src/Models/Concerns/HasTenant.php
Normal file
93
src/Models/Concerns/HasTenant.php
Normal 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');
|
||||
}
|
||||
}
|
||||
105
src/Models/WhatsappInstance.php
Normal file
105
src/Models/WhatsappInstance.php
Normal 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 ?? '');
|
||||
}
|
||||
}
|
||||
124
src/Models/WhatsappMessage.php
Normal file
124
src/Models/WhatsappMessage.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
95
src/Models/WhatsappWebhook.php
Normal file
95
src/Models/WhatsappWebhook.php
Normal 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);
|
||||
}
|
||||
}
|
||||
446
src/Services/EvolutionClient.php
Normal file
446
src/Services/EvolutionClient.php
Normal 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);
|
||||
}
|
||||
}
|
||||
67
tests/Feature/WebhookControllerTest.php
Normal file
67
tests/Feature/WebhookControllerTest.php
Normal 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
42
tests/TestCase.php
Normal 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);
|
||||
}
|
||||
}
|
||||
32
tests/Unit/EvolutionClientTest.php
Normal file
32
tests/Unit/EvolutionClientTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
58
tests/Unit/InstanceDataTest.php
Normal file
58
tests/Unit/InstanceDataTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
43
tests/Unit/StatusConnectionEnumTest.php
Normal file
43
tests/Unit/StatusConnectionEnumTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user