diff --git a/app/Actions/Notifications/SendAppriseTestNotification.php b/app/Actions/Notifications/SendAppriseTestNotification.php new file mode 100644 index 000000000..062202b06 --- /dev/null +++ b/app/Actions/Notifications/SendAppriseTestNotification.php @@ -0,0 +1,45 @@ +title('You need to add Apprise channel URLs!') + ->warning() + ->send(); + + return; + } + + foreach ($channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Notification::make() + ->title('Skipping missing channel URL!') + ->warning() + ->send(); + + continue; + } + + FacadesNotification::route('apprise_urls', $channelUrl) + ->notify(new TestNotification); + } + + Notification::make() + ->title('Test Apprise notification sent.') + ->success() + ->send(); + } +} diff --git a/app/Filament/Pages/Settings/Notification.php b/app/Filament/Pages/Settings/Notification.php index 915225265..f2fb0f2da 100755 --- a/app/Filament/Pages/Settings/Notification.php +++ b/app/Filament/Pages/Settings/Notification.php @@ -2,6 +2,7 @@ namespace App\Filament\Pages\Settings; +use App\Actions\Notifications\SendAppriseTestNotification; use App\Actions\Notifications\SendDatabaseTestNotification; use App\Actions\Notifications\SendDiscordTestNotification; use App\Actions\Notifications\SendGotifyTestNotification; @@ -12,6 +13,7 @@ use App\Actions\Notifications\SendSlackTestNotification; use App\Actions\Notifications\SendTelegramTestNotification; use App\Actions\Notifications\SendWebhookTestNotification; +use App\Rules\AppriseScheme; use App\Settings\NotificationSettings; use CodeWithDennis\SimpleAlert\Components\SimpleAlert; use Filament\Actions\Action; @@ -199,6 +201,80 @@ public function form(Schema $schema): Schema // ... ]), + Tab::make(__('settings/notifications.apprise')) + ->icon(Heroicon::CloudArrowUp) + ->schema([ + SimpleAlert::make('wehbook_info') + ->title(__('general.documentation')) + ->description(__('settings/notifications.apprise_hint_description')) + ->border() + ->info() + ->actions([ + Action::make('webhook_docs') + ->label(__('general.view_documentation')) + ->icon('heroicon-m-arrow-long-right') + ->color('info') + ->link() + ->url('https://docs.speedtest-tracker.dev/settings/notifications/apprise') + ->openUrlInNewTab(), + ]) + ->columnSpanFull(), + + Toggle::make('apprise_enabled') + ->label(__('settings/notifications.enable_apprise_notifications')) + ->reactive() + ->columnSpanFull(), + Grid::make([ + 'default' => 1, + ]) + ->hidden(fn (Get $get) => $get('apprise_enabled') !== true) + ->schema([ + Fieldset::make(__('settings/notifications.apprise_server')) + ->schema([ + TextInput::make('apprise_server_url') + ->label(__('settings/notifications.apprise_server_url')) + ->placeholder('http://localhost:8000') + ->maxLength(2000) + ->required() + ->url() + ->columnSpanFull(), + Checkbox::make('apprise_verify_ssl') + ->label(__('settings/notifications.apprise_verify_ssl')) + ->default(true) + ->columnSpanFull(), + ]), + Fieldset::make(__('settings.triggers')) + ->schema([ + Checkbox::make('apprise_on_speedtest_run') + ->label(__('settings/notifications.notify_on_every_speedtest_run')) + ->columnSpanFull(), + Checkbox::make('apprise_on_threshold_failure') + ->label(__('settings/notifications.notify_on_threshold_failures')) + ->columnSpanFull(), + ]), + Repeater::make('apprise_channel_urls') + ->label(__('settings/notifications.apprise_channels')) + ->schema([ + TextInput::make('channel_url') + ->label(__('settings/notifications.apprise_channel_url')) + ->placeholder('discord://WebhookID/WebhookToken') + ->helperText(__('settings/notifications.apprise_channel_url_helper')) + ->maxLength(2000) + ->distinct() + ->required() + ->rule(new AppriseScheme), + ]) + ->columnSpanFull(), + Actions::make([ + Action::make('test apprise') + ->label(__('settings/notifications.test_apprise_channel')) + ->action(fn (Get $get) => SendAppriseTestNotification::run( + channel_urls: $get('apprise_channel_urls'), + )) + ->hidden(fn (Get $get) => ! count($get('apprise_channel_urls'))), + ]), + ]), + ]), ]) ->columnSpanFull(), diff --git a/app/Listeners/ProcessCompletedSpeedtest.php b/app/Listeners/ProcessCompletedSpeedtest.php index 228151750..66b927f1a 100644 --- a/app/Listeners/ProcessCompletedSpeedtest.php +++ b/app/Listeners/ProcessCompletedSpeedtest.php @@ -3,15 +3,19 @@ namespace App\Listeners; use App\Events\SpeedtestCompleted; +use App\Helpers\Number; use App\Mail\CompletedSpeedtestMail; use App\Models\Result; use App\Models\User; +use App\Notifications\Apprise\SpeedtestNotification; use App\Settings\NotificationSettings; use Filament\Actions\Action; -use Filament\Notifications\Notification; +use Filament\Notifications\Notification as FilamentNotification; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Str; use Spatie\WebhookServer\WebhookCall; class ProcessCompletedSpeedtest @@ -29,7 +33,7 @@ public function handle(SpeedtestCompleted $event): void $result->loadMissing(['dispatchedBy']); - // $this->notifyAppriseChannels($result); + $this->notifyAppriseChannels($result); $this->notifyDatabaseChannels($result); $this->notifyDispatchingUser($result); $this->notifyMailChannels($result); @@ -42,11 +46,50 @@ public function handle(SpeedtestCompleted $event): void private function notifyAppriseChannels(Result $result): void { // Don't send Apprise notification if dispatched by a user or test is unhealthy. - if (filled($result->dispatched_by) || ! $result->healthy) { + if (filled($result->dispatched_by) || $result->healthy === false) { return; } - // + // Check if Apprise notifications are enabled. + if (! $this->notificationSettings->apprise_enabled || ! $this->notificationSettings->apprise_on_speedtest_run) { + return; + } + + if (! count($this->notificationSettings->apprise_channel_urls)) { + Log::warning('Apprise channel URLs not found, check Apprise notification channel settings.'); + + return; + } + + // Build the speedtest data + $body = view('apprise.speedtest-completed', [ + 'id' => $result->id, + 'service' => Str::title($result->service->getLabel()), + 'serverName' => $result->server_name, + 'serverId' => $result->server_id, + 'isp' => $result->isp, + 'ping' => round($result->ping).' ms', + 'download' => Number::toBitRate(bits: $result->download_bits, precision: 2), + 'upload' => Number::toBitRate(bits: $result->upload_bits, precision: 2), + 'packetLoss' => $result->packet_loss, + 'speedtest_url' => $result->result_url, + 'url' => url('/admin/results'), + ])->render(); + + $title = 'Speedtest Completed – #'.$result->id; + + // Send notification to each configured channel URL + foreach ($this->notificationSettings->apprise_channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Log::warning('Skipping entry with missing channel_url.'); + + continue; + } + + Notification::route('apprise_urls', $channelUrl) + ->notify(new SpeedtestNotification($title, $body, 'info')); + } } /** @@ -65,7 +108,7 @@ private function notifyDatabaseChannels(Result $result): void } foreach (User::all() as $user) { - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_completed')) ->actions([ Action::make('view') @@ -87,7 +130,7 @@ private function notifyDispatchingUser(Result $result): void } $result->dispatchedBy->notify( - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_completed')) ->actions([ Action::make('view') diff --git a/app/Listeners/ProcessUnhealthySpeedtest.php b/app/Listeners/ProcessUnhealthySpeedtest.php index 5a6d0e057..68b9b6a9b 100644 --- a/app/Listeners/ProcessUnhealthySpeedtest.php +++ b/app/Listeners/ProcessUnhealthySpeedtest.php @@ -3,14 +3,18 @@ namespace App\Listeners; use App\Events\SpeedtestBenchmarkFailed; +use App\Helpers\Number; use App\Mail\UnhealthySpeedtestMail; use App\Models\Result; use App\Models\User; +use App\Notifications\Apprise\SpeedtestNotification; use App\Settings\NotificationSettings; use Filament\Actions\Action; -use Filament\Notifications\Notification; +use Filament\Notifications\Notification as FilamentNotification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Str; use Spatie\WebhookServer\WebhookCall; class ProcessUnhealthySpeedtest @@ -31,7 +35,7 @@ public function handle(SpeedtestBenchmarkFailed $event): void $result->loadMissing(['dispatchedBy']); - // $this->notifyAppriseChannels($result); + $this->notifyAppriseChannels($result); $this->notifyDatabaseChannels($result); $this->notifyDispatchingUser($result); $this->notifyMailChannels($result); @@ -48,7 +52,79 @@ private function notifyAppriseChannels(Result $result): void return; } - // + if (! $this->notificationSettings->apprise_enabled || ! $this->notificationSettings->apprise_on_threshold_failure) { + return; + } + + if (! count($this->notificationSettings->apprise_channel_urls)) { + Log::warning('Apprise channel URLs not found, check Apprise notification channel settings.'); + + return; + } + + if (empty($result->benchmarks)) { + Log::warning('Benchmark data not found, won\'t send Apprise notification.'); + + return; + } + + // Build metrics array from failed benchmarks + $failed = []; + + foreach ($result->benchmarks as $metric => $benchmark) { + if ($benchmark['passed'] === false) { + $failed[] = [ + 'name' => ucfirst($metric), + 'threshold' => $benchmark['value'].' '.$benchmark['unit'], + 'value' => $this->formatMetricValue($metric, $result), + ]; + } + } + + if (! count($failed)) { + Log::warning('No failed thresholds found in benchmarks, won\'t send Apprise notification.'); + + return; + } + + $body = view('apprise.speedtest-threshold', [ + 'id' => $result->id, + 'service' => Str::title($result->service->getLabel()), + 'serverName' => $result->server_name, + 'serverId' => $result->server_id, + 'isp' => $result->isp, + 'metrics' => $failed, + 'speedtest_url' => $result->result_url, + 'url' => url('/admin/results'), + ])->render(); + + $title = 'Speedtest Threshold Breach – #'.$result->id; + + // Send notification to each configured channel URL + foreach ($this->notificationSettings->apprise_channel_urls as $row) { + $channelUrl = $row['channel_url'] ?? null; + if (! $channelUrl) { + Log::warning('Skipping entry with missing channel_url.'); + + continue; + } + + Notification::route('apprise_urls', $channelUrl) + ->notify(new SpeedtestNotification($title, $body, 'warning')); + } + } + + /** + * Format metric value for display in notification. + */ + private function formatMetricValue(string $metric, Result $result): string + { + return match ($metric) { + 'download' => Number::toBitRate(bits: $result->download_bits, precision: 2), + 'upload' => Number::toBitRate(bits: $result->upload_bits, precision: 2), + 'ping' => round($result->ping, 2).' ms', + default => '', + }; } /** @@ -67,7 +143,7 @@ private function notifyDatabaseChannels(Result $result): void } foreach (User::all() as $user) { - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_benchmark_failed')) ->actions([ Action::make('view') @@ -89,7 +165,7 @@ private function notifyDispatchingUser(Result $result): void } $result->dispatchedBy->notify( - Notification::make() + FilamentNotification::make() ->title(__('results.speedtest_benchmark_failed')) ->actions([ Action::make('view') @@ -106,7 +182,7 @@ private function notifyDispatchingUser(Result $result): void */ private function notifyMailChannels(Result $result): void { - // Don't send webhook if dispatched by a user. + // Don't send mail if dispatched by a user. if (filled($result->dispatched_by)) { return; } diff --git a/app/Notifications/Apprise/AppriseMessage.php b/app/Notifications/Apprise/AppriseMessage.php new file mode 100644 index 000000000..a510ded7b --- /dev/null +++ b/app/Notifications/Apprise/AppriseMessage.php @@ -0,0 +1,66 @@ +urls = $urls; + + return $this; + } + + public function title(string $title): self + { + $this->title = $title; + + return $this; + } + + public function body(string $body): self + { + $this->body = $body; + + return $this; + } + + public function type(string $type): self + { + $this->type = $type; + + return $this; + } + + public function format(string $format): self + { + $this->format = $format; + + return $this; + } + + public function tag(string $tag): self + { + $this->tag = $tag; + + return $this; + } +} diff --git a/app/Notifications/Apprise/SpeedtestNotification.php b/app/Notifications/Apprise/SpeedtestNotification.php new file mode 100644 index 000000000..3c2ffb3cd --- /dev/null +++ b/app/Notifications/Apprise/SpeedtestNotification.php @@ -0,0 +1,40 @@ + + */ + public function via(object $notifiable): array + { + return ['apprise']; + } + + /** + * Get the Apprise message representation of the notification. + */ + public function toApprise(object $notifiable): AppriseMessage + { + return AppriseMessage::create() + ->urls($notifiable->routes['apprise_urls']) + ->title($this->title) + ->body($this->body) + ->type($this->type); + } +} diff --git a/app/Notifications/Apprise/TestNotification.php b/app/Notifications/Apprise/TestNotification.php new file mode 100644 index 000000000..f07810fcc --- /dev/null +++ b/app/Notifications/Apprise/TestNotification.php @@ -0,0 +1,34 @@ + + */ + public function via(object $notifiable): array + { + return ['apprise']; + } + + /** + * Get the Apprise message representation of the notification. + */ + public function toApprise(object $notifiable): AppriseMessage + { + return AppriseMessage::create() + ->urls($notifiable->routes['apprise_urls']) + ->title('Test Notification') + ->body('👋 Testing the Apprise notification channel.') + ->type('info'); + } +} diff --git a/app/Notifications/AppriseChannel.php b/app/Notifications/AppriseChannel.php index af5ac3683..3cd2592a1 100644 --- a/app/Notifications/AppriseChannel.php +++ b/app/Notifications/AppriseChannel.php @@ -2,6 +2,7 @@ namespace App\Notifications; +use App\Settings\NotificationSettings; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -20,34 +21,54 @@ public function send(object $notifiable, Notification $notification): void return; } - $appriseUrl = config('services.apprise.url'); + $settings = app(NotificationSettings::class); + $appriseUrl = rtrim($settings->apprise_server_url ?? '', '/'); + + if (empty($appriseUrl)) { + Log::warning('Apprise notification skipped: No Server URL configured'); + + return; + } try { - $response = Http::timeout(5) + $request = Http::timeout(5) ->withHeaders([ 'Content-Type' => 'application/json', - ]) - // ->when(true, function ($http) { - // $http->withoutVerifying(); - // }) - ->post("{$appriseUrl}/notify", [ - 'urls' => $message->urls, - 'title' => $message->title, - 'body' => $message->body, - 'type' => $message->type ?? 'info', - 'format' => $message->format ?? 'text', - 'tag' => $message->tag ?? null, ]); + // If SSL verification is disabled in settings, skip it + if (! $settings->apprise_verify_ssl) { + $request = $request->withoutVerifying(); + } + + $response = $request->post("{$appriseUrl}/notify", [ + 'urls' => $message->urls, + 'title' => $message->title, + 'body' => $message->body, + 'type' => $message->type ?? 'info', + 'format' => $message->format ?? 'text', + 'tag' => $message->tag ?? null, + ]); + if ($response->failed()) { Log::error('Apprise notification failed', [ + 'channel' => $message->urls, + 'instance' => $appriseUrl, 'status' => $response->status(), 'body' => $response->body(), ]); + } else { + Log::info('Apprise notification sent', [ + 'channel' => $message->urls, + 'instance' => $appriseUrl, + ]); } - } catch (\Exception $e) { + } catch (\Throwable $e) { Log::error('Apprise notification exception', [ + 'channel' => $message->urls ?? 'unknown', + 'instance' => $appriseUrl, 'message' => $e->getMessage(), + 'exception' => get_class($e), ]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e4ea6209f..ba434c79e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,11 +4,14 @@ use App\Enums\UserRole; use App\Models\User; +use App\Notifications\AppriseChannel; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Http\Request; +use Illuminate\Notifications\ChannelManager; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; @@ -44,12 +47,25 @@ public function boot(): void $this->defineGates(); $this->forceHttps(); $this->setApiRateLimit(); + $this->registerNotificationChannels(); AboutCommand::add('Speedtest Tracker', fn () => [ 'Version' => config('speedtest.build_version'), ]); } + /** + * Register custom notification channels. + */ + protected function registerNotificationChannels(): void + { + Notification::resolved(function (ChannelManager $service) { + $service->extend('apprise', function ($app) { + return new AppriseChannel; + }); + }); + } + /** * Define custom if statements, these were added to make the blade templates more readable. * diff --git a/app/Rules/AppriseScheme.php b/app/Rules/AppriseScheme.php new file mode 100644 index 000000000..03a50059e --- /dev/null +++ b/app/Rules/AppriseScheme.php @@ -0,0 +1,22 @@ + [ - 'url' => env('APPRISE_URL', 'http://apprise:8000'), - ], - 'telegram-bot-api' => [ 'token' => env('TELEGRAM_BOT_TOKEN'), ], diff --git a/database/settings/2024_12_31_164343_create_apprise_notification.php b/database/settings/2024_12_31_164343_create_apprise_notification.php new file mode 100644 index 000000000..1be9ac906 --- /dev/null +++ b/database/settings/2024_12_31_164343_create_apprise_notification.php @@ -0,0 +1,16 @@ +migrator->add('notification.apprise_enabled', false); + $this->migrator->add('notification.apprise_server_url', null); + $this->migrator->add('notification.apprise_on_speedtest_run', false); + $this->migrator->add('notification.apprise_on_threshold_failure', false); + $this->migrator->add('notification.apprise_verify_ssl', true); + $this->migrator->add('notification.apprise_channel_urls', null); + } +}; diff --git a/lang/en/settings/notifications.php b/lang/en/settings/notifications.php index ddc1baae8..203590b23 100644 --- a/lang/en/settings/notifications.php +++ b/lang/en/settings/notifications.php @@ -14,6 +14,19 @@ 'recipients' => 'Recipients', 'test_mail_channel' => 'Test mail channel', + // Apprise notifications + 'apprise' => 'Apprise', + 'enable_apprise_notifications' => 'Enable Apprise notifications', + 'apprise_server' => 'Apprise Server', + 'apprise_server_url' => 'Apprise Server URL', + 'apprise_verify_ssl' => 'Verify SSL', + 'apprise_channels' => 'Apprise Channels', + 'apprise_channel_url' => 'Channel URL', + 'apprise_hint_description' => 'For more information on setting up Apprise, view the documentation.', + 'apprise_channel_url_helper' => 'Provide the service endpoint URL for notifications.', + 'test_apprise_channel' => 'Test Apprise', + 'apprise_channel_url_validation_error' => 'The Apprise channel URL must not start with "http" or "https". Please provide a valid Apprise URL scheme.', + // Webhook 'webhook' => 'Webhook', 'webhooks' => 'Webhooks', diff --git a/resources/views/apprise/speedtest-completed.blade.php b/resources/views/apprise/speedtest-completed.blade.php new file mode 100644 index 000000000..6363ee642 --- /dev/null +++ b/resources/views/apprise/speedtest-completed.blade.php @@ -0,0 +1,11 @@ +A new speedtest on {{ config('app.name') }} was completed using {{ $service }}. + +Server name: {{ $serverName }} +Server ID: {{ $serverId }} +ISP: {{ $isp }} +Ping: {{ $ping }} +Download: {{ $download }} +Upload: {{ $upload }} +Packet Loss: {{ $packetLoss }} % +Ookla Speedtest: {{ $speedtest_url }} +URL: {{ $url }} diff --git a/resources/views/apprise/speedtest-threshold.blade.php b/resources/views/apprise/speedtest-threshold.blade.php new file mode 100644 index 000000000..6d0bb4926 --- /dev/null +++ b/resources/views/apprise/speedtest-threshold.blade.php @@ -0,0 +1,7 @@ +A new speedtest on **{{ config('app.name') }}** was completed using **{{ $service }}** on **{{ $isp }}** but a threshold was breached. + +@foreach ($metrics as $item) +- **{{ $item['name'] }}** {{ $item['threshold'] }}: {{ $item['value'] }} +@endforeach +- **Ookla Speedtest:** {{ $speedtest_url }} +- **URL:** {{ $url }}