Skip to content

Commit

Permalink
Merge pull request #53 from brunomers11/main
Browse files Browse the repository at this point in the history
Username, avatar, tts webhook functionality.
  • Loading branch information
freekmurze authored Feb 6, 2025
2 parents 7797327 + 784e423 commit 4550a62
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 11 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down Expand Up @@ -96,6 +104,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.
Expand Down
8 changes: 8 additions & 0 deletions config/discord-alerts.php
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
24 changes: 23 additions & 1 deletion src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
53 changes: 45 additions & 8 deletions src/DiscordAlert.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,54 @@
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
{
$webhookUrl = Config::getWebhookUrl($this->webhookUrlName);
Expand All @@ -42,16 +65,30 @@ 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;
} else {
$defaultAvatar = Config::getAvatarUrl('default');
if (!empty($defaultAvatar)) {
$jobArguments['avatar_url'] = $defaultAvatar;
}
}

$job = Config::getJob($jobArguments);

dispatch($job)->delay(now()->addMinutes($this->delay))->onConnection(Config::getConnection());
Expand All @@ -61,4 +98,4 @@ private function parseNewline(string $text): string
{
return str_replace('\n', PHP_EOL, $text);
}
}
}
16 changes: 14 additions & 2 deletions src/Jobs/SendToDiscordChannelJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}
Expand All @@ -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);
}
}
}
82 changes: 82 additions & 0 deletions tests/DiscordAlertsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('<script>alert(1)</script>')->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;
});
});

0 comments on commit 4550a62

Please sign in to comment.