diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md index 5f0d8dd96e02e..ed823933c78dc 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md +++ b/src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.4 +--- + + * Add `DiscordBotTransport` + 6.2 --- diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordBotTransport.php b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordBotTransport.php new file mode 100644 index 0000000000000..d99cbebd5309f --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordBotTransport.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Discord; + +use Symfony\Component\Notifier\Exception\LengthException; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Piot + * @author Tomas Norkūnas + */ +final class DiscordBotTransport extends AbstractTransport +{ + protected const HOST = 'discord.com'; + + private const SUBJECT_LIMIT = 2000; + + public function __construct( + #[\SensitiveParameter] private string $token, + ?HttpClientInterface $client = null, + ?EventDispatcherInterface $dispatcher = null, + ) { + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return \sprintf('discord+bot://%s', $this->getEndpoint()); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage && $message->getOptions() instanceof DiscordOptions; + } + + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof ChatMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); + } + + $channelId = $message->getOptions()?->getRecipientId(); + if (null === $channelId) { + throw new LogicException('Missing configured recipient id on Discord message.'); + } + + $options = $message->getOptions()?->toArray() ?? []; + $options['content'] = $message->getSubject(); + + if (mb_strlen($options['content'], 'UTF-8') > self::SUBJECT_LIMIT) { + throw new LengthException(\sprintf('The subject length of a Discord message must not exceed %d characters.', self::SUBJECT_LIMIT)); + } + + $endpoint = \sprintf('https://%s/api/channels/%s/messages', $this->getEndpoint(), $channelId); + $response = $this->client->request('POST', $endpoint, [ + 'headers' => [ + 'Authorization' => 'Bot '.$this->token, + ], + 'json' => array_filter($options), + ]); + + try { + $statusCode = $response->getStatusCode(); + } catch (TransportExceptionInterface $e) { + throw new TransportException('Could not reach the remote Discord server.', $response, 0, $e); + } + + if (200 !== $statusCode) { + $result = $response->toArray(false); + + if (401 === $statusCode) { + $originalContent = $message->getSubject(); + $errorMessage = $result['message']; + $errorCode = $result['code']; + throw new TransportException(\sprintf('Unable to post the Discord message: "%s" (%d: "%s").', $originalContent, $errorCode, $errorMessage), $response); + } + + if (400 === $statusCode) { + $originalContent = $message->getSubject(); + + $errorMessage = ''; + foreach ($result as $fieldName => $message) { + $message = \is_array($message) ? implode(' ', $message) : $message; + $errorMessage .= $fieldName.': '.$message.' '; + } + + $errorMessage = trim($errorMessage); + throw new TransportException(\sprintf('Unable to post the Discord message: "%s" (%s).', $originalContent, $errorMessage), $response); + } + + throw new TransportException(\sprintf('Unable to post the Discord message: "%s" (Status Code: %d).', $message->getSubject(), $statusCode), $response); + } + + return new SentMessage($message, (string) $this); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php index 8eb0c436abdf9..e225412f9a888 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php @@ -17,9 +17,15 @@ /** * @author Karoly Gossler + * @author Tomas Norkūnas */ final class DiscordOptions implements MessageOptionsInterface { + /** + * @var non-empty-string|null + */ + private ?string $recipientId = null; + public function __construct( private array $options = [], ) { @@ -30,9 +36,24 @@ public function toArray(): array return $this->options; } - public function getRecipientId(): string + /** + * @param non-empty-string $id + * + * @return $this + */ + public function recipient(string $id): static + { + $this->recipientId = $id; + + return $this; + } + + /** + * @return non-empty-string|null + */ + public function getRecipientId(): ?string { - return ''; + return $this->recipientId; } /** diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php index 13cb0f5c21fc7..75230e1fef1c1 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php @@ -14,30 +14,40 @@ use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; /** * @author Mathieu Piot + * @author Tomas Norkūnas */ final class DiscordTransportFactory extends AbstractTransportFactory { - public function create(Dsn $dsn): DiscordTransport + public function create(Dsn $dsn): TransportInterface { $scheme = $dsn->getScheme(); - if ('discord' !== $scheme) { - throw new UnsupportedSchemeException($dsn, 'discord', $this->getSupportedSchemes()); + if ('discord' === $scheme) { + $token = $this->getUser($dsn); + $webhookId = $dsn->getRequiredOption('webhook_id'); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new DiscordTransport($token, $webhookId, $this->client, $this->dispatcher))->setHost($host)->setPort($port); } - $token = $this->getUser($dsn); - $webhookId = $dsn->getRequiredOption('webhook_id'); - $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); - $port = $dsn->getPort(); + if ('discord+bot' === $scheme) { + $token = $this->getUser($dsn); + $host = 'default' === $dsn->getHost() ? null : $dsn->getHost(); + $port = $dsn->getPort(); + + return (new DiscordBotTransport($token, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + } - return (new DiscordTransport($token, $webhookId, $this->client, $this->dispatcher))->setHost($host)->setPort($port); + throw new UnsupportedSchemeException($dsn, 'discord', $this->getSupportedSchemes()); } protected function getSupportedSchemes(): array { - return ['discord']; + return ['discord', 'discord+bot']; } } diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/README.md b/src/Symfony/Component/Notifier/Bridge/Discord/README.md index 97fc260708e96..4cce4e0728007 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/README.md +++ b/src/Symfony/Component/Notifier/Bridge/Discord/README.md @@ -14,6 +14,12 @@ where: - `TOKEN` the secure token of the webhook (returned for Incoming Webhooks) - `ID` the id of the webhook +To use a custom application bot: + +``` +DISCORD_DSN=discord+bot://BOT_TOKEN@default +``` + Adding Interactions to a Message -------------------------------- diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordBotTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordBotTransportTest.php new file mode 100644 index 0000000000000..7ca7043eca90d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordBotTransportTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Discord\Tests; + +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; +use Symfony\Component\Notifier\Bridge\Discord\DiscordBotTransport; +use Symfony\Component\Notifier\Bridge\Discord\DiscordOptions; +use Symfony\Component\Notifier\Exception\LengthException; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Test\TransportTestCase; +use Symfony\Component\Notifier\Tests\Transport\DummyMessage; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final class DiscordBotTransportTest extends TransportTestCase +{ + public static function createTransport(?HttpClientInterface $client = null): DiscordBotTransport + { + return (new DiscordBotTransport('testToken', $client ?? new MockHttpClient()))->setHost('host.test'); + } + + public static function toStringProvider(): iterable + { + yield ['discord+bot://host.test', self::createTransport()]; + } + + public static function supportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!', new DiscordOptions(['recipient_id' => 'channel_id']))]; + } + + public static function unsupportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + yield [new DummyMessage()]; + } + + public function testSendThrowsWithoutRecipientId() + { + $transport = self::createTransport(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Missing configured recipient id on Discord message.'); + + $transport->send(new ChatMessage('testMessage')); + } + + public function testSendChatMessageWithMoreThan2000CharsThrowsLogicException() + { + $transport = self::createTransport(); + + $this->expectException(LengthException::class); + $this->expectExceptionMessage('The subject length of a Discord message must not exceed 2000 characters.'); + + $transport->send(new ChatMessage(str_repeat('囍', 2001), (new DiscordOptions())->recipient('channel_id'))); + } + + public function testSendWithErrorResponseThrows() + { + $response = new JsonMockResponse( + ['message' => 'testDescription', 'code' => 'testErrorCode'], + ['http_code' => 400], + ); + + $client = new MockHttpClient($response); + + $transport = self::createTransport($client); + + $this->expectException(TransportException::class); + $this->expectExceptionMessageMatches('/testDescription.+testErrorCode/'); + + $transport->send(new ChatMessage('testMessage', (new DiscordOptions())->recipient('channel_id'))); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordOptionsTest.php b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordOptionsTest.php index cd11c1ffe44f3..05bf58548bd30 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordOptionsTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordOptionsTest.php @@ -191,4 +191,25 @@ public function testDiscordAuthorEmbedFields() 'proxy_icon_url' => 'https://proxy.ic.on/url', ]); } + + /** + * @dataProvider getRecipientIdProvider + */ + public function testGetRecipientId(?string $expected, DiscordOptions $options) + { + $this->assertSame($expected, $options->getRecipientId()); + } + + public static function getRecipientIdProvider(): iterable + { + yield [null, new DiscordOptions()]; + yield ['foo', (new DiscordOptions())->recipient('foo')]; + } + + public function testToArrayUnsetsRecipientId() + { + $options = (new DiscordOptions())->recipient('foo'); + + $this->assertSame([], $options->toArray()); + } } diff --git a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php index c0bfa31d01f07..2d960b5ae07a6 100644 --- a/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php +++ b/src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php @@ -31,18 +31,22 @@ public static function createProvider(): iterable yield [ 'discord://host.test?webhook_id=testWebhookId', 'discord://token@host.test?webhook_id=testWebhookId', + 'discord+bot://host.test', + 'discord+bot://token@host.test', ]; } public static function supportsProvider(): iterable { yield [true, 'discord://host?webhook_id=testWebhookId']; + yield [true, 'discord+bot://token@host']; yield [false, 'somethingElse://host?webhook_id=testWebhookId']; } public static function incompleteDsnProvider(): iterable { yield 'missing token' => ['discord://host.test?webhook_id=testWebhookId']; + yield 'missing bot token' => ['discord+bot://host.test', 'Invalid "discord+bot://host.test" notifier DSN: User is not set.']; } public static function missingRequiredOptionProvider(): iterable