8000 [Notifier] Add Matrix bridge · symfony/symfony@af8b15d · GitHub
[go: up one dir, main page]

Skip to content

Commit af8b15d

Browse files
chii0815fabpot
authored andcommitted
[Notifier] Add Matrix bridge
1 parent f6312d3 commit af8b15d

18 files changed

+557
-0
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

+1
Original file line numberDiff line numberDiff line change
@@ -2926,6 +2926,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $
29262926
NotifierBridge\Lox24\Lox24TransportFactory::class => 'notifier.transport_factory.lox24',
29272927
NotifierBridge\Mailjet\MailjetTransportFactory::class => 'notifier.transport_factory.mailjet',
29282928
NotifierBridge\Mastodon\MastodonTransportFactory::class => 'notifier.transport_factory.mastodon',
2929+
NotifierBridge\Matrix\MatrixTransportFactory::class => 'notifier.transport_factory.matrix',
29292930
NotifierBridge\Mattermost\MattermostTransportFactory::class => 'notifier.transport_factory.mattermost',
29302931
NotifierBridge\Mercure\MercureTransportFactory::class => 'notifier.transport_factory.mercure',
29312932
NotifierBridge\MessageBird\MessageBirdTransportFactory::class => 'notifier.transport_factory.message-bird',

src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
'line-notify' => Bridge\LineNotify\LineNotifyTransportFactory::class,
3737
'linked-in' => Bridge\LinkedIn\LinkedInTransportFactory::class,
3838
'mastodon' => Bridge\Mastodon\MastodonTransportFactory::class,
39+
'matrix' => Bridge\Matrix\MatrixTransportFactory::class,
3940
'mattermost' => Bridge\Mattermost\MattermostTransportFactory::class,
4041
'mercure' => Bridge\Mercure\MercureTransportFactory::class,
4142
'microsoft-teams' => Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory::class,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/Tests export-ignore
2+
/phpunit.xml.dist export-ignore
3+
/.git* export-ignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor/
2+
composer.lock
3+
phpunit.xml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CHANGELOG
2+
=========
3+
4+
7.3
5+
---
6+
7+
* Add the bridge
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2025-present Fabien Potencier
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is furnished
8+
to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all
11+
copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
THE SOFTWARE.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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\Matrix;
13+
14+
use Symfony\Component\Notifier\Message\MessageOptionsInterface;
15+
16+
/**
17+
* @author Frank Schulze <frank@akiber.de>
18+
*/
19+
final class MatrixOptions implements MessageOptionsInterface, \JsonSerializable
20+
{
21+
public function __construct(
22+
private array $options = [],
23+
) {
24+
}
25+
26+
public function toArray(): array
27+
{
28+
return $this->options;
29+
}
30+
31+
public function getRecipientId(): ?string
32+
{
33+
return $this->options['recipient_id'] ?? null;
34+
}
35+
36+
public function jsonSerialize(): mixed
37+
{
38+
return $this->options;
39+
}
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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\Matrix;
13+
14+
use Symfony\Component\Notifier\Exception\LogicException;
15+
use Symfony\Component\Notifier\Exception\TransportException;
16+
use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException;
17+
use Symfony\Component\Notifier\Exception\UnsupportedOptionsException;
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\Component\Uid\Uuid;
23+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
24+
use Symfony\Contracts\HttpClient\HttpClientInterface;
25+
use Symfony\Contracts\HttpClient\ResponseInterface;
26+
27+
/**
28+
* @author Frank Schulze <frank@akiber.de>
29+
*/
30+
final class MatrixTransport extends AbstractTransport
31+
{
32+
// not all Message Types are supported by Matrix API
33+
private const SUPPORTED_MSG_TYPES_BY_API = ['m.text', 'm.emote', 'm.notice', 'm.image', 'm.file', 'm.audio', 'm.video', 'm.key.verification'];
34+
35+
public function __construct(
36+
#[\SensitiveParameter] private string $accessToken,
37+
private bool $ssl,
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('matrix://%s', $this->getEndpoint(false));
47+
}
48+
49+
public function supports(MessageInterface $message): bool
50+
{
51+
return $message instanceof ChatMessage && (null === $message->getOptions() || $message->getOptions() instanceof MatrixOptions);
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+
if (($opts = $message->getOptions()) && !$message->getOptions() instanceof MatrixOptions) {
61+
throw new UnsupportedOptionsException(__CLASS__, MatrixOptions::class, $opts);
62+
}
63+
64+
$options = $opts ? $opts->toArray() : [];
65+
66+
$options['msgtype'] = $options['msgtype'] ?? 'm.text';
67+
68+
if (!\in_array($options['msgtype'], self::SUPPORTED_MSG_TYPES_BY_API, true)) {
69+
throw new LogicException(\sprintf('Unsupported message type: "%s". Only "%s" are supported by Matrix Client-Server API v3.', $options['msgtype'], implode(', ', self::SUPPORTED_MSG_TYPES_BY_API)));
70+
}
71+
72+
if (null === $message->getRecipientId()) {
73+
throw new LogicException('Recipient id is required.');
74+
}
75+
76+
$recipient = match ($message->getRecipientId()[0]) {
77+
'@' => $this->getDirectMessageChannel($message->getRecipientId()),
78+
'!' => $message->getRecipientId(),
79+
'#' => $this->getRoomFromAlias($message->getRecipientId()),
80+
default => throw new LogicException(\sprintf('Only recipients starting with "!","@","#" are supported ("%s" given).', $message->getRecipientId()[0])),
81+
};
82+
83+
$options['body'] = $message->getSubject();
84+
if ('org.matrix.custom.html' === $options['format']) {
85+
$options['formatted_body'] = $message->getSubject(); 10000
86+
$options['body'] = strip_tags($message->getSubject());
87+
}
88+
89+
$response = $this->request('PUT', \sprintf('/_matrix/client/v3/rooms/%s/send/%s/%s', $recipient, 'm.room.message', Uuid::v4()), ['json' => $options]);
90+
91+
$success = $response->toArray(false);
92+
$sentMessage = new SentMessage($message, (string) $this);
93+
$sentMessage->setMessageId($success['event_id']);
94+
95+
return $sentMessage;
96+
}
97+
98+
protected function getEndpoint(bool $full = false): string
99+
{
100+
return rtrim(($full ? $this->getScheme().'://' : '').$this->host.($this->port ? ':'.$this->port : ''), '/');
101+
}
102+
103+
private function getRoomFromAlias(string $alias): string
104+
{
105+
$response = $this->request('GET', \sprintf('/_matrix/client/v3/directory/room/%s', urlencode($alias)));
106+
107+
return $response->toArray()['room_id'];
108+
}
109+
110+
private function createPrivateChannel(string $recipientId): ?array
111+
{
112+
$invites[] = $recipientId;
113+
$response = $this->request('POST', '/_matrix/client/v3/createRoom', ['json' => ['creation_content' => ['m.federate' => false], 'is_direct' => true, 'preset' => 'trusted_private_chat', 'invite' => $invites]]);
114+
115+
return $response->toArray();
116+
}
117+
118+
private function getDirectMessageChannel(string $recipientId): ?string
119+
{
120+
$response = $this->getAccountData($this->getWhoami()['user_id'], 'm.direct');
121+
if (!isset($response[$recipientId])) {
122+
$roomid = $this->createPrivateChannel($recipientId)['room_id'];
123+
$response[$recipientId] = [$roomid];
124+
$this->updateAccountData($this->getWhoami()['user_id'], 'm.direct', $response);
125+
126+
return $roomid;
127+
}
128+
129+
return $response[$recipientId][0];
130+
}
131+
132+
private function updateAccountData(string $userId, string $type, array $data): void
133+
{
134+
$response = $this->request('PUT', \sprintf('/_matrix/client/v3/user/%s/account_data/%s', urlencode($userId), $type), ['json' => $data]);
135+
if ([] !== $response->toArray()) {
136+
throw new TransportException('Unable to update account data.', $response);
137+
}
138+
}
139+
140+
private function getAccountData(string $userId, string $type): ?array
141+
{
142+
$response = $this->request('GET', \sprintf('/_matrix/client/v3/user/%s/account_data/%s', urlencode($userId), $type));
143+
144+
return $response->toArray();
145+
}
146+
147+
private function getWhoami(): ?array
148+
{
149+
$response = $this->request('GET', '/_matrix/client/v3/account/whoami');
150+
151+
return $response->toArray();
152+
}
153+
154+
private function getScheme(): string
155+
{
156+
return $this->ssl ? 'https' : 'http';
157+
}
158+
159+
private function request(string $method, string $uri, ?array $options = []): ResponseInterface
160+
{
161+
$options += [
162+
'auth_bearer' => $this->accessToken,
163+
];
164+
$response = $this->client->request($method, $this->getEndpoint(true).$uri, $options);
165+
166+
try {
167+
$statusCode = $response->getStatusCode();
168+
} catch (TransportException $e) {
169+
throw new TransportException('Could not reach the Matrix server.', $response, 0, $e);
170+
}
171+
172+
if (\in_array($statusCode, [400, 403, 405])) {
173+
$result = $response->toArray(false);
174+
throw new TransportException(\sprintf('Error: Matrix responded with "%s (%s)"', $result['error'], $result['errcode']), $response);
175+
}
176+
177+
return $response;
178+
}
179+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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\Matrix;
13+
14+
use Symfony\Component\Notifier\Exception\UnsupportedSchemeException;
15+
use Symfony\Component\Notifier\Transport\AbstractTransportFactory;
16+
use Symfony\Component\Notifier\Transport\Dsn;
17+
18+
/**
19+
* @author Frank Schulze <frank@akiber.de>
20+
*/
21+
class MatrixTransportFactory extends AbstractTransportFactory
22+
{
23+
public function create(Dsn $dsn): MatrixTransport
24+
{
25+
if ('matrix' !== $dsn->getScheme()) {
26+
throw new UnsupportedSchemeException($dsn, 'matrix', $this->getSupportedSchemes());
27+
}
28+
29+
$token = $dsn->getRequiredOption('accessToken');
30+
$host = $dsn->getHost();
31+
$port = $dsn->getPort();
32+
$ssl = $dsn->getBooleanOption('ssl', true);
33+
34+
return (new MatrixTransport($token, $ssl, $this->client, $this->dispatcher))->setHost($host)->setPort($port);
35+
}
36+
37+
protected function getSupportedSchemes(): array
38+
{
39+
return ['matrix'];
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
Matrix Notifier
2+
===============
3+
4+
Provides [Matrix](https://matrix.org) integration for Symfony Notifier.
5+
It uses the [Matrix Client-Server API](https://spec.matrix.org/v1.13/client-server-api/).
6+
7+
```
8+
Note:
9+
This Bridge was tested with the official Matrix Synapse Server and the Client-Server API v3 (version v1.13).
10+
But it should work with every Matrix Client-Server API v3 compliant homeserver.
11+
```
12+
13+
DSN example
14+
-----------
15+
16+
```
17+
MATRIX_DSN=matrix://HOST:PORT/?accessToken=ACCESS_TOKEN&ssl=true
18+
```
19+
To get started you need an access token. The simplest way to get that is to open Element in a private (incognito) window in your webbrowser or just use your currently open Element. Go to Settings > Help & About > Advanced > Access Token `click to reveal` and copy your access token.
20+
21+
Resources
22+
---------
23+
24+
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
25+
* [Report issues](https://github.com/symfony/symfony/issues) and
26+
[send Pull Requests](https://github.com/symfony/symfony/pulls)
27+
in the [main Symfony repository](https://github.com/symfony/symfony)
28+
* [Matrix Playground](https://playground.matrix.org)
29+
* [Matrix Client-Server API](https://spec.matrix.org/latest/client-server-api/)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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\Matrix\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Notifier\Bridge\Matrix\MatrixOptions;
16+
17+
class MatrixOptionsTest extends TestCase
18+
{
19+
public function testToArray()
20+
{
21+
$options = new MatrixOptions([
22+
'recipient_id' => '@testuser:matrix.io',
23+
'msgtype' => 'm.text',
24+
'format' => 'org.matrix.custom.html',
25+
]);
26+
$this->assertSame(['recipient_id' => '@testuser:matrix.io', 'msgtype' => 'm.text', 'format' => 'org.matrix.custom.html'], $options->toArray());
27+
}
28+
29+
public function testGetRecipientId()
30+
{
31+
$options = new MatrixOptions([
32+
'recipient_id' => '@testuser:matrix.io',
33+
]);
34+
$this->assertSame('@testuser:matrix.io', $options->getRecipientId());
35+
}
36+
37+
public function testJsonSerialize()
38+
{
39+
$options = new MatrixOptions([
40+
'recipient_id' => '@testuser:matrix.io',
41+
'msgtype' => 'm.text',
42+
'format' => 'org.matrix.custom.html',
43+
]);
44+
$this->assertSame(['recipient_id' => '@testuser:matrix.io', 'msgtype' => 'm.text', 'format' => 'org.matrix.custom.html'], $options->jsonSerialize());
45+
}
46+
}

0 commit comments

Comments
 (0)
0