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:
Wallace Martins
2025-12-07 12:24:39 -03:00
parent 3bf496e8a9
commit 81bdf54c70
15 changed files with 1772 additions and 72 deletions

View File

@@ -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
View File

@@ -1,7 +1,7 @@
# Filament Evolution - WhatsApp Connector
[![Latest Version on Packagist](https://img.shields.io/packagist/v/wallacemartinss/filament-whatsapp-conector.svg?style=flat-square)](https://packagist.org/packages/wallacemartinss/filament-whatsapp-conector)
[![Total Downloads](https://img.shields.io/packagist/dt/wallacemartinss/filament-whatsapp-conector.svg?style=flat-square)](https://packagist.org/packages/wallacemartinss/filament-whatsapp-conector)
[![Latest Version on Packagist](https://img.shields.io/packagist/v/wallacemartinss/filament-evolution.svg?style=flat-square)](https://packagist.org/packages/wallacemartinss/filament-evolution)
[![Total Downloads](https://img.shields.io/packagist/dt/wallacemartinss/filament-evolution.svg?style=flat-square)](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

View File

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

View 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.',
];

View 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.',
];

View 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;
}
}
}

View 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
View 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;
}
}

View File

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

View File

@@ -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,
]);
}
}

View File

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

View 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;
}
}

View File

@@ -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',

View File

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

View File

@@ -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());
}