mirror of
https://github.com/pelican-dev/panel.git
synced 2026-07-15 21:48:08 +03:00
Webhook fix + server area webhooks (#2377)
This commit is contained in:
17
app/Enums/WebhookScope.php
Normal file
17
app/Enums/WebhookScope.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Admin\Resources\Webhooks\Pages;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Enums\WebhookScope;
|
||||
use App\Enums\WebhookType;
|
||||
use App\Filament\Admin\Resources\Webhooks\WebhookResource;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
@@ -10,6 +11,7 @@ use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CreateWebhookConfiguration extends CreateRecord
|
||||
{
|
||||
@@ -44,6 +46,15 @@ class CreateWebhookConfiguration extends CreateRecord
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
// Ensure name is set (required field)
|
||||
if (empty($data['name'] ?? null)) {
|
||||
throw ValidationException::withMessages(['name' => 'Webhook name is required']);
|
||||
}
|
||||
|
||||
// Set scope to GLOBAL by default for admin webhooks
|
||||
$data['scope'] = WebhookScope::Global;
|
||||
unset($data['server_id']);
|
||||
|
||||
if (($data['type'] ?? null) === WebhookType::Discord->value) {
|
||||
$embeds = data_get($data, 'embeds', []);
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
namespace App\Filament\Admin\Resources\Webhooks\Pages;
|
||||
|
||||
use App\Enums\WebhookScope;
|
||||
use App\Filament\Admin\Resources\Webhooks\WebhookResource;
|
||||
use App\Models\WebhookConfiguration;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ListWebhookConfigurations extends ListRecords
|
||||
{
|
||||
@@ -13,4 +19,24 @@ class ListWebhookConfigurations extends ListRecords
|
||||
use CanCustomizeHeaderWidgets;
|
||||
|
||||
protected static string $resource = WebhookResource::class;
|
||||
|
||||
/** @return array<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace App\Filament\Admin\Resources\Webhooks;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Enums\WebhookScope;
|
||||
use App\Enums\WebhookType;
|
||||
use App\Filament\Admin\Resources\Webhooks\Pages\CreateWebhookConfiguration;
|
||||
use App\Filament\Admin\Resources\Webhooks\Pages\EditWebhookConfiguration;
|
||||
use App\Filament\Admin\Resources\Webhooks\Pages\ListWebhookConfigurations;
|
||||
use App\Filament\Admin\Resources\Webhooks\Pages\ViewWebhookConfiguration;
|
||||
use App\Livewire\AlertBanner;
|
||||
use App\Models\Server;
|
||||
use App\Models\WebhookConfiguration;
|
||||
use App\Traits\Filament\CanCustomizePages;
|
||||
use App\Traits\Filament\CanCustomizeRelations;
|
||||
@@ -16,7 +18,6 @@ use App\Traits\Filament\CanModifyForm;
|
||||
use App\Traits\Filament\CanModifyTable;
|
||||
use BackedEnum;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
@@ -26,14 +27,19 @@ use Filament\Actions\ViewAction;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\ToggleButtons;
|
||||
use Filament\Resources\Pages\PageRegistration;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
@@ -42,7 +48,6 @@ use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Livewire\Component as Livewire;
|
||||
use Livewire\Features\SupportEvents\HandlesEvents;
|
||||
|
||||
class WebhookResource extends Resource
|
||||
@@ -57,7 +62,7 @@ class WebhookResource extends Resource
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = TablerIcon::Webhook;
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'description';
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
@@ -87,17 +92,25 @@ class WebhookResource extends Resource
|
||||
public static function defaultTable(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->groups([
|
||||
'server.name',
|
||||
])
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label(trans('admin/webhook.name')),
|
||||
TextColumn::make('description')
|
||||
->label(trans('admin/webhook.table.description')),
|
||||
IconColumn::make('type'),
|
||||
TextColumn::make('server.name')
|
||||
->label('Server')
|
||||
->placeholder('—')
|
||||
->icon('tabler-server')
|
||||
->iconColor('info'),
|
||||
TextColumn::make('endpoint')
|
||||
->label(trans('admin/webhook.table.endpoint'))
|
||||
->label(trans('admin/webhook.endpoint'))
|
||||
->formatStateUsing(fn (string $state) => str($state)->after('://'))
|
||||
->limit(60)
|
||||
->wrap(),
|
||||
TextColumn::make('description')
|
||||
->label(trans('admin/webhook.table.description')),
|
||||
TextColumn::make('endpoint')
|
||||
->label(trans('admin/webhook.table.endpoint')),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make()
|
||||
@@ -108,7 +121,7 @@ class WebhookResource extends Resource
|
||||
->tooltip(trans('filament-actions::replicate.single.label'))
|
||||
->modal(false)
|
||||
->excludeAttributes(['created_at', 'updated_at'])
|
||||
->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->description .= ' Copy ' . now()->format('Y-m-d H:i:s'))
|
||||
->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->name .= ' Copy ' . now()->format('Y-m-d H:i:s'))
|
||||
->successRedirectUrl(fn (WebhookConfiguration $replica) => EditWebhookConfiguration::getUrl(['record' => $replica])),
|
||||
])
|
||||
->toolbarActions([
|
||||
@@ -125,6 +138,9 @@ class WebhookResource extends Resource
|
||||
SelectFilter::make('type')
|
||||
->options(WebhookType::class)
|
||||
->attribute('type'),
|
||||
SelectFilter::make('server_id')
|
||||
->label('Server')
|
||||
->options(Server::query()->pluck('name', 'id')->toArray()),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -132,50 +148,73 @@ class WebhookResource extends Resource
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
ToggleButtons::make('type')
|
||||
->live()
|
||||
->inline()
|
||||
->options(WebhookType::class)
|
||||
->default(WebhookType::Regular),
|
||||
TextInput::make('description')
|
||||
->label(trans('admin/webhook.description'))
|
||||
->required(),
|
||||
TextInput::make('endpoint')
|
||||
->label(trans('admin/webhook.endpoint'))
|
||||
->required()
|
||||
Tabs::make('webhook_tabs')
|
||||
->persistTab()
|
||||
->columnSpanFull()
|
||||
->afterStateUpdated(fn (string $state, Set $set) => $set('type', str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)),
|
||||
Section::make(trans('admin/webhook.regular'))
|
||||
->hidden(fn (Get $get) => $get('type') === WebhookType::Discord)
|
||||
->schema(fn () => self::getRegularFields())
|
||||
->headerActions([
|
||||
Action::make('reset_headers')
|
||||
->tooltip(trans('admin/webhook.reset_headers'))
|
||||
->color('danger')
|
||||
->icon(TablerIcon::Restore)
|
||||
->action(fn (Get $get, Set $set) => $set('headers', [
|
||||
'X-Webhook-Event' => '{{event}}',
|
||||
])),
|
||||
])
|
||||
->formBefore(),
|
||||
Section::make(trans('admin/webhook.discord'))
|
||||
->hidden(fn (Get $get) => $get('type') === WebhookType::Regular)
|
||||
->afterStateUpdated(fn (Livewire $livewire) => $livewire->dispatch('refresh-widget'))
|
||||
->schema(fn () => self::getDiscordFields())
|
||||
->view('filament.components.webhooksection')
|
||||
->aside()
|
||||
->formBefore()
|
||||
->columnSpanFull(),
|
||||
Section::make(trans('admin/webhook.events'))
|
||||
->schema([
|
||||
CheckboxList::make('events')
|
||||
->live()
|
||||
->options(fn () => WebhookConfiguration::filamentCheckboxList())
|
||||
->searchable()
|
||||
->bulkToggleable()
|
||||
->columns(3)
|
||||
->columnSpanFull()
|
||||
->required(),
|
||||
->tabs([
|
||||
Tab::make(trans('admin/webhook.information'))
|
||||
->icon(TablerIcon::InfoCircle)
|
||||
->schema([
|
||||
Grid::make()
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label(trans('admin/webhook.name'))
|
||||
->required(),
|
||||
Select::make('server_id')
|
||||
->label(trans('admin/webhook.server'))
|
||||
->relationship('server', 'id')
|
||||
->preload()
|
||||
->disabled(),
|
||||
]),
|
||||
TextInput::make('description')
|
||||
->label(trans('admin/webhook.description'))
|
||||
->required(),
|
||||
Grid::make()
|
||||
->schema([
|
||||
ToggleButtons::make('type')
|
||||
->live()
|
||||
->inline()
|
||||
->options(WebhookType::class)
|
||||
->default(WebhookType::Regular),
|
||||
Hidden::make('scope')
|
||||
->formatStateUsing(fn (Get $get) => $get('server_id') ? WebhookScope::Server : WebhookScope::Global),
|
||||
TextInput::make('endpoint')
|
||||
->label(trans('admin/webhook.endpoint'))
|
||||
->required()
|
||||
->afterStateUpdated(fn (?string $state, Set $set) => $set('type', $state && str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)),
|
||||
]),
|
||||
]),
|
||||
Tab::make(trans('admin/webhook.payload'))
|
||||
->icon(TablerIcon::FileCode)
|
||||
->schema([
|
||||
Section::make()
|
||||
->schema(fn (Get $get) => $get('type') === WebhookType::Discord
|
||||
? self::getDiscordFields()
|
||||
: self::getRegularFields()
|
||||
),
|
||||
]),
|
||||
Tab::make(trans('admin/webhook.events'))
|
||||
->icon(TablerIcon::Star)
|
||||
->schema([
|
||||
Section::make()
|
||||
->schema([
|
||||
CheckboxList::make('events')
|
||||
->live()
|
||||
->options(function (Get $get) {
|
||||
$scope = $get('scope');
|
||||
if (!$scope instanceof WebhookScope) {
|
||||
$scope = WebhookScope::from($scope ?? 'global');
|
||||
}
|
||||
|
||||
return WebhookConfiguration::filamentCheckboxList($scope);
|
||||
})
|
||||
->searchable()
|
||||
->bulkToggleable()
|
||||
->columns(3)
|
||||
->columnSpanFull()
|
||||
->required(),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
@@ -200,134 +239,150 @@ class WebhookResource extends Resource
|
||||
private static function getDiscordFields(): array
|
||||
{
|
||||
return [
|
||||
Section::make(trans('admin/webhook.discord_message.profile'))
|
||||
->collapsible()
|
||||
Grid::make()
|
||||
->schema([
|
||||
TextInput::make('username')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_message.username')),
|
||||
TextInput::make('avatar_url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_message.avatar_url')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_message.message'))
|
||||
->collapsible()
|
||||
->schema([
|
||||
TextInput::make('content')
|
||||
->label(trans('admin/webhook.discord_message.message'))
|
||||
->live(debounce: 500)
|
||||
->required(fn (Get $get) => empty($get('embeds'))),
|
||||
TextInput::make('thread_name')
|
||||
->label(trans('admin/webhook.discord_message.forum_thread')),
|
||||
CheckboxList::make('flags')
|
||||
->label(trans('admin/webhook.discord_embed.flags'))
|
||||
->options([
|
||||
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds'),
|
||||
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications'),
|
||||
])
|
||||
->descriptions([
|
||||
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds_text'),
|
||||
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications_text'),
|
||||
]),
|
||||
CheckboxList::make('allowed_mentions')
|
||||
->label(trans('admin/webhook.discord_embed.allowed_mentions'))
|
||||
->options([
|
||||
'roles' => trans('admin/webhook.discord_embed.roles'),
|
||||
'users' => trans('admin/webhook.discord_embed.users'),
|
||||
'everyone' => trans('admin/webhook.discord_embed.everyone'),
|
||||
]),
|
||||
]),
|
||||
Repeater::make('embeds')
|
||||
->live(debounce: 500)
|
||||
->itemLabel(fn (array $state) => $state['title'])
|
||||
->addActionLabel(trans('admin/webhook.discord_embed.add_embed'))
|
||||
->required(fn (Get $get) => empty($get('content')))
|
||||
->reorderable()
|
||||
->collapsible()
|
||||
->maxItems(10)
|
||||
->schema([
|
||||
Section::make(trans('admin/webhook.discord_embed.author'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
Section::make()
|
||||
->columnSpanFull()
|
||||
->poll('15s')
|
||||
->view('filament.components.webhooksection'),
|
||||
Grid::make()
|
||||
->columnSpan(8)
|
||||
->schema([
|
||||
TextInput::make('author.name')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.author'))
|
||||
->required(fn (Get $get) => filled($get('author.url')) || filled($get('author.icon_url'))),
|
||||
TextInput::make('author.url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.author_url')),
|
||||
TextInput::make('author.icon_url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.author_icon_url')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_embed.body'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.title'))
|
||||
->required(fn (Get $get) => $get('description') === null),
|
||||
Textarea::make('description')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.body'))
|
||||
->required(fn (Get $get) => $get('title') === null),
|
||||
ColorPicker::make('color')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.color'))
|
||||
->hex(),
|
||||
TextInput::make('url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.url')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_embed.images'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('image.url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.image_url')),
|
||||
TextInput::make('thumbnail.url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.image_thumbnail')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_embed.footer'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('footer.text')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.footer')),
|
||||
Checkbox::make('has_timestamp')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.has_timestamp')),
|
||||
TextInput::make('footer.icon_url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.footer_icon_url')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_embed.fields'))
|
||||
->collapsible()->collapsed()
|
||||
->schema([
|
||||
Repeater::make('fields')
|
||||
->reorderable()
|
||||
->addActionLabel(trans('admin/webhook.discord_embed.add_field'))
|
||||
Section::make(trans('admin/webhook.discord_message.profile'))
|
||||
->collapsible()
|
||||
->columnSpanFull()
|
||||
|
||||
->schema([
|
||||
TextInput::make('username')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_message.username')),
|
||||
TextInput::make('avatar_url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_message.avatar_url')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_message.message'))
|
||||
->columnSpanFull()
|
||||
->collapsible()
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
TextInput::make('content')
|
||||
->label(trans('admin/webhook.discord_message.message'))
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.field_name'))
|
||||
->required(),
|
||||
Textarea::make('value')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.field_value'))
|
||||
->rows(4)
|
||||
->required(),
|
||||
Checkbox::make('inline')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.inline_field')),
|
||||
->required(fn (Get $get) => empty($get('embeds'))),
|
||||
TextInput::make('thread_name')
|
||||
->label(trans('admin/webhook.discord_message.forum_thread')),
|
||||
CheckboxList::make('flags')
|
||||
->label(trans('admin/webhook.discord_embed.flags'))
|
||||
->options([
|
||||
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds'), // Discord flag: SUPPRESS_EMBEDS (4)
|
||||
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications'), // Discord flag: SUPPRESS_NOTIFICATIONS (4096)
|
||||
])
|
||||
->descriptions([
|
||||
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds_text'),
|
||||
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications_text'),
|
||||
]),
|
||||
CheckboxList::make('allowed_mentions')
|
||||
->label(trans('admin/webhook.discord_embed.allowed_mentions'))
|
||||
->options([
|
||||
'roles' => trans('admin/webhook.discord_embed.roles'),
|
||||
'users' => trans('admin/webhook.discord_embed.users'),
|
||||
'everyone' => trans('admin/webhook.discord_embed.everyone'),
|
||||
]),
|
||||
]),
|
||||
Repeater::make('embeds')
|
||||
->live(debounce: 500)
|
||||
->itemLabel(fn (array $state) => $state['title'] ?? '')
|
||||
->addActionLabel(trans('admin/webhook.discord_embed.add_embed'))
|
||||
->required(fn (Get $get) => empty($get('content')))
|
||||
->reorderable()
|
||||
->columnSpanFull()
|
||||
->collapsible()
|
||||
->maxItems(10)
|
||||
->schema([
|
||||
Section::make(trans('admin/webhook.discord_embed.author'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('author.name')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.author'))
|
||||
->required(fn (Get $get) => filled($get('author.url')) || filled($get('author.icon_url'))),
|
||||
TextInput::make('author.url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.author_url')),
|
||||
TextInput::make('author.icon_url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.author_icon_url')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_embed.body'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('title')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.title'))
|
||||
->required(fn (Get $get) => $get('description') === null),
|
||||
Textarea::make('description')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.body'))
|
||||
->required(fn (Get $get) => $get('title') === null),
|
||||
ColorPicker::make('color')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.color'))
|
||||
->hex(),
|
||||
TextInput::make('url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.url')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_embed.images'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('image.url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.image_url')),
|
||||
TextInput::make('thumbnail.url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.image_thumbnail')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_embed.footer'))
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('footer.text')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.footer')),
|
||||
Checkbox::make('has_timestamp')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.has_timestamp')),
|
||||
TextInput::make('footer.icon_url')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.footer_icon_url')),
|
||||
]),
|
||||
Section::make(trans('admin/webhook.discord_embed.fields'))
|
||||
->collapsible()->collapsed()
|
||||
->schema([
|
||||
Repeater::make('fields')
|
||||
->reorderable()
|
||||
->addActionLabel(trans('admin/webhook.discord_embed.add_field'))
|
||||
->collapsible()
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.field_name'))
|
||||
->required(),
|
||||
Textarea::make('value')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.field_value'))
|
||||
->rows(4)
|
||||
->required(),
|
||||
Checkbox::make('inline')
|
||||
->live(debounce: 500)
|
||||
->label(trans('admin/webhook.discord_embed.inline_field')),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
41
app/Filament/Server/Resources/Webhooks/Pages/EditWebhook.php
Normal file
41
app/Filament/Server/Resources/Webhooks/Pages/EditWebhook.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Server/Resources/Webhooks/Pages/ViewWebhook.php
Normal file
19
app/Filament/Server/Resources/Webhooks/Pages/ViewWebhook.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
356
app/Filament/Server/Resources/Webhooks/WebhookResource.php
Normal file
356
app/Filament/Server/Resources/Webhooks/WebhookResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Remote;
|
||||
|
||||
use App\Events\ActivityLogged;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\Remote\ActivityEventRequest;
|
||||
use App\Models\ActivityLog;
|
||||
@@ -11,6 +12,7 @@ use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ActivityProcessingController extends Controller
|
||||
@@ -77,6 +79,7 @@ class ActivityProcessingController extends Controller
|
||||
'subject_id' => $server->id,
|
||||
'subject_type' => $server->getMorphClass(),
|
||||
]);
|
||||
Event::dispatch(new ActivityLogged($activityLog));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,7 @@ class ProcessWebhook implements ShouldQueue
|
||||
$data = reset($data);
|
||||
}
|
||||
|
||||
if (is_object($data)) {
|
||||
$data = get_object_vars($data);
|
||||
}
|
||||
|
||||
if (is_string($data)) {
|
||||
$data = Arr::wrap(json_decode($data, true) ?? []);
|
||||
}
|
||||
$data = $this->normalizeData($data);
|
||||
$data['event'] = $this->webhookConfiguration->transformClassName($this->eventName);
|
||||
|
||||
if ($this->webhookConfiguration->type === WebhookType::Discord) {
|
||||
@@ -82,4 +76,18 @@ class ProcessWebhook implements ShouldQueue
|
||||
'endpoint' => $this->webhookConfiguration->endpoint,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,35 +2,207 @@
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\WebhookScope;
|
||||
use App\Events\ActivityLogged;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookConfiguration;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DispatchWebhooks
|
||||
{
|
||||
/**
|
||||
* @param array<mixed> $data
|
||||
*/
|
||||
public function handle(string $eventName, array $data): void
|
||||
/** @param array<mixed>|string|null $action */
|
||||
public function handle(mixed $event, array|string|null $action = null): void
|
||||
{
|
||||
if (is_string($event) && is_array($action)) {
|
||||
if (str_starts_with($event, 'eloquent.')) {
|
||||
$this->handleEloquentEvent($action[0], str($event)->between('eloquent.', ':'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event !== ActivityLogged::class) {
|
||||
$this->handleGenericClassEvent($event, $action);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event instanceof ActivityLogged) {
|
||||
$this->handleActivityLogged($event);
|
||||
$this->handleGlobalWebhooks($event);
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleEloquentEvent(Model $model, string $action): void
|
||||
{
|
||||
$modelClass = $model::class;
|
||||
$eventName = "eloquent.$action: $modelClass";
|
||||
|
||||
$webhooks = WebhookConfiguration::query()
|
||||
->where('scope', WebhookScope::Global)
|
||||
->whereJsonContains('events', $eventName)
|
||||
->get();
|
||||
|
||||
if ($webhooks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webhookData = [
|
||||
'event' => $eventName,
|
||||
'data' => $model->toArray(),
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
if (!$this->hasPayloadContent($webhookData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var WebhookConfiguration $webhookConfig */
|
||||
foreach ($webhooks as $webhookConfig) {
|
||||
$webhookConfig->run($eventName, [$webhookData]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<mixed> $payload */
|
||||
protected function handleGenericClassEvent(string $eventName, array $payload): void
|
||||
{
|
||||
if (!$this->eventIsWatched($eventName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) {
|
||||
return WebhookConfiguration::query()->whereJsonContains('events', $eventName)->get();
|
||||
return WebhookConfiguration::query()
|
||||
->where('scope', WebhookScope::Global)
|
||||
->whereJsonContains('events', $eventName)
|
||||
->get();
|
||||
});
|
||||
|
||||
/** @var WebhookConfiguration $webhookConfig */
|
||||
if ($matchingHooks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$obj = $payload[0] ?? null;
|
||||
$webhookData = ['event' => $eventName, 'timestamp' => now()->toIso8601String()];
|
||||
if (is_object($obj)) {
|
||||
$webhookData['data'] = $obj->toArray();
|
||||
} elseif (is_array($obj)) {
|
||||
$webhookData['data'] = $obj;
|
||||
}
|
||||
|
||||
foreach ($matchingHooks as $webhookConfig) {
|
||||
if (in_array($eventName, $webhookConfig->events)) {
|
||||
$webhookConfig->run($eventName, $data);
|
||||
$webhookConfig->run($eventName, [$webhookData]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleActivityLogged(ActivityLogged $activityLogged): void
|
||||
{
|
||||
$eventName = $activityLogged->model->event;
|
||||
|
||||
if (!$activityLogged->isServerEvent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = null;
|
||||
$morphClass = (new Server())->getMorphClass();
|
||||
foreach ($activityLogged->model->subjects as $subject) {
|
||||
if ($subject->subject_type === $morphClass && $subject->subject instanceof Server) {
|
||||
$server = $subject->subject;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$server && isset($activityLogged->model->properties['server'])) {
|
||||
$server = Server::find($activityLogged->model->properties['server']['id'] ?? null);
|
||||
}
|
||||
|
||||
if (!$server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webhooks = $server->webhookConfigurations()
|
||||
->whereJsonContains('events', $eventName)
|
||||
->get();
|
||||
|
||||
if ($webhooks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webhookData = $this->buildActivityPayload($activityLogged);
|
||||
|
||||
if (!$this->hasPayloadContent($webhookData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($webhooks as $webhookConfig) {
|
||||
$webhookConfig->run($eventName, [$webhookData]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<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
|
||||
{
|
||||
$watchedEvents = cache()->rememberForever('watchedWebhooks', function () {
|
||||
return WebhookConfiguration::pluck('events')
|
||||
return WebhookConfiguration::where('scope', WebhookScope::Global)
|
||||
->pluck('events')
|
||||
->flatten()
|
||||
->unique()
|
||||
->values()
|
||||
@@ -39,4 +211,17 @@ class DispatchWebhooks
|
||||
|
||||
return in_array($eventName, $watchedEvents);
|
||||
}
|
||||
|
||||
/** @param array<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();
|
||||
}
|
||||
}
|
||||
|
||||
150
app/Livewire/DiscordPreview.php
Normal file
150
app/Livewire/DiscordPreview.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Events\ActivityLogged;
|
||||
use App\Traits\HasValidation;
|
||||
use BackedEnum;
|
||||
use Filament\Facades\Filament;
|
||||
@@ -18,7 +17,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use LogicException;
|
||||
|
||||
@@ -140,9 +138,6 @@ class ActivityLog extends Model implements HasIcon, HasLabel
|
||||
$model->timestamp = Carbon::now();
|
||||
});
|
||||
|
||||
static::created(function (self $model) {
|
||||
Event::dispatch(new ActivityLogged($model));
|
||||
});
|
||||
}
|
||||
|
||||
public function getIcon(): BackedEnum
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Validatable;
|
||||
use App\Enums\ContainerStatus;
|
||||
use App\Enums\ServerResourceType;
|
||||
use App\Enums\ServerState;
|
||||
use App\Enums\WebhookScope;
|
||||
use App\Exceptions\Http\Server\ServerStateConflictException;
|
||||
use App\Models\Traits\HasIcon;
|
||||
use App\Repositories\Daemon\DaemonServerRepository;
|
||||
@@ -381,6 +382,15 @@ class Server extends Model implements HasAvatar, Validatable
|
||||
return $this->morphToMany(Mount::class, 'mountable');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<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.
|
||||
*/
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\WebhookScope;
|
||||
use App\Enums\WebhookType;
|
||||
use App\Jobs\ProcessWebhook;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Features\SupportEvents\HandlesEvents;
|
||||
|
||||
@@ -18,6 +21,7 @@ use Livewire\Features\SupportEvents\HandlesEvents;
|
||||
* @property string $endpoint
|
||||
* @property string $description
|
||||
* @property string[] $events
|
||||
* @property WebhookScope $scope
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property Carbon|null $deleted_at
|
||||
@@ -55,6 +59,9 @@ class WebhookConfiguration extends Model
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'scope',
|
||||
'server_id',
|
||||
'type',
|
||||
'payload',
|
||||
'endpoint',
|
||||
@@ -67,6 +74,7 @@ class WebhookConfiguration extends Model
|
||||
* Default values for specific fields in the database.
|
||||
*/
|
||||
protected $attributes = [
|
||||
'scope' => WebhookScope::Global,
|
||||
'type' => WebhookType::Regular,
|
||||
'payload' => null,
|
||||
];
|
||||
@@ -74,6 +82,7 @@ class WebhookConfiguration extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'scope' => WebhookScope::class,
|
||||
'events' => 'array',
|
||||
'payload' => 'array',
|
||||
'type' => WebhookType::class,
|
||||
@@ -100,10 +109,23 @@ class WebhookConfiguration extends Model
|
||||
private static function updateCache(Collection $eventList): void
|
||||
{
|
||||
$eventList->each(function (string $event) {
|
||||
cache()->forever("webhooks.$event", WebhookConfiguration::query()->whereJsonContains('events', $event)->get());
|
||||
cache()->forget("webhooks.$event");
|
||||
});
|
||||
|
||||
cache()->forever('watchedWebhooks', WebhookConfiguration::pluck('events')->flatten()->unique()->values()->all());
|
||||
$eventList->each(function (string $event) {
|
||||
cache()->forever("webhooks.$event", static::query()
|
||||
->where('scope', WebhookScope::Global)
|
||||
->whereJsonContains('events', $event)
|
||||
->get());
|
||||
});
|
||||
|
||||
cache()->forget('watchedWebhooks');
|
||||
cache()->forever('watchedWebhooks', static::where('scope', WebhookScope::Global)
|
||||
->pluck('events')
|
||||
->flatten()
|
||||
->unique()
|
||||
->values()
|
||||
->all());
|
||||
}
|
||||
|
||||
public function webhooks(): HasMany
|
||||
@@ -111,6 +133,11 @@ class WebhookConfiguration extends Model
|
||||
return $this->hasMany(Webhook::class);
|
||||
}
|
||||
|
||||
public function server(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public static function allPossibleEvents(): array
|
||||
{
|
||||
@@ -121,18 +148,211 @@ class WebhookConfiguration extends Model
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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> */
|
||||
public static function filamentCheckboxList(): array
|
||||
public static function filamentCheckboxList(WebhookScope $scope): array
|
||||
{
|
||||
$list = [];
|
||||
$events = static::allPossibleEvents();
|
||||
foreach ($events as $event) {
|
||||
$list[$event] = static::transformClassName($event);
|
||||
|
||||
if ($scope === WebhookScope::Server) {
|
||||
$events = static::allPossibleServerEvents();
|
||||
foreach ($events as $event) {
|
||||
$list[$event] = static::transformServerEventName($event);
|
||||
}
|
||||
} else {
|
||||
$events = static::allPossibleAdminOnlyEvents();
|
||||
foreach ($events as $event) {
|
||||
$list[$event] = static::transformClassName($event);
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
public static function transformServerEventName(string $event): string
|
||||
{
|
||||
return str($event)
|
||||
->after('server:')
|
||||
->replace('.', ' → ')
|
||||
->title()
|
||||
->toString();
|
||||
}
|
||||
|
||||
public static function transformClassName(string $event): string
|
||||
{
|
||||
return str($event)
|
||||
@@ -214,10 +434,16 @@ class WebhookConfiguration extends Model
|
||||
/** @param array<mixed, mixed> $eventData */
|
||||
public function run(?string $eventName = null, ?array $eventData = null): void
|
||||
{
|
||||
if ($this->scope === WebhookScope::Server) {
|
||||
$eventName ??= 'server:file.write';
|
||||
$eventData ??= static::getServerWebhookSampleData();
|
||||
}
|
||||
|
||||
$eventName ??= 'eloquent.created: '.Server::class;
|
||||
$eventData ??= static::getWebhookSampleData();
|
||||
|
||||
ProcessWebhook::dispatch($this, $eventName, [$eventData]);
|
||||
$payload = array_is_list($eventData) ? $eventData : [$eventData];
|
||||
ProcessWebhook::dispatch($this, $eventName, $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,4 +646,29 @@ class WebhookConfiguration extends Model
|
||||
'event' => 'updated: Server',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Events\ActivityLogged;
|
||||
use App\Listeners\DispatchWebhooks;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
|
||||
@@ -11,7 +12,8 @@ class EventServiceProvider extends ServiceProvider
|
||||
* The event to listener mappings for the application.
|
||||
*/
|
||||
protected $listen = [
|
||||
'App\\*' => [DispatchWebhooks::class],
|
||||
ActivityLogged::class => [DispatchWebhooks::class],
|
||||
'App\\Events\\*' => [DispatchWebhooks::class],
|
||||
'eloquent.created*' => [DispatchWebhooks::class],
|
||||
'eloquent.deleted*' => [DispatchWebhooks::class],
|
||||
'eloquent.updated*' => [DispatchWebhooks::class],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Activity;
|
||||
|
||||
use App\Events\ActivityLogged;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
@@ -12,6 +13,7 @@ use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use Throwable;
|
||||
use Webmozart\Assert\Assert;
|
||||
@@ -244,6 +246,8 @@ class ActivityLogService
|
||||
return $this->activity;
|
||||
});
|
||||
|
||||
Event::dispatch(new ActivityLogged($response));
|
||||
|
||||
$this->activity = null;
|
||||
$this->subjects = [];
|
||||
|
||||
|
||||
44
app/Services/WebhookService.php
Normal file
44
app/Services/WebhookService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,16 +6,21 @@ return [
|
||||
'model_label_plural' => 'Webhooks',
|
||||
'endpoint' => 'Endpoint',
|
||||
'description' => 'Description',
|
||||
'name' => 'Name',
|
||||
'server' => 'Server',
|
||||
'information' => 'Information',
|
||||
'payload' => 'Payload',
|
||||
'events' => 'Events',
|
||||
'no_webhooks' => 'No Webhooks',
|
||||
'help' => 'Help',
|
||||
'help_text' => 'You have to wrap variable name in between {{ }} for example if you want to get the name from the api you can use {{name}}.',
|
||||
'test_now' => 'Test now',
|
||||
'test_now' => 'Test Now',
|
||||
'test_now_help' => 'This will fire a `created: Server` event',
|
||||
'table' => [
|
||||
'description' => 'Description',
|
||||
'endpoint' => 'Endpoint',
|
||||
],
|
||||
'headers' => 'Headers',
|
||||
'events' => 'Events',
|
||||
'regular' => 'Regular',
|
||||
'reset_headers' => 'Reset Headers',
|
||||
'discord' => 'Discord',
|
||||
|
||||
253
resources/css/discord-preview.css
Normal file
253
resources/css/discord-preview.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -68,7 +68,21 @@
|
||||
:secondary="$isSecondary"
|
||||
>
|
||||
<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>
|
||||
|
||||
{{ $getChildSchema()->gap(! $isDivided)->extraAttributes(['class' => 'fi-section-content']) }}
|
||||
|
||||
113
resources/views/livewire/discord-preview.blade.php
Normal file
113
resources/views/livewire/discord-preview.blade.php
Normal 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>
|
||||
Reference in New Issue
Block a user