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
92 changes: 92 additions & 0 deletions domain/Message/MessageSweepSchedule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace Domain\Message;

use Domain\Channel\ChannelId;
use Domain\Common\CommonTime;

class MessageSweepSchedule
{
public const int COOLDOWN_SECONDS = 300;

/**
* @var array<array-key, true>
*/
private array $pendingDiscordMessageIds = [];

/**
* @var array<array-key, CommonTime>
*/
private array $messageRetryTimes = [];

/**
* @var array<array-key, CommonTime>
*/
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<array-key, CommonTime> $retryTimes
* @return array<array-key, CommonTime>
*/
private function withoutExpired(array $retryTimes, CommonTime $now): array
{
return array_filter($retryTimes, fn (CommonTime $retryTime) => $this->isWaiting($retryTime, $now));
}
}
25 changes: 2 additions & 23 deletions infra/Discord/DiscordBotClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,6 +31,7 @@ public function __construct(
private readonly ChannelRepository $channelRepository,
private readonly MessageRepository $messageRepository,
private readonly MessageBackfill $messageBackfill,
private readonly MessageSweep $messageSweep,
) {
}

Expand Down Expand Up @@ -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;
Expand Down
141 changes: 141 additions & 0 deletions infra/Discord/MessageSweep.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

namespace Infra\Discord;

use Discord\Discord;
use Discord\Http\Exceptions\NotFoundException as DiscordNotFoundException;
use Discord\Parts\Channel\Channel;
use Discord\Parts\Channel\Message as DiscordMessage;
use Discord\Parts\Part;
use Domain\Channel\ChannelId;
use Domain\Channel\ChannelRepository;
use Domain\Common\CommonTime;
use Domain\Common\CreatedAt;
use Domain\Message\DiscordMessageIdList;
use Domain\Message\Message;
use Domain\Message\MessageList;
use Domain\Message\MessageRepository;
use Domain\Message\MessageSweepSchedule;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Log;
use React\Promise\PromiseInterface;

use function React\Promise\resolve;

class MessageSweep
{
private const int UNKNOWN_MESSAGE = 10008;

public function __construct(
private readonly ChannelRepository $channelRepository,
private readonly MessageRepository $messageRepository,
private readonly MessageSweepSchedule $schedule,
) {
}

public function run(Discord $discord): void
{
try {
$now = $this->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<mixed>
*/
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());
}
}
Loading
Loading