diff --git a/domain/Message/MessageSweepSchedule.php b/domain/Message/MessageSweepSchedule.php new file mode 100644 index 0000000..b81a59d --- /dev/null +++ b/domain/Message/MessageSweepSchedule.php @@ -0,0 +1,92 @@ + + */ + private array $pendingDiscordMessageIds = []; + + /** + * @var array + */ + private array $messageRetryTimes = []; + + /** + * @var array + */ + private array $channelRetryTimes = []; + + public function selectRequestable(MessageList $messageList, CommonTime $now): MessageList + { + $requestable = []; + + /** @var Message $message */ + foreach ($messageList as $message) { + if ($this->isRequestable($message, $now)) { + $requestable[] = $message; + } + } + + return new MessageList($requestable); + } + + public function markPending(Message $message): void + { + $this->pendingDiscordMessageIds[$message->getDiscordMessageId()->getRawValue()] = true; + } + + public function release(Message $message): void + { + unset($this->pendingDiscordMessageIds[$message->getDiscordMessageId()->getRawValue()]); + } + + public function postpone(Message $message, CommonTime $now): void + { + $this->messageRetryTimes = $this->withoutExpired($this->messageRetryTimes, $now); + $this->messageRetryTimes[$message->getDiscordMessageId()->getRawValue()] = $this->retryTime($now); + } + + public function postponeChannel(ChannelId $channelId, CommonTime $now): void + { + $this->channelRetryTimes = $this->withoutExpired($this->channelRetryTimes, $now); + $this->channelRetryTimes[$channelId->getRawValue()] = $this->retryTime($now); + } + + private function isRequestable(Message $message, CommonTime $now): bool + { + $discordMessageId = $message->getDiscordMessageId()->getRawValue(); + + return !isset($this->pendingDiscordMessageIds[$discordMessageId]) + && !$this->isWaiting($this->messageRetryTimes[$discordMessageId] ?? null, $now) + && !$this->isWaiting($this->channelRetryTimes[$message->getChannelId()->getRawValue()] ?? null, $now); + } + + private function isWaiting(?CommonTime $retryTime, CommonTime $now): bool + { + return !is_null($retryTime) && $now->isLessThan($retryTime); + } + + private function retryTime(CommonTime $now): CommonTime + { + return new CommonTime($now->timeStamp() + self::COOLDOWN_SECONDS); + } + + /** + * @param array $retryTimes + * @return array + */ + private function withoutExpired(array $retryTimes, CommonTime $now): array + { + return array_filter($retryTimes, fn (CommonTime $retryTime) => $this->isWaiting($retryTime, $now)); + } +} diff --git a/infra/Discord/DiscordBotClient.php b/infra/Discord/DiscordBotClient.php index 1780179..8cc62c4 100644 --- a/infra/Discord/DiscordBotClient.php +++ b/infra/Discord/DiscordBotClient.php @@ -16,10 +16,8 @@ use Discord\Parts\Channel\Message; use Discord\Parts\Interactions\Interaction; use Discord\WebSockets\Intents; -use Domain\Channel\ChannelId; use Domain\Channel\ChannelRepository; use Domain\Channel\DiscordChannelId; -use Domain\Common\CreatedAt; use Domain\DiscordBot\BotCommand; use Domain\DiscordBot\SlashCommands\SlashCommand; use Domain\Exception\NotFoundException; @@ -33,6 +31,7 @@ public function __construct( private readonly ChannelRepository $channelRepository, private readonly MessageRepository $messageRepository, private readonly MessageBackfill $messageBackfill, + private readonly MessageSweep $messageSweep, ) { } @@ -162,27 +161,7 @@ public function run(): void $this->messageBackfill->run($discord); $discord->getLoop()->addPeriodicTimer(1, function () use ($discord) { - $createdAt = CreatedAt::now()->subDay(1); - - $messageList = $this->messageRepository->oneDayHasPassMessageList($createdAt); - - $channelIdList = $messageList->getChannelIdList(); - - $channelIdList->map(function (ChannelId $channelId) use ($messageList) { - $c = $this->channelRepository->get($channelId); - $channel = $this->discord->getChannel($c->getDiscordChanelId()->getRawValue()); - $channelMessageList = $messageList->getByChannelId($channelId); - - $channelMessageList->map(function (\Domain\Message\Message $message) use ($channel) { - $channel->messages->fetch($message->getDiscordMessageId()->getRawValue())->then( - function (Message $m) { - $m->delete(); - } - ); - }); - - $this->messageRepository->deleteByDiscordMessageIdList($channelMessageList->getDiscordMessageIdList()); - }); + $this->messageSweep->run($discord); }); $discord->on('message', function (Message $message) use ($discord) { $this->message = $message; diff --git a/infra/Discord/MessageSweep.php b/infra/Discord/MessageSweep.php new file mode 100644 index 0000000..6b17079 --- /dev/null +++ b/infra/Discord/MessageSweep.php @@ -0,0 +1,141 @@ +now(); + $messageList = $this->schedule->selectRequestable( + $this->messageRepository->oneDayHasPassMessageList((new CreatedAt($now->timeStamp()))->subDay(1)), + $now, + ); + + /** @var ChannelId $channelId */ + foreach ($messageList->getChannelIdList() as $channelId) { + $this->sweepChannel($discord, $channelId, $messageList->getByChannelId($channelId), $now); + } + } catch (\Throwable $e) { + Log::error("Failed to sweep messages: {$e->getMessage()}", ['exception' => $e]); + } + } + + private function sweepChannel( + Discord $discord, + ChannelId $channelId, + MessageList $messageList, + CommonTime $now, + ): void { + try { + $discordChannelId = $this->channelRepository->get($channelId)->getDiscordChanelId()->getRawValue(); + $discordChannel = $discord->getChannel($discordChannelId); + + if (is_null($discordChannel)) { + $this->schedule->postponeChannel($channelId, $now); + Log::warning("Skipped sweeping channel {$discordChannelId} because the bot cannot resolve it."); + return; + } + + /** @var Message $message */ + foreach ($messageList as $message) { + $this->sweepMessage($discordChannel, $message); + } + } catch (\Throwable $e) { + $this->schedule->postponeChannel($channelId, $now); + Log::error("Failed to sweep channel {$channelId->getRawValue()}: {$e->getMessage()}", ['exception' => $e]); + } + } + + private function sweepMessage(Channel $discordChannel, Message $message): void + { + $discordMessageId = $message->getDiscordMessageId()->getRawValue(); + + $this->schedule->markPending($message); + + resolve(null) + ->then(fn () => $discordChannel->messages->fetch($discordMessageId)) + ->then(fn (Part $discordMessage) => $this->deleteOnDiscord($discordMessage)) + ->then( + fn () => $this->forget($message), + function (\Throwable $e) use ($message): void { + if (!$this->isUnknownMessage($e)) { + throw $e; + } + + $this->forget($message); + }, + ) + ->catch(function (\Throwable $e) use ($message, $discordMessageId): void { + $this->schedule->postpone($message, $this->now()); + Log::error("Failed to sweep message {$discordMessageId}: {$e->getMessage()}", ['exception' => $e]); + }) + ->finally(fn () => $this->schedule->release($message)); + } + + /** + * @return PromiseInterface + */ + private function deleteOnDiscord(Part $discordMessage): PromiseInterface + { + /** @var DiscordMessage $discordMessage */ + if (!$discordMessage->isDeletable()) { + Log::warning( + "Dropping message {$discordMessage->id}: Discord does not delete type {$discordMessage->type}." + ); + + return resolve(null); + } + + return $discordMessage->delete(); + } + + private function forget(Message $message): void + { + $this->messageRepository->deleteByDiscordMessageIdList( + new DiscordMessageIdList([$message->getDiscordMessageId()]) + ); + } + + private function isUnknownMessage(\Throwable $e): bool + { + return $e instanceof DiscordNotFoundException && $e->getCode() === self::UNKNOWN_MESSAGE; + } + + private function now(): CommonTime + { + return new CommonTime(Date::now()->getTimestamp()); + } +} diff --git a/tests/DbIntegration/Infra/Discord/MessageSweepTest.php b/tests/DbIntegration/Infra/Discord/MessageSweepTest.php new file mode 100644 index 0000000..50eaa70 --- /dev/null +++ b/tests/DbIntegration/Infra/Discord/MessageSweepTest.php @@ -0,0 +1,420 @@ + + */ + private array $discordChannels = []; + + /** + * @var array + */ + private array $discordMessages = []; + + /** + * @var array> + */ + private array $failures = []; + + /** + * @var list<\Closure(): void> + */ + private array $waitingResponses = []; + + /** + * @var list + */ + private array $requests = []; + + /** + * @var list + */ + private array $channelLookups = []; + + public function setUp(): void + { + parent::setUp(); + + $this->freezeTime(); + Log::spy(); + + $this->discord = $this->createMock(Discord::class); + $this->discord->method('getChannel')->willReturnCallback(function (string $id): ?Channel { + $this->channelLookups[] = $id; + + return $this->discordChannels[$id] ?? null; + }); + } + + public function testRemovesRecordOnlyAfterDiscordDeletedTheMessage(): void + { + $channel = $this->registerChannel('100'); + $this->postOnDiscord('100', '1001'); + $this->recordMessage($channel, '1001', now()->subDays(2)); + $sweep = $this->app->make(MessageSweep::class); + + $sweep->run($this->discord); + $this->respondToNextRequest(); + + $this->assertSame(['fetch 1001', 'delete 1001'], $this->requests); + $this->assertSame(['1001'], $this->recordedIds()); + + $this->respondToNextRequest(); + + $this->assertArrayNotHasKey('1001', $this->discordMessages); + $this->assertSame([], $this->recordedIds()); + Log::shouldNotHaveReceived('error'); + Log::shouldNotHaveReceived('warning'); + } + + #[DataProvider('provideFailures')] + public function testKeepsRecordWhenDiscordFailsAndWaitsForTheCooldown(string $stage, \Throwable $failure): void + { + $channel = $this->registerChannel('100'); + $this->postOnDiscord('100', '1001'); + $this->recordMessage($channel, '1001', now()->subDays(2)); + $this->failures[$stage]['1001'] = $failure; + $sweep = $this->app->make(MessageSweep::class); + + $sweep->run($this->discord); + $this->respondToAllRequests(); + $requestsAfterFailure = $this->requests; + + $this->assertSame(['1001'], $this->recordedIds()); + + $sweep->run($this->discord); + $this->travel(MessageSweepSchedule::COOLDOWN_SECONDS - 1)->seconds(); + $sweep->run($this->discord); + $this->respondToAllRequests(); + + $this->assertSame($requestsAfterFailure, $this->requests); + Log::shouldHaveReceived('error')->once(); + + $this->travel(1)->seconds(); + $sweep->run($this->discord); + + $this->assertSame('fetch 1001', $this->requests[count($requestsAfterFailure)] ?? null); + } + + public static function provideFailures(): array + { + return [ + 'no access on fetch' => ['fetch', new NoPermissionsException('Forbidden - Missing Access', 50001)], + 'missing permissions on delete' => [ + 'delete', + new NoPermissionsException('Forbidden - Missing Permissions', 50013), + ], + 'server error on delete' => ['delete', new RequestFailedException('Internal Server Error', 500)], + 'unknown channel on fetch' => ['fetch', new NotFoundException('Not Found - Unknown Channel', 10003)], + 'fetch throws instead of rejecting' => ['fetch-throws', new \RuntimeException('Unexpected failure')], + ]; + } + + public function testRemovesRecordWhenDiscordSaysTheMessageIsUnknownOnFetch(): void + { + $channel = $this->registerChannel('100'); + $this->givenDiscordChannel('100'); + $this->recordMessage($channel, '1001', now()->subDays(2)); + + $this->app->make(MessageSweep::class)->run($this->discord); + $this->respondToAllRequests(); + + $this->assertSame(['fetch 1001'], $this->requests); + $this->assertSame([], $this->recordedIds()); + Log::shouldNotHaveReceived('error'); + } + + public function testRemovesRecordWhenDiscordSaysTheMessageIsUnknownOnDelete(): void + { + $channel = $this->registerChannel('100'); + $this->postOnDiscord('100', '1001'); + $this->recordMessage($channel, '1001', now()->subDays(2)); + + $this->app->make(MessageSweep::class)->run($this->discord); + $this->respondToNextRequest(); + unset($this->discordMessages['1001']); + $this->respondToNextRequest(); + + $this->assertSame(['fetch 1001', 'delete 1001'], $this->requests); + $this->assertSame([], $this->recordedIds()); + Log::shouldNotHaveReceived('error'); + } + + public function testRemovesRecordOfMessageTypeThatCannotBeDeletedWithWarning(): void + { + $channel = $this->registerChannel('100'); + $this->givenDiscordChannel('100'); + $this->discordMessages['1001'] = new Message( + $this->discord, + ['id' => '1001', 'type' => Message::TYPE_POLL_RESULT], + true, + ); + $this->recordMessage($channel, '1001', now()->subDays(2)); + + $this->app->make(MessageSweep::class)->run($this->discord); + $this->respondToAllRequests(); + + $this->assertSame(['fetch 1001'], $this->requests); + $this->assertSame([], $this->recordedIds()); + Log::shouldHaveReceived('warning')->once(); + Log::shouldNotHaveReceived('error'); + } + + public function testDoesNotRequestMessageAgainWhileItsRequestIsPending(): void + { + $channel = $this->registerChannel('100'); + $this->postOnDiscord('100', '1001'); + $this->recordMessage($channel, '1001', now()->subDays(2)); + $sweep = $this->app->make(MessageSweep::class); + + $sweep->run($this->discord); + $this->travel(1)->seconds(); + $sweep->run($this->discord); + $this->respondToNextRequest(); + $this->travel(1)->seconds(); + $sweep->run($this->discord); + + $this->assertSame(['fetch 1001', 'delete 1001'], $this->requests); + + $this->respondToAllRequests(); + $sweep->run($this->discord); + + $this->assertSame(['fetch 1001', 'delete 1001'], $this->requests); + $this->assertSame([], $this->recordedIds()); + } + + public function testKeepsRecordsOfChannelTheBotCannotResolveAndWaitsForTheCooldown(): void + { + $missing = $this->registerChannel('100'); + $available = $this->registerChannel('200'); + $this->postOnDiscord('200', '2001'); + $this->recordMessage($missing, '1001', now()->subDays(2)); + $this->recordMessage($missing, '1002', now()->subDays(2)); + $this->recordMessage($available, '2001', now()->subDays(2)); + $sweep = $this->app->make(MessageSweep::class); + + $sweep->run($this->discord); + $this->respondToAllRequests(); + + $this->assertSame(['1001', '1002'], $this->recordedIds()); + $this->assertSame(['fetch 2001', 'delete 2001'], $this->requests); + Log::shouldHaveReceived('warning')->once(); + + $this->travel(MessageSweepSchedule::COOLDOWN_SECONDS - 1)->seconds(); + $sweep->run($this->discord); + + $this->assertSame(['100', '200'], $this->channelLookups); + + $this->travel(1)->seconds(); + $sweep->run($this->discord); + + $this->assertSame(['100', '200', '100'], $this->channelLookups); + $this->assertSame(['1001', '1002'], $this->recordedIds()); + } + + public function testLeavesMessagesYoungerThanOneDayAlone(): void + { + $channel = $this->registerChannel('100'); + $this->postOnDiscord('100', '1001'); + $this->postOnDiscord('100', '1002'); + $this->recordMessage($channel, '1001', now()->subDay()->subSecond()); + $this->recordMessage($channel, '1002', now()->subDay()->addSecond()); + + $this->app->make(MessageSweep::class)->run($this->discord); + $this->respondToAllRequests(); + + $this->assertSame(['fetch 1001', 'delete 1001'], $this->requests); + $this->assertSame(['1002'], $this->recordedIds()); + $this->assertArrayHasKey('1002', $this->discordMessages); + } + + public function testTwoSweepsOnTheSameRecordsRemoveThemWithoutErrors(): void + { + $channel = $this->registerChannel('100'); + $this->postOnDiscord('100', '1001'); + $this->postOnDiscord('100', '1002'); + $this->recordMessage($channel, '1001', now()->subDays(2)); + $this->recordMessage($channel, '1002', now()->subDays(2)); + $first = $this->app->make(MessageSweep::class); + $second = $this->app->make(MessageSweep::class); + + $first->run($this->discord); + $second->run($this->discord); + $this->respondToAllRequests(); + $first->run($this->discord); + $second->run($this->discord); + + $this->assertSame( + [ + 'fetch 1001', 'fetch 1002', 'fetch 1001', 'fetch 1002', + 'delete 1001', 'delete 1002', 'delete 1001', 'delete 1002', + ], + $this->requests, + ); + $this->assertSame([], $this->discordMessages); + $this->assertSame([], $this->recordedIds()); + Log::shouldNotHaveReceived('error'); + Log::shouldNotHaveReceived('warning'); + } + + public function testFailureToLoadRecordsIsLoggedAndNotThrown(): void + { + $messageRepository = $this->createMock(MessageRepository::class); + $messageRepository->method('oneDayHasPassMessageList')->willThrowException( + new \RuntimeException('Connection refused') + ); + $this->app->instance(MessageRepository::class, $messageRepository); + + $this->app->make(MessageSweep::class)->run($this->discord); + + $this->assertSame([], $this->requests); + Log::shouldHaveReceived('error')->once(); + } + + private function registerChannel(string $discordChannelId): int + { + return DB::table('channels')->insertGetId([ + 'discord_channel_id' => $discordChannelId, + 'created_at' => '2026-10-01 12:00:00', + 'updated_at' => '2026-10-01 12:00:00', + ]); + } + + private function recordMessage(int $channel, string $discordMessageId, CarbonInterface $postedAt): void + { + DB::table('messages')->insert([ + 'channel_id' => $channel, + 'discord_message_id' => $discordMessageId, + 'created_at' => $postedAt->toDateTimeString(), + 'updated_at' => $postedAt->toDateTimeString(), + ]); + } + + private function givenDiscordChannel(string $discordChannelId): void + { + if (isset($this->discordChannels[$discordChannelId])) { + return; + } + + $messages = $this->createMock(DiscordMessageRepository::class); + $messages->method('fetch')->willReturnCallback(fn (string $id): PromiseInterface => $this->fetchOnDiscord($id)); + + $channel = $this->createMock(Channel::class); + $channel->method('__get')->willReturnCallback( + fn (string $key) => $key === 'messages' ? $messages : null + ); + + $this->discordChannels[$discordChannelId] = $channel; + } + + private function postOnDiscord(string $discordChannelId, string $id): void + { + $this->givenDiscordChannel($discordChannelId); + + $message = $this->createMock(Message::class); + $message->method('isDeletable')->willReturn(true); + $message->method('delete')->willReturnCallback(fn (): PromiseInterface => $this->deleteOnDiscord($id)); + + $this->discordMessages[$id] = $message; + } + + private function fetchOnDiscord(string $id): PromiseInterface + { + $this->requests[] = "fetch {$id}"; + + if (isset($this->failures['fetch-throws'][$id])) { + throw $this->failures['fetch-throws'][$id]; + } + + return $this->waitForResponse(function (Deferred $deferred) use ($id): void { + match (true) { + isset($this->failures['fetch'][$id]) => $deferred->reject($this->failures['fetch'][$id]), + isset($this->discordMessages[$id]) => $deferred->resolve($this->discordMessages[$id]), + default => $deferred->reject(self::unknownMessage()), + }; + }); + } + + private function deleteOnDiscord(string $id): PromiseInterface + { + $this->requests[] = "delete {$id}"; + + return $this->waitForResponse(function (Deferred $deferred) use ($id): void { + if (isset($this->failures['delete'][$id])) { + $deferred->reject($this->failures['delete'][$id]); + return; + } + + if (!isset($this->discordMessages[$id])) { + $deferred->reject(self::unknownMessage()); + return; + } + + unset($this->discordMessages[$id]); + $deferred->resolve(null); + }); + } + + /** + * @param \Closure(Deferred): void $respond + */ + private function waitForResponse(\Closure $respond): PromiseInterface + { + $deferred = new Deferred(); + $this->waitingResponses[] = fn () => $respond($deferred); + + return $deferred->promise(); + } + + private function respondToNextRequest(): void + { + $respond = array_shift($this->waitingResponses) ?? $this->fail('No request is waiting for a response.'); + $respond(); + } + + private function respondToAllRequests(): void + { + while ($this->waitingResponses !== []) { + $this->respondToNextRequest(); + } + } + + private static function unknownMessage(): NotFoundException + { + return new NotFoundException('Not Found - {"message": "Unknown Message", "code": 10008}', 10008); + } + + /** + * @return list + */ + private function recordedIds(): array + { + return DB::table('messages')->orderBy('discord_message_id')->pluck('discord_message_id')->all(); + } +} diff --git a/tests/Unit/Domain/Message/MessageSweepScheduleTest.php b/tests/Unit/Domain/Message/MessageSweepScheduleTest.php new file mode 100644 index 0000000..bb015a2 --- /dev/null +++ b/tests/Unit/Domain/Message/MessageSweepScheduleTest.php @@ -0,0 +1,117 @@ +assertSame( + ['1001', '1002', '2001'], + $this->requestableAt($schedule, self::NOW), + ); + } + + public function testPendingMessageIsNotRequestableUntilReleased(): void + { + $schedule = new MessageSweepSchedule(); + $schedule->markPending($this->message(1, 1, '1001')); + + $this->assertSame(['1002', '2001'], $this->requestableAt($schedule, self::NOW)); + $this->assertSame(['1002', '2001'], $this->requestableAt($schedule, self::NOW + 3600)); + + $schedule->release($this->message(1, 1, '1001')); + + $this->assertSame(['1001', '1002', '2001'], $this->requestableAt($schedule, self::NOW)); + } + + public function testPostponedMessageWaitsForTheCooldown(): void + { + $schedule = new MessageSweepSchedule(); + $schedule->postpone($this->message(1, 1, '1001'), new CommonTime(self::NOW)); + + $this->assertSame( + ['1002', '2001'], + $this->requestableAt($schedule, self::NOW + MessageSweepSchedule::COOLDOWN_SECONDS - 1), + ); + $this->assertSame( + ['1001', '1002', '2001'], + $this->requestableAt($schedule, self::NOW + MessageSweepSchedule::COOLDOWN_SECONDS), + ); + } + + public function testPostponedChannelHoldsEveryMessageOfThatChannelForTheCooldown(): void + { + $schedule = new MessageSweepSchedule(); + $schedule->postponeChannel(new ChannelId(1), new CommonTime(self::NOW)); + + $this->assertSame( + ['2001'], + $this->requestableAt($schedule, self::NOW + MessageSweepSchedule::COOLDOWN_SECONDS - 1), + ); + $this->assertSame( + ['1001', '1002', '2001'], + $this->requestableAt($schedule, self::NOW + MessageSweepSchedule::COOLDOWN_SECONDS), + ); + } + + public function testPostponingAgainRestartsTheCooldown(): void + { + $secondFailure = self::NOW + MessageSweepSchedule::COOLDOWN_SECONDS; + $schedule = new MessageSweepSchedule(); + $schedule->postpone($this->message(1, 1, '1001'), new CommonTime(self::NOW)); + $schedule->postpone($this->message(1, 1, '1001'), new CommonTime($secondFailure)); + + $this->assertSame( + ['1002', '2001'], + $this->requestableAt($schedule, $secondFailure + MessageSweepSchedule::COOLDOWN_SECONDS - 1), + ); + $this->assertSame( + ['1001', '1002', '2001'], + $this->requestableAt($schedule, $secondFailure + MessageSweepSchedule::COOLDOWN_SECONDS), + ); + } + + private function messageList(): MessageList + { + return new MessageList([ + $this->message(1, 1, '1001'), + $this->message(2, 1, '1002'), + $this->message(3, 2, '2001'), + ]); + } + + private function message(int $id, int $channelId, string $discordMessageId): Message + { + return new Message( + new MessageId($id), + new ChannelId($channelId), + new DiscordMessageId($discordMessageId), + ); + } + + /** + * @return string[] + */ + private function requestableAt(MessageSweepSchedule $schedule, int $now): array + { + return $schedule->selectRequestable($this->messageList(), new CommonTime($now)) + ->getDiscordMessageIdList() + ->getRawValueList(); + } +}