diff --git a/.env.example b/.env.example index 5f0e1466..7bb64897 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,7 @@ VITE_REVERB_SCHEME="${REVERB_SCHEME}" ### ENABLE/DISABLE NEW USER REGISTRATION ### USER_REGISTRATION_ENABLED=true + +### TELEGRAM CREDENTIALS ### +TELEGRAM_BOT_ID= +TELEGRAM_BOT_TOKEN= \ No newline at end of file diff --git a/app/Http/Controllers/Api/NotificationStreamController.php b/app/Http/Controllers/Api/NotificationStreamController.php index 73a57d16..125ff628 100644 --- a/app/Http/Controllers/Api/NotificationStreamController.php +++ b/app/Http/Controllers/Api/NotificationStreamController.php @@ -178,6 +178,7 @@ private function validateNotificationStream(Request $request): array NotificationStream::TYPE_SLACK, NotificationStream::TYPE_TEAMS, NotificationStream::TYPE_PUSHOVER, + NotificationStream::TYPE_TELEGRAM, ])], 'value' => ['required', 'string', 'max:255'], 'notifications' => ['required', 'array'], diff --git a/app/Jobs/BackupTasks/SendTelegramNotificationJob.php b/app/Jobs/BackupTasks/SendTelegramNotificationJob.php new file mode 100644 index 00000000..b486f590 --- /dev/null +++ b/app/Jobs/BackupTasks/SendTelegramNotificationJob.php @@ -0,0 +1,52 @@ +backupTask->sendTelegramNotification($this->backupTaskLog, $this->notificationStreamValue); + } +} diff --git a/app/Livewire/NotificationStreams/Forms/CreateNotificationStream.php b/app/Livewire/NotificationStreams/Forms/CreateNotificationStream.php index 4c931486..3d7e376f 100644 --- a/app/Livewire/NotificationStreams/Forms/CreateNotificationStream.php +++ b/app/Livewire/NotificationStreams/Forms/CreateNotificationStream.php @@ -4,6 +4,7 @@ namespace App\Livewire\NotificationStreams\Forms; +use App\Livewire\NotificationStreams\Forms\Traits\LogsJsErrors; use App\Models\NotificationStream; use App\Models\User; use Illuminate\Http\RedirectResponse; @@ -22,6 +23,8 @@ */ class CreateNotificationStream extends Component { + use LogsJsErrors; + /** @var NotificationStreamForm The form object for creating a notification stream */ public NotificationStreamForm $form; diff --git a/app/Livewire/NotificationStreams/Forms/NotificationStreamForm.php b/app/Livewire/NotificationStreams/Forms/NotificationStreamForm.php index 02cbff5e..88b1c44f 100644 --- a/app/Livewire/NotificationStreams/Forms/NotificationStreamForm.php +++ b/app/Livewire/NotificationStreams/Forms/NotificationStreamForm.php @@ -57,6 +57,10 @@ class NotificationStreamForm extends Form 'label' => 'API Token', 'input_type' => 'text', ], + NotificationStream::TYPE_TELEGRAM => [ + 'label' => 'ID', + 'input_type' => 'text', + ], ]; /** @@ -133,6 +137,11 @@ public function initialize(): void NotificationStream::TYPE_EMAIL => __('Email'), NotificationStream::TYPE_PUSHOVER => __('Pushover'), ]); + + $config = config('services.telegram'); + if ($config['bot_id']) { + $this->availableTypes = $this->availableTypes->merge([NotificationStream::TYPE_TELEGRAM => __('Telegram')]); + } } public function setNotificationStream(NotificationStream $notificationStream): void @@ -177,6 +186,7 @@ protected function getValueValidationRule(): array|string NotificationStream::TYPE_TEAMS => ['url', 'regex:/^https:\/\/.*\.webhook\.office\.com\/webhookb2\/.+/i'], NotificationStream::TYPE_PUSHOVER => ['required', 'string'], NotificationStream::TYPE_EMAIL => ['email'], + NotificationStream::TYPE_TELEGRAM => ['required', 'string', 'regex:/^\d+$/'], default => 'string', }; } @@ -189,6 +199,7 @@ protected function getValueErrorMessage(): string NotificationStream::TYPE_TEAMS => __('Please enter a valid Microsoft Teams Webhook URL.'), NotificationStream::TYPE_EMAIL => __('Please enter a valid email address.'), NotificationStream::TYPE_PUSHOVER => __('Please enter a valid Pushover API Token.'), + NotificationStream::TYPE_TELEGRAM => __('Please enter a valid Telegram ID.'), default => __('Please enter a valid value for the selected notification type.'), }; } diff --git a/app/Livewire/NotificationStreams/Forms/Traits/LogsJsErrors.php b/app/Livewire/NotificationStreams/Forms/Traits/LogsJsErrors.php new file mode 100644 index 00000000..3849e7cc --- /dev/null +++ b/app/Livewire/NotificationStreams/Forms/Traits/LogsJsErrors.php @@ -0,0 +1,20 @@ + $message]); + } +} diff --git a/app/Livewire/NotificationStreams/Forms/UpdateNotificationStream.php b/app/Livewire/NotificationStreams/Forms/UpdateNotificationStream.php index bf0c65d9..b75d5416 100644 --- a/app/Livewire/NotificationStreams/Forms/UpdateNotificationStream.php +++ b/app/Livewire/NotificationStreams/Forms/UpdateNotificationStream.php @@ -4,6 +4,7 @@ namespace App\Livewire\NotificationStreams\Forms; +use App\Livewire\NotificationStreams\Forms\Traits\LogsJsErrors; use App\Models\NotificationStream; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; @@ -22,7 +23,7 @@ class UpdateNotificationStream extends Component { use AuthorizesRequests; - + use LogsJsErrors; /** @var NotificationStream The notification stream being updated */ public NotificationStream $notificationStream; diff --git a/app/Models/BackupTask.php b/app/Models/BackupTask.php index 5fcc38e8..2678ff30 100644 --- a/app/Models/BackupTask.php +++ b/app/Models/BackupTask.php @@ -8,9 +8,11 @@ use App\Jobs\BackupTasks\SendPushoverNotificationJob; use App\Jobs\BackupTasks\SendSlackNotificationJob; use App\Jobs\BackupTasks\SendTeamsNotificationJob; +use App\Jobs\BackupTasks\SendTelegramNotificationJob; use App\Jobs\RunDatabaseBackupTaskJob; use App\Jobs\RunFileBackupTaskJob; use App\Mail\BackupTasks\OutputMail; +use App\Models\Traits\ComposesTelegramNotification; use App\Traits\HasTags; use Carbon\CarbonInterface; use Cron\CronExpression; @@ -43,6 +45,7 @@ class BackupTask extends Model { use AuditableModel; + use ComposesTelegramNotification; /** @use HasFactory */ use HasFactory; use HasTags; @@ -662,6 +665,16 @@ public function hasPushoverNotification(): bool ->exists(); } + /** + * Check if the task has Telegram notifications enabled. + */ + public function hasTelegramNotification(): bool + { + return $this->notificationStreams() + ->where('type', NotificationStream::TYPE_TELEGRAM) + ->exists(); + } + /** * Send notifications for the latest backup task log. * @@ -727,6 +740,7 @@ public function dispatchNotification(NotificationStream $notificationStream, Bac NotificationStream::TYPE_SLACK => SendSlackNotificationJob::dispatch($this, $backupTaskLog, $streamValue)->onQueue($queue), NotificationStream::TYPE_TEAMS => SendTeamsNotificationJob::dispatch($this, $backupTaskLog, $streamValue)->onQueue($queue), NotificationStream::TYPE_PUSHOVER => SendPushoverNotificationJob::dispatch($this, $backupTaskLog, $streamValue, $additionalStreamValueOne)->onQueue($queue), + NotificationStream::TYPE_TELEGRAM => SendTelegramNotificationJob::dispatch($this, $backupTaskLog, $streamValue)->onQueue($queue), default => throw new InvalidArgumentException("Unsupported notification type: {$notificationStream->getAttribute('type')}"), }; } @@ -958,6 +972,31 @@ public function sendPushoverNotification(BackupTaskLog $backupTaskLog, string $p } } + /** + * Send a Telegram notification for the backup task. + * + * @param BackupTaskLog $backupTaskLog The log entry for the backup task + * @param string $chatID The target Telegram chat ID + * + * @throws RuntimeException|ConnectionException If the Telegram request fails. + */ + public function sendTelegramNotification(BackupTaskLog $backupTaskLog, string $chatID): void + { + $url = $this->getTelegramUrl(); + $message = $this->composeTelegramNotificationText($this, $backupTaskLog); + $payload = [ + 'chat_id' => $chatID, + 'text' => $message, + 'parse_mode' => 'HTML', + ]; + + $response = Http::post($url, $payload); + + if (! $response->successful()) { + throw new RuntimeException('Telegram notification failed: ' . $response->body()); + } + } + /** * Check if the task has a custom store path. */ diff --git a/app/Models/NotificationStream.php b/app/Models/NotificationStream.php index 035fa8b8..3ed81330 100644 --- a/app/Models/NotificationStream.php +++ b/app/Models/NotificationStream.php @@ -29,6 +29,7 @@ class NotificationStream extends Model public const string TYPE_SLACK = 'slack_webhook'; public const string TYPE_TEAMS = 'teams_webhook'; public const string TYPE_PUSHOVER = 'pushover'; + public const string TYPE_TELEGRAM = 'telegram'; /** * The attributes that aren't mass assignable. @@ -108,6 +109,14 @@ public function isPushover(): bool return $this->type === self::TYPE_PUSHOVER; } + /** + * Check if the notification stream type is Telegram. + */ + public function isTelegram(): bool + { + return $this->type === self::TYPE_TELEGRAM; + } + /** * Returns whether this stream will send backup notifications on success. */ @@ -139,6 +148,7 @@ protected function formattedType(): Attribute self::TYPE_SLACK => (string) __('Slack Webhook'), self::TYPE_TEAMS => (string) __('Teams Webhook'), self::TYPE_PUSHOVER => (string) __('Pushover'), + self::TYPE_TELEGRAM => (string) __('Telegram'), default => null, }; } @@ -160,6 +170,7 @@ protected function typeIcon(): Attribute self::TYPE_SLACK => 'M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z', self::TYPE_TEAMS => 'M 12.5 2 A 3 3 0 0 0 9.7089844 6.09375 C 9.4804148 6.0378189 9.2455412 6 9 6 L 4 6 C 2.346 6 1 7.346 1 9 L 1 14 C 1 15.654 2.346 17 4 17 L 9 17 C 10.654 17 12 15.654 12 14 L 12 9 C 12 8.6159715 11.921192 8.2518913 11.789062 7.9140625 A 3 3 0 0 0 12.5 8 A 3 3 0 0 0 12.5 2 z M 19 4 A 2 2 0 0 0 19 8 A 2 2 0 0 0 19 4 z M 4.5 9 L 8.5 9 C 8.776 9 9 9.224 9 9.5 C 9 9.776 8.776 10 8.5 10 L 7 10 L 7 14 C 7 14.276 6.776 14.5 6.5 14.5 C 6.224 14.5 6 14.276 6 14 L 6 10 L 4.5 10 C 4.224 10 4 9.776 4 9.5 C 4 9.224 4.224 9 4.5 9 z M 15 9 C 14.448 9 14 9.448 14 10 L 14 14 C 14 16.761 11.761 19 9 19 C 8.369 19 8.0339375 19.755703 8.4609375 20.220703 C 9.4649375 21.313703 10.903 22 12.5 22 C 15.24 22 17.529453 20.040312 17.939453 17.320312 C 17.979453 17.050312 18 16.78 18 16.5 L 18 11 C 18 9.9 17.1 9 16 9 L 15 9 z M 20.888672 9 C 20.322672 9 19.870625 9.46625 19.890625 10.03125 C 19.963625 12.09325 20 16.5 20 16.5 C 20 16.618 19.974547 16.859438 19.935547 17.148438 C 19.812547 18.048438 20.859594 18.653266 21.558594 18.072266 C 22.439594 17.340266 23 16.237 23 15 L 23 11 C 23 9.9 22.1 9 21 9 L 20.888672 9 z', self::TYPE_PUSHOVER => 'M11.6685 21.0473c5.2435.1831 9.6426-3.9191 9.8257-9.1627.1831-5.24355-3.9191-9.64267-9.1626-9.82578-5.24355-.18311-9.64265 3.91918-9.82576 9.16268-.18311 5.2435 3.91916 9.6427 9.16266 9.8258zM11.8206 8.47095l1.9374-.1867-2.0265 4.17345c.331-.0144.6576-.1144.9816-.3018.324-.1873.6257-.4274.9014-.7186.2775-.291.5191-.6168.7267-.9791.2075-.3603.3593-.7189.457-1.0701.0577-.2189.0892-.4295.0926-.6317s-.0442-.3822-.1409-.5378c-.0967-.1556-.2463-.2834-.4508-.3833-.2044-.1-.4828-.1561-.8389-.1686-.4153-.0145-.8256.038-1.2309.1594-.4071.1213-.7848.3049-1.1369.5507-.352.2458-.66.5562-.9274.933-.2676.3769-.4646.8082-.5911 1.2939-.0483.1598-.0768.2869-.0895.3849s-.0174.1776-.014.2408c.0015.0632.009.1136.0208.1474.0119.0339.0218.0676.028.1031-.4321-.015-.7443-.1132-.9366-.2926-.1924-.1794-.23-.4852-.1149-.9119.1177-.4452.3625-.8637.7364-1.2572.374-.3936.8129-.73655 1.3188-1.02705.5059-.29055 1.0559-.51825 1.6501-.67945.5942-.1612 1.1704-.2321 1.7285-.2126.4914.0172.9006.102 1.2295.2527.329.1508.5839.3453.7614.5799.1775.2346.2849.5057.3207.8114.0358.3057.0099.6223-.0777.9497-.1066.3936-.2967.7879-.5685 1.18135s-.609.7455-1.0079 1.0565c-.4008.3128-.8551.5605-1.3666.7469-.5096.1846-1.049.2679-1.6164.248l-.0631-.0022-1.7377 3.5597-1.82655-.0638z', + self::TYPE_TELEGRAM => 'M 18.632812 1.714844 L 0.519531 8.722656 C 0.507812 8.726562 0.5 8.730469 0.488281 8.738281 C 0.34375 8.820312 -0.683594 9.445312 0.761719 10.007812 L 0.777344 10.015625 L 5.089844 11.40625 C 5.15625 11.429688 5.230469 11.421875 5.289062 11.382812 L 15.984375 4.710938 C 16.011719 4.695312 16.042969 4.683594 16.070312 4.675781 C 16.222656 4.652344 16.648438 4.605469 16.378906 4.949219 C 16.070312 5.339844 8.765625 11.890625 7.953125 12.617188 C 7.90625 12.65625 7.878906 12.714844 7.875 12.777344 L 7.519531 16.996094 C 7.519531 17.085938 7.558594 17.167969 7.628906 17.21875 C 7.730469 17.28125 7.859375 17.273438 7.949219 17.195312 L 10.511719 14.902344 C 10.59375 14.828125 10.71875 14.824219 10.808594 14.890625 L 15.28125 18.132812 L 15.292969 18.144531 C 15.402344 18.210938 16.570312 18.890625 16.910156 17.371094 L 19.996094 2.699219 C 20 2.652344 20.042969 2.140625 19.675781 1.839844 C 19.292969 1.523438 18.75 1.683594 18.667969 1.699219 C 18.65625 1.703125 18.644531 1.707031 18.632812 1.714844 Z M 18.632812 1.714844', default => 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z', }; } diff --git a/app/Models/Traits/ComposesTelegramNotification.php b/app/Models/Traits/ComposesTelegramNotification.php new file mode 100644 index 00000000..39bac026 --- /dev/null +++ b/app/Models/Traits/ComposesTelegramNotification.php @@ -0,0 +1,47 @@ +get('services.telegram'); + + if ($config['bot_token'] === null) { + throw new RuntimeException('Telegram bot token is not configured.'); + } + + return 'https://api.telegram.org/bot' . $config['bot_token'] . '/sendMessage'; + } + + /** + * Compose message for Telegram notification based on the backup task and its log. + */ + public function composeTelegramNotificationText(BackupTask $backupTask, BackupTaskLog $backupTaskLog): string + { + $isSuccessful = $backupTaskLog->getAttribute('successful_at') !== null; + $message = $isSuccessful + ? 'The backup task was SUCCESSFUL. 👌' + : 'The backup task FAILED. 😭'; + + return $message . "\nDetails:\n" . + 'Backup Type: ' . ucfirst($backupTask->getAttribute('type')) . "\n" . + 'Remote Server: ' . ($backupTask->getAttribute('remoteServer')?->label ?? 'N/A') . "\n" . + 'Backup Destination: ' . ($backupTask->getAttribute('backupDestination')?->label ?? 'N/A') . + ' (' . ($backupTask->getAttribute('backupDestination')?->type() ?? 'N/A') . ")\n" . + 'Ran at: ' . Carbon::parse($backupTaskLog->getAttribute('created_at'))->format('jS F Y, H:i:s'); + } +} diff --git a/config/services.php b/config/services.php index 318bd299..bf8c9d52 100644 --- a/config/services.php +++ b/config/services.php @@ -44,4 +44,9 @@ 'client_secret' => env('BITBUCKET_CLIENT_SECRET'), 'redirect' => config('app.url') . '/auth/bitbucket/callback', ], + + 'telegram' => [ + 'bot_id' => env('TELEGRAM_BOT_ID'), + 'bot_token' => env('TELEGRAM_BOT_TOKEN'), + ], ]; diff --git a/database/factories/NotificationStreamFactory.php b/database/factories/NotificationStreamFactory.php index 7f60e6df..f4d58e18 100644 --- a/database/factories/NotificationStreamFactory.php +++ b/database/factories/NotificationStreamFactory.php @@ -21,6 +21,7 @@ public function definition(): array NotificationStream::TYPE_DISCORD, NotificationStream::TYPE_TEAMS, NotificationStream::TYPE_PUSHOVER, + NotificationStream::TYPE_TELEGRAM, ]); return [ @@ -88,6 +89,17 @@ public function pushover(): static ]); } + /** + * Indicate that the notification stream is for Telegram. + */ + public function telegram(): static + { + return $this->state([ + 'type' => NotificationStream::TYPE_TELEGRAM, + 'value' => $this->generateTelegram(), + ]); + } + /** * Indicate that the notification stream will send successful notifications. */ @@ -138,6 +150,7 @@ private function getValueForType(string $type): string NotificationStream::TYPE_SLACK => $this->generateSlackWebhook(), NotificationStream::TYPE_DISCORD => $this->generateDiscordWebhook(), NotificationStream::TYPE_TEAMS => $this->generateTeamsWebhook(), + NotificationStream::TYPE_TELEGRAM => $this->generateTelegram(), default => $this->faker->url, }; } @@ -186,4 +199,12 @@ private function generatePushover(): string { return $this->faker->regexify('[a-zA-Z0-9]{30}'); } + + /** + * Generate a realistic Telegram chatID. + */ + private function generateTelegram(): string + { + return $this->faker->regexify('[0-9]{10}'); + } } diff --git a/resources/views/components/notification-stream-form.blade.php b/resources/views/components/notification-stream-form.blade.php index fd371f65..1f4e1d32 100644 --- a/resources/views/components/notification-stream-form.blade.php +++ b/resources/views/components/notification-stream-form.blade.php @@ -52,7 +52,9 @@ class="mt-1 block w-full" @enderror - + @foreach ($form->getAdditionalFieldsConfig() as $field => $config)
diff --git a/resources/views/components/telegram-form.blade.php b/resources/views/components/telegram-form.blade.php new file mode 100644 index 00000000..edc16ffd --- /dev/null +++ b/resources/views/components/telegram-form.blade.php @@ -0,0 +1,58 @@ + + +@assets + +@endassets + +@script + +@endscript diff --git a/tests/Feature/NotificationStreams/Livewire/Forms/CreateNotificationStreamTest.php b/tests/Feature/NotificationStreams/Livewire/Forms/CreateNotificationStreamTest.php index e1f70003..ce9605f2 100644 --- a/tests/Feature/NotificationStreams/Livewire/Forms/CreateNotificationStreamTest.php +++ b/tests/Feature/NotificationStreams/Livewire/Forms/CreateNotificationStreamTest.php @@ -5,6 +5,7 @@ use App\Livewire\NotificationStreams\Forms\CreateNotificationStream; use App\Models\User; use Carbon\Carbon; +use Illuminate\Support\Facades\Log; use Livewire\Livewire; beforeEach(function (): void { @@ -178,6 +179,24 @@ ->assertHasErrors(['form.value']); }); +it('validates Telegraf format', function (): void { + Livewire::actingAs($this->user) + ->test(CreateNotificationStream::class) + ->set('form.label', 'Test') + ->set('form.type', 'telegram') + ->set('form.value', 'abcdef') + ->call('submit') + ->assertHasErrors(['form.value']); + + Livewire::actingAs($this->user) + ->test(CreateNotificationStream::class) + ->set('form.label', 'Test') + ->set('form.type', 'telegram') + ->set('form.value', '123456789') + ->call('submit') + ->assertHasNoErrors(['form.value']); +}); + it('clears validation errors when type changes', function (): void { $component = Livewire::actingAs($this->user) ->test(CreateNotificationStream::class) @@ -350,6 +369,42 @@ $this->assertNotNull($notificationStream->receive_failed_backup_notifications); }); +it('submits successfully with Telegram ID and both notification preferences enabled', function (): void { + Config::set('services.telegram.bot_token', '456'); + Config::set('services.telegram.bot_id', '123'); + + $testData = [ + 'label' => 'Telegram', + 'type' => 'telegram', + 'value' => '1234567890', + 'success_notification' => true, + 'failed_notification' => true, + ]; + + Livewire::actingAs($this->user) + ->test(CreateNotificationStream::class) + ->set('form.label', $testData['label']) + ->set('form.type', $testData['type']) + ->set('form.value', $testData['value']) + ->set('form.success_notification', $testData['success_notification']) + ->set('form.failed_notification', $testData['failed_notification']) + ->call('submit') + ->assertHasNoErrors(['form.type']) // Check specifically for 'form.type' errors + ->assertHasNoErrors() + ->assertRedirect(route('notification-streams.index')); + + $this->assertDatabaseHas('notification_streams', [ + 'user_id' => $this->user->id, + 'label' => $testData['label'], + 'type' => $testData['type'], + 'value' => $testData['value'], + ]); + + $notificationStream = $this->user->notificationStreams()->latest()->first(); + $this->assertNotNull($notificationStream->receive_successful_backup_notifications); + $this->assertNotNull($notificationStream->receive_failed_backup_notifications); +}); + it('submits successfully with both notification preferences disabled', function (): void { $testData = [ 'label' => 'Test Notification', @@ -455,3 +510,10 @@ ->call('submit') ->assertHasErrors(['form.additional_field_one']); }); + +it('writes error to log on event', function (): void { + Log::shouldReceive('error')->once()->with('Error from js script for Telegram authentication.', ['error' => 'Some js error']); + Livewire::actingAs($this->user) + ->test(CreateNotificationStream::class) + ->dispatch('jsError', 'Some js error'); +}); diff --git a/tests/Feature/NotificationStreams/Livewire/Forms/UpdateNotificationStreamTest.php b/tests/Feature/NotificationStreams/Livewire/Forms/UpdateNotificationStreamTest.php index 0d4bfb7d..b7fd6c1e 100644 --- a/tests/Feature/NotificationStreams/Livewire/Forms/UpdateNotificationStreamTest.php +++ b/tests/Feature/NotificationStreams/Livewire/Forms/UpdateNotificationStreamTest.php @@ -6,6 +6,7 @@ use App\Models\NotificationStream; use App\Models\User; use Carbon\Carbon; +use Illuminate\Support\Facades\Log; use Livewire\Livewire; it('renders successfully', function (): void { @@ -436,3 +437,14 @@ ->call('submit') ->assertHasErrors(['form.additional_field_one']); }); + +it('writes error to log on event', function (): void { + $user = User::factory()->create(); + $notificationStream = NotificationStream::factory()->create(['user_id' => $user->id]); + + Log::shouldReceive('error')->once()->with('Error from js script for Telegram authentication.', ['error' => 'Some js error']); + + Livewire::actingAs($user) + ->test(UpdateNotificationStream::class, ['notificationStream' => $notificationStream]) + ->dispatch('jsError', 'Some js error'); +}); diff --git a/tests/Unit/Jobs/SendTelegramNotificationJobTest.php b/tests/Unit/Jobs/SendTelegramNotificationJobTest.php new file mode 100644 index 00000000..088d0c8c --- /dev/null +++ b/tests/Unit/Jobs/SendTelegramNotificationJobTest.php @@ -0,0 +1,32 @@ +create(); + $backup_task_log = BackupTaskLog::factory()->create(); + Queue::fake(); + Queue::assertNothingPushed(); + SendTelegramNotificationJob::dispatch($backup_task, $backup_task_log, 'TelegramID'); + Queue::assertPushed(SendTelegramNotificationJob::class, 1); + Queue::assertPushed(function (SendTelegramNotificationJob $sendTelegramNotificationJob) use ($backup_task, $backup_task_log): bool { + return $sendTelegramNotificationJob->notificationStreamValue === 'TelegramID' && $sendTelegramNotificationJob->backupTaskLog === $backup_task_log && $sendTelegramNotificationJob->backupTask === $backup_task; + }); +}); + +it('calls the sendTelegramNotification method', function (): void { + /** + * @var BackupTask|MockInterface $mock + */ + $mock = Mockery::mock(BackupTask::class); + $backup_task_log = BackupTaskLog::factory()->create(); + $mock->shouldReceive('sendTelegramNotification')->with($backup_task_log, 'TelegramID')->once(); + (new SendTelegramNotificationJob($mock, $backup_task_log, 'TelegramID'))->handle(); +}); diff --git a/tests/Unit/Models/BackupTaskTest.php b/tests/Unit/Models/BackupTaskTest.php index 5676a6ac..9be2bb70 100644 --- a/tests/Unit/Models/BackupTaskTest.php +++ b/tests/Unit/Models/BackupTaskTest.php @@ -6,6 +6,7 @@ use App\Jobs\BackupTasks\SendPushoverNotificationJob; use App\Jobs\BackupTasks\SendSlackNotificationJob; use App\Jobs\BackupTasks\SendTeamsNotificationJob; +use App\Jobs\BackupTasks\SendTelegramNotificationJob; use App\Jobs\RunDatabaseBackupTaskJob; use App\Jobs\RunFileBackupTaskJob; use App\Mail\BackupTasks\OutputMail; @@ -15,9 +16,13 @@ use App\Models\NotificationStream; use App\Models\RemoteServer; use App\Models\Tag; +use App\Models\Traits\ComposesTelegramNotification; use App\Models\User; +use Illuminate\Http\Client\Request; use Illuminate\Support\Carbon; +uses(ComposesTelegramNotification::class); + it('sets the last run at timestamp', function (): void { $task = BackupTask::factory()->create(); @@ -679,6 +684,21 @@ expect($task->hasPushoverNotification())->toBeFalse(); }); +it('returns true if there is a notification stream telegram set', function (): void { + $task = BackupTask::factory()->create(); + $stream = NotificationStream::factory()->telegram()->create(); + + $task->notificationStreams()->attach($stream); + + expect($task->hasTelegramNotification())->toBeTrue(); +}); + +it('returns false if there is no notification stream telegram set', function (): void { + $task = BackupTask::factory()->create(); + + expect($task->hasEmailNotification())->toBeFalse(); +}); + it('queues up a teams notification job if a teams notification has been set', function (): void { Queue::fake(); @@ -826,6 +846,32 @@ Mail::assertNotQueued(OutputMail::class); }); +it('queues up a telegram notification job if a telegram notification has been set', function (): void { + + Queue::fake(); + + $task = BackupTask::factory()->create(); + $stream = NotificationStream::factory()->telegram()->create(); + $task->notificationStreams()->attach($stream); + + BackupTaskLog::factory()->create(['backup_task_id' => $task->id]); + + $task->sendNotifications(); + + Queue::assertPushed(SendTelegramNotificationJob::class); +}); + +it('does not queue up a telegram notification job if a telegram notification has not been set', function (): void { + + Queue::fake(); + + $task = BackupTask::factory()->create(); + + $task->sendNotifications(); + + Queue::assertNotPushed(SendTelegramNotificationJob::class); +}); + it('can send multiple types of notifications simultaneously', function (): void { Queue::fake(); Mail::fake(); @@ -984,6 +1030,52 @@ ->toThrow(RuntimeException::class, 'Pushover notification failed: Error'); }); +it('sends a Telegram notification successfully', function (): void { + $botToken = 'abc123'; + $userToken = 'def456'; + + Config::set('services.telegram.bot_token', $botToken); + + $task = BackupTask::factory()->create(); + $log = BackupTaskLog::factory()->create(['backup_task_id' => $task->id]); + + Http::fake([ + "https://api.telegram.org/bot{$botToken}/sendMessage" => Http::response('', 200), + ]); + + $task->sendTelegramNotification($log, $userToken); + + Http::assertSent(function (Request $request) use ($botToken, $userToken, $task, $log): bool { + return $request->url() === "https://api.telegram.org/bot{$botToken}/sendMessage" && + $request['text'] === $this->composeTelegramNotificationText($task, $log) && + $request['chat_id'] === $userToken && + $request['parse_mode'] === 'HTML'; + }); +}); + +it('throws an exception when Telegram notification fails', function (): void { + $task = BackupTask::factory()->create(); + $log = BackupTaskLog::factory()->create(['backup_task_id' => $task->id]); + + Config::set('services.telegram.bot_token', '456'); + + Http::fake([ + $this->getTelegramUrl() => Http::response('Error', 500), + ]); + + expect(fn () => $task->sendTelegramNotification($log, 'fakeToken')) + ->toThrow(RuntimeException::class, 'Telegram notification failed: Error'); +}); + +it('throws an exception when Telegram bot token missing', function (): void { + config()->set('services.telegram.bot_token', null); + $task = BackupTask::factory()->create(); + $log = BackupTaskLog::factory()->create(['backup_task_id' => $task->id]); + + expect(fn () => $task->sendTelegramNotification($log, 'fakeToken')) + ->toThrow(RuntimeException::class, 'Telegram bot token is not configured'); +}); + it('sends notifications based on backup task outcome and user preferences', function (): void { Queue::fake(); Mail::fake(); diff --git a/tests/Unit/Models/NotificationStreamTest.php b/tests/Unit/Models/NotificationStreamTest.php index 5aeb3646..b9c04588 100644 --- a/tests/Unit/Models/NotificationStreamTest.php +++ b/tests/Unit/Models/NotificationStreamTest.php @@ -64,6 +64,16 @@ expect($notificationStream->formatted_type)->toBe('Discord Webhook'); }); +it('returns true if stream type is telegram', function (): void { + $notificationStream = NotificationStream::factory()->telegram()->create(); + expect($notificationStream->isTelegram())->toBeTrue(); +}); + +it('returns false if the stream type is not telegram', function (): void { + $notificationStream = NotificationStream::factory()->email()->create(); + expect($notificationStream->isTelegram())->toBeFalse(); +}); + it('returns correct formatted type for slack', function (): void { $notificationStream = NotificationStream::factory()->slack()->create(); expect($notificationStream->formatted_type)->toBe('Slack Webhook'); @@ -79,6 +89,11 @@ expect($notificationStream->formatted_type)->toBe('Pushover'); }); +it('returns correct formatted type for telegram', function (): void { + $notificationStream = NotificationStream::factory()->telegram()->create(); + expect($notificationStream->formatted_type)->toBe('Telegram'); +}); + it('returns correct type icon for email', function (): void { $notificationStream = NotificationStream::factory()->email()->create(); expect($notificationStream->type_icon)->toBe('M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z'); @@ -104,6 +119,11 @@ expect($notificationStream->type_icon)->toBe('M11.6685 21.0473c5.2435.1831 9.6426-3.9191 9.8257-9.1627.1831-5.24355-3.9191-9.64267-9.1626-9.82578-5.24355-.18311-9.64265 3.91918-9.82576 9.16268-.18311 5.2435 3.91916 9.6427 9.16266 9.8258zM11.8206 8.47095l1.9374-.1867-2.0265 4.17345c.331-.0144.6576-.1144.9816-.3018.324-.1873.6257-.4274.9014-.7186.2775-.291.5191-.6168.7267-.9791.2075-.3603.3593-.7189.457-1.0701.0577-.2189.0892-.4295.0926-.6317s-.0442-.3822-.1409-.5378c-.0967-.1556-.2463-.2834-.4508-.3833-.2044-.1-.4828-.1561-.8389-.1686-.4153-.0145-.8256.038-1.2309.1594-.4071.1213-.7848.3049-1.1369.5507-.352.2458-.66.5562-.9274.933-.2676.3769-.4646.8082-.5911 1.2939-.0483.1598-.0768.2869-.0895.3849s-.0174.1776-.014.2408c.0015.0632.009.1136.0208.1474.0119.0339.0218.0676.028.1031-.4321-.015-.7443-.1132-.9366-.2926-.1924-.1794-.23-.4852-.1149-.9119.1177-.4452.3625-.8637.7364-1.2572.374-.3936.8129-.73655 1.3188-1.02705.5059-.29055 1.0559-.51825 1.6501-.67945.5942-.1612 1.1704-.2321 1.7285-.2126.4914.0172.9006.102 1.2295.2527.329.1508.5839.3453.7614.5799.1775.2346.2849.5057.3207.8114.0358.3057.0099.6223-.0777.9497-.1066.3936-.2967.7879-.5685 1.18135s-.609.7455-1.0079 1.0565c-.4008.3128-.8551.5605-1.3666.7469-.5096.1846-1.049.2679-1.6164.248l-.0631-.0022-1.7377 3.5597-1.82655-.0638z'); }); +it('returns correct type icon for telegram', function (): void { + $notificationStream = NotificationStream::factory()->telegram()->create(); + expect($notificationStream->type_icon)->toBe('M 18.632812 1.714844 L 0.519531 8.722656 C 0.507812 8.726562 0.5 8.730469 0.488281 8.738281 C 0.34375 8.820312 -0.683594 9.445312 0.761719 10.007812 L 0.777344 10.015625 L 5.089844 11.40625 C 5.15625 11.429688 5.230469 11.421875 5.289062 11.382812 L 15.984375 4.710938 C 16.011719 4.695312 16.042969 4.683594 16.070312 4.675781 C 16.222656 4.652344 16.648438 4.605469 16.378906 4.949219 C 16.070312 5.339844 8.765625 11.890625 7.953125 12.617188 C 7.90625 12.65625 7.878906 12.714844 7.875 12.777344 L 7.519531 16.996094 C 7.519531 17.085938 7.558594 17.167969 7.628906 17.21875 C 7.730469 17.28125 7.859375 17.273438 7.949219 17.195312 L 10.511719 14.902344 C 10.59375 14.828125 10.71875 14.824219 10.808594 14.890625 L 15.28125 18.132812 L 15.292969 18.144531 C 15.402344 18.210938 16.570312 18.890625 16.910156 17.371094 L 19.996094 2.699219 C 20 2.652344 20.042969 2.140625 19.675781 1.839844 C 19.292969 1.523438 18.75 1.683594 18.667969 1.699219 C 18.65625 1.703125 18.644531 1.707031 18.632812 1.714844 Z M 18.632812 1.714844'); +}); + it('returns default type icon for unknown type', function (): void { $notificationStream = NotificationStream::factory()->create(['type' => 'unknown']); expect($notificationStream->type_icon)->toBe('M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z');