From c5aa8a3980a29b99fcbd7f5066e0602b139480ca Mon Sep 17 00:00:00 2001 From: JoanFo <161775222+JoanFo1456@users.noreply.github.com> Date: Sat, 5 Jul 2025 18:42:34 +0200 Subject: [PATCH] DiscordWebhooks (#1355) Co-authored-by: notCharles Co-authored-by: Lance Pioch Co-authored-by: Boy132 Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com> --- app/Enums/WebhookType.php | 34 +++ .../Admin/Resources/WebhookResource.php | 259 ++++++++++++++++-- .../Pages/CreateWebhookConfiguration.php | 33 +++ .../Pages/EditWebhookConfiguration.php | 93 +++++++ app/Filament/Admin/Widgets/DiscordPreview.php | 163 +++++++++++ app/Jobs/ProcessWebhook.php | 41 ++- app/Listeners/DispatchWebhooks.php | 3 +- app/Models/WebhookConfiguration.php | 95 ++++++- app/Providers/AppServiceProvider.php | 1 + composer.json | 2 +- ...add_webhook_configurations_type_column.php | 41 +++ .../2025_05_26_150328_add_headers_webhook.php | 28 ++ lang/en/admin/webhook.php | 49 ++++ .../admin/widgets/discord-preview.blade.php | 221 +++++++++++++++ .../components/webhooksection.blade.php | 26 ++ 15 files changed, 1057 insertions(+), 32 deletions(-) create mode 100644 app/Enums/WebhookType.php create mode 100644 app/Filament/Admin/Widgets/DiscordPreview.php create mode 100644 database/migrations/2025_04_09_015500_add_webhook_configurations_type_column.php create mode 100644 database/migrations/2025_05_26_150328_add_headers_webhook.php create mode 100644 resources/views/filament/admin/widgets/discord-preview.blade.php create mode 100644 resources/views/filament/components/webhooksection.blade.php diff --git a/app/Enums/WebhookType.php b/app/Enums/WebhookType.php new file mode 100644 index 000000000..f0e5a9866 --- /dev/null +++ b/app/Enums/WebhookType.php @@ -0,0 +1,34 @@ +value); + } + + public function getColor(): ?string + { + return match ($this) { + self::Regular => null, + self::Discord => 'blurple', + }; + } + + public function getIcon(): string + { + return match ($this) { + self::Regular => 'tabler-world-www', + self::Discord => 'tabler-brand-discord', + }; + } +} diff --git a/app/Filament/Admin/Resources/WebhookResource.php b/app/Filament/Admin/Resources/WebhookResource.php index 6ffe3d7aa..85f0f26b2 100644 --- a/app/Filament/Admin/Resources/WebhookResource.php +++ b/app/Filament/Admin/Resources/WebhookResource.php @@ -3,23 +3,40 @@ namespace App\Filament\Admin\Resources; use App\Filament\Admin\Resources\WebhookResource\Pages; +use App\Filament\Admin\Resources\WebhookResource\Pages\EditWebhookConfiguration; +use App\Livewire\AlertBanner; use App\Models\WebhookConfiguration; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; use App\Traits\Filament\CanModifyForm; use App\Traits\Filament\CanModifyTable; +use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\ColorPicker; +use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Section; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Form; use Filament\Resources\Pages\PageRegistration; +use Filament\Forms\Get; use Filament\Resources\Resource; -use Filament\Tables\Actions\CreateAction; -use Filament\Tables\Actions\DeleteAction; +use Filament\Forms\Set; use Filament\Tables\Actions\DeleteBulkAction; -use Filament\Tables\Actions\EditAction; use Filament\Tables\Actions\ViewAction; +use Filament\Tables\Actions\EditAction; +use Filament\Tables\Actions\ReplicateAction; +use Filament\Tables\Actions\CreateAction; +use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; +use Livewire\Features\SupportEvents\HandlesEvents; +use App\Enums\WebhookType; +use Filament\Forms\Components\Component; +use Livewire\Component as Livewire; class WebhookResource extends Resource { @@ -27,6 +44,7 @@ class WebhookResource extends Resource use CanCustomizeRelations; use CanModifyForm; use CanModifyTable; + use HandlesEvents; protected static ?string $model = WebhookConfiguration::class; @@ -63,6 +81,12 @@ class WebhookResource extends Resource { return $table ->columns([ + IconColumn::make('type'), + TextColumn::make('endpoint') + ->label(trans('admin/webhook.table.endpoint')) + ->formatStateUsing(fn (string $state) => str($state)->after('://')) + ->limit(60) + ->wrap(), TextColumn::make('description') ->label(trans('admin/webhook.table.description')), TextColumn::make('endpoint') @@ -70,9 +94,15 @@ class WebhookResource extends Resource ]) ->actions([ ViewAction::make() - ->hidden(fn ($record) => static::canEdit($record)), + ->hidden(fn (WebhookConfiguration $record) => static::canEdit($record)), EditAction::make(), - DeleteAction::make(), + ReplicateAction::make() + ->iconButton() + ->tooltip(trans('filament-actions::replicate.single.label')) + ->modal(false) + ->excludeAttributes(['created_at', 'updated_at']) + ->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->description .= ' Copy ' . now()->format('Y-m-d H:i:s')) + ->successRedirectUrl(fn (WebhookConfiguration $replica) => EditWebhookConfiguration::getUrl(['record' => $replica])), ]) ->groupedBulkActions([ DeleteBulkAction::make(), @@ -82,6 +112,12 @@ class WebhookResource extends Resource ->emptyStateHeading(trans('admin/webhook.no_webhooks')) ->emptyStateActions([ CreateAction::make(), + ]) + ->persistFiltersInSession() + ->filters([ + SelectFilter::make('type') + ->options(WebhookType::class) + ->attribute('type'), ]); } @@ -89,25 +125,214 @@ class WebhookResource extends Resource { return $form ->schema([ - TextInput::make('endpoint') - ->label(trans('admin/webhook.endpoint')) - ->activeUrl() - ->required(), + ToggleButtons::make('type') + ->live() + ->inline() + ->options(WebhookType::class) + ->default(WebhookType::Regular->value) + ->afterStateHydrated(function (string $state) { + if ($state === WebhookType::Discord->value) { + self::sendHelpBanner(); + } + }) + ->afterStateUpdated(function (string $state) { + if ($state === WebhookType::Discord->value) { + self::sendHelpBanner(); + } + }), TextInput::make('description') ->label(trans('admin/webhook.description')) ->required(), - CheckboxList::make('events') - ->lazy() - ->options(fn () => WebhookConfiguration::filamentCheckboxList()) - ->searchable() - ->bulkToggleable() - ->columns(3) + TextInput::make('endpoint') + ->label(trans('admin/webhook.endpoint')) + ->activeUrl() + ->required() ->columnSpanFull() - ->gridDirection('row') - ->required(), + ->afterStateUpdated(fn (string $state, Set $set) => $set('type', str($state)->contains('discord.com') ? WebhookType::Discord->value : WebhookType::Regular->value)), + Section::make(trans('admin/webhook.regular')) + ->hidden(fn (Get $get) => $get('type') === WebhookType::Discord->value) + ->dehydratedWhenHidden() + ->schema(fn () => self::getRegularFields()) + ->formBefore(), + Section::make(trans('admin/webhook.discord')) + ->hidden(fn (Get $get) => $get('type') === WebhookType::Regular->value) + ->dehydratedWhenHidden() + ->afterStateUpdated(fn (Livewire $livewire) => $livewire->dispatch('refresh-widget')) + ->schema(fn () => self::getDiscordFields()) + ->view('filament.components.webhooksection') + ->aside() + ->formBefore(), + Section::make(trans('admin/webhook.events')) + ->collapsible() + ->collapsed(fn (Get $get) => count($get('events') ?? [])) + ->schema([ + CheckboxList::make('events') + ->live() + ->options(fn () => WebhookConfiguration::filamentCheckboxList()) + ->searchable() + ->bulkToggleable() + ->columns(3) + ->columnSpanFull() + ->required(), + ]), ]); } + /** @return Component[] */ + private static function getRegularFields(): array + { + return [ + KeyValue::make('headers') + ->label(trans('admin/webhook.headers')), + ]; + } + + /** @return Component[] */ + private static function getDiscordFields(): array + { + return [ + Section::make(trans('admin/webhook.discord_message.profile')) + ->collapsible() + ->schema([ + TextInput::make('username') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_message.username')), + TextInput::make('avatar_url') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_message.avatar_url')), + ]), + Section::make(trans('admin/webhook.discord_message.message')) + ->collapsible() + ->schema([ + TextInput::make('content') + ->label(trans('admin/webhook.discord_message.message')) + ->live(debounce: 500) + ->required(fn (Get $get) => empty($get('embeds'))), + TextInput::make('thread_name') + ->label(trans('admin/webhook.discord_message.forum_thread')), + CheckboxList::make('flags') + ->label('Flags') + ->options([ + (1 << 2) => trans('admin/webhook.discord_message.supress_embeds'), + (1 << 12) => trans('admin/webhook.discord_message.supress_notifications'), + ]) + ->descriptions([ + (1 << 2) => trans('admin/webhook.discord_message.supress_embeds_text'), + (1 << 12) => trans('admin/webhook.discord_message.supress_notifications_text'), + ]), + CheckboxList::make('allowed_mentions') + ->label(trans('admin/webhook.discord_embed.allowed_mentions')) + ->options([ + 'roles' => trans('admin/webhook.discord_embed.roles'), + 'users' => trans('admin/webhook.discord_embed.users'), + 'everyone' => trans('admin/webhook.discord_embed.everyone'), + ]), + ]), + Repeater::make('embeds') + ->live(debounce: 500) + ->itemLabel(fn (array $state) => $state['title']) + ->addActionLabel(trans('admin/webhook.discord_embed.add_embed')) + ->required(fn (Get $get) => empty($get('content'))) + ->reorderable() + ->collapsible() + ->maxItems(10) + ->schema([ + Section::make(trans('admin/webhook.discord_embed.author')) + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('author.name') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.author')) + ->required(fn (Get $get) => filled($get('author.url')) || filled($get('author.icon_url'))), + TextInput::make('author.url') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.author_url')), + TextInput::make('author.icon_url') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.author_icon_url')), + ]), + Section::make(trans('admin/webhook.discord_embed.body')) + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('title') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.title')) + ->required(fn (Get $get) => $get('description') === null), + Textarea::make('description') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.body')) + ->required(fn (Get $get) => $get('title') === null), + ColorPicker::make('color') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.color')) + ->hex(), + TextInput::make('url') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.url')), + ]), + Section::make(trans('admin/webhook.discord_embed.images')) + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('image.url') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.image_url')), + TextInput::make('thumbnail.url') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.image_thumbnail')), + ]), + Section::make(trans('admin/webhook.discord_embed.footer')) + ->collapsible() + ->collapsed() + ->schema([ + TextInput::make('footer.text') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.footer')), + Checkbox::make('has_timestamp') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.has_timestamp')), + TextInput::make('footer.icon_url') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.footer_icon_url')), + ]), + Section::make(trans('admin/webhook.discord_embed.fields')) + ->collapsible()->collapsed() + ->schema([ + Repeater::make('fields') + ->reorderable() + ->addActionLabel(trans('admin/webhook.discord_embed.add_field')) + ->collapsible() + ->schema([ + TextInput::make('name') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.field_name')) + ->required(), + Textarea::make('value') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.field_value')) + ->rows(4) + ->required(), + Checkbox::make('inline') + ->live(debounce: 500) + ->label(trans('admin/webhook.discord_embed.inline_field')), + ]), + ]), + ]), + ]; + } + + public static function sendHelpBanner(): void + { + AlertBanner::make('discord_webhook_help') + ->title(trans('admin/webhook.help')) + ->body(trans('admin/webhook.help_text')) + ->icon('tabler-question-mark') + ->info() + ->send(); + } + /** @return array */ public static function getDefaultPages(): array { diff --git a/app/Filament/Admin/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php b/app/Filament/Admin/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php index 7eccf101b..68ae9a22f 100644 --- a/app/Filament/Admin/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php +++ b/app/Filament/Admin/Resources/WebhookResource/Pages/CreateWebhookConfiguration.php @@ -8,6 +8,7 @@ use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Resources\Pages\CreateRecord; +use App\Enums\WebhookType; class CreateWebhookConfiguration extends CreateRecord { @@ -22,6 +23,7 @@ class CreateWebhookConfiguration extends CreateRecord protected function getDefaultHeaderActions(): array { return [ + $this->getCancelFormAction()->formId('form'), $this->getCreateFormAction()->formId('form'), ]; } @@ -30,4 +32,35 @@ class CreateWebhookConfiguration extends CreateRecord { return []; } + + protected function mutateFormDataBeforeCreate(array $data): array + { + if (($data['type'] ?? null) === WebhookType::Discord->value) { + $embeds = data_get($data, 'embeds', []); + + foreach ($embeds as &$embed) { + $embed['color'] = hexdec(str_replace('#', '', data_get($embed, 'color'))); + $embed = collect($embed)->filter(fn ($key) => is_array($key) ? array_filter($key, fn ($arr_key) => !empty($arr_key)) : !empty($key))->all(); + } + + $flags = collect($data['flags'] ?? [])->reduce(fn ($carry, $bit) => $carry | $bit, 0); + + $tmp = collect([ + 'username' => data_get($data, 'username'), + 'avatar_url' => data_get($data, 'avatar_url'), + 'content' => data_get($data, 'content'), + 'image' => data_get($data, 'image'), + 'thumbnail' => data_get($data, 'thumbnail'), + 'embeds' => $embeds, + 'thread_name' => data_get($data, 'thread_name'), + 'flags' => $flags, + 'allowed_mentions' => data_get($data, 'allowed_mentions', []), + ])->filter(fn ($key) => !empty($key))->all(); + + unset($data['username'], $data['avatar_url'], $data['content'], $data['image'], $data['thumbnail'], $data['embeds'], $data['thread_name'], $data['flags'], $data['allowed_mentions']); + $data['payload'] = $tmp; + } + + return $data; + } } diff --git a/app/Filament/Admin/Resources/WebhookResource/Pages/EditWebhookConfiguration.php b/app/Filament/Admin/Resources/WebhookResource/Pages/EditWebhookConfiguration.php index 9e0313c5a..6cc49df23 100644 --- a/app/Filament/Admin/Resources/WebhookResource/Pages/EditWebhookConfiguration.php +++ b/app/Filament/Admin/Resources/WebhookResource/Pages/EditWebhookConfiguration.php @@ -7,8 +7,10 @@ use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; +use App\Models\WebhookConfiguration; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; +use App\Enums\WebhookType; class EditWebhookConfiguration extends EditRecord { @@ -22,6 +24,12 @@ class EditWebhookConfiguration extends EditRecord { return [ DeleteAction::make(), + Action::make('test_now') + ->label(trans('admin/webhook.test_now')) + ->color('primary') + ->disabled(fn (WebhookConfiguration $webhookConfiguration) => count($webhookConfiguration->events) === 0) + ->action(fn (WebhookConfiguration $webhookConfiguration) => $webhookConfiguration->run()) + ->tooltip(trans('admin/webhook.test_now_help')), $this->getSaveFormAction()->formId('form'), ]; } @@ -30,4 +38,89 @@ class EditWebhookConfiguration extends EditRecord { return []; } + + protected function mutateFormDataBeforeSave(array $data): array + { + if (($data['type'] ?? null) === WebhookType::Discord->value) { + $embeds = data_get($data, 'embeds', []); + + foreach ($embeds as &$embed) { + $embed['color'] = hexdec(str_replace('#', '', data_get($embed, 'color'))); + $embed = collect($embed)->filter(fn ($key) => is_array($key) ? array_filter($key, fn ($arr_key) => !empty($arr_key)) : !empty($key))->all(); + } + + $flags = collect($data['flags'] ?? [])->reduce(fn ($carry, $bit) => $carry | $bit, 0); + + $tmp = collect([ + 'username' => data_get($data, 'username'), + 'avatar_url' => data_get($data, 'avatar_url'), + 'content' => data_get($data, 'content'), + 'image' => data_get($data, 'image'), + 'thumbnail' => data_get($data, 'thumbnail'), + 'embeds' => $embeds, + 'thread_name' => data_get($data, 'thread_name'), + 'flags' => $flags, + 'allowed_mentions' => data_get($data, 'allowed_mentions', []), + ])->filter(fn ($key) => !empty($key))->all(); + + unset($data['username'], $data['avatar_url'], $data['content'], $data['image'], $data['thumbnail'], $data['embeds'], $data['thread_name'], $data['flags'], $data['allowed_mentions']); + + $data['payload'] = $tmp; + } + + if (($data['type'] ?? null) === WebhookType::Regular->value && isset($data['headers']) && is_array($data['headers'])) { + $newHeaders = []; + foreach ($data['headers'] as $key => $value) { + $newKey = str_replace(' ', '-', $key); + $newHeaders[$newKey] = $value; + } + $data['headers'] = $newHeaders; + } + + return $data; + } + + protected function mutateFormDataBeforeFill(array $data): array + { + if (($data['type'] ?? null) === WebhookType::Discord->value) { + $embeds = data_get($data, 'payload.embeds', []); + + foreach ($embeds as &$embed) { + $embed['color'] = '#' . dechex(data_get($embed, 'color')); + $embed = collect($embed)->filter(fn ($key) => is_array($key) ? array_filter($key, fn ($arr_key) => !empty($arr_key)) : !empty($key))->all(); + } + + $flags = data_get($data, 'payload.flags'); + $flags = collect(range(0, PHP_INT_SIZE * 8 - 1)) + ->filter(fn ($i) => ($flags & (1 << $i)) !== 0) + ->map(fn ($i) => 1 << $i) + ->values(); + + $tmp = collect([ + 'username' => data_get($data, 'payload.username'), + 'avatar_url' => data_get($data, 'payload.avatar_url'), + 'content' => data_get($data, 'payload.content'), + 'image' => data_get($data, 'payload.image'), + 'thumbnail' => data_get($data, 'payload.thumbnail'), + 'embeds' => $embeds, + 'thread_name' => data_get($data, 'payload.thread_name'), + 'flags' => $flags, + 'allowed_mentions' => data_get($data, 'payload.allowed_mentions'), + ])->filter(fn ($key) => !empty($key))->all(); + + unset($data['payload'], $data['created_at'], $data['updated_at'], $data['deleted_at']); + $data = array_merge($data, $tmp); + } + + if (($data['type'] ?? null) === WebhookType::Regular->value) { + $data['headers'] = $data['headers'] ?? []; + } + + return $data; + } + + protected function afterSave(): void + { + $this->dispatch('refresh-widget'); + } } diff --git a/app/Filament/Admin/Widgets/DiscordPreview.php b/app/Filament/Admin/Widgets/DiscordPreview.php new file mode 100644 index 000000000..406f468ac --- /dev/null +++ b/app/Filament/Admin/Widgets/DiscordPreview.php @@ -0,0 +1,163 @@ + */ + protected $listeners = [ + 'refresh-widget' => '$refresh', + ]; + + protected static bool $isDiscovered = false; // Without this its shown on every Admin Pages + + protected int|string|array $columnSpan = 1; + + public ?WebhookConfiguration $record = null; + + /** @var string|array|null */ + public string|array|null $payload = null; + + /** + * @return array{ + * link: callable, + * content: mixed, + * sender: array{name: string, avatar: string}, + * embeds: array, + * getTime: mixed + * } + */ + public function getViewData(): array + { + if (!$this->record || !$this->record->payload) { + return [ + 'link' => fn ($href, $child) => $href ? "$child" : $child, + 'content' => null, + 'sender' => [ + 'name' => 'Pelican', + 'avatar' => 'https://raw.githubusercontent.com/pelican-dev/panel/refs/heads/main/public/pelican.ico', + ], + 'embeds' => [], + 'getTime' => 'Today at ' . Carbon::now()->format('h:i A'), + ]; + } + + $data = $this->getWebhookSampleData(); + + if (is_string($this->record->payload)) { + $payload = $this->replaceVarsInStringPayload($this->record->payload, $data); + } else { + $payload = $this->replaceVarsInArrayPayload($this->record->payload, $data); + } + + $embeds = data_get($payload, 'embeds', []); + foreach ($embeds as &$embed) { + if (data_get($embed, 'has_timestamp')) { + unset($embed['has_timestamp']); + $embed['timestamp'] = 'Today at ' . Carbon::now()->format('h:i A'); + } + } + + return [ + 'link' => fn ($href, $child) => $href ? sprintf('%s', $href, $child) : $child, + 'content' => data_get($payload, 'content'), + 'sender' => [ + 'name' => data_get($payload, 'username', 'Pelican'), + 'avatar' => data_get($payload, 'avatar_url', 'https://raw.githubusercontent.com/pelican-dev/panel/refs/heads/main/public/pelican.ico'), + ], + 'embeds' => $embeds, + 'getTime' => 'Today at ' . Carbon::now()->format('h:i A'), + ]; + } + + /** + * @param array $data + */ + private function replaceVarsInStringPayload(?string $payload, array $data): ?string + { + if ($payload === null) { + return null; + } + + return preg_replace_callback('/{{\s*([\w\.]+)\s*}}/', fn ($m) => data_get($data, $m[1], $m[0]), + $payload + ); + } + + /** + * @param array|null $payload + * @param array $data + * @return array|null + */ + private function replaceVarsInArrayPayload(?array $payload, array $data): ?array + { + if ($payload === null) { + return null; + } + + foreach ($payload as $key => $value) { + if (is_string($value)) { + $payload[$key] = $this->replaceVarsInStringPayload($value, $data); + } elseif (is_array($value)) { + $payload[$key] = $this->replaceVarsInArrayPayload($value, $data); + } + } + + return $payload; + } + + /** + * @return array + */ + public function getWebhookSampleData(): array + { + return [ + 'event' => 'updated: server', + 'id' => 2, + 'external_id' => 10, + 'uuid' => '651fgbc1-dee6-4250-814e-10slda13f1e', + 'uuid_short' => '651fgbc1', + 'node_id' => 1, + 'name' => 'Example Server', + 'description' => 'This is an example server description.', + 'status' => 'running', + 'skip_scripts' => false, + 'owner_id' => 1, + 'memory' => 512, + 'swap' => 128, + 'disk' => 10240, + 'io' => 500, + 'cpu' => 500, + 'threads' => '1, 3, 5', + 'oom_killer' => false, + 'allocation_id' => 4, + 'egg_id' => 2, + 'startup' => 'This is a example startup command.', + 'image' => 'Image here', + 'allocation_limit' => 5, + 'database_limit' => 1, + 'backup_limit' => 3, + 'created_at' => '2025-03-17T15:20:32.000000Z', + 'updated_at' => '2025-05-12T17:53:12.000000Z', + 'installed_at' => '2025-04-27T21:06:01.000000Z', + 'docker_labels' => [], + 'allocation' => [ + 'id' => 4, + 'node_id' => 1, + 'ip' => '192.168.0.3', + 'ip_alias' => null, + 'port' => 25567, + 'server_id' => 2, + 'notes' => null, + 'created_at' => '2025-03-17T15:20:09.000000Z', + 'updated_at' => '2025-03-17T15:20:32.000000Z', + ], + ]; + } +} diff --git a/app/Jobs/ProcessWebhook.php b/app/Jobs/ProcessWebhook.php index 724789ee6..f499518a8 100644 --- a/app/Jobs/ProcessWebhook.php +++ b/app/Jobs/ProcessWebhook.php @@ -3,12 +3,15 @@ namespace App\Jobs; use App\Models\WebhookConfiguration; +use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; +use App\Enums\WebhookType; class ProcessWebhook implements ShouldQueue { @@ -25,17 +28,45 @@ class ProcessWebhook implements ShouldQueue public function handle(): void { + $data = $this->data[0]; + + if ($this->webhookConfiguration->type === WebhookType::Discord) { + $data = array_merge( + is_array($data) ? $data : json_decode($data, true), + ['event' => $this->webhookConfiguration->transformClassName($this->eventName)] + ); + + $payload = json_encode($this->webhookConfiguration->payload); + $tmp = $this->webhookConfiguration->replaceVars($data, $payload); + $data = json_decode($tmp, true); + + $embeds = data_get($data, 'embeds'); + if ($embeds) { + foreach ($embeds as &$embed) { + if (data_get($embed, 'has_timestamp')) { + $embed['timestamp'] = Carbon::now(); + unset($embed['has_timestamp']); + } + } + $data['embeds'] = $embeds; + } + } + try { - Http::withHeader('X-Webhook-Event', $this->eventName) - ->post($this->webhookConfiguration->endpoint, $this->data) - ->throw(); + $headers = []; + if ($this->webhookConfiguration->type === WebhookType::Regular && $customHeaders = $this->webhookConfiguration->headers) { + $headers = array_merge(['X-Webhook-Event', $this->eventName], $customHeaders); + } + + Http::withHeaders($headers)->post($this->webhookConfiguration->endpoint, $data)->throw(); $successful = now(); - } catch (\Exception) { + } catch (Exception $exception) { + report($exception->getMessage()); $successful = null; } $this->webhookConfiguration->webhooks()->create([ - 'payload' => $this->data, + 'payload' => $data, 'successful_at' => $successful, 'event' => $this->eventName, 'endpoint' => $this->webhookConfiguration->endpoint, diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php index 1d0febaba..3332d0a5b 100644 --- a/app/Listeners/DispatchWebhooks.php +++ b/app/Listeners/DispatchWebhooks.php @@ -2,7 +2,6 @@ namespace App\Listeners; -use App\Jobs\ProcessWebhook; use App\Models\WebhookConfiguration; class DispatchWebhooks @@ -23,7 +22,7 @@ class DispatchWebhooks /** @var WebhookConfiguration $webhookConfig */ foreach ($matchingHooks as $webhookConfig) { if (in_array($eventName, $webhookConfig->events)) { - ProcessWebhook::dispatch($webhookConfig, $eventName, $data); + $webhookConfig->run($eventName, $data); } } } diff --git a/app/Models/WebhookConfiguration.php b/app/Models/WebhookConfiguration.php index f6a048e02..7c8a8fab0 100644 --- a/app/Models/WebhookConfiguration.php +++ b/app/Models/WebhookConfiguration.php @@ -2,24 +2,31 @@ namespace App\Models; +use App\Jobs\ProcessWebhook; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\File; +use Livewire\Features\SupportEvents\HandlesEvents; +use App\Enums\WebhookType; /** + * @property string|array|null $payload * @property string $endpoint * @property string $description * @property string[] $events + * @property WebhookType|string|null $type * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $deleted_at + * @property array|null $headers */ class WebhookConfiguration extends Model { - use HasFactory, SoftDeletes; + use HandlesEvents, HasFactory, SoftDeletes; /** @var string[] */ protected static array $eventBlacklist = [ @@ -27,15 +34,29 @@ class WebhookConfiguration extends Model ]; protected $fillable = [ + 'type', + 'payload', 'endpoint', 'description', 'events', + 'headers', + ]; + + /** + * Default values for specific fields in the database. + */ + protected $attributes = [ + 'type' => WebhookType::Regular, + 'payload' => null, ]; protected function casts(): array { return [ - 'events' => 'json', + 'events' => 'array', + 'payload' => 'array', + 'type' => WebhookType::class, + 'headers' => 'array', ]; } @@ -43,7 +64,7 @@ class WebhookConfiguration extends Model { self::saved(static function (self $webhookConfiguration): void { $changedEvents = collect([ - ...((array) $webhookConfiguration->events), + ...($webhookConfiguration->events), ...$webhookConfiguration->getOriginal('events', '[]'), ])->unique(); @@ -51,7 +72,7 @@ class WebhookConfiguration extends Model }); self::deleted(static function (self $webhookConfiguration): void { - self::updateCache(collect((array) $webhookConfiguration->events)); + self::updateCache(collect($webhookConfiguration->events)); }); } @@ -140,9 +161,7 @@ class WebhookConfiguration extends Model foreach (File::allFiles($directory) as $file) { $namespace = str($file->getPath()) ->after(base_path()) - ->replace(DIRECTORY_SEPARATOR, '\\') - ->replace('\\app\\', 'App\\') - ->toString(); + ->replace([DIRECTORY_SEPARATOR, '\\app\\'], ['\\', 'App\\']); $events[] = $namespace . '\\' . str($file->getFilename()) ->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']); @@ -150,4 +169,66 @@ class WebhookConfiguration extends Model return $events; } + + /** + * @param array $replacement + * */ + public function replaceVars(array $replacement, string $subject): string + { + return preg_replace_callback( + '/{{(.*?)}}/', + function ($matches) use ($replacement) { + $trimmed = trim($matches[1]); + + return Arr::get($replacement, $trimmed, $trimmed); + }, + $subject + ); + } + + /** @param array $eventData */ + public function run(?string $eventName = null, ?array $eventData = null): void + { + $eventName ??= 'eloquent.created: '.Server::class; + $eventData ??= $this->getWebhookSampleData(); + + ProcessWebhook::dispatch($this, $eventName, [$eventData]); + } + + /** + * @return array + */ + public function getWebhookSampleData(): array + { + return [ + 'status' => 'installing', + 'oom_killer' => false, + 'installed_at' => null, + 'external_id' => 10, + 'uuid' => '651fgbc1-dee6-4250-814e-10slda13f1e', + 'uuid_short' => '651fgbc1', + 'node_id' => 1, + 'name' => 'Eagle', + 'description' => 'This is an example server description.', + 'skip_scripts' => false, + 'owner_id' => 1, + 'memory' => 2048, + 'swap' => 128, + 'disk' => 10240, + 'io' => 500, + 'cpu' => 100, + 'threads' => '1,3,5', + 'allocation_id' => 4, + 'egg_id' => 2, + 'startup' => 'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}', + 'image' => 'ghcr.io/parkervcp/yolks:java_21', + 'database_limit' => 1, + 'allocation_limit' => 5, + 'backup_limit' => 3, + 'docker_labels' => [], + 'created_at' => '2025-03-17T15:20:32.000000Z', + 'updated_at' => '2025-05-12T17:53:12.000000Z', + 'id' => 2, + ]; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 16c2f48ef..a36ab4247 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -98,6 +98,7 @@ class AppServiceProvider extends ServiceProvider 'primary' => Color::Blue, 'success' => Color::Green, 'warning' => Color::Amber, + 'blurple' => Color::hex('#5865F2'), ]); FilamentView::registerRenderHook( diff --git a/composer.json b/composer.json index 5ecff2d06..d675224b6 100644 --- a/composer.json +++ b/composer.json @@ -104,4 +104,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} +} \ No newline at end of file diff --git a/database/migrations/2025_04_09_015500_add_webhook_configurations_type_column.php b/database/migrations/2025_04_09_015500_add_webhook_configurations_type_column.php new file mode 100644 index 000000000..009e3d96a --- /dev/null +++ b/database/migrations/2025_04_09_015500_add_webhook_configurations_type_column.php @@ -0,0 +1,41 @@ +string('type')->nullable()->after('id'); + $table->json('payload')->nullable()->after('type'); + }); + + foreach (WebhookConfiguration::all() as $webhookConfig) { + $type = str($webhookConfig->endpoint)->contains('discord.com') ? WebhookType::Discord->value : WebhookType::Regular->value; + + DB::table('webhook_configurations') + ->where('id', $webhookConfig->id) + ->update(['type' => $type]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_configurations', function (Blueprint $table) { + $table->dropColumn('type'); + $table->dropColumn('payload'); + }); + } +}; diff --git a/database/migrations/2025_05_26_150328_add_headers_webhook.php b/database/migrations/2025_05_26_150328_add_headers_webhook.php new file mode 100644 index 000000000..c7a2ea144 --- /dev/null +++ b/database/migrations/2025_05_26_150328_add_headers_webhook.php @@ -0,0 +1,28 @@ +json('headers')->nullable()->after('payload'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_configurations', function (Blueprint $table) { + $table->dropColumn('headers'); + }); + } +}; diff --git a/lang/en/admin/webhook.php b/lang/en/admin/webhook.php index 443b4f1a2..691ad8501 100644 --- a/lang/en/admin/webhook.php +++ b/lang/en/admin/webhook.php @@ -8,8 +8,57 @@ return [ 'description' => 'Description', 'events' => 'Events', 'no_webhooks' => 'No Webhooks', + 'help' => 'Help', + 'help_text' => 'You have to wrap variable name in between {{ }} for example if you want to get the name from the api you can use {{name}}.', + 'test_now' => 'Test Now', + 'test_now_help' => 'This will fire a `created: Server` event', 'table' => [ 'description' => 'Description', 'endpoint' => 'Endpoint', ], + 'headers' => 'Headers', + 'events' => 'Events', + 'regular' => 'Regular', + 'discord' => 'Discord', + 'discord_message' => [ + 'profile' => 'Profile', + 'message' => 'Message', + 'username' => 'Username', + 'avatar_url' => 'Avatar URL', + 'forum_thread' => 'Forum Thread Name', + 'supress_embeds' => 'Suppress Embeds', + 'supress_embeds_text' => 'Do not include any embeds when serializing this message', + 'supress_notifications' => 'Suppress Notifications', + 'supress_notifications_text' => 'This message will not trigger push and desktop notifications', + ], + 'discord_embed' => [ + 'add_embed' => 'Add Embed', + 'flags' => 'Flags', + 'thumbnail' => 'Thumbnail URL', + 'embeds' => 'Embeds', + 'thread_name' => 'Forum Thread Name', + 'flags' => 'Flags', + 'allowed_mentions' => 'Allowed Mentions', + 'roles' => 'Roles', + 'users' => 'Users', + 'everyone' => '@everyone & @here', + 'author' => 'Author', + 'author_url' => 'Author URL', + 'author_icon_url' => 'Author Icon URL', + 'body' => 'Body', + 'title' => 'Title', + 'color' => 'Embed Color', + 'url' => 'URL', + 'images' => 'Images', + 'image_url' => 'Image URL', + 'image_thumbnail' => 'Thumbnail URL', + 'footer' => 'Footer', + 'has_timestamp' => 'Has Timestamp', + 'footer_icon_url' => 'Footer Icon URL', + 'add_field' => 'Add Field', + 'fields' => 'Fields', + 'field_name' => 'Field Name', + 'field_value' => 'Field Value', + 'inline_field' => 'Inline Field', + ], ]; diff --git a/resources/views/filament/admin/widgets/discord-preview.blade.php b/resources/views/filament/admin/widgets/discord-preview.blade.php new file mode 100644 index 000000000..fc475c163 --- /dev/null +++ b/resources/views/filament/admin/widgets/discord-preview.blade.php @@ -0,0 +1,221 @@ + + @assets + + @endassets +
+
+ @if($avatar = $sender['avatar']) + Avatar + @endif +
+ +
+
+

{{ data_get($sender, 'name') }}

+ @if(!data_get($sender, 'human')) + app + @endif + {{ $getTime }} +
+ + @if(filled($content)) +

{!! nl2br($content) !!}

+ @endif + + @foreach($embeds as $embed) + @php + $name = $embed['author']['name'] ?? null; + $thumbnail = $embed['thumbnail']['url'] ?? null; + $author_icon_url = $embed['author']['icon_url'] ?? null; + $author_url = $embed['author']['url'] ?? null; + $footer_icon_url = $embed['footer']['icon_url'] ?? null; + $footer_text = $embed['footer']['text'] ?? null; + $footer_timestamp = $embed['timestamp'] ?? null; + @endphp +
+ @if($name || $thumbnail) +
+ @if($author_icon_url || $name) +
+ @if($author_icon_url) + Author Avatar + @endif + @if($author_url) + {!! $link($author_url, $name ? '

' . e($name) . '

' : '') !!} + @elseif($name) +

{{ $name }}

+ @endif +
+ @endif + @if($thumbnail) + Embed Thumbnail + @endif +
+ @endif + + @if($title = data_get($embed, 'title')) + {!! $link( + $url = data_get($embed, 'url'), + '

' . e($title) . '

' + ) !!} + @endif + + @if($description = data_get($embed, 'description')) +

{!! nl2br($description) !!}

+ @endif + + @if($fields = data_get($embed, 'fields')) +
+ @foreach($fields as $field) +
+ {{ data_get($field, 'name') }} + {{ data_get($field, 'value') }} +
+ @endforeach +
+ @endif + + @if($image = data_get($embed, 'image.url')) + Embed Image + @endif + + @if($footer_text || $footer_timestamp) + + @endforeach +
+
+ + \ No newline at end of file diff --git a/resources/views/filament/components/webhooksection.blade.php b/resources/views/filament/components/webhooksection.blade.php new file mode 100644 index 000000000..d635c6925 --- /dev/null +++ b/resources/views/filament/components/webhooksection.blade.php @@ -0,0 +1,26 @@ + + + @livewire(App\Filament\Admin\Widgets\DiscordPreview::class, ['record' => $getRecord(), 'pollingInterval' => $pollingInterval ?? null]) + + + {{ $getChildComponentContainer() }} +