Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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);

Expand All @@ -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');
Expand All @@ -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;
Expand All @@ -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,
]);
Expand All @@ -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'));
Expand All @@ -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,
]);

Expand All @@ -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;
}
Expand Down
92 changes: 92 additions & 0 deletions app/Jobs/Discord/ReportWebhookJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace App\Jobs\Discord;

use App\Models\Report;
use App\Models\User;
use Carbon\CarbonInterface;
use Filament\Facades\Filament;
use Illuminate\Http\Client\Response;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\Attributes\Queue;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use JustinKluever\DiscordWebhookBuilder\Components\ActionRow;
use JustinKluever\DiscordWebhookBuilder\Components\Button;
use JustinKluever\DiscordWebhookBuilder\Components\Container;
use JustinKluever\DiscordWebhookBuilder\Components\Separator;
use JustinKluever\DiscordWebhookBuilder\Components\TextDisplay;
use JustinKluever\DiscordWebhookBuilder\Enums\Components\ButtonStyle;
use JustinKluever\DiscordWebhookBuilder\Enums\Support\MessageFlag;
use JustinKluever\DiscordWebhookBuilder\Support\Color;
use JustinKluever\DiscordWebhookBuilder\Support\Webhook\AllowedMentions;
use JustinKluever\DiscordWebhookBuilder\Webhook;

#[DeleteWhenMissingModels]
#[Queue('moderation')]
class ReportWebhookJob extends BaseDiscordWebhookJob
{
public function __construct(
private readonly Report $report,
) {}

protected function getPayload(): Webhook
{
$currentStatus = $this->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 "<t:$dateTime->timestamp:R>";
}
}
19 changes: 2 additions & 17 deletions app/Jobs/Reports/NotifyAboutReportsJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
}
}
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading