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)
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