diff --git a/discord-webhooks/LICENSE b/discord-webhooks/LICENSE
new file mode 100644
index 00000000..17b5870a
--- /dev/null
+++ b/discord-webhooks/LICENSE
@@ -0,0 +1,6 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
diff --git a/discord-webhooks/README.md b/discord-webhooks/README.md
new file mode 100644
index 00000000..b7cb272f
--- /dev/null
+++ b/discord-webhooks/README.md
@@ -0,0 +1,47 @@
+# Discord Webhooks (by notjami)
+
+Send Discord webhook notifications for various server events in Pelican Panel.
+
+## Features
+
+- Discord webhook integration
+- Server status notifications (online/offline)
+- Multiple webhooks support
+- Event-based triggers
+- Automatic status checking via scheduled task
+
+## Supported Events
+
+- Server Started (online)
+- Server Stopped (offline)
+- Server Installing
+- Server Installed
+
+## Installation
+
+1. Copy the `discord-webhooks` folder to your Pelican Panel plugins directory
+2. Install the plugin
+
+## Server Status Detection
+
+Server start/stop detection works via a scheduled command that checks server status every minute.
+
+**Manual check:**
+```bash
+php artisan discord-webhooks:check-status
+```
+
+## Usage
+
+1. Navigate to Admin Panel → Webhooks
+2. Create a new webhook with your Discord webhook URL
+3. Select which events should trigger the webhook
+4. Save and test your webhook
+
+## Configuration
+
+Each webhook can be configured with:
+- **Name**: A friendly name for the webhook
+- **Webhook URL**: Your Discord webhook URL
+- **Events**: Which events trigger this webhook
+- **Enabled**: Toggle the webhook on/off
diff --git a/discord-webhooks/database/migrations/001_create_webhooks_table.php b/discord-webhooks/database/migrations/001_create_webhooks_table.php
new file mode 100644
index 00000000..539a4d54
--- /dev/null
+++ b/discord-webhooks/database/migrations/001_create_webhooks_table.php
@@ -0,0 +1,29 @@
+increments('id');
+ $table->string('name');
+ $table->string('webhook_url');
+ $table->foreignId('server_id')->nullable()->constrained('servers')->nullOnDelete();
+ $table->json('events')->default('[]');
+ $table->boolean('enabled')->default(true);
+ $table->timestamp('last_triggered_at')->nullable();
+ $table->timestamps();
+
+ // foreignId above handles FK constraint and nullOnDelete
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('webhooks');
+ }
+};
diff --git a/discord-webhooks/plugin.json b/discord-webhooks/plugin.json
new file mode 100644
index 00000000..8909db0c
--- /dev/null
+++ b/discord-webhooks/plugin.json
@@ -0,0 +1,17 @@
+{
+ "id": "discord-webhooks",
+ "name": "Discord Webhooks",
+ "author": "notjami",
+ "version": "1.1.1",
+ "description": "Send Discord webhook notifications for server events",
+ "category": "plugin",
+ "url": "https://github.com/jami100YT/pelican-plugins/tree/discord-webhooks/discord-webhooks",
+ "update_url": null,
+ "namespace": "Notjami\\Webhooks",
+ "class": "WebhooksPlugin",
+ "panels": [
+ "server"
+ ],
+ "panel_version": null,
+ "composer_packages": null
+}
diff --git a/discord-webhooks/src/Console/Commands/CheckServerStatus.php b/discord-webhooks/src/Console/Commands/CheckServerStatus.php
new file mode 100644
index 00000000..da5c10be
--- /dev/null
+++ b/discord-webhooks/src/Console/Commands/CheckServerStatus.php
@@ -0,0 +1,66 @@
+whereNotNull('server_id')
+ ->pluck('server_id')
+ ->unique();
+
+ // Also check servers if there are global webhooks
+ $hasGlobalWebhooks = Webhook::enabled()
+ ->whereNull('server_id')
+ ->exists();
+
+ if ($hasGlobalWebhooks) {
+ $servers = Server::whereNull('status')->get();
+ } else {
+ $servers = Server::whereIn('id', $serverIds)->whereNull('status')->get();
+ }
+
+ foreach ($servers as $server) {
+ try {
+ $details = $repository->setServer($server)->getDetails();
+ $currentState = $details['state'] ?? 'offline';
+
+ $cacheKey = "webhook_server_status_{$server->id}";
+ $previousState = Cache::get($cacheKey, 'unknown');
+ // Always refresh the cache TTL, even if state hasn't changed
+ Cache::put($cacheKey, $currentState, now()->addHours(24));
+
+ if ($previousState !== $currentState) {
+ if ($previousState !== 'unknown') {
+ if ($currentState === 'running') {
+ $webhookService->triggerEvent(WebhookEvent::ServerStarted, $server);
+ $this->info("Server {$server->name} started - webhook triggered");
+ } elseif (in_array($currentState, ['offline', 'stopped'])) {
+ $webhookService->triggerEvent(WebhookEvent::ServerStopped, $server);
+ $this->info("Server {$server->name} stopped - webhook triggered");
+ }
+ }
+ }
+ } catch (\Exception $e) {
+ $this->error("Failed to check server {$server->name}: {$e->getMessage()}");
+ }
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/discord-webhooks/src/Enums/WebhookEvent.php b/discord-webhooks/src/Enums/WebhookEvent.php
new file mode 100644
index 00000000..202ed655
--- /dev/null
+++ b/discord-webhooks/src/Enums/WebhookEvent.php
@@ -0,0 +1,51 @@
+ 'Server Started',
+ self::ServerStopped => 'Server Stopped',
+ self::ServerInstalling => 'Server Installing',
+ self::ServerInstalled => 'Server Installed',
+ };
+ }
+
+ public function getDescription(): string
+ {
+ return match ($this) {
+ self::ServerStarted => 'Triggered when a server comes online',
+ self::ServerStopped => 'Triggered when a server goes offline',
+ self::ServerInstalling => 'Triggered when a server starts installing',
+ self::ServerInstalled => 'Triggered when a server finishes installation',
+ };
+ }
+
+ public function getColor(): string
+ {
+ return match ($this) {
+ self::ServerStarted => '3066993', // Green
+ self::ServerStopped => '15158332', // Red
+ self::ServerInstalling => '15105570', // Orange
+ self::ServerInstalled => '3447003', // Blue
+ };
+ }
+
+ public function getEmoji(): string
+ {
+ return match ($this) {
+ self::ServerStarted => '🟢',
+ self::ServerStopped => '🔴',
+ self::ServerInstalling => '🔧',
+ self::ServerInstalled => '✅',
+ };
+ }
+}
diff --git a/discord-webhooks/src/Filament/Server/Resources/Webhooks/Pages/ManageWebhooks.php b/discord-webhooks/src/Filament/Server/Resources/Webhooks/Pages/ManageWebhooks.php
new file mode 100644
index 00000000..09701270
--- /dev/null
+++ b/discord-webhooks/src/Filament/Server/Resources/Webhooks/Pages/ManageWebhooks.php
@@ -0,0 +1,11 @@
+count() ?: null;
+ }
+
+ public static function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ TextColumn::make('name')
+ ->label('Name')
+ ->searchable()
+ ->sortable(),
+ TextColumn::make('webhook_url')
+ ->label('Webhook URL')
+ ->limit(30)
+ ->formatStateUsing(function ($state) {
+ // Mask the URL: show scheme://host/...****
+ if (empty($state)) return '';
+ $parsed = parse_url($state);
+ if (!$parsed || !isset($parsed['scheme'], $parsed['host'])) return '••••••';
+ $last4 = substr($parsed['path'] ?? '', -4);
+ $masked = $parsed['scheme'] . '://' . $parsed['host'] . '/...';
+ if ($last4) {
+ $masked .= $last4;
+ }
+ return $masked;
+ })
+ ->tooltip('••••••'),
+ TextColumn::make('events')
+ ->label('Events')
+ ->badge()
+ ->formatStateUsing(function ($state) {
+ $events = is_array($state) ? $state : (array) $state;
+ return collect($events)
+ ->map(fn ($event) => WebhookEvent::tryFrom($event))
+ ->filter()
+ ->map(fn ($event) => $event->getLabel())
+ ->join(', ');
+ }),
+ IconColumn::make('enabled')
+ ->label('Enabled')
+ ->boolean(),
+ TextColumn::make('last_triggered_at')
+ ->label('Last Triggered')
+ ->dateTime()
+ ->placeholder('Never'),
+ ])
+ ->recordActions([
+ Action::make('test')
+ ->label('Test')
+ ->icon('tabler-send')
+ ->color('info')
+ ->requiresConfirmation()
+ ->modalHeading('Test Webhook')
+ ->modalDescription('This will send a test message to the webhook URL.')
+ ->action(function (Webhook $record, DiscordWebhookService $service) {
+ $success = $service->sendTestMessage($record);
+
+ if ($success) {
+ Notification::make()
+ ->title('Test message sent!')
+ ->success()
+ ->send();
+ } else {
+ Notification::make()
+ ->title('Failed to send test message')
+ ->danger()
+ ->send();
+ }
+ }),
+ EditAction::make(),
+ DeleteAction::make(),
+ ])
+ ->toolbarActions([
+ CreateAction::make()
+ ->createAnother(false),
+ ])
+ ->emptyStateIcon('tabler-webhook')
+ ->emptyStateDescription('')
+ ->emptyStateHeading('No webhooks configured');
+ }
+
+ public static function form(Schema $schema): Schema
+ {
+ return $schema
+ ->components([
+ TextInput::make('name')
+ ->label('Name')
+ ->placeholder('My Discord Webhook')
+ ->required()
+ ->maxLength(255)
+ ->columnSpanFull(),
+ TextInput::make('webhook_url')
+ ->label('Discord Webhook URL')
+ ->placeholder('https://discord.com/api/webhooks/...')
+ ->required()
+ ->url()
+ ->regex('/^https:\/\/discord\\.com\/api\/webhooks\/.+/')
+ ->maxLength(500)
+ ->columnSpanFull(),
+ Select::make('events')
+ ->label('Events')
+ ->multiple()
+ ->options(collect(WebhookEvent::cases())->mapWithKeys(fn ($event) => [
+ $event->value => $event->getLabel(),
+ ]))
+ ->required()
+ ->columnSpanFull(),
+ Toggle::make('enabled')
+ ->label('Enabled')
+ ->default(true)
+ ->inline(false),
+ ]);
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => ManageWebhooks::route('/'),
+ ];
+ }
+}
diff --git a/discord-webhooks/src/Models/Webhook.php b/discord-webhooks/src/Models/Webhook.php
new file mode 100644
index 00000000..409514fc
--- /dev/null
+++ b/discord-webhooks/src/Models/Webhook.php
@@ -0,0 +1,90 @@
+ $events
+ * @property bool $enabled
+ * @property Carbon|null $last_triggered_at
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ * @property Server|null $server
+ */
+class Webhook extends Model
+{
+ protected $fillable = [
+ 'name',
+ 'webhook_url',
+ 'server_id',
+ 'events',
+ 'enabled',
+ 'last_triggered_at',
+ ];
+
+ protected $attributes = [
+ 'events' => '[]',
+ 'enabled' => true,
+ ];
+
+ protected function casts(): array
+ {
+ return [
+ 'events' => 'array',
+ 'enabled' => 'boolean',
+ 'last_triggered_at' => 'datetime',
+ ];
+ }
+
+ public function server(): BelongsTo
+ {
+ return $this->belongsTo(Server::class);
+ }
+
+ /**
+ * Check if the webhook has the given event.
+ *
+ * @param WebhookEvent|string $event
+ * @return bool
+ */
+ public function hasEvent(WebhookEvent|string $event): bool
+ {
+ $value = $event instanceof WebhookEvent ? $event->value : (string)$event;
+ // $this->events ist array
+ return in_array($value, $this->events, true);
+ }
+
+ public function appliesToServer(Server $server): bool
+ {
+ return $this->server_id === null || $this->server_id === $server->id;
+ }
+
+ public function scopeEnabled(Builder $query): Builder
+ {
+ return $query->where('enabled', true);
+ }
+
+ public function scopeForEvent(Builder $query, WebhookEvent $event): Builder
+ {
+ return $query->whereJsonContains('events', $event->value);
+ }
+
+ public function scopeForServer(Builder $query, Server $server): Builder
+ {
+ return $query->where(function ($q) use ($server) {
+ $q->whereNull('server_id')
+ ->orWhere('server_id', $server->id);
+ });
+ }
+}
diff --git a/discord-webhooks/src/Policies/WebhookPolicy.php b/discord-webhooks/src/Policies/WebhookPolicy.php
new file mode 100644
index 00000000..7ef20ba2
--- /dev/null
+++ b/discord-webhooks/src/Policies/WebhookPolicy.php
@@ -0,0 +1,8 @@
+app->singleton(DiscordWebhookService::class, function () {
+ return new DiscordWebhookService();
+ });
+
+ // Register console commands
+ $this->commands([
+ CheckServerStatus::class,
+ ]);
+ }
+
+ public function boot(): void
+ {
+ $this->registerServerStatusListeners();
+ $this->registerScheduledTasks();
+ }
+
+ protected function registerServerStatusListeners(): void
+ {
+ // Listen for Pelican Panel server events
+ // These events are dispatched when server status changes
+
+ // Server Installation Events
+ Event::listen('eloquent.updating: App\Models\Server', function (Server $server) {
+ // Check if status changed to installing
+ $status = $server->status;
+ $isInstalling = (
+ (is_string($status) && $status === 'installing') ||
+ ($status instanceof BackedEnum && $status->value === 'installing')
+ );
+ if ($server->isDirty('status') && $isInstalling) {
+ DB::afterCommit(function () use ($server) {
+ app(DiscordWebhookService::class)->triggerEvent(WebhookEvent::ServerInstalling, $server);
+ });
+ }
+
+ // Check if installation completed (status changed from installing to null)
+ $original = $server->getOriginal('status');
+ $wasInstalling = (
+ (is_string($original) && $original === 'installing') ||
+ ($original instanceof BackedEnum && $original->value === 'installing')
+ );
+ if ($server->isDirty('status') && $wasInstalling && $server->status === null) {
+ DB::afterCommit(function () use ($server) {
+ app(DiscordWebhookService::class)->triggerEvent(WebhookEvent::ServerInstalled, $server);
+ });
+ }
+ });
+
+ // Try to listen for daemon status events if they exist
+ $statusEvents = [
+ 'App\Events\Server\Started' => WebhookEvent::ServerStarted,
+ 'App\Events\Server\Stopped' => WebhookEvent::ServerStopped,
+ ];
+
+ foreach ($statusEvents as $eventClass => $webhookEvent) {
+ if (class_exists($eventClass)) {
+ Event::listen($eventClass, function ($event) use ($webhookEvent) {
+ $server = $event->server ?? null;
+ // Do not update the cache here; let CheckServerStatus handle state and webhook dispatching.
+ });
+ }
+ }
+ }
+
+ protected function registerScheduledTasks(): void
+ {
+ $this->app->booted(function () {
+ $schedule = $this->app->make(Schedule::class);
+ $schedule->command('discord-webhooks:check-status')->everyMinute();
+ });
+ }
+}
diff --git a/discord-webhooks/src/Services/DiscordWebhookService.php b/discord-webhooks/src/Services/DiscordWebhookService.php
new file mode 100644
index 00000000..f85c9524
--- /dev/null
+++ b/discord-webhooks/src/Services/DiscordWebhookService.php
@@ -0,0 +1,145 @@
+ [
+ [
+ 'title' => '🔔 Webhook Test',
+ 'description' => 'This is a test message from Pelican Panel.',
+ 'color' => 3447003,
+ 'fields' => [
+ [
+ 'name' => 'Webhook Name',
+ 'value' => $webhook->name,
+ 'inline' => true,
+ ],
+ [
+ 'name' => 'Status',
+ 'value' => '✅ Working',
+ 'inline' => true,
+ ],
+ ],
+ 'footer' => [
+ 'text' => 'Pelican Panel Webhooks',
+ ],
+ 'timestamp' => now()->toIso8601String(),
+ ],
+ ],
+ ];
+
+ return $this->send($webhook, $payload);
+ }
+
+ public function sendServerEvent(Webhook $webhook, Server $server, WebhookEvent $event): bool
+ {
+ $payload = [
+ 'embeds' => [
+ [
+ 'title' => $event->getEmoji() . ' ' . $event->getLabel(),
+ 'description' => $this->getEventDescription($event, $server),
+ 'color' => (int) $event->getColor(),
+ 'fields' => [
+ [
+ 'name' => 'Server',
+ 'value' => $server->name,
+ 'inline' => true,
+ ],
+ [
+ 'name' => 'UUID',
+ 'value' => '`' . substr($server->uuid, 0, 8) . '...`',
+ 'inline' => true,
+ ],
+ [
+ 'name' => 'Owner',
+ 'value' => $server->user->username ?? 'Unknown',
+ 'inline' => true,
+ ],
+ [
+ 'name' => 'Node',
+ 'value' => $server->node->name ?? 'Unknown',
+ 'inline' => true,
+ ],
+ ],
+ 'footer' => [
+ 'text' => 'Pelican Panel Webhooks',
+ ],
+ 'timestamp' => now()->toIso8601String(),
+ ],
+ ],
+ ];
+
+ return $this->send($webhook, $payload);
+ }
+
+ public function triggerEvent(WebhookEvent $event, Server $server): void
+ {
+ $webhooks = Webhook::enabled()
+ ->forEvent($event)
+ ->forServer($server)
+ ->get();
+
+ foreach ($webhooks as $webhook) {
+ try {
+ $sent = $this->sendServerEvent($webhook, $server, $event);
+ if ($sent) {
+ $webhook->update(['last_triggered_at' => now()]);
+ }
+ } catch (\Exception $e) {
+ Log::error('Failed to send webhook', [
+ 'webhook_id' => $webhook->id,
+ 'event' => $event->value,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ /**
+ * @param array $payload
+ */
+ protected function send(Webhook $webhook, array $payload): bool
+ {
+ // Enforce Discord webhook URL pattern as a second layer of validation
+ if (!preg_match('/^https:\/\/discord\.com\/api\/webhooks\/.+/', $webhook->webhook_url)) {
+ Log::warning('Rejected non-Discord webhook URL', [
+ 'webhook_id' => $webhook->id,
+ 'url' => $webhook->webhook_url,
+ ]);
+ return false;
+ }
+ try {
+ $response = Http::timeout(10)
+ ->post($webhook->webhook_url, $payload);
+
+ return $response->successful();
+ } catch (\Exception $e) {
+ Log::error('Discord webhook failed', [
+ 'webhook_id' => $webhook->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
+ }
+
+ protected function getEventDescription(WebhookEvent $event, Server $server): string
+ {
+ return match ($event) {
+ WebhookEvent::ServerStarted => "Server **{$server->name}** is now online.",
+ WebhookEvent::ServerStopped => "Server **{$server->name}** has gone offline.",
+ WebhookEvent::ServerInstalling => "Server **{$server->name}** is being installed.",
+ WebhookEvent::ServerInstalled => "Server **{$server->name}** has been installed successfully.",
+ };
+ }
+}
diff --git a/discord-webhooks/src/WebhooksPlugin.php b/discord-webhooks/src/WebhooksPlugin.php
new file mode 100644
index 00000000..33624a90
--- /dev/null
+++ b/discord-webhooks/src/WebhooksPlugin.php
@@ -0,0 +1,23 @@
+getId())->title();
+
+ $panel->discoverResources(plugin_path($this->getId(), "src/Filament/$id/Resources"), "Notjami\\Webhooks\\Filament\\$id\\Resources");
+ }
+
+ public function boot(Panel $panel): void {}
+}