From 5ee0339ed9baecc97cd5868592a5debe4d011e89 Mon Sep 17 00:00:00 2001 From: brunomers11 Date: Wed, 5 Feb 2025 17:50:41 +0700 Subject: [PATCH 1/4] Added avatar, username and tts functionality. --- config/discord-alerts.php | 8 +++ src/Config.php | 24 +++++++- src/DiscordAlert.php | 48 +++++++++++++--- src/Jobs/SendToDiscordChannelJob.php | 16 +++++- tests/DiscordAlertsTest.php | 82 ++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 11 deletions(-) diff --git a/config/discord-alerts.php b/config/discord-alerts.php index cd8a1f6..6f31989 100644 --- a/config/discord-alerts.php +++ b/config/discord-alerts.php @@ -8,6 +8,14 @@ 'default' => env('DISCORD_ALERT_WEBHOOK'), ], + /* + * Default avatar is an empty string '' which means it will not be included in the payload. + * You can add multiple custom avatars and then specify directly with withAvatar() + */ + 'avatar_urls' => [ + 'default' => '', + ], + /* * This job will send the message to Discord. You can extend this * job to set timeouts, retries, etc... diff --git a/src/Config.php b/src/Config.php index 1ce8a92..79d093d 100644 --- a/src/Config.php +++ b/src/Config.php @@ -39,11 +39,33 @@ public static function getWebhookUrl(string $name): string return $url; } + public static function getAvatarUrl(string $name): ?string + { + $url = config("discord-alerts.avatar_urls.{$name}", ''); + + // If the URL is empty, return null (no avatar included in payload) + if ($url === '') { + return null; + } + + // Validate that it is a proper URL + if (!filter_var($url, FILTER_VALIDATE_URL)) { + throw new \InvalidArgumentException("Invalid avatar URL: {$url}"); + } + + // Optional: Enforce HTTPS only + if (!preg_match('/^https:\/\//', $url)) { + throw new \InvalidArgumentException("Invalid avatar URL: {$url}. Must use HTTPS."); + } + + return $url; + } + public static function getConnection(): string { $connection = config("discord-alerts.queue_connection"); - if (is_null($connection)) { + if(is_null($connection)) { $connection = config("queue.default"); } diff --git a/src/DiscordAlert.php b/src/DiscordAlert.php index b4d8bdc..03e8378 100644 --- a/src/DiscordAlert.php +++ b/src/DiscordAlert.php @@ -5,30 +5,53 @@ class DiscordAlert { protected string $webhookUrlName = 'default'; - - protected int $delay = 0; // minutes + protected int $delay = 0; + protected ?string $username = null; + protected bool $tts = false; + protected ?string $avatarUrl = null; public function to(string $webhookUrlName): self { $this->webhookUrlName = $webhookUrlName; $this->delay = 0; - return $this; } - public function delayMinutes(int $minutes = 0) + public function delayMinutes(int $minutes = 0): self { $this->delay += $minutes; - return $this; } - public function delayHours(int $hours = 0) + public function delayHours(int $hours = 0): self { $this->delay += $hours * 60; + return $this; + } + + public function withUsername(string $username): self + { + // Validate username: Allow letters, numbers, spaces, underscores, and dashes + if (!preg_match('/^[a-zA-Z0-9 _-]{1,32}$/', $username)) { + throw new \InvalidArgumentException("Invalid username. Allowed: letters, numbers, spaces, underscores, dashes (max 32 chars)."); + } + + $this->username = $username; + return $this; + } + + public function enableTTS(bool $enabled = false): self + { + $this->tts = $enabled; + return $this; + } + public function withAvatar(string $avatarName): self + { + $this->avatarUrl = Config::getAvatarUrl($avatarName); return $this; } + public function message(string $text, array $embeds = []): void { @@ -42,16 +65,25 @@ public function message(string $text, array $embeds = []): void } if (array_key_exists('color', $embed)) { - $embeds[$key]['color'] = hexdec(str_replace('#', '', $embed['color'])) ; + $embeds[$key]['color'] = hexdec(str_replace('#', '', $embed['color'])); } } $jobArguments = [ 'text' => $text, 'webhookUrl' => $webhookUrl, + 'tts' => $this->tts, 'embeds' => $embeds, ]; + if (!empty($this->username)) { + $jobArguments['username'] = $this->username; + } + + if (!empty($this->avatarUrl)) { + $jobArguments['avatar_url'] = $this->avatarUrl; + } + $job = Config::getJob($jobArguments); dispatch($job)->delay(now()->addMinutes($this->delay))->onConnection(Config::getConnection()); @@ -61,4 +93,4 @@ private function parseNewline(string $text): string { return str_replace('\n', PHP_EOL, $text); } -} +} \ No newline at end of file diff --git a/src/Jobs/SendToDiscordChannelJob.php b/src/Jobs/SendToDiscordChannelJob.php index 1584ed0..b84809b 100644 --- a/src/Jobs/SendToDiscordChannelJob.php +++ b/src/Jobs/SendToDiscordChannelJob.php @@ -24,6 +24,9 @@ class SendToDiscordChannelJob implements ShouldQueue public function __construct( public string $text, public string $webhookUrl, + public ?string $username = null, + public bool $tts = false, + public ?string $avatar_url = null, public array|null $embeds = null ) { } @@ -32,12 +35,21 @@ public function handle(): void { $payload = [ 'content' => $this->text, + 'tts' => $this->tts, ]; - if (! blank($this->embeds)) { + if (!empty($this->username)) { + $payload['username'] = $this->username; + } + + if (!empty($this->avatar_url)) { + $payload['avatar_url'] = $this->avatar_url; + } + + if (!empty($this->embeds)) { $payload['embeds'] = $this->embeds; } Http::post($this->webhookUrl, $payload); } -} +} \ No newline at end of file diff --git a/tests/DiscordAlertsTest.php b/tests/DiscordAlertsTest.php index 315d43f..a4e03b4 100644 --- a/tests/DiscordAlertsTest.php +++ b/tests/DiscordAlertsTest.php @@ -134,3 +134,85 @@ return $job->delay === 70; }); }); + +it('includes username when specified', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + + DiscordAlert::withUsername('CronBot')->message('test-data'); + + Bus::assertDispatched(SendToDiscordChannelJob::class, function ($job) { + return $job->username === 'CronBot'; + }); +}); + +it('does not include username when not set', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + + DiscordAlert::message('test-data'); + + Bus::assertDispatched(SendToDiscordChannelJob::class, function ($job) { + return $job->username === null; + }); +}); + +it('throws an exception for an invalid username', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + + DiscordAlert::withUsername('')->message('test-data'); +})->throws(InvalidArgumentException::class); + +it('includes avatar_url when a valid one is set', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + config()->set('discord-alerts.avatar_urls.custom', 'https://example.com/avatar.png'); + + DiscordAlert::withAvatar('custom')->message('test-data'); + + Bus::assertDispatched(SendToDiscordChannelJob::class, function ($job) { + return $job->avatar_url === 'https://example.com/avatar.png'; + }); +}); + +it('does not include avatar_url when default is empty', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + config()->set('discord-alerts.avatar_urls.default', ''); + + DiscordAlert::message('test-data'); + + Bus::assertDispatched(SendToDiscordChannelJob::class, function ($job) { + return $job->avatar_url === null; + }); +}); + +it('throws an exception for an invalid avatar URL', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + config()->set('discord-alerts.avatar_urls.malicious', 'invalid-url'); + + DiscordAlert::withAvatar('malicious')->message('test-data'); +})->throws(InvalidArgumentException::class); + +it('throws an exception if avatar URL is not HTTPS', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + config()->set('discord-alerts.avatar_urls.insecure', 'http://example.com/avatar.png'); + + DiscordAlert::withAvatar('insecure')->message('test-data'); +})->throws(InvalidArgumentException::class); + +it('does not include tts when not explicitly set', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + + DiscordAlert::message('test-data'); + + Bus::assertDispatched(SendToDiscordChannelJob::class, function ($job) { + return $job->tts === false; + }); +}); + +it('includes tts when explicitly set to true', function () { + config()->set('discord-alerts.webhook_urls.default', 'https://test-domain.com'); + + DiscordAlert::enableTTS(true)->message('test-data'); + + Bus::assertDispatched(SendToDiscordChannelJob::class, function ($job) { + return $job->tts === true; + }); +}); \ No newline at end of file From 11c8b16d51b26e9f1c286b618755eb13c116195b Mon Sep 17 00:00:00 2001 From: brunomers11 Date: Wed, 5 Feb 2025 18:17:46 +0700 Subject: [PATCH 2/4] readme --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 5ffe904..98422e2 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ return [ 'default' => env('DISCORD_ALERT_WEBHOOK'), ], + /* + * Default avatar is an empty string '' which means it will not be included in the payload. + * You can add multiple custom avatars and then specify directly with withAvatar() + */ + 'avatar_urls' => [ + 'default' => '', + ], + /* * This job will send the message to Discord. You can extend this * job to set timeouts, retries, etc... @@ -68,6 +76,8 @@ return [ ``` +**NOTE**: You have to publish the config if you want to add or edit the avatar. + ## Usage To send a message to Discord, simply call `DiscordAlert::message()` and pass it any message you want. @@ -96,6 +106,15 @@ DiscordAlert::message("You have a new subscriber to the {$newsletter->name} news You can also send multiple embeds as one message. Just be careful that you don't hit the limit of Discord. +## Changing webhook username/avatar/tts + +Add/change the functions before invoking the message. `DiscordAlert::message()` +tts is false by default. You can add multiple custom avatars in the config file (same as multiple webhooks). + +```php +DiscordAlert::withUsername('Test')->enableTTS('true')->withAvatar('custom')->message("You have a new subscriber to the {$newsletter->name} newsletter!"); +``` + ## Using multiple webhooks You can also use an alternative webhook, by specify extra ones in the config file. From c6ccfab88d6f281469cdbd7512cee7a4b261fe76 Mon Sep 17 00:00:00 2001 From: brunomers11 Date: Wed, 5 Feb 2025 23:07:33 +0700 Subject: [PATCH 3/4] fixed a brain far. --- src/DiscordAlert.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/DiscordAlert.php b/src/DiscordAlert.php index 03e8378..a3974d9 100644 --- a/src/DiscordAlert.php +++ b/src/DiscordAlert.php @@ -82,6 +82,11 @@ public function message(string $text, array $embeds = []): void if (!empty($this->avatarUrl)) { $jobArguments['avatar_url'] = $this->avatarUrl; + } else { + $defaultAvatar = Config::getAvatarUrl('default'); + if (!empty($defaultAvatar)) { + $jobArguments['avatar_url'] = $defaultAvatar; + } } $job = Config::getJob($jobArguments); From 784e423cd780dd49be58ecd9c3b3d813dfb8bc54 Mon Sep 17 00:00:00 2001 From: Freek Van der Herten Date: Thu, 6 Feb 2025 12:59:21 +0100 Subject: [PATCH 4/4] Update README.md --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 98422e2..8b98c47 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,6 @@ return [ ``` -**NOTE**: You have to publish the config if you want to add or edit the avatar. - ## Usage To send a message to Discord, simply call `DiscordAlert::message()` and pass it any message you want.