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 {} +}