From ed7d4f5a6b706326d3ff892fdc458ff86386c97b Mon Sep 17 00:00:00 2001 From: JoanFo <161775222+JoanFo1456@users.noreply.github.com> Date: Fri, 19 Jun 2026 05:28:14 +0200 Subject: [PATCH] Webhook fix + server area webhooks (#2377) --- app/Enums/WebhookScope.php | 17 + .../Pages/CreateWebhookConfiguration.php | 11 + .../Pages/ListWebhookConfigurations.php | 26 ++ .../Resources/Webhooks/WebhookResource.php | 401 ++++++++++-------- app/Filament/Admin/Widgets/DiscordPreview.php | 163 ------- .../Webhooks/Pages/CreateWebhook.php | 59 +++ .../Resources/Webhooks/Pages/EditWebhook.php | 41 ++ .../Resources/Webhooks/Pages/ListWebhooks.php | 39 ++ .../Resources/Webhooks/Pages/ViewWebhook.php | 19 + .../Resources/Webhooks/WebhookResource.php | 356 ++++++++++++++++ .../Remote/ActivityProcessingController.php | 3 + app/Jobs/ProcessWebhook.php | 22 +- app/Listeners/DispatchWebhooks.php | 203 ++++++++- app/Livewire/DiscordPreview.php | 150 +++++++ app/Models/ActivityLog.php | 5 - app/Models/Server.php | 10 + app/Models/WebhookConfiguration.php | 265 +++++++++++- app/Providers/EventServiceProvider.php | 4 +- app/Services/Activity/ActivityLogService.php | 4 + app/Services/WebhookService.php | 44 ++ ..._scope_to_webhook_configurations_table.php | 34 ++ ..._28_152500_rename_description_webhooks.php | 30 ++ lang/en/admin/webhook.php | 9 +- resources/css/discord-preview.css | 253 +++++++++++ .../admin/widgets/discord-preview.blade.php | 221 ---------- .../components/webhooksection.blade.php | 16 +- .../views/livewire/discord-preview.blade.php | 113 +++++ 27 files changed, 1929 insertions(+), 589 deletions(-) create mode 100644 app/Enums/WebhookScope.php delete mode 100644 app/Filament/Admin/Widgets/DiscordPreview.php create mode 100644 app/Filament/Server/Resources/Webhooks/Pages/CreateWebhook.php create mode 100644 app/Filament/Server/Resources/Webhooks/Pages/EditWebhook.php create mode 100644 app/Filament/Server/Resources/Webhooks/Pages/ListWebhooks.php create mode 100644 app/Filament/Server/Resources/Webhooks/Pages/ViewWebhook.php create mode 100644 app/Filament/Server/Resources/Webhooks/WebhookResource.php create mode 100644 app/Livewire/DiscordPreview.php create mode 100644 app/Services/WebhookService.php create mode 100644 database/migrations/2025_09_26_234340_add_scope_to_webhook_configurations_table.php create mode 100644 database/migrations/2025_10_28_152500_rename_description_webhooks.php create mode 100644 resources/css/discord-preview.css delete mode 100644 resources/views/filament/admin/widgets/discord-preview.blade.php create mode 100644 resources/views/livewire/discord-preview.blade.php diff --git a/app/Enums/WebhookScope.php b/app/Enums/WebhookScope.php new file mode 100644 index 000000000..4e84b5eb1 --- /dev/null +++ b/app/Enums/WebhookScope.php @@ -0,0 +1,17 @@ + 'Global', + self::Server => 'Server', + }; + } +} diff --git a/app/Filament/Admin/Resources/Webhooks/Pages/CreateWebhookConfiguration.php b/app/Filament/Admin/Resources/Webhooks/Pages/CreateWebhookConfiguration.php index 2fdc7375d..7e4ff36e6 100644 --- a/app/Filament/Admin/Resources/Webhooks/Pages/CreateWebhookConfiguration.php +++ b/app/Filament/Admin/Resources/Webhooks/Pages/CreateWebhookConfiguration.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\Webhooks\Pages; use App\Enums\TablerIcon; +use App\Enums\WebhookScope; use App\Enums\WebhookType; use App\Filament\Admin\Resources\Webhooks\WebhookResource; use App\Traits\Filament\CanCustomizeHeaderActions; @@ -10,6 +11,7 @@ use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Resources\Pages\CreateRecord; +use Illuminate\Validation\ValidationException; class CreateWebhookConfiguration extends CreateRecord { @@ -44,6 +46,15 @@ class CreateWebhookConfiguration extends CreateRecord protected function mutateFormDataBeforeCreate(array $data): array { + // Ensure name is set (required field) + if (empty($data['name'] ?? null)) { + throw ValidationException::withMessages(['name' => 'Webhook name is required']); + } + + // Set scope to GLOBAL by default for admin webhooks + $data['scope'] = WebhookScope::Global; + unset($data['server_id']); + if (($data['type'] ?? null) === WebhookType::Discord->value) { $embeds = data_get($data, 'embeds', []); diff --git a/app/Filament/Admin/Resources/Webhooks/Pages/ListWebhookConfigurations.php b/app/Filament/Admin/Resources/Webhooks/Pages/ListWebhookConfigurations.php index 50a1f17b1..ef8cfb459 100644 --- a/app/Filament/Admin/Resources/Webhooks/Pages/ListWebhookConfigurations.php +++ b/app/Filament/Admin/Resources/Webhooks/Pages/ListWebhookConfigurations.php @@ -2,10 +2,16 @@ namespace App\Filament\Admin\Resources\Webhooks\Pages; +use App\Enums\WebhookScope; use App\Filament\Admin\Resources\Webhooks\WebhookResource; +use App\Models\WebhookConfiguration; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; +use Filament\Actions\Action; +use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; +use Filament\Schemas\Components\Tabs\Tab; +use Illuminate\Database\Eloquent\Builder; class ListWebhookConfigurations extends ListRecords { @@ -13,4 +19,24 @@ class ListWebhookConfigurations extends ListRecords use CanCustomizeHeaderWidgets; protected static string $resource = WebhookResource::class; + + /** @return array */ + protected function getHeaderActions(): array + { + return [ + CreateAction::make() + ->hidden(fn () => $this->activeTab === 'server-webhooks'), + ]; + } + + public function getTabs(): array + { + return collect(WebhookScope::cases()) + ->mapWithKeys(fn (WebhookScope $scope) => [ + $scope->value . '-webhooks' => Tab::make($scope->getLabel() . ' Webhooks') + ->modifyQueryUsing(fn (Builder $query) => $query->where('scope', $scope)) + ->badge(WebhookConfiguration::where('scope', $scope)->count()), + ]) + ->all(); + } } diff --git a/app/Filament/Admin/Resources/Webhooks/WebhookResource.php b/app/Filament/Admin/Resources/Webhooks/WebhookResource.php index a4411f2f4..b272fdc31 100644 --- a/app/Filament/Admin/Resources/Webhooks/WebhookResource.php +++ b/app/Filament/Admin/Resources/Webhooks/WebhookResource.php @@ -3,12 +3,14 @@ namespace App\Filament\Admin\Resources\Webhooks; use App\Enums\TablerIcon; +use App\Enums\WebhookScope; use App\Enums\WebhookType; use App\Filament\Admin\Resources\Webhooks\Pages\CreateWebhookConfiguration; use App\Filament\Admin\Resources\Webhooks\Pages\EditWebhookConfiguration; use App\Filament\Admin\Resources\Webhooks\Pages\ListWebhookConfigurations; use App\Filament\Admin\Resources\Webhooks\Pages\ViewWebhookConfiguration; use App\Livewire\AlertBanner; +use App\Models\Server; use App\Models\WebhookConfiguration; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; @@ -16,7 +18,6 @@ use App\Traits\Filament\CanModifyForm; use App\Traits\Filament\CanModifyTable; use BackedEnum; use Exception; -use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; use Filament\Actions\DeleteBulkAction; @@ -26,14 +27,19 @@ use Filament\Actions\ViewAction; use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\ColorPicker; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\ToggleButtons; use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Resource; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; @@ -42,7 +48,6 @@ use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; -use Livewire\Component as Livewire; use Livewire\Features\SupportEvents\HandlesEvents; class WebhookResource extends Resource @@ -57,7 +62,7 @@ class WebhookResource extends Resource protected static string|BackedEnum|null $navigationIcon = TablerIcon::Webhook; - protected static ?string $recordTitleAttribute = 'description'; + protected static ?string $recordTitleAttribute = 'name'; public static function getNavigationLabel(): string { @@ -87,17 +92,25 @@ class WebhookResource extends Resource public static function defaultTable(Table $table): Table { return $table + ->groups([ + 'server.name', + ]) ->columns([ + TextColumn::make('name') + ->label(trans('admin/webhook.name')), + TextColumn::make('description') + ->label(trans('admin/webhook.table.description')), IconColumn::make('type'), + TextColumn::make('server.name') + ->label('Server') + ->placeholder('—') + ->icon('tabler-server') + ->iconColor('info'), TextColumn::make('endpoint') - ->label(trans('admin/webhook.table.endpoint')) + ->label(trans('admin/webhook.endpoint')) ->formatStateUsing(fn (string $state) => str($state)->after('://')) ->limit(60) ->wrap(), - TextColumn::make('description') - ->label(trans('admin/webhook.table.description')), - TextColumn::make('endpoint') - ->label(trans('admin/webhook.table.endpoint')), ]) ->recordActions([ ViewAction::make() @@ -108,7 +121,7 @@ class WebhookResource extends Resource ->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')) + ->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->name .= ' Copy ' . now()->format('Y-m-d H:i:s')) ->successRedirectUrl(fn (WebhookConfiguration $replica) => EditWebhookConfiguration::getUrl(['record' => $replica])), ]) ->toolbarActions([ @@ -125,6 +138,9 @@ class WebhookResource extends Resource SelectFilter::make('type') ->options(WebhookType::class) ->attribute('type'), + SelectFilter::make('server_id') + ->label('Server') + ->options(Server::query()->pluck('name', 'id')->toArray()), ]); } @@ -132,50 +148,73 @@ class WebhookResource extends Resource { return $schema ->components([ - ToggleButtons::make('type') - ->live() - ->inline() - ->options(WebhookType::class) - ->default(WebhookType::Regular), - TextInput::make('description') - ->label(trans('admin/webhook.description')) - ->required(), - TextInput::make('endpoint') - ->label(trans('admin/webhook.endpoint')) - ->required() + Tabs::make('webhook_tabs') + ->persistTab() ->columnSpanFull() - ->afterStateUpdated(fn (string $state, Set $set) => $set('type', str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)), - Section::make(trans('admin/webhook.regular')) - ->hidden(fn (Get $get) => $get('type') === WebhookType::Discord) - ->schema(fn () => self::getRegularFields()) - ->headerActions([ - Action::make('reset_headers') - ->tooltip(trans('admin/webhook.reset_headers')) - ->color('danger') - ->icon(TablerIcon::Restore) - ->action(fn (Get $get, Set $set) => $set('headers', [ - 'X-Webhook-Event' => '{{event}}', - ])), - ]) - ->formBefore(), - Section::make(trans('admin/webhook.discord')) - ->hidden(fn (Get $get) => $get('type') === WebhookType::Regular) - ->afterStateUpdated(fn (Livewire $livewire) => $livewire->dispatch('refresh-widget')) - ->schema(fn () => self::getDiscordFields()) - ->view('filament.components.webhooksection') - ->aside() - ->formBefore() - ->columnSpanFull(), - Section::make(trans('admin/webhook.events')) - ->schema([ - CheckboxList::make('events') - ->live() - ->options(fn () => WebhookConfiguration::filamentCheckboxList()) - ->searchable() - ->bulkToggleable() - ->columns(3) - ->columnSpanFull() - ->required(), + ->tabs([ + Tab::make(trans('admin/webhook.information')) + ->icon(TablerIcon::InfoCircle) + ->schema([ + Grid::make() + ->schema([ + TextInput::make('name') + ->label(trans('admin/webhook.name')) + ->required(), + Select::make('server_id') + ->label(trans('admin/webhook.server')) + ->relationship('server', 'id') + ->preload() + ->disabled(), + ]), + TextInput::make('description') + ->label(trans('admin/webhook.description')) + ->required(), + Grid::make() + ->schema([ + ToggleButtons::make('type') + ->live() + ->inline() + ->options(WebhookType::class) + ->default(WebhookType::Regular), + Hidden::make('scope') + ->formatStateUsing(fn (Get $get) => $get('server_id') ? WebhookScope::Server : WebhookScope::Global), + TextInput::make('endpoint') + ->label(trans('admin/webhook.endpoint')) + ->required() + ->afterStateUpdated(fn (?string $state, Set $set) => $set('type', $state && str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)), + ]), + ]), + Tab::make(trans('admin/webhook.payload')) + ->icon(TablerIcon::FileCode) + ->schema([ + Section::make() + ->schema(fn (Get $get) => $get('type') === WebhookType::Discord + ? self::getDiscordFields() + : self::getRegularFields() + ), + ]), + Tab::make(trans('admin/webhook.events')) + ->icon(TablerIcon::Star) + ->schema([ + Section::make() + ->schema([ + CheckboxList::make('events') + ->live() + ->options(function (Get $get) { + $scope = $get('scope'); + if (!$scope instanceof WebhookScope) { + $scope = WebhookScope::from($scope ?? 'global'); + } + + return WebhookConfiguration::filamentCheckboxList($scope); + }) + ->searchable() + ->bulkToggleable() + ->columns(3) + ->columnSpanFull() + ->required(), + ]), + ]), ]), ]); } @@ -200,134 +239,150 @@ class WebhookResource extends Resource private static function getDiscordFields(): array { return [ - Section::make(trans('admin/webhook.discord_message.profile')) - ->collapsible() + Grid::make() ->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(trans('admin/webhook.discord_embed.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() + Section::make() + ->columnSpanFull() + ->poll('15s') + ->view('filament.components.webhooksection'), + Grid::make() + ->columnSpan(8) ->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')) + Section::make(trans('admin/webhook.discord_message.profile')) + ->collapsible() + ->columnSpanFull() + + ->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')) + ->columnSpanFull() ->collapsible() ->schema([ - TextInput::make('name') + TextInput::make('content') + ->label(trans('admin/webhook.discord_message.message')) ->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')), + ->required(fn (Get $get) => empty($get('embeds'))), + TextInput::make('thread_name') + ->label(trans('admin/webhook.discord_message.forum_thread')), + CheckboxList::make('flags') + ->label(trans('admin/webhook.discord_embed.flags')) + ->options([ + (1 << 2) => trans('admin/webhook.discord_message.supress_embeds'), // Discord flag: SUPPRESS_EMBEDS (4) + (1 << 12) => trans('admin/webhook.discord_message.supress_notifications'), // Discord flag: SUPPRESS_NOTIFICATIONS (4096) + ]) + ->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() + ->columnSpanFull() + ->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')), + ]), + ]), ]), ]), + ]), ]; } diff --git a/app/Filament/Admin/Widgets/DiscordPreview.php b/app/Filament/Admin/Widgets/DiscordPreview.php deleted file mode 100644 index 2981c08ea..000000000 --- a/app/Filament/Admin/Widgets/DiscordPreview.php +++ /dev/null @@ -1,163 +0,0 @@ - */ - 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/Filament/Server/Resources/Webhooks/Pages/CreateWebhook.php b/app/Filament/Server/Resources/Webhooks/Pages/CreateWebhook.php new file mode 100644 index 000000000..5407bd4af --- /dev/null +++ b/app/Filament/Server/Resources/Webhooks/Pages/CreateWebhook.php @@ -0,0 +1,59 @@ + */ + protected function getDefaultHeaderActions(): array + { + return [ + $this->getCancelFormAction()->formId('form')->icon('tabler-cancel'), + $this->getCreateFormAction()->formId('form')->icon('tabler-plus'), + ]; + } + + protected function getFormActions(): array + { + return []; + } + + protected function mutateFormDataBeforeCreate(array $data): array + { + $server = Filament::getTenant(); + abort_unless($server instanceof Server, 403); + + $data['server_id'] = $server->id; + $data['scope'] = WebhookScope::Server; + + return $data; + } + + protected function getRedirectUrl(): string + { + return EditWebhook::getUrl(['record' => $this->getRecord()]); + } + + public function mount(): void + { + parent::mount(); + WebhookResource::sendHelpBanner(); + } +} diff --git a/app/Filament/Server/Resources/Webhooks/Pages/EditWebhook.php b/app/Filament/Server/Resources/Webhooks/Pages/EditWebhook.php new file mode 100644 index 000000000..f98825593 --- /dev/null +++ b/app/Filament/Server/Resources/Webhooks/Pages/EditWebhook.php @@ -0,0 +1,41 @@ + */ + protected function getDefaultHeaderActions(): array + { + return [ + DeleteAction::make() + ->icon('tabler-trash'), + Action::make('test_now') + ->label(trans('admin/webhook.test_now')) + ->color('primary') + ->icon('tabler-send') + ->action(fn (WebhookConfiguration $record) => $record->run()) + ->tooltip(trans('admin/webhook.test_now_help')), + $this->getSaveFormAction()->formId('form')->icon('tabler-device-floppy'), + ]; + } + + protected function getFormActions(): array + { + return []; + } +} diff --git a/app/Filament/Server/Resources/Webhooks/Pages/ListWebhooks.php b/app/Filament/Server/Resources/Webhooks/Pages/ListWebhooks.php new file mode 100644 index 000000000..7888f4b1a --- /dev/null +++ b/app/Filament/Server/Resources/Webhooks/Pages/ListWebhooks.php @@ -0,0 +1,39 @@ + */ + protected function getDefaultHeaderActions(): array + { + return [ + CreateAction::make() + ->icon('tabler-plus') + ->hiddenLabel() + ->iconButton() + ->iconSize(IconSize::ExtraLarge) + ->hidden(function () { + $server = Filament::getTenant(); + + return !$server instanceof Server || $server->webhookConfigurations()->count() <= 0; + }), + ]; + } +} diff --git a/app/Filament/Server/Resources/Webhooks/Pages/ViewWebhook.php b/app/Filament/Server/Resources/Webhooks/Pages/ViewWebhook.php new file mode 100644 index 000000000..15e9d3223 --- /dev/null +++ b/app/Filament/Server/Resources/Webhooks/Pages/ViewWebhook.php @@ -0,0 +1,19 @@ +id)->count(); + + return $count > 0 ? (string) $count : null; + } + + public static function defaultTable(Table $table): Table + { + return $table + ->columns([ + IconColumn::make('type'), + TextColumn::make('name') + ->label(trans('admin/webhook.name')), + TextColumn::make('endpoint') + ->label(trans('admin/webhook.endpoint')) + ->formatStateUsing(fn (string $state) => str($state)->after('://')) + ->limit(60) + ->wrap(), + ]) + ->recordActions([ + ViewAction::make() + ->hidden(fn (WebhookConfiguration $record) => static::canEdit($record)), + EditAction::make(), + ReplicateAction::make() + ->iconButton() + ->tooltip(trans('filament-actions::replicate.single.label')) + ->modal(false) + ->excludeAttributes(['created_at', 'updated_at']), + ]) + ->groupedBulkActions([ + DeleteBulkAction::make(), + ]) + ->emptyStateIcon('tabler-webhook') + ->emptyStateDescription('') + ->emptyStateHeading(trans('admin/webhook.no_webhooks')) + ->emptyStateActions([ + CreateAction::make(), + ]) + ->persistFiltersInSession() + ->filters([ + SelectFilter::make('type') + ->options(WebhookType::class) + ->attribute('type'), + ]); + } + + public static function defaultForm(Schema $schema): Schema + { + return $schema + ->components([ + ToggleButtons::make('type') + ->live() + ->inline() + ->options(WebhookType::class) + ->default(WebhookType::Regular), + TextInput::make('endpoint') + ->label(trans('admin/webhook.endpoint')) + ->required() + ->afterStateUpdated(fn (?string $state, Set $set) => $set('type', $state && str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)), + TextInput::make('name') + ->label(trans('admin/webhook.name')) + ->columnSpanFull() + ->required(), + Section::make(trans('admin/webhook.regular')) + ->hidden(fn (Get $get) => $get('type') === WebhookType::Discord) + ->schema(fn () => self::getRegularFields()) + ->headerActions([ + Action::make('reset_headers') + ->label(trans('admin/webhook.reset_headers')) + ->color('danger') + ->icon('heroicon-o-trash') + ->action(fn (Set $set) => $set('headers', [ + 'X-Webhook-Event' => '{{event}}', + ])), + ]) + ->formBefore(), + Section::make(trans('admin/webhook.discord')) + ->hidden(fn (Get $get) => $get('type') === WebhookType::Regular) + ->afterStateUpdated(fn (Livewire $livewire) => $livewire->dispatch('refresh-widget')) + ->schema(fn () => self::getDiscordFields()) + ->poll('15s') + ->view('filament.components.webhooksection') + ->aside() + ->formBefore() + ->columnSpanFull(), + Section::make(trans('admin/webhook.events')) + ->schema([ + CheckboxList::make('events') + ->live() + ->options(fn () => WebhookConfiguration::filamentCheckboxList(WebhookScope::Server)) + ->searchable() + ->bulkToggleable() + ->columns(3) + ->columnSpanFull() + ->required(), + ]), + ]); + } + + /** @return Component[] + * @throws Exception + */ + private static function getRegularFields(): array + { + return [ + KeyValue::make('headers') + ->label(trans('admin/webhook.headers')) + ->default(fn () => [ + 'X-Webhook-Event' => '{{event}}', + ]), + ]; + } + + /** @return Component[] + * @throws Exception + */ + 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(trans('admin/webhook.discord_embed.flags')) + ->options([ + (1 << 2) => trans('admin/webhook.discord_message.supress_embeds'), // Discord flag: SUPPRESS_EMBEDS (4) + (1 << 12) => trans('admin/webhook.discord_message.supress_notifications'), // Discord flag: SUPPRESS_NOTIFICATIONS (4096) + ]) + ->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('name') === null), + Textarea::make('name') + ->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 + { + return [ + 'index' => ListWebhooks::route('/'), + 'create' => CreateWebhook::route('/create'), + 'view' => ViewWebhook::route('/{record}'), + 'edit' => EditWebhook::route('/{record}/edit'), + ]; + } +} diff --git a/app/Http/Controllers/Api/Remote/ActivityProcessingController.php b/app/Http/Controllers/Api/Remote/ActivityProcessingController.php index 6c4c92c74..631a625f1 100644 --- a/app/Http/Controllers/Api/Remote/ActivityProcessingController.php +++ b/app/Http/Controllers/Api/Remote/ActivityProcessingController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Remote; +use App\Events\ActivityLogged; use App\Http\Controllers\Controller; use App\Http\Requests\Api\Remote\ActivityEventRequest; use App\Models\ActivityLog; @@ -11,6 +12,7 @@ use App\Models\User; use Carbon\Carbon; use DateTimeInterface; use Exception; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; class ActivityProcessingController extends Controller @@ -77,6 +79,7 @@ class ActivityProcessingController extends Controller 'subject_id' => $server->id, 'subject_type' => $server->getMorphClass(), ]); + Event::dispatch(new ActivityLogged($activityLog)); } } } diff --git a/app/Jobs/ProcessWebhook.php b/app/Jobs/ProcessWebhook.php index 59f8a4ef9..14f55432a 100644 --- a/app/Jobs/ProcessWebhook.php +++ b/app/Jobs/ProcessWebhook.php @@ -34,13 +34,7 @@ class ProcessWebhook implements ShouldQueue $data = reset($data); } - if (is_object($data)) { - $data = get_object_vars($data); - } - - if (is_string($data)) { - $data = Arr::wrap(json_decode($data, true) ?? []); - } + $data = $this->normalizeData($data); $data['event'] = $this->webhookConfiguration->transformClassName($this->eventName); if ($this->webhookConfiguration->type === WebhookType::Discord) { @@ -82,4 +76,18 @@ class ProcessWebhook implements ShouldQueue 'endpoint' => $this->webhookConfiguration->endpoint, ]); } + + /** @return array */ + private function normalizeData(mixed $data): array + { + if (is_string($data)) { + return Arr::wrap(json_decode($data, true) ?? []); + } + + if (is_object($data)) { + return Arr::wrap($data->toArray()); + } + + return Arr::wrap($data); + } } diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php index 3332d0a5b..acbe8f98f 100644 --- a/app/Listeners/DispatchWebhooks.php +++ b/app/Listeners/DispatchWebhooks.php @@ -2,35 +2,207 @@ namespace App\Listeners; +use App\Enums\WebhookScope; +use App\Events\ActivityLogged; +use App\Models\Server; +use App\Models\User; use App\Models\WebhookConfiguration; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; class DispatchWebhooks { - /** - * @param array $data - */ - public function handle(string $eventName, array $data): void + /** @param array|string|null $action */ + public function handle(mixed $event, array|string|null $action = null): void + { + if (is_string($event) && is_array($action)) { + if (str_starts_with($event, 'eloquent.')) { + $this->handleEloquentEvent($action[0], str($event)->between('eloquent.', ':')); + + return; + } + + if ($event !== ActivityLogged::class) { + $this->handleGenericClassEvent($event, $action); + } + + return; + } + + if ($event instanceof ActivityLogged) { + $this->handleActivityLogged($event); + $this->handleGlobalWebhooks($event); + } + } + + protected function handleEloquentEvent(Model $model, string $action): void + { + $modelClass = $model::class; + $eventName = "eloquent.$action: $modelClass"; + + $webhooks = WebhookConfiguration::query() + ->where('scope', WebhookScope::Global) + ->whereJsonContains('events', $eventName) + ->get(); + + if ($webhooks->isEmpty()) { + return; + } + + $webhookData = [ + 'event' => $eventName, + 'data' => $model->toArray(), + 'timestamp' => now()->toIso8601String(), + ]; + + if (!$this->hasPayloadContent($webhookData)) { + return; + } + + /** @var WebhookConfiguration $webhookConfig */ + foreach ($webhooks as $webhookConfig) { + $webhookConfig->run($eventName, [$webhookData]); + } + } + + /** @param array $payload */ + protected function handleGenericClassEvent(string $eventName, array $payload): void { if (!$this->eventIsWatched($eventName)) { return; } $matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) { - return WebhookConfiguration::query()->whereJsonContains('events', $eventName)->get(); + return WebhookConfiguration::query() + ->where('scope', WebhookScope::Global) + ->whereJsonContains('events', $eventName) + ->get(); }); - /** @var WebhookConfiguration $webhookConfig */ + if ($matchingHooks->isEmpty()) { + return; + } + + $obj = $payload[0] ?? null; + $webhookData = ['event' => $eventName, 'timestamp' => now()->toIso8601String()]; + if (is_object($obj)) { + $webhookData['data'] = $obj->toArray(); + } elseif (is_array($obj)) { + $webhookData['data'] = $obj; + } + foreach ($matchingHooks as $webhookConfig) { - if (in_array($eventName, $webhookConfig->events)) { - $webhookConfig->run($eventName, $data); + $webhookConfig->run($eventName, [$webhookData]); + } + } + + protected function handleActivityLogged(ActivityLogged $activityLogged): void + { + $eventName = $activityLogged->model->event; + + if (!$activityLogged->isServerEvent()) { + return; + } + + $server = null; + $morphClass = (new Server())->getMorphClass(); + foreach ($activityLogged->model->subjects as $subject) { + if ($subject->subject_type === $morphClass && $subject->subject instanceof Server) { + $server = $subject->subject; + break; } } + if (!$server && isset($activityLogged->model->properties['server'])) { + $server = Server::find($activityLogged->model->properties['server']['id'] ?? null); + } + + if (!$server) { + return; + } + + $webhooks = $server->webhookConfigurations() + ->whereJsonContains('events', $eventName) + ->get(); + + if ($webhooks->isEmpty()) { + return; + } + + $webhookData = $this->buildActivityPayload($activityLogged); + + if (!$this->hasPayloadContent($webhookData)) { + return; + } + + foreach ($webhooks as $webhookConfig) { + $webhookConfig->run($eventName, [$webhookData]); + } + } + + /** @return array */ + protected function buildActivityPayload(ActivityLogged $activityLogged): array + { + $webhookData = [ + 'event' => $activityLogged->model->event, + 'description' => $activityLogged->model->description, + 'ip' => $activityLogged->model->ip, + 'timestamp' => $activityLogged->model->timestamp->toIso8601String(), + ]; + + if ($activityLogged->model->actor_id) { + $actor = $activityLogged->model->actor; + $webhookData['actor'] = [ + 'id' => $activityLogged->model->actor_id, + 'type' => $activityLogged->model->actor_type, + 'username' => $actor instanceof User ? $actor->username : null, + ]; + } + + if ($activityLogged->model->properties->isNotEmpty()) { + $webhookData['properties'] = $activityLogged->model->properties->toArray(); + } + + if ($activityLogged->model->subjects->isNotEmpty()) { + $webhookData['subjects'] = $activityLogged->model->subjects->map(fn ($subject) => [ + 'id' => $subject->subject_id, + 'type' => $subject->subject_type, + ])->toArray(); + } + + return $webhookData; + } + + protected function handleGlobalWebhooks(ActivityLogged $activityLogged): void + { + $eventName = $activityLogged->model->event; + + if (!$this->eventIsWatched($eventName)) { + return; + } + + $matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) { + return WebhookConfiguration::query() + ->where('scope', WebhookScope::Global) + ->whereJsonContains('events', $eventName) + ->get(); + }); + + $webhookData = $this->buildActivityPayload($activityLogged); + + if (!$this->hasPayloadContent($webhookData)) { + return; + } + + foreach ($matchingHooks as $webhookConfig) { + $webhookConfig->run($eventName, [$webhookData]); + } } protected function eventIsWatched(string $eventName): bool { $watchedEvents = cache()->rememberForever('watchedWebhooks', function () { - return WebhookConfiguration::pluck('events') + return WebhookConfiguration::where('scope', WebhookScope::Global) + ->pluck('events') ->flatten() ->unique() ->values() @@ -39,4 +211,17 @@ class DispatchWebhooks return in_array($eventName, $watchedEvents); } + + /** @param array $webhookData */ + protected function hasPayloadContent(array $webhookData): bool + { + return collect($webhookData) + ->except('event') + ->reject(fn (mixed $value) => match (true) { + is_array($value) => empty($value), + $value instanceof Collection => $value->isEmpty(), + default => $value === null || $value === '', + }) + ->isNotEmpty(); + } } diff --git a/app/Livewire/DiscordPreview.php b/app/Livewire/DiscordPreview.php new file mode 100644 index 000000000..62d1ae5a5 --- /dev/null +++ b/app/Livewire/DiscordPreview.php @@ -0,0 +1,150 @@ +|null */ + public ?array $formPayload = null; + + #[On('discord-form-changed')] + public function onFormChanged( + string $content = '', + string $username = '', + string $avatar_url = '', + mixed $embeds = [], + ): void { + $this->formPayload = [ + 'content' => $content, + 'username' => $username, + 'avatar_url' => $avatar_url, + 'embeds' => is_array($embeds) ? $embeds : [], + ]; + } + + private function safeUrl(?string $url): ?string + { + return ($url && preg_match('/^https?:\/\//i', $url)) ? $url : null; + } + + /** @return array> */ + private function processEmbeds(mixed $embeds): array + { + return collect($embeds) + ->filter(fn (mixed $embed) => is_array($embed)) + ->take(10) + ->map(function (array $embed): array { + $color = $embed['color'] ?? null; + $embed['color'] = match (true) { + is_int($color) => '#' . str_pad(dechex($color), 6, '0', STR_PAD_LEFT), + is_string($color) => $color, + default => null, + }; + + if (!isset($embed['timestamp']) && !empty($embed['has_timestamp'])) { + $embed['timestamp'] = now()->toIso8601String(); + } + + if (isset($embed['timestamp'])) { + try { + $embed['timestamp'] = Carbon::parse($embed['timestamp'])->format('M j, Y H:i'); + } catch (\Throwable) { + unset($embed['timestamp']); + } + } + + $embed['view'] = [ + 'author_name' => $embed['author']['name'] ?? null, + 'author_url' => $this->safeUrl($embed['author']['url'] ?? null), + 'author_icon' => $this->safeUrl($embed['author']['icon_url'] ?? null), + 'title' => $embed['title'] ?? null, + 'title_url' => $this->safeUrl($embed['url'] ?? null), + 'description' => $embed['description'] ?? null, + 'fields' => $embed['fields'] ?? [], + 'image' => $this->safeUrl($embed['image']['url'] ?? null), + 'thumbnail' => $this->safeUrl($embed['thumbnail']['url'] ?? null), + 'footer_text' => $embed['footer']['text'] ?? null, + 'footer_icon' => $this->safeUrl($embed['footer']['icon_url'] ?? null), + 'timestamp' => $embed['timestamp'] ?? null, + 'color_style' => $embed['color'] + ? 'border-left-color: ' . $embed['color'] + : 'border-left-color: #1e1f22', + ]; + + return $embed; + }) + ->values() + ->all(); + } + + public function render(): View + { + return view('livewire.discord-preview', $this->getViewData()); + } + + /** + * @return array{ + * link: callable, + * content: mixed, + * sender: array{name: string, avatar: string}, + * embeds: array, + * getTime: mixed + * } + */ + public function getViewData(): array + { + $default = [ + 'link' => fn ($href, $child) => $href ? "$child" : $child, + 'content' => null, + 'sender' => [ + 'name' => 'Pelican', + 'avatar' => asset('pelican.svg'), + ], + 'embeds' => [], + 'getTime' => fn () => now()->format('H:i'), + ]; + + $payloadArray = $this->formPayload; + + if ($payloadArray === null) { + if (!$this->record || !$this->record->payload) { + return $default; + } + $payloadArray = $this->record->payload; + } + + $scope = $this->record !== null ? $this->record->scope : WebhookScope::Global; + $sampleData = $scope === WebhookScope::Server + ? WebhookConfiguration::getServerWebhookSampleData() + : WebhookConfiguration::getWebhookSampleData(); + + $payloadJson = json_encode($payloadArray) ?: '{}'; + $replacedPayload = (new WebhookConfiguration())->replaceVars($sampleData, $payloadJson); + $data = json_decode($replacedPayload, true) ?? []; + + return [ + 'link' => fn ($href, $child) => $href ? "$child" : $child, + 'content' => data_get($data, 'content'), + 'sender' => [ + 'name' => filled(data_get($data, 'username')) ? data_get($data, 'username') : 'Pelican', + 'avatar' => filled(data_get($data, 'avatar_url')) ? data_get($data, 'avatar_url') : asset('pelican.svg'), + ], + 'embeds' => $this->processEmbeds(data_get($data, 'embeds', [])), + 'getTime' => fn () => now()->format('H:i'), + ]; + } +} diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php index 062ba862a..b5a250f83 100644 --- a/app/Models/ActivityLog.php +++ b/app/Models/ActivityLog.php @@ -3,7 +3,6 @@ namespace App\Models; use App\Enums\TablerIcon; -use App\Events\ActivityLogged; use App\Traits\HasValidation; use BackedEnum; use Filament\Facades\Filament; @@ -18,7 +17,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; use LogicException; @@ -140,9 +138,6 @@ class ActivityLog extends Model implements HasIcon, HasLabel $model->timestamp = Carbon::now(); }); - static::created(function (self $model) { - Event::dispatch(new ActivityLogged($model)); - }); } public function getIcon(): BackedEnum diff --git a/app/Models/Server.php b/app/Models/Server.php index fbfba6f0f..0909cdd09 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -6,6 +6,7 @@ use App\Contracts\Validatable; use App\Enums\ContainerStatus; use App\Enums\ServerResourceType; use App\Enums\ServerState; +use App\Enums\WebhookScope; use App\Exceptions\Http\Server\ServerStateConflictException; use App\Models\Traits\HasIcon; use App\Repositories\Daemon\DaemonServerRepository; @@ -381,6 +382,15 @@ class Server extends Model implements HasAvatar, Validatable return $this->morphToMany(Mount::class, 'mountable'); } + /** + * @return HasMany + */ + public function webhookConfigurations(): HasMany + { + return $this->hasMany(WebhookConfiguration::class, 'server_id', 'id') + ->where('scope', WebhookScope::Server); + } + /** * Returns all the activity log entries where the server is the subject. */ diff --git a/app/Models/WebhookConfiguration.php b/app/Models/WebhookConfiguration.php index a9bd57660..ff3012ebc 100644 --- a/app/Models/WebhookConfiguration.php +++ b/app/Models/WebhookConfiguration.php @@ -2,14 +2,17 @@ namespace App\Models; +use App\Enums\WebhookScope; use App\Enums\WebhookType; use App\Jobs\ProcessWebhook; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\File; use Livewire\Features\SupportEvents\HandlesEvents; @@ -18,6 +21,7 @@ use Livewire\Features\SupportEvents\HandlesEvents; * @property string $endpoint * @property string $description * @property string[] $events + * @property WebhookScope $scope * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property Carbon|null $deleted_at @@ -55,6 +59,9 @@ class WebhookConfiguration extends Model ]; protected $fillable = [ + 'name', + 'scope', + 'server_id', 'type', 'payload', 'endpoint', @@ -67,6 +74,7 @@ class WebhookConfiguration extends Model * Default values for specific fields in the database. */ protected $attributes = [ + 'scope' => WebhookScope::Global, 'type' => WebhookType::Regular, 'payload' => null, ]; @@ -74,6 +82,7 @@ class WebhookConfiguration extends Model protected function casts(): array { return [ + 'scope' => WebhookScope::class, 'events' => 'array', 'payload' => 'array', 'type' => WebhookType::class, @@ -100,10 +109,23 @@ class WebhookConfiguration extends Model private static function updateCache(Collection $eventList): void { $eventList->each(function (string $event) { - cache()->forever("webhooks.$event", WebhookConfiguration::query()->whereJsonContains('events', $event)->get()); + cache()->forget("webhooks.$event"); }); - cache()->forever('watchedWebhooks', WebhookConfiguration::pluck('events')->flatten()->unique()->values()->all()); + $eventList->each(function (string $event) { + cache()->forever("webhooks.$event", static::query() + ->where('scope', WebhookScope::Global) + ->whereJsonContains('events', $event) + ->get()); + }); + + cache()->forget('watchedWebhooks'); + cache()->forever('watchedWebhooks', static::where('scope', WebhookScope::Global) + ->pluck('events') + ->flatten() + ->unique() + ->values() + ->all()); } public function webhooks(): HasMany @@ -111,6 +133,11 @@ class WebhookConfiguration extends Model return $this->hasMany(Webhook::class); } + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + /** @return string[] */ public static function allPossibleEvents(): array { @@ -121,18 +148,211 @@ class WebhookConfiguration extends Model ->all(); } + /** + * @param array $filterList + * @return array + */ + public static function eventList(array $filterList): array + { + return collect(static::allPossibleEvents()) + ->filter(function ($event) use ($filterList) { + foreach ($filterList as $filter) { + $eventLower = strtolower($event); + $filterLower = strtolower($filter); + + if ($eventLower === $filterLower) { + return true; + } + + $pattern = '/(?:\\\\|\\.)' . preg_quote($filterLower) . '(?:\\\\|:|$)/i'; + if (preg_match($pattern, $eventLower)) { + return true; + } + } + + return false; + }) + ->values() + ->all(); + } + + /** @return array */ + public static function adminEvents(): array + { + return static::eventList([ + 'User', + 'Role', + 'SSHKey', + 'ApiKey', + 'Token', + 'HasAccessTokens', + 'Node', + 'Allocation', + 'DatabaseHost', + 'Mount', + 'NodeRole', + 'Egg', + 'EggVariable', + 'Plugin', + 'WebhookConfiguration', + 'Webhook', + 'Captcha', + 'Authentication', + 'ActivityLogged', + ]); + } + + /** @return array */ + public static function globalServerEvents(): array + { + return static::eventList([ + 'Server', + 'ServerTransfer', + 'ServerVariable', + 'Allocation', + 'Backup', + 'Database', + 'File', + 'Schedule', + 'Task', + 'Subuser', + 'Installed', + 'SubUserAdded', + 'SubUserRemoved', + ]); + } + + /** @return string[] */ + public static function allPossibleServerEvents(): array + { + $events = [ + 'server:file.read', + 'server:file.write', + 'server:file.rename', + 'server:file.copy', + 'server:file.compress', + 'server:file.decompress', + 'server:file.delete', + 'server:file.create-directory', + 'server:file.uploaded', + 'server:file.pull', + 'server:file.download', + + 'server:power.start', + 'server:power.stop', + 'server:power.restart', + 'server:power.kill', + + 'server:console.command', + + 'server:startup.edit', + 'server:startup.image', + 'server:settings.rename', + 'server:settings.description', + 'server:settings.reinstall', + + 'server:allocation.notes', + 'server:allocation.primary', + 'server:allocation.create', + 'server:allocation.delete', + + 'server:schedule.create', + 'server:schedule.update', + 'server:schedule.execute', + 'server:schedule.delete', + + 'server:task.create', + 'server:task.update', + 'server:task.delete', + + 'server:backup.start', + 'server:backup.delete', + 'server:backup.download', + 'server:backup.rename', + 'server:backup.restore', + 'server:backup.restore-complete', + 'server:backup.restore-failed', + + 'server:database.create', + 'server:database.rotate-password', + 'server:database.delete', + + 'server:subuser.create', + 'server:subuser.update', + 'server:subuser.delete', + + 'server:sftp.denied', + ]; + + Event::dispatch('server:webhook.events', [&$events]); + + return array_unique($events); + } + + /** @return string[] */ + public static function allPossibleAdminOnlyEvents(): array + { + $events = static::eventList([ + 'User', + 'Role', + 'SSHKey', + 'ApiKey', + 'Token', + 'HasAccessTokens', + 'Node', + 'Allocation', + 'DatabaseHost', + 'Mount', + 'NodeRole', + 'Egg', + 'EggVariable', + 'Plugin', + 'WebhookConfiguration', + 'Webhook', + 'Captcha', + 'Authentication', + 'ActivityLogged', + ]); + + $serverEvents = collect(static::discoverCustomEvents()) + ->merge(static::allModelEvents()) + ->unique() + ->filter(fn ($event) => str($event)->contains('App\\Models\\Server') && !str($event)->contains('Subuser')) + ->values() + ->all(); + + return array_values(array_unique(array_merge($events, $serverEvents))); + } + /** @return array */ - public static function filamentCheckboxList(): array + public static function filamentCheckboxList(WebhookScope $scope): array { $list = []; - $events = static::allPossibleEvents(); - foreach ($events as $event) { - $list[$event] = static::transformClassName($event); + + if ($scope === WebhookScope::Server) { + $events = static::allPossibleServerEvents(); + foreach ($events as $event) { + $list[$event] = static::transformServerEventName($event); + } + } else { + $events = static::allPossibleAdminOnlyEvents(); + foreach ($events as $event) { + $list[$event] = static::transformClassName($event); + } } return $list; } + public static function transformServerEventName(string $event): string + { + return str($event) + ->after('server:') + ->replace('.', ' → ') + ->title() + ->toString(); + } + public static function transformClassName(string $event): string { return str($event) @@ -214,10 +434,16 @@ class WebhookConfiguration extends Model /** @param array $eventData */ public function run(?string $eventName = null, ?array $eventData = null): void { + if ($this->scope === WebhookScope::Server) { + $eventName ??= 'server:file.write'; + $eventData ??= static::getServerWebhookSampleData(); + } + $eventName ??= 'eloquent.created: '.Server::class; $eventData ??= static::getWebhookSampleData(); - ProcessWebhook::dispatch($this, $eventName, [$eventData]); + $payload = array_is_list($eventData) ? $eventData : [$eventData]; + ProcessWebhook::dispatch($this, $eventName, $payload); } /** @@ -420,4 +646,29 @@ class WebhookConfiguration extends Model 'event' => 'updated: Server', ]; } + + /** + * @return array + */ + public static function getServerWebhookSampleData(): array + { + return [ + 'user' => [ + 'uuid' => '12345678-1234-5678-9012-123456789012', + 'username' => 'admin', + 'email' => 'admin@example.com', + 'image' => 'https://www.gravatar.com/avatar/default', + 'admin' => true, + 'language' => 'en', + 'created_at' => '2025-06-01T12:31:50.000000Z', + 'updated_at' => '2025-06-01T12:31:50.000000Z', + ], + 'server' => [ + 'uuid' => '87654321-4321-8765-2109-876543210987', + 'name' => 'Example Server', + 'node' => 'node1.example.com', + 'description' => 'Sample Minecraft server', + ], + ]; + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 50a6e42cd..309f6d116 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Events\ActivityLogged; use App\Listeners\DispatchWebhooks; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -11,7 +12,8 @@ class EventServiceProvider extends ServiceProvider * The event to listener mappings for the application. */ protected $listen = [ - 'App\\*' => [DispatchWebhooks::class], + ActivityLogged::class => [DispatchWebhooks::class], + 'App\\Events\\*' => [DispatchWebhooks::class], 'eloquent.created*' => [DispatchWebhooks::class], 'eloquent.deleted*' => [DispatchWebhooks::class], 'eloquent.updated*' => [DispatchWebhooks::class], diff --git a/app/Services/Activity/ActivityLogService.php b/app/Services/Activity/ActivityLogService.php index 9cf8e2d1e..b3e4f40ca 100644 --- a/app/Services/Activity/ActivityLogService.php +++ b/app/Services/Activity/ActivityLogService.php @@ -2,6 +2,7 @@ namespace App\Services\Activity; +use App\Events\ActivityLogged; use App\Models\ActivityLog; use App\Models\Server; use App\Models\User; @@ -12,6 +13,7 @@ use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Request; use Throwable; use Webmozart\Assert\Assert; @@ -244,6 +246,8 @@ class ActivityLogService return $this->activity; }); + Event::dispatch(new ActivityLogged($response)); + $this->activity = null; $this->subjects = []; diff --git a/app/Services/WebhookService.php b/app/Services/WebhookService.php new file mode 100644 index 000000000..7cf49b861 --- /dev/null +++ b/app/Services/WebhookService.php @@ -0,0 +1,44 @@ + $contextualData + */ + public function dispatch(string $eventName, array $contextualData, ?Server $server = null): void + { + if ($server) { + $webhooks = $server->webhookConfigurations() + ->whereJsonContains('events', $eventName) + ->get(); + + foreach ($webhooks as $webhook) { + ProcessWebhook::dispatch($webhook, $eventName, [$contextualData]); + } + } + + $globalWebhooks = WebhookConfiguration::query() + ->where('scope', WebhookScope::Global) + ->whereJsonContains('events', $eventName) + ->get(); + + foreach ($globalWebhooks as $webhook) { + ProcessWebhook::dispatch($webhook, $eventName, [$contextualData]); + } + } + + /** + * @return array + */ + public function getAllEvents(WebhookScope $scope = WebhookScope::Global): array + { + return WebhookConfiguration::filamentCheckboxList($scope); + } +} diff --git a/database/migrations/2025_09_26_234340_add_scope_to_webhook_configurations_table.php b/database/migrations/2025_09_26_234340_add_scope_to_webhook_configurations_table.php new file mode 100644 index 000000000..a052db722 --- /dev/null +++ b/database/migrations/2025_09_26_234340_add_scope_to_webhook_configurations_table.php @@ -0,0 +1,34 @@ +string('scope')->default('global')->after('id'); + $table->unsignedInteger('server_id')->nullable()->after('scope'); + $table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade'); + + $table->index(['scope', 'server_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_configurations', function (Blueprint $table) { + $table->dropForeign(['server_id']); + $table->dropIndex(['scope', 'server_id']); + $table->dropColumn(['scope', 'server_id']); + }); + } +}; diff --git a/database/migrations/2025_10_28_152500_rename_description_webhooks.php b/database/migrations/2025_10_28_152500_rename_description_webhooks.php new file mode 100644 index 000000000..c29405a81 --- /dev/null +++ b/database/migrations/2025_10_28_152500_rename_description_webhooks.php @@ -0,0 +1,30 @@ +renameColumn('description', 'name'); + $table->text('description')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('webhook_configurations', function (Blueprint $table) { + $table->dropColumn('description'); + $table->renameColumn('name', 'description'); + }); + } +}; diff --git a/lang/en/admin/webhook.php b/lang/en/admin/webhook.php index 66586c6b4..5a2038235 100644 --- a/lang/en/admin/webhook.php +++ b/lang/en/admin/webhook.php @@ -6,16 +6,21 @@ return [ 'model_label_plural' => 'Webhooks', 'endpoint' => 'Endpoint', 'description' => 'Description', + 'name' => 'Name', + 'server' => 'Server', + 'information' => 'Information', + 'payload' => 'Payload', + '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' => 'Test Now', + 'test_now_help' => 'This will fire a `created: Server` event', 'table' => [ 'description' => 'Description', 'endpoint' => 'Endpoint', ], 'headers' => 'Headers', - 'events' => 'Events', 'regular' => 'Regular', 'reset_headers' => 'Reset Headers', 'discord' => 'Discord', diff --git a/resources/css/discord-preview.css b/resources/css/discord-preview.css new file mode 100644 index 000000000..93fd058e3 --- /dev/null +++ b/resources/css/discord-preview.css @@ -0,0 +1,253 @@ +.dc-chat { + background: #313338; + padding: 8px 16px; + border-radius: 6px; + font-family: 'gg sans', 'Noto Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-size: 1rem; + line-height: 1.375rem; + color: #dbdee1; +} + +.dc-msg { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.dc-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + margin-top: 2px; +} + +.dc-msg-body { + flex: 1; + min-width: 0; +} + +.dc-meta { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 4px; +} + +.dc-username { + font-size: 1rem; + font-weight: 500; + color: #f2f3f5; + line-height: 1.375rem; +} + +.dc-bot-tag { + background: #5865f2; + border-radius: 3px; + font-size: 0.625rem; + font-weight: 500; + padding: 1px 4px; + color: #fff; + text-transform: uppercase; + letter-spacing: 0.02em; + line-height: 1.3; + vertical-align: middle; + position: relative; + top: -1px; +} + +.dc-timestamp { + font-size: 0.75rem; + color: #949ba4; + line-height: 1.375rem; +} + +.dc-content { + font-size: 1rem; + color: #dbdee1; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.375rem; +} + +/* ── Embed ────────────────────────────────── */ + +.dc-embed { + background: #2b2d31; + border-left: 4px solid #1e1f22; + border-radius: 4px; + max-width: 516px; + margin-top: 4px; + overflow: hidden; +} + +.dc-embed-body { + padding: 8px 16px 16px 12px; +} + +/* Grid: content column + optional thumbnail column */ +.dc-embed-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0 16px; +} + +.dc-embed-grid.has-thumbnail { + grid-template-columns: 1fr 80px; +} + +.dc-embed-content { + grid-column: 1; + min-width: 0; +} + +.dc-embed-thumbnail { + grid-column: 2; + grid-row: 1; + width: 80px; + height: 80px; + object-fit: cover; + border-radius: 3px; + align-self: start; + margin-top: 8px; +} + +/* Author */ +.dc-embed-author { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.dc-embed-author-icon { + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; +} + +.dc-embed-author-name { + font-size: 0.875rem; + font-weight: 600; + color: #f2f3f5; + line-height: 1.375rem; + overflow-wrap: break-word; +} + +a.dc-embed-author-name:hover { + text-decoration: underline; +} + +/* Title */ +.dc-embed-title { + display: inline-block; + font-size: 1rem; + font-weight: 600; + color: #f2f3f5; + margin-top: 8px; + line-height: 1.375rem; + overflow-wrap: break-word; +} + +a.dc-embed-title { + color: #00a8fc; + text-decoration: none; +} + +a.dc-embed-title:hover { + text-decoration: underline; +} + +/* Description */ +.dc-embed-desc { + font-size: 0.875rem; + color: #dbdee1; + line-height: 1.375rem; + margin-top: 8px; + white-space: pre-wrap; + word-break: break-word; +} + +/* Fields — 3-column grid */ +.dc-embed-fields { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-top: 8px; +} + +.dc-embed-field { + grid-column: 1 / -1; + min-width: 0; +} + +.dc-embed-field.inline { + grid-column: span 1; +} + +.dc-embed-field-name { + font-size: 0.875rem; + font-weight: 600; + color: #f2f3f5; + line-height: 1.375rem; + margin-bottom: 2px; + overflow-wrap: break-word; +} + +.dc-embed-field-value { + font-size: 0.875rem; + color: #dbdee1; + line-height: 1.375rem; + white-space: pre-wrap; + overflow-wrap: break-word; +} + +/* Large image */ +.dc-embed-image { + display: block; + width: 100%; + max-height: 300px; + object-fit: contain; + border-radius: 0 0 4px 4px; + margin-top: 16px; + cursor: pointer; +} + +/* Footer */ +.dc-embed-footer { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; +} + +.dc-embed-footer-icon { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.dc-embed-footer-text { + font-size: 0.75rem; + color: #949ba4; + line-height: 1.375rem; + overflow-wrap: break-word; +} + +.dc-embed-footer-sep { + margin: 0 4px; +} + +a.dc-link { + color: #00a8fc; + text-decoration: none; +} + +a.dc-link:hover { + text-decoration: underline; +} diff --git a/resources/views/filament/admin/widgets/discord-preview.blade.php b/resources/views/filament/admin/widgets/discord-preview.blade.php deleted file mode 100644 index fc475c163..000000000 --- a/resources/views/filament/admin/widgets/discord-preview.blade.php +++ /dev/null @@ -1,221 +0,0 @@ - - @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 index ae290339f..c814a79f8 100644 --- a/resources/views/filament/components/webhooksection.blade.php +++ b/resources/views/filament/components/webhooksection.blade.php @@ -68,7 +68,21 @@ :secondary="$isSecondary" > - @livewire(App\Filament\Admin\Widgets\DiscordPreview::class, ['record' => $getRecord(), 'pollingInterval' => $pollingInterval ?? null]) + + @livewire('discord-preview', ['record' => $getRecord()]) {{ $getChildSchema()->gap(! $isDivided)->extraAttributes(['class' => 'fi-section-content']) }} diff --git a/resources/views/livewire/discord-preview.blade.php b/resources/views/livewire/discord-preview.blade.php new file mode 100644 index 000000000..40c2dca0e --- /dev/null +++ b/resources/views/livewire/discord-preview.blade.php @@ -0,0 +1,113 @@ +
+ @assets + @vite('resources/css/discord-preview.css') + @endassets + +
+
+ {{-- Avatar --}} + @if ($avatar = $sender['avatar']) + + @else +
+ @endif + +
+ {{-- Meta row --}} +
+ {{ $sender['name'] ?? 'Pelican' }} + @if (!($sender['human'] ?? false)) + app + @endif + {{ $getTime() }} +
+ + {{-- Message content --}} + @if (filled($content)) +
{!! nl2br(e($content)) !!}
+ @endif + + {{-- Embeds --}} + @foreach ($embeds as $embed) +
+
+
+ +
+ + {{-- Author --}} + @if ($embed['view']['author_name']) +
+ @if ($embed['view']['author_icon']) + + @endif + @if ($embed['view']['author_url']) + {{ $embed['view']['author_name'] }} + @else + {{ $embed['view']['author_name'] }} + @endif +
+ @endif + + {{-- Title --}} + @if ($embed['view']['title']) + @if ($embed['view']['title_url']) + {{ $embed['view']['title'] }} + @else +
{{ $embed['view']['title'] }}
+ @endif + @endif + + {{-- Description --}} + @if ($embed['view']['description']) +
{!! nl2br(e($embed['view']['description'])) !!}
+ @endif + + {{-- Fields --}} + @if (!empty($embed['view']['fields'])) +
+ @foreach ($embed['view']['fields'] as $field) +
+
{{ $field['name'] ?? '' }}
+
{!! nl2br(e($field['value'] ?? '')) !!}
+
+ @endforeach +
+ @endif + +
+ + {{-- Thumbnail --}} + @if ($embed['view']['thumbnail']) + + @endif + +
{{-- /.dc-embed-grid --}} + + {{-- Large image --}} + @if ($embed['view']['image']) + + @endif + + {{-- Footer --}} + @if ($embed['view']['footer_text'] || $embed['view']['timestamp']) + + @endif + +
{{-- /.dc-embed-body --}} +
{{-- /.dc-embed --}} + @endforeach + +
{{-- /.dc-msg-body --}} +
{{-- /.dc-msg --}} +
{{-- /.dc-chat --}} +