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, ],