diff --git a/app/Jobs/Discord/DiscordWebhookJob.php b/app/Jobs/Discord/BaseDiscordWebhookJob.php similarity index 54% rename from app/Jobs/Discord/DiscordWebhookJob.php rename to app/Jobs/Discord/BaseDiscordWebhookJob.php index 52750d5b7..b13dd37a7 100644 --- a/app/Jobs/Discord/DiscordWebhookJob.php +++ b/app/Jobs/Discord/BaseDiscordWebhookJob.php @@ -5,41 +5,46 @@ namespace App\Jobs\Discord; use App\Events\Discord\DiscordWebhookDied; +use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Illuminate\Queue\Attributes\MaxExceptions; +use Illuminate\Queue\Attributes\Queue; use Illuminate\Queue\Attributes\Tries; +use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\RateLimited; use Illuminate\Queue\Middleware\Skip; use Illuminate\Queue\Middleware\WithoutOverlapping; +use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use InvalidArgumentException; -use Spatie\DiscordAlerts\Jobs\SendToDiscordChannelJob; +use JustinKluever\DiscordWebhookBuilder\Webhook; #[Tries(254)] #[MaxExceptions(3)] -class DiscordWebhookJob extends SendToDiscordChannelJob implements ShouldBeEncrypted +#[Queue('discord-webhooks')] +abstract class BaseDiscordWebhookJob implements ShouldBeEncrypted, ShouldQueue { - private readonly string $webhookId; - - public function __construct( - string $text, - string $webhookUrl, - ?string $username = null, - bool $tts = false, - ?string $avatar_url = null, - ?array $embeds = null - ) { - if (! preg_match('/webhooks\/(\d+)\//', $webhookUrl, $match)) { - throw new InvalidArgumentException('Could not extract Webhook Id from provided webhook url, are you sure this is a discord webhook url?'); - } + use Dispatchable; + use InteractsWithQueue; + use Queueable; + use SerializesModels; - parent::__construct($text, $webhookUrl, $username, $tts, $avatar_url, $embeds); - $this->webhookId = $match[1]; - } + /** + * The Webhook Payload we should send to discord + */ + abstract protected function getPayload(): Webhook; + + /** + * The Webhook Url we send the Payload to + */ + abstract protected function getWebhook(): string; public function middleware(): array { @@ -52,12 +57,25 @@ public function middleware(): array public function handle(): void { + if ($this->shouldRun() === false) { + return; + } + if ($this->releaseIfCurrentlyRateLimited()) { return; } try { - $response = Http::timeout(5)->post($this->webhookUrl, $this->getPayload()); + Log::debug("Sending to Discord Webhook {$this->getWebhookId()}", [ + 'webhook_id' => $this->getWebhookId(), + 'payload' => $this->getPayload(), + ]); + + $response = $this->getRequest(); + + if ($response instanceof PendingRequest) { + $response = $response->post($this->getWebhook(), $this->getPayload()); + } } catch (ConnectionException) { $this->release(30); @@ -81,16 +99,57 @@ public function handle(): void } if ($response->failed()) { - $this->fail("Discord webhook $this->webhookId failed with status {$response->status()}: {$response->body()}"); + $this->fail("Discord webhook {$this->getWebhookId()} failed with status {$response->status()}: {$response->body()}"); + + return; + } + + $this->handleResponse($response); + } + + /** + * Allows us to override the entire request if needed + * + * @throws ConnectionException + */ + protected function getRequest(): PendingRequest|Response + { + return Http::timeout(5)->post($this->getWebhook(), $this->getPayload()); + } + + /** + * Allows us to do stuff with the response later + */ + protected function handleResponse(Response $response): void {} + + /** + * In case we need a way to easily stop a job at handle time + */ + protected function shouldRun(): bool + { + return true; + } + + protected function getWebhookId(): string + { + if (! preg_match('/webhooks\/(\d+)\//', $this->getWebhook(), $match)) { + throw new InvalidArgumentException('Could not extract Webhook Id from provided webhook url, are you sure this is a discord webhook url?'); } + + return $match[1]; } - private function isWebhookInvalid(): bool + protected function isWebhookInvalid(): bool { return Cache::has($this->cacheKey('invalid')); } - private function updateRateLimitStatus(Response $response): void + protected function cacheKey(string $suffix): string + { + return "discord:webhook:{$this->getWebhookId()}:$suffix"; + } + + protected function updateRateLimitStatus(Response $response): void { $rateLimitRemainingHeader = $response->header('X-RateLimit-Remaining'); $rateLimitResetAtHeader = $response->header('X-RateLimit-Reset'); @@ -105,34 +164,7 @@ private function updateRateLimitStatus(Response $response): void } } - private function getPayload(): array - { - $payload = [ - 'content' => $this->text, - 'tts' => $this->tts, - ]; - - if (filled($this->username)) { - $payload['username'] = $this->username; - } - - if (filled($this->avatar_url)) { - $payload['avatar_url'] = $this->avatar_url; - } - - if (filled($this->embeds)) { - $payload['embeds'] = $this->embeds; - } - - return $payload; - } - - private function cacheKey(string $suffix): string - { - return "discord:webhook:$this->webhookId:$suffix"; - } - - private function releaseIfRateLimitedAfter(Response $response): bool + protected function releaseIfRateLimitedAfter(Response $response): bool { if ($response->status() !== 429) { return false; @@ -142,8 +174,8 @@ private function releaseIfRateLimitedAfter(Response $response): bool $retryAfter = (int) ceil($body['retry_after'] ?? $response->header('Retry-After') ?? 60); $scope = $response->header('X-RateLimit-Scope') ?? 'unknown'; - Log::debug("Webhook '$this->webhookId' has triggered discord rate-limit in the '$scope' rate limit scope", [ - 'webhook_id' => $this->webhookId, + Log::debug("Webhook '{$this->getWebhookId()}' has triggered discord rate-limit in the '$scope' rate limit scope", [ + 'webhook_id' => $this->getWebhookId(), 'retry-after' => $retryAfter, 'scope' => $scope, ]); @@ -153,7 +185,7 @@ private function releaseIfRateLimitedAfter(Response $response): bool return true; } - private function releaseIfCurrentlyRateLimited(): bool + protected function releaseIfCurrentlyRateLimited(): bool { $rateLimitRemaining = Cache::get($this->cacheKey('rate-limit:remaining')); $rateLimitResetAt = Cache::get($this->cacheKey('rate-limit:reset')); @@ -167,7 +199,7 @@ private function releaseIfCurrentlyRateLimited(): bool $retryAfter = max($rateLimitResetAt - now()->timestamp, 1); Log::debug('Discord Webhook has been delayed', [ - 'webhook_id' => $this->webhookId, + 'webhook_id' => $this->getWebhookId(), 'retry_after' => $retryAfter, ]); @@ -179,20 +211,20 @@ private function releaseIfCurrentlyRateLimited(): bool return false; } - private function failIfWebhookNotFound(Response $response): bool + protected function failIfWebhookNotFound(Response $response): bool { if ($response->status() !== 404) { return false; } - Log::info("Discord Webhook '$this->webhookId' was not found on discord, discarding future attempts.", [ - 'webhook_id' => $this->webhookId, + Log::info("Discord Webhook '{$this->getWebhookId()}' was not found on discord, discarding future attempts.", [ + 'webhook_id' => $this->getWebhookId(), ]); Cache::put($this->cacheKey('invalid'), true, now()->addWeek()); - DiscordWebhookDied::dispatch($this->webhookId, $this->webhookUrl); + DiscordWebhookDied::dispatch($this->getWebhookId(), $this->getWebhook()); - $this->fail("Webhook '$this->webhookId' was not found (404)"); + $this->fail("Webhook '{$this->getWebhookId()}' was not found (404)"); return true; } diff --git a/app/Jobs/Discord/ReportWebhookJob.php b/app/Jobs/Discord/ReportWebhookJob.php new file mode 100644 index 000000000..b80c1b18a --- /dev/null +++ b/app/Jobs/Discord/ReportWebhookJob.php @@ -0,0 +1,92 @@ +report->status->name; + + if ($this->report->resolver instanceof User) { + $currentStatus = 'Resolved by '.$this->report->resolver->name." ({$this->getDiscordTimestamp($this->report->resolved_at)})"; + } elseif ($this->report->claimer instanceof User) { + $currentStatus = 'Claimed by '.$this->report->claimer->name." ({$this->getDiscordTimestamp($this->report->claimed_at)})"; + } + + return Webhook::make() + ->flag(MessageFlag::IS_COMPONENTS_V2) + ->allowedMentions(AllowedMentions::none()->roles('1494691682422226996')) + ->component( + Container::make( + TextDisplay::make("### Report of type `{$this->report->reason->name}`"), + TextDisplay::make($this->report->description ?? 'No Details'), + Separator::make(), + TextDisplay::make("-# $currentStatus • Created {$this->getDiscordTimestamp($this->report->created_at)} • <@&1494691682422226996>") + ) + ->accentColor(Color::fromHex('#e71d73')), + ActionRow::make( + Button::make() + ->label('View Report') + ->style(ButtonStyle::Link) + ->url(Filament::getPanel('admin')->getResourceUrl($this->report, 'view')), + Button::make() + ->label('View '.Str::title($this->report->reportable_type)) + ->style(ButtonStyle::Link) + ->url(Filament::getPanel('admin')->getResourceUrl($this->report->reportable, 'view')), + Button::make() + ->label('View All Reports') + ->style(ButtonStyle::Link) + ->url(Filament::getPanel('admin')->getResourceUrl($this->report)) + ) + ); + } + + protected function handleResponse(Response $response): void + { + $messageId = $response->json('id'); + + Log::debug('Discord webhook response for report', [ + 'report_id' => $this->report->id, + 'message_id' => $messageId, + ]); + } + + protected function getWebhook(): string + { + return config('services.discord.webhooks.moderation').'?with_components=true&wait=true'; + } + + private function getDiscordTimestamp(CarbonInterface $dateTime): string + { + return "timestamp:R>"; + } +} diff --git a/app/Jobs/Reports/NotifyAboutReportsJob.php b/app/Jobs/Reports/NotifyAboutReportsJob.php index f3d696959..86e3f1bb3 100644 --- a/app/Jobs/Reports/NotifyAboutReportsJob.php +++ b/app/Jobs/Reports/NotifyAboutReportsJob.php @@ -4,14 +4,12 @@ namespace App\Jobs\Reports; +use App\Jobs\Discord\ReportWebhookJob; use App\Models\Report; -use Exception; -use Filament\Facades\Filament; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Queue\Attributes\DebounceFor; use Illuminate\Support\Facades\Cache; -use Spatie\DiscordAlerts\Facades\DiscordAlert; /** * Notify moderation team about new reports @@ -38,20 +36,7 @@ public function handle(): void Cache::put(self::NOTIFICATION_CACHE_KEY_PREFIX.$report->id, true, now()->addWeek()); - $this->notifyDiscord($report); + ReportWebhookJob::dispatch($report); }); } - - private function notifyDiscord(Report $report): void - { - try { - DiscordAlert::to('moderation')->message('<@&1494691682422226996>', [[ - 'title' => 'New Report ('.$report->reason->name.')', - 'url' => Filament::getPanel('admin')->getResourceUrl($report, 'view'), - 'color' => '#e71d73', - ]]); - } catch (Exception $exception) { - report($exception); - } - } } diff --git a/composer.json b/composer.json index e0442783b..907b768c2 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "blade-ui-kit/blade-icons": "^1.9", "filament/filament": "^5.4", "flowframe/laravel-trend": "^0.5.0", + "justinkluever/discord-webhook-builder": "^0.1.0", "kirschbaum-development/commentions": "^0.7", "lara-zeus/spatie-translatable": "^2.0", "laravel/framework": "^13.3", @@ -26,7 +27,6 @@ "sentry/sentry-laravel": "^4.24", "socialiteproviders/twitch": "^5.4", "spatie/laravel-backup": "^10.2", - "spatie/laravel-discord-alerts": "^1.9", "spatie/laravel-translatable": "^6.13", "tales-from-a-dev/tailwind-merge-php": "^0.3.0", "ublabs/blade-simple-icons": "^0.203.0", diff --git a/composer.lock b/composer.lock index 49e83213a..dc224c0f9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "209e86fabd21118bea34e29d0d1f24a8", + "content-hash": "bd97665457e0998da47805787c2d0a10", "packages": [ { "name": "blade-ui-kit/blade-heroicons", @@ -2159,6 +2159,70 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "justinkluever/discord-webhook-builder", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/justinkluever/discord-webhook-builder.git", + "reference": "e19ede6eaa3904db7c854843a0bdb7d9a1d696ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinkluever/discord-webhook-builder/zipball/e19ede6eaa3904db7c854843a0bdb7d9a1d696ac", + "reference": "e19ede6eaa3904db7c854843a0bdb7d9a1d696ac", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "require-dev": { + "laravel/pint": "^v1.29.1", + "pestphp/pest": "^4.7.3", + "pestphp/pest-plugin-arch": "^4.0.2", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "phpstan/phpstan": "^2.2.2", + "rector/rector": "^2.4.5", + "symfony/var-dumper": "^7.0.0|^8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "JustinKluever\\DiscordWebhookBuilder\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Klüver", + "email": "justin@kluever.info" + } + ], + "description": "a simple and fluent builder for discord webhook messages with embeds or components v2", + "homepage": "https://github.com/justinkluever/discord-webhook-builder", + "keywords": [ + "builder", + "component", + "components-v2", + "discord", + "discord-webhook", + "dto", + "embed", + "fluent", + "notification", + "payload", + "php", + "webhook" + ], + "support": { + "issues": "https://github.com/justinkluever/discord-webhook-builder/issues", + "source": "https://github.com/justinkluever/discord-webhook-builder" + }, + "time": "2026-06-13T11:22:39+00:00" + }, { "name": "kirschbaum-development/commentions", "version": "0.7.10", @@ -6779,90 +6843,6 @@ ], "time": "2026-06-01T22:44:58+00:00" }, - { - "name": "spatie/laravel-discord-alerts", - "version": "1.9.1", - "source": { - "type": "git", - "url": "https://github.com/spatie/laravel-discord-alerts.git", - "reference": "f199b6e03dcbcdb06f2a0b0978e37df2c2c397e7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-discord-alerts/zipball/f199b6e03dcbcdb06f2a0b0978e37df2c2c397e7", - "reference": "f199b6e03dcbcdb06f2a0b0978e37df2c2c397e7", - "shasum": "" - }, - "require": { - "illuminate/contracts": "^12.0|^13.0", - "php": "^8.3", - "spatie/laravel-package-tools": "^1.93.0" - }, - "require-dev": { - "larastan/larastan": "^3.0", - "nunomaduro/collision": "^8.0|^9.0", - "orchestra/testbench": "^10.0|^11.0", - "pestphp/pest": "^4.0", - "phpstan/extension-installer": "^1.3.1", - "spatie/laravel-ray": "^1.26" - }, - "type": "library", - "extra": { - "laravel": { - "aliases": { - "Discord": "DiscordAlert" - }, - "providers": [ - "Spatie\\DiscordAlerts\\DiscordAlertsServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Spatie\\DiscordAlerts\\": "src", - "Spatie\\DiscordAlerts\\Database\\Factories\\": "database/factories" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Rias Van der Veken", - "email": "rias@spatie.be", - "role": "Developer" - }, - { - "name": "Niels Vanpachtenbeke", - "email": "niels@spatie.be", - "role": "Developer" - }, - { - "name": "Freek Van der Herten", - "email": "freek@spatie.be", - "role": "Developer" - } - ], - "description": "Send a message to Discord", - "homepage": "https://github.com/spatie/laravel-discord-alerts", - "keywords": [ - "laravel", - "laravel-discord-alerts", - "spatie" - ], - "support": { - "issues": "https://github.com/spatie/laravel-discord-alerts/issues", - "source": "https://github.com/spatie/laravel-discord-alerts/tree/1.9.1" - }, - "funding": [ - { - "url": "https://github.com/spatie", - "type": "github" - } - ], - "time": "2026-03-17T20:51:12+00:00" - }, { "name": "spatie/laravel-package-tools", "version": "1.93.1", diff --git a/config/discord-alerts.php b/config/discord-alerts.php deleted file mode 100644 index 9668fb944..000000000 --- a/config/discord-alerts.php +++ /dev/null @@ -1,43 +0,0 @@ - [ - 'default' => env('DISCORD_ALERT_WEBHOOK'), - 'issues' => env('VHEART_IT_ISSUES_WEBHOOK'), - 'moderation' => env('VHEART_DISCORD_MODERATION_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... - */ - 'job' => DiscordWebhookJob::class, - - /* - * The queue connection that should be used to send the alert. - * - * If not specified, we'll use the default queue connection. - */ - 'queue_connection' => env('DISCORD_ALERT_QUEUE_CONNECTION'), - - /* - * The queue name that should be used to send the alert. Only supported for drivers - * that allow multiple queues (e.g., redis, database, beanstalkd). Ignored for sync and null drivers. - */ - 'queue' => env('DISCORD_ALERT_QUEUE', 'default'), -]; diff --git a/config/services.php b/config/services.php index 469d2aa5c..c29c2c166 100644 --- a/config/services.php +++ b/config/services.php @@ -42,4 +42,11 @@ 'redirect' => env('TWITCH_REDIRECT_URI'), ], + 'discord' => [ + 'webhooks' => [ + 'default' => env('DISCORD_ALERT_WEBHOOK'), + 'issues' => env('VHEART_IT_ISSUES_WEBHOOK'), + 'moderation' => env('VHEART_DISCORD_MODERATION_WEBHOOK'), + ], + ], ]; diff --git a/docker/release/entrypoint.sh b/docker/release/entrypoint.sh index 62f125116..6b56d9b71 100644 --- a/docker/release/entrypoint.sh +++ b/docker/release/entrypoint.sh @@ -60,7 +60,7 @@ elif [ "$INSTANCE" = "worker" ]; then echo "[Entrypoint] Starting Laravel Worker..." exec /app/artisan queue:work \ --name=queue-worker \ - --queue=default \ + --queue=moderation,default,discord-webhooks \ --sleep=3 \ --tries=3 \ --max-time=3600 \