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
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions docs/reference/messaging/direct-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- Generated from the source by `composer docs`. Do not edit by hand. -->

# 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* | |

60 changes: 60 additions & 0 deletions src/Messaging/DirectMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

namespace Tempcord\Messaging;

use Tempcord\Discord\Discord;
use Tempcord\Discord\Rest\Helpers\Channel\MessageBuilder;
use Tempest\Log\Logger;
use Throwable;

use function React\Async\await;

/**
* Writes to a member privately, on a best-effort basis.
*
* 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. That is an ordinary
* state of affairs rather than a failure — and a bot that lets it throw
* abandons whatever it was in the middle of, which is usually the punishment or
* the decision the message was only announcing.
*
* So this reports whether the message landed instead of throwing, leaving the
* caller to decide whether it mattered. Reaching a member is almost never a
* precondition for the thing being announced.
*/
final readonly class DirectMessage
{
public function __construct(
private Discord $discord,
private Logger $logger,
) {}

/**
* @return bool whether the member could be reached
*/
public function send(string $userId, MessageBuilder|string $message): bool
{
$message = is_string($message)
? MessageBuilder::new()->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;
}
}
}
4 changes: 4 additions & 0 deletions tests/Doubles/RecordingLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ final class RecordingLogger extends AbstractLogger implements Logger
/** @var list<string> */
public array $messages = [];

/** @var list<string> 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;
}
}
110 changes: 110 additions & 0 deletions tests/Unit/Messaging/DirectMessageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

namespace Tempcord\Tests\Unit\Messaging;

use PHPUnit\Framework\Attributes\CoversClass;
use Tempcord\Discord\Rest\Helpers\Channel\MessageBuilder;
use Tempcord\Messaging\DirectMessage;
use Tempcord\Tests\Doubles\FakeDiscord;
use Tempcord\Tests\Doubles\RecordingHttp;
use Tempcord\Tests\Doubles\RecordingLogger;
use Tempcord\Tests\Unit\TestCase;

use function React\Async\async;
use function React\Async\await;

#[CoversClass(DirectMessage::class)]
final class DirectMessageTest extends TestCase
{
private const string USER = '254766810296090626';

private RecordingHttp $http;

private RecordingLogger $logger;

private function directMessage(string ...$refusing): DirectMessage
{
$this->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)));
}
}
3 changes: 3 additions & 0 deletions tools/src/ApiReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
'configuration' => [
\Tempcord\TempcordConfig::class,
],
'messaging' => [
\Tempcord\Messaging\DirectMessage::class,
],
'enums' => [
\Tempcord\Enums\DiscordLocale::class,
],
Expand Down
Loading