From 597f876961443dbe7865e444157059ae267d6410 Mon Sep 17 00:00:00 2001 From: Venelin Kochev Date: Thu, 14 May 2026 16:39:05 +0300 Subject: [PATCH] Add Discord webhook notification channel Introduce Discord as a notification channel: add enum value and ordering, implement DiscordWebhookChannel to post incoming-webhook payloads, and add routeNotificationForDiscord and isConfigured checks on NotificationChannel. Add Discord payloads to MonitorDown and MonitorRecovered, Livewire settings support (UI, state, validation, add/save/delete flows and regex validation), view updates, factory helper, and unit/feature tests. Update README to list Discord and document webhook setup. --- README.md | 3 +- app/Enums/NotificationChannelType.php | 9 +- app/Livewire/Settings/Notifications.php | 120 +++++++++++++++++ app/Models/NotificationChannel.php | 11 ++ .../Channels/DiscordWebhookChannel.php | 43 ++++++ app/Notifications/MonitorDown.php | 36 +++++ app/Notifications/MonitorRecovered.php | 19 +++ .../factories/NotificationChannelFactory.php | 11 ++ .../livewire/settings/notifications.blade.php | 124 ++++++++++++++++++ .../Settings/NotificationsSettingsTest.php | 69 ++++++++++ .../DiscordWebhookChannelTest.php | 74 +++++++++++ 11 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 app/Notifications/Channels/DiscordWebhookChannel.php create mode 100644 tests/Unit/Notifications/DiscordWebhookChannelTest.php diff --git a/README.md b/README.md index de15842..bef1e3e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ EasyMonitor is a full-stack monitoring platform for your websites and APIs. Add - **HTTP and ICMP checks** — every 30 seconds to 1 hour per monitor - **Multi-region probes** — lightweight Go binaries (~10 MB) you can deploy anywhere - **Consecutive-failure threshold** — configurable per monitor; no alerts on flaky single failures -- **Multi-channel alerts** — email, Slack, generic webhooks (HMAC-signed), and Pushover (per-user, per-monitor selection) on down and recovery +- **Multi-channel alerts** — email, Slack, Discord, generic webhooks (HMAC-signed), and Pushover (per-user, per-monitor selection) on down and recovery - **Projects** — group related monitors (e.g. main site + APIs) - **Teams** — share monitors and projects with collaborators with role-based access - **Status pages** — public, unlisted (secret link), or private @@ -149,6 +149,7 @@ Supported channels: |---------|-------|-----------------| | Email | Configured by the admin via `MAIL_MAILER` (log, SES, SMTP) | Uses the account email | | Slack | No admin setup — Slack-side only | User adds one or more [incoming webhooks](https://api.slack.com/messaging/webhooks), each labelled (e.g. `#alerts-api`, `#alerts-frontend`) — pick which ones to alert per monitor | +| Discord | No admin setup — Discord-side only | User adds one or more [channel webhooks](https://support.discord.com/hc/en-us/articles/228383668) (Server Settings → Integrations → Webhooks), each labelled — pick which ones to alert per monitor | | Webhook | No admin setup | User adds one or more HTTP endpoints (any URL) — each gets an auto-generated HMAC-SHA256 secret. Payloads are signed with `X-EasyMonitor-Signature: sha256=…` and tagged with `X-EasyMonitor-Event: monitor.down\|monitor.recovered`. Pipe to PagerDuty, Zapier, n8n, custom services | | Pushover | Admin sets `PUSHOVER_APP_TOKEN` once (from [pushover.net/apps/build](https://pushover.net/apps/build)) | User pastes their user key (and optional device) | diff --git a/app/Enums/NotificationChannelType.php b/app/Enums/NotificationChannelType.php index 6bba934..540a5fc 100644 --- a/app/Enums/NotificationChannelType.php +++ b/app/Enums/NotificationChannelType.php @@ -4,6 +4,7 @@ namespace App\Enums; +use App\Notifications\Channels\DiscordWebhookChannel; use App\Notifications\Channels\SlackWebhookChannel; use App\Notifications\Channels\WebhookChannel; use NotificationChannels\Pushover\PushoverChannel; @@ -13,6 +14,7 @@ enum NotificationChannelType: string case Email = 'email'; case Pushover = 'pushover'; case Slack = 'slack'; + case Discord = 'discord'; case Webhook = 'webhook'; /** @@ -24,6 +26,7 @@ public function label(): string self::Email => 'Email', self::Pushover => 'Pushover', self::Slack => 'Slack', + self::Discord => 'Discord', self::Webhook => 'Webhook', }; } @@ -37,6 +40,7 @@ public function laravelChannel(): string self::Email => 'mail', self::Pushover => PushoverChannel::class, self::Slack => SlackWebhookChannel::class, + self::Discord => DiscordWebhookChannel::class, self::Webhook => WebhookChannel::class, }; } @@ -49,8 +53,9 @@ public function sortOrder(): int return match ($this) { self::Email => 0, self::Slack => 1, - self::Webhook => 2, - self::Pushover => 3, + self::Discord => 2, + self::Webhook => 3, + self::Pushover => 4, }; } } diff --git a/app/Livewire/Settings/Notifications.php b/app/Livewire/Settings/Notifications.php index 7a38cc1..2870ccb 100644 --- a/app/Livewire/Settings/Notifications.php +++ b/app/Livewire/Settings/Notifications.php @@ -36,6 +36,20 @@ class Notifications extends Component public bool $newSlackActive = true; + /** + * Editable in-place state for each existing Discord channel, + * keyed by NotificationChannel id. + * + * @var array + */ + public array $discordEdits = []; + + public string $newDiscordLabel = ''; + + public string $newDiscordWebhookUrl = ''; + + public bool $newDiscordActive = true; + /** * Editable in-place state for each existing Webhook channel, * keyed by NotificationChannel id. @@ -67,6 +81,7 @@ public function mount(): void } $this->refreshSlackEdits(); + $this->refreshDiscordEdits(); $this->refreshWebhookEdits(); $this->defaultChannelId = $user->notificationChannels() @@ -186,6 +201,80 @@ public function deleteSlackChannel(int $channelId): void $this->dispatch('notifications-saved'); } + public function addDiscordChannel(): void + { + $this->validate( + [ + 'newDiscordLabel' => ['required', 'string', 'max:50'], + 'newDiscordWebhookUrl' => $this->discordWebhookRules(), + 'newDiscordActive' => ['boolean'], + ], + [ + 'newDiscordWebhookUrl.regex' => __('Paste a Discord webhook URL (https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...).'), + ], + ); + + Auth::user()->notificationChannels()->create([ + 'type' => NotificationChannelType::Discord, + 'label' => $this->newDiscordLabel, + 'config' => ['webhook_url' => $this->newDiscordWebhookUrl], + 'is_active' => $this->newDiscordActive, + 'is_default' => false, + ]); + + $this->newDiscordLabel = ''; + $this->newDiscordWebhookUrl = ''; + $this->newDiscordActive = true; + + $this->refreshDiscordEdits(); + + $this->dispatch('notifications-saved'); + } + + public function saveDiscordChannel(int $channelId): void + { + $prefix = "discordEdits.{$channelId}"; + + $this->validate( + [ + "{$prefix}.label" => ['required', 'string', 'max:50'], + "{$prefix}.webhook_url" => $this->discordWebhookRules(), + "{$prefix}.is_active" => ['boolean'], + ], + [ + "{$prefix}.webhook_url.regex" => __('Paste a Discord webhook URL (https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/...).'), + ], + ); + + $row = $this->discordEdits[$channelId]; + + $channel = Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Discord->value) + ->findOrFail($channelId); + + $channel->update([ + 'label' => $row['label'], + 'config' => ['webhook_url' => $row['webhook_url']], + 'is_active' => (bool) ($row['is_active'] ?? true), + ]); + + $this->dispatch('notifications-saved'); + } + + public function deleteDiscordChannel(int $channelId): void + { + Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Discord->value) + ->whereKey($channelId) + ->delete(); + + unset($this->discordEdits[$channelId]); + + $this->dispatch('notifications-saved'); + } + public function addWebhookChannel(): void { $this->validate([ @@ -325,6 +414,7 @@ public function render() return view('livewire.settings.notifications', [ 'channels' => $channels, 'slackChannels' => $channels->where('type', NotificationChannelType::Slack)->values(), + 'discordChannels' => $channels->where('type', NotificationChannelType::Discord)->values(), 'webhookChannels' => $channels->where('type', NotificationChannelType::Webhook)->values(), ]); } @@ -353,6 +443,36 @@ protected function refreshSlackEdits(): void ->all(); } + /** + * @return array + */ + protected function discordWebhookRules(): array + { + return [ + 'required', + 'string', + 'url', + 'max:500', + 'regex:#^https://(discord\.com|discordapp\.com)/api/webhooks/#', + ]; + } + + protected function refreshDiscordEdits(): void + { + $this->discordEdits = Auth::user() + ->notificationChannels() + ->where('type', NotificationChannelType::Discord->value) + ->get() + ->mapWithKeys(fn (NotificationChannel $channel) => [ + $channel->id => [ + 'label' => (string) $channel->label, + 'webhook_url' => (string) ($channel->config['webhook_url'] ?? ''), + 'is_active' => (bool) $channel->is_active, + ], + ]) + ->all(); + } + /** * @return array */ diff --git a/app/Models/NotificationChannel.php b/app/Models/NotificationChannel.php index ed6ca74..9107aa5 100644 --- a/app/Models/NotificationChannel.php +++ b/app/Models/NotificationChannel.php @@ -93,6 +93,16 @@ public function routeNotificationForSlack(): ?string return is_string($webhookUrl) && $webhookUrl !== '' ? $webhookUrl : null; } + /** + * Route Discord notifications to the incoming webhook URL stored in config. + */ + public function routeNotificationForDiscord(): ?string + { + $webhookUrl = $this->config['webhook_url'] ?? null; + + return is_string($webhookUrl) && $webhookUrl !== '' ? $webhookUrl : null; + } + /** * Route generic webhook notifications to the URL stored in config. */ @@ -122,6 +132,7 @@ public function isConfigured(): bool NotificationChannelType::Email => filled($this->user?->email), NotificationChannelType::Pushover => filled($this->config['user_key'] ?? null), NotificationChannelType::Slack => filled($this->config['webhook_url'] ?? null), + NotificationChannelType::Discord => filled($this->config['webhook_url'] ?? null), NotificationChannelType::Webhook => filled($this->config['url'] ?? null) && filled($this->config['secret'] ?? null), }; } diff --git a/app/Notifications/Channels/DiscordWebhookChannel.php b/app/Notifications/Channels/DiscordWebhookChannel.php new file mode 100644 index 0000000..38fd9f0 --- /dev/null +++ b/app/Notifications/Channels/DiscordWebhookChannel.php @@ -0,0 +1,43 @@ +routeNotificationFor('discord', $notification); + $payload = $notification->toDiscord($notifiable); + + if (! is_string($webhookUrl) || $webhookUrl === '' || ! is_array($payload)) { + return; + } + + $response = Http::asJson() + ->timeout(10) + ->post($webhookUrl, $payload); + + if ($response->failed()) { + Log::warning('Discord webhook delivery failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + } + } +} diff --git a/app/Notifications/MonitorDown.php b/app/Notifications/MonitorDown.php index 727227a..5ece806 100644 --- a/app/Notifications/MonitorDown.php +++ b/app/Notifications/MonitorDown.php @@ -109,6 +109,42 @@ public function toSlack(object $notifiable): array return ['text' => $fallback, 'blocks' => $blocks]; } + /** + * Discord incoming-webhook payload. + * + * @return array + */ + public function toDiscord(object $notifiable): array + { + $dashboardUrl = url("/monitors/{$this->monitor->id}"); + + $fields = []; + if ($this->errorMessage) { + $fields[] = [ + 'name' => 'Error', + 'value' => mb_substr($this->errorMessage, 0, 1024), + 'inline' => false, + ]; + } + + $embed = [ + 'title' => "🔴 {$this->monitor->name} is DOWN", + 'url' => $dashboardUrl, + 'description' => $this->monitor->url, + 'color' => 0xED4245, // Discord red + 'timestamp' => $this->monitor->last_checked_at?->toIso8601String(), + ]; + + if ($fields !== []) { + $embed['fields'] = $fields; + } + + return [ + 'username' => 'EasyMonitor', + 'embeds' => [$embed], + ]; + } + /** * Generic webhook payload — signed and POSTed by WebhookChannel. * diff --git a/app/Notifications/MonitorRecovered.php b/app/Notifications/MonitorRecovered.php index 762972e..2bcfb68 100644 --- a/app/Notifications/MonitorRecovered.php +++ b/app/Notifications/MonitorRecovered.php @@ -94,6 +94,25 @@ public function toSlack(object $notifiable): array return ['text' => $fallback, 'blocks' => $blocks]; } + /** + * Discord incoming-webhook payload. + * + * @return array + */ + public function toDiscord(object $notifiable): array + { + return [ + 'username' => 'EasyMonitor', + 'embeds' => [[ + 'title' => "🟢 {$this->monitor->name} has recovered", + 'url' => url("/monitors/{$this->monitor->id}"), + 'description' => $this->monitor->url, + 'color' => 0x57F287, // Discord green + 'timestamp' => $this->monitor->last_checked_at?->toIso8601String(), + ]], + ]; + } + /** * Generic webhook payload — signed and POSTed by WebhookChannel. * diff --git a/database/factories/NotificationChannelFactory.php b/database/factories/NotificationChannelFactory.php index eb5ed92..18dbff1 100644 --- a/database/factories/NotificationChannelFactory.php +++ b/database/factories/NotificationChannelFactory.php @@ -55,6 +55,17 @@ public function slack(?string $webhookUrl = null, ?string $label = null): static ]); } + public function discord(?string $webhookUrl = null, ?string $label = null): static + { + return $this->state(fn () => [ + 'type' => NotificationChannelType::Discord, + 'label' => $label ?? '#alerts', + 'config' => [ + 'webhook_url' => $webhookUrl ?? 'https://discord.com/api/webhooks/0/aaaaaaaaaaaa', + ], + ]); + } + public function webhook(?string $url = null, ?string $label = null, ?string $secret = null): static { return $this->state(fn () => [ diff --git a/resources/views/livewire/settings/notifications.blade.php b/resources/views/livewire/settings/notifications.blade.php index 43d2994..4f13056 100644 --- a/resources/views/livewire/settings/notifications.blade.php +++ b/resources/views/livewire/settings/notifications.blade.php @@ -38,6 +38,12 @@ class="card bg-base-100 border border-base-300"> @case (\App\Enums\NotificationChannelType::Slack) {{ __('Webhook configured') }} @break + @case (\App\Enums\NotificationChannelType::Discord) + {{ __('Webhook configured') }} + @break + @case (\App\Enums\NotificationChannelType::Webhook) + {{ __('Endpoint configured') }} + @break @endswitch

@@ -196,6 +202,124 @@ class="input input-bordered input-sm rounded-lg font-mono text-xs @error('newSla + +
+
+
+

{{ __('Discord') }}

+

+ {{ __('In your Discord server settings, open') }} + {{ __('Integrations → Webhooks → New Webhook') }}, + {{ __('pick a channel, and copy the URL. Add as many as you need.') }} +

+
+ + @foreach ($discordChannels as $existing) +
+
+
+ + + @error('discordEdits.'.$existing->id.'.label') + {{ $message }} + @enderror +
+
+ + + @error('discordEdits.'.$existing->id.'.webhook_url') + {{ $message }} + @enderror +
+
+ +
+ + +
+ + +
+
+
+ @endforeach + +
+
+ {{ $discordChannels->isEmpty() ? __('Add your first Discord channel') : __('Add another Discord channel') }} +
+
+
+ + + @error('newDiscordLabel') + {{ $message }} + @enderror +
+
+ + + @error('newDiscordWebhookUrl') + {{ $message }} + @enderror +
+
+ +
+ + +
+ + {{ __('Saved.') }} + + +
+
+
+
+
+
diff --git a/tests/Feature/Feature/Settings/NotificationsSettingsTest.php b/tests/Feature/Feature/Settings/NotificationsSettingsTest.php index 07ca455..2b44c4c 100644 --- a/tests/Feature/Feature/Settings/NotificationsSettingsTest.php +++ b/tests/Feature/Feature/Settings/NotificationsSettingsTest.php @@ -193,6 +193,75 @@ expect($user->notificationChannels()->whereKey($existing->id)->exists())->toBeFalse(); }); +test('adding a discord channel creates a new notification channel', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $url = 'https://discord.com/api/webhooks/123/abc'; + + Livewire::test(Notifications::class) + ->set('newDiscordLabel', '#alerts') + ->set('newDiscordWebhookUrl', $url) + ->call('addDiscordChannel') + ->assertHasNoErrors(); + + $discord = $user->notificationChannels() + ->where('type', NotificationChannelType::Discord->value) + ->first(); + + expect($discord)->not->toBeNull(); + expect($discord->label)->toBe('#alerts'); + expect($discord->config['webhook_url'])->toBe($url); +}); + +test('discord webhook url must come from discord.com or discordapp.com', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set('newDiscordLabel', '#alerts') + ->set('newDiscordWebhookUrl', 'https://example.com/webhook') + ->call('addDiscordChannel') + ->assertHasErrors(['newDiscordWebhookUrl']); +}); + +test('discord channel requires a label', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set('newDiscordWebhookUrl', 'https://discord.com/api/webhooks/1/x') + ->call('addDiscordChannel') + ->assertHasErrors(['newDiscordLabel']); +}); + +test('a discord channel can be updated in place', function () { + $user = User::factory()->create(); + $existing = NotificationChannel::factory()->for($user)->discord(label: '#alerts')->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->set("discordEdits.{$existing->id}.label", '#renamed') + ->set("discordEdits.{$existing->id}.webhook_url", 'https://discordapp.com/api/webhooks/9/zzz') + ->call('saveDiscordChannel', $existing->id) + ->assertHasNoErrors(); + + $existing->refresh(); + expect($existing->label)->toBe('#renamed'); + expect($existing->config['webhook_url'])->toBe('https://discordapp.com/api/webhooks/9/zzz'); +}); + +test('a discord channel can be deleted', function () { + $user = User::factory()->create(); + $existing = NotificationChannel::factory()->for($user)->discord()->create(); + $this->actingAs($user); + + Livewire::test(Notifications::class) + ->call('deleteDiscordChannel', $existing->id); + + expect($user->notificationChannels()->whereKey($existing->id)->exists())->toBeFalse(); +}); + test('adding a webhook channel generates a signing secret', function () { $user = User::factory()->create(); $this->actingAs($user); diff --git a/tests/Unit/Notifications/DiscordWebhookChannelTest.php b/tests/Unit/Notifications/DiscordWebhookChannelTest.php new file mode 100644 index 0000000..83a27b5 --- /dev/null +++ b/tests/Unit/Notifications/DiscordWebhookChannelTest.php @@ -0,0 +1,74 @@ +create(); + $channel = NotificationChannel::factory() + ->for($user) + ->discord('https://discord.com/api/webhooks/123/secrettoken') + ->create(); + $monitor = Monitor::factory()->for($user)->create(['name' => 'API', 'url' => 'https://api.example.com']); + + NotificationFacade::sendNow([$channel], new MonitorDown($monitor, 'connection refused')); + + Http::assertSent(function ($request) { + $body = $request->data(); + $embed = $body['embeds'][0] ?? []; + + return $request->url() === 'https://discord.com/api/webhooks/123/secrettoken' + && ($body['username'] ?? null) === 'EasyMonitor' + && str_contains($embed['title'] ?? '', 'API') + && str_contains($embed['title'] ?? '', 'DOWN') + && ($embed['color'] ?? null) === 0xED4245 + && collect($embed['fields'] ?? [])->contains(fn ($f) => str_contains($f['value'] ?? '', 'connection refused')); + }); +}); + +test('MonitorRecovered posts a green Discord embed', function () { + Http::fake(); + + $user = User::factory()->create(); + $channel = NotificationChannel::factory() + ->for($user) + ->discord('https://discord.com/api/webhooks/123/secrettoken') + ->create(); + $monitor = Monitor::factory()->for($user)->create(['name' => 'API']); + + NotificationFacade::sendNow([$channel], new MonitorRecovered($monitor)); + + Http::assertSent(function ($request) { + $embed = $request->data()['embeds'][0] ?? []; + + return ($embed['color'] ?? null) === 0x57F287 + && str_contains($embed['title'] ?? '', 'recovered'); + }); +}); + +test('a discord channel with no webhook url is skipped', function () { + Http::fake(); + + $user = User::factory()->create(); + $channel = NotificationChannel::factory()->for($user)->discord()->create(); + $channel->update(['config' => []]); + $channel->refresh(); + + $monitor = Monitor::factory()->for($user)->create(); + + NotificationFacade::sendNow([$channel], new MonitorRecovered($monitor)); + + Http::assertNothingSent(); +});