feat: add WhatsappMessageResource and WhatsappWebhookResource

- Add WhatsappMessageResource to view message history
  - List view with filters by instance, direction, type, status
  - View page with message details and raw payload

- Add WhatsappWebhookResource to view webhook logs
  - List view with filters by instance, event, processed status, errors
  - View page with webhook details, error info and payload

- Update FilamentEvolutionPlugin with optional resource loading:
  - viewMessageHistory() - enable message history resource (default: false)
  - viewWebhookLogs() - enable webhook logs resource (default: false)
  - whatsappInstanceResource() - show/hide instances (default: true)

- Add translations for new resources (en/pt_BR)

- Update README with plugin options documentation

- Fix Filament v4 infolist signature (Schema instead of Infolist)
This commit is contained in:
Wallace Martins
2025-12-07 12:47:06 -03:00
parent 9102ebb2e6
commit aa606930d2
19 changed files with 699 additions and 15 deletions

View File

@@ -64,6 +64,31 @@ public function panel(Panel $panel): Panel
}
```
### Plugin Options
You can customize which resources are available in the panel:
```php
FilamentEvolutionPlugin::make()
->viewMessageHistory() // Enable message history resource
->viewWebhookLogs() // Enable webhook logs resource
```
| Method | Default | Description |
|--------|---------|-------------|
| `whatsappInstanceResource(bool)` | `true` | Show/hide the WhatsApp Instances resource |
| `viewMessageHistory(bool)` | `false` | Show/hide the Message History resource |
| `viewWebhookLogs(bool)` | `false` | Show/hide the Webhook Logs resource |
#### Example: Full Configuration
```php
FilamentEvolutionPlugin::make()
->whatsappInstanceResource() // Show instances (default: true)
->viewMessageHistory() // Show message history
->viewWebhookLogs() // Show webhook logs
```
---
## Configuration

View File

@@ -0,0 +1,29 @@
<?php
return [
'navigation_label' => 'Messages',
'model_label' => 'Message',
'plural_model_label' => 'Messages',
'sections' => [
'message_info' => 'Message Information',
'content' => 'Content',
'timestamps' => 'Timestamps',
'raw_payload' => 'Raw Payload',
],
'fields' => [
'instance' => 'Instance',
'direction' => 'Direction',
'phone' => 'Phone',
'type' => 'Type',
'content' => 'Content',
'status' => 'Status',
'message_id' => 'Message ID',
'media' => 'Media',
'sent_at' => 'Sent At',
'delivered_at' => 'Delivered At',
'read_at' => 'Read At',
'created_at' => 'Created At',
],
];

View File

@@ -0,0 +1,29 @@
<?php
return [
'navigation_label' => 'Webhook Logs',
'model_label' => 'Webhook Log',
'plural_model_label' => 'Webhook Logs',
'sections' => [
'webhook_info' => 'Webhook Information',
'payload' => 'Payload',
'error' => 'Error',
],
'fields' => [
'instance' => 'Instance',
'event' => 'Event',
'processed' => 'Processed',
'has_error' => 'Has Error',
'error' => 'Error',
'processing_time' => 'Processing Time',
'created_at' => 'Created At',
'updated_at' => 'Updated At',
],
'status' => [
'yes' => 'Yes',
'no' => 'No',
],
];

View File

@@ -0,0 +1,29 @@
<?php
return [
'navigation_label' => 'Mensagens',
'model_label' => 'Mensagem',
'plural_model_label' => 'Mensagens',
'sections' => [
'message_info' => 'Informações da Mensagem',
'content' => 'Conteúdo',
'timestamps' => 'Datas',
'raw_payload' => 'Payload Original',
],
'fields' => [
'instance' => 'Instância',
'direction' => 'Direção',
'phone' => 'Telefone',
'type' => 'Tipo',
'content' => 'Conteúdo',
'status' => 'Status',
'message_id' => 'ID da Mensagem',
'media' => 'Mídia',
'sent_at' => 'Enviado em',
'delivered_at' => 'Entregue em',
'read_at' => 'Lido em',
'created_at' => 'Criado em',
],
];

View File

@@ -0,0 +1,29 @@
<?php
return [
'navigation_label' => 'Logs de Webhook',
'model_label' => 'Log de Webhook',
'plural_model_label' => 'Logs de Webhook',
'sections' => [
'webhook_info' => 'Informações do Webhook',
'payload' => 'Payload',
'error' => 'Erro',
],
'fields' => [
'instance' => 'Instância',
'event' => 'Evento',
'processed' => 'Processado',
'has_error' => 'Tem Erro',
'error' => 'Erro',
'processing_time' => 'Tempo de Processamento',
'created_at' => 'Criado em',
'updated_at' => 'Atualizado em',
],
'status' => [
'yes' => 'Sim',
'no' => 'Não',
],
];

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use WallaceMartinss\FilamentEvolution\Enums\MessageDirectionEnum;
use WallaceMartinss\FilamentEvolution\Enums\MessageStatusEnum;
use WallaceMartinss\FilamentEvolution\Enums\MessageTypeEnum;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappMessageResource\Pages;
use WallaceMartinss\FilamentEvolution\Models\WhatsappMessage;
class WhatsappMessageResource extends Resource
{
protected static ?string $model = WhatsappMessage::class;
public static function getNavigationSort(): ?int
{
return config('filament-evolution.filament.navigation_sort', 100) + 1;
}
public static function getNavigationIcon(): string|Heroicon|null
{
return Heroicon::ChatBubbleBottomCenterText;
}
public static function getNavigationGroup(): ?string
{
return __('filament-evolution::resource.navigation_group');
}
public static function getNavigationLabel(): string
{
return __('filament-evolution::message.navigation_label');
}
public static function getModelLabel(): string
{
return __('filament-evolution::message.model_label');
}
public static function getPluralModelLabel(): string
{
return __('filament-evolution::message.plural_model_label');
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
public static function form(Schema $form): Schema
{
return $form->schema([]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('instance.name')
->label(__('filament-evolution::message.fields.instance'))
->searchable()
->sortable(),
TextColumn::make('direction')
->label(__('filament-evolution::message.fields.direction'))
->badge()
->sortable(),
TextColumn::make('phone')
->label(__('filament-evolution::message.fields.phone'))
->searchable()
->copyable(),
TextColumn::make('type')
->label(__('filament-evolution::message.fields.type'))
->badge()
->sortable(),
TextColumn::make('content')
->label(__('filament-evolution::message.fields.content'))
->limit(50)
->wrap()
->searchable(),
TextColumn::make('status')
->label(__('filament-evolution::message.fields.status'))
->badge()
->sortable(),
TextColumn::make('sent_at')
->label(__('filament-evolution::message.fields.sent_at'))
->dateTime()
->sortable(),
TextColumn::make('created_at')
->label(__('filament-evolution::message.fields.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('instance')
->relationship('instance', 'name')
->label(__('filament-evolution::message.fields.instance'))
->preload(),
SelectFilter::make('direction')
->options(MessageDirectionEnum::class)
->label(__('filament-evolution::message.fields.direction')),
SelectFilter::make('type')
->options(MessageTypeEnum::class)
->label(__('filament-evolution::message.fields.type')),
SelectFilter::make('status')
->options(MessageStatusEnum::class)
->label(__('filament-evolution::message.fields.status')),
])
->actions([])
->bulkActions([]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListWhatsappMessages::route('/'),
'view' => Pages\ViewWhatsappMessage::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappMessageResource\Pages;
use Filament\Resources\Pages\ListRecords;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappMessageResource;
class ListWhatsappMessages extends ListRecords
{
protected static string $resource = WhatsappMessageResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappMessageResource\Pages;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappMessageResource;
class ViewWhatsappMessage extends ViewRecord
{
protected static string $resource = WhatsappMessageResource::class;
public function infolist(Schema $infolist): Schema
{
return $infolist
->schema([
Section::make(__('filament-evolution::message.sections.message_info'))
->schema([
TextEntry::make('instance.name')
->label(__('filament-evolution::message.fields.instance')),
TextEntry::make('direction')
->label(__('filament-evolution::message.fields.direction'))
->badge(),
TextEntry::make('phone')
->label(__('filament-evolution::message.fields.phone'))
->copyable(),
TextEntry::make('type')
->label(__('filament-evolution::message.fields.type'))
->badge(),
TextEntry::make('status')
->label(__('filament-evolution::message.fields.status'))
->badge(),
TextEntry::make('message_id')
->label(__('filament-evolution::message.fields.message_id'))
->copyable(),
])
->columns(3),
Section::make(__('filament-evolution::message.sections.content'))
->schema([
TextEntry::make('content')
->label(__('filament-evolution::message.fields.content'))
->columnSpanFull()
->prose(),
TextEntry::make('media')
->label(__('filament-evolution::message.fields.media'))
->columnSpanFull()
->formatStateUsing(fn ($state) => $state ? json_encode($state, JSON_PRETTY_PRINT) : '-')
->visible(fn ($record) => ! empty($record->media)),
]),
Section::make(__('filament-evolution::message.sections.timestamps'))
->schema([
TextEntry::make('sent_at')
->label(__('filament-evolution::message.fields.sent_at'))
->dateTime(),
TextEntry::make('delivered_at')
->label(__('filament-evolution::message.fields.delivered_at'))
->dateTime(),
TextEntry::make('read_at')
->label(__('filament-evolution::message.fields.read_at'))
->dateTime(),
TextEntry::make('created_at')
->label(__('filament-evolution::message.fields.created_at'))
->dateTime(),
])
->columns(4),
Section::make(__('filament-evolution::message.sections.raw_payload'))
->schema([
TextEntry::make('raw_payload')
->label('')
->columnSpanFull()
->formatStateUsing(fn ($state) => $state ? json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '-')
->prose(),
])
->collapsible()
->collapsed(),
]);
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use WallaceMartinss\FilamentEvolution\Enums\WebhookEventEnum;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappWebhookResource\Pages;
use WallaceMartinss\FilamentEvolution\Models\WhatsappWebhook;
class WhatsappWebhookResource extends Resource
{
protected static ?string $model = WhatsappWebhook::class;
public static function getNavigationSort(): ?int
{
return config('filament-evolution.filament.navigation_sort', 100) + 2;
}
public static function getNavigationIcon(): string|Heroicon|null
{
return Heroicon::QueueList;
}
public static function getNavigationGroup(): ?string
{
return __('filament-evolution::resource.navigation_group');
}
public static function getNavigationLabel(): string
{
return __('filament-evolution::webhook.navigation_label');
}
public static function getModelLabel(): string
{
return __('filament-evolution::webhook.model_label');
}
public static function getPluralModelLabel(): string
{
return __('filament-evolution::webhook.plural_model_label');
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
public static function form(Schema $form): Schema
{
return $form->schema([]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('instance.name')
->label(__('filament-evolution::webhook.fields.instance'))
->searchable()
->sortable()
->placeholder('-'),
TextColumn::make('event')
->label(__('filament-evolution::webhook.fields.event'))
->badge()
->sortable(),
IconColumn::make('processed')
->label(__('filament-evolution::webhook.fields.processed'))
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-clock')
->trueColor('success')
->falseColor('warning')
->sortable(),
IconColumn::make('error')
->label(__('filament-evolution::webhook.fields.has_error'))
->boolean()
->getStateUsing(fn ($record) => ! empty($record->error))
->trueIcon('heroicon-o-x-circle')
->falseIcon('heroicon-o-check')
->trueColor('danger')
->falseColor('success'),
TextColumn::make('processing_time_ms')
->label(__('filament-evolution::webhook.fields.processing_time'))
->suffix(' ms')
->sortable()
->placeholder('-'),
TextColumn::make('created_at')
->label(__('filament-evolution::webhook.fields.created_at'))
->dateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('instance')
->relationship('instance', 'name')
->label(__('filament-evolution::webhook.fields.instance'))
->preload(),
SelectFilter::make('event')
->options(WebhookEventEnum::class)
->label(__('filament-evolution::webhook.fields.event')),
TernaryFilter::make('processed')
->label(__('filament-evolution::webhook.fields.processed')),
TernaryFilter::make('has_error')
->label(__('filament-evolution::webhook.fields.has_error'))
->queries(
true: fn ($query) => $query->whereNotNull('error'),
false: fn ($query) => $query->whereNull('error'),
),
])
->actions([])
->bulkActions([]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListWhatsappWebhooks::route('/'),
'view' => Pages\ViewWhatsappWebhook::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappWebhookResource\Pages;
use Filament\Resources\Pages\ListRecords;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappWebhookResource;
class ListWhatsappWebhooks extends ListRecords
{
protected static string $resource = WhatsappWebhookResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappWebhookResource\Pages;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappWebhookResource;
class ViewWhatsappWebhook extends ViewRecord
{
protected static string $resource = WhatsappWebhookResource::class;
public function infolist(Schema $infolist): Schema
{
return $infolist
->schema([
Section::make(__('filament-evolution::webhook.sections.webhook_info'))
->schema([
TextEntry::make('instance.name')
->label(__('filament-evolution::webhook.fields.instance'))
->placeholder('-'),
TextEntry::make('event')
->label(__('filament-evolution::webhook.fields.event'))
->badge(),
TextEntry::make('processed')
->label(__('filament-evolution::webhook.fields.processed'))
->badge()
->formatStateUsing(fn ($state) => $state ? __('filament-evolution::webhook.status.yes') : __('filament-evolution::webhook.status.no'))
->color(fn ($state) => $state ? 'success' : 'warning'),
TextEntry::make('processing_time_ms')
->label(__('filament-evolution::webhook.fields.processing_time'))
->suffix(' ms')
->placeholder('-'),
TextEntry::make('created_at')
->label(__('filament-evolution::webhook.fields.created_at'))
->dateTime(),
TextEntry::make('updated_at')
->label(__('filament-evolution::webhook.fields.updated_at'))
->dateTime(),
])
->columns(3),
Section::make(__('filament-evolution::webhook.sections.error'))
->schema([
TextEntry::make('error')
->label('')
->columnSpanFull()
->prose()
->color('danger'),
])
->visible(fn ($record) => ! empty($record->error)),
Section::make(__('filament-evolution::webhook.sections.payload'))
->schema([
TextEntry::make('payload')
->label('')
->columnSpanFull()
->formatStateUsing(fn ($state) => $state ? json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) : '-')
->prose(),
])
->collapsible(),
]);
}
}

View File

@@ -7,11 +7,17 @@ namespace WallaceMartinss\FilamentEvolution;
use Filament\Contracts\Plugin;
use Filament\Panel;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappInstanceResource;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappMessageResource;
use WallaceMartinss\FilamentEvolution\Filament\Resources\WhatsappWebhookResource;
class FilamentEvolutionPlugin implements Plugin
{
protected bool $hasWhatsappInstanceResource = true;
protected bool $hasWhatsappMessageResource = false;
protected bool $hasWhatsappWebhookResource = false;
public static function make(): static
{
return app(static::class);
@@ -32,10 +38,22 @@ class FilamentEvolutionPlugin implements Plugin
public function register(Panel $panel): void
{
$resources = [];
if ($this->hasWhatsappInstanceResource) {
$panel->resources([
WhatsappInstanceResource::class,
]);
$resources[] = WhatsappInstanceResource::class;
}
if ($this->hasWhatsappMessageResource) {
$resources[] = WhatsappMessageResource::class;
}
if ($this->hasWhatsappWebhookResource) {
$resources[] = WhatsappWebhookResource::class;
}
if (! empty($resources)) {
$panel->resources($resources);
}
}
@@ -44,10 +62,33 @@ class FilamentEvolutionPlugin implements Plugin
//
}
/**
* Enable or disable the WhatsApp Instance resource.
*/
public function whatsappInstanceResource(bool $condition = true): static
{
$this->hasWhatsappInstanceResource = $condition;
return $this;
}
/**
* Enable the Message History resource to view all messages.
*/
public function viewMessageHistory(bool $condition = true): static
{
$this->hasWhatsappMessageResource = $condition;
return $this;
}
/**
* Enable the Webhook Logs resource to view all webhook events.
*/
public function viewWebhookLogs(bool $condition = true): static
{
$this->hasWhatsappWebhookResource = $condition;
return $this;
}
}

View File

@@ -41,7 +41,7 @@ class FilamentEvolutionServiceProvider extends PackageServiceProvider
public function packageRegistered(): void
{
$this->app->singleton(EvolutionClient::class, function () {
return new EvolutionClient();
return new EvolutionClient;
});
$this->app->singleton(WhatsappService::class, function ($app) {

View File

@@ -51,7 +51,7 @@ class WhatsappWebhook extends Model
return ! empty($this->error);
}
public function markAsProcessed(int $processingTimeMs = null): void
public function markAsProcessed(?int $processingTimeMs = null): void
{
$this->update([
'processed' => true,
@@ -59,7 +59,7 @@ class WhatsappWebhook extends Model
]);
}
public function markAsFailed(string $error, int $processingTimeMs = null): void
public function markAsFailed(string $error, ?int $processingTimeMs = null): void
{
$this->update([
'processed' => false,

View File

@@ -184,7 +184,7 @@ class EvolutionClient
*/
public function fetchInstance(string $instanceName): array
{
return $this->request('GET', "/instance/fetchInstances", [
return $this->request('GET', '/instance/fetchInstances', [
'instanceName' => $instanceName,
]);
}

View File

@@ -80,6 +80,7 @@ class WhatsappService
/**
* 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

View File

@@ -11,21 +11,21 @@ class EvolutionClientTest extends TestCase
{
public function test_client_can_be_instantiated(): void
{
$client = new EvolutionClient();
$client = new EvolutionClient;
$this->assertInstanceOf(EvolutionClient::class, $client);
}
public function test_client_is_configured_when_has_url_and_key(): void
{
$client = new EvolutionClient();
$client = new EvolutionClient;
$this->assertTrue($client->isConfigured());
}
public function test_client_returns_configured_base_url(): void
{
$client = new EvolutionClient();
$client = new EvolutionClient;
$this->assertSame('https://api.evolution.test', $client->getBaseUrl());
}