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)
+
+ @endforeach
+
+
+
+
+
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();
+});