Skip to content

Commit 4815173

Browse files
committed
[Notifier][Discord] Add DiscordBotTransport
1 parent 7e089a5 commit 4815173

File tree

8 files changed

+278
-11
lines changed

8 files changed

+278
-11
lines changed

src/Symfony/Component/Notifier/Bridge/Discord/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add `DiscordBotTransport`
8+
49
6.2
510
---
611

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Notifier\Bridge\Discord;
13+
14+
use Symfony\Component\Notifier\Exception\LengthException;
15+
use Symfony\Component\Notifier\Exception\LogicException;
16+
use Symfony\Component\Notifier\Exception\TransportException;
17+
use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException;
18+
use Symfony\Component\Notifier\Message\ChatMessage;
19+
use Symfony\Component\Notifier\Message\MessageInterface;
20+
use Symfony\Component\Notifier\Message\SentMessage;
21+
use Symfony\Component\Notifier\Transport\AbstractTransport;
22+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
23+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
24+
use Symfony\Contracts\HttpClient\HttpClientInterface;
25+
26+
/**
27+
* @author Mathieu Piot <math.piot@gmail.com>
28+
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
29+
*/
30+
final class DiscordBotTransport extends AbstractTransport
31+
{
32+
protected const HOST = 'discord.com';
33+
34+
private const SUBJECT_LIMIT = 2000;
35+
36+
public function __construct(
37+
#[\SensitiveParameter] private string $token,
38+
?HttpClientInterface $client = null,
39+
?EventDispatcherInterface $dispatcher = null,
40+
) {
41+
parent::__construct($client, $dispatcher);
42+
}
43+
44+
public function __toString(): string
45+
{
46+
return \sprintf('discord+bot://%s', $this->getEndpoint());
47+
}
48+
49+
public function supports(MessageInterface $message): bool
50+
{
51+
return $message instanceof ChatMessage && $message->getOptions() instanceof DiscordOptions;
52+
}
53+
54+
protected function doSend(MessageInterface $message): SentMessage
55+
{
56+
if (!$message instanceof ChatMessage) {
57+
throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message);
58+
}
59+
60+
$channelId = $message->getOptions()?->getRecipientId();
61+
if (null === $channelId) {
62+
throw new LogicException('Missing configured recipient id on Discord message.');
63+
}
64+
65+
$options = $message->getOptions()?->toArray() ?? [];
66+
$options['content'] = $message->getSubject();
67+
68+
if (mb_strlen($options['content'], 'UTF-8') > self::SUBJECT_LIMIT) {
69+
throw new LengthException(\sprintf('The subject length of a Discord message must not exceed %d characters.', self::SUBJECT_LIMIT));
70+
}
71+
72+
$endpoint = \sprintf('https://%s/api/channels/%s/messages', $this->getEndpoint(), $channelId);
73+
$response = $this->client->request('POST', $endpoint, [
74+
'headers' => [
75+
'Authorization' => 'Bot '.$this->token,
76+
],
77+
'json' => array_filter($options),
78+
]);
79+
80+
try {
81+
$statusCode = $response->getStatusCode();
82+
} catch (TransportExceptionInterface $e) {
83+
throw new TransportException('Could not reach the remote Discord server.', $response, 0, $e);
84+
}
85+
86+
if (200 !== $statusCode) {
87+
$result = $response->toArray(false);
88+
89+
if (401 === $statusCode) {
90+
$originalContent = $message->getSubject();
91+
$errorMessage = $result['message'];
92+
$errorCode = $result['code'];
93+
throw new TransportException(\sprintf('Unable to post the Discord message: "%s" (%d: "%s").', $originalContent, $errorCode, $errorMessage), $response);
94+
}
95+
96+
if (400 === $statusCode) {
97+
$originalContent = $message->getSubject();
98+
99+
$errorMessage = '';
100+
foreach ($result as $fieldName => $message) {
101+
$message = \is_array($message) ? implode(' ', $message) : $message;
102+
$errorMessage .= $fieldName.': '.$message.' ';
103+
}
104+
105+
$errorMessage = trim($errorMessage);
106+
throw new TransportException(\sprintf('Unable to post the Discord message: "%s" (%s).', $originalContent, $errorMessage), $response);
107+
}
108+
109+
throw new TransportException(\sprintf('Unable to post the Discord message: "%s" (Status Code: %d).', $message->getSubject(), $statusCode), $response);
110+
}
111+
112+
return new SentMessage($message, (string) $this);
113+
}
114+
}

src/Symfony/Component/Notifier/Bridge/Discord/DiscordOptions.php

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@
1717

1818
/**
1919
* @author Karoly Gossler <connor@connor.hu>
20+
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
2021
*/
2122
final class DiscordOptions implements MessageOptionsInterface
2223
{
24+
/**
25+
* @var non-empty-string|null
26+
*/
27+
private ?string $recipientId = null;
28+
2329
public function __construct(
2430
private array $options = [],
2531
) {
@@ -30,9 +36,24 @@ public function toArray(): array
3036
return $this->options;
3137
}
3238

33-
public function getRecipientId(): string
39+
/**
40+
* @param non-empty-string $id
41+
*
42+
* @return $this
43+
*/
44+
public function recipient(string $id): static
45+
{
46+
$this->recipientId = $id;
47+
48+
return $this;
49+
}
50+
51+
/**
52+
* @return non-empty-string|null
53+
*/
54+
public function getRecipientId(): ?string
3455
{
35-
return '';
56+
return $this->recipientId;
3657
}
3758

3859
/**

src/Symfony/Component/Notifier/Bridge/Discord/DiscordTransportFactory.php

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,40 @@
1414
use Symfony\Component\Notifier\Exception\UnsupportedSchemeException;
1515
use Symfony\Component\Notifier\Transport\AbstractTransportFactory;
1616
use Symfony\Component\Notifier\Transport\Dsn;
17+
use Symfony\Component\Notifier\Transport\TransportInterface;
1718

1819
/**
1920
* @author Mathieu Piot <math.piot@gmail.com>
21+
* @author Tomas Norkūnas <norkunas.tom@gmail.com>
2022
*/
2123
final class DiscordTransportFactory extends AbstractTransportFactory
2224
{
23-
public function create(Dsn $dsn): DiscordTransport
25+
public function create(Dsn $dsn): TransportInterface
2426
{
2527
$scheme = $dsn->getScheme();
2628

27-
if ('discord' !== $scheme) {
28-
throw new UnsupportedSchemeException($dsn, 'discord', $this->getSupportedSchemes());
29+
if ('discord' === $scheme) {
30+
$token = $this->getUser($dsn);
31+
$webhookId = $dsn->getRequiredOption('webhook_id');
32+
$host = 'default' === $dsn->getHost() ? null : $dsn->getHost();
33+
$port = $dsn->getPort();
34+
35+
return (new DiscordTransport($token, $webhookId, $this->client, $this->dispatcher))->setHost($host)->setPort($port);
2936
}
3037

31-
$token = $this->getUser($dsn);
32-
$webhookId = $dsn->getRequiredOption('webhook_id');
33-
$host = 'default' === $dsn->getHost() ? null : $dsn->getHost();
34-
$port = $dsn->getPort();
38+
if ('discord+bot' === $scheme) {
39+
$token = $this->getUser($dsn);
40+
$host = 'default' === $dsn->getHost() ? null : $dsn->getHost();
41+
$port = $dsn->getPort();
42+
43+
return (new DiscordBotTransport($token, $this->client, $this->dispatcher))->setHost($host)->setPort($port);
44+
}
3545

36-
return (new DiscordTransport($token, $webhookId, $this->client, $this->dispatcher))->setHost($host)->setPort($port);
46+
throw new UnsupportedSchemeException($dsn, 'discord', $this->getSupportedSchemes());
3747
}
3848

3949
protected function getSupportedSchemes(): array
4050
{
41-
return ['discord'];
51+
return ['discord', 'discord+bot'];
4252
}
4353
}

src/Symfony/Component/Notifier/Bridge/Discord/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ where:
1414
- `TOKEN` the secure token of the webhook (returned for Incoming Webhooks)
1515
- `ID` the id of the webhook
1616

17+
To use a custom application bot:
18+
19+
```
20+
DISCORD_DSN=discord+bot://BOT_TOKEN@default
21+
```
22+
1723
Adding Interactions to a Message
1824
--------------------------------
1925

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Notifier\Bridge\Discord\Tests;
13+
14+
use Symfony\Component\HttpClient\MockHttpClient;
15+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
16+
use Symfony\Component\Notifier\Bridge\Discord\DiscordBotTransport;
17+
use Symfony\Component\Notifier\Bridge\Discord\DiscordOptions;
18+
use Symfony\Component\Notifier\Exception\LengthException;
19+
use Symfony\Component\Notifier\Exception\LogicException;
20+
use Symfony\Component\Notifier\Exception\TransportException;
21+
use Symfony\Component\Notifier\Message\ChatMessage;
22+
use Symfony\Component\Notifier\Message\SmsMessage;
23+
use Symfony\Component\Notifier\Test\TransportTestCase;
24+
use Symfony\Component\Notifier\Tests\Transport\DummyMessage;
25+
use Symfony\Contracts\HttpClient\HttpClientInterface;
26+
27+
final class DiscordBotTransportTest extends TransportTestCase
28+
{
29+
public static function createTransport(?HttpClientInterface $client = null): DiscordBotTransport
30+
{
31+
return (new DiscordBotTransport('testToken', $client ?? new MockHttpClient()))->setHost('host.test');
32+
}
33+
34+
public static function toStringProvider(): iterable
35+
{
36+
yield ['discord+bot://host.test', self::createTransport()];
37+
}
38+
39+
public static function supportedMessagesProvider(): iterable
40+
{
41+
yield [new ChatMessage('Hello!', new DiscordOptions(['recipient_id' => 'channel_id']))];
42+
}
43+
44+
public static function unsupportedMessagesProvider(): iterable
45+
{
46+
yield [new SmsMessage('0611223344', 'Hello!')];
47+
yield [new DummyMessage()];
48+
}
49+
50+
public function testSendThrowsWithoutRecipientId()
51+
{
52+
$transport = self::createTransport();
53+
54+
$this->expectException(LogicException::class);
55+
$this->expectExceptionMessage('Missing configured recipient id on Discord message.');
56+
57+
$transport->send(new ChatMessage('testMessage'));
58+
}
59+
60+
public function testSendChatMessageWithMoreThan2000CharsThrowsLogicException()
61+
{
62+
$transport = self::createTransport();
63+
64+
$this->expectException(LengthException::class);
65+
$this->expectExceptionMessage('The subject length of a Discord message must not exceed 2000 characters.');
66+
67+
$transport->send(new ChatMessage(str_repeat('', 2001), (new DiscordOptions())->recipient('channel_id')));
68+
}
69+
70+
public function testSendWithErrorResponseThrows()
71+
{
72+
$response = new JsonMockResponse(
73+
['message' => 'testDescription', 'code' => 'testErrorCode'],
74+
['http_code' => 400],
75+
);
76+
77+
$client = new MockHttpClient($response);
78+
79+
$transport = self::createTransport($client);
80+
81+
$this->expectException(TransportException::class);
82+
$this->expectExceptionMessageMatches('/testDescription.+testErrorCode/');
83+
84+
$transport->send(new ChatMessage('testMessage', (new DiscordOptions())->recipient('channel_id')));
85+
}
86+
}

src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordOptionsTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,25 @@ public function testDiscordAuthorEmbedFields()
191191
'proxy_icon_url' => 'https://proxy.ic.on/url',
192192
]);
193193
}
194+
195+
/**
196+
* @dataProvider getRecipientIdProvider
197+
*/
198+
public function testGetRecipientId(?string $expected, DiscordOptions $options)
199+
{
200+
$this->assertSame($expected, $options->getRecipientId());
201+
}
202+
203+
public static function getRecipientIdProvider(): iterable
204+
{
205+
yield [null, new DiscordOptions()];
206+
yield ['foo', (new DiscordOptions())->recipient('foo')];
207+
}
208+
209+
public function testToArrayUnsetsRecipientId()
210+
{
211+
$options = (new DiscordOptions())->recipient('foo');
212+
213+
$this->assertSame([], $options->toArray());
214+
}
194215
}

src/Symfony/Component/Notifier/Bridge/Discord/Tests/DiscordTransportFactoryTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,22 @@ public static function createProvider(): iterable
3131
yield [
3232
'discord://host.test?webhook_id=testWebhookId',
3333
'discord://token@host.test?webhook_id=testWebhookId',
34+
'discord+bot://host.test',
35+
'discord+bot://token@host.test',
3436
];
3537
}
3638

3739
public static function supportsProvider(): iterable
3840
{
3941
yield [true, 'discord://host?webhook_id=testWebhookId'];
42+
yield [true, 'discord+bot://token@host'];
4043
yield [false, 'somethingElse://host?webhook_id=testWebhookId'];
4144
}
4245

4346
public static function incompleteDsnProvider(): iterable
4447
{
4548
yield 'missing token' => ['discord://host.test?webhook_id=testWebhookId'];
49+
yield 'missing bot token' => ['discord+bot://host.test', 'Invalid "discord+bot://host.test" notifier DSN: User is not set.'];
4650
}
4751

4852
public static function missingRequiredOptionProvider(): iterable

0 commit comments

Comments
 (0)