DiscordWebhooks (#1355)

Co-authored-by: notCharles <charles@pelican.dev>
Co-authored-by: Lance Pioch <lancepioch@gmail.com>
Co-authored-by: Boy132 <mail@boy132.de>
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
This commit is contained in:
JoanFo 2025-07-05 18:42:34 +02:00 committed by GitHub
parent 21ac75efae
commit c5aa8a3980
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1057 additions and 32 deletions

34
app/Enums/WebhookType.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasLabel;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
enum WebhookType: string implements HasColor, HasIcon, HasLabel
{
case Regular = 'regular';
case Discord = 'discord';
public function getLabel(): string
{
return trans('admin/webhook.' . $this->value);
}
public function getColor(): ?string
{
return match ($this) {
self::Regular => null,
self::Discord => 'blurple',
};
}
public function getIcon(): string
{
return match ($this) {
self::Regular => 'tabler-world-www',
self::Discord => 'tabler-brand-discord',
};
}
}

View File

@ -3,23 +3,40 @@
namespace App\Filament\Admin\Resources; namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\WebhookResource\Pages; use App\Filament\Admin\Resources\WebhookResource\Pages;
use App\Filament\Admin\Resources\WebhookResource\Pages\EditWebhookConfiguration;
use App\Livewire\AlertBanner;
use App\Models\WebhookConfiguration; use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations; use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm; use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable; use App\Traits\Filament\CanModifyTable;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Resources\Pages\PageRegistration; use Filament\Resources\Pages\PageRegistration;
use Filament\Forms\Get;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables\Actions\CreateAction; use Filament\Forms\Set;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction; use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ReplicateAction;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Livewire\Features\SupportEvents\HandlesEvents;
use App\Enums\WebhookType;
use Filament\Forms\Components\Component;
use Livewire\Component as Livewire;
class WebhookResource extends Resource class WebhookResource extends Resource
{ {
@ -27,6 +44,7 @@ class WebhookResource extends Resource
use CanCustomizeRelations; use CanCustomizeRelations;
use CanModifyForm; use CanModifyForm;
use CanModifyTable; use CanModifyTable;
use HandlesEvents;
protected static ?string $model = WebhookConfiguration::class; protected static ?string $model = WebhookConfiguration::class;
@ -63,6 +81,12 @@ class WebhookResource extends Resource
{ {
return $table return $table
->columns([ ->columns([
IconColumn::make('type'),
TextColumn::make('endpoint')
->label(trans('admin/webhook.table.endpoint'))
->formatStateUsing(fn (string $state) => str($state)->after('://'))
->limit(60)
->wrap(),
TextColumn::make('description') TextColumn::make('description')
->label(trans('admin/webhook.table.description')), ->label(trans('admin/webhook.table.description')),
TextColumn::make('endpoint') TextColumn::make('endpoint')
@ -70,9 +94,15 @@ class WebhookResource extends Resource
]) ])
->actions([ ->actions([
ViewAction::make() ViewAction::make()
->hidden(fn ($record) => static::canEdit($record)), ->hidden(fn (WebhookConfiguration $record) => static::canEdit($record)),
EditAction::make(), EditAction::make(),
DeleteAction::make(), ReplicateAction::make()
->iconButton()
->tooltip(trans('filament-actions::replicate.single.label'))
->modal(false)
->excludeAttributes(['created_at', 'updated_at'])
->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->description .= ' Copy ' . now()->format('Y-m-d H:i:s'))
->successRedirectUrl(fn (WebhookConfiguration $replica) => EditWebhookConfiguration::getUrl(['record' => $replica])),
]) ])
->groupedBulkActions([ ->groupedBulkActions([
DeleteBulkAction::make(), DeleteBulkAction::make(),
@ -82,6 +112,12 @@ class WebhookResource extends Resource
->emptyStateHeading(trans('admin/webhook.no_webhooks')) ->emptyStateHeading(trans('admin/webhook.no_webhooks'))
->emptyStateActions([ ->emptyStateActions([
CreateAction::make(), CreateAction::make(),
])
->persistFiltersInSession()
->filters([
SelectFilter::make('type')
->options(WebhookType::class)
->attribute('type'),
]); ]);
} }
@ -89,25 +125,214 @@ class WebhookResource extends Resource
{ {
return $form return $form
->schema([ ->schema([
TextInput::make('endpoint') ToggleButtons::make('type')
->label(trans('admin/webhook.endpoint')) ->live()
->activeUrl() ->inline()
->required(), ->options(WebhookType::class)
->default(WebhookType::Regular->value)
->afterStateHydrated(function (string $state) {
if ($state === WebhookType::Discord->value) {
self::sendHelpBanner();
}
})
->afterStateUpdated(function (string $state) {
if ($state === WebhookType::Discord->value) {
self::sendHelpBanner();
}
}),
TextInput::make('description') TextInput::make('description')
->label(trans('admin/webhook.description')) ->label(trans('admin/webhook.description'))
->required(), ->required(),
CheckboxList::make('events') TextInput::make('endpoint')
->lazy() ->label(trans('admin/webhook.endpoint'))
->options(fn () => WebhookConfiguration::filamentCheckboxList()) ->activeUrl()
->searchable() ->required()
->bulkToggleable()
->columns(3)
->columnSpanFull() ->columnSpanFull()
->gridDirection('row') ->afterStateUpdated(fn (string $state, Set $set) => $set('type', str($state)->contains('discord.com') ? WebhookType::Discord->value : WebhookType::Regular->value)),
->required(), Section::make(trans('admin/webhook.regular'))
->hidden(fn (Get $get) => $get('type') === WebhookType::Discord->value)
->dehydratedWhenHidden()
->schema(fn () => self::getRegularFields())
->formBefore(),
Section::make(trans('admin/webhook.discord'))
->hidden(fn (Get $get) => $get('type') === WebhookType::Regular->value)
->dehydratedWhenHidden()
->afterStateUpdated(fn (Livewire $livewire) => $livewire->dispatch('refresh-widget'))
->schema(fn () => self::getDiscordFields())
->view('filament.components.webhooksection')
->aside()
->formBefore(),
Section::make(trans('admin/webhook.events'))
->collapsible()
->collapsed(fn (Get $get) => count($get('events') ?? []))
->schema([
CheckboxList::make('events')
->live()
->options(fn () => WebhookConfiguration::filamentCheckboxList())
->searchable()
->bulkToggleable()
->columns(3)
->columnSpanFull()
->required(),
]),
]); ]);
} }
/** @return Component[] */
private static function getRegularFields(): array
{
return [
KeyValue::make('headers')
->label(trans('admin/webhook.headers')),
];
}
/** @return Component[] */
private static function getDiscordFields(): array
{
return [
Section::make(trans('admin/webhook.discord_message.profile'))
->collapsible()
->schema([
TextInput::make('username')
->live(debounce: 500)
->label(trans('admin/webhook.discord_message.username')),
TextInput::make('avatar_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_message.avatar_url')),
]),
Section::make(trans('admin/webhook.discord_message.message'))
->collapsible()
->schema([
TextInput::make('content')
->label(trans('admin/webhook.discord_message.message'))
->live(debounce: 500)
->required(fn (Get $get) => empty($get('embeds'))),
TextInput::make('thread_name')
->label(trans('admin/webhook.discord_message.forum_thread')),
CheckboxList::make('flags')
->label('Flags')
->options([
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds'),
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications'),
])
->descriptions([
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds_text'),
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications_text'),
]),
CheckboxList::make('allowed_mentions')
->label(trans('admin/webhook.discord_embed.allowed_mentions'))
->options([
'roles' => trans('admin/webhook.discord_embed.roles'),
'users' => trans('admin/webhook.discord_embed.users'),
'everyone' => trans('admin/webhook.discord_embed.everyone'),
]),
]),
Repeater::make('embeds')
->live(debounce: 500)
->itemLabel(fn (array $state) => $state['title'])
->addActionLabel(trans('admin/webhook.discord_embed.add_embed'))
->required(fn (Get $get) => empty($get('content')))
->reorderable()
->collapsible()
->maxItems(10)
->schema([
Section::make(trans('admin/webhook.discord_embed.author'))
->collapsible()
->collapsed()
->schema([
TextInput::make('author.name')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author'))
->required(fn (Get $get) => filled($get('author.url')) || filled($get('author.icon_url'))),
TextInput::make('author.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author_url')),
TextInput::make('author.icon_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author_icon_url')),
]),
Section::make(trans('admin/webhook.discord_embed.body'))
->collapsible()
->collapsed()
->schema([
TextInput::make('title')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.title'))
->required(fn (Get $get) => $get('description') === null),
Textarea::make('description')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.body'))
->required(fn (Get $get) => $get('title') === null),
ColorPicker::make('color')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.color'))
->hex(),
TextInput::make('url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.url')),
]),
Section::make(trans('admin/webhook.discord_embed.images'))
->collapsible()
->collapsed()
->schema([
TextInput::make('image.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.image_url')),
TextInput::make('thumbnail.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.image_thumbnail')),
]),
Section::make(trans('admin/webhook.discord_embed.footer'))
->collapsible()
->collapsed()
->schema([
TextInput::make('footer.text')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.footer')),
Checkbox::make('has_timestamp')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.has_timestamp')),
TextInput::make('footer.icon_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.footer_icon_url')),
]),
Section::make(trans('admin/webhook.discord_embed.fields'))
->collapsible()->collapsed()
->schema([
Repeater::make('fields')
->reorderable()
->addActionLabel(trans('admin/webhook.discord_embed.add_field'))
->collapsible()
->schema([
TextInput::make('name')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_name'))
->required(),
Textarea::make('value')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_value'))
->rows(4)
->required(),
Checkbox::make('inline')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.inline_field')),
]),
]),
]),
];
}
public static function sendHelpBanner(): void
{
AlertBanner::make('discord_webhook_help')
->title(trans('admin/webhook.help'))
->body(trans('admin/webhook.help_text'))
->icon('tabler-question-mark')
->info()
->send();
}
/** @return array<string, PageRegistration> */ /** @return array<string, PageRegistration> */
public static function getDefaultPages(): array public static function getDefaultPages(): array
{ {

View File

@ -8,6 +8,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 App\Enums\WebhookType;
class CreateWebhookConfiguration extends CreateRecord class CreateWebhookConfiguration extends CreateRecord
{ {
@ -22,6 +23,7 @@ class CreateWebhookConfiguration extends CreateRecord
protected function getDefaultHeaderActions(): array protected function getDefaultHeaderActions(): array
{ {
return [ return [
$this->getCancelFormAction()->formId('form'),
$this->getCreateFormAction()->formId('form'), $this->getCreateFormAction()->formId('form'),
]; ];
} }
@ -30,4 +32,35 @@ class CreateWebhookConfiguration extends CreateRecord
{ {
return []; return [];
} }
protected function mutateFormDataBeforeCreate(array $data): array
{
if (($data['type'] ?? null) === WebhookType::Discord->value) {
$embeds = data_get($data, 'embeds', []);
foreach ($embeds as &$embed) {
$embed['color'] = hexdec(str_replace('#', '', data_get($embed, 'color')));
$embed = collect($embed)->filter(fn ($key) => is_array($key) ? array_filter($key, fn ($arr_key) => !empty($arr_key)) : !empty($key))->all();
}
$flags = collect($data['flags'] ?? [])->reduce(fn ($carry, $bit) => $carry | $bit, 0);
$tmp = collect([
'username' => data_get($data, 'username'),
'avatar_url' => data_get($data, 'avatar_url'),
'content' => data_get($data, 'content'),
'image' => data_get($data, 'image'),
'thumbnail' => data_get($data, 'thumbnail'),
'embeds' => $embeds,
'thread_name' => data_get($data, 'thread_name'),
'flags' => $flags,
'allowed_mentions' => data_get($data, 'allowed_mentions', []),
])->filter(fn ($key) => !empty($key))->all();
unset($data['username'], $data['avatar_url'], $data['content'], $data['image'], $data['thumbnail'], $data['embeds'], $data['thread_name'], $data['flags'], $data['allowed_mentions']);
$data['payload'] = $tmp;
}
return $data;
}
} }

View File

@ -7,8 +7,10 @@ use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use App\Models\WebhookConfiguration;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use App\Enums\WebhookType;
class EditWebhookConfiguration extends EditRecord class EditWebhookConfiguration extends EditRecord
{ {
@ -22,6 +24,12 @@ class EditWebhookConfiguration extends EditRecord
{ {
return [ return [
DeleteAction::make(), DeleteAction::make(),
Action::make('test_now')
->label(trans('admin/webhook.test_now'))
->color('primary')
->disabled(fn (WebhookConfiguration $webhookConfiguration) => count($webhookConfiguration->events) === 0)
->action(fn (WebhookConfiguration $webhookConfiguration) => $webhookConfiguration->run())
->tooltip(trans('admin/webhook.test_now_help')),
$this->getSaveFormAction()->formId('form'), $this->getSaveFormAction()->formId('form'),
]; ];
} }
@ -30,4 +38,89 @@ class EditWebhookConfiguration extends EditRecord
{ {
return []; return [];
} }
protected function mutateFormDataBeforeSave(array $data): array
{
if (($data['type'] ?? null) === WebhookType::Discord->value) {
$embeds = data_get($data, 'embeds', []);
foreach ($embeds as &$embed) {
$embed['color'] = hexdec(str_replace('#', '', data_get($embed, 'color')));
$embed = collect($embed)->filter(fn ($key) => is_array($key) ? array_filter($key, fn ($arr_key) => !empty($arr_key)) : !empty($key))->all();
}
$flags = collect($data['flags'] ?? [])->reduce(fn ($carry, $bit) => $carry | $bit, 0);
$tmp = collect([
'username' => data_get($data, 'username'),
'avatar_url' => data_get($data, 'avatar_url'),
'content' => data_get($data, 'content'),
'image' => data_get($data, 'image'),
'thumbnail' => data_get($data, 'thumbnail'),
'embeds' => $embeds,
'thread_name' => data_get($data, 'thread_name'),
'flags' => $flags,
'allowed_mentions' => data_get($data, 'allowed_mentions', []),
])->filter(fn ($key) => !empty($key))->all();
unset($data['username'], $data['avatar_url'], $data['content'], $data['image'], $data['thumbnail'], $data['embeds'], $data['thread_name'], $data['flags'], $data['allowed_mentions']);
$data['payload'] = $tmp;
}
if (($data['type'] ?? null) === WebhookType::Regular->value && isset($data['headers']) && is_array($data['headers'])) {
$newHeaders = [];
foreach ($data['headers'] as $key => $value) {
$newKey = str_replace(' ', '-', $key);
$newHeaders[$newKey] = $value;
}
$data['headers'] = $newHeaders;
}
return $data;
}
protected function mutateFormDataBeforeFill(array $data): array
{
if (($data['type'] ?? null) === WebhookType::Discord->value) {
$embeds = data_get($data, 'payload.embeds', []);
foreach ($embeds as &$embed) {
$embed['color'] = '#' . dechex(data_get($embed, 'color'));
$embed = collect($embed)->filter(fn ($key) => is_array($key) ? array_filter($key, fn ($arr_key) => !empty($arr_key)) : !empty($key))->all();
}
$flags = data_get($data, 'payload.flags');
$flags = collect(range(0, PHP_INT_SIZE * 8 - 1))
->filter(fn ($i) => ($flags & (1 << $i)) !== 0)
->map(fn ($i) => 1 << $i)
->values();
$tmp = collect([
'username' => data_get($data, 'payload.username'),
'avatar_url' => data_get($data, 'payload.avatar_url'),
'content' => data_get($data, 'payload.content'),
'image' => data_get($data, 'payload.image'),
'thumbnail' => data_get($data, 'payload.thumbnail'),
'embeds' => $embeds,
'thread_name' => data_get($data, 'payload.thread_name'),
'flags' => $flags,
'allowed_mentions' => data_get($data, 'payload.allowed_mentions'),
])->filter(fn ($key) => !empty($key))->all();
unset($data['payload'], $data['created_at'], $data['updated_at'], $data['deleted_at']);
$data = array_merge($data, $tmp);
}
if (($data['type'] ?? null) === WebhookType::Regular->value) {
$data['headers'] = $data['headers'] ?? [];
}
return $data;
}
protected function afterSave(): void
{
$this->dispatch('refresh-widget');
}
} }

View File

@ -0,0 +1,163 @@
<?php
namespace App\Filament\Admin\Widgets;
use App\Models\WebhookConfiguration;
use Filament\Widgets\Widget;
use Illuminate\Support\Carbon;
class DiscordPreview extends Widget
{
protected static 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

@ -3,12 +3,15 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\WebhookConfiguration; use App\Models\WebhookConfiguration;
use Exception;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use App\Enums\WebhookType;
class ProcessWebhook implements ShouldQueue class ProcessWebhook implements ShouldQueue
{ {
@ -25,17 +28,45 @@ class ProcessWebhook implements ShouldQueue
public function handle(): void public function handle(): void
{ {
$data = $this->data[0];
if ($this->webhookConfiguration->type === WebhookType::Discord) {
$data = array_merge(
is_array($data) ? $data : json_decode($data, true),
['event' => $this->webhookConfiguration->transformClassName($this->eventName)]
);
$payload = json_encode($this->webhookConfiguration->payload);
$tmp = $this->webhookConfiguration->replaceVars($data, $payload);
$data = json_decode($tmp, true);
$embeds = data_get($data, 'embeds');
if ($embeds) {
foreach ($embeds as &$embed) {
if (data_get($embed, 'has_timestamp')) {
$embed['timestamp'] = Carbon::now();
unset($embed['has_timestamp']);
}
}
$data['embeds'] = $embeds;
}
}
try { try {
Http::withHeader('X-Webhook-Event', $this->eventName) $headers = [];
->post($this->webhookConfiguration->endpoint, $this->data) if ($this->webhookConfiguration->type === WebhookType::Regular && $customHeaders = $this->webhookConfiguration->headers) {
->throw(); $headers = array_merge(['X-Webhook-Event', $this->eventName], $customHeaders);
}
Http::withHeaders($headers)->post($this->webhookConfiguration->endpoint, $data)->throw();
$successful = now(); $successful = now();
} catch (\Exception) { } catch (Exception $exception) {
report($exception->getMessage());
$successful = null; $successful = null;
} }
$this->webhookConfiguration->webhooks()->create([ $this->webhookConfiguration->webhooks()->create([
'payload' => $this->data, 'payload' => $data,
'successful_at' => $successful, 'successful_at' => $successful,
'event' => $this->eventName, 'event' => $this->eventName,
'endpoint' => $this->webhookConfiguration->endpoint, 'endpoint' => $this->webhookConfiguration->endpoint,

View File

@ -2,7 +2,6 @@
namespace App\Listeners; namespace App\Listeners;
use App\Jobs\ProcessWebhook;
use App\Models\WebhookConfiguration; use App\Models\WebhookConfiguration;
class DispatchWebhooks class DispatchWebhooks
@ -23,7 +22,7 @@ class DispatchWebhooks
/** @var WebhookConfiguration $webhookConfig */ /** @var WebhookConfiguration $webhookConfig */
foreach ($matchingHooks as $webhookConfig) { foreach ($matchingHooks as $webhookConfig) {
if (in_array($eventName, $webhookConfig->events)) { if (in_array($eventName, $webhookConfig->events)) {
ProcessWebhook::dispatch($webhookConfig, $eventName, $data); $webhookConfig->run($eventName, $data);
} }
} }
} }

View File

@ -2,24 +2,31 @@
namespace App\Models; namespace App\Models;
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\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Livewire\Features\SupportEvents\HandlesEvents;
use App\Enums\WebhookType;
/** /**
* @property string|array<string, mixed>|null $payload
* @property string $endpoint * @property string $endpoint
* @property string $description * @property string $description
* @property string[] $events * @property string[] $events
* @property WebhookType|string|null $type
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at * @property \Illuminate\Support\Carbon|null $deleted_at
* @property array<string, string>|null $headers
*/ */
class WebhookConfiguration extends Model class WebhookConfiguration extends Model
{ {
use HasFactory, SoftDeletes; use HandlesEvents, HasFactory, SoftDeletes;
/** @var string[] */ /** @var string[] */
protected static array $eventBlacklist = [ protected static array $eventBlacklist = [
@ -27,15 +34,29 @@ class WebhookConfiguration extends Model
]; ];
protected $fillable = [ protected $fillable = [
'type',
'payload',
'endpoint', 'endpoint',
'description', 'description',
'events', 'events',
'headers',
];
/**
* Default values for specific fields in the database.
*/
protected $attributes = [
'type' => WebhookType::Regular,
'payload' => null,
]; ];
protected function casts(): array protected function casts(): array
{ {
return [ return [
'events' => 'json', 'events' => 'array',
'payload' => 'array',
'type' => WebhookType::class,
'headers' => 'array',
]; ];
} }
@ -43,7 +64,7 @@ class WebhookConfiguration extends Model
{ {
self::saved(static function (self $webhookConfiguration): void { self::saved(static function (self $webhookConfiguration): void {
$changedEvents = collect([ $changedEvents = collect([
...((array) $webhookConfiguration->events), ...($webhookConfiguration->events),
...$webhookConfiguration->getOriginal('events', '[]'), ...$webhookConfiguration->getOriginal('events', '[]'),
])->unique(); ])->unique();
@ -51,7 +72,7 @@ class WebhookConfiguration extends Model
}); });
self::deleted(static function (self $webhookConfiguration): void { self::deleted(static function (self $webhookConfiguration): void {
self::updateCache(collect((array) $webhookConfiguration->events)); self::updateCache(collect($webhookConfiguration->events));
}); });
} }
@ -140,9 +161,7 @@ class WebhookConfiguration extends Model
foreach (File::allFiles($directory) as $file) { foreach (File::allFiles($directory) as $file) {
$namespace = str($file->getPath()) $namespace = str($file->getPath())
->after(base_path()) ->after(base_path())
->replace(DIRECTORY_SEPARATOR, '\\') ->replace([DIRECTORY_SEPARATOR, '\\app\\'], ['\\', 'App\\']);
->replace('\\app\\', 'App\\')
->toString();
$events[] = $namespace . '\\' . str($file->getFilename()) $events[] = $namespace . '\\' . str($file->getFilename())
->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']); ->replace([DIRECTORY_SEPARATOR, '.php'], ['\\', '']);
@ -150,4 +169,66 @@ class WebhookConfiguration extends Model
return $events; return $events;
} }
/**
* @param array<mixed, mixed> $replacement
* */
public function replaceVars(array $replacement, string $subject): string
{
return preg_replace_callback(
'/{{(.*?)}}/',
function ($matches) use ($replacement) {
$trimmed = trim($matches[1]);
return Arr::get($replacement, $trimmed, $trimmed);
},
$subject
);
}
/** @param array<mixed, mixed> $eventData */
public function run(?string $eventName = null, ?array $eventData = null): void
{
$eventName ??= 'eloquent.created: '.Server::class;
$eventData ??= $this->getWebhookSampleData();
ProcessWebhook::dispatch($this, $eventName, [$eventData]);
}
/**
* @return array<string, mixed>
*/
public function getWebhookSampleData(): array
{
return [
'status' => 'installing',
'oom_killer' => false,
'installed_at' => null,
'external_id' => 10,
'uuid' => '651fgbc1-dee6-4250-814e-10slda13f1e',
'uuid_short' => '651fgbc1',
'node_id' => 1,
'name' => 'Eagle',
'description' => 'This is an example server description.',
'skip_scripts' => false,
'owner_id' => 1,
'memory' => 2048,
'swap' => 128,
'disk' => 10240,
'io' => 500,
'cpu' => 100,
'threads' => '1,3,5',
'allocation_id' => 4,
'egg_id' => 2,
'startup' => 'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}',
'image' => 'ghcr.io/parkervcp/yolks:java_21',
'database_limit' => 1,
'allocation_limit' => 5,
'backup_limit' => 3,
'docker_labels' => [],
'created_at' => '2025-03-17T15:20:32.000000Z',
'updated_at' => '2025-05-12T17:53:12.000000Z',
'id' => 2,
];
}
} }

View File

@ -98,6 +98,7 @@ class AppServiceProvider extends ServiceProvider
'primary' => Color::Blue, 'primary' => Color::Blue,
'success' => Color::Green, 'success' => Color::Green,
'warning' => Color::Amber, 'warning' => Color::Amber,
'blurple' => Color::hex('#5865F2'),
]); ]);
FilamentView::registerRenderHook( FilamentView::registerRenderHook(

View File

@ -104,4 +104,4 @@
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true
} }

View File

@ -0,0 +1,41 @@
<?php
use App\Enums\WebhookType;
use App\Models\WebhookConfiguration;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
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('type')->nullable()->after('id');
$table->json('payload')->nullable()->after('type');
});
foreach (WebhookConfiguration::all() as $webhookConfig) {
$type = str($webhookConfig->endpoint)->contains('discord.com') ? WebhookType::Discord->value : WebhookType::Regular->value;
DB::table('webhook_configurations')
->where('id', $webhookConfig->id)
->update(['type' => $type]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('webhook_configurations', function (Blueprint $table) {
$table->dropColumn('type');
$table->dropColumn('payload');
});
}
};

View File

@ -0,0 +1,28 @@
<?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->json('headers')->nullable()->after('payload');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('webhook_configurations', function (Blueprint $table) {
$table->dropColumn('headers');
});
}
};

View File

@ -8,8 +8,57 @@ return [
'description' => 'Description', 'description' => 'Description',
'events' => 'Events', 'events' => 'Events',
'no_webhooks' => 'No Webhooks', 'no_webhooks' => 'No Webhooks',
'help' => 'Help',
'help_text' => 'You have to wrap variable name in between {{ }} for example if you want to get the name from the api you can use {{name}}.',
'test_now' => 'Test Now',
'test_now_help' => 'This will fire a `created: Server` event',
'table' => [ 'table' => [
'description' => 'Description', 'description' => 'Description',
'endpoint' => 'Endpoint', 'endpoint' => 'Endpoint',
], ],
'headers' => 'Headers',
'events' => 'Events',
'regular' => 'Regular',
'discord' => 'Discord',
'discord_message' => [
'profile' => 'Profile',
'message' => 'Message',
'username' => 'Username',
'avatar_url' => 'Avatar URL',
'forum_thread' => 'Forum Thread Name',
'supress_embeds' => 'Suppress Embeds',
'supress_embeds_text' => 'Do not include any embeds when serializing this message',
'supress_notifications' => 'Suppress Notifications',
'supress_notifications_text' => 'This message will not trigger push and desktop notifications',
],
'discord_embed' => [
'add_embed' => 'Add Embed',
'flags' => 'Flags',
'thumbnail' => 'Thumbnail URL',
'embeds' => 'Embeds',
'thread_name' => 'Forum Thread Name',
'flags' => 'Flags',
'allowed_mentions' => 'Allowed Mentions',
'roles' => 'Roles',
'users' => 'Users',
'everyone' => '@everyone & @here',
'author' => 'Author',
'author_url' => 'Author URL',
'author_icon_url' => 'Author Icon URL',
'body' => 'Body',
'title' => 'Title',
'color' => 'Embed Color',
'url' => 'URL',
'images' => 'Images',
'image_url' => 'Image URL',
'image_thumbnail' => 'Thumbnail URL',
'footer' => 'Footer',
'has_timestamp' => 'Has Timestamp',
'footer_icon_url' => 'Footer Icon URL',
'add_field' => 'Add Field',
'fields' => 'Fields',
'field_name' => 'Field Name',
'field_value' => 'Field Value',
'inline_field' => 'Inline Field',
],
]; ];

View File

@ -0,0 +1,221 @@
<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

@ -0,0 +1,26 @@
<x-filament::section
:aside="$isAside()"
:collapsed="$isCollapsed()"
:collapsible="$isCollapsible() && (! $isAside)"
:compact="$isCompact()"
:content-before="$isFormBefore()"
:description="$getDescription()"
:footer-actions="$getFooterActions()"
:footer-actions-alignment="$getFooterActionsAlignment()"
:header-actions="$getHeaderActions()"
:heading="$getHeading()"
:icon="$getIcon()"
:icon-color="$getIconColor()"
:icon-size="$getIconSize()"
:persist-collapsed="$shouldPersistCollapsed()"
:attributes="
\Filament\Support\prepare_inherited_attributes($attributes)
->merge(['id' => $getId()], escape: false)
"
>
<x-slot name="heading">
@livewire(App\Filament\Admin\Widgets\DiscordPreview::class, ['record' => $getRecord(), 'pollingInterval' => $pollingInterval ?? null])
</x-slot>
{{ $getChildComponentContainer() }}
</x-filament::section>