Implement Discord webhook delivery

This commit is contained in:
Eric Peters
2026-04-24 18:29:32 +02:00
parent a1dc615958
commit 4908064f6d
6 changed files with 429 additions and 24 deletions
@@ -19,6 +19,8 @@ class DeliverNotificationAction implements ShouldQueue
{
use Queueable;
private const int DISCORD_CONTENT_LIMIT = 2000;
public int $tries = 3;
public int $timeout = 15;
@@ -45,22 +47,38 @@ class DeliverNotificationAction implements ShouldQueue
return;
}
if ($notificationAction->channel !== NotificationChannel::Webhook) {
if (! in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) {
return;
}
$deliveryLog = $this->resolveDeliveryLog($notificationAction);
$attemptCount = $this->attempts();
$attemptedAt = CarbonImmutable::now();
$discordMessage = null;
if ($notificationAction->channel === NotificationChannel::Discord) {
$discordMessage = app(RenderDiscordMessageTemplate::class)->handle(
$notificationAction->message_template,
$this->payload,
);
$validationError = $this->discordMessageValidationError($discordMessage);
if ($validationError !== null) {
$this->markFailedWithoutResponse($deliveryLog, $attemptCount, $attemptedAt, $validationError);
$this->fail(new \RuntimeException($validationError));
return;
}
}
try {
$response = Http::acceptJson()
->asJson()
->connectTimeout(5)
->timeout(10)
->post($notificationAction->delivery_url, $this->payload->toArray());
$response = match ($notificationAction->channel) {
NotificationChannel::Discord => $this->sendDiscordRequest($notificationAction, $discordMessage ?? ''),
NotificationChannel::Webhook => $this->sendWebhookRequest($notificationAction),
};
} catch (ConnectionException $exception) {
$this->handleConnectionFailure($deliveryLog, $attemptCount, $attemptedAt, $exception);
$this->handleConnectionFailure($deliveryLog, $notificationAction->channel, $attemptCount, $attemptedAt, $exception);
return;
}
@@ -72,14 +90,14 @@ class DeliverNotificationAction implements ShouldQueue
}
if ($this->shouldRetry($response) && $this->hasAttemptsRemaining($attemptCount)) {
$this->markRetryable($deliveryLog, $response, $attemptCount, $attemptedAt);
$this->markRetryable($deliveryLog, $notificationAction->channel, $response, $attemptCount, $attemptedAt);
$this->release($this->backoffDelay($attemptCount));
return;
}
$this->markFailed($deliveryLog, $response, $attemptCount, $attemptedAt);
$this->fail(new \RuntimeException($this->responseFailureDescription($response)));
$this->markFailed($deliveryLog, $notificationAction->channel, $response, $attemptCount, $attemptedAt);
$this->fail(new \RuntimeException($this->responseFailureDescription($notificationAction->channel, $response)));
}
public function failed(?Throwable $exception): void
@@ -101,11 +119,12 @@ class DeliverNotificationAction implements ShouldQueue
private function handleConnectionFailure(
NotificationDeliveryLog $deliveryLog,
NotificationChannel $channel,
int $attemptCount,
CarbonImmutable $attemptedAt,
ConnectionException $exception,
): void {
$errorExcerpt = $this->excerpt($exception->getMessage()) ?? 'Webhook request failed.';
$errorExcerpt = $this->excerpt($exception->getMessage()) ?? $this->requestFailureDescription($channel);
if ($this->hasAttemptsRemaining($attemptCount)) {
$deliveryLog->update([
@@ -155,6 +174,7 @@ class DeliverNotificationAction implements ShouldQueue
private function markRetryable(
NotificationDeliveryLog $deliveryLog,
NotificationChannel $channel,
Response $response,
int $attemptCount,
CarbonImmutable $attemptedAt,
@@ -163,7 +183,7 @@ class DeliverNotificationAction implements ShouldQueue
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Retryable,
'delivered_at' => null,
'error_excerpt' => $this->responseFailureDescription($response),
'error_excerpt' => $this->responseFailureDescription($channel, $response),
'http_status' => $response->status(),
'last_attempted_at' => $attemptedAt,
'response_excerpt' => $this->responseExcerpt($response),
@@ -172,6 +192,7 @@ class DeliverNotificationAction implements ShouldQueue
private function markFailed(
NotificationDeliveryLog $deliveryLog,
NotificationChannel $channel,
Response $response,
int $attemptCount,
CarbonImmutable $attemptedAt,
@@ -180,13 +201,30 @@ class DeliverNotificationAction implements ShouldQueue
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Failed,
'delivered_at' => null,
'error_excerpt' => $this->responseFailureDescription($response),
'error_excerpt' => $this->responseFailureDescription($channel, $response),
'http_status' => $response->status(),
'last_attempted_at' => $attemptedAt,
'response_excerpt' => $this->responseExcerpt($response),
]);
}
private function markFailedWithoutResponse(
NotificationDeliveryLog $deliveryLog,
int $attemptCount,
CarbonImmutable $attemptedAt,
string $errorExcerpt,
): void {
$deliveryLog->update([
'attempt_count' => $attemptCount,
'delivery_status' => NotificationDeliveryStatus::Failed,
'delivered_at' => null,
'error_excerpt' => $errorExcerpt,
'http_status' => null,
'last_attempted_at' => $attemptedAt,
'response_excerpt' => null,
]);
}
private function resolveDeliveryLog(NotificationAction $notificationAction): NotificationDeliveryLog
{
$deliveryLog = $this->loadDeliveryLog();
@@ -226,6 +264,26 @@ class DeliverNotificationAction implements ShouldQueue
return $response->serverError() || in_array($response->status(), [408, 429], true);
}
private function sendDiscordRequest(NotificationAction $notificationAction, string $message): Response
{
return Http::acceptJson()
->asJson()
->connectTimeout(5)
->timeout(10)
->post($notificationAction->delivery_url, [
'content' => $message,
]);
}
private function sendWebhookRequest(NotificationAction $notificationAction): Response
{
return Http::acceptJson()
->asJson()
->connectTimeout(5)
->timeout(10)
->post($notificationAction->delivery_url, $this->payload->toArray());
}
private function hasAttemptsRemaining(int $attemptCount): bool
{
return $attemptCount < $this->tries;
@@ -244,15 +302,45 @@ class DeliverNotificationAction implements ShouldQueue
return $this->excerpt($response->body()) ?? $this->excerpt($response->reason());
}
private function responseFailureDescription(Response $response): string
private function responseFailureDescription(NotificationChannel $channel, Response $response): string
{
$reason = $this->excerpt($response->reason());
$subject = $this->deliverySubject($channel);
if ($reason === null) {
return "Webhook returned HTTP {$response->status()}.";
return "{$subject} returned HTTP {$response->status()}.";
}
return "Webhook returned HTTP {$response->status()} {$reason}.";
return "{$subject} returned HTTP {$response->status()} {$reason}.";
}
private function requestFailureDescription(NotificationChannel $channel): string
{
return match ($channel) {
NotificationChannel::Discord => 'Discord webhook request failed.',
NotificationChannel::Webhook => 'Webhook request failed.',
};
}
private function deliverySubject(NotificationChannel $channel): string
{
return match ($channel) {
NotificationChannel::Discord => 'Discord webhook',
NotificationChannel::Webhook => 'Webhook',
};
}
private function discordMessageValidationError(string $message): ?string
{
if (trim($message) === '') {
return 'Discord message content cannot be empty.';
}
if (Str::length($message) > self::DISCORD_CONTENT_LIMIT) {
return 'Discord message content exceeds the 2000 character limit.';
}
return null;
}
/**
@@ -19,7 +19,7 @@ class QueueTicketStatusChangedNotificationDeliveries
->each(function (NotificationAction $notificationAction) use ($event): void {
$notificationDeliveryLogId = null;
if ($notificationAction->channel === NotificationChannel::Webhook) {
if (in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) {
$notificationDeliveryLogId = NotificationDeliveryLog::query()->create([
'attempt_count' => 0,
'channel' => $notificationAction->channel,
@@ -0,0 +1,21 @@
<?php
namespace App\Actions\Notifications;
use Illuminate\Support\Str;
class RenderDiscordMessageTemplate
{
public function handle(?string $messageTemplate, TicketStatusChangedPayload $payload): string
{
return strtr((string) Str::of((string) $messageTemplate), [
'$url' => $payload->ticketUrl,
'$ticket_key' => $payload->ticketKey,
'$ticket_title' => $payload->ticketTitle,
'$project_name' => $payload->projectName,
'$status_from' => $payload->fromStatusLabel,
'$status_to' => $payload->toStatusLabel,
'$actor_name' => $payload->actorName ?? '',
]);
}
}