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

Skip to content

Commit a2b5feb

Browse files
committed
[Notifier] Add Matrix bridge
1 parent f6312d3 commit a2b5feb

File tree

11 files changed

+378
-0
lines changed

11 files changed

+378
-0
lines changed

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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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,
Lines changed: 3 additions & 0 deletions
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
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
vendor/
2+
composer.lock
3+
phpunit.xml
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Symfony\Component\Notifier\Bridge\Matrix\Exception;
4+
5+
use Symfony\Component\Notifier\Exception\LogicException;
6+
7+
class UnsupportedRecipiantTypeException extends LogicException
8+
{
9+
public function __construct(string $transport, string $given)
10+
{
11+
$message = \sprintf(
12+
'The "%s" transport only supports recipiants starting with "!","@","#" ("%s" given).',
13+
$transport< F438 /span>,
14+
$given
15+
);
16+
17+
parent::__construct($message);
18+
}
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Symfony\Component\Notifier\Bridge\Matrix;
4+
5+
use Symfony\Component\Notifier\Message\MessageOptionsInterface;
6+
7+
final class MatrixOptions implements MessageOptionsInterface
8+
{
9+
public function __construct(
10+
private array $options = [],
11+
){
12+
}
13+
14+
public function toArray(): array
15+
{
16+
return $this->options;
17+
}
18+
19+
public function getRecipientId(): ?string
20+
{
21+
return $this->options['recipient_id'] ?? null;
22+
}
23+
24+
public function getMsgType(): string
25+
{
26+
return $this->options['msgtype'] ?? 'm.text';
27+
}
28+
public function getFormat(): ?string
29+
{
30+
return $this->options['format'] ?? null;
31+
}
32+
33+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
<?php
2+
namespace Symfony\Component\Notifier\Bridge\Matrix;
3+
4+
use Symfony\Component\Notifier\Bridge\Matrix\Exception\UnsupportedRecipiantTypeException;
5+
use Symfony\Component\Notifier\Exception\LogicException;
6+
use Symfony\Component\Notifier\Exception\TransportException;
7+
use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException;
8+
use Symfony\Component\Notifier\Message\ChatMessage;
9+
use Symfony\Component\Notifier\Message\MessageInterface;
10+
use Symfony\Component\Notifier\Message\SentMessage;
11+
use Symfony\Component\Notifier\Transport\AbstractTransport;
12+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
13+
use Symfony\Contracts\HttpClient\HttpClientInterface;
14+
use Symfony\Component\Uid\Uuid;
15+
use Symfony\Contracts\HttpClient\ResponseInterface;
16+
17+
final class MatrixTransport extends AbstractTransport
18+
{
19+
public function __construct(
20+
#[\SensitiveParameter] private string $accessToken,
21+
private bool $ssl,
22+
?HttpClientInterface $client = null,
23+
?EventDispatcherInterface $dispatcher = null
24+
){
25+
parent::__construct($client, $dispatcher);
26+
}
27+
28+
public function __toString(): string
29+
{
30+
return \sprintf( 'matrix://%s', $this->getEndpoint(false));
31+
}
32+
33+
public function supports(MessageInterface $message): bool
34+
{
35+
return $message instanceof ChatMessage && (null === $message->getOptions() || $message->getOptions() instanceof MatrixOptions);
36+
}
37+
38+
protected function doSend(MessageInterface $message): SentMessage
39+
{
40+
if(!$message instanceof ChatMessage) {
41+
throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message);
42+
}
43+
44+
if($message->getRecipientId() === null){
45+
throw new LogicException('Recipient id is required.');
46+
}
47+
48+
$recipient = match(mb_substr($message->getRecipientId(),0,1)){
49+
"@" => $this->getDirectMessageChannel($message->getRecipientId()),
50+
"!" => $message->getRecipientId(),
51+
"#" => $this->getRoomFromAlias($message->getRecipientId()),
52+
default => throw new UnsupportedRecipiantTypeException(__CLASS__, mb_substr($message->getRecipientId(),0,1)),
53+
};
54+
return $this->sendMessage($recipient, $message);
55+
}
56+
57+
protected function sendMessage(
58+
string $recipientId,
59+
MessageInterface $message,
60+
): SentMessage
61+
{
62+
/** @var MatrixOptions $options */
63+
$options = $message->getOptions();
64+
$uri = '/_matrix/client/v3/rooms/%s/send/%s/%s';
65+
66+
$content['msgtype'] = $options->getMsgType();
67+
if($options->getFormat() === 'org.matrix.custom.html') {
68+
$content['format'] = $options->getFormat();
69+
$content['formatted_body'] = $message->getSubject();
70+
$content['body'] = strip_tags($message->getSubject());
71+
} else {
72+
$content['body'] = $message->getSubject();
73+
}
74+
75+
76+
$response = $this->connect(
77+
method: 'PUT',
78+
uri: \sprintf($uri, $recipientId, 'm.room.message', Uuid::v4()),
79+
options: [
80+
'json' => $content,
81+
]
82+
);
83+
$success = $response->toArray(false);
84+
$sentMessage = new SentMessage($message, (string) $this);
85+
$sentMessage->setMessageId($success['event_id']);
86+
return $sentMessage;
87+
}
88+
89+
protected function getRoomFromAlias(
90+
string $alias,
91+
): string
92+
{
93+
$uri = '/_matrix/client/v3/directory/room/%s';
94+
$response = $this->connect('GET', \sprintf($uri, urlencode($alias)));
95+
return $response->toArray()['room_id'];
96+
}
97+
98+
protected function createPrivateChannel(
99+
string $recipientId,
100+
): array| null
101+
{
102+
$uri = '/_matrix/client/v3/createRoom';
103+
$invites[] = $recipientId;
104+
$response = $this->connect('POST', $uri, [
105+
'json' => [
106+
"creation_content" => [
107+
"m.federate" => false
108+
],
109+
"is_direct" => true,
110+
"preset" => "trusted_private_chat",
111+
"invite" => $invites
112+
]
113+
]);
114+
return $response->toArray();
115+
}
116+
117+
protected function getDirectMessageChannel(
118+
string $recipientId
119+
): string| null
120+
{
121+
$response = $this->getAccountData(
122+
userId: $this->getWhoami()['user_id'],
123+
type:'m.direct'
124+
);
125+
if(!isset($response[$recipientId])){
126+
$roomid = $this->createPrivateChannel($recipientId)['room_id'];
127+
$response[$recipientId] = [$roomid];
128+
$this->updateAccountData('m.direct', $response);
129+
return $roomid;
130+
}
131+
132+
return $response[$recipientId][0];
133+
}
134+
135+
protected function updateAccountData(
136+
string $type,
137+
array $option
138+
): array | null
139+
{
140+
$uri = '/_matrix/client/v3/user/%s/account_data/%s';
141+
$response = $this->connect(
142+
method: 'PUT',
143+
uri: \sprintf($uri,urlencode($this->getWhoami()['user_id']), $type),
144+
options: [
145+
'json' => $option,
146+
]);
147+
return $response->toArray();
148+
}
149+
150+
protected function getAccountData(
151+
string $userId,
152+
string $type,
153+
): array|null
154+
{
155+
$uri = '/_matrix/client/v3/user/%s/account_data/%s';
156+
$response = $this->connect(
157+
method: 'GET',
158+
uri: \sprintf($uri, urlencode($userId), $type));
159+
160+
return $response->toArray();
161+
}
162+
163+
protected function getWhoami(): array|null
164+
{
165+
$uri = '/_matrix/client/v3/account/whoami';
166+
$response = $this->connect(
167+
method: 'GET',
168+
uri: $uri,
169+
);
170+
171+
return $response->toArray();
172+
}
173+
174+
protected function getEndpoint(
175+
bool $full=false
176+
): string
177+
{
178+
return rtrim(
179+
($full?$this->getScheme().'://':'').$this->host.($this->port ? ':'.$this->port : ''),
180+
'/');
181+
}
182+
protected function getScheme(): string
183+
{
184+
return $this->ssl? 'https': 'http';
185+
}
186+
187+
protected function connect(
188+
string $method,
189+
string $uri,
190+
?array $options = [],
191+
): ResponseInterface
192+
{
193+
$options += [
194+
'auth_bearer' => $this->accessToken,
195+
];
196+
$url = $this->getEndpoint(true).$uri;
197+
$response = $this->client->request($method, $url, $options);
198+
try {
199+
$statusCode = $response->getStatusCode();
200+
} catch (TransportException $e) {
201+
throw new TransportException('Could not reach the Matrix server.', $response, 0, $e);
202+
}
203+
204+
if (400 == $statusCode) {
205+
$result = $response->toArray(false);
206+
207+
throw new TransportException(
208+
\sprintf(
209+
'Error: Matrix responded with "%s (%s)"',
210+
$result['error'],
211+
$result['errcode']
212+
),
213+
$response);
214+
}
215+
if(!$response instanceof ResponseInterface) {
216+
throw new LogicException('Expected response to be an instance of ResponseInterface');
217+
}
218+
return $response;
219+
}
220+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace Symfony\Component\Notifier\Bridge\Matrix;
4+
5+
use Symfony\Component\Notifier\Exception\UnsupportedSchemeException;
6+
use Symfony\Component\Notifier\Transport\AbstractTransportFactory;
7+
use Symfony\Component\Notifier\Transport\Dsn;
8+
9+
class MatrixTransportFactory extends AbstractTransportFactory
10+
{
11+
12+
public function create(Dsn $dsn): MatrixTransport
13+
{
14+
$scheme = $dsn->getScheme();
15+
if('matrix' !== $scheme){
16+
throw new UnsupportedSchemeException($dsn, 'matrix', $this->getSupportedSchemes());
17+
}
18+
19+
$token = $dsn->getRequiredOption('accessToken');
20+
$host = $dsn->getHost();
21+
$port = $dsn->getPort();
22+
$ssl = filter_var($dsn->getOption('ssl', false), FILTER_VALIDATE_BOOLEAN);
23+
24+
return (new MatrixTransport($token, $ssl, $this->client, $this->dispatcher))->setHost($host)->setPort($port);
25+
}
26+
27+
protected function getSupportedSchemes(): array
28+
{
29+
return ['matrix'];
30+
}
31+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
Matrix Synapse Notifier
2+
================
3+
4+
Provides [Matrix Synapse](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+
DSN example
8+
-----------
9+
10+
```
11+
MATRIX_DSN=matrix://HOST:PORT/?accessToken=ACCESSTOKEN&ssl=false
12+
```
13+
14+
where:
15+
- `HOST` is your Matrix Synapse Hostname / IP ( without `http(s)://` )
16+
- `PORT` is your Matrix Synapse Port
17+
- `ACCESSTOKEN` is your Matrix Synapse Token (botuser).
18+
19+
The `ssl` parameter is optional and defaults to `false`.
20+
21+
Getting the ACCESSTOKEN
22+
---------
23+
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.
24+
25+
Resources
26+
---------
27+
28+
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
29+
* [Report issues](https://github.com/symfony/symfony/issues) and
30+
[send Pull Requests](https://github.com/symfony/symfony/pulls)
31+
in the [main Symfony repository](https://github.com/symfony/symfony)
32+
* [Matrix Playground](https://playground.matrix.org)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "xit/matrix-notifier",
3+
"type": "symfony-notifier-bridge",
4+
"description": "Symfony Matrix Synapse Notifier Bridge",
5+
"keywords": ["matrix", "notifier"],
6+
"homepage": "https://symfony.com",
7+
"license": "MIT",
8+
"authors": [
9+
{
10+
"name": "Frank Schulze",
11+
"email": "frank@akiber.de"
12+
},
13+
{
14+
"name": "Symfony Community",
15+
"homepage": "https://symfony.com/contributors"
16+
}
17+
],
18+
"require": {
19+
"php": ">=8.2",
20+
"ext-mbstring": "*",
21+
"symfony/notifier": "^7.2",
22+
"symfony/uid": "^7.2"
23+
},
24+
"autoload": {
25+
"psr-4": {
26+
"Symfony\\Component\\Notifier\\Bridge\\Matrix\\": ""
27+
},
28+
"exclude-from-classmap": [
29+
"/Tests/"
30+
]
31+
},
32+
"minimum-stability": "dev"
33+
}

0 commit comments

Comments
 (0)
0