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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy