Skip to content

[Notifier][Discord] Add DiscordBotTransport #60218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: 7.4
Choose a base branch
from
Open
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) {
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 @@ -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());
}
}
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