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
5 changes: 5 additions & 0 deletions src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.4
---

* Add `DiscordBotTransport`

6.2
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <math.piot@gmail.com>
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
*/
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this username check, if the api responds with an actionable error message. Is this the case?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then it should be removed from both transports. Can it be done in a follow-up pr?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,15 @@

/**
* @author Karoly Gossler <connor@connor.hu>
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
*/
final class DiscordOptions implements MessageOptionsInterface
{
/**
* @var non-empty-string|null
*/
private ?string $recipientId = null;

public function __construct(
private array $options = [],
) {
Expand All @@ -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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <math.piot@gmail.com>
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
*/
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'];
}
}
6 changes: 6 additions & 0 deletions src/Symfony/Component/Notifier/Bridge/Discord/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------------------

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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')));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\Notifier\Bridge\Discord\Tests;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Notifier\Bridge\Discord\DiscordOptions;
use Symfony\Component\Notifier\Bridge\Discord\Embeds\DiscordAuthorEmbedObject;
Expand Down Expand Up @@ -191,4 +192,23 @@ 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading