From 27584bd276149372005d1c18c361a9ac36f5e308 Mon Sep 17 00:00:00 2001 From: "Vladyslav G." Date: Wed, 2 Sep 2026 03:15:06 +0200 Subject: [PATCH] feat(messaging): write to a member without risking the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member who has closed their direct messages, who shares no server with the bot any more, or who has blocked it cannot be written to. Letting that throw abandons whatever the caller was in the middle of — which is usually the punishment or the decision the message was only announcing, so the member escapes the ban by having closed their DMs. DirectMessage reports whether the message landed and leaves the caller to decide whether it mattered. Being unable to reach someone is logged at info rather than error: it says nothing about the health of the bot, and reporting it loudly only teaches whoever reads the log to ignore it. RecordingLogger now keeps the level each message was logged at, so that last part is something a test can hold the framework to. --- docs/README.md | 2 + docs/index.json | 28 ++++++ docs/reference/index.md | 4 + docs/reference/messaging/direct-message.md | 17 ++++ src/Messaging/DirectMessage.php | 60 +++++++++++ tests/Doubles/RecordingLogger.php | 4 + tests/Unit/Messaging/DirectMessageTest.php | 110 +++++++++++++++++++++ tools/src/ApiReflector.php | 3 + 8 files changed, 228 insertions(+) create mode 100644 docs/reference/messaging/direct-message.md create mode 100644 src/Messaging/DirectMessage.php create mode 100644 tests/Unit/Messaging/DirectMessageTest.php diff --git a/docs/README.md b/docs/README.md index 7eb526f..dfc6a5d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,8 @@ Generated from the source, so it describes what the framework actually does. **Configuration** — [TempcordConfig](reference/configuration/tempcord-config.md) +**Messaging** — [DirectMessage](reference/messaging/direct-message.md) + **Enums** — [DiscordLocale](reference/enums/discord-locale.md) **Plugins** — [Plugin](reference/plugins/plugin.md) diff --git a/docs/index.json b/docs/index.json index bb16faf..33b506c 100644 --- a/docs/index.json +++ b/docs/index.json @@ -460,6 +460,34 @@ "methods": [] } ], + "messaging": [ + { + "name": "DirectMessage", + "fqcn": "Tempcord\\Messaging\\DirectMessage", + "kind": "class", + "target": null, + "summary": "Writes to a member privately, on a best-effort basis.", + "slug": "reference/messaging/direct-message", + "parameters": [ + { + "name": "discord", + "type": "Discord", + "default": null, + "required": true, + "summary": "" + }, + { + "name": "logger", + "type": "Logger", + "default": null, + "required": true, + "summary": "" + } + ], + "cases": [], + "methods": [] + } + ], "enums": [ { "name": "DiscordLocale", diff --git a/docs/reference/index.md b/docs/reference/index.md index 69fd048..67dc016 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -31,6 +31,10 @@ - [TempcordConfig](configuration/tempcord-config.md) +## Messaging + +- [DirectMessage](messaging/direct-message.md) — Writes to a member privately, on a best-effort basis. + ## Enums - [DiscordLocale](enums/discord-locale.md) — The locales Discord accepts for name and description localizations. diff --git a/docs/reference/messaging/direct-message.md b/docs/reference/messaging/direct-message.md new file mode 100644 index 0000000..c7990d9 --- /dev/null +++ b/docs/reference/messaging/direct-message.md @@ -0,0 +1,17 @@ + + +# DirectMessage + +Writes to a member privately, on a best-effort basis. + +```php +use Tempcord\Messaging\DirectMessage; +``` + +## Parameters + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `discord` | `Discord` | *required* | | +| `logger` | `Logger` | *required* | | + diff --git a/src/Messaging/DirectMessage.php b/src/Messaging/DirectMessage.php new file mode 100644 index 0000000..73a47d3 --- /dev/null +++ b/src/Messaging/DirectMessage.php @@ -0,0 +1,60 @@ +setContent($message) + : $message; + + try { + $channel = await($this->discord->rest->user->createDm($userId)); + + await($this->discord->rest->channel->createMessage($channel->id, $message)); + + return true; + } catch (Throwable $throwable) { + /* + * Logged at info: closed DMs are the common case and say nothing + * about the health of the bot, so reporting them as errors would + * only teach whoever reads the log to ignore it. + */ + $this->logger->info( + 'Could not write to ' . $userId . ': ' . $throwable->getMessage(), + ); + + return false; + } + } +} diff --git a/tests/Doubles/RecordingLogger.php b/tests/Doubles/RecordingLogger.php index fe6f74e..9074070 100644 --- a/tests/Doubles/RecordingLogger.php +++ b/tests/Doubles/RecordingLogger.php @@ -17,8 +17,12 @@ final class RecordingLogger extends AbstractLogger implements Logger /** @var list */ public array $messages = []; + /** @var list the level each message was logged at, in step with $messages */ + public array $levels = []; + public function log($level, string|Stringable $message, array $context = []): void { $this->messages[] = (string) $message; + $this->levels[] = (string) $level; } } diff --git a/tests/Unit/Messaging/DirectMessageTest.php b/tests/Unit/Messaging/DirectMessageTest.php new file mode 100644 index 0000000..e543a7e --- /dev/null +++ b/tests/Unit/Messaging/DirectMessageTest.php @@ -0,0 +1,110 @@ +http = new RecordingHttp(failPostsMatching: $refusing); + $this->logger = new RecordingLogger(); + + return new DirectMessage(new FakeDiscord($this->http), $this->logger); + } + + /** + * The REST calls are awaited, so this runs inside a fiber exactly as the + * dispatcher runs a handler. + */ + private function send(DirectMessage $dm, MessageBuilder|string $message): bool + { + return await(async(static fn() => $dm->send(self::USER, $message))()); + } + + private function posted(string $needle): array + { + return array_values(array_filter( + $this->http->posts, + static fn(array $post) => str_contains($post['url'], $needle), + )); + } + + public function test_it_opens_a_private_channel_and_writes_to_it(): void + { + $dm = $this->directMessage(); + + $sent = $this->send($dm, MessageBuilder::new()->setContent('You have been warned.')); + + $this->assertTrue($sent); + $this->assertNotSame([], $this->posted('users/@me/channels')); + $this->assertNotSame([], $this->posted('messages')); + } + + /** + * Most of what a bot says privately is one line, and building a message for + * it says nothing the string does not. + */ + public function test_a_plain_string_is_sent_as_the_content(): void + { + $dm = $this->directMessage(); + + $this->send($dm, 'You have been warned.'); + + $this->assertSame('You have been warned.', $this->posted('messages')[0]['content']['content']); + } + + /** + * A member with closed DMs is an ordinary state of affairs, not a failure: + * letting it throw would abandon whatever the caller was in the middle of, + * which is usually the punishment the message was only announcing. + */ + public function test_a_member_who_cannot_be_reached_is_reported_rather_than_thrown_at(): void + { + $dm = $this->directMessage('users/@me/channels'); + + $this->assertFalse($this->send($dm, 'You have been warned.')); + } + + public function test_a_message_refused_after_the_channel_opened_is_also_reported(): void + { + $dm = $this->directMessage('messages'); + + $this->assertFalse($this->send($dm, 'You have been warned.')); + $this->assertNotSame([], $this->posted('users/@me/channels')); + } + + /** + * Closed DMs say nothing about the health of the bot, so reporting them as + * errors would only teach whoever reads the log to ignore it. + */ + public function test_being_unable_to_reach_someone_is_noted_but_not_as_an_error(): void + { + $dm = $this->directMessage('users/@me/channels'); + + $this->send($dm, 'You have been warned.'); + + $this->assertNotSame([], array_filter( + $this->logger->messages, + static fn(string $message) => str_contains($message, self::USER), + )); + $this->assertSame(['info'], array_values(array_unique($this->logger->levels))); + } +} diff --git a/tools/src/ApiReflector.php b/tools/src/ApiReflector.php index f8a176a..e24ec06 100644 --- a/tools/src/ApiReflector.php +++ b/tools/src/ApiReflector.php @@ -48,6 +48,9 @@ 'configuration' => [ \Tempcord\TempcordConfig::class, ], + 'messaging' => [ + \Tempcord\Messaging\DirectMessage::class, + ], 'enums' => [ \Tempcord\Enums\DiscordLocale::class, ],