feat: add SendWhatsappMessageAction, WhatsappService and CanSendWhatsappMessage trait
- Add SendWhatsappMessageAction for sending messages from any Filament page/table - Support for text, image, video, audio and document messages - numberFrom() and instanceFrom() methods for record-based values - hideInstanceSelect(), hideNumberInput(), textOnly() options - allowedTypes() for limiting message types - disk() for custom storage - Add WhatsappService with support for local and S3 storage - Automatic base64 encoding for media files - Temporary URL generation for S3 - Add CanSendWhatsappMessage trait for service integration - sendWhatsappText, sendWhatsappImage, sendWhatsappVideo, etc. - Customizable instance selection via getWhatsappInstanceId() - Add Whatsapp Facade for quick access - Fix Evolution API v2 payload format (flat structure, raw base64) - Update README with comprehensive documentation - Fix tests (WebhookControllerTest, StatusConnectionEnumTest)
This commit is contained in:
44
CHANGELOG.md
44
CHANGELOG.md
@@ -2,6 +2,49 @@
|
||||
|
||||
All notable changes to `filament-whatsapp-conector` will be documented in this file.
|
||||
|
||||
## [0.2.0] - 2025-12-07
|
||||
|
||||
### Added
|
||||
|
||||
#### SendWhatsappMessageAction
|
||||
- Reusable Filament Action for sending WhatsApp messages from anywhere
|
||||
- Support for all message types: Text, Image, Video, Audio, Document, Location, Contact
|
||||
- Dynamic form with conditional fields based on message type
|
||||
- File upload with configurable disk (local, S3, etc.)
|
||||
- Pre-fill support for number, instance, and message
|
||||
- Option to restrict allowed message types (e.g., `textOnly()`)
|
||||
- Full i18n support (English and Portuguese)
|
||||
|
||||
#### CanSendWhatsappMessage Trait
|
||||
- Easy-to-use trait for sending messages from any service class
|
||||
- Methods: `sendWhatsappText()`, `sendWhatsappImage()`, `sendWhatsappVideo()`, etc.
|
||||
- Automatic instance selection with `getWhatsappInstanceId()` override support
|
||||
- `hasWhatsappInstance()` check for conditional messaging
|
||||
|
||||
#### WhatsappService
|
||||
- High-level service for message sending
|
||||
- Automatic phone number formatting (adds country code if missing)
|
||||
- Smart file URL handling (local URL vs S3 temporary URLs)
|
||||
- Support for all Evolution API message types
|
||||
- Instance validation before sending
|
||||
|
||||
#### Whatsapp Facade
|
||||
- Quick access to WhatsappService methods
|
||||
- `Whatsapp::sendText()`, `Whatsapp::sendImage()`, etc.
|
||||
- `Whatsapp::getConnectedInstances()` for listing active instances
|
||||
|
||||
#### Media Configuration
|
||||
- New config options for media storage:
|
||||
- `EVOLUTION_MEDIA_DISK` - Storage disk for uploads
|
||||
- `EVOLUTION_MEDIA_DIRECTORY` - Upload directory
|
||||
- `EVOLUTION_MEDIA_MAX_SIZE` - Max file size in KB
|
||||
- `EVOLUTION_DEFAULT_INSTANCE` - Default instance for trait usage
|
||||
|
||||
### Fixed
|
||||
- Added missing `sendVideo()` method to EvolutionClient
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2025-12-07
|
||||
|
||||
### Added
|
||||
@@ -53,3 +96,4 @@ EVOLUTION_QRCODE_EXPIRES=30
|
||||
EVOLUTION_TENANCY_ENABLED=true
|
||||
EVOLUTION_TENANT_COLUMN=team_id
|
||||
```
|
||||
|
||||
|
||||
395
README.md
395
README.md
@@ -1,7 +1,7 @@
|
||||
# Filament Evolution - WhatsApp Connector
|
||||
|
||||
[](https://packagist.org/packages/wallacemartinss/filament-whatsapp-conector)
|
||||
[](https://packagist.org/packages/wallacemartinss/filament-whatsapp-conector)
|
||||
[](https://packagist.org/packages/wallacemartinss/filament-evolution)
|
||||
[](https://packagist.org/packages/wallacemartinss/filament-evolution)
|
||||
|
||||
A Filament v4 plugin for WhatsApp integration using [Evolution API v2](https://doc.evolution-api.com/).
|
||||
|
||||
@@ -11,6 +11,9 @@ A Filament v4 plugin for WhatsApp integration using [Evolution API v2](https://d
|
||||
- 🏢 **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.)
|
||||
- 💬 **Message Sending** - Send text, images, videos, audio, documents and more
|
||||
- 🎯 **Filament Action** - Ready-to-use action for sending messages from anywhere
|
||||
- 🔧 **Service Trait** - Easily integrate message sending into your own services
|
||||
- 🔐 **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)
|
||||
@@ -23,62 +26,31 @@ A Filament v4 plugin for WhatsApp integration using [Evolution API v2](https://d
|
||||
- Filament v4
|
||||
- Evolution API v2 instance
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Install via Composer
|
||||
|
||||
```bash
|
||||
composer require wallacemartinss/filament-whatsapp-conector
|
||||
composer require wallacemartinss/filament-evolution
|
||||
```
|
||||
|
||||
Publish the config file:
|
||||
### Step 2: Publish Configuration
|
||||
|
||||
```bash
|
||||
php artisan vendor:publish --tag="filament-evolution-config"
|
||||
```
|
||||
|
||||
Run the migrations:
|
||||
### Step 3: Run Migrations
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
## Configuration
|
||||
### Step 4: Register the Plugin
|
||||
|
||||
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:
|
||||
Add the plugin to your Filament Panel Provider:
|
||||
|
||||
```php
|
||||
use WallaceMartinss\FilamentEvolution\FilamentEvolutionPlugin;
|
||||
@@ -92,6 +64,59 @@ public function panel(Panel $panel): Panel
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Add these variables to your `.env` file:
|
||||
|
||||
### Required Settings
|
||||
|
||||
```env
|
||||
# Evolution API Connection
|
||||
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
|
||||
```
|
||||
|
||||
### Optional Settings
|
||||
|
||||
```env
|
||||
# QR Code Settings
|
||||
EVOLUTION_QRCODE_EXPIRES=30
|
||||
|
||||
# Instance Defaults
|
||||
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
|
||||
|
||||
# Media Storage
|
||||
EVOLUTION_MEDIA_DISK=public
|
||||
EVOLUTION_MEDIA_DIRECTORY=whatsapp-media
|
||||
EVOLUTION_MEDIA_MAX_SIZE=16384
|
||||
```
|
||||
|
||||
### Multi-Tenancy Settings
|
||||
|
||||
```env
|
||||
EVOLUTION_TENANCY_ENABLED=true
|
||||
EVOLUTION_TENANT_COLUMN=team_id
|
||||
EVOLUTION_TENANT_TABLE=teams
|
||||
EVOLUTION_TENANT_MODEL=App\Models\Team
|
||||
EVOLUTION_TENANT_COLUMN_TYPE=uuid
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Instance Management
|
||||
|
||||
### Creating an Instance
|
||||
|
||||
1. Navigate to **WhatsApp > Instances**
|
||||
@@ -103,8 +128,6 @@ public function panel(Panel $panel): Panel
|
||||
|
||||
### Instance Settings
|
||||
|
||||
When creating an instance, you can configure:
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| **Reject Calls** | Automatically reject incoming calls |
|
||||
@@ -115,17 +138,263 @@ When creating an instance, you can configure:
|
||||
| **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:
|
||||
## Sending Messages
|
||||
|
||||
- `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
|
||||
The plugin provides three ways to send WhatsApp messages:
|
||||
|
||||
1. **Filament Action** - For UI-based sending in tables, pages and widgets
|
||||
2. **Whatsapp Facade** - For quick message sending anywhere
|
||||
3. **CanSendWhatsappMessage Trait** - For integration into your services
|
||||
|
||||
### 1. Using the Filament Action
|
||||
|
||||
The `SendWhatsappMessageAction` can be used in any Filament page, resource, or widget.
|
||||
|
||||
#### Basic Usage
|
||||
|
||||
```php
|
||||
use WallaceMartinss\FilamentEvolution\Actions\SendWhatsappMessageAction;
|
||||
|
||||
// In a table
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->actions([
|
||||
SendWhatsappMessageAction::make(),
|
||||
]);
|
||||
}
|
||||
|
||||
// In a page header
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
SendWhatsappMessageAction::make(),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### Pre-filling Values
|
||||
|
||||
```php
|
||||
SendWhatsappMessageAction::make()
|
||||
->number('5511999999999') // Default phone number
|
||||
->instance($instanceId) // Default instance
|
||||
->message('Hello World!') // Default message
|
||||
```
|
||||
|
||||
#### Using with Table Records
|
||||
|
||||
Get the phone number automatically from the record:
|
||||
|
||||
```php
|
||||
// Using attribute name
|
||||
SendWhatsappMessageAction::make()
|
||||
->numberFrom('phone'),
|
||||
|
||||
// Using dot notation for relationships
|
||||
SendWhatsappMessageAction::make()
|
||||
->numberFrom('contact.phone'),
|
||||
|
||||
// Using closure for custom logic
|
||||
SendWhatsappMessageAction::make()
|
||||
->numberFrom(fn ($record) => $record->celular ?? $record->telefone),
|
||||
|
||||
// Also set instance from record
|
||||
SendWhatsappMessageAction::make()
|
||||
->numberFrom('phone')
|
||||
->instanceFrom('whatsapp_instance_id'),
|
||||
```
|
||||
|
||||
#### Hiding Form Fields
|
||||
|
||||
```php
|
||||
SendWhatsappMessageAction::make()
|
||||
->hideInstanceSelect() // Hide instance selector
|
||||
->hideNumberInput() // Hide phone number input
|
||||
->textOnly() // Only allow text messages (hide file upload)
|
||||
```
|
||||
|
||||
#### Limiting Message Types
|
||||
|
||||
```php
|
||||
use WallaceMartinss\FilamentEvolution\Enums\MessageTypeEnum;
|
||||
|
||||
SendWhatsappMessageAction::make()
|
||||
->allowedTypes([
|
||||
MessageTypeEnum::TEXT,
|
||||
MessageTypeEnum::IMAGE,
|
||||
]);
|
||||
```
|
||||
|
||||
#### Custom Storage Disk
|
||||
|
||||
```php
|
||||
SendWhatsappMessageAction::make()
|
||||
->disk('s3') // Use S3 for file uploads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Using the Whatsapp Facade
|
||||
|
||||
For programmatic message sending from anywhere in your application:
|
||||
|
||||
```php
|
||||
use WallaceMartinss\FilamentEvolution\Facades\Whatsapp;
|
||||
|
||||
// Send text
|
||||
Whatsapp::sendText($instanceId, '5511999999999', 'Hello!');
|
||||
|
||||
// Send image with caption
|
||||
Whatsapp::sendImage($instanceId, '5511999999999', 'path/to/image.jpg', 'Check this out!');
|
||||
|
||||
// Send video with caption
|
||||
Whatsapp::sendVideo($instanceId, '5511999999999', 'path/to/video.mp4', 'Watch this!');
|
||||
|
||||
// Send audio
|
||||
Whatsapp::sendAudio($instanceId, '5511999999999', 'path/to/audio.mp3');
|
||||
|
||||
// Send document
|
||||
Whatsapp::sendDocument($instanceId, '5511999999999', 'path/to/file.pdf', 'report.pdf', 'Monthly Report');
|
||||
|
||||
// Send location
|
||||
Whatsapp::sendLocation($instanceId, '5511999999999', -23.5505, -46.6333, 'My Office', 'São Paulo, SP');
|
||||
|
||||
// Send contact card
|
||||
Whatsapp::sendContact($instanceId, '5511999999999', 'John Doe', '+5511888888888');
|
||||
|
||||
// Generic send method
|
||||
Whatsapp::send($instanceId, '5511999999999', 'text', 'Hello World!');
|
||||
Whatsapp::send($instanceId, '5511999999999', 'image', 'path/to/image.jpg', ['caption' => 'Nice!']);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Using the Trait in Your Services
|
||||
|
||||
Add the `CanSendWhatsappMessage` trait to integrate message sending into your business logic:
|
||||
|
||||
```php
|
||||
use WallaceMartinss\FilamentEvolution\Concerns\CanSendWhatsappMessage;
|
||||
|
||||
class InvoiceService
|
||||
{
|
||||
use CanSendWhatsappMessage;
|
||||
|
||||
public function sendPaymentReminder(Invoice $invoice): void
|
||||
{
|
||||
$this->sendWhatsappText(
|
||||
$invoice->customer->phone,
|
||||
"Hello {$invoice->customer->name}, your invoice #{$invoice->number} is due on {$invoice->due_date->format('d/m/Y')}."
|
||||
);
|
||||
}
|
||||
|
||||
public function sendInvoicePdf(Invoice $invoice): void
|
||||
{
|
||||
$this->sendWhatsappDocument(
|
||||
$invoice->customer->phone,
|
||||
$invoice->pdf_path,
|
||||
"invoice-{$invoice->number}.pdf",
|
||||
"Your invoice is ready!"
|
||||
);
|
||||
}
|
||||
|
||||
public function sendPromoImage(Customer $customer, string $imagePath): void
|
||||
{
|
||||
$this->sendWhatsappImage(
|
||||
$customer->phone,
|
||||
$imagePath,
|
||||
"Special promotion just for you! 🎉"
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Available Trait Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `sendWhatsappText($number, $message)` | Send text message |
|
||||
| `sendWhatsappImage($number, $path, $caption)` | Send image |
|
||||
| `sendWhatsappVideo($number, $path, $caption)` | Send video |
|
||||
| `sendWhatsappAudio($number, $path)` | Send audio |
|
||||
| `sendWhatsappDocument($number, $path, $fileName, $caption)` | Send document |
|
||||
| `sendWhatsappLocation($number, $lat, $lng, $name, $address)` | Send location |
|
||||
| `sendWhatsappContact($number, $contactName, $contactNumber)` | Send contact card |
|
||||
| `sendWhatsappMessage($number, $type, $content, $options)` | Generic send method |
|
||||
| `hasWhatsappInstance()` | Check if an instance is available |
|
||||
| `getConnectedWhatsappInstances()` | Get all connected instances |
|
||||
|
||||
#### Customizing the Instance Selection
|
||||
|
||||
Override `getWhatsappInstanceId()` to use a specific instance:
|
||||
|
||||
```php
|
||||
class TenantInvoiceService
|
||||
{
|
||||
use CanSendWhatsappMessage;
|
||||
|
||||
protected function getWhatsappInstanceId(): ?string
|
||||
{
|
||||
// Use tenant's specific WhatsApp instance
|
||||
return auth()->user()->tenant->whatsapp_instance_id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Storage Support
|
||||
|
||||
The plugin supports both local and cloud storage (S3, etc.) for media files.
|
||||
|
||||
### Configuration
|
||||
|
||||
```env
|
||||
EVOLUTION_MEDIA_DISK=public
|
||||
EVOLUTION_MEDIA_DIRECTORY=whatsapp-media
|
||||
EVOLUTION_MEDIA_MAX_SIZE=16384
|
||||
```
|
||||
|
||||
### Using Different Disks
|
||||
|
||||
```php
|
||||
// Using the Facade with S3
|
||||
Whatsapp::sendDocument($instanceId, $number, 'documents/report.pdf', 'report.pdf', null, 's3');
|
||||
|
||||
// Using the Action with custom disk
|
||||
SendWhatsappMessageAction::make()->disk('s3');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
The plugin includes a webhook endpoint to receive events from Evolution API.
|
||||
|
||||
### Available Events
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `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 |
|
||||
|
||||
### Webhook URL
|
||||
|
||||
Configure this URL in your Evolution API:
|
||||
|
||||
```
|
||||
https://your-app.com/api/webhooks/evolution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Multi-Tenancy
|
||||
|
||||
@@ -135,20 +404,21 @@ The plugin supports Filament's native multi-tenancy. When enabled:
|
||||
- Models automatically scope queries by tenant
|
||||
- Records are auto-assigned to current tenant on creation
|
||||
|
||||
### Enable Multi-Tenancy
|
||||
### Configuration
|
||||
|
||||
```env
|
||||
EVOLUTION_TENANCY_ENABLED=true
|
||||
EVOLUTION_TENANT_COLUMN=team_id
|
||||
EVOLUTION_TENANT_TABLE=teams
|
||||
EVOLUTION_TENANT_MODEL=App\Models\Team
|
||||
EVOLUTION_TENANT_COLUMN_TYPE=uuid
|
||||
```
|
||||
|
||||
## API
|
||||
---
|
||||
|
||||
### Evolution Client
|
||||
## Using the Evolution Client Directly
|
||||
|
||||
You can use the Evolution client directly:
|
||||
For advanced use cases, you can use the Evolution client directly:
|
||||
|
||||
```php
|
||||
use WallaceMartinss\FilamentEvolution\Services\EvolutionClient;
|
||||
@@ -166,14 +436,21 @@ $state = $client->getConnectionState('my-instance');
|
||||
|
||||
// Send text message
|
||||
$client->sendText('my-instance', '5511999999999', 'Hello World!');
|
||||
|
||||
// Send image (path is base64 encoded by the service)
|
||||
$client->sendImage('my-instance', '5511999999999', $base64Content, 'image.jpg', 'Check this!');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
composer test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.
|
||||
@@ -189,10 +466,6 @@ Please review [our security policy](../../security/policy) on how to report secu
|
||||
## 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
|
||||
|
||||
@@ -105,6 +105,31 @@ return [
|
||||
'webhooks' => env('EVOLUTION_QUEUE_WEBHOOKS', 'default'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Media Storage Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configuration for media file uploads when sending messages.
|
||||
|
|
||||
*/
|
||||
'media' => [
|
||||
'disk' => env('EVOLUTION_MEDIA_DISK', 'public'),
|
||||
'directory' => env('EVOLUTION_MEDIA_DIRECTORY', 'whatsapp-media'),
|
||||
'max_size' => env('EVOLUTION_MEDIA_MAX_SIZE', 16384), // KB (16MB default)
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Instance
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The default instance ID to use when sending messages without specifying one.
|
||||
| Useful for simple use cases with a single WhatsApp instance.
|
||||
|
|
||||
*/
|
||||
'default_instance' => env('EVOLUTION_DEFAULT_INSTANCE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Logging
|
||||
|
||||
42
resources/lang/en/action.php
Normal file
42
resources/lang/en/action.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'send_message' => 'Send WhatsApp Message',
|
||||
'modal_heading' => 'Send WhatsApp Message',
|
||||
'modal_description' => 'Send a message to a WhatsApp number.',
|
||||
'send' => 'Send Message',
|
||||
|
||||
// Form fields
|
||||
'instance' => 'Instance',
|
||||
'instance_helper' => 'Select the WhatsApp instance to send the message from.',
|
||||
'number' => 'Phone Number',
|
||||
'number_helper' => 'Enter the phone number with country code (e.g., 5511999999999).',
|
||||
'type' => 'Message Type',
|
||||
'message' => 'Message',
|
||||
'message_placeholder' => 'Type your message here...',
|
||||
'caption' => 'Caption',
|
||||
'caption_placeholder' => 'Optional caption for the media...',
|
||||
'media' => 'Media File',
|
||||
'media_helper' => 'Upload the file to be sent.',
|
||||
|
||||
// Location fields
|
||||
'latitude' => 'Latitude',
|
||||
'longitude' => 'Longitude',
|
||||
'location_name' => 'Location Name',
|
||||
'location_name_placeholder' => 'e.g., My Office',
|
||||
'location_address' => 'Address',
|
||||
'location_address_placeholder' => 'e.g., 123 Main St, City',
|
||||
|
||||
// Contact fields
|
||||
'contact_name' => 'Contact Name',
|
||||
'contact_number' => 'Contact Phone',
|
||||
|
||||
// Notifications
|
||||
'success_title' => 'Message Sent!',
|
||||
'success_body' => 'Your WhatsApp message has been sent successfully.',
|
||||
'error_title' => 'Failed to Send',
|
||||
'missing_required_fields' => 'Instance ID and phone number are required.',
|
||||
'unsupported_type' => 'Unsupported message type.',
|
||||
];
|
||||
42
resources/lang/pt_BR/action.php
Normal file
42
resources/lang/pt_BR/action.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'send_message' => 'Enviar Mensagem WhatsApp',
|
||||
'modal_heading' => 'Enviar Mensagem WhatsApp',
|
||||
'modal_description' => 'Envie uma mensagem para um número do WhatsApp.',
|
||||
'send' => 'Enviar Mensagem',
|
||||
|
||||
// Form fields
|
||||
'instance' => 'Instância',
|
||||
'instance_helper' => 'Selecione a instância do WhatsApp para enviar a mensagem.',
|
||||
'number' => 'Número de Telefone',
|
||||
'number_helper' => 'Digite o número com código do país (ex: 5511999999999).',
|
||||
'type' => 'Tipo de Mensagem',
|
||||
'message' => 'Mensagem',
|
||||
'message_placeholder' => 'Digite sua mensagem aqui...',
|
||||
'caption' => 'Legenda',
|
||||
'caption_placeholder' => 'Legenda opcional para a mídia...',
|
||||
'media' => 'Arquivo de Mídia',
|
||||
'media_helper' => 'Faça upload do arquivo a ser enviado.',
|
||||
|
||||
// Location fields
|
||||
'latitude' => 'Latitude',
|
||||
'longitude' => 'Longitude',
|
||||
'location_name' => 'Nome do Local',
|
||||
'location_name_placeholder' => 'ex: Meu Escritório',
|
||||
'location_address' => 'Endereço',
|
||||
'location_address_placeholder' => 'ex: Rua Principal, 123, Cidade',
|
||||
|
||||
// Contact fields
|
||||
'contact_name' => 'Nome do Contato',
|
||||
'contact_number' => 'Telefone do Contato',
|
||||
|
||||
// Notifications
|
||||
'success_title' => 'Mensagem Enviada!',
|
||||
'success_body' => 'Sua mensagem WhatsApp foi enviada com sucesso.',
|
||||
'error_title' => 'Falha ao Enviar',
|
||||
'missing_required_fields' => 'ID da instância e número de telefone são obrigatórios.',
|
||||
'unsupported_type' => 'Tipo de mensagem não suportado.',
|
||||
];
|
||||
535
src/Actions/SendWhatsappMessageAction.php
Normal file
535
src/Actions/SendWhatsappMessageAction.php
Normal file
@@ -0,0 +1,535 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WallaceMartinss\FilamentEvolution\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\Concerns\CanCustomizeProcess;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use WallaceMartinss\FilamentEvolution\Enums\MessageTypeEnum;
|
||||
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
|
||||
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
|
||||
use WallaceMartinss\FilamentEvolution\Services\WhatsappService;
|
||||
|
||||
class SendWhatsappMessageAction extends Action
|
||||
{
|
||||
use CanCustomizeProcess;
|
||||
|
||||
protected ?string $defaultNumber = null;
|
||||
|
||||
protected ?string $defaultInstanceId = null;
|
||||
|
||||
protected ?string $defaultMessage = null;
|
||||
|
||||
protected bool $showInstanceSelect = true;
|
||||
|
||||
protected bool $showNumberInput = true;
|
||||
|
||||
protected ?string $mediaDisk = null;
|
||||
|
||||
protected array $allowedTypes = [];
|
||||
|
||||
protected string|\Closure|null $numberAttribute = null;
|
||||
|
||||
protected string|\Closure|null $instanceAttribute = null;
|
||||
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'send_whatsapp_message';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->label(__('filament-evolution::action.send_message'));
|
||||
|
||||
$this->icon(Heroicon::ChatBubbleLeftRight);
|
||||
|
||||
$this->color('success');
|
||||
|
||||
$this->modalHeading(__('filament-evolution::action.modal_heading'));
|
||||
|
||||
$this->modalDescription(__('filament-evolution::action.modal_description'));
|
||||
|
||||
$this->modalIcon(Heroicon::ChatBubbleLeftRight);
|
||||
|
||||
$this->modalSubmitActionLabel(__('filament-evolution::action.send'));
|
||||
|
||||
$this->modalWidth('lg');
|
||||
|
||||
$this->form(fn (): array => $this->getFormSchema());
|
||||
|
||||
$this->action(function (array $data): void {
|
||||
$this->sendMessage($data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default phone number.
|
||||
*/
|
||||
public function number(?string $number): static
|
||||
{
|
||||
$this->defaultNumber = $number;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default instance ID.
|
||||
*/
|
||||
public function instance(?string $instanceId): static
|
||||
{
|
||||
$this->defaultInstanceId = $instanceId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default message.
|
||||
*/
|
||||
public function message(?string $message): static
|
||||
{
|
||||
$this->defaultMessage = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the instance select field.
|
||||
*/
|
||||
public function hideInstanceSelect(bool $hide = true): static
|
||||
{
|
||||
$this->showInstanceSelect = ! $hide;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the number input field.
|
||||
*/
|
||||
public function hideNumberInput(bool $hide = true): static
|
||||
{
|
||||
$this->showNumberInput = ! $hide;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the phone number from a record attribute.
|
||||
* Can be a string (attribute name) or a closure that receives the record.
|
||||
*
|
||||
* @param string|\Closure $attribute The attribute name or a closure that receives the record
|
||||
*
|
||||
* Example usage:
|
||||
* - SendWhatsappMessageAction::make()->numberFrom('phone')
|
||||
* - SendWhatsappMessageAction::make()->numberFrom('contact.phone')
|
||||
* - SendWhatsappMessageAction::make()->numberFrom(fn ($record) => $record->phone)
|
||||
*/
|
||||
public function numberFrom(string|\Closure $attribute): static
|
||||
{
|
||||
$this->numberAttribute = $attribute;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the instance from a record attribute.
|
||||
* Can be a string (attribute name) or a closure that receives the record.
|
||||
*/
|
||||
public function instanceFrom(string|\Closure $attribute): static
|
||||
{
|
||||
$this->instanceAttribute = $attribute;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the phone number from the record.
|
||||
*/
|
||||
protected function getNumberFromRecord(mixed $record): ?string
|
||||
{
|
||||
if ($this->numberAttribute === null) {
|
||||
return $this->defaultNumber;
|
||||
}
|
||||
|
||||
if ($this->numberAttribute instanceof \Closure) {
|
||||
return ($this->numberAttribute)($record);
|
||||
}
|
||||
|
||||
// Support dot notation for nested attributes
|
||||
return data_get($record, $this->numberAttribute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the instance ID from the record.
|
||||
*/
|
||||
protected function getInstanceFromRecord(mixed $record): ?string
|
||||
{
|
||||
if ($this->instanceAttribute === null) {
|
||||
return $this->defaultInstanceId;
|
||||
}
|
||||
|
||||
if ($this->instanceAttribute instanceof \Closure) {
|
||||
return ($this->instanceAttribute)($record);
|
||||
}
|
||||
|
||||
return data_get($record, $this->instanceAttribute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the disk for media uploads.
|
||||
*/
|
||||
public function disk(?string $disk): static
|
||||
{
|
||||
$this->mediaDisk = $disk;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the allowed message types.
|
||||
*/
|
||||
public function allowedTypes(array $types): static
|
||||
{
|
||||
$this->allowedTypes = $types;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only allow text messages.
|
||||
*/
|
||||
public function textOnly(): static
|
||||
{
|
||||
return $this->allowedTypes([MessageTypeEnum::TEXT]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the form schema.
|
||||
*/
|
||||
protected function getFormSchema(): array
|
||||
{
|
||||
return [
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
$this->getInstanceSelect(),
|
||||
$this->getNumberInput(),
|
||||
]),
|
||||
|
||||
$this->getTypeSelect(),
|
||||
|
||||
$this->getMessageInput(),
|
||||
|
||||
$this->getCaptionInput(),
|
||||
|
||||
$this->getMediaUpload(),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
$this->getLatitudeInput(),
|
||||
$this->getLongitudeInput(),
|
||||
])
|
||||
->visible(fn (Get $get): bool => $get('type') === MessageTypeEnum::LOCATION->value),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
$this->getLocationNameInput(),
|
||||
$this->getLocationAddressInput(),
|
||||
])
|
||||
->visible(fn (Get $get): bool => $get('type') === MessageTypeEnum::LOCATION->value),
|
||||
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
$this->getContactNameInput(),
|
||||
$this->getContactNumberInput(),
|
||||
])
|
||||
->visible(fn (Get $get): bool => $get('type') === MessageTypeEnum::CONTACT->value),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getInstanceSelect(): Select
|
||||
{
|
||||
$action = $this;
|
||||
|
||||
return Select::make('instance_id')
|
||||
->label(__('filament-evolution::action.instance'))
|
||||
->options(function (): array {
|
||||
return WhatsappInstance::where('status', StatusConnectionEnum::OPEN)
|
||||
->pluck('name', 'id')
|
||||
->toArray();
|
||||
})
|
||||
->default(function () use ($action): ?string {
|
||||
$record = $action->getRecord();
|
||||
if ($record && $action->instanceAttribute) {
|
||||
return $action->getInstanceFromRecord($record);
|
||||
}
|
||||
|
||||
if ($action->defaultInstanceId) {
|
||||
return $action->defaultInstanceId;
|
||||
}
|
||||
|
||||
$first = WhatsappInstance::where('status', StatusConnectionEnum::OPEN)->first();
|
||||
|
||||
return $first?->id;
|
||||
})
|
||||
->required()
|
||||
->searchable()
|
||||
->preload()
|
||||
->visible($this->showInstanceSelect)
|
||||
->helperText(__('filament-evolution::action.instance_helper'));
|
||||
}
|
||||
|
||||
protected function getNumberInput(): TextInput
|
||||
{
|
||||
$action = $this;
|
||||
|
||||
return TextInput::make('number')
|
||||
->label(__('filament-evolution::action.number'))
|
||||
->default(function () use ($action): ?string {
|
||||
$record = $action->getRecord();
|
||||
if ($record && $action->numberAttribute) {
|
||||
return $action->getNumberFromRecord($record);
|
||||
}
|
||||
|
||||
return $action->defaultNumber;
|
||||
})
|
||||
->required()
|
||||
->tel()
|
||||
->placeholder('5511999999999')
|
||||
->visible($this->showNumberInput)
|
||||
->helperText(__('filament-evolution::action.number_helper'));
|
||||
}
|
||||
|
||||
protected function getTypeSelect(): Select
|
||||
{
|
||||
$options = $this->getAllowedTypeOptions();
|
||||
|
||||
return Select::make('type')
|
||||
->label(__('filament-evolution::action.type'))
|
||||
->options($options)
|
||||
->default(MessageTypeEnum::TEXT->value)
|
||||
->required()
|
||||
->live()
|
||||
->visible(count($options) > 1);
|
||||
}
|
||||
|
||||
protected function getAllowedTypeOptions(): array
|
||||
{
|
||||
$allTypes = [
|
||||
MessageTypeEnum::TEXT,
|
||||
MessageTypeEnum::IMAGE,
|
||||
MessageTypeEnum::VIDEO,
|
||||
MessageTypeEnum::AUDIO,
|
||||
MessageTypeEnum::DOCUMENT,
|
||||
MessageTypeEnum::LOCATION,
|
||||
MessageTypeEnum::CONTACT,
|
||||
];
|
||||
|
||||
$types = ! empty($this->allowedTypes) ? $this->allowedTypes : $allTypes;
|
||||
|
||||
return collect($types)
|
||||
->mapWithKeys(fn (MessageTypeEnum $type) => [$type->value => $type->getLabel()])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
protected function getMessageInput(): Textarea
|
||||
{
|
||||
return Textarea::make('message')
|
||||
->label(__('filament-evolution::action.message'))
|
||||
->default($this->defaultMessage)
|
||||
->required(fn (Get $get): bool => $get('type') === MessageTypeEnum::TEXT->value)
|
||||
->visible(fn (Get $get): bool => $get('type') === MessageTypeEnum::TEXT->value)
|
||||
->rows(4)
|
||||
->placeholder(__('filament-evolution::action.message_placeholder'));
|
||||
}
|
||||
|
||||
protected function getCaptionInput(): Textarea
|
||||
{
|
||||
return Textarea::make('caption')
|
||||
->label(__('filament-evolution::action.caption'))
|
||||
->visible(fn (Get $get): bool => in_array($get('type'), [
|
||||
MessageTypeEnum::IMAGE->value,
|
||||
MessageTypeEnum::VIDEO->value,
|
||||
MessageTypeEnum::DOCUMENT->value,
|
||||
]))
|
||||
->rows(2)
|
||||
->placeholder(__('filament-evolution::action.caption_placeholder'));
|
||||
}
|
||||
|
||||
protected function getMediaUpload(): FileUpload
|
||||
{
|
||||
return FileUpload::make('media')
|
||||
->label(__('filament-evolution::action.media'))
|
||||
->required(fn (Get $get): bool => in_array($get('type'), [
|
||||
MessageTypeEnum::IMAGE->value,
|
||||
MessageTypeEnum::VIDEO->value,
|
||||
MessageTypeEnum::AUDIO->value,
|
||||
MessageTypeEnum::DOCUMENT->value,
|
||||
]))
|
||||
->visible(fn (Get $get): bool => in_array($get('type'), [
|
||||
MessageTypeEnum::IMAGE->value,
|
||||
MessageTypeEnum::VIDEO->value,
|
||||
MessageTypeEnum::AUDIO->value,
|
||||
MessageTypeEnum::DOCUMENT->value,
|
||||
]))
|
||||
->disk($this->mediaDisk ?? config('filament-evolution.media.disk', 'public'))
|
||||
->directory(config('filament-evolution.media.directory', 'whatsapp-media'))
|
||||
->acceptedFileTypes(fn (Get $get): array => $this->getAcceptedFileTypes($get('type')))
|
||||
->maxSize(config('filament-evolution.media.max_size', 16384))
|
||||
->helperText(__('filament-evolution::action.media_helper'));
|
||||
}
|
||||
|
||||
protected function getAcceptedFileTypes(?string $type): array
|
||||
{
|
||||
return match ($type) {
|
||||
MessageTypeEnum::IMAGE->value => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
MessageTypeEnum::VIDEO->value => ['video/mp4', 'video/3gpp', 'video/quicktime'],
|
||||
MessageTypeEnum::AUDIO->value => ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/aac', 'audio/mp4'],
|
||||
MessageTypeEnum::DOCUMENT->value => [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
protected function getLatitudeInput(): TextInput
|
||||
{
|
||||
return TextInput::make('latitude')
|
||||
->label(__('filament-evolution::action.latitude'))
|
||||
->required(fn (Get $get): bool => $get('type') === MessageTypeEnum::LOCATION->value)
|
||||
->numeric()
|
||||
->step(0.000001)
|
||||
->placeholder('-23.5505');
|
||||
}
|
||||
|
||||
protected function getLongitudeInput(): TextInput
|
||||
{
|
||||
return TextInput::make('longitude')
|
||||
->label(__('filament-evolution::action.longitude'))
|
||||
->required(fn (Get $get): bool => $get('type') === MessageTypeEnum::LOCATION->value)
|
||||
->numeric()
|
||||
->step(0.000001)
|
||||
->placeholder('-46.6333');
|
||||
}
|
||||
|
||||
protected function getLocationNameInput(): TextInput
|
||||
{
|
||||
return TextInput::make('location_name')
|
||||
->label(__('filament-evolution::action.location_name'))
|
||||
->placeholder(__('filament-evolution::action.location_name_placeholder'));
|
||||
}
|
||||
|
||||
protected function getLocationAddressInput(): TextInput
|
||||
{
|
||||
return TextInput::make('location_address')
|
||||
->label(__('filament-evolution::action.location_address'))
|
||||
->placeholder(__('filament-evolution::action.location_address_placeholder'));
|
||||
}
|
||||
|
||||
protected function getContactNameInput(): TextInput
|
||||
{
|
||||
return TextInput::make('contact_name')
|
||||
->label(__('filament-evolution::action.contact_name'))
|
||||
->required(fn (Get $get): bool => $get('type') === MessageTypeEnum::CONTACT->value)
|
||||
->placeholder('John Doe');
|
||||
}
|
||||
|
||||
protected function getContactNumberInput(): TextInput
|
||||
{
|
||||
return TextInput::make('contact_number')
|
||||
->label(__('filament-evolution::action.contact_number'))
|
||||
->required(fn (Get $get): bool => $get('type') === MessageTypeEnum::CONTACT->value)
|
||||
->tel()
|
||||
->placeholder('5511999999999');
|
||||
}
|
||||
|
||||
protected function sendMessage(array $data): void
|
||||
{
|
||||
try {
|
||||
$service = app(WhatsappService::class);
|
||||
$type = MessageTypeEnum::from($data['type'] ?? MessageTypeEnum::TEXT->value);
|
||||
|
||||
$instanceId = $data['instance_id'] ?? $this->defaultInstanceId;
|
||||
$number = $data['number'] ?? $this->defaultNumber;
|
||||
|
||||
if (! $instanceId || ! $number) {
|
||||
throw new \Exception(__('filament-evolution::action.missing_required_fields'));
|
||||
}
|
||||
|
||||
// FileUpload returns an array, get the first file path
|
||||
$mediaPath = null;
|
||||
if (isset($data['media'])) {
|
||||
$mediaPath = is_array($data['media']) ? ($data['media'][0] ?? null) : $data['media'];
|
||||
}
|
||||
|
||||
$result = match ($type) {
|
||||
MessageTypeEnum::TEXT => $service->sendText($instanceId, $number, $data['message']),
|
||||
MessageTypeEnum::IMAGE => $service->sendImage(
|
||||
$instanceId,
|
||||
$number,
|
||||
$mediaPath,
|
||||
$data['caption'] ?? null
|
||||
),
|
||||
MessageTypeEnum::VIDEO => $service->sendVideo(
|
||||
$instanceId,
|
||||
$number,
|
||||
$mediaPath,
|
||||
$data['caption'] ?? null
|
||||
),
|
||||
MessageTypeEnum::AUDIO => $service->sendAudio($instanceId, $number, $mediaPath),
|
||||
MessageTypeEnum::DOCUMENT => $service->sendDocument(
|
||||
$instanceId,
|
||||
$number,
|
||||
$mediaPath,
|
||||
null,
|
||||
$data['caption'] ?? null
|
||||
),
|
||||
MessageTypeEnum::LOCATION => $service->sendLocation(
|
||||
$instanceId,
|
||||
$number,
|
||||
(float) $data['latitude'],
|
||||
(float) $data['longitude'],
|
||||
$data['location_name'] ?? null,
|
||||
$data['location_address'] ?? null
|
||||
),
|
||||
MessageTypeEnum::CONTACT => $service->sendContact(
|
||||
$instanceId,
|
||||
$number,
|
||||
$data['contact_name'],
|
||||
$data['contact_number']
|
||||
),
|
||||
default => throw new \Exception(__('filament-evolution::action.unsupported_type')),
|
||||
};
|
||||
|
||||
Notification::make()
|
||||
->title(__('filament-evolution::action.success_title'))
|
||||
->body(__('filament-evolution::action.success_body'))
|
||||
->success()
|
||||
->send();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title(__('filament-evolution::action.error_title'))
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
242
src/Concerns/CanSendWhatsappMessage.php
Normal file
242
src/Concerns/CanSendWhatsappMessage.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WallaceMartinss\FilamentEvolution\Concerns;
|
||||
|
||||
use WallaceMartinss\FilamentEvolution\Enums\MessageTypeEnum;
|
||||
use WallaceMartinss\FilamentEvolution\Exceptions\EvolutionApiException;
|
||||
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
|
||||
use WallaceMartinss\FilamentEvolution\Services\WhatsappService;
|
||||
|
||||
/**
|
||||
* Trait to easily send WhatsApp messages from any class.
|
||||
*
|
||||
* Usage:
|
||||
* ```php
|
||||
* class InvoiceService
|
||||
* {
|
||||
* use CanSendWhatsappMessage;
|
||||
*
|
||||
* public function sendInvoiceNotification(Invoice $invoice): void
|
||||
* {
|
||||
* $this->sendWhatsappText(
|
||||
* $invoice->customer->phone,
|
||||
* "Olá {$invoice->customer->name}, sua fatura #{$invoice->number} vence em {$invoice->due_date->format('d/m/Y')}."
|
||||
* );
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
trait CanSendWhatsappMessage
|
||||
{
|
||||
/**
|
||||
* Get the WhatsApp instance ID to use for sending messages.
|
||||
* Override this method to customize instance selection.
|
||||
*/
|
||||
protected function getWhatsappInstanceId(): ?string
|
||||
{
|
||||
// Try to get from config default instance
|
||||
$defaultInstance = config('filament-evolution.default_instance');
|
||||
|
||||
if ($defaultInstance) {
|
||||
return $defaultInstance;
|
||||
}
|
||||
|
||||
// Get the first connected instance
|
||||
$instance = $this->whatsappService()->getConnectedInstances()->first();
|
||||
|
||||
return $instance?->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WhatsApp service instance.
|
||||
*/
|
||||
protected function whatsappService(): WhatsappService
|
||||
{
|
||||
return app(WhatsappService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message via WhatsApp.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function sendWhatsappText(string $number, string $message, ?string $instanceId = null): array
|
||||
{
|
||||
$instanceId = $instanceId ?? $this->getWhatsappInstanceId();
|
||||
|
||||
if (! $instanceId) {
|
||||
throw new EvolutionApiException('No WhatsApp instance available for sending messages.');
|
||||
}
|
||||
|
||||
return $this->whatsappService()->sendText($instanceId, $number, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an image via WhatsApp.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function sendWhatsappImage(
|
||||
string $number,
|
||||
string $imagePath,
|
||||
?string $caption = null,
|
||||
?string $instanceId = null,
|
||||
?string $disk = null
|
||||
): array {
|
||||
$instanceId = $instanceId ?? $this->getWhatsappInstanceId();
|
||||
|
||||
if (! $instanceId) {
|
||||
throw new EvolutionApiException('No WhatsApp instance available for sending messages.');
|
||||
}
|
||||
|
||||
return $this->whatsappService()->sendImage($instanceId, $number, $imagePath, $caption, $disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a video via WhatsApp.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function sendWhatsappVideo(
|
||||
string $number,
|
||||
string $videoPath,
|
||||
?string $caption = null,
|
||||
?string $instanceId = null,
|
||||
?string $disk = null
|
||||
): array {
|
||||
$instanceId = $instanceId ?? $this->getWhatsappInstanceId();
|
||||
|
||||
if (! $instanceId) {
|
||||
throw new EvolutionApiException('No WhatsApp instance available for sending messages.');
|
||||
}
|
||||
|
||||
return $this->whatsappService()->sendVideo($instanceId, $number, $videoPath, $caption, $disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an audio via WhatsApp.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function sendWhatsappAudio(
|
||||
string $number,
|
||||
string $audioPath,
|
||||
?string $instanceId = null,
|
||||
?string $disk = null
|
||||
): array {
|
||||
$instanceId = $instanceId ?? $this->getWhatsappInstanceId();
|
||||
|
||||
if (! $instanceId) {
|
||||
throw new EvolutionApiException('No WhatsApp instance available for sending messages.');
|
||||
}
|
||||
|
||||
return $this->whatsappService()->sendAudio($instanceId, $number, $audioPath, $disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a document via WhatsApp.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function sendWhatsappDocument(
|
||||
string $number,
|
||||
string $documentPath,
|
||||
?string $fileName = null,
|
||||
?string $caption = null,
|
||||
?string $instanceId = null,
|
||||
?string $disk = null
|
||||
): array {
|
||||
$instanceId = $instanceId ?? $this->getWhatsappInstanceId();
|
||||
|
||||
if (! $instanceId) {
|
||||
throw new EvolutionApiException('No WhatsApp instance available for sending messages.');
|
||||
}
|
||||
|
||||
return $this->whatsappService()->sendDocument($instanceId, $number, $documentPath, $fileName, $caption, $disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a location via WhatsApp.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function sendWhatsappLocation(
|
||||
string $number,
|
||||
float $latitude,
|
||||
float $longitude,
|
||||
?string $name = null,
|
||||
?string $address = null,
|
||||
?string $instanceId = null
|
||||
): array {
|
||||
$instanceId = $instanceId ?? $this->getWhatsappInstanceId();
|
||||
|
||||
if (! $instanceId) {
|
||||
throw new EvolutionApiException('No WhatsApp instance available for sending messages.');
|
||||
}
|
||||
|
||||
return $this->whatsappService()->sendLocation($instanceId, $number, $latitude, $longitude, $name, $address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a contact card via WhatsApp.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function sendWhatsappContact(
|
||||
string $number,
|
||||
string $contactName,
|
||||
string $contactNumber,
|
||||
?string $instanceId = null
|
||||
): array {
|
||||
$instanceId = $instanceId ?? $this->getWhatsappInstanceId();
|
||||
|
||||
if (! $instanceId) {
|
||||
throw new EvolutionApiException('No WhatsApp instance available for sending messages.');
|
||||
}
|
||||
|
||||
return $this->whatsappService()->sendContact($instanceId, $number, $contactName, $contactNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic method to send any type of WhatsApp message.
|
||||
*
|
||||
* @param string|array $content Message content
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function sendWhatsappMessage(
|
||||
string $number,
|
||||
string|MessageTypeEnum $type,
|
||||
string|array $content,
|
||||
array $options = [],
|
||||
?string $instanceId = null
|
||||
): array {
|
||||
$instanceId = $instanceId ?? $this->getWhatsappInstanceId();
|
||||
|
||||
if (! $instanceId) {
|
||||
throw new EvolutionApiException('No WhatsApp instance available for sending messages.');
|
||||
}
|
||||
|
||||
return $this->whatsappService()->send($instanceId, $number, $type, $content, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a connected WhatsApp instance available.
|
||||
*/
|
||||
protected function hasWhatsappInstance(): bool
|
||||
{
|
||||
return $this->getWhatsappInstanceId() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected WhatsApp instances.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<WhatsappInstance>
|
||||
*/
|
||||
protected function getConnectedWhatsappInstances(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return $this->whatsappService()->getConnectedInstances();
|
||||
}
|
||||
}
|
||||
30
src/Facades/Whatsapp.php
Normal file
30
src/Facades/Whatsapp.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WallaceMartinss\FilamentEvolution\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use WallaceMartinss\FilamentEvolution\Services\WhatsappService;
|
||||
|
||||
/**
|
||||
* @method static array sendText(string|int $instanceId, string $number, string $message)
|
||||
* @method static array sendImage(string|int $instanceId, string $number, string $mediaPath, ?string $caption = null)
|
||||
* @method static array sendVideo(string|int $instanceId, string $number, string $mediaPath, ?string $caption = null)
|
||||
* @method static array sendAudio(string|int $instanceId, string $number, string $mediaPath)
|
||||
* @method static array sendDocument(string|int $instanceId, string $number, string $mediaPath, ?string $fileName = null, ?string $caption = null)
|
||||
* @method static array sendLocation(string|int $instanceId, string $number, float $latitude, float $longitude, ?string $name = null, ?string $address = null)
|
||||
* @method static array sendContact(string|int $instanceId, string $number, string $contactName, string $contactNumber)
|
||||
* @method static array send(string|int $instanceId, string $number, string $type, string|array $content, array $options = [])
|
||||
* @method static \WallaceMartinss\FilamentEvolution\Models\WhatsappInstance|null getInstance(string|int $instanceId)
|
||||
* @method static \Illuminate\Database\Eloquent\Collection getConnectedInstances()
|
||||
*
|
||||
* @see \WallaceMartinss\FilamentEvolution\Services\WhatsappService
|
||||
*/
|
||||
class Whatsapp extends Facade
|
||||
{
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return WhatsappService::class;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Spatie\LaravelPackageTools\Package;
|
||||
use Spatie\LaravelPackageTools\PackageServiceProvider;
|
||||
use WallaceMartinss\FilamentEvolution\Livewire\QrCodeDisplay;
|
||||
use WallaceMartinss\FilamentEvolution\Services\EvolutionClient;
|
||||
use WallaceMartinss\FilamentEvolution\Services\WhatsappService;
|
||||
|
||||
class FilamentEvolutionServiceProvider extends PackageServiceProvider
|
||||
{
|
||||
@@ -42,6 +43,10 @@ class FilamentEvolutionServiceProvider extends PackageServiceProvider
|
||||
$this->app->singleton(EvolutionClient::class, function () {
|
||||
return new EvolutionClient();
|
||||
});
|
||||
|
||||
$this->app->singleton(WhatsappService::class, function ($app) {
|
||||
return new WhatsappService($app->make(EvolutionClient::class));
|
||||
});
|
||||
}
|
||||
|
||||
public function packageBooted(): void
|
||||
|
||||
@@ -130,9 +130,15 @@ class ProcessWebhookJob implements ShouldQueue
|
||||
{
|
||||
$data = MessageUpsertData::fromWebhook($this->payload);
|
||||
|
||||
// Extract remoteJid from payload
|
||||
$messageData = $this->payload['data'] ?? $this->payload;
|
||||
$key = $messageData['key'] ?? [];
|
||||
$remoteJid = $key['remoteJid'] ?? $data->message->phone;
|
||||
|
||||
// Store message in database
|
||||
$instance->messages()->create([
|
||||
'message_id' => $data->message->messageId,
|
||||
'remote_jid' => $remoteJid,
|
||||
'phone' => $data->message->phone,
|
||||
'direction' => $data->message->direction,
|
||||
'type' => $data->message->type,
|
||||
@@ -190,7 +196,7 @@ class ProcessWebhookJob implements ShouldQueue
|
||||
{
|
||||
if ($this->webhookId) {
|
||||
WhatsappWebhook::where('id', $this->webhookId)->update([
|
||||
'processed_at' => now(),
|
||||
'processed' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ class EvolutionClient
|
||||
protected function request(string $method, string $endpoint, array $data = []): array
|
||||
{
|
||||
try {
|
||||
// Log request for debugging (without base64 content to avoid huge logs)
|
||||
$logData = $data;
|
||||
if (isset($logData['mediaMessage']['media']) && str_starts_with($logData['mediaMessage']['media'] ?? '', 'data:')) {
|
||||
$logData['mediaMessage']['media'] = '[BASE64 CONTENT OMITTED]';
|
||||
}
|
||||
|
||||
$response = match (strtoupper($method)) {
|
||||
'GET' => $this->client()->get($endpoint, $data),
|
||||
'POST' => $this->client()->post($endpoint, $data),
|
||||
@@ -77,6 +83,12 @@ class EvolutionClient
|
||||
if ($response->failed()) {
|
||||
$message = $body['message'] ?? $body['error'] ?? 'Unknown API error';
|
||||
|
||||
// Log the full response for debugging
|
||||
\Illuminate\Support\Facades\Log::error('Evolution API Error', [
|
||||
'status' => $response->status(),
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
throw new EvolutionApiException(
|
||||
message: "Evolution API error: {$message}",
|
||||
code: $response->status()
|
||||
@@ -242,16 +254,49 @@ class EvolutionClient
|
||||
?string $caption = null,
|
||||
array $options = []
|
||||
): array {
|
||||
$data = array_merge([
|
||||
$data = [
|
||||
'number' => $number,
|
||||
'media' => $imageUrl,
|
||||
'mediatype' => 'image',
|
||||
], $options);
|
||||
'media' => $imageUrl,
|
||||
];
|
||||
|
||||
if ($caption) {
|
||||
$data['caption'] = $caption;
|
||||
}
|
||||
|
||||
if (! empty($options)) {
|
||||
$data['options'] = $options;
|
||||
}
|
||||
|
||||
return $this->request('POST', "/message/sendMedia/{$instanceName}", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a video message.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function sendVideo(
|
||||
string $instanceName,
|
||||
string $number,
|
||||
string $videoUrl,
|
||||
?string $caption = null,
|
||||
array $options = []
|
||||
): array {
|
||||
$data = [
|
||||
'number' => $number,
|
||||
'mediatype' => 'video',
|
||||
'media' => $videoUrl,
|
||||
];
|
||||
|
||||
if ($caption) {
|
||||
$data['caption'] = $caption;
|
||||
}
|
||||
|
||||
if (! empty($options)) {
|
||||
$data['options'] = $options;
|
||||
}
|
||||
|
||||
return $this->request('POST', "/message/sendMedia/{$instanceName}", $data);
|
||||
}
|
||||
|
||||
@@ -262,10 +307,10 @@ class EvolutionClient
|
||||
*/
|
||||
public function sendAudio(string $instanceName, string $number, string $audioUrl, array $options = []): array
|
||||
{
|
||||
return $this->request('POST', "/message/sendWhatsAppAudio/{$instanceName}", array_merge([
|
||||
return $this->request('POST', "/message/sendWhatsAppAudio/{$instanceName}", [
|
||||
'number' => $number,
|
||||
'audio' => $audioUrl,
|
||||
], $options));
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,17 +326,21 @@ class EvolutionClient
|
||||
?string $caption = null,
|
||||
array $options = []
|
||||
): array {
|
||||
$data = array_merge([
|
||||
$data = [
|
||||
'number' => $number,
|
||||
'media' => $documentUrl,
|
||||
'mediatype' => 'document',
|
||||
'media' => $documentUrl,
|
||||
'fileName' => $fileName,
|
||||
], $options);
|
||||
];
|
||||
|
||||
if ($caption) {
|
||||
$data['caption'] = $caption;
|
||||
}
|
||||
|
||||
if (! empty($options)) {
|
||||
$data['options'] = $options;
|
||||
}
|
||||
|
||||
return $this->request('POST', "/message/sendMedia/{$instanceName}", $data);
|
||||
}
|
||||
|
||||
|
||||
334
src/Services/WhatsappService.php
Normal file
334
src/Services/WhatsappService.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace WallaceMartinss\FilamentEvolution\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use WallaceMartinss\FilamentEvolution\Enums\MessageTypeEnum;
|
||||
use WallaceMartinss\FilamentEvolution\Enums\StatusConnectionEnum;
|
||||
use WallaceMartinss\FilamentEvolution\Exceptions\EvolutionApiException;
|
||||
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
|
||||
|
||||
class WhatsappService
|
||||
{
|
||||
protected EvolutionClient $client;
|
||||
|
||||
public function __construct(EvolutionClient $client)
|
||||
{
|
||||
$this->client = $client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a WhatsApp instance by ID.
|
||||
*/
|
||||
public function getInstance(string|int $instanceId): ?WhatsappInstance
|
||||
{
|
||||
return WhatsappInstance::find($instanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected instances.
|
||||
*/
|
||||
public function getConnectedInstances(): Collection
|
||||
{
|
||||
return WhatsappInstance::where('status', StatusConnectionEnum::OPEN)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a phone number for WhatsApp.
|
||||
*/
|
||||
public function formatNumber(string $number): string
|
||||
{
|
||||
// Remove all non-digits
|
||||
$number = preg_replace('/\D/', '', $number);
|
||||
|
||||
// If Brazilian number without country code, add it
|
||||
if (strlen($number) === 10 || strlen($number) === 11) {
|
||||
$number = '55' . $number;
|
||||
}
|
||||
|
||||
return $number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media content for Evolution API.
|
||||
* Returns base64 encoded string (without data: prefix) for local files, or URL for remote files.
|
||||
*/
|
||||
public function getMediaContent(string $path, ?string $disk = null): string
|
||||
{
|
||||
$disk = $disk ?? config('filament-evolution.media.disk', 'public');
|
||||
|
||||
// If it's already a URL, return as-is
|
||||
if (filter_var($path, FILTER_VALIDATE_URL)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
// For local files, convert to base64 (Evolution API expects raw base64 without data: prefix)
|
||||
$storage = Storage::disk($disk);
|
||||
|
||||
if (! $storage->exists($path)) {
|
||||
throw new EvolutionApiException("File not found: {$path}");
|
||||
}
|
||||
|
||||
$contents = $storage->get($path);
|
||||
|
||||
return base64_encode($contents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public URL for a file (supports local and S3).
|
||||
* @deprecated Use getMediaContent() instead for Evolution API
|
||||
*/
|
||||
public function getFileUrl(string $path, ?string $disk = null): string
|
||||
{
|
||||
$disk = $disk ?? config('filesystems.default');
|
||||
|
||||
// If it's already a URL, return as-is
|
||||
if (filter_var($path, FILTER_VALIDATE_URL)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
// Check if it's a temporary upload path
|
||||
if (str_starts_with($path, 'livewire-tmp/')) {
|
||||
return Storage::disk($disk)->temporaryUrl($path, now()->addMinutes(5));
|
||||
}
|
||||
|
||||
// For S3/cloud storage, use temporary URL for private files
|
||||
$diskConfig = config("filesystems.disks.{$disk}");
|
||||
|
||||
if (isset($diskConfig['driver']) && $diskConfig['driver'] === 's3') {
|
||||
return Storage::disk($disk)->temporaryUrl($path, now()->addMinutes(30));
|
||||
}
|
||||
|
||||
// For local storage, return public URL
|
||||
return Storage::disk($disk)->url($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an uploaded file and return its path.
|
||||
*/
|
||||
public function storeFile(UploadedFile $file, string $directory = 'whatsapp-media', ?string $disk = null): string
|
||||
{
|
||||
$disk = $disk ?? config('filament-evolution.media.disk', 'public');
|
||||
|
||||
return $file->store($directory, $disk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function sendText(string|int $instanceId, string $number, string $message): array
|
||||
{
|
||||
$instance = $this->resolveInstance($instanceId);
|
||||
$number = $this->formatNumber($number);
|
||||
|
||||
return $this->client->sendText($instance->name, $number, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an image message.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function sendImage(
|
||||
string|int $instanceId,
|
||||
string $number,
|
||||
string $mediaPath,
|
||||
?string $caption = null,
|
||||
?string $disk = null
|
||||
): array {
|
||||
$instance = $this->resolveInstance($instanceId);
|
||||
$number = $this->formatNumber($number);
|
||||
$media = $this->getMediaContent($mediaPath, $disk);
|
||||
|
||||
return $this->client->sendImage($instance->name, $number, $media, $caption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a video message.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function sendVideo(
|
||||
string|int $instanceId,
|
||||
string $number,
|
||||
string $mediaPath,
|
||||
?string $caption = null,
|
||||
?string $disk = null
|
||||
): array {
|
||||
$instance = $this->resolveInstance($instanceId);
|
||||
$number = $this->formatNumber($number);
|
||||
$media = $this->getMediaContent($mediaPath, $disk);
|
||||
|
||||
return $this->client->sendVideo($instance->name, $number, $media, $caption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an audio message.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function sendAudio(
|
||||
string|int $instanceId,
|
||||
string $number,
|
||||
string $mediaPath,
|
||||
?string $disk = null
|
||||
): array {
|
||||
$instance = $this->resolveInstance($instanceId);
|
||||
$number = $this->formatNumber($number);
|
||||
$media = $this->getMediaContent($mediaPath, $disk);
|
||||
|
||||
return $this->client->sendAudio($instance->name, $number, $media);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a document.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function sendDocument(
|
||||
string|int $instanceId,
|
||||
string $number,
|
||||
string $mediaPath,
|
||||
?string $fileName = null,
|
||||
?string $caption = null,
|
||||
?string $disk = null
|
||||
): array {
|
||||
$instance = $this->resolveInstance($instanceId);
|
||||
$number = $this->formatNumber($number);
|
||||
$media = $this->getMediaContent($mediaPath, $disk);
|
||||
|
||||
// Extract filename from path if not provided
|
||||
$fileName = $fileName ?? basename($mediaPath);
|
||||
|
||||
return $this->client->sendDocument($instance->name, $number, $media, $fileName, $caption);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a location.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function sendLocation(
|
||||
string|int $instanceId,
|
||||
string $number,
|
||||
float $latitude,
|
||||
float $longitude,
|
||||
?string $name = null,
|
||||
?string $address = null
|
||||
): array {
|
||||
$instance = $this->resolveInstance($instanceId);
|
||||
$number = $this->formatNumber($number);
|
||||
|
||||
return $this->client->sendLocation($instance->name, $number, $latitude, $longitude, $name, $address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a contact card.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function sendContact(
|
||||
string|int $instanceId,
|
||||
string $number,
|
||||
string $contactName,
|
||||
string $contactNumber
|
||||
): array {
|
||||
$instance = $this->resolveInstance($instanceId);
|
||||
$number = $this->formatNumber($number);
|
||||
$contactNumber = $this->formatNumber($contactNumber);
|
||||
|
||||
return $this->client->sendContact($instance->name, $number, $contactName, $contactNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic send method that routes to the appropriate type.
|
||||
*
|
||||
* @param string|array $content The message content (text string, file path, or array for location/contact)
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
public function send(
|
||||
string|int $instanceId,
|
||||
string $number,
|
||||
string|MessageTypeEnum $type,
|
||||
string|array $content,
|
||||
array $options = []
|
||||
): array {
|
||||
$type = $type instanceof MessageTypeEnum ? $type : MessageTypeEnum::from($type);
|
||||
|
||||
return match ($type) {
|
||||
MessageTypeEnum::TEXT => $this->sendText($instanceId, $number, $content),
|
||||
MessageTypeEnum::IMAGE => $this->sendImage(
|
||||
$instanceId,
|
||||
$number,
|
||||
$content,
|
||||
$options['caption'] ?? null,
|
||||
$options['disk'] ?? null
|
||||
),
|
||||
MessageTypeEnum::VIDEO => $this->sendVideo(
|
||||
$instanceId,
|
||||
$number,
|
||||
$content,
|
||||
$options['caption'] ?? null,
|
||||
$options['disk'] ?? null
|
||||
),
|
||||
MessageTypeEnum::AUDIO => $this->sendAudio(
|
||||
$instanceId,
|
||||
$number,
|
||||
$content,
|
||||
$options['disk'] ?? null
|
||||
),
|
||||
MessageTypeEnum::DOCUMENT => $this->sendDocument(
|
||||
$instanceId,
|
||||
$number,
|
||||
$content,
|
||||
$options['fileName'] ?? null,
|
||||
$options['caption'] ?? null,
|
||||
$options['disk'] ?? null
|
||||
),
|
||||
MessageTypeEnum::LOCATION => $this->sendLocation(
|
||||
$instanceId,
|
||||
$number,
|
||||
$content['latitude'],
|
||||
$content['longitude'],
|
||||
$content['name'] ?? null,
|
||||
$content['address'] ?? null
|
||||
),
|
||||
MessageTypeEnum::CONTACT => $this->sendContact(
|
||||
$instanceId,
|
||||
$number,
|
||||
$content['name'],
|
||||
$content['number']
|
||||
),
|
||||
default => throw new EvolutionApiException("Unsupported message type: {$type->value}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve instance from ID or model.
|
||||
*
|
||||
* @throws EvolutionApiException
|
||||
*/
|
||||
protected function resolveInstance(string|int $instanceId): WhatsappInstance
|
||||
{
|
||||
$instance = $instanceId instanceof WhatsappInstance
|
||||
? $instanceId
|
||||
: $this->getInstance($instanceId);
|
||||
|
||||
if (! $instance) {
|
||||
throw new EvolutionApiException("WhatsApp instance not found: {$instanceId}");
|
||||
}
|
||||
|
||||
if (! $instance->isConnected()) {
|
||||
throw new EvolutionApiException("WhatsApp instance is not connected: {$instance->name}");
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,20 @@ declare(strict_types=1);
|
||||
|
||||
namespace WallaceMartinss\FilamentEvolution\Tests\Feature;
|
||||
|
||||
use WallaceMartinss\FilamentEvolution\Models\WhatsappInstance;
|
||||
use WallaceMartinss\FilamentEvolution\Tests\TestCase;
|
||||
|
||||
class WebhookControllerTest extends TestCase
|
||||
{
|
||||
public function test_webhook_endpoint_returns_success(): void
|
||||
{
|
||||
// Create a test instance
|
||||
WhatsappInstance::create([
|
||||
'name' => 'test-instance',
|
||||
'phone' => '5511999999999',
|
||||
'status' => 'close',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'event' => 'connection.update',
|
||||
'instance' => 'test-instance',
|
||||
@@ -48,6 +56,13 @@ class WebhookControllerTest extends TestCase
|
||||
{
|
||||
config(['filament-evolution.webhook.secret' => 'super-secret']);
|
||||
|
||||
// Create a test instance
|
||||
WhatsappInstance::create([
|
||||
'name' => 'test-instance',
|
||||
'phone' => '5511999999999',
|
||||
'status' => 'close',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'event' => 'connection.update',
|
||||
'instance' => 'test-instance',
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace WallaceMartinss\FilamentEvolution\Tests;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Orchestra\Testbench\TestCase as Orchestra;
|
||||
use WallaceMartinss\FilamentEvolution\FilamentEvolutionServiceProvider;
|
||||
|
||||
@@ -16,7 +18,63 @@ abstract class TestCase extends Orchestra
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
||||
$this->setUpDatabase();
|
||||
}
|
||||
|
||||
protected function setUpDatabase(): void
|
||||
{
|
||||
// Create whatsapp_instances table
|
||||
if (! Schema::hasTable('whatsapp_instances')) {
|
||||
Schema::create('whatsapp_instances', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('name')->unique();
|
||||
$table->string('phone')->nullable();
|
||||
$table->string('status')->default('close');
|
||||
$table->string('profile_name')->nullable();
|
||||
$table->string('profile_picture_url')->nullable();
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
// Create whatsapp_webhooks table
|
||||
if (! Schema::hasTable('whatsapp_webhooks')) {
|
||||
Schema::create('whatsapp_webhooks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('instance_id')->nullable();
|
||||
$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');
|
||||
$table->index('processed');
|
||||
});
|
||||
}
|
||||
|
||||
// Create whatsapp_messages table
|
||||
if (! Schema::hasTable('whatsapp_messages')) {
|
||||
Schema::create('whatsapp_messages', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->uuid('instance_id');
|
||||
$table->string('remote_jid');
|
||||
$table->string('message_id')->unique();
|
||||
$table->boolean('from_me')->default(false);
|
||||
$table->string('message_type')->default('text');
|
||||
$table->text('content')->nullable();
|
||||
$table->json('media')->nullable();
|
||||
$table->string('status')->default('pending');
|
||||
$table->timestamp('message_timestamp')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('remote_jid');
|
||||
$table->index('message_type');
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected function getPackageProviders($app): array
|
||||
|
||||
@@ -29,7 +29,7 @@ class StatusConnectionEnumTest extends TestCase
|
||||
{
|
||||
$this->assertSame('success', StatusConnectionEnum::OPEN->getColor());
|
||||
$this->assertSame('warning', StatusConnectionEnum::CONNECTING->getColor());
|
||||
$this->assertSame('gray', StatusConnectionEnum::CLOSE->getColor());
|
||||
$this->assertSame('danger', StatusConnectionEnum::CLOSE->getColor());
|
||||
$this->assertSame('danger', StatusConnectionEnum::REFUSED->getColor());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user