Webhook fix + server area webhooks (#2377)

This commit is contained in:
JoanFo
2026-06-19 05:28:14 +02:00
committed by GitHub
parent d5df688638
commit ed7d4f5a6b
27 changed files with 1929 additions and 589 deletions

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum WebhookScope: string
{
case Global = 'global';
case Server = 'server';
public function getLabel(): string
{
return match ($this) {
self::Global => 'Global',
self::Server => 'Server',
};
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Admin\Resources\Webhooks\Pages; namespace App\Filament\Admin\Resources\Webhooks\Pages;
use App\Enums\TablerIcon; use App\Enums\TablerIcon;
use App\Enums\WebhookScope;
use App\Enums\WebhookType; use App\Enums\WebhookType;
use App\Filament\Admin\Resources\Webhooks\WebhookResource; use App\Filament\Admin\Resources\Webhooks\WebhookResource;
use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderActions;
@@ -10,6 +11,7 @@ use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Validation\ValidationException;
class CreateWebhookConfiguration extends CreateRecord class CreateWebhookConfiguration extends CreateRecord
{ {
@@ -44,6 +46,15 @@ class CreateWebhookConfiguration extends CreateRecord
protected function mutateFormDataBeforeCreate(array $data): array 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) { if (($data['type'] ?? null) === WebhookType::Discord->value) {
$embeds = data_get($data, 'embeds', []); $embeds = data_get($data, 'embeds', []);

View File

@@ -2,10 +2,16 @@
namespace App\Filament\Admin\Resources\Webhooks\Pages; namespace App\Filament\Admin\Resources\Webhooks\Pages;
use App\Enums\WebhookScope;
use App\Filament\Admin\Resources\Webhooks\WebhookResource; use App\Filament\Admin\Resources\Webhooks\WebhookResource;
use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
class ListWebhookConfigurations extends ListRecords class ListWebhookConfigurations extends ListRecords
{ {
@@ -13,4 +19,24 @@ class ListWebhookConfigurations extends ListRecords
use CanCustomizeHeaderWidgets; use CanCustomizeHeaderWidgets;
protected static string $resource = WebhookResource::class; protected static string $resource = WebhookResource::class;
/** @return array<Action> */
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();
}
} }

View File

@@ -3,12 +3,14 @@
namespace App\Filament\Admin\Resources\Webhooks; namespace App\Filament\Admin\Resources\Webhooks;
use App\Enums\TablerIcon; use App\Enums\TablerIcon;
use App\Enums\WebhookScope;
use App\Enums\WebhookType; use App\Enums\WebhookType;
use App\Filament\Admin\Resources\Webhooks\Pages\CreateWebhookConfiguration; use App\Filament\Admin\Resources\Webhooks\Pages\CreateWebhookConfiguration;
use App\Filament\Admin\Resources\Webhooks\Pages\EditWebhookConfiguration; use App\Filament\Admin\Resources\Webhooks\Pages\EditWebhookConfiguration;
use App\Filament\Admin\Resources\Webhooks\Pages\ListWebhookConfigurations; use App\Filament\Admin\Resources\Webhooks\Pages\ListWebhookConfigurations;
use App\Filament\Admin\Resources\Webhooks\Pages\ViewWebhookConfiguration; use App\Filament\Admin\Resources\Webhooks\Pages\ViewWebhookConfiguration;
use App\Livewire\AlertBanner; use App\Livewire\AlertBanner;
use App\Models\Server;
use App\Models\WebhookConfiguration; use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations; use App\Traits\Filament\CanCustomizeRelations;
@@ -16,7 +18,6 @@ use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable; use App\Traits\Filament\CanModifyTable;
use BackedEnum; use BackedEnum;
use Exception; use Exception;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
@@ -26,14 +27,19 @@ use Filament\Actions\ViewAction;
use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Components\ToggleButtons;
use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section; 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\Get;
use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
@@ -42,7 +48,6 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Livewire\Component as Livewire;
use Livewire\Features\SupportEvents\HandlesEvents; use Livewire\Features\SupportEvents\HandlesEvents;
class WebhookResource extends Resource class WebhookResource extends Resource
@@ -57,7 +62,7 @@ class WebhookResource extends Resource
protected static string|BackedEnum|null $navigationIcon = TablerIcon::Webhook; protected static string|BackedEnum|null $navigationIcon = TablerIcon::Webhook;
protected static ?string $recordTitleAttribute = 'description'; protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string public static function getNavigationLabel(): string
{ {
@@ -87,17 +92,25 @@ class WebhookResource extends Resource
public static function defaultTable(Table $table): Table public static function defaultTable(Table $table): Table
{ {
return $table return $table
->groups([
'server.name',
])
->columns([ ->columns([
TextColumn::make('name')
->label(trans('admin/webhook.name')),
TextColumn::make('description')
->label(trans('admin/webhook.table.description')),
IconColumn::make('type'), IconColumn::make('type'),
TextColumn::make('server.name')
->label('Server')
->placeholder('—')
->icon('tabler-server')
->iconColor('info'),
TextColumn::make('endpoint') TextColumn::make('endpoint')
->label(trans('admin/webhook.table.endpoint')) ->label(trans('admin/webhook.endpoint'))
->formatStateUsing(fn (string $state) => str($state)->after('://')) ->formatStateUsing(fn (string $state) => str($state)->after('://'))
->limit(60) ->limit(60)
->wrap(), ->wrap(),
TextColumn::make('description')
->label(trans('admin/webhook.table.description')),
TextColumn::make('endpoint')
->label(trans('admin/webhook.table.endpoint')),
]) ])
->recordActions([ ->recordActions([
ViewAction::make() ViewAction::make()
@@ -108,7 +121,7 @@ class WebhookResource extends Resource
->tooltip(trans('filament-actions::replicate.single.label')) ->tooltip(trans('filament-actions::replicate.single.label'))
->modal(false) ->modal(false)
->excludeAttributes(['created_at', 'updated_at']) ->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])), ->successRedirectUrl(fn (WebhookConfiguration $replica) => EditWebhookConfiguration::getUrl(['record' => $replica])),
]) ])
->toolbarActions([ ->toolbarActions([
@@ -125,6 +138,9 @@ class WebhookResource extends Resource
SelectFilter::make('type') SelectFilter::make('type')
->options(WebhookType::class) ->options(WebhookType::class)
->attribute('type'), ->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 return $schema
->components([ ->components([
ToggleButtons::make('type') Tabs::make('webhook_tabs')
->live() ->persistTab()
->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()
->columnSpanFull() ->columnSpanFull()
->afterStateUpdated(fn (string $state, Set $set) => $set('type', str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)), ->tabs([
Section::make(trans('admin/webhook.regular')) Tab::make(trans('admin/webhook.information'))
->hidden(fn (Get $get) => $get('type') === WebhookType::Discord) ->icon(TablerIcon::InfoCircle)
->schema(fn () => self::getRegularFields()) ->schema([
->headerActions([ Grid::make()
Action::make('reset_headers') ->schema([
->tooltip(trans('admin/webhook.reset_headers')) TextInput::make('name')
->color('danger') ->label(trans('admin/webhook.name'))
->icon(TablerIcon::Restore) ->required(),
->action(fn (Get $get, Set $set) => $set('headers', [ Select::make('server_id')
'X-Webhook-Event' => '{{event}}', ->label(trans('admin/webhook.server'))
])), ->relationship('server', 'id')
]) ->preload()
->formBefore(), ->disabled(),
Section::make(trans('admin/webhook.discord')) ]),
->hidden(fn (Get $get) => $get('type') === WebhookType::Regular) TextInput::make('description')
->afterStateUpdated(fn (Livewire $livewire) => $livewire->dispatch('refresh-widget')) ->label(trans('admin/webhook.description'))
->schema(fn () => self::getDiscordFields()) ->required(),
->view('filament.components.webhooksection') Grid::make()
->aside() ->schema([
->formBefore() ToggleButtons::make('type')
->columnSpanFull(), ->live()
Section::make(trans('admin/webhook.events')) ->inline()
->schema([ ->options(WebhookType::class)
CheckboxList::make('events') ->default(WebhookType::Regular),
->live() Hidden::make('scope')
->options(fn () => WebhookConfiguration::filamentCheckboxList()) ->formatStateUsing(fn (Get $get) => $get('server_id') ? WebhookScope::Server : WebhookScope::Global),
->searchable() TextInput::make('endpoint')
->bulkToggleable() ->label(trans('admin/webhook.endpoint'))
->columns(3) ->required()
->columnSpanFull() ->afterStateUpdated(fn (?string $state, Set $set) => $set('type', $state && str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)),
->required(), ]),
]),
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 private static function getDiscordFields(): array
{ {
return [ return [
Section::make(trans('admin/webhook.discord_message.profile')) Grid::make()
->collapsible()
->schema([ ->schema([
TextInput::make('username') Section::make()
->live(debounce: 500) ->columnSpanFull()
->label(trans('admin/webhook.discord_message.username')), ->poll('15s')
TextInput::make('avatar_url') ->view('filament.components.webhooksection'),
->live(debounce: 500) Grid::make()
->label(trans('admin/webhook.discord_message.avatar_url')), ->columnSpan(8)
]),
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()
->schema([ ->schema([
TextInput::make('author.name') Section::make(trans('admin/webhook.discord_message.profile'))
->live(debounce: 500) ->collapsible()
->label(trans('admin/webhook.discord_embed.author')) ->columnSpanFull()
->required(fn (Get $get) => filled($get('author.url')) || filled($get('author.icon_url'))),
TextInput::make('author.url') ->schema([
->live(debounce: 500) TextInput::make('username')
->label(trans('admin/webhook.discord_embed.author_url')), ->live(debounce: 500)
TextInput::make('author.icon_url') ->label(trans('admin/webhook.discord_message.username')),
->live(debounce: 500) TextInput::make('avatar_url')
->label(trans('admin/webhook.discord_embed.author_icon_url')), ->live(debounce: 500)
]), ->label(trans('admin/webhook.discord_message.avatar_url')),
Section::make(trans('admin/webhook.discord_embed.body')) ]),
->collapsible() Section::make(trans('admin/webhook.discord_message.message'))
->collapsed() ->columnSpanFull()
->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() ->collapsible()
->schema([ ->schema([
TextInput::make('name') TextInput::make('content')
->label(trans('admin/webhook.discord_message.message'))
->live(debounce: 500) ->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_name')) ->required(fn (Get $get) => empty($get('embeds'))),
->required(), TextInput::make('thread_name')
Textarea::make('value') ->label(trans('admin/webhook.discord_message.forum_thread')),
->live(debounce: 500) CheckboxList::make('flags')
->label(trans('admin/webhook.discord_embed.field_value')) ->label(trans('admin/webhook.discord_embed.flags'))
->rows(4) ->options([
->required(), (1 << 2) => trans('admin/webhook.discord_message.supress_embeds'), // Discord flag: SUPPRESS_EMBEDS (4)
Checkbox::make('inline') (1 << 12) => trans('admin/webhook.discord_message.supress_notifications'), // Discord flag: SUPPRESS_NOTIFICATIONS (4096)
->live(debounce: 500) ])
->label(trans('admin/webhook.discord_embed.inline_field')), ->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')),
]),
]),
]), ]),
]), ]),
]), ]),
]; ];
} }

View File

@@ -1,163 +0,0 @@
<?php
namespace App\Filament\Admin\Widgets;
use App\Models\WebhookConfiguration;
use Filament\Widgets\Widget;
use Illuminate\Support\Carbon;
class DiscordPreview extends Widget
{
protected string $view = 'filament.admin.widgets.discord-preview';
/** @var array<string, string> */
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<string, mixed>|null */
public string|array|null $payload = null;
/**
* @return array{
* link: callable,
* content: mixed,
* sender: array{name: string, avatar: string},
* embeds: array<int, mixed>,
* getTime: mixed
* }
*/
public function getViewData(): array
{
if (!$this->record || !$this->record->payload) {
return [
'link' => fn ($href, $child) => $href ? "<a href=\"$href\" target=\"_blank\" class=\"link\">$child</a>" : $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('<a href="%s" target="_blank" class="link">%s</a>', $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<string, mixed> $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<string, mixed>|null $payload
* @param array<string, mixed> $data
* @return array<string, mixed>|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<string, mixed>
*/
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',
],
];
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Filament\Server\Resources\Webhooks\Pages;
use App\Enums\WebhookScope;
use App\Filament\Server\Resources\Webhooks\WebhookResource;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Filament\Resources\Pages\CreateRecord;
class CreateWebhook extends CreateRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = WebhookResource::class;
protected static bool $canCreateAnother = false;
/** @return array<Action|ActionGroup> */
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();
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Filament\Server\Resources\Webhooks\Pages;
use App\Filament\Server\Resources\Webhooks\WebhookResource;
use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWebhook extends EditRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = WebhookResource::class;
/** @return array<Action|ActionGroup> */
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 [];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Filament\Server\Resources\Webhooks\Pages;
use App\Filament\Server\Resources\Webhooks\WebhookResource;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\IconSize;
class ListWebhooks extends ListRecords
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = WebhookResource::class;
/** @return array<Action|ActionGroup> */
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;
}),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Server\Resources\Webhooks\Pages;
use App\Filament\Server\Resources\Webhooks\WebhookResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewWebhook extends ViewRecord
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,356 @@
<?php
namespace App\Filament\Server\Resources\Webhooks;
use App\Enums\WebhookScope;
use App\Enums\WebhookType;
use App\Filament\Server\Resources\Webhooks\Pages\CreateWebhook;
use App\Filament\Server\Resources\Webhooks\Pages\EditWebhook;
use App\Filament\Server\Resources\Webhooks\Pages\ListWebhooks;
use App\Filament\Server\Resources\Webhooks\Pages\ViewWebhook;
use App\Livewire\AlertBanner;
use App\Models\Server;
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 Exception;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
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\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Support\Components\Component;
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
{
use CanCustomizePages;
use CanCustomizeRelations;
use CanModifyForm;
use CanModifyTable;
use HandlesEvents;
protected static ?string $model = WebhookConfiguration::class;
protected static string|\BackedEnum|null $navigationIcon = 'tabler-webhook';
protected static ?int $navigationSort = 11;
protected static ?string $slug = 'webhook';
public static function getNavigationLabel(): string
{
return trans('admin/webhook.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/webhook.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/webhook.model_label_plural');
}
public static function getNavigationBadge(): ?string
{
/** @var Server|null $server */
$server = Filament::getTenant();
if (!$server instanceof Server) {
return null;
}
$count = static::getModel()::where('server_id', $server->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<string, PageRegistration> */
public static function getDefaultPages(): array
{
return [
'index' => ListWebhooks::route('/'),
'create' => CreateWebhook::route('/create'),
'view' => ViewWebhook::route('/{record}'),
'edit' => EditWebhook::route('/{record}/edit'),
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Remote; namespace App\Http\Controllers\Api\Remote;
use App\Events\ActivityLogged;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ActivityEventRequest; use App\Http\Requests\Api\Remote\ActivityEventRequest;
use App\Models\ActivityLog; use App\Models\ActivityLog;
@@ -11,6 +12,7 @@ use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use DateTimeInterface; use DateTimeInterface;
use Exception; use Exception;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class ActivityProcessingController extends Controller class ActivityProcessingController extends Controller
@@ -77,6 +79,7 @@ class ActivityProcessingController extends Controller
'subject_id' => $server->id, 'subject_id' => $server->id,
'subject_type' => $server->getMorphClass(), 'subject_type' => $server->getMorphClass(),
]); ]);
Event::dispatch(new ActivityLogged($activityLog));
} }
} }
} }

View File

@@ -34,13 +34,7 @@ class ProcessWebhook implements ShouldQueue
$data = reset($data); $data = reset($data);
} }
if (is_object($data)) { $data = $this->normalizeData($data);
$data = get_object_vars($data);
}
if (is_string($data)) {
$data = Arr::wrap(json_decode($data, true) ?? []);
}
$data['event'] = $this->webhookConfiguration->transformClassName($this->eventName); $data['event'] = $this->webhookConfiguration->transformClassName($this->eventName);
if ($this->webhookConfiguration->type === WebhookType::Discord) { if ($this->webhookConfiguration->type === WebhookType::Discord) {
@@ -82,4 +76,18 @@ class ProcessWebhook implements ShouldQueue
'endpoint' => $this->webhookConfiguration->endpoint, 'endpoint' => $this->webhookConfiguration->endpoint,
]); ]);
} }
/** @return array<mixed> */
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);
}
} }

View File

@@ -2,35 +2,207 @@
namespace App\Listeners; namespace App\Listeners;
use App\Enums\WebhookScope;
use App\Events\ActivityLogged;
use App\Models\Server;
use App\Models\User;
use App\Models\WebhookConfiguration; use App\Models\WebhookConfiguration;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class DispatchWebhooks class DispatchWebhooks
{ {
/** /** @param array<mixed>|string|null $action */
* @param array<mixed> $data public function handle(mixed $event, array|string|null $action = null): void
*/ {
public function handle(string $eventName, array $data): 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<mixed> $payload */
protected function handleGenericClassEvent(string $eventName, array $payload): void
{ {
if (!$this->eventIsWatched($eventName)) { if (!$this->eventIsWatched($eventName)) {
return; return;
} }
$matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) { $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) { foreach ($matchingHooks as $webhookConfig) {
if (in_array($eventName, $webhookConfig->events)) { $webhookConfig->run($eventName, [$webhookData]);
$webhookConfig->run($eventName, $data); }
}
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<string, mixed> */
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 protected function eventIsWatched(string $eventName): bool
{ {
$watchedEvents = cache()->rememberForever('watchedWebhooks', function () { $watchedEvents = cache()->rememberForever('watchedWebhooks', function () {
return WebhookConfiguration::pluck('events') return WebhookConfiguration::where('scope', WebhookScope::Global)
->pluck('events')
->flatten() ->flatten()
->unique() ->unique()
->values() ->values()
@@ -39,4 +211,17 @@ class DispatchWebhooks
return in_array($eventName, $watchedEvents); return in_array($eventName, $watchedEvents);
} }
/** @param array<mixed> $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();
}
} }

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Livewire;
use App\Enums\WebhookScope;
use App\Models\WebhookConfiguration;
use Filament\Schemas\Components\Concerns\CanPoll;
use Filament\Support\Concerns\EvaluatesClosures;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
class DiscordPreview extends Component
{
use CanPoll;
use EvaluatesClosures;
public ?WebhookConfiguration $record = null;
/** @var array<string, mixed>|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<int, array<string, mixed>> */
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<int, mixed>,
* getTime: mixed
* }
*/
public function getViewData(): array
{
$default = [
'link' => fn ($href, $child) => $href ? "<a href=\"$href\" target=\"_blank\" class=\"link\">$child</a>" : $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 ? "<a href=\"$href\" target=\"_blank\" class=\"link\">$child</a>" : $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'),
];
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use App\Enums\TablerIcon; use App\Enums\TablerIcon;
use App\Events\ActivityLogged;
use App\Traits\HasValidation; use App\Traits\HasValidation;
use BackedEnum; use BackedEnum;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@@ -18,7 +17,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use LogicException; use LogicException;
@@ -140,9 +138,6 @@ class ActivityLog extends Model implements HasIcon, HasLabel
$model->timestamp = Carbon::now(); $model->timestamp = Carbon::now();
}); });
static::created(function (self $model) {
Event::dispatch(new ActivityLogged($model));
});
} }
public function getIcon(): BackedEnum public function getIcon(): BackedEnum

View File

@@ -6,6 +6,7 @@ use App\Contracts\Validatable;
use App\Enums\ContainerStatus; use App\Enums\ContainerStatus;
use App\Enums\ServerResourceType; use App\Enums\ServerResourceType;
use App\Enums\ServerState; use App\Enums\ServerState;
use App\Enums\WebhookScope;
use App\Exceptions\Http\Server\ServerStateConflictException; use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Models\Traits\HasIcon; use App\Models\Traits\HasIcon;
use App\Repositories\Daemon\DaemonServerRepository; use App\Repositories\Daemon\DaemonServerRepository;
@@ -381,6 +382,15 @@ class Server extends Model implements HasAvatar, Validatable
return $this->morphToMany(Mount::class, 'mountable'); return $this->morphToMany(Mount::class, 'mountable');
} }
/**
* @return HasMany<WebhookConfiguration, $this>
*/
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. * Returns all the activity log entries where the server is the subject.
*/ */

View File

@@ -2,14 +2,17 @@
namespace App\Models; namespace App\Models;
use App\Enums\WebhookScope;
use App\Enums\WebhookType; use App\Enums\WebhookType;
use App\Jobs\ProcessWebhook; use App\Jobs\ProcessWebhook;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Livewire\Features\SupportEvents\HandlesEvents; use Livewire\Features\SupportEvents\HandlesEvents;
@@ -18,6 +21,7 @@ use Livewire\Features\SupportEvents\HandlesEvents;
* @property string $endpoint * @property string $endpoint
* @property string $description * @property string $description
* @property string[] $events * @property string[] $events
* @property WebhookScope $scope
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property Carbon|null $deleted_at * @property Carbon|null $deleted_at
@@ -55,6 +59,9 @@ class WebhookConfiguration extends Model
]; ];
protected $fillable = [ protected $fillable = [
'name',
'scope',
'server_id',
'type', 'type',
'payload', 'payload',
'endpoint', 'endpoint',
@@ -67,6 +74,7 @@ class WebhookConfiguration extends Model
* Default values for specific fields in the database. * Default values for specific fields in the database.
*/ */
protected $attributes = [ protected $attributes = [
'scope' => WebhookScope::Global,
'type' => WebhookType::Regular, 'type' => WebhookType::Regular,
'payload' => null, 'payload' => null,
]; ];
@@ -74,6 +82,7 @@ class WebhookConfiguration extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'scope' => WebhookScope::class,
'events' => 'array', 'events' => 'array',
'payload' => 'array', 'payload' => 'array',
'type' => WebhookType::class, 'type' => WebhookType::class,
@@ -100,10 +109,23 @@ class WebhookConfiguration extends Model
private static function updateCache(Collection $eventList): void private static function updateCache(Collection $eventList): void
{ {
$eventList->each(function (string $event) { $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 public function webhooks(): HasMany
@@ -111,6 +133,11 @@ class WebhookConfiguration extends Model
return $this->hasMany(Webhook::class); return $this->hasMany(Webhook::class);
} }
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
/** @return string[] */ /** @return string[] */
public static function allPossibleEvents(): array public static function allPossibleEvents(): array
{ {
@@ -121,18 +148,211 @@ class WebhookConfiguration extends Model
->all(); ->all();
} }
/**
* @param array<string> $filterList
* @return array<string>
*/
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<string> */
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<string> */
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<string, string> */ /** @return array<string, string> */
public static function filamentCheckboxList(): array public static function filamentCheckboxList(WebhookScope $scope): array
{ {
$list = []; $list = [];
$events = static::allPossibleEvents();
foreach ($events as $event) { if ($scope === WebhookScope::Server) {
$list[$event] = static::transformClassName($event); $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; return $list;
} }
public static function transformServerEventName(string $event): string
{
return str($event)
->after('server:')
->replace('.', ' → ')
->title()
->toString();
}
public static function transformClassName(string $event): string public static function transformClassName(string $event): string
{ {
return str($event) return str($event)
@@ -214,10 +434,16 @@ class WebhookConfiguration extends Model
/** @param array<mixed, mixed> $eventData */ /** @param array<mixed, mixed> $eventData */
public function run(?string $eventName = null, ?array $eventData = null): void 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; $eventName ??= 'eloquent.created: '.Server::class;
$eventData ??= static::getWebhookSampleData(); $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', 'event' => 'updated: Server',
]; ];
} }
/**
* @return array<string, mixed>
*/
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',
],
];
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Providers; namespace App\Providers;
use App\Events\ActivityLogged;
use App\Listeners\DispatchWebhooks; use App\Listeners\DispatchWebhooks;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
@@ -11,7 +12,8 @@ class EventServiceProvider extends ServiceProvider
* The event to listener mappings for the application. * The event to listener mappings for the application.
*/ */
protected $listen = [ protected $listen = [
'App\\*' => [DispatchWebhooks::class], ActivityLogged::class => [DispatchWebhooks::class],
'App\\Events\\*' => [DispatchWebhooks::class],
'eloquent.created*' => [DispatchWebhooks::class], 'eloquent.created*' => [DispatchWebhooks::class],
'eloquent.deleted*' => [DispatchWebhooks::class], 'eloquent.deleted*' => [DispatchWebhooks::class],
'eloquent.updated*' => [DispatchWebhooks::class], 'eloquent.updated*' => [DispatchWebhooks::class],

View File

@@ -2,6 +2,7 @@
namespace App\Services\Activity; namespace App\Services\Activity;
use App\Events\ActivityLogged;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Server; use App\Models\Server;
use App\Models\User; use App\Models\User;
@@ -12,6 +13,7 @@ use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Request;
use Throwable; use Throwable;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
@@ -244,6 +246,8 @@ class ActivityLogService
return $this->activity; return $this->activity;
}); });
Event::dispatch(new ActivityLogged($response));
$this->activity = null; $this->activity = null;
$this->subjects = []; $this->subjects = [];

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Services;
use App\Enums\WebhookScope;
use App\Jobs\ProcessWebhook;
use App\Models\Server;
use App\Models\WebhookConfiguration;
class WebhookService
{
/**
* @param array<string, mixed> $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<string, string>
*/
public function getAllEvents(WebhookScope $scope = WebhookScope::Global): array
{
return WebhookConfiguration::filamentCheckboxList($scope);
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('webhook_configurations', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('webhook_configurations', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -6,16 +6,21 @@ return [
'model_label_plural' => 'Webhooks', 'model_label_plural' => 'Webhooks',
'endpoint' => 'Endpoint', 'endpoint' => 'Endpoint',
'description' => 'Description', 'description' => 'Description',
'name' => 'Name',
'server' => 'Server',
'information' => 'Information',
'payload' => 'Payload',
'events' => 'Events',
'no_webhooks' => 'No Webhooks', 'no_webhooks' => 'No Webhooks',
'help' => 'Help', '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}}.', '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' => [ 'table' => [
'description' => 'Description', 'description' => 'Description',
'endpoint' => 'Endpoint', 'endpoint' => 'Endpoint',
], ],
'headers' => 'Headers', 'headers' => 'Headers',
'events' => 'Events',
'regular' => 'Regular', 'regular' => 'Regular',
'reset_headers' => 'Reset Headers', 'reset_headers' => 'Reset Headers',
'discord' => 'Discord', 'discord' => 'Discord',

View File

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

View File

@@ -1,221 +0,0 @@
<x-filament-widgets::widget>
@assets
<style>
:root {
--discord-embed-background-color: #13162a;
--discord-tag-color: #5865f2;
--discord-timestamp-color: #949ba4;
--discord-text-color: #dbdee1;
--discord-link-color: #00a8fc;
--discord-avatar-margin-right: 8px;
--discord-thumbnail-margin-right: 8px;
--discord-footer-margin-top: 8px;
--discord-spacer-margin-left: 4px;
--discord-avatar-length: 40px;
--discord-avatar-height: 40px;
}
.container {
background-color: #11131f !important;
}
.link {
color: var(--discord-link-color);
}
.link:hover {
text-decoration: underline;
}
img:hover {
cursor: pointer !important;
}
.sender .avatar {
border: 1px solid rgba(0, 0, 0, 0.2);
}
.sender .name {
display: inline;
vertical-align: baseline;
margin: 0px 0.25rem 0px 0px;
color: var(--color-white);
font-size: 1rem;
font-weight: 500;
line-height: 1.375rem;
overflow-wrap: break-word;
cursor: pointer;
}
.sender .tag {
min-height: 1.275em;
max-height: 1.275em;
margin: 0.075em 0.25rem 0px 0px;
padding: 0.071875rem 0.275rem;
border-radius: 3px;
background: var(--discord-tag-color);
font-size: .8rem;
font-weight: 500;
line-height: 1.3;
vertical-align: baseline;
text-transform: uppercase;
}
.sender .timestamp {
display: inline-block;
height: 1.25rem;
cursor: default;
color: var(--discord-timestamp-color);
margin-left: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.375rem;
vertical-align: baseline;
}
.embed {
border-left: 5px solid;
background-color: var(--discord-embed-background-color) !important;
}
.avatar,
.footer-icon {
margin-right: var(--discord-avatar-margin-right);
}
.thumbnail {
width: 64px;
height: 64px;
object-fit: cover;
position: absolute;
top: 50%;
right: 0;
transform: translateY(-15%);
}
.description,
.field-value {
color: var(--discord-text-color);
}
.footer {
margin-top: var(--discord-footer-margin-top);
color: var(--discord-text-color);
}
.spacer {
margin-left: var(--discord-spacer-margin-left);
}
</style>
@endassets
<div class="container mx-auto p-4 bg-gray-800 p-4 rounded-lg shadow-lg w-full max-w-full flex items-start mb-4 sender">
<div class="relative" style="width: 44px; min-width: 44px; height: 44px; margin-right: 12px;">
@if($avatar = $sender['avatar'])
<img
src="{{ $avatar }}"
alt="Avatar"
class="w-full h-full rounded-full object-cover absolute top-0 left-0 z-10 avatar"
>
@endif
</div>
<div class="flex flex-col flex-grow">
<div class="flex items-center space-x-2">
<h1 class="font-bold text-white name">{{ data_get($sender, 'name') }}</h1>
@if(!data_get($sender, 'human'))
<span class="text-white text-xs rounded-md tag">app</span>
@endif
<span class="timestamp text-xs">{{ $getTime }}</span>
</div>
@if(filled($content))
<p class="text-gray-300 break-words">{!! nl2br($content) !!}</p>
@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
<div class="p-3 mt-3 rounded-lg w-full max-w-full embed relative" style="border-color: #{{ dechex(data_get($embed, 'color')) }}">
@if($name || $thumbnail)
<div class="flex items-start mb-0 relative" style="height: auto; overflow: visible;">
@if($author_icon_url || $name)
<div class="flex items-center">
@if($author_icon_url)
<img src="{{ $author_icon_url }}" alt="Author Avatar" class="w-8 h-8 rounded-full mr-2 object-cover avatar">
@endif
@if($author_url)
{!! $link($author_url, $name ? '<h2 class="font-bold text-lg whitespace-nowrap">' . e($name) . '</h2>' : '') !!}
@elseif($name)
<h2 class="font-bold text-lg whitespace-nowrap">{{ $name }}</h2>
@endif
</div>
@endif
@if($thumbnail)
<img src="{{ $thumbnail }}" alt="Embed Thumbnail" class="thumbnail rounded-lg">
@endif
</div>
@endif
@if($title = data_get($embed, 'title'))
{!! $link(
$url = data_get($embed, 'url'),
'<h3 class="font-bold text-lg break-words mb-0">' . e($title) . '</h3>'
) !!}
@endif
@if($description = data_get($embed, 'description'))
<p class="break-words description mt-0">{!! nl2br($description) !!}</p>
@endif
@if($fields = data_get($embed, 'fields'))
<div class="mt-2 w-full">
@foreach($fields as $field)
<div class="mb-2 w-full">
<strong class="break-words mt-2">{{ data_get($field, 'name') }}</strong>
<span class="break-words field-value">{{ data_get($field, 'value') }}</span>
</div>
@endforeach
</div>
@endif
@if($image = data_get($embed, 'image.url'))
<img src="{{ $image }}" alt="Embed Image" class="object-contain mt-3 w-full">
@endif
@if($footer_text || $footer_timestamp)
<div class="flex items-center text-sm mt-4 footer">
@if($footer_icon_url)
<img src="{{ $footer_icon_url }}" alt="Footer Icon" class="w-5 h-5 rounded-full mr-2 object-cover footer-icon">
@endif
<div class="flex space-x-1">
@if($footer_text)
<p class="break-words">{!! nl2br($footer_text) !!}</p>
@endif
@if($footer_timestamp)
<span class="timestamp">
@if($footer_text)
<span class="spacer"></span>
@endif
{{ $footer_timestamp }}
</span>
@endif
</div>
@endif
</div>
@endforeach
</div>
</div>
</x-filament-widgets::widget>

View File

@@ -68,7 +68,21 @@
:secondary="$isSecondary" :secondary="$isSecondary"
> >
<x-slot name="heading"> <x-slot name="heading">
@livewire(App\Filament\Admin\Widgets\DiscordPreview::class, ['record' => $getRecord(), 'pollingInterval' => $pollingInterval ?? null]) <span
x-data
x-init="$watch(() => JSON.stringify($wire.data), (json) => {
try {
const d = JSON.parse(json);
$wire.dispatch('discord-form-changed', {
content: d.content ?? '',
username: d.username ?? '',
avatar_url: d.avatar_url ?? '',
embeds: d.embeds ?? [],
});
} catch (_) {}
})"
></span>
@livewire('discord-preview', ['record' => $getRecord()])
</x-slot> </x-slot>
{{ $getChildSchema()->gap(! $isDivided)->extraAttributes(['class' => 'fi-section-content']) }} {{ $getChildSchema()->gap(! $isDivided)->extraAttributes(['class' => 'fi-section-content']) }}

View File

@@ -0,0 +1,113 @@
<div>
@assets
@vite('resources/css/discord-preview.css')
@endassets
<div class="dc-chat">
<div class="dc-msg">
{{-- Avatar --}}
@if ($avatar = $sender['avatar'])
<img src="{{ $avatar }}" class="dc-avatar" alt="">
@else
<div style="width:40px;height:40px;border-radius:50%;background:#5865f2;flex-shrink:0;margin-top:2px;"></div>
@endif
<div class="dc-msg-body">
{{-- Meta row --}}
<div class="dc-meta">
<span class="dc-username">{{ $sender['name'] ?? 'Pelican' }}</span>
@if (!($sender['human'] ?? false))
<span class="dc-bot-tag">app</span>
@endif
<span class="dc-timestamp">{{ $getTime() }}</span>
</div>
{{-- Message content --}}
@if (filled($content))
<div class="dc-content">{!! nl2br(e($content)) !!}</div>
@endif
{{-- Embeds --}}
@foreach ($embeds as $embed)
<div class="dc-embed" style="{{ $embed['view']['color_style'] }}">
<div class="dc-embed-body">
<div class="dc-embed-grid {{ $embed['view']['thumbnail'] ? 'has-thumbnail' : '' }}">
<div class="dc-embed-content">
{{-- Author --}}
@if ($embed['view']['author_name'])
<div class="dc-embed-author">
@if ($embed['view']['author_icon'])
<img src="{{ $embed['view']['author_icon'] }}" class="dc-embed-author-icon" alt="">
@endif
@if ($embed['view']['author_url'])
<a href="{{ $embed['view']['author_url'] }}" target="_blank" rel="noopener noreferrer" class="dc-embed-author-name dc-link">{{ $embed['view']['author_name'] }}</a>
@else
<span class="dc-embed-author-name">{{ $embed['view']['author_name'] }}</span>
@endif
</div>
@endif
{{-- Title --}}
@if ($embed['view']['title'])
@if ($embed['view']['title_url'])
<a href="{{ $embed['view']['title_url'] }}" target="_blank" rel="noopener noreferrer" class="dc-embed-title">{{ $embed['view']['title'] }}</a>
@else
<div class="dc-embed-title">{{ $embed['view']['title'] }}</div>
@endif
@endif
{{-- Description --}}
@if ($embed['view']['description'])
<div class="dc-embed-desc">{!! nl2br(e($embed['view']['description'])) !!}</div>
@endif
{{-- Fields --}}
@if (!empty($embed['view']['fields']))
<div class="dc-embed-fields">
@foreach ($embed['view']['fields'] as $field)
<div class="dc-embed-field {{ !empty($field['inline']) ? 'inline' : '' }}">
<div class="dc-embed-field-name">{{ $field['name'] ?? '' }}</div>
<div class="dc-embed-field-value">{!! nl2br(e($field['value'] ?? '')) !!}</div>
</div>
@endforeach
</div>
@endif
</div>
{{-- Thumbnail --}}
@if ($embed['view']['thumbnail'])
<img src="{{ $embed['view']['thumbnail'] }}" class="dc-embed-thumbnail" alt="">
@endif
</div>{{-- /.dc-embed-grid --}}
{{-- Large image --}}
@if ($embed['view']['image'])
<img src="{{ $embed['view']['image'] }}" class="dc-embed-image" alt="">
@endif
{{-- Footer --}}
@if ($embed['view']['footer_text'] || $embed['view']['timestamp'])
<div class="dc-embed-footer">
@if ($embed['view']['footer_icon'])
<img src="{{ $embed['view']['footer_icon'] }}" class="dc-embed-footer-icon" alt="">
@endif
<span class="dc-embed-footer-text">
@if ($embed['view']['footer_text']){{ $embed['view']['footer_text'] }}@endif
@if ($embed['view']['footer_text'] && $embed['view']['timestamp'])<span class="dc-embed-footer-sep"></span>@endif
@if ($embed['view']['timestamp']){{ $embed['view']['timestamp'] }}@endif
</span>
</div>
@endif
</div>{{-- /.dc-embed-body --}}
</div>{{-- /.dc-embed --}}
@endforeach
</div>{{-- /.dc-msg-body --}}
</div>{{-- /.dc-msg --}}
</div>{{-- /.dc-chat --}}
</div>