Initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
vendor/
|
||||
composer.lock
|
||||
.DS_Store
|
||||
/node_modules
|
||||
/vendor
|
||||
.idea
|
||||
/logs/
|
||||
.idea/
|
||||
/.idea
|
||||
/.idea/*
|
||||
/.vscode
|
||||
/nbproject
|
||||
/.vagrant
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.phpunit.result.cache
|
||||
Example2\.php
|
||||
Example3\.php
|
||||
Example4\.php
|
||||
request\.xml
|
||||
response\.xml
|
||||
*.log
|
||||
\.php_cs\.cache
|
||||
.php-cs-fixer.cache
|
84
Example.php
Normal file
84
Example.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Pdahal\Xmpp;
|
||||
|
||||
class Example
|
||||
{
|
||||
protected static string $host = 'host.example.com';
|
||||
protected static int $port = 5222;
|
||||
protected static string $username = 'foo';
|
||||
protected static string $password = 'bar';
|
||||
|
||||
public static function test()
|
||||
{
|
||||
$options = new Options();
|
||||
|
||||
$options
|
||||
->setHost(self::$host)
|
||||
->setPort(self::$port)
|
||||
->setUsername(self::$username)
|
||||
->setPassword(self::$password)
|
||||
;
|
||||
|
||||
$client = new XmppClient($options);
|
||||
$client->connect();
|
||||
|
||||
$client->iq->getRoster();
|
||||
|
||||
$client->message->send('Hello world', 'test@jabber.com');
|
||||
|
||||
// Uncomment if you want to manually enter raw XML (or call a function) and see a server response
|
||||
// (new self)->sendRawXML($client);
|
||||
|
||||
while (true) {
|
||||
$response = $client->getResponse();
|
||||
$client->prettyPrint($response);
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
public function sendRawXML(XmppClient $client)
|
||||
{
|
||||
do {
|
||||
$response = $client->getResponse();
|
||||
$client->prettyPrint($response);
|
||||
|
||||
// if you provide a function name here, (i.e. getRoster ...arg)
|
||||
// the function will be called instead of sending raw XML
|
||||
$line = readline("\nEnter XML: ");
|
||||
|
||||
if ($line == 'exit') {
|
||||
break;
|
||||
}
|
||||
|
||||
$parsedLine = explode(' ', $line);
|
||||
|
||||
if (method_exists($client, $parsedLine[0])) {
|
||||
if (count($parsedLine) < 2) {
|
||||
$client->{$parsedLine[0]}();
|
||||
|
||||
continue;
|
||||
}
|
||||
$client->{$parsedLine[0]}($parsedLine[1]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (@simplexml_load_string($line)) {
|
||||
$client->send($line);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
echo 'This is not a method nor a valid XML';
|
||||
} while ($line != 'exit');
|
||||
}
|
||||
}
|
||||
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
Example::test();
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Dupor Marko
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
277
README.md
Normal file
277
README.md
Normal file
@ -0,0 +1,277 @@
|
||||
# PHP client library for XMPP (Jabber) protocol
|
||||
|
||||
> This library is based on [xmppo/xmpp-php](https://github.com/xmppo/xmpp-php)
|
||||
|
||||
This is low level socket implementation for enabling PHP to
|
||||
communicate with XMPP due to lack of such libraries online (at least ones I
|
||||
could find that had decent documentation).
|
||||
|
||||
XMPP core documentation can be found [here](https://xmpp.org/rfcs/rfc6120.html).
|
||||
|
||||
# Usage
|
||||
|
||||
You can see usage example in `Example.php` file by changing credentials to
|
||||
point to your XMPP server and from project root run `php Example.php`.
|
||||
|
||||
# Library usage
|
||||
|
||||
## Initialization
|
||||
|
||||
In order to start using the library you first need to instantiate a new `Options`
|
||||
class. Host, username and password are mandatory fields, while port number, if omitted,
|
||||
will default to `5222` which is XMPP default.
|
||||
|
||||
Username can be either bare `JID` or in `JID/resource` form. If you are using a bare `JID`
|
||||
the resource will be added automatically. You can override this by explicitly setting a
|
||||
resource with `$client->iq->setResource()`. In the second case the username will be automatically
|
||||
parsed to `username` and `resource` variables. In case of `JID/resource/xyz` format, everything
|
||||
after second slash will be ignored. If both `JID/resource` is present as well as using the
|
||||
`$client->iq->setResource()` method, which ever was defined last will take precedence.
|
||||
|
||||
```php
|
||||
$options = new Options();
|
||||
|
||||
$options
|
||||
->setHost($host) // required
|
||||
->setPort($port) // not required, defaults to 5222
|
||||
->setUsername($username) // required
|
||||
->setPassword($password); // required
|
||||
```
|
||||
|
||||
`Options` object is required for establishing the connection and every other subsequent
|
||||
request, so once set it should not be changed.
|
||||
|
||||
Once this is set you can instantiate a new `XmppClient` object and pass the `Options` object in.
|
||||
|
||||
## XMPP client class explanation
|
||||
|
||||
Since XMPP is all about 3 major stanzas, (**IQ, Message and Presence**), I've
|
||||
created separate classes which are dependant on socket implementation so that you
|
||||
can directly send XML by calling a stanza method.
|
||||
|
||||
This means that 3 stanzas have been made available from `XmppClient` class constructor
|
||||
to be used like a chained method on client's concrete class.
|
||||
|
||||
Current logic thus is `$client->STANZA->METHOD()`. For example:
|
||||
|
||||
```php
|
||||
$client->iq->getRoster();
|
||||
$client->message->send();
|
||||
$client->presence->subscribe();
|
||||
```
|
||||
|
||||
## Connecting to the server
|
||||
Beside being a stanza wrapper, `XmppClient` class offers a few public methods.
|
||||
|
||||
`$client->connect()` method does a few things:
|
||||
1. Connects to the socket which was initialized in `XmppClient` constructor
|
||||
2. Opens an XML stream to exchange with the XMPP server
|
||||
3. Tries to authenticate with the server based on provided credentials
|
||||
4. Starts the initial communication with the server, bare minimum to get you started
|
||||
|
||||
Current version supports `PLAIN` and `DIGEST-MD5` auth methods.
|
||||
|
||||
TLS is supported by default. If server has support for TLS, library will
|
||||
automatically try to connect with TLS and make the connection secure.
|
||||
|
||||
If you'd like to explicitly disable this functionality, you can use `setUseTls(false)`
|
||||
function on the `Options` instance so that TLS communication is disabled. Note
|
||||
that this will function in environments where TLS is supported but not required.
|
||||
If TLS is required, program will connect to it independently of the option you
|
||||
set.
|
||||
|
||||
## Sending raw data
|
||||
|
||||
`send()` message is exposed as being public in `XmppClient` class, and its intention
|
||||
is to send raw XML data to the server. For it to work correctly, XML which you send
|
||||
has to be valid XML.
|
||||
|
||||
## Getting raw response
|
||||
|
||||
Server responses (or server side continuous XML session to be exact) can be retrieved with
|
||||
`$client->getResponse()`. This should be used in an infinite loop or for more sustainable
|
||||
solution in some WebSocket solution like [Ratchet](http://socketo.me/) if you'd like to
|
||||
see continuous stream of everything coming from the server.
|
||||
|
||||
If you would like to see the output of the received response in the console you can call the
|
||||
`$client->prettyPrint($response)` method.
|
||||
|
||||
## Receiving messages and other responses
|
||||
|
||||
In case you are not interested in complete response which comes from server, you may also use
|
||||
`$client->message->receive()` (`$client->getMessages()` was removed because it was just a shorthand
|
||||
method for this one) which will match message tags with regex and return array of matched messages.
|
||||
In case you'd like to see the response in the terminal, you can do something like this:
|
||||
|
||||
```php
|
||||
do {
|
||||
$response = $client->message->receive();
|
||||
if($response)
|
||||
echo print_r($response);
|
||||
} while (true);
|
||||
```
|
||||
|
||||
## Disconnect
|
||||
Disconnect method sends closing XML to the server to end the currently open session and
|
||||
closes the open socket.
|
||||
|
||||
# Stanza method breakdown
|
||||
|
||||
Remember from [here](#xmpp-client-class-explanation) -> `$client->STANZA->METHOD()`
|
||||
|
||||
## Message
|
||||
|
||||
`send()` - sending a message to someone. Takes 3 parameters of which the last one is
|
||||
optional. First parameter is the actual message you'd like to send (body), second
|
||||
one is recipient of the message and third one is type of message to be sent. This
|
||||
defaults to `chat`.
|
||||
|
||||
You can find possible types in [this RFC document](https://xmpp.org/rfcs/rfc3921.html#stanzas)
|
||||
|
||||
`receive()` - covered in [this section](#receiving-messages-and-other-responses)
|
||||
|
||||
## IQ
|
||||
|
||||
`getRoster()` - takes no arguments and fetches current authenticated user roster.
|
||||
|
||||
`setGroup()` - puts a given user in group you provide. Method takes two arguments:
|
||||
first one being the group name which you will attach to given user, and other
|
||||
being JID of that user.
|
||||
|
||||
## Presence
|
||||
|
||||
`setPriority()` - sets priority for given resource. First argument is an integer
|
||||
`-128 <> 127`. If no second argument is given, priority will be set for currently used resource.
|
||||
Other resource can be provided as a second argument whereas the priority will be set for that
|
||||
specific resource.
|
||||
|
||||
`subscribe()` - takes JID as an argument and asks that user for presence.
|
||||
|
||||
`acceptSubscription()` - takes JID as an argument and accepts presence from that user.
|
||||
|
||||
`declineSubscription()` - takes JID as an argument and declines presence from that user.
|
||||
|
||||
## Sessions
|
||||
|
||||
Sessions are currently being used only to differentiate logs if multiple connections
|
||||
are being made.
|
||||
|
||||
`XmppClient` class takes in second optional parameter `$sessionId` to which you can
|
||||
forward session ID from your system, or it will be assigned automatically.
|
||||
|
||||
You can disable sessions through `Options` object (`$options->setSessionManager(false)`),
|
||||
as they can cause collision with already established sessions if being used inside
|
||||
frameworks or similar. Needless to say if this is disabled, forwarding a second parameter
|
||||
to `XmppClient` will not establish a new session.
|
||||
|
||||
# More options (not required)
|
||||
|
||||
`Options` object can take more options which may be chained but are not required. These are explained
|
||||
and commented in the code directly in the `Options` class:
|
||||
|
||||
```php
|
||||
$options
|
||||
->setProtocol($protocol) // defaults to TCP
|
||||
->setResource($resource) // defaults to 'Pdahal_machine_' string + timestamp
|
||||
->setLogger($logger) // logger instance (logging explained below)
|
||||
->setAuthType($authType) // Takes on classes which implement Authenticable
|
||||
```
|
||||
|
||||
## Socket options
|
||||
|
||||
Most of the socket options are set by default so there is no need to tamper
|
||||
with this class, however you can additionally change the timeout for the period
|
||||
the socket will be alive when doing a `socket_read()`, and you can do that with
|
||||
`$socket->setTimeout()`.
|
||||
|
||||
## Logging
|
||||
|
||||
Upon new established session the library is creating a `xmpp.log` log file in `logs/` folder:
|
||||
|
||||
You can manually set logger when instantiating `Options` with `setLogger($logger)`. The method accepts
|
||||
any object which implements `Loggable` interface so you can create your own implementation.
|
||||
|
||||
Fun fact: this used to be a `PSR-3` logger interface, but I decided it was an overkill for this
|
||||
stage of development.
|
||||
|
||||
# Other
|
||||
|
||||
`Example.php` has a `sendRawXML()` method which can be helpful with debugging. Method works in a way
|
||||
that you can provide hand-written XML and send it to the server. On the other hand you can
|
||||
also trigger a method by providing method name instead of XML.
|
||||
|
||||
```
|
||||
Enter XML: <xml>foo</xml> <-- will send XML
|
||||
Enter XML: getRoster <-- will run getRoster() method
|
||||
Enter XML: requestPresence x@x.com <-- will run with argument requestPresence(x@x.com)
|
||||
```
|
||||
|
||||
Some valid XMPP XML will be declined (like sending `<presence/>`) because `simplexml_load_string()`
|
||||
is not able to parse it as being a valid XML. In cases you need to do some custom stuff like
|
||||
that and you are sure it is a XMPP valid XML, you can remove the parsing line and just let the
|
||||
`send()` method do its magic.
|
||||
|
||||
**Be aware! Be very aware!** sending an invalid XML to the server
|
||||
will probably invalidate currently open XML session and you will probably need to restart the
|
||||
script. This is highly experimental and not maintained really. It is a poor orphan method whose
|
||||
parents have abandoned it and left in a hurry. It may prove to be Harry Potter one day, but hey...
|
||||
we all clearly doubt it. You may be a special snowflake but no one likes you. Onward to
|
||||
orphanagemobil! Begone method (just kidding, I won't delete it)!
|
||||
|
||||
Who says readme's are boring.
|
||||
|
||||
# Dev documentation
|
||||
|
||||
For anyone willing to contribute, a quick breakdown of the structure:
|
||||
|
||||
---
|
||||
- `Options.php` - everything that is variable about the library
|
||||
- `Socket.php` - socket related implementation (connecting, reading, writing etc.)
|
||||
- `XmppClient.php` - user friendly methods to interact with the library and stanza wrapper enabling users to call stanza methods through
|
||||
instantiated class. This should contain as little logic as possible, turns out it's not so easy :)
|
||||
---
|
||||
|
||||
- `AuthTypes` - contains methods to authenticate yourself to XMPP server. Besides concrete implementations there is also an
|
||||
abstract class with minor logic to avoid duplication and interface having all the necessary methods should the need for new
|
||||
auth type arise.
|
||||
|
||||
- `Buffers` - implementation of buffer (or should I say a simple array) which gets filled when socket is calling the `receive()`
|
||||
method, and it flushes on any read, which happens when calling `getResponse()` method for example. A brief history of why: I had
|
||||
issues when a non-recoverable error would be thrown. In this situation I had to do 2 things: try to reconnect, show the error
|
||||
to the user. The thing is that `getResponse()` returns string, and in case of reconnection the program execution would continue
|
||||
returning either nothing or returning error string after the server already connected for the second time, thus misinforming the
|
||||
user of the error which occurred before reconnection. Buffer was born.
|
||||
|
||||
- `Exceptions` - this is more or less a standard. I am just overriding constructors so I can get my message in.
|
||||
|
||||
- `Loggers` - containing logic to store logs to the `logs/xmpp.log` file. The idea was to keep several log types inside (full, simple,
|
||||
no logger), but I found the one made to be sufficient.
|
||||
|
||||
---
|
||||
|
||||
`Xml`
|
||||
|
||||
- `Xml.php` - a trait consisting of way too many regex matching. This should be reformatted.
|
||||
- `Stanzas` - main logic for all stanza communication with the server. This used to be just plain XML, but I have decided to
|
||||
forward a socket dependency inside so that when you call the method, you actually also send it to the server. That used to be
|
||||
something like `$this->socket->send($this->iq->getRoster())` which is correct from the programming perspective, but for the
|
||||
simplicity sake, I like the `$client->iq->getRoster()` more. I'm open to other suggestions.
|
||||
|
||||
---
|
||||
|
||||
## TODO's
|
||||
|
||||
- **unit testing** - unfortunately I have been prolonging this for far too long, maybe there is a good soul out there who enjoys writing tests.
|
||||
- **throttling** - when an unrecoverable error occurs (currently I am catching `<stream:error>` ones which break the stream)
|
||||
reconnect is being made automatically. In case this is happening over and over again, program will try connecting indefinitely,
|
||||
which is fine to any aspect except logs which will get clogged. I would like to throttle the connection so that it increases the
|
||||
connection time each time it is unsuccessful. Problem here is that I can only catch the error when getting the response, and
|
||||
response can be successful on the first XML exchange (for example when you send opening stream request), while breaking on the
|
||||
second request. With this in mind my only idea was to implement throttling with timestamps or something.
|
||||
- **sessions** - I presume this part is working correctly but should be tested from a framework
|
||||
- **multiple connections** - I think this part works fine, but I am worried that triggering `getRoster()` while simultaneously
|
||||
fetching a message may delete one server response. If you get in one batch both roster and message, it will be added to the buffer.
|
||||
Calling back the response will get either roster or message, not both. And then buffer will be flushed. This is something that
|
||||
needs thinking.
|
||||
- **structure of XmppClient** - in order to enable the `$client->stanza->method` I need to instantiate all stanzas within the
|
||||
class. I feel as this could be simplified.
|
38
composer.json
Normal file
38
composer.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "pdahal/php-xmpp",
|
||||
"description": "PHP library for XMPP based on `xmppo/xmpp-php`",
|
||||
"version": "1.0.0",
|
||||
"keywords": [
|
||||
"php",
|
||||
"xmpp",
|
||||
"jabber",
|
||||
"library"
|
||||
],
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Prabin Dahal",
|
||||
"email": "mr.prabin@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Marko Dupor",
|
||||
"email": "marko.dupor@gmail.com"
|
||||
}
|
||||
],
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~11.0",
|
||||
"phpmd/phpmd": "~2.15",
|
||||
"squizlabs/php_codesniffer": "~3.10",
|
||||
"friendsofphp/php-cs-fixer": "~3.64",
|
||||
"rector/rector": "~1.2"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Pdahal\\Xmpp\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
12
phpunit.xml
Normal file
12
phpunit.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<phpunit bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
convertErrorsToExceptions="true"
|
||||
convertNoticesToExceptions="true"
|
||||
convertWarningsToExceptions="true"
|
||||
stopOnFailure="true">
|
||||
<testsuites>
|
||||
<testsuite name="Unit Tests">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
17
rector.php
Normal file
17
rector.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\Renaming\Rector\Name\RenameClassRector;
|
||||
|
||||
return RectorConfig::configure()
|
||||
->withPaths([
|
||||
__DIR__.'/src',
|
||||
__DIR__.'/tests',
|
||||
])
|
||||
->withPreparedSets(deadCode: true)
|
||||
// uncomment to reach your current PHP version
|
||||
->withPhpSets(php82: true)
|
||||
->withTypeCoverageLevel(7)
|
||||
;
|
15
src/AuthTypes/Authenticable.php
Normal file
15
src/AuthTypes/Authenticable.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\AuthTypes;
|
||||
|
||||
interface Authenticable
|
||||
{
|
||||
/**
|
||||
* Based on auth type, return the right format of credentials to be sent to the server.
|
||||
*/
|
||||
public function encodedCredentials(): string;
|
||||
|
||||
public function getName(): string;
|
||||
}
|
24
src/AuthTypes/Authentication.php
Normal file
24
src/AuthTypes/Authentication.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\AuthTypes;
|
||||
|
||||
use Pdahal\Xmpp\Options;
|
||||
use Pdahal\Xmpp\Xml\Xml;
|
||||
|
||||
abstract class Authentication implements Authenticable
|
||||
{
|
||||
use Xml;
|
||||
|
||||
protected string $name;
|
||||
|
||||
public function __construct(protected Options $options)
|
||||
{
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
16
src/AuthTypes/DigestMD5.php
Normal file
16
src/AuthTypes/DigestMD5.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\AuthTypes;
|
||||
|
||||
class DigestMD5 extends Authentication
|
||||
{
|
||||
protected string $name = 'DIGEST-MD5';
|
||||
|
||||
public function encodedCredentials(): string
|
||||
{
|
||||
$credentials = "\x00{$this->options->getUsername()}\x00{$this->options->getPassword()}";
|
||||
return self::quote(sha1($credentials));
|
||||
}
|
||||
}
|
16
src/AuthTypes/Plain.php
Normal file
16
src/AuthTypes/Plain.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\AuthTypes;
|
||||
|
||||
class Plain extends Authentication
|
||||
{
|
||||
protected string $name = 'PLAIN';
|
||||
|
||||
public function encodedCredentials(): string
|
||||
{
|
||||
$credentials = "\x00{$this->options->getUsername()}\x00{$this->options->getPassword()}";
|
||||
return self::quote(base64_encode($credentials));
|
||||
}
|
||||
}
|
18
src/Buffers/Buffer.php
Normal file
18
src/Buffers/Buffer.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Buffers;
|
||||
|
||||
interface Buffer
|
||||
{
|
||||
/**
|
||||
* Write to buffer (add to array of values)
|
||||
*/
|
||||
public function write(string $data): void;
|
||||
|
||||
/**
|
||||
* Read from buffer and delete the data
|
||||
*/
|
||||
public function read(): string;
|
||||
}
|
35
src/Buffers/Response.php
Normal file
35
src/Buffers/Response.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Buffers;
|
||||
|
||||
class Response implements Buffer
|
||||
{
|
||||
protected array $response = [];
|
||||
|
||||
public function write(?string $data = null): void
|
||||
{
|
||||
if ($data) {
|
||||
$this->response[] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
public function read(): string
|
||||
{
|
||||
$implodedResponse = $this->response != [] ? implode('', $this->response) : '';
|
||||
$this->flush();
|
||||
|
||||
return $implodedResponse;
|
||||
}
|
||||
|
||||
protected function flush(): void
|
||||
{
|
||||
$this->response = [];
|
||||
}
|
||||
|
||||
public function getCurrentBufferData(): array
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
}
|
18
src/Exceptions/DeadSocket.php
Normal file
18
src/Exceptions/DeadSocket.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class DeadSocket extends Exception
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$errorCode = socket_last_error();
|
||||
$errorMsg = socket_strerror($errorCode);
|
||||
|
||||
parent::__construct("Couldn't create socket: [$errorCode] $errorMsg");
|
||||
}
|
||||
}
|
15
src/Exceptions/StreamError.php
Normal file
15
src/Exceptions/StreamError.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class StreamError extends Exception
|
||||
{
|
||||
public function __construct(string $streamErrorType)
|
||||
{
|
||||
parent::__construct("Unrecoverable stream error ({$streamErrorType}), trying to reconnect...");
|
||||
}
|
||||
}
|
33
src/Loggers/Loggable.php
Normal file
33
src/Loggers/Loggable.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Loggers;
|
||||
|
||||
interface Loggable
|
||||
{
|
||||
/**
|
||||
* Standard log message
|
||||
*/
|
||||
public function log(string $message): void;
|
||||
|
||||
/**
|
||||
* Shorthand method for logging with prepended "REQUEST" string
|
||||
*/
|
||||
public function logRequest(string $message): void;
|
||||
|
||||
/**
|
||||
* Shorthand method for logging with prepended "RESPONSE" string
|
||||
*/
|
||||
public function logResponse(string $message): void;
|
||||
|
||||
/**
|
||||
* Shorthand method for logging with prepended "ERROR" string
|
||||
*/
|
||||
public function error(string $message): void;
|
||||
|
||||
/**
|
||||
* Returns relative path from given resource
|
||||
*/
|
||||
public function getFilePathFromResource(mixed $resource): string;
|
||||
}
|
69
src/Loggers/Logger.php
Normal file
69
src/Loggers/Logger.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Loggers;
|
||||
|
||||
use Exception;
|
||||
|
||||
class Logger implements Loggable
|
||||
{
|
||||
public mixed $log;
|
||||
|
||||
public const LOG_FOLDER = "logs";
|
||||
public const LOG_FILE = "xmpp.log";
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createLogFile();
|
||||
$this->log = fopen(self::LOG_FOLDER . '/' . self::LOG_FILE, 'a');
|
||||
}
|
||||
|
||||
protected function createLogFile(): void
|
||||
{
|
||||
if (!file_exists(self::LOG_FOLDER)) {
|
||||
mkdir(self::LOG_FOLDER, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function log(string $message): void
|
||||
{
|
||||
$this->writeToLog($message);
|
||||
}
|
||||
|
||||
public function logRequest(string $message): void
|
||||
{
|
||||
$this->writeToLog($message, "REQUEST");
|
||||
}
|
||||
|
||||
public function logResponse(string $message): void
|
||||
{
|
||||
$this->writeToLog($message, "RESPONSE");
|
||||
}
|
||||
|
||||
public function error(string $message): void
|
||||
{
|
||||
$this->writeToLog($message, "ERROR");
|
||||
}
|
||||
|
||||
protected function writeToLog(string $message, string $type = ''): void
|
||||
{
|
||||
$prefix = date("Y.m.d H:i:s") . " " . session_id() . ($type ? " {$type}::" : " ");
|
||||
$this->writeToFile($this->log, $prefix . "$message\n");
|
||||
}
|
||||
|
||||
protected function writeToFile(mixed $file, string $message): void
|
||||
{
|
||||
try {
|
||||
fwrite($file, $message);
|
||||
} catch (Exception) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
public function getFilePathFromResource(mixed $resource): string
|
||||
{
|
||||
$metaData = stream_get_meta_data($resource);
|
||||
return $metaData["uri"];
|
||||
}
|
||||
}
|
259
src/Options.php
Normal file
259
src/Options.php
Normal file
@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp;
|
||||
|
||||
use Pdahal\Xmpp\AuthTypes\Authenticable;
|
||||
use Pdahal\Xmpp\AuthTypes\Plain;
|
||||
use Pdahal\Xmpp\Loggers\Loggable;
|
||||
use Pdahal\Xmpp\Loggers\Logger;
|
||||
use Psr\Log\InvalidArgumentException;
|
||||
|
||||
class Options
|
||||
{
|
||||
/**
|
||||
* Hostname of XMPP server.
|
||||
*/
|
||||
protected string $host = '';
|
||||
|
||||
/**
|
||||
* Domain Name of XMPP server.
|
||||
*/
|
||||
protected string $domain = '';
|
||||
|
||||
/**
|
||||
* XMPP server port. Usually 5222.
|
||||
*/
|
||||
protected int $port = 5222;
|
||||
|
||||
/**
|
||||
* Protocol used for socket connection, defaults to TCP.
|
||||
*/
|
||||
protected string $protocol = 'tcp';
|
||||
|
||||
/**
|
||||
* Username to authenticate on XMPP server.
|
||||
*/
|
||||
protected string $username = '';
|
||||
|
||||
/**
|
||||
* Password to authenticate on XMPP server.
|
||||
*/
|
||||
protected string $password = '';
|
||||
|
||||
/**
|
||||
* XMPP resource.
|
||||
*/
|
||||
protected string $resource = '';
|
||||
|
||||
/**
|
||||
* Custom logger interface.
|
||||
*/
|
||||
protected ?Loggable $logger = null;
|
||||
|
||||
/**
|
||||
* Use TLS if available.
|
||||
*/
|
||||
protected bool $useTls = true;
|
||||
|
||||
/**
|
||||
* Auth type (Authentication/AuthTypes/).
|
||||
*/
|
||||
protected ?Authenticable $authType = null;
|
||||
|
||||
public function getHost(): string
|
||||
{
|
||||
if (!$this->host) {
|
||||
$this->getLogger()->error(__METHOD__.'::'.__LINE__.
|
||||
' No host found, please set the host variable');
|
||||
|
||||
throw new InvalidArgumentException();
|
||||
}
|
||||
|
||||
return $this->host;
|
||||
}
|
||||
|
||||
public function setHost(string $host): Options
|
||||
{
|
||||
$this->host = trim($host);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDomain(string $domain): Options
|
||||
{
|
||||
$this->domain = $domain;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function getDomain(): string
|
||||
{
|
||||
if ($this->domain == '') {
|
||||
return $this->getHost();
|
||||
}
|
||||
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function getPort(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function setPort(int $port): Options
|
||||
{
|
||||
$this->port = $port;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsername(): string
|
||||
{
|
||||
if ($this->username == '') {
|
||||
$this->getLogger()->error(__METHOD__.'::'.__LINE__.
|
||||
' No username found, please set the username variable');
|
||||
|
||||
throw new InvalidArgumentException();
|
||||
}
|
||||
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to assign a resource if it exists. If bare JID is forwarded, this will default to your username.
|
||||
*/
|
||||
public function setUsername(string $username): Options
|
||||
{
|
||||
$usernameResource = explode('/', $username);
|
||||
|
||||
if (count($usernameResource) > 1) {
|
||||
$this->setResource($usernameResource[1]);
|
||||
$username = $usernameResource[0];
|
||||
}
|
||||
|
||||
$this->username = trim($username);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getPassword(): string
|
||||
{
|
||||
if ($this->password == '') {
|
||||
$this->getLogger()->error(__METHOD__.'::'.__LINE__.
|
||||
' No password found, please set the password variable');
|
||||
|
||||
throw new InvalidArgumentException();
|
||||
}
|
||||
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function setPassword(string $password): Options
|
||||
{
|
||||
$this->password = $password;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResource(): string
|
||||
{
|
||||
if ($this->resource == '') {
|
||||
$this->resource = 'Pdahal_machine_'.time();
|
||||
}
|
||||
|
||||
return $this->resource;
|
||||
}
|
||||
|
||||
public function setResource(string $resource): Options
|
||||
{
|
||||
$this->resource = trim($resource);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProtocol(): string
|
||||
{
|
||||
return $this->protocol;
|
||||
}
|
||||
|
||||
public function setProtocol(string $protocol): Options
|
||||
{
|
||||
$this->protocol = $protocol;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function fullSocketAddress(): string
|
||||
{
|
||||
$protocol = $this->getProtocol();
|
||||
$host = $this->getHost();
|
||||
$port = $this->getPort();
|
||||
|
||||
return "{$protocol}://{$host}:{$port}";
|
||||
}
|
||||
|
||||
public function fullJid(): string
|
||||
{
|
||||
$username = $this->getUsername();
|
||||
$resource = $this->getResource();
|
||||
$host = $this->getHost();
|
||||
|
||||
return "{$username}@{$host}/{$resource}";
|
||||
}
|
||||
|
||||
public function bareJid(): string
|
||||
{
|
||||
$username = $this->getUsername();
|
||||
$host = $this->getHost();
|
||||
|
||||
return "{$username}@{$host}";
|
||||
}
|
||||
|
||||
public function setLogger(Loggable $logger): void
|
||||
{
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function getLogger(): Loggable
|
||||
{
|
||||
if ($this->logger === null) {
|
||||
$this->logger = new Logger();
|
||||
}
|
||||
|
||||
return $this->logger;
|
||||
}
|
||||
|
||||
public function setUseTls(bool $enable): void
|
||||
{
|
||||
$this->useTls = $enable;
|
||||
}
|
||||
|
||||
public function usingTls(): bool
|
||||
{
|
||||
return $this->useTls;
|
||||
}
|
||||
|
||||
public function getAuthType(): Authenticable
|
||||
{
|
||||
if ($this->authType === null) {
|
||||
$this->setAuthType(new Plain($this));
|
||||
}
|
||||
|
||||
return $this->authType;
|
||||
}
|
||||
|
||||
public function setAuthType(Authenticable $authType): Options
|
||||
{
|
||||
$this->authType = $authType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
111
src/Socket.php
Normal file
111
src/Socket.php
Normal file
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp;
|
||||
|
||||
use Pdahal\Xmpp\Buffers\Response;
|
||||
use Pdahal\Xmpp\Exceptions\DeadSocket;
|
||||
|
||||
class Socket
|
||||
{
|
||||
public mixed $connection;
|
||||
protected Response $responseBuffer;
|
||||
protected Options $options;
|
||||
|
||||
/**
|
||||
* Period in microseconds for imposed timeout while doing socket_read().
|
||||
*/
|
||||
protected int $timeout = 150000;
|
||||
|
||||
/**
|
||||
* Socket constructor.
|
||||
*
|
||||
* @throws DeadSocket
|
||||
*/
|
||||
public function __construct(Options $options)
|
||||
{
|
||||
$this->responseBuffer = new Response();
|
||||
$this->connection = stream_socket_client($options->fullSocketAddress());
|
||||
|
||||
if (!$this->isAlive($this->connection)) {
|
||||
throw new DeadSocket();
|
||||
}
|
||||
|
||||
// stream_set_blocking($this->connection, true);
|
||||
stream_set_timeout($this->connection, 0, $this->timeout);
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
public function disconnect(): void
|
||||
{
|
||||
fclose($this->connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sending XML stanzas to open socket.
|
||||
*/
|
||||
public function send(string $xml)
|
||||
{
|
||||
try {
|
||||
fwrite($this->connection, $xml);
|
||||
$this->options->getLogger()->logRequest(__METHOD__.'::'.__LINE__." {$xml}");
|
||||
// $this->checkSocketStatus();
|
||||
} catch (\Exception $e) {
|
||||
$this->options->getLogger()->error(__METHOD__.'::'.__LINE__.' fwrite() failed '.$e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->receive();
|
||||
}
|
||||
|
||||
public function receive(): void
|
||||
{
|
||||
$response = '';
|
||||
while ($out = fgets($this->connection)) {
|
||||
$response .= $out;
|
||||
}
|
||||
|
||||
if (!$response) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->responseBuffer->write($response);
|
||||
$this->options->getLogger()->logResponse(__METHOD__.'::'.__LINE__." {$response}");
|
||||
}
|
||||
|
||||
protected function isAlive($socket): bool
|
||||
{
|
||||
return $socket !== false;
|
||||
}
|
||||
|
||||
public function setTimeout($timeout): void
|
||||
{
|
||||
$this->timeout = $timeout;
|
||||
}
|
||||
|
||||
public function getResponseBuffer(): Response
|
||||
{
|
||||
return $this->responseBuffer;
|
||||
}
|
||||
|
||||
public function getOptions(): Options
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
protected function checkSocketStatus(): void
|
||||
{
|
||||
$status = socket_get_status($this->connection);
|
||||
|
||||
// echo print_r($status);
|
||||
|
||||
if ($status['eof']) {
|
||||
$this->options->getLogger()->logResponse(
|
||||
__METHOD__.'::'.__LINE__.
|
||||
" ---Probably a broken pipe, restart connection\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
51
src/Xml/Stanzas/Auth.php
Normal file
51
src/Xml/Stanzas/Auth.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Xml\Stanzas;
|
||||
|
||||
use Pdahal\Xmpp\AuthTypes\Authenticable;
|
||||
|
||||
class Auth extends Stanza
|
||||
{
|
||||
public function authenticate(): void
|
||||
{
|
||||
$response = $this->socket->getResponseBuffer()->read();
|
||||
$options = $this->socket->getOptions();
|
||||
|
||||
$tlsSupported = self::isTlsSupported($response);
|
||||
$tlsRequired = self::isTlsRequired($response);
|
||||
|
||||
if ($tlsSupported && ($tlsRequired || (!$tlsRequired && $options->usingTls()))) {
|
||||
$this->startTls();
|
||||
$this->socket->send(self::openXmlStream($options->getDomain()));
|
||||
}
|
||||
|
||||
$xml = $this->generateAuthXml($options->getAuthType());
|
||||
$this->socket->send($xml);
|
||||
$this->socket->send(self::openXmlStream($options->getDomain()));
|
||||
}
|
||||
|
||||
protected function startTls(): void
|
||||
{
|
||||
$this->socket->send("<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>");
|
||||
$response = $this->socket->getResponseBuffer()->read();
|
||||
|
||||
if (!self::canProceed($response)) {
|
||||
$this->socket->getOptions()->getLogger()->error(__METHOD__ . '::' . __LINE__ .
|
||||
" TLS authentication failed. Trying to continue but will most likely fail.");
|
||||
return;
|
||||
}
|
||||
|
||||
stream_socket_enable_crypto($this->socket->connection, true, STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT);
|
||||
}
|
||||
|
||||
protected function generateAuthXml(Authenticable $authType): string
|
||||
{
|
||||
$mechanism = $authType->getName();
|
||||
$encodedCredentials = $authType->encodedCredentials();
|
||||
$nameSpace = "urn:ietf:params:xml:ns:xmpp-sasl";
|
||||
|
||||
return "<auth xmlns='{$nameSpace}' mechanism='{$mechanism}'>{$encodedCredentials}</auth>";
|
||||
}
|
||||
}
|
98
src/Xml/Stanzas/Iq.php
Normal file
98
src/Xml/Stanzas/Iq.php
Normal file
@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Xml\Stanzas;
|
||||
|
||||
class Iq extends Stanza
|
||||
{
|
||||
public function getRoster(): void
|
||||
{
|
||||
$query = "<query xmlns='jabber:iq:roster'/>";
|
||||
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function addToRoster(string $name, string $forJid, string $from, ?string $groupName = null): void
|
||||
{
|
||||
$group = $groupName ? "<group>{$groupName}</group>" : null;
|
||||
$item = "<item jid='{$forJid}' name='{$name}'>{$group}</item>";
|
||||
$query = "<query xmlns='jabber:iq:roster'>{$item}</query>";
|
||||
$xml = "<iq type='set' id='{$this->uniqueId()}' from='{$from}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function removeFromRoster(string $jid, string $myJid): void
|
||||
{
|
||||
$item = "<item jid='{$jid}' subscription='remove'/>";
|
||||
$query = "<query xmlns='jabber:iq:roster'>{$item}</query>";
|
||||
$xml = "<iq type='set' id='{$this->uniqueId()}' from='{$myJid}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function setResource(string $name): void
|
||||
{
|
||||
if (!trim($name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resource = "<resource>{$name}</resource>";
|
||||
$bind = "<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'>{$resource}</bind>";
|
||||
$xml = "<iq type='set' id='{$this->uniqueId()}'>{$bind}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function setGroup(string $name, string $forJid): void
|
||||
{
|
||||
$group = "<group>{$name}</group>";
|
||||
$item = "<item jid='{$forJid}'>{$group}</item>";
|
||||
$query = "<query xmlns='jabber:iq:roster'>{$item}</query>";
|
||||
$xml = "<iq type='set' id='{$this->uniqueId()}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function getServerVersion(): void
|
||||
{
|
||||
$query = "<query xmlns='jabber:iq:version'/>";
|
||||
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function getServerFeatures(): void
|
||||
{
|
||||
$query = "<query xmlns='http://jabber.org/protocol/disco#info'></query>";
|
||||
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function getServerTime(): void
|
||||
{
|
||||
$query = "<query xmlns='urn:xmpp:time'/>";
|
||||
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function getFeatures(string $forJid): void
|
||||
{
|
||||
$query = "<query xmlns='http://jabber.org/protocol/disco#info'></query>";
|
||||
$xml = "<iq type='get' to='{$forJid}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function ping(): void
|
||||
{
|
||||
$query = "<query xmlns='urn:xmpp:ping'/>";
|
||||
$xml = "<iq type='get' id='{$this->uniqueId()}'>{$query}</iq>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
}
|
31
src/Xml/Stanzas/Message.php
Normal file
31
src/Xml/Stanzas/Message.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Xml\Stanzas;
|
||||
|
||||
class Message extends Stanza
|
||||
{
|
||||
public function send(string $body, string $to, string $type = "chat"): void
|
||||
{
|
||||
$xml = $this->generateMessageXml($body, $to, $type);
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function receive(): array
|
||||
{
|
||||
$this->socket->receive();
|
||||
$rawResponse = $this->socket->getResponseBuffer()->read();
|
||||
return self::parseTag($rawResponse, "message");
|
||||
}
|
||||
|
||||
protected function generateMessageXml(string $body, string $to, string $type): string
|
||||
{
|
||||
$to = self::quote($to);
|
||||
$body = self::quote($body);
|
||||
|
||||
$bodyXml = "<body>{$body}</body>";
|
||||
|
||||
return "<message to='{$to}' type='{$type}'>{$bodyXml}</message>";
|
||||
}
|
||||
}
|
67
src/Xml/Stanzas/Presence.php
Normal file
67
src/Xml/Stanzas/Presence.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Xml\Stanzas;
|
||||
|
||||
class Presence extends Stanza
|
||||
{
|
||||
public const PRIORITY_UPPER_BOUND = 127;
|
||||
public const PRIORITY_LOWER_BOUND = -128;
|
||||
|
||||
public function subscribe(string $to): void
|
||||
{
|
||||
$this->setPresence($to, 'subscribe');
|
||||
}
|
||||
|
||||
public function unsubscribe(string $from): void
|
||||
{
|
||||
$this->setPresence($from, 'unsubscribe');
|
||||
}
|
||||
|
||||
public function acceptSubscription(string $from): void
|
||||
{
|
||||
$this->setPresence($from, 'subscribed');
|
||||
}
|
||||
|
||||
public function declineSubscription(string $from): void
|
||||
{
|
||||
$this->setPresence($from, 'unsubscribed');
|
||||
}
|
||||
|
||||
protected function setPresence(string $to, string $type = "subscribe"): void
|
||||
{
|
||||
$xml = "<presence from='{$this->socket->getOptions()->bareJid()}' to='{$to}' type='{$type}'/>";
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set priority to current resource by default, or optional other resource tied to the
|
||||
* current username
|
||||
* @param string|null $forResource
|
||||
*/
|
||||
public function setPriority(int $value, string $forResource = null): void
|
||||
{
|
||||
$from = self::quote($this->socket->getOptions()->fullJid());
|
||||
|
||||
if ($forResource) {
|
||||
$from = $this->socket->getOptions()->getUsername() . "/$forResource";
|
||||
}
|
||||
|
||||
$priority = "<priority>{$this->limitPriority($value)}</priority>";
|
||||
$xml = "<presence from='{$from}'>{$priority}</presence>";
|
||||
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
protected function limitPriority(int $value): int
|
||||
{
|
||||
if ($value > self::PRIORITY_UPPER_BOUND) {
|
||||
return self::PRIORITY_UPPER_BOUND;
|
||||
} elseif ($value < self::PRIORITY_LOWER_BOUND) {
|
||||
return self::PRIORITY_LOWER_BOUND;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
31
src/Xml/Stanzas/Stanza.php
Normal file
31
src/Xml/Stanzas/Stanza.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Xml\Stanzas;
|
||||
|
||||
use Pdahal\Xmpp\Socket;
|
||||
use Pdahal\Xmpp\Xml\Xml;
|
||||
|
||||
abstract class Stanza
|
||||
{
|
||||
use Xml;
|
||||
|
||||
public function __construct(protected Socket $socket)
|
||||
{
|
||||
}
|
||||
|
||||
protected function uniqueId(): string
|
||||
{
|
||||
return uniqid();
|
||||
}
|
||||
|
||||
protected function readResponseFile(): string|false
|
||||
{
|
||||
$logger = $this->socket->getOptions()->getLogger();
|
||||
$responseFilePath = $logger->getFilePathFromResource($logger->log);
|
||||
$responseFile = fopen($responseFilePath, 'r');
|
||||
|
||||
return fread($responseFile, filesize($responseFilePath));
|
||||
}
|
||||
}
|
123
src/Xml/Xml.php
Normal file
123
src/Xml/Xml.php
Normal file
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp\Xml;
|
||||
|
||||
use Pdahal\Xmpp\Exceptions\StreamError;
|
||||
|
||||
trait Xml
|
||||
{
|
||||
/**
|
||||
* Opening tag for starting a XMPP stream exchange.
|
||||
*/
|
||||
public static function openXmlStream(string $host): string
|
||||
{
|
||||
$xmlOpen = "<?xml version='1.0' encoding='UTF-8'?>";
|
||||
$to = "to='{$host}'";
|
||||
$stream = "xmlns:stream='http://etherx.jabber.org/streams'";
|
||||
$client = "xmlns='jabber:client'";
|
||||
$version = "version='1.0'";
|
||||
|
||||
return "{$xmlOpen}<stream:stream {$to} {$stream} {$client} {$version}>";
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing tag for one XMPP stream session.
|
||||
*/
|
||||
public static function closeXmlStream(): string
|
||||
{
|
||||
return '</stream:stream>';
|
||||
}
|
||||
|
||||
public static function quote(string $input): string
|
||||
{
|
||||
return htmlspecialchars($input, ENT_XML1, 'utf-8');
|
||||
}
|
||||
|
||||
public static function parseTag(string $rawResponse, string $tag): array
|
||||
{
|
||||
preg_match_all("#(<{$tag}.*?>.*?<\\/{$tag}>)#si", $rawResponse, $matched);
|
||||
|
||||
return count($matched) <= 1 ? [] : array_map(fn ($match): false|\SimpleXMLElement => @simplexml_load_string($match), $matched[1]);
|
||||
}
|
||||
|
||||
public static function parseFeatures(string $xml): string
|
||||
{
|
||||
return self::matchInsideOfTag($xml, 'stream:features');
|
||||
}
|
||||
|
||||
public static function isTlsSupported(string $xml): bool
|
||||
{
|
||||
$matchTag = self::matchCompleteTag($xml, 'starttls');
|
||||
|
||||
return !empty($matchTag);
|
||||
}
|
||||
|
||||
public static function isTlsRequired(string $xml): bool
|
||||
{
|
||||
if (!self::isTlsSupported($xml)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tls = self::matchCompleteTag($xml, 'starttls');
|
||||
preg_match('#required#', $tls, $match);
|
||||
|
||||
return count($match) > 0;
|
||||
}
|
||||
|
||||
public static function matchCompleteTag(string $xml, string $tag): string
|
||||
{
|
||||
$matches = self::matchTag($xml, $tag);
|
||||
|
||||
return is_array($matches) && count($matches) > 0 ? $matches[0] : '';
|
||||
}
|
||||
|
||||
public static function matchInsideOfTag(string $xml, string $tag): string
|
||||
{
|
||||
$match = self::matchTag($xml, $tag);
|
||||
|
||||
return is_array($match) && count($match) > 1 ? $match[1] : '';
|
||||
}
|
||||
|
||||
private static function matchTag(string $xml, string $tag): array
|
||||
{
|
||||
$matches = null;
|
||||
preg_match("#<{$tag}.*?>(.*)<\\/{$tag}>#", $xml, $matches);
|
||||
|
||||
return count($matches) < 1 ? [] : $matches;
|
||||
}
|
||||
|
||||
public static function canProceed($xml): bool
|
||||
{
|
||||
preg_match("#<proceed xmlns=[\\'|\"]urn:ietf:params:xml:ns:xmpp-tls[\\'|\"]\\/>#", (string) $xml, $match);
|
||||
|
||||
return count($match) > 0;
|
||||
}
|
||||
|
||||
public static function supportedAuthMethods($xml): array
|
||||
{
|
||||
preg_match_all('#<mechanism>(.*?)<\\/mechanism>#', (string) $xml, $match);
|
||||
|
||||
return count($match) < 1 ? [] : $match[1];
|
||||
}
|
||||
|
||||
public static function roster(string $xml): array
|
||||
{
|
||||
preg_match_all("#<iq.*?type=[\\'|\"]result[\\'|\"]>(.*?)<\\/iq>#", $xml, $match);
|
||||
|
||||
return count($match) < 1 ? [] : $match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws StreamError
|
||||
*/
|
||||
public static function checkForUnrecoverableErrors(string $response): void
|
||||
{
|
||||
preg_match_all('#<stream:error>(<(.*?) (.*?)\\/>)<\\/stream:error>#', $response, $streamErrors);
|
||||
|
||||
if ((!empty($streamErrors[0])) && count($streamErrors[2]) > 0) {
|
||||
throw new StreamError($streamErrors[2][0]);
|
||||
}
|
||||
}
|
||||
}
|
131
src/XmppClient.php
Normal file
131
src/XmppClient.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdahal\Xmpp;
|
||||
|
||||
use Pdahal\Xmpp\Exceptions\StreamError;
|
||||
use Pdahal\Xmpp\Xml\Stanzas\Auth;
|
||||
use Pdahal\Xmpp\Xml\Stanzas\Iq;
|
||||
use Pdahal\Xmpp\Xml\Stanzas\Message;
|
||||
use Pdahal\Xmpp\Xml\Stanzas\Presence;
|
||||
use Pdahal\Xmpp\Xml\Xml;
|
||||
|
||||
class XmppClient
|
||||
{
|
||||
use Xml;
|
||||
|
||||
protected Socket $socket;
|
||||
|
||||
public Auth $auth;
|
||||
|
||||
public Iq $iq;
|
||||
|
||||
public Presence $presence;
|
||||
|
||||
public Message $message;
|
||||
|
||||
public function __construct(protected Options $options)
|
||||
{
|
||||
$this->initDependencies();
|
||||
$this->initSession();
|
||||
}
|
||||
|
||||
protected function initDependencies(): void
|
||||
{
|
||||
$this->socket = $this->initSocket();
|
||||
$this->initStanzas($this->socket);
|
||||
}
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
$this->openStream();
|
||||
$this->auth->authenticate();
|
||||
$this->iq->setResource($this->options->getResource());
|
||||
$this->sendInitialPresenceStanza();
|
||||
}
|
||||
|
||||
public function send(string $xml): void
|
||||
{
|
||||
$this->socket->send($xml);
|
||||
}
|
||||
|
||||
public function getResponse(): string
|
||||
{
|
||||
$this->socket->receive();
|
||||
$response = $this->socket->getResponseBuffer()->read();
|
||||
|
||||
return $this->checkForErrors($response);
|
||||
}
|
||||
|
||||
public function prettyPrint(string $response): void
|
||||
{
|
||||
if ($response) {
|
||||
$separator = "\n-------------\n";
|
||||
echo "{$separator} {$response} {$separator}";
|
||||
}
|
||||
}
|
||||
|
||||
public function disconnect(): void
|
||||
{
|
||||
$this->socket->send(self::closeXmlStream());
|
||||
$this->socket->disconnect();
|
||||
}
|
||||
|
||||
protected function openStream(): void
|
||||
{
|
||||
$openStreamXml = self::openXmlStream($this->options->getDomain());
|
||||
$this->socket->send($openStreamXml);
|
||||
}
|
||||
|
||||
protected function sendInitialPresenceStanza(): void
|
||||
{
|
||||
$this->socket->send('<presence/>');
|
||||
}
|
||||
|
||||
protected function initStanzas(Socket $socket): void
|
||||
{
|
||||
$this->auth = new Auth($socket);
|
||||
$this->iq = new Iq($socket);
|
||||
$this->presence = new Presence($socket);
|
||||
$this->message = new Message($socket);
|
||||
}
|
||||
|
||||
protected function initSession(): void
|
||||
{
|
||||
if (!headers_sent($filename, $linenum)) {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function initSocket(): Socket
|
||||
{
|
||||
return new Socket($this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws StreamError
|
||||
*/
|
||||
protected function checkForErrors(string $response): string
|
||||
{
|
||||
try {
|
||||
self::checkForUnrecoverableErrors($response);
|
||||
} catch (StreamError $e) {
|
||||
$this->options->getLogger()->logResponse(__METHOD__.'::'.__LINE__." {$response}");
|
||||
$this->options->getLogger()->error(__METHOD__.'::'.__LINE__.' '.$e->getMessage());
|
||||
$this->reconnect();
|
||||
$response = '';
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function reconnect(): void
|
||||
{
|
||||
$this->disconnect();
|
||||
$this->initDependencies();
|
||||
$this->connect();
|
||||
}
|
||||
}
|
31
tests/AuthPlainTest.php
Normal file
31
tests/AuthPlainTest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Pdahal\Xmpp\AuthTypes\Plain;
|
||||
use Pdahal\Xmpp\Options;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class AuthPlainTest extends TestCase
|
||||
{
|
||||
public Plain $plainAuth;
|
||||
|
||||
public Options $optionsStub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->optionsStub = $this->createMock(Options::class);
|
||||
$this->optionsStub->method('getUsername')->willReturn('Foo');
|
||||
$this->optionsStub->method('getPassword')->willReturn('Bar');
|
||||
|
||||
$this->plainAuth = new Plain($this->optionsStub);
|
||||
}
|
||||
|
||||
public function testIfCredentialsAreEncodedRight(): void
|
||||
{
|
||||
$this->assertEquals('AEZvbwBCYXI=', $this->plainAuth->encodedCredentials());
|
||||
}
|
||||
}
|
26
tests/DigestMD5Test.php
Normal file
26
tests/DigestMD5Test.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Pdahal\Xmpp\AuthTypes\DigestMD5;
|
||||
use Pdahal\Xmpp\Options;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class DigestMD5Test extends TestCase
|
||||
{
|
||||
public DigestMD5 $digestAuth;
|
||||
|
||||
public Options $optionsStub;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->optionsStub = $this->createMock(Options::class);
|
||||
$this->optionsStub->method('getUsername')->willReturn('Foo');
|
||||
$this->optionsStub->method('getPassword')->willReturn('Bar');
|
||||
|
||||
$this->digestAuth = new DigestMD5($this->optionsStub);
|
||||
}
|
||||
|
||||
public function testIfCredentialsAreEncodedRight(): void
|
||||
{
|
||||
$this->assertEquals("80e25ae4140c338afbe621c1b3d7a9ec9480f731", $this->digestAuth->encodedCredentials());
|
||||
}
|
||||
}
|
124
tests/OptionsTest.php
Normal file
124
tests/OptionsTest.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use Pdahal\Xmpp\Options;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class OptionsTest extends TestCase
|
||||
{
|
||||
protected $host;
|
||||
protected $port;
|
||||
protected $username;
|
||||
protected $password;
|
||||
|
||||
public Options $options;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->host = 'www.host.com';
|
||||
$this->username = 'foo';
|
||||
$this->password = 'bar';
|
||||
$this->port = 5222;
|
||||
$this->options = new Options();
|
||||
|
||||
$this->options
|
||||
->setHost($this->host)
|
||||
->setPort($this->port)
|
||||
->setUsername($this->username)
|
||||
->setPassword($this->password)
|
||||
;
|
||||
}
|
||||
|
||||
public function testIfNoHostThrowsError(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->options->setHost('');
|
||||
$this->options->getHost();
|
||||
}
|
||||
|
||||
public function testIfNoUsernameThrowsError(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->options->setUsername('');
|
||||
$this->options->getUsername();
|
||||
}
|
||||
|
||||
public function testIfNoPasswordThrowsError(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->options->setPassword('');
|
||||
$this->options->getPassword();
|
||||
}
|
||||
|
||||
public function testIfHostGetsTrimmed(): void
|
||||
{
|
||||
$this->options->setHost(' host');
|
||||
$this->assertEquals('host', $this->options->getHost());
|
||||
|
||||
$this->options->setHost('host ');
|
||||
$this->assertEquals('host', $this->options->getHost());
|
||||
|
||||
$this->options->setHost(' host ');
|
||||
$this->assertEquals('host', $this->options->getHost());
|
||||
}
|
||||
|
||||
public function testIfUsernameSplitResource(): void
|
||||
{
|
||||
$this->options->setUsername('user/resource');
|
||||
$this->assertEquals('user', $this->options->getUsername());
|
||||
$this->assertEquals('resource', $this->options->getResource());
|
||||
|
||||
$this->options->setUsername('user');
|
||||
$this->assertEquals('user', $this->options->getUsername());
|
||||
|
||||
$this->options->setUsername('user/resource/resource2/resource3');
|
||||
$this->assertEquals('user', $this->options->getUsername());
|
||||
$this->assertEquals('resource', $this->options->getResource());
|
||||
}
|
||||
|
||||
public function testResourcePrecedence(): void
|
||||
{
|
||||
$this->options->setUsername('user/resource');
|
||||
$this->options->setResource('resource2');
|
||||
$this->assertEquals('user', $this->options->getUsername());
|
||||
$this->assertEquals('resource2', $this->options->getResource());
|
||||
|
||||
$this->options->setResource('resource2');
|
||||
$this->options->setUsername('user/resource');
|
||||
$this->assertEquals('user', $this->options->getUsername());
|
||||
$this->assertEquals('resource', $this->options->getResource());
|
||||
}
|
||||
|
||||
public function testIfResourceGetsTrimmed(): void
|
||||
{
|
||||
$this->options->setResource(' resource');
|
||||
$this->assertEquals('resource', $this->options->getResource());
|
||||
|
||||
$this->options->setResource('resource ');
|
||||
$this->assertEquals('resource', $this->options->getResource());
|
||||
|
||||
$this->options->setResource(' resource ');
|
||||
$this->assertEquals('resource', $this->options->getResource());
|
||||
}
|
||||
|
||||
public function testFullSocketAddress(): void
|
||||
{
|
||||
$this->assertEquals('tcp://www.host.com:5222', $this->options->fullSocketAddress());
|
||||
}
|
||||
|
||||
public function testFullJid(): void
|
||||
{
|
||||
$this->options->setResource('resource');
|
||||
$this->assertEquals('foo@www.host.com/resource', $this->options->fullJid());
|
||||
}
|
||||
|
||||
public function testBareJid(): void
|
||||
{
|
||||
$this->assertEquals('foo@www.host.com', $this->options->bareJid());
|
||||
}
|
||||
}
|
70
tests/ResponseBufferTest.php
Normal file
70
tests/ResponseBufferTest.php
Normal file
@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use Pdahal\Xmpp\Buffers\Response;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class ResponseBufferTest extends TestCase
|
||||
{
|
||||
public Response $buffer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->buffer = new Response();
|
||||
}
|
||||
|
||||
public function testWriteString(): void
|
||||
{
|
||||
$this->buffer->write('test');
|
||||
$this->assertEquals(['test'], $this->buffer->getCurrentBufferData());
|
||||
}
|
||||
|
||||
public function testWriteNumber(): void
|
||||
{
|
||||
$this->buffer->write(123);
|
||||
$this->assertEquals(['123'], $this->buffer->getCurrentBufferData());
|
||||
}
|
||||
|
||||
public function testWriteEmpty(): void
|
||||
{
|
||||
$this->buffer->write('');
|
||||
$this->assertEquals([], $this->buffer->getCurrentBufferData());
|
||||
}
|
||||
|
||||
public function testWriteNull(): void
|
||||
{
|
||||
$this->buffer->write(null);
|
||||
$this->assertEquals([], $this->buffer->getCurrentBufferData());
|
||||
}
|
||||
|
||||
public function testRead(): void
|
||||
{
|
||||
$this->buffer->write('test');
|
||||
$response = $this->buffer->read();
|
||||
$this->assertEquals('test', $response);
|
||||
}
|
||||
|
||||
public function testReadNullInput(): void
|
||||
{
|
||||
$this->buffer->write(null);
|
||||
$response = $this->buffer->read();
|
||||
$this->assertEquals('', $response);
|
||||
}
|
||||
|
||||
public function testFlushWithInput(): void
|
||||
{
|
||||
$this->buffer->write('test');
|
||||
$this->buffer->read();
|
||||
$this->assertEquals([], $this->buffer->getCurrentBufferData());
|
||||
}
|
||||
|
||||
public function testFlushWithoutInput(): void
|
||||
{
|
||||
$this->buffer->read();
|
||||
$this->assertEquals([], $this->buffer->getCurrentBufferData());
|
||||
}
|
||||
}
|
16
tests/XmlTest.php
Normal file
16
tests/XmlTest.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Pdahal\Xmpp\Xml\Xml;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class XmlTest extends TestCase
|
||||
{
|
||||
use Xml;
|
||||
public $host = 'www.test.com';
|
||||
|
||||
public function testOpeningStream(): void
|
||||
{
|
||||
$expected = "<?xml version='1.0' encoding='UTF-8'?><stream:stream to='www.test.com' xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client' version='1.0'>";
|
||||
$this->assertEquals($expected, $this->openXmlStream($this->host));
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user