Merge pull request 'Implement Discord webhook delivery' (#59) from codex/v0.5-webhook-notification/05-discord-webhook-delivery into main
Reviewed-on: OpenSource/KanbanGit#59
This commit was merged in pull request #59.
This commit is contained in:
@@ -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,29 @@ 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,
|
||||
'allowed_mentions' => [
|
||||
'parse' => [],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
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 +305,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 ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user