Skip to content
Merged
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 @@ -4,25 +4,35 @@

namespace He4rt\IntegrationDiscord\Sync\Actions;

use He4rt\IntegrationDiscord\Sync\DTOs\MatchedInviteDTO;
use He4rt\IntegrationDiscord\Sync\DTOs\PurgeInvitesResultDTO;
use He4rt\IntegrationDiscord\Transport\DiscordConnector;
use He4rt\IntegrationDiscord\Transport\Requests\Invites\DeleteInvite;
use He4rt\IntegrationDiscord\Transport\Requests\Invites\ListGuildInvites;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use JsonException;
use Random\RandomException;
use RuntimeException;
use Saloon\Exceptions\Request\FatalRequestException;
use Saloon\Exceptions\Request\RequestException;
use Throwable;

final readonly class PurgeUnusedInvitesAction
{
private const int MAX_RETRIES = 3;

public function __construct(
private DiscordConnector $connector,
) {}

/**
* @return array{total: int, matched: int, deleted: int, failed: int, invites: list<array{code: string, channel: string, inviter: string, created_at: string}>}
* @throws RandomException
* @throws FatalRequestException
* @throws RequestException
* @throws JsonException
*/
public function execute(string $guildId, bool $dryRun = false, bool $includeExpiring = false): array
public function execute(string $guildId, bool $dryRun = false, bool $includeExpiring = false): PurgeInvitesResultDTO
{
$response = $this->connector->send(new ListGuildInvites($guildId));

Expand All @@ -40,42 +50,24 @@ public function execute(string $guildId, bool $dryRun = false, bool $includeExpi
);

$matches = array_values(array_map(
static fn (array $invite): array => [
'code' => $invite['code'],
'channel' => $invite['channel']['name'] ?? 'unknown',
'inviter' => $invite['inviter']['username'] ?? 'unknown',
'created_at' => isset($invite['created_at'])
? Date::parse($invite['created_at'])->timezone(config('app.display_timezone'))->format('d/m/Y H:i')
: '',
],
MatchedInviteDTO::fromDiscordApi(...),
$unused,
));

if ($dryRun) {
return [
'total' => count($allInvites),
'matched' => count($unused),
'deleted' => 0,
'failed' => 0,
'invites' => $matches,
];
return PurgeInvitesResultDTO::fromDryRun(total: count($allInvites), invites: $matches);
}

$deleted = 0;
$failed = 0;

foreach ($unused as $index => $invite) {
foreach (array_values($unused) as $index => $invite) {
if ($index > 0) {
Sleep::usleep(random_int(200_000, 500_000));
$this->jitteredSleep();
}

try {
$response = $this->connector->send(new DeleteInvite($invite['code']));

if ($response->failed()) {
throw new RuntimeException(sprintf('HTTP %d: %s', $response->status(), $response->body()));
}

$this->deleteInvite($invite['code']);
$deleted++;
} catch (Throwable $e) {
$failed++;
Expand All @@ -86,12 +78,47 @@ public function execute(string $guildId, bool $dryRun = false, bool $includeExpi
}
}

return [
'total' => count($allInvites),
'matched' => count($unused),
'deleted' => $deleted,
'failed' => $failed,
'invites' => $matches,
];
return PurgeInvitesResultDTO::fromPurge(
total: count($allInvites),
invites: $matches,
deleted: $deleted,
failed: $failed,
);
}

/**
* @throws RandomException
*/
private function jitteredSleep(float $baseSeconds = 0.0): void
{
$jitter = random_int(3_000, 6_000) / 1_000;

Sleep::for($baseSeconds + $jitter)->seconds();
}

/**
* @throws RandomException
* @throws FatalRequestException
* @throws RequestException
* @throws JsonException
*/
private function deleteInvite(string $code): void
{
for ($attempt = 0; $attempt <= self::MAX_RETRIES; $attempt++) {
$response = $this->connector->send(new DeleteInvite($code));

if ($response->successful()) {
return;
}

if ($response->status() === 429 && $attempt < self::MAX_RETRIES) {
$retryAfter = (float) ($response->json('retry_after') ?? 1.0);
$this->jitteredSleep($retryAfter);

continue;
}

throw new RuntimeException(sprintf('HTTP %d: %s', $response->status(), $response->body()));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@
namespace He4rt\IntegrationDiscord\Sync\Console;

use He4rt\IntegrationDiscord\Sync\Actions\PurgeUnusedInvitesAction;
use He4rt\IntegrationDiscord\Sync\DTOs\MatchedInviteDTO;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use JsonException;
use Random\RandomException;
use Saloon\Exceptions\Request\FatalRequestException;
use Saloon\Exceptions\Request\RequestException;

#[Description('Purge unused infinite Discord guild invites (max_age=0, uses=0)')]
#[Signature('discord:purge-invites {guild_id?} {--dry-run : List invites without deleting} {--include-expiring : Also purge unused invites that have an expiration time}')]
final class PurgeUnusedInvitesCommand extends Command
{
/**
* @throws RandomException
* @throws FatalRequestException
* @throws RequestException
* @throws JsonException
*/
public function handle(PurgeUnusedInvitesAction $action): int
{
$guildId = $this->argument('guild_id') ?? config('he4rt.discord.guild_id');
Expand All @@ -37,7 +48,7 @@ public function handle(PurgeUnusedInvitesAction $action): int

$result = $action->execute((string) $guildId, $dryRun, $includeExpiring);

if ($result['matched'] === 0) {
if ($result->matched === 0) {
$this->info(sprintf('No %s invites found. Nothing to do.', $scope));

return self::SUCCESS;
Expand All @@ -46,22 +57,22 @@ public function handle(PurgeUnusedInvitesAction $action): int
$this->table(
['Code', 'Inviter', 'Channel', 'Created At'],
array_map(
static fn (array $invite): array => [
$invite['code'],
$invite['inviter'],
$invite['channel'],
$invite['created_at'],
static fn (MatchedInviteDTO $invite): array => [
$invite->code,
$invite->inviter,
$invite->channel,
$invite->createdAt,
],
$result['invites'],
$result->invites,
),
);

$this->newLine();
$this->info(sprintf(
'Found %d %s invite(s) out of %d total.',
$result['matched'],
$result->matched,
$scope,
$result['total'],
$result->total,
));

if ($dryRun) {
Expand All @@ -70,10 +81,10 @@ public function handle(PurgeUnusedInvitesAction $action): int
return self::SUCCESS;
}

$this->info(sprintf('Deleted %d invite(s).', $result['deleted']));
$this->info(sprintf('Deleted %d invite(s).', $result->deleted));

if ($result['failed'] > 0) {
$this->warn(sprintf('%d invite(s) failed to delete. Check logs for details.', $result['failed']));
if ($result->failed > 0) {
$this->warn(sprintf('%d invite(s) failed to delete. Check logs for details.', $result->failed));

return self::FAILURE;
}
Expand Down
32 changes: 32 additions & 0 deletions app-modules/integration-discord/src/Sync/DTOs/MatchedInviteDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace He4rt\IntegrationDiscord\Sync\DTOs;

use Illuminate\Support\Facades\Date;

final readonly class MatchedInviteDTO
{
public function __construct(
public string $code,
public string $channel,
public string $inviter,
public string $createdAt,
) {}

/**
* @param array<string, mixed> $invite
*/
public static function fromDiscordApi(array $invite): self
{
return new self(
code: $invite['code'],
channel: $invite['channel']['name'] ?? 'unknown',
inviter: $invite['inviter']['username'] ?? 'unknown',
createdAt: isset($invite['created_at'])
? Date::parse($invite['created_at'])->timezone(config('app.display_timezone'))->format('d/m/Y H:i')
: '',
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace He4rt\IntegrationDiscord\Sync\DTOs;

final readonly class PurgeInvitesResultDTO
{
/**
* @param list<MatchedInviteDTO> $invites
*/
public function __construct(
public int $total,
public int $matched,
public int $deleted,
public int $failed,
public array $invites,
) {}

/**
* @param list<MatchedInviteDTO> $invites
*/
public static function fromDryRun(int $total, array $invites): self
{
return new self(
total: $total,
matched: count($invites),
deleted: 0,
failed: 0,
invites: $invites,
);
}

/**
* @param list<MatchedInviteDTO> $invites
*/
public static function fromPurge(int $total, array $invites, int $deleted, int $failed): self
{
return new self(
total: $total,
matched: count($invites),
deleted: $deleted,
failed: $failed,
invites: $invites,
);
}
}
Loading