From bbfc64031f905c8263596b63644a04496f428151 Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 3 Jun 2026 23:19:06 +0200 Subject: [PATCH] Add page visit Telegram notifications --- app/Http/Controllers/FrontendController.php | 14 ++ app/Http/Requests/PageVisitRequest.php | 43 ++++++ .../NotifyTelegramAboutContactMessage.php | 35 ++--- app/Jobs/NotifyTelegramAboutPageVisit.php | 30 ++++ .../NotifyTelegramAboutPersonaliaClick.php | 23 +-- app/Services/TelegramNotificationService.php | 146 ++++++++++++++++++ resources/views/welcome.blade.php | 56 +++++++ routes/web.php | 1 + .../Controllers/FrontendControllerTest.php | 53 +++++++ .../TelegramNotificationServiceTest.php | 78 ++++++++++ 10 files changed, 434 insertions(+), 45 deletions(-) create mode 100644 app/Http/Requests/PageVisitRequest.php create mode 100644 app/Jobs/NotifyTelegramAboutPageVisit.php create mode 100644 app/Services/TelegramNotificationService.php create mode 100644 tests/Feature/TelegramNotificationServiceTest.php diff --git a/app/Http/Controllers/FrontendController.php b/app/Http/Controllers/FrontendController.php index 6716f00..5f2eb40 100644 --- a/app/Http/Controllers/FrontendController.php +++ b/app/Http/Controllers/FrontendController.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use App\Http\Requests\PageVisitRequest; use App\Jobs\NotifyTelegramAboutContactMessage; +use App\Jobs\NotifyTelegramAboutPageVisit; use App\Jobs\NotifyTelegramAboutPersonaliaClick; use App\Models\Education; use App\Models\Personalia; @@ -58,4 +60,16 @@ class FrontendController extends Controller return response()->json(['status' => 'success']); } + + public function pageVisit(PageVisitRequest $request): JsonResponse + { + NotifyTelegramAboutPageVisit::dispatch( + $request->validated(), + $request->ip(), + $request->userAgent(), + $request->header('Accept-Language') + ); + + return response()->json(['status' => 'queued']); + } } diff --git a/app/Http/Requests/PageVisitRequest.php b/app/Http/Requests/PageVisitRequest.php new file mode 100644 index 0000000..a9da34b --- /dev/null +++ b/app/Http/Requests/PageVisitRequest.php @@ -0,0 +1,43 @@ +> + */ + public function rules(): array + { + return [ + 'visitor_id' => ['required', 'string', 'max:100'], + 'url' => ['required', 'url', 'max:2048'], + 'path' => ['nullable', 'string', 'max:2048'], + 'title' => ['nullable', 'string', 'max:255'], + 'referrer' => ['nullable', 'string', 'max:2048'], + 'language' => ['nullable', 'string', 'max:50'], + 'timezone' => ['nullable', 'string', 'max:100'], + 'screen' => ['nullable', 'array'], + 'screen.width' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'screen.height' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'viewport' => ['nullable', 'array'], + 'viewport.width' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'viewport.height' => ['nullable', 'integer', 'min:0', 'max:100000'], + 'device_pixel_ratio' => ['nullable', 'numeric', 'min:0', 'max:100'], + 'color_depth' => ['nullable', 'integer', 'min:0', 'max:1000'], + 'platform' => ['nullable', 'string', 'max:255'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'hardware_concurrency' => ['nullable', 'integer', 'min:0', 'max:1024'], + 'device_memory' => ['nullable', 'numeric', 'min:0', 'max:1024'], + 'cookies_enabled' => ['nullable', 'boolean'], + 'online' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Jobs/NotifyTelegramAboutContactMessage.php b/app/Jobs/NotifyTelegramAboutContactMessage.php index 45caff9..20988ee 100644 --- a/app/Jobs/NotifyTelegramAboutContactMessage.php +++ b/app/Jobs/NotifyTelegramAboutContactMessage.php @@ -2,12 +2,12 @@ namespace App\Jobs; +use App\Services\TelegramNotificationService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Http; class NotifyTelegramAboutContactMessage implements ShouldQueue { @@ -35,30 +35,15 @@ class NotifyTelegramAboutContactMessage implements ShouldQueue $this->phone = $phone; } - public function handle(): void + public function handle(TelegramNotificationService $telegram): void { - $email = $this->email ?? '–'; - $phone = $this->phone ?? '–'; - - $text = <<name}* - -💬 Bericht: -{$this->message} -📧 Email: {$email} -📱 Telefoon: {$phone} -🌐 IP: {$this->ip} -🧭 User Agent: `{$this->userAgent}` - -🕒 Tijdstip: *{now()->format('d-m-Y H:i')}* -TEXT; - - Http::post('https://api.telegram.org/bot'.config('services.telegram.bot_token').'/sendMessage', [ - 'chat_id' => config('services.telegram.chat_id'), - 'text' => $text, - 'parse_mode' => 'Markdown', - ]); + $telegram->notifyContactMessage( + $this->name, + $this->message, + $this->ip, + $this->userAgent, + $this->email, + $this->phone + ); } } diff --git a/app/Jobs/NotifyTelegramAboutPageVisit.php b/app/Jobs/NotifyTelegramAboutPageVisit.php new file mode 100644 index 0000000..8e0ae96 --- /dev/null +++ b/app/Jobs/NotifyTelegramAboutPageVisit.php @@ -0,0 +1,30 @@ + $visit + */ + public function __construct( + protected array $visit, + protected ?string $ip, + protected ?string $userAgent, + protected ?string $acceptLanguage + ) {} + + public function handle(TelegramNotificationService $telegram): void + { + $telegram->notifyPageVisit($this->visit, $this->ip, $this->userAgent, $this->acceptLanguage); + } +} diff --git a/app/Jobs/NotifyTelegramAboutPersonaliaClick.php b/app/Jobs/NotifyTelegramAboutPersonaliaClick.php index e4dc5f5..4b8e18f 100644 --- a/app/Jobs/NotifyTelegramAboutPersonaliaClick.php +++ b/app/Jobs/NotifyTelegramAboutPersonaliaClick.php @@ -3,12 +3,12 @@ namespace App\Jobs; use App\Models\Personalia; +use App\Services\TelegramNotificationService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Http; class NotifyTelegramAboutPersonaliaClick implements ShouldQueue { @@ -27,25 +27,8 @@ class NotifyTelegramAboutPersonaliaClick implements ShouldQueue $this->userAgent = $userAgent; } - public function handle(): void + public function handle(TelegramNotificationService $telegram): void { - $ip = $this->ip ?? '–'; - $userAgent = $this->userAgent ?? '–'; - - $message = <<personalia->value} -IP: {$ip} -User Agent: `{$userAgent}` - -📅 Tijdstip: *{now()->format('d-m-Y H:i')}* -TEXT; - - Http::post('https://api.telegram.org/bot'.config('services.telegram.bot_token').'/sendMessage', [ - 'chat_id' => config('services.telegram.chat_id'), - 'text' => $message, - 'parse_mode' => 'Markdown', - ]); + $telegram->notifyPersonaliaClick($this->personalia, $this->ip, $this->userAgent); } } diff --git a/app/Services/TelegramNotificationService.php b/app/Services/TelegramNotificationService.php new file mode 100644 index 0000000..c7f9204 --- /dev/null +++ b/app/Services/TelegramNotificationService.php @@ -0,0 +1,146 @@ +format('d-m-Y H:i')} +TEXT; + + $this->send($text); + } + + public function notifyPersonaliaClick(Personalia $personalia, ?string $ip, ?string $userAgent): void + { + $ip ??= '-'; + $userAgent ??= '-'; + + $message = <<value} +IP: {$ip} +User Agent: {$userAgent} + +Tijdstip: {now()->format('d-m-Y H:i')} +TEXT; + + $this->send($message); + } + + /** + * @param array $visit + */ + public function notifyPageVisit(array $visit, ?string $ip, ?string $userAgent, ?string $acceptLanguage): void + { + $screen = $this->formatDimensions($visit['screen'] ?? null); + $viewport = $this->formatDimensions($visit['viewport'] ?? null); + + $message = <<value($visit['url'] ?? null)} +Pad: {$this->value($visit['path'] ?? null)} +Titel: {$this->value($visit['title'] ?? null)} +Referrer: {$this->value($visit['referrer'] ?? null)} + +Visitor ID: {$this->value($visit['visitor_id'] ?? null)} +IP: {$this->value($ip)} +User Agent: {$this->value($userAgent)} +Accept-Language: {$this->value($acceptLanguage)} +Browser taal: {$this->value($visit['language'] ?? null)} +Timezone: {$this->value($visit['timezone'] ?? null)} +Platform: {$this->value($visit['platform'] ?? null)} +Vendor: {$this->value($visit['vendor'] ?? null)} +Scherm: {$screen} +Viewport: {$viewport} +DPR: {$this->value($visit['device_pixel_ratio'] ?? null)} +Color depth: {$this->value($visit['color_depth'] ?? null)} +CPU cores: {$this->value($visit['hardware_concurrency'] ?? null)} +Device memory: {$this->value($visit['device_memory'] ?? null)} +Cookies: {$this->boolean($visit['cookies_enabled'] ?? null)} +Online: {$this->boolean($visit['online'] ?? null)} + +Tijdstip: {now()->format('d-m-Y H:i')} +TEXT; + + $this->send($message); + } + + protected function send(string $text): void + { + $botToken = config('services.telegram.bot_token'); + $chatId = config('services.telegram.chat_id'); + + if (! is_string($botToken) || $botToken === '' || ! is_string($chatId) || $chatId === '') { + return; + } + + Http::post("https://api.telegram.org/bot{$botToken}/sendMessage", [ + 'chat_id' => $chatId, + 'text' => $text, + ]); + } + + protected function value(mixed $value): string + { + if (is_bool($value)) { + return $this->boolean($value); + } + + if (is_scalar($value)) { + $value = trim((string) $value); + + return $value !== '' ? $value : '-'; + } + + return '-'; + } + + protected function boolean(mixed $value): string + { + return match ($value) { + true => 'ja', + false => 'nee', + default => '-', + }; + } + + protected function formatDimensions(mixed $dimensions): string + { + if (! is_array($dimensions)) { + return '-'; + } + + $width = $this->value($dimensions['width'] ?? null); + $height = $this->value($dimensions['height'] ?? null); + + return "{$width}x{$height}"; + } +} diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 58a01dc..459b6df 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -170,6 +170,62 @@ }); + + @stack('scripts') diff --git a/routes/web.php b/routes/web.php index b86170f..45762bb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ Route::get('/dashboard', function (): \Illuminate\View\View { })->middleware(['auth', 'verified'])->name('dashboard'); Route::get('/getPersonalia/{personalia}', [FrontendController::class, 'getPersonalia'])->name('personalia'); Route::post('/contact', [FrontendController::class, 'message'])->name('contact'); +Route::post('/page-visits', [FrontendController::class, 'pageVisit'])->name('page-visits.store'); Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); diff --git a/tests/Feature/Controllers/FrontendControllerTest.php b/tests/Feature/Controllers/FrontendControllerTest.php index 355568e..12d1c44 100644 --- a/tests/Feature/Controllers/FrontendControllerTest.php +++ b/tests/Feature/Controllers/FrontendControllerTest.php @@ -1,6 +1,7 @@ withHeaders([ + 'User-Agent' => 'Pest Browser', + 'Accept-Language' => 'nl-NL,nl;q=0.9', + ])->postJson(route('page-visits.store'), [ + 'visitor_id' => 'visitor-123', + 'url' => 'https://cv.robert.ooo/', + 'path' => '/', + 'title' => 'CV Roberto', + 'referrer' => 'https://example.com', + 'language' => 'nl-NL', + 'timezone' => 'Europe/Amsterdam', + 'screen' => [ + 'width' => 1920, + 'height' => 1080, + ], + 'viewport' => [ + 'width' => 1440, + 'height' => 900, + ], + 'device_pixel_ratio' => 1, + 'color_depth' => 24, + 'platform' => 'Linux x86_64', + 'vendor' => 'Google Inc.', + 'hardware_concurrency' => 8, + 'device_memory' => 8, + 'cookies_enabled' => true, + 'online' => true, + ]) + ->assertOk() + ->assertJson([ + 'status' => 'queued', + ]); + + Queue::assertPushed(NotifyTelegramAboutPageVisit::class); +}); + +test('a page visit requires a visitor id and valid url', function () { + Queue::fake(); + + $this->postJson(route('page-visits.store'), [ + 'visitor_id' => '', + 'url' => 'not-a-url', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['visitor_id', 'url']); + + Queue::assertNotPushed(NotifyTelegramAboutPageVisit::class); +}); diff --git a/tests/Feature/TelegramNotificationServiceTest.php b/tests/Feature/TelegramNotificationServiceTest.php new file mode 100644 index 0000000..5d8c6b3 --- /dev/null +++ b/tests/Feature/TelegramNotificationServiceTest.php @@ -0,0 +1,78 @@ + 'telegram-token', + 'services.telegram.chat_id' => 'telegram-chat', + ]); + + Http::fake(); + + app(TelegramNotificationService::class)->notifyContactMessage( + name: 'Roberto', + message: 'Hoi, ik wil graag contact opnemen.', + ip: '127.0.0.1', + userAgent: 'Pest Browser', + email: 'roberto@example.com', + phone: '+31612345678' + ); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.telegram.org/bottelegram-token/sendMessage' + && $request['chat_id'] === 'telegram-chat' + && str_contains($request['text'], 'Nieuw contactbericht ontvangen') + && str_contains($request['text'], 'roberto@example.com')); +}); + +test('telegram service sends a personalia click notification', function () { + config([ + 'services.telegram.bot_token' => 'telegram-token', + 'services.telegram.chat_id' => 'telegram-chat', + ]); + + Http::fake(); + + $personalia = Personalia::factory()->create([ + 'value' => 'roberto@example.com', + ]); + + app(TelegramNotificationService::class)->notifyPersonaliaClick($personalia, '127.0.0.1', 'Pest Browser'); + + Http::assertSent(fn ($request) => str_contains($request['text'], 'Persoonlijke gegevens bekeken') + && str_contains($request['text'], 'roberto@example.com')); +}); + +test('telegram service sends a page visit notification', function () { + config([ + 'services.telegram.bot_token' => 'telegram-token', + 'services.telegram.chat_id' => 'telegram-chat', + ]); + + Http::fake(); + + app(TelegramNotificationService::class)->notifyPageVisit([ + 'visitor_id' => 'visitor-123', + 'url' => 'https://cv.robert.ooo/', + 'path' => '/', + 'title' => 'CV Roberto', + 'referrer' => 'https://example.com', + 'language' => 'nl-NL', + 'timezone' => 'Europe/Amsterdam', + 'screen' => [ + 'width' => 1920, + 'height' => 1080, + ], + 'viewport' => [ + 'width' => 1440, + 'height' => 900, + ], + ], '127.0.0.1', 'Pest Browser', 'nl-NL,nl;q=0.9'); + + Http::assertSent(fn ($request) => str_contains($request['text'], 'Pagina bezocht') + && str_contains($request['text'], 'Visitor ID: visitor-123') + && str_contains($request['text'], 'Scherm: 1920x1080') + && str_contains($request['text'], 'Viewport: 1440x900')); +}); -- 2.47.3