├── .github └── FUNDING.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Client.php ├── Factory.php ├── Io ├── Binary.php ├── DatastreamProtocol.php ├── LegacyProtocol.php ├── PacketSplitter.php ├── Prober.php └── Protocol.php └── Models ├── BufferInfo.php └── Message.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: clue 2 | custom: https://clue.engineering/support 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.7.0 (2021-02-26) 4 | 5 | * Feature / BC break: Add `BufferInfo` and `Message` to represent complex data types, 6 | represent all map structures as `stdClass` objects instead of assoc arrays, 7 | represent message timestamps as `DateTime` objects and 8 | change `IrcUsersAndChannels` structure to its logic represenation. 9 | (#41, #43, #44, #46, #47 and #49 by @clue) 10 | 11 | Incoming buffers/channels and chat messages use complex data models, 12 | so they are represented by `BufferInfo` and `Message` respectively. 13 | 14 | All other data types use plain structured data, so you can access its structure very similar to a JSON-like data structure. 15 | All map structures are now represented as `stdClass` object instead of assoc arrays. 16 | This only applies to object maps and lists will continue to be represented as arrays. 17 | This allows for a clear distinction between these concepts and allows you to 18 | differentiate between empty objects and empty lists. 19 | This is in line with how PHP's `json_decode()` function works by default. 20 | 21 | This is a major BC break because this means these data structures can no longer be accessed like normal PHP arrays. 22 | However, using the new `BufferInfo` and `Message` models makes accessing these somewhat easier. 23 | See the updated examples for more details on practical effect, for example: 24 | 25 | ```php 26 | // old 27 | assert(is_array($message)); 28 | echo $message['sender'] . ': ' . $message['content'] . PHP_EOL; 29 | assert(is_int($message['timestamp'])); 30 | echo 'Date: ' . date('Y-m-d', $message['timestamp']) . PHP_EOL; 31 | assert(is_array($message['bufferInfo'])); 32 | echo 'Channel: ' . $message['bufferInfo']['name'] . PHP_EOL; 33 | 34 | // new 35 | assert($message instanceof Message); 36 | echo $message->sender . ': ' . $message->contents . PHP_EOL; 37 | assert($message->timestamp instanceof DateTime); 38 | echo 'Date: ' . $message->timestamp->format('Y-m-d') . PHP_EOL; 39 | assert($message->bufferInfo instanceof BufferInfo); 40 | echo 'Channel: ' . $message->bufferInfo->name . PHP_EOL; 41 | ``` 42 | 43 | * Feature / BC break: Update `writeBufferRequestBacklog()` parameters and add new `writeBufferRequestBacklogAll()`. 44 | (#42 by @clue) 45 | 46 | ```php 47 | // old 48 | $client->writeBufferRequestBacklog($bufferId, 100); 49 | 50 | // new 51 | $client->writeBufferRequestBacklog($bufferId, -1, -1, 100, 0); 52 | $client->writeBufferRequestBacklogAll(-1, -1, 100, 0); 53 | ``` 54 | 55 | * Feature / BC Break: Automatically send heartbeat requests and replies (ping messages). 56 | This can be controlled with the new `?ping=0` and `?pong=0` parameters. 57 | (#38 and #39 by @clue) 58 | 59 | * Feature: Support passing authentication as part of URL to Quassel core. 60 | (#36 by @clue) 61 | 62 | ```php 63 | $factory->createClient('quassel://user:h%40llo@localhost')->then( 64 | function (Client $client) { 65 | // client sucessfully connected and authenticated 66 | $client->on('data', function ($data) { 67 | // next message to follow would be "SessionInit" 68 | }); 69 | } 70 | ); 71 | ``` 72 | 73 | * Feature: Ignore unsolicited `CoreInfo reports` (Quassel v0.13+). 74 | (#45 by @clue) 75 | 76 | * Feature: Support permanently removing networks and improve examples to support disconnected networks. 77 | (#48 and #50 by @clue) 78 | 79 | ```php 80 | // new 81 | $client->writeNetworkRemove($networkId); 82 | ``` 83 | 84 | * Feature: Update QDataStream dependency to no longer depend on `ext-mbstring`. 85 | (#35 by @clue) 86 | 87 | * Improve test suite and add `.gitattributes` to exclude dev files from exports. 88 | Prepare PHP 8 support, update to PHPUnit 9 and simplify test matrix. 89 | (#33 by @carusogabriel, #34, #37 and #54 by @clue and #52 and #53 by @SimonFrings) 90 | 91 | ## 0.6.0 (2017-10-20) 92 | 93 | * Feature / BC break: Upcast legacy Network sync model to newer datastream protocol variant 94 | and only support optional `quassel://` URI scheme and always use probing 95 | (#27 and #31 by @clue) 96 | 97 | This means that both the old "legacy" protocol and the newer "datastream" 98 | protocol now expose message data in the exact same format so that you no 99 | longer have to worry about protocol inconsistencies. 100 | 101 | > Note that this is not a BC break for most consumers, it merely updates 102 | the "legacy" fallback protocol to behave just like the the "datastream" 103 | protocol. 104 | 105 | * Feature / BC break: Significantly improve performance by updating QDataStream dependency and 106 | suggest `ext-mbstring` for character encoding and mark all protocol classes as `@internal` only 107 | (#26 by @clue) 108 | 109 | This update significantly improves performance and in particular parsing 110 | large messages (such as the `SessionInit` message) is now ~20 to ~100 111 | times as fast. What previously took seconds now takes mere milliseconds. 112 | This also makes the previously implicit dependency on `ext-mbstring` 113 | entirely optional. 114 | It is recommended to install this extension to support special characters 115 | outside of ASCII / ISO8859-1 range. 116 | 117 | > Note that this is not a BC break for most consumers, it merely updates 118 | internal protocol handler classes. 119 | 120 | * Feature / Fix: Report error if connection ends while receiving data and 121 | simplify close logic to remove all event listeners once closed 122 | (#30 by @clue) 123 | 124 | * Feature: Automatically send current timestamp for heartbeat requests by default unless explicit timestamp is given 125 | (#32 by @clue) 126 | 127 | ```php 128 | // new: no parameter sends current timestamp 129 | $client->writeHeartBeatRequest(); 130 | ``` 131 | 132 | * Feature: Limit incoming packet size to 16 MB to avoid excessive buffers 133 | (#29 by @clue) 134 | 135 | * Update examples and add chatbot examples 136 | (#28 by @clue) 137 | 138 | ## 0.5.0 (2017-08-05) 139 | 140 | * Feature / BC break: Replace legacy SocketClient with new Socket component and 141 | improve forward compatibility with new components 142 | (#25 by @clue) 143 | 144 | > Note that this is not a BC break for most consumers, it merely updates 145 | internal references and the optional parameter passed to the `Factory`. 146 | 147 | ```php 148 | // old from SocketClient component 149 | $dnsFactory = new React\Dns\Resolver\Factory(); 150 | $resolver = $dnsFactory->create('8.8.8.8', $loop); 151 | $connector = new React\SocketClient\Connector($loop, $resolver); 152 | $factory = new Factory($loop, $connector); 153 | 154 | // new from Socket component 155 | $connector = new React\Socket\Connector($loop, array( 156 | 'dns' => '8.8.8.8' 157 | )); 158 | $factory = new Factory($loop, $connector); 159 | ``` 160 | 161 | * Forward compatibility with PHP 7.1 and PHPUnit v5 162 | (#24 by @clue) 163 | 164 | * Improve test suite by locking Travis distro so new defaults will not break the build 165 | (#23 by @clue) 166 | 167 | ## 0.4.0 (2016-09-26) 168 | 169 | * Feature / BC break: The Client implements DuplexStreamInterface and behaves like a normal stream 170 | (#21 and #22 by @clue) 171 | 172 | ```php 173 | // old (custom "message" event) 174 | $client->on('message', $callback); 175 | 176 | // new (default "data" event) 177 | $client->on('data', $callback); 178 | 179 | // old (applies to app send*() methods 180 | $client->sendClientInit(…); 181 | 182 | // new (now uses write*() prefix) 183 | $client->writeClientInit(…); 184 | 185 | // shared interfaces allow for interoperability with other components 186 | $client->pipe($logger); 187 | 188 | // allows advanced / custom messages through writable interface 189 | $client->write(array(…)); 190 | 191 | // supports and reports back pressure to avoid buffer overflows 192 | $more = $client->write*(…); 193 | $client->pause(); 194 | $client->resume(); 195 | ``` 196 | 197 | * Feature: Use default time zone and support sub-second accuracy for heartbeats 198 | (#20 by @clue) 199 | 200 | ## 0.3.1 (2016-09-24) 201 | 202 | * Feature: Support SocketClient v0.5 (while keeping BC) 203 | (#18 by @clue) 204 | 205 | * Maintenance: First class support for PHP 5.3 through PHP 7 and HHVM 206 | (#19 by @clue) 207 | 208 | ## 0.3.0 (2015-08-20) 209 | 210 | * Feature: Support newer "datastream" protocol 211 | ([#13](https://github.com/clue/php-quassel-react/pull/13), [#15](https://github.com/clue/php-quassel-react/pull/15)) 212 | 213 | * Feature: Explicitly pass "legacy" protocol scheme to avoid probing protocol 214 | ([#16](https://github.com/clue/php-quassel-react/pull/16)) 215 | 216 | * Improved documentation, more SOLID code base and updated dependencies. 217 | ([#11](https://github.com/clue/php-quassel-react/pull/11), [#14](https://github.com/clue/php-quassel-react/pull/14)) 218 | 219 | ## 0.2.1 (2015-05-14) 220 | 221 | * Update clue/qdatastream to v0.5.0 222 | ([#12](https://github.com/clue/php-quassel-react/pull/12)) 223 | 224 | ## 0.2.0 (2015-05-12) 225 | 226 | * Feature: Support sending buffer input, accessing its backlog and (dis)connecting networks 227 | ([#9](https://github.com/clue/php-quassel-react/pull/9)) 228 | 229 | * Feature: Support sending heart beat requests and fix invalid heart beat timezone 230 | ([#10](https://github.com/clue/php-quassel-react/pull/10)) 231 | 232 | ## 0.1.0 (2015-05-03) 233 | 234 | * First tagged release 235 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Christian Lück 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clue/reactphp-quassel 2 | 3 | [![CI status](https://github.com/clue/reactphp-quassel/workflows/CI/badge.svg)](https://github.com/clue/reactphp-quassel/actions) 4 | [![installs on Packagist](https://img.shields.io/packagist/dt/clue/quassel-react?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/clue/quassel-react) 5 | 6 | Streaming, event-driven access to your [Quassel IRC](http://quassel-irc.org/) core, 7 | built on top of [ReactPHP](https://reactphp.org/). 8 | 9 | This is a lightweight and low-level networking library which can be used to 10 | communicate with your Quassel IRC core. 11 | It allows you to react to incoming events (such as an incoming message) and to 12 | perform new requests (such as sending an outgoing reply message). 13 | This can be used to build chatbots, export your channel backlog, list online 14 | users, forward backend events as a message to a channel and much more. 15 | Unlike conventional IRC chatbots, Quassel IRC allows re-using your existing 16 | identity and sharing it with both a person and a chatbot, so that an outside 17 | person has no idea about this and only sees a single contact. 18 | 19 | * **Async execution of requests** - 20 | Send any number of requests to your Quassel IRC core in parallel (automatic pipeline) and 21 | process their responses as soon as results come in. 22 | * **Event-driven core** - 23 | Register your event handler callbacks to react to incoming events, such as an incoming chat message event. 24 | * **Lightweight, SOLID design** - 25 | Provides a thin abstraction that is [*just good enough*](http://en.wikipedia.org/wiki/Principle_of_good_enough) 26 | and does not get in your way. 27 | Future or custom commands and events require little to no changes to be supported. 28 | * **Good test coverage** - 29 | Comes with an automated tests suite and is regularly tested against actual Quassel IRC cores in the wild 30 | 31 | **Table of contents** 32 | 33 | * [Support us](#support-us) 34 | * [Quickstart example](#quickstart-example) 35 | * [Usage](#usage) 36 | * [Factory](#factory) 37 | * [createClient()](#createclient) 38 | * [Client](#client) 39 | * [Commands](#commands) 40 | * [Processing](#processing) 41 | * [on()](#on) 42 | * [close()](#close) 43 | * [Install](#install) 44 | * [Tests](#tests) 45 | * [License](#license) 46 | 47 | ## Support us 48 | 49 | We invest a lot of time developing, maintaining and updating our awesome 50 | open-source projects. You can help us sustain this high-quality of our work by 51 | [becoming a sponsor on GitHub](https://github.com/sponsors/clue). Sponsors get 52 | numerous benefits in return, see our [sponsoring page](https://github.com/sponsors/clue) 53 | for details. 54 | 55 | Let's take these projects to the next level together! 🚀 56 | 57 | ## Quickstart example 58 | 59 | The Quassel IRC protocol is not exactly trivial to explain and has some 60 | *interesting* message semantics. As such, it's highly recommended to check out 61 | the [examples](examples) to get started. 62 | 63 | ## Usage 64 | 65 | ### Factory 66 | 67 | The `Factory` is responsible for creating your [`Client`](#client) instance. 68 | 69 | ```php 70 | $factory = new Clue\React\Quassel\Factory(); 71 | ``` 72 | 73 | This class takes an optional `LoopInterface|null $loop` parameter that can be used to 74 | pass the event loop instance to use for this object. You can use a `null` value 75 | here in order to use the [default loop](https://github.com/reactphp/event-loop#loop). 76 | This value SHOULD NOT be given unless you're sure you want to explicitly use a 77 | given event loop instance. 78 | 79 | If you need custom connector settings (DNS resolution, TLS parameters, timeouts, 80 | proxy servers etc.), you can explicitly pass a custom instance of the 81 | [`ConnectorInterface`](https://github.com/reactphp/socket#connectorinterface): 82 | 83 | ```php 84 | $connector = new React\Socket\Connector(array( 85 | 'dns' => '127.0.0.1', 86 | 'tcp' => array( 87 | 'bindto' => '192.168.10.1:0' 88 | ), 89 | 'tls' => array( 90 | 'verify_peer' => false, 91 | 'verify_peer_name' => false 92 | ) 93 | )); 94 | 95 | $factory = new Clue\React\Quassel\Factory(null, $connector); 96 | ``` 97 | 98 | #### createClient() 99 | 100 | The `createClient($uri)` method can be used to create a new [`Client`](#client). 101 | It helps with establishing a plain TCP/IP connection to your Quassel IRC core 102 | and probing for the correct protocol to use. 103 | 104 | ```php 105 | $factory->createClient('localhost')->then( 106 | function (Client $client) { 107 | // client connected (and authenticated) 108 | }, 109 | function (Exception $e) { 110 | // an error occured while trying to connect (or authenticate) client 111 | } 112 | ); 113 | ``` 114 | 115 | The `$uri` parameter must be a valid URI which must contain a host part and can 116 | optionally be preceded by the `quassel://` URI scheme and may contain a port 117 | if your Quassel IRC core is not using the default TCP/IP port `4242`: 118 | 119 | ```php 120 | $factory->createClient('quassel://localhost:4242'); 121 | ``` 122 | 123 | Quassel supports password-based authentication. If you want to create a "normal" 124 | client connection, you're recommended to pass the authentication details as part 125 | of the URI. You can pass the password `h@llo` URL-encoded (percent-encoded) as 126 | part of the URI like this: 127 | 128 | ```php 129 | $factory->createClient('quassel://user:h%40llo@localhost')->then( 130 | function (Client $client) { 131 | // client sucessfully connected and authenticated 132 | $client->on('data', function ($data) { 133 | // next message to follow would be "SessionInit" 134 | }); 135 | } 136 | ); 137 | ``` 138 | 139 | Note that if you do not pass the authentication details as part of the URI, then 140 | this method will resolve with a "bare" `Client` right after connecting without 141 | sending any application messages. This can be useful if you need full control 142 | over the message flow, see below for more details. 143 | 144 | Quassel uses "heartbeat" messages as a keep-alive mechanism to check the 145 | connection between Quassel core and Quassel client is still active. This project 146 | will automatically respond to each incoming "ping" (heartbeat request) with an 147 | appropriate "pong" (heartbeat response) message. If you do not want this and 148 | want to handle incoming heartbeat request messages yourself, you may pass the 149 | optional `?pong=0` parameter like this: 150 | 151 | ```php 152 | $factory->createClient('quassel://localhost?pong=0'); 153 | ``` 154 | 155 | This automatic "pong" mechanism allows the Quassel core to detect the connection 156 | to the client is still active. However, it does not allow the client to detect 157 | if the connection to the Quassel core is still active. Because of this, this 158 | project will automatically send a "ping" (heartbeat request) message to the 159 | Quassel core if it did not receive any messages for 60s by default. If no 160 | message has been received after waiting for another period, the connection is 161 | assumed to be dead and will be closed. You can pass the `?ping=120.0` parameter 162 | to change this default interval. The Quassel core uses a configurable ping 163 | interval of 30s by default and also sends all IRC network state changes to the 164 | client, so this mechanism should only really kick in if the connection looks 165 | dead. If you do not want this and want to handle outgoing heartbeat request 166 | messages yourself, you may pass the optional `?ping=0` parameter like this: 167 | 168 | ```php 169 | $factory->createClient('quassel://localhost?ping=0'); 170 | ``` 171 | 172 | > This method uses Quassel IRC's probing mechanism for the correct protocol to 173 | use (newer "datastream" protocol or original "legacy" protocol). 174 | Protocol handling will be abstracted away for you, so you don't have to 175 | worry about this (see also below for more details about protocol messages). 176 | Note that this project does not currently implement encryption and 177 | compression support. 178 | 179 | ### Client 180 | 181 | The `Client` is responsible for exchanging messages with your Quassel IRC core 182 | and emitting incoming messages. 183 | It implements the [`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface), 184 | i.e. it is both a normal readable and writable stream instance. 185 | 186 | #### Commands 187 | 188 | The `Client` exposes several public methods which can be used to send outgoing commands to your Quassel IRC core: 189 | 190 | ```php 191 | $client->writeClientInit() 192 | $client->writeClientLogin($user, $password); 193 | 194 | $client->writeHeartBeatRequest($time); 195 | $client->writeHeartBeatReply($time); 196 | 197 | $client->writeBufferRequestBacklog($bufferId, $messageIdFirst, $messageIdLast, $maxAmount, $additional); 198 | $client->writeBufferRequestBacklogAll($messageIdFirst, $messageIdLast, $maxAmount, $additional); 199 | $client->writeBufferInput($bufferInfo, $input); 200 | 201 | // many more… 202 | ``` 203 | 204 | Listing all available commands is out of scope here, please refer to the [class outline](src/Client.php). 205 | 206 | #### Processing 207 | 208 | Sending commands is async (non-blocking), so you can actually send multiple commands in parallel. 209 | You can send multiple commands in parallel, pending commands will be pipelined automatically. 210 | 211 | Quassel IRC has some *interesting* protocol semantics, which means that commands do not use request-response style. 212 | *Some* commands will trigger a message to be sent in response, see [on()](#on) for more details. 213 | 214 | #### on() 215 | 216 | The `on($eventName, $eventHandler)` method can be used to register a new event handler. 217 | Incoming events will be forwarded to registered event handler callbacks: 218 | 219 | ```php 220 | $client->on('data', function ($data) { 221 | // process an incoming message (raw message object or array) 222 | var_dump($data); 223 | }); 224 | 225 | $client->on('end', function () { 226 | // connection ended, client will close 227 | }); 228 | 229 | $client->on('error', function (Exception $e) { 230 | // an error occured, client will close 231 | }); 232 | 233 | $client->on('close', function () { 234 | // the connection to Quassel IRC just closed 235 | }); 236 | ``` 237 | 238 | The `data` event will be forwarded with the PHP representation of whatever the 239 | remote Quassel IRC core sent to this client. 240 | From a consumer perspective this looks very similar to a parsed JSON structure, 241 | but this actually uses a binary wire format under the hood. 242 | This library exposes this parsed structure as-is and does usually not change 243 | anything about it. 244 | 245 | There are only few noticable exceptions to this rule: 246 | 247 | * Incoming buffers/channels and chat messages use complex data models, so they 248 | are represented by `BufferInfo` and `Message` respectively. All other data 249 | types use plain structured data, so you can access its object-based 250 | structure very similar to a JSON-like data structure. 251 | * The legacy protocol uses plain times for heartbeat messages while the newer 252 | datastream protocol uses `DateTime` objects. 253 | This library always converts this to `DateTime` for consistency reasons. 254 | * The initial `Network` synchronization uses different structures for the 255 | `IrcUsersAndChannels` format structures depending on which wire-protocol is 256 | used. This library always exposes this structure in its simpler "logic" form 257 | for consistency reasons. This means it always contains the keys `Users` and 258 | `Channels` which both contain a list of objects decribing each element. 259 | 260 | This combined basically means that you should always get consistent `data` 261 | events for both the legacy protocol and the newer datastream protocol. 262 | 263 | #### close() 264 | 265 | The `close()` method can be used to force-close the Quassel connection immediately. 266 | 267 | ## Install 268 | 269 | The recommended way to install this library is [through Composer](https://getcomposer.org). 270 | [New to Composer?](https://getcomposer.org/doc/00-intro.md) 271 | 272 | This will install the latest supported version: 273 | 274 | ```bash 275 | $ composer require clue/quassel-react:^0.7 276 | ``` 277 | 278 | See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. 279 | 280 | This project aims to run on any platform and thus does not require any PHP 281 | extensions and supports running on legacy PHP 5.3 through current PHP 8+ and 282 | HHVM. 283 | It's *highly recommended to use PHP 7+* for this project. 284 | 285 | Internally, it will use the `ext-mbstring` for converting between different 286 | character encodings for message strings. 287 | If this extension is missing, then this library will use a slighty slower Regex 288 | work-around that should otherwise work equally well. 289 | Installing `ext-mbstring` is highly recommended. 290 | 291 | ## Tests 292 | 293 | To run the test suite, you first need to clone this repo and then install all 294 | dependencies [through Composer](https://getcomposer.org): 295 | 296 | ```bash 297 | $ composer install 298 | ``` 299 | 300 | To run the test suite, go to the project root and run: 301 | 302 | ```bash 303 | $ php vendor/bin/phpunit 304 | ``` 305 | 306 | The test suite contains both unit tests and functional integration tests. 307 | The functional tests require access to a running Quassel core server instance 308 | and will be skipped by default. 309 | 310 | Note that the functional test suite contains tests that set up your Quassel core 311 | (i.e. register your initial user if not already present). This test will be skipped 312 | if your core is already set up. 313 | You can use a [Docker container](https://github.com/clue/docker-quassel-core) 314 | if you want to test this against a fresh Quassel core: 315 | 316 | ``` 317 | $ docker run -it --rm -p 4242:4242 clue/quassel-core -d 318 | ``` 319 | 320 | If you want to run the functional tests, you need to supply *your* Quassel login 321 | details in environment variables like this: 322 | 323 | ```bash 324 | $ QUASSEL_HOST=127.0.0.1 QUASSEL_USER=quassel QUASSEL_PASS=secret phpunit 325 | ``` 326 | 327 | ## License 328 | 329 | This project is released under the permissive [MIT license](LICENSE). 330 | 331 | > Did you know that I offer custom development services and issuing invoices for 332 | sponsorships of releases and for contributions? Contact me (@clue) for details. 333 | 334 | This library took some inspiration from other existing tools and libraries. 335 | As such, a huge shoutout to the authors of the following repositories! 336 | 337 | * [Quassel](https://github.com/quassel/quassel) 338 | * [QuasselDroid](https://github.com/sandsmark/QuasselDroid) 339 | * [node-libquassel](https://github.com/magne4000/node-libquassel) 340 | * [node-qtdatastream](https://github.com/magne4000/node-qtdatastream) 341 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clue/quassel-react", 3 | "description": "Streaming, event-driven access to your Quassel IRC core, built on top of ReactPHP.", 4 | "keywords": ["Quassel", "IRC", "ReactPHP", "async"], 5 | "homepage": "https://github.com/clue/reactphp-quassel", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Christian Lück", 10 | "email": "christian@clue.engineering" 11 | } 12 | ], 13 | "autoload": { 14 | "psr-4": { "Clue\\React\\Quassel\\": "src/" } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { "Clue\\Tests\\React\\Quassel\\": "tests/" } 18 | }, 19 | "require": { 20 | "php": ">=5.3", 21 | "clue/qdatastream": "^0.8", 22 | "react/event-loop": "^1.2", 23 | "react/promise": "~2.0|~1.1", 24 | "react/socket": "^1.9", 25 | "react/stream": "^1.2" 26 | }, 27 | "require-dev": { 28 | "clue/block-react": "^1.1", 29 | "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 37 | $this->protocol = $protocol; 38 | $this->splitter = $splitter; 39 | 40 | $stream->on('data', array($this, 'handleData')); 41 | $stream->on('end', array($this, 'handleEnd')); 42 | $stream->on('error', array($this, 'handleError')); 43 | $stream->on('close', array($this, 'close')); 44 | $stream->on('drain', array($this, 'handleDrain')); 45 | } 46 | 47 | /** 48 | * send client init info 49 | * 50 | * expect either of ClientInitAck or ClientInitReject["Error"] in response 51 | * 52 | * ClientInitAck["Configured"] === true means you should continue with 53 | * writeClientInit() next 54 | * 55 | * ClientInitAck["Configured"] === false means you should continue with 56 | * writeCoreSetupData() next 57 | * 58 | * @param boolean $compression (only for legacy protocol) 59 | * @param boolean $ssl (only for legacy protocol) 60 | * @return boolean 61 | */ 62 | public function writeClientInit($compression = false, $ssl = false) 63 | { 64 | // MMM dd yyyy HH:mm:ss 65 | $date = date('M d Y H:i:s'); 66 | 67 | $data = array( 68 | 'MsgType' => 'ClientInit', 69 | 'ClientDate' => $date, 70 | 'ClientVersion' => 'clue/quassel-react alpha' 71 | ); 72 | 73 | if ($this->protocol->isLegacy()) { 74 | $data += array( 75 | 'ProtocolVersion' => 10, 76 | 'UseCompression' => (bool)$compression, 77 | 'UseSsl' => (bool)$ssl 78 | ); 79 | } 80 | 81 | return $this->write($data); 82 | } 83 | 84 | /** 85 | * send client login credentials 86 | * 87 | * expect either of ClientLoginAck or ClientLoginReject["Error"] in response 88 | * 89 | * @param string $user 90 | * @param string $password 91 | * @return boolean 92 | */ 93 | public function writeClientLogin($user, $password) 94 | { 95 | return $this->write(array( 96 | 'MsgType' => 'ClientLogin', 97 | 'User' => (string)$user, 98 | 'Password' => (string)$password 99 | )); 100 | } 101 | 102 | /** 103 | * send setup data 104 | * 105 | * expect either of CoreSetupAck or CoreSetupReject["Error"] in response 106 | * 107 | * Possible values for the $backend and $properties parameters are reported 108 | * as part of the ClientInitAck["StorageBackends"] list of maps. 109 | * 110 | * At the time of writing this, the only supported backends are the default 111 | * "SQLite" which requires no additional configuration and the significantly 112 | * faster "PostgreSQL" which accepts a map with your database credentials. 113 | * 114 | * @param string $user admin user name 115 | * @param string $password admin password 116 | * @param string $backend One of the available "DisplayName" values from ClientInitAck["StorageBackends"] 117 | * @param array $properties (optional) map with keys from "SetupKeys" from ClientInitAck["StorageBackends"], where missing keys default to those from the "SetupDefaults" 118 | * @return boolean 119 | */ 120 | public function writeCoreSetupData($user, $password, $backend = 'SQLite', $properties = array()) 121 | { 122 | return $this->write(array( 123 | 'MsgType' => 'CoreSetupData', 124 | 'SetupData' => array( 125 | 'AdminUser' => (string)$user, 126 | 'AdminPasswd' => (string)$password, 127 | 'Backend' => (string)$backend, 128 | 'ConnectionProperties' => $properties 129 | ) 130 | )); 131 | } 132 | 133 | public function writeInitRequest($class, $name) 134 | { 135 | return $this->write(array( 136 | Protocol::REQUEST_INITREQUEST, 137 | (string)$class, 138 | (string)$name 139 | )); 140 | } 141 | 142 | /** 143 | * Sends a heartbeat request 144 | * 145 | * Expect the Quassel IRC core to respond with a heartbeat reply with the 146 | * same Datetime object with millisecond precision. 147 | * 148 | * Giving a DateTime object is optional because the most common use case is 149 | * to send the current timestamp. It is recommended to not give one so the 150 | * appropriate timestamp is sent automatically. Otherwise, you should make 151 | * sure to use a DateTime object with appropriate precision. 152 | * 153 | * Note that the Quassel protocol limits the DateTime accuracy to 154 | * millisecond precision and incoming DateTime objects will always be 155 | * expressed in the current default timezone. Also note that the legacy 156 | * protocol only transports number of milliseconds since midnight, so this 157 | * is not suited a transport arbitrary timestamps. 158 | * 159 | * @param null|\DateTime $dt (optional) DateTime object with millisecond precision or null=current timestamp 160 | * @return bool 161 | */ 162 | public function writeHeartBeatRequest(\DateTime $dt = null) 163 | { 164 | if ($dt === null) { 165 | $dt = \DateTime::createFromFormat('U.u', sprintf('%.6F', microtime(true))); 166 | } 167 | 168 | return $this->write(array( 169 | Protocol::REQUEST_HEARTBEAT, 170 | $dt 171 | )); 172 | } 173 | 174 | /** 175 | * Sends a heartbeat reply 176 | * 177 | * This should be sent in response to an incoming heartbeat request from 178 | * the Quassel IRC core. 179 | * 180 | * Giving a DateTime object is mandatory because the most common use case is 181 | * responding with the same timestamp that was given in the incoming 182 | * heartbeat request message. 183 | * 184 | * @param \DateTime $dt 185 | * @return boolean 186 | */ 187 | public function writeHeartBeatReply(\DateTime $dt) 188 | { 189 | return $this->write(array( 190 | Protocol::REQUEST_HEARTBEATREPLY, 191 | $dt 192 | )); 193 | } 194 | 195 | /** 196 | * Sends a chat message to the given buffer/channel 197 | * 198 | * @param BufferInfo $bufferInfo buffer/channel to send to (from previous Message object or SessionInit message) 199 | * @param string $contents buffer input (chat message) to send 200 | * @return bool 201 | */ 202 | public function writeBufferInput(BufferInfo $bufferInfo, $contents) 203 | { 204 | return $this->write(array( 205 | Protocol::REQUEST_RPCCALL, 206 | "2sendInput(BufferInfo,QString)", 207 | new QVariant($bufferInfo, 'BufferInfo'), 208 | (string)$contents 209 | )); 210 | } 211 | 212 | /** 213 | * Sends a backlog request for the given buffer/channel 214 | * 215 | * If you want to fetch the newest 20 messages for a channel, you can simply 216 | * pass the correct buffer ID, a $maxAmount of 20 and leave the other 217 | * parameters unset. This will respond with a message that contains the last 218 | * 20 messages (if any) where the newest message is the first element in the 219 | * array of messages. 220 | * 221 | * ```php 222 | * $client->writeBufferRequestBacklog($id, -1, -1, 20, 0); 223 | * ``` 224 | * 225 | * If you want to fetch the next 20 older messages for this channel, you 226 | * can simply pick the message ID of the oldested (and thus last) message 227 | * in this array and pass this to this method as `$messageIdLast`. 228 | * 229 | * ```php 230 | * $oldest = end($messages)->id; 231 | * $client->writeBufferRequestBacklog($id, -1, $oldest, 20, 0); 232 | * ``` 233 | * 234 | * If you want to poll the channel for new messages, you can simply pick the 235 | * message ID of the newest (and thus first) message in the previous array 236 | * and pass this ID to this method as `$messageIdFirst`. This will return 237 | * the last 20 messages (if any) and will include the given message ID as 238 | * the last element in the array of messages if no more than 20 new messages 239 | * arrived in the meantime. If no new messages are available, this array 240 | * will contain the given message ID as the only entry. 241 | * 242 | * ```php 243 | * $newest = reset($messages)->id; 244 | * $client->writeBufferRequestBacklog($id, $newest, -1, 20, 0); 245 | * ``` 246 | * 247 | * @param int $bufferId buffer/channel to fetch backlog from 248 | * @param int $messageIdFirst optional, only fetch messages newer than this ID, -1=no limit 249 | * @param int $messageIdLast optional, only fetch messages older than this ID, -1=no limit 250 | * @param int $maxAmount maximum number of messages to fetch at once, -1=no limit 251 | * @param int $additional number of additional messages to fetch, 0=none, -1=no limit 252 | * @return bool 253 | * @see self::writeBufferRequestBacklogAll() 254 | */ 255 | public function writeBufferRequestBacklog($bufferId, $messageIdFirst, $messageIdLast, $maxAmount, $additional) 256 | { 257 | return $this->write(array( 258 | Protocol::REQUEST_SYNC, 259 | "BacklogManager", 260 | "", 261 | "requestBacklog", 262 | new QVariant((int)$bufferId, 'BufferId'), 263 | new QVariant((int)$messageIdFirst, 'MsgId'), 264 | new QVariant((int)$messageIdLast, 'MsgId'), 265 | (int)$maxAmount, 266 | (int)$additional 267 | )); 268 | } 269 | 270 | /** 271 | * Sends a backlog request for all messages in all channels 272 | * 273 | * @param int $messageIdFirst 274 | * @param int $messageIdLast 275 | * @param int $maxAmount 276 | * @param int $additional 277 | * @return bool 278 | * @see self::writeBufferRequestBacklog() for parameter description 279 | */ 280 | public function writeBufferRequestBacklogAll($messageIdFirst, $messageIdLast, $maxAmount, $additional) 281 | { 282 | return $this->write(array( 283 | Protocol::REQUEST_SYNC, 284 | "BacklogManager", 285 | "", 286 | "requestBacklogAll", 287 | new QVariant((int)$messageIdFirst, 'MsgId'), 288 | new QVariant((int)$messageIdLast, 'MsgId'), 289 | (int)$maxAmount, 290 | (int)$additional 291 | )); 292 | } 293 | 294 | public function writeBufferRequestRemove($bufferId) 295 | { 296 | return $this->write(array( 297 | Protocol::REQUEST_SYNC, 298 | "BufferSyncer", 299 | "", 300 | new QVariant("requestRemoveBuffer", Types::TYPE_QBYTE_ARRAY), 301 | new QVariant($bufferId, 'BufferId') 302 | )); 303 | } 304 | 305 | public function writeBufferRequestMarkAsRead($bufferId) 306 | { 307 | return $this->write(array( 308 | Protocol::REQUEST_SYNC, 309 | "BufferSyncer", 310 | "", 311 | new QVariant("requestMarkBufferAsRead", Types::TYPE_QBYTE_ARRAY), 312 | new QVariant($bufferId, 'BufferId') 313 | )); 314 | } 315 | 316 | public function writeBufferRequestSetLastSeenMessage($bufferId, $messageId) 317 | { 318 | return $this->write(array( 319 | Protocol::REQUEST_SYNC, 320 | "BufferSyncer", 321 | "", 322 | new QVariant("requestSetLastSeenMsg", Types::TYPE_QBYTE_ARRAY), 323 | new QVariant($bufferId, 'BufferId'), 324 | new QVariant($messageId, 'MsgId') 325 | )); 326 | } 327 | 328 | public function writeBufferRequestSetMarkerLine($bufferId, $messageId) 329 | { 330 | return $this->write(array( 331 | Protocol::REQUEST_SYNC, 332 | "BufferSyncer", 333 | "", 334 | new QVariant("requestSetMarkerLine", Types::TYPE_QBYTE_ARRAY), 335 | new QVariant($bufferId, 'BufferId'), 336 | new QVariant($messageId, 'MsgId') 337 | )); 338 | } 339 | 340 | public function writeNetworkRequestConnect($networkId) 341 | { 342 | return $this->write(array( 343 | Protocol::REQUEST_SYNC, 344 | "Network", 345 | (string)$networkId, 346 | new QVariant("requestConnect", Types::TYPE_QBYTE_ARRAY) 347 | )); 348 | } 349 | 350 | public function writeNetworkRequestDisconnect($networkId) 351 | { 352 | return $this->write(array( 353 | Protocol::REQUEST_SYNC, 354 | "Network", 355 | (string)$networkId, 356 | new QVariant("requestDisconnect", Types::TYPE_QBYTE_ARRAY) 357 | )); 358 | } 359 | 360 | /** 361 | * Permanently removes the network identified by the given network ID. 362 | * 363 | * Will be followed by `[2,"2networkRemoved(NetworkId)",4]` on success. 364 | * 365 | * See "SessionInit" or `BufferInfo` for possible network IDs to remove. 366 | * 367 | * @param int $networkId 368 | * @return bool 369 | */ 370 | public function writeNetworkRemove($networkId) 371 | { 372 | return $this->write(array( 373 | Protocol::REQUEST_RPCCALL, 374 | "2removeNetwork(NetworkId)", 375 | new QVariant($networkId, 'NetworkId') 376 | )); 377 | } 378 | 379 | public function isReadable() 380 | { 381 | return $this->stream->isReadable(); 382 | } 383 | 384 | public function isWritable() 385 | { 386 | return $this->stream->isWritable(); 387 | } 388 | 389 | public function pipe(WritableStreamInterface $dest, array $options = array()) 390 | { 391 | Util::pipe($this, $dest, $options); 392 | 393 | return $dest; 394 | } 395 | 396 | public function pause() 397 | { 398 | $this->stream->pause(); 399 | } 400 | 401 | public function resume() 402 | { 403 | $this->stream->resume(); 404 | } 405 | 406 | /** 407 | * writes the given data array to the underlying connection 408 | * 409 | * This is a low level method that should only be used if you know what 410 | * you're doing. Also check the other write*() methods instead. 411 | * 412 | * @param array $data 413 | * @return boolean returns boolean false if buffer is full and writing should be throttled 414 | */ 415 | public function write($data) 416 | { 417 | return $this->stream->write( 418 | $this->splitter->writePacket( 419 | $this->protocol->serializeVariantPacket($data) 420 | ) 421 | ); 422 | } 423 | 424 | public function end($data = null) 425 | { 426 | if ($data !== null) { 427 | $this->write($data); 428 | } 429 | 430 | $this->stream->end(); 431 | } 432 | 433 | public function close() 434 | { 435 | if ($this->closed) { 436 | return; 437 | } 438 | 439 | $this->closed = true; 440 | $this->stream->close(); 441 | 442 | $this->emit('close'); 443 | $this->removeAllListeners(); 444 | } 445 | 446 | /** @internal */ 447 | public function handleData($chunk) 448 | { 449 | // chunk of packet data received 450 | // feed chunk to splitter, which will invoke a callable for each complete packet 451 | try { 452 | $this->splitter->push($chunk, array($this, 'handlePacket')); 453 | } catch (\OverflowException $e) { 454 | $this->handleError($e); 455 | } 456 | } 457 | 458 | /** @internal */ 459 | public function handlePacket($packet) 460 | { 461 | // complete packet data received 462 | // parse variant data from binary packet and forward as data event 463 | $this->emit('data', array($this->protocol->parseVariantPacket($packet))); 464 | } 465 | 466 | /** @internal */ 467 | public function handleEnd() 468 | { 469 | if ($this->splitter->isEmpty()) { 470 | $this->emit('end'); 471 | $this->close(); 472 | } else { 473 | $this->handleError(new \RuntimeException('Connection ended while receiving data')); 474 | } 475 | } 476 | 477 | /** @internal */ 478 | public function handleError(\Exception $e) 479 | { 480 | $this->emit('error', array($e)); 481 | $this->close(); 482 | } 483 | 484 | /** @internal */ 485 | public function handleDrain() 486 | { 487 | $this->emit('drain'); 488 | } 489 | } 490 | -------------------------------------------------------------------------------- /src/Factory.php: -------------------------------------------------------------------------------- 1 | loop = $loop ?: Loop::get(); 29 | if ($connector === null) { 30 | $connector = new Connector($loop); 31 | } 32 | if ($prober === null) { 33 | $prober = new Prober(); 34 | } 35 | $this->connector = $connector; 36 | $this->prober = $prober; 37 | } 38 | 39 | public function createClient($uri) 40 | { 41 | if (strpos($uri, '://') === false) { 42 | $uri= 'quassel://' . $uri; 43 | } 44 | $parts = parse_url($uri); 45 | if (!$parts || !isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'quassel') { 46 | return Promise\reject(new InvalidArgumentException('Given argument "' . $uri. '" is not a valid Quassel URI')); 47 | } 48 | if (!isset($parts['port'])) { 49 | $parts['port'] = 4242; 50 | } 51 | 52 | $args = array(); 53 | if (isset($parts['query'])) { 54 | parse_str($parts['query'], $args); 55 | } 56 | 57 | // establish low-level TCP/IP connection to Quassel IRC core 58 | $promise = $this->connector->connect($parts['host'] . ':' . $parts['port']); 59 | 60 | // probe protocol once connected 61 | $probe = 0; 62 | $connector = $this->connector; 63 | $prober = $this->prober; 64 | $promise = $promise->then(function (DuplexStreamInterface $stream) use ($prober, &$probe, $connector, $parts) { 65 | return $prober->probe($stream)->then( 66 | function ($ret) use (&$probe, $stream) { 67 | // probe returned successfully, create new client for this stream 68 | $probe = $ret; 69 | 70 | return $stream; 71 | }, 72 | function ($e) use ($connector, $parts) { 73 | // probing failed 74 | if ($e->getCode() === Prober::ERROR_CLOSED) { 75 | // legacy servers will terminate connection while probing 76 | // let's just open a new connection and assume default probe 77 | return $connector->connect($parts['host'] . ':' . $parts['port']); 78 | } 79 | throw $e; 80 | } 81 | ); 82 | }); 83 | 84 | // decorate client once probing is finished 85 | $promise = $promise->then( 86 | function (DuplexStreamInterface $stream) use (&$probe) { 87 | return new Client($stream, Protocol::createFromProbe($probe)); 88 | } 89 | ); 90 | 91 | // automatic login if username/password is given as part of URI 92 | if (isset($parts['user']) || isset($parts['pass'])) { 93 | $that = $this; 94 | $promise = $promise->then(function (Client $client) use ($that, $parts) { 95 | return $that->awaitLogin( 96 | $client, 97 | isset($parts['user']) ? urldecode($parts['user']) : '', 98 | isset($parts['pass']) ? urldecode($parts['pass']) : '' 99 | ); 100 | }); 101 | } 102 | 103 | // automatically send ping requests and await pong replies unless "?ping=0" is given 104 | // automatically reply to incoming ping requests with a pong unless "?pong=0" is given 105 | $ping = (!isset($args['ping'])) ? 60 : (float)$args['ping']; 106 | $pong = (!isset($args['pong']) || $args['pong']) ? true : false; 107 | if ($ping !== 0.0 || $pong) { 108 | $loop = $this->loop; 109 | $await = false; 110 | $promise = $promise->then(function (Client $client) use ($loop, $ping, $pong) { 111 | $timer = null; 112 | if ($ping !== 0.0) { 113 | // send heartbeat message every X seconds to check dropped connection 114 | $timer = $loop->addPeriodicTimer($ping, function () use ($client, &$await) { 115 | if ($await) { 116 | $client->emit('error', array( 117 | new \RuntimeException('Connection to Quassel core timed out') 118 | )); 119 | $client->close(); 120 | } else { 121 | $client->writeHeartBeatRequest(); 122 | $await = true; 123 | } 124 | }); 125 | 126 | // stop heartbeat timer once connection closes 127 | $client->on('close', function () use ($loop, &$timer) { 128 | $loop->cancelTimer($timer); 129 | $timer = null; 130 | }); 131 | } 132 | 133 | $client->on('data', function ($message) use ($client, $pong, &$timer, &$await, $loop) { 134 | // reply to incoming ping messages with pong 135 | if (is_array($message) && isset($message[0]) && $message[0] === Protocol::REQUEST_HEARTBEAT && $pong) { 136 | $client->writeHeartBeatReply($message[1]); 137 | } 138 | 139 | // restart heartbeat timer once data comes in 140 | if ($timer !== null) { 141 | $loop->cancelTimer($timer); 142 | $timer = $loop->addPeriodicTimer($timer->getInterval(), $timer->getCallback()); 143 | $await = false; 144 | } 145 | }); 146 | 147 | return $client; 148 | }); 149 | } 150 | 151 | return $promise; 152 | } 153 | 154 | /** @internal */ 155 | public function awaitLogin(Client $client, $user, $pass) 156 | { 157 | return new Promise\Promise(function ($resolve, $reject) use ($client, $user, $pass) { 158 | // handle incoming response messages 159 | $client->on('data', $handler = function ($data) use ($resolve, $reject, $client, $user, $pass, &$handler) { 160 | $type = isset($data->MsgType) ? $data->MsgType : null; 161 | 162 | // continue to login if connection is initialized 163 | if ($type === 'ClientInitAck') { 164 | if (!isset($data->Configured) || !$data->Configured) { 165 | $reject(new \RuntimeException('Unable to log in to unconfigured Quassel IRC core')); 166 | return $client->close(); 167 | } 168 | 169 | $client->writeClientLogin($user, $pass); 170 | 171 | return; 172 | } 173 | 174 | // reject if core rejects initialization 175 | if ($type === 'ClientInitReject') { 176 | $reject(new \RuntimeException('Connection rejected by Quassel core: ' . $data->Error)); 177 | return $client->close(); 178 | } 179 | 180 | // reject promise if login is rejected 181 | if ($type === 'ClientLoginReject') { 182 | $reject(new \RuntimeException('Unable to log in: ' . $data->Error)); 183 | return $client->close(); 184 | } 185 | 186 | // resolve promise if login is successful 187 | if ($type === 'ClientLoginAck') { 188 | $client->removeListener('data', $handler); 189 | $handler = null; 190 | $resolve($client); 191 | 192 | return; 193 | } 194 | 195 | // otherwise reject if we receive an unexpected message 196 | $reject(new \RuntimeException('Received unexpected "' . $type . '" message during login')); 197 | $client->close(); 198 | }); 199 | 200 | // reject promise if client emits error 201 | $client->on('error', function ($error) use ($reject) { 202 | $reject($error); 203 | }); 204 | 205 | // reject promise if client closes while waiting for login 206 | $client->on('close', function () use ($reject) { 207 | $reject(new \RuntimeException('Unexpected close')); 208 | }); 209 | 210 | $client->writeClientInit(); 211 | }); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Io/Binary.php: -------------------------------------------------------------------------------- 1 | mapToList($data); 30 | } 31 | 32 | // datastream protocol always uses list contents without variant prefix 33 | $writer = new Writer($this->userTypeWriter); 34 | $writer->writeQVariantList($data); 35 | 36 | return (string)$writer; 37 | } 38 | 39 | public function parseVariantPacket($packet) 40 | { 41 | $reader = new Reader($packet, $this->userTypeReader); 42 | 43 | // datastream protocol always uses list contents (even for maps) 44 | $data = $reader->readQVariantList(); 45 | 46 | // if the first element is a string, then this is actually a map transported as a list 47 | // actual lists will always start with an integer request type 48 | if (is_string($data[0])) { 49 | // datastream protocol uses lists with UTF-8 keys 50 | // https://github.com/quassel/quassel/blob/master/src/common/protocols/datastream/datastreampeer.cpp#L109 51 | return $this->listToMap($data); 52 | } 53 | 54 | if ($data[0] === self::REQUEST_INITDATA) { 55 | // make sure InitData is in line with legacy protocol wire format 56 | // first 3 elements are unchanged, everything else should be a map 57 | // https://github.com/quassel/quassel/blob/master/src/common/protocols/datastream/datastreampeer.cpp#L383 58 | $data = array_slice($data, 0, 3) + array(3 => $this->listToMap(array_slice($data, 3))); 59 | } 60 | 61 | // Don't downcast newer datagram InitData for "Network" to older legacy variant. 62 | // The datastream protocol uses a much more network-efficient wire-protocol 63 | // which avoids repeating the same keys over and over again, but this 64 | // format is very hard to work with from a consumer's perspective. 65 | // Instead, we use a "logic" representation of the data inspired by the legacy protocol: 66 | // The "IrcUsersAndChannels" structure always contains the keys "Users" and "Channels" 67 | // both keys always consist of a list of objects with additional details. 68 | // https://github.com/quassel/quassel/commit/208ccb6d91ebb3c26a67c35c11411ba3ab27708a#diff-c3c5a4e63a0b757912ba28686747b040 69 | if (is_array($data) && isset($data[0]) && $data[0] === self::REQUEST_INITDATA && $data[1] === 'Network' && isset($data[3]->IrcUsersAndChannels)) { 70 | $new = (object)array( 71 | 'Users' => array(), 72 | 'Channels' => array() 73 | ); 74 | foreach ($data[3]->IrcUsersAndChannels as $type => $all) { 75 | // each type is logically represented by a list of objects 76 | // initialize with empty list even if no records are found at all 77 | $list = array(); 78 | foreach ($all as $key => $values) { 79 | foreach ($values as $i => $value) { 80 | if (!isset($list[$i])) { 81 | $list[$i] = new \stdClass(); 82 | } 83 | $list[$i]->$key = $value; 84 | } 85 | } 86 | $new->$type = $list; 87 | } 88 | $data[3]->IrcUsersAndChannels = $new; 89 | } 90 | 91 | return $data; 92 | } 93 | 94 | /** 95 | * converts the given map to a list 96 | * 97 | * @param mixed[]|array $map 98 | * @return mixed[]|array 99 | * @internal 100 | */ 101 | public function mapToList($map) 102 | { 103 | $list = array(); 104 | foreach ($map as $key => $value) { 105 | // explicitly pass key as UTF-8 byte array 106 | // pass value with automatic type detection 107 | $list []= new QVariant($key, Types::TYPE_QBYTE_ARRAY); 108 | $list []= $value; 109 | } 110 | return $list; 111 | } 112 | 113 | /** 114 | * converts the given list to a map 115 | * 116 | * @param mixed[]|array $list 117 | * @return \stdClass `map` 118 | * @internal 119 | */ 120 | public function listToMap(array $list) 121 | { 122 | $map = array(); 123 | for ($i = 0, $n = count($list); $i < $n; $i += 2) { 124 | $map[$list[$i]] = $list[$i + 1]; 125 | } 126 | return (object)$map; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Io/LegacyProtocol.php: -------------------------------------------------------------------------------- 1 | setTimeZone(new \DateTimeZone('UTC')); 24 | $data[1] = new QVariant($dt, Types::TYPE_QTIME); 25 | } 26 | 27 | // legacy protocol prefixes both list and map with variant information 28 | $writer = new Writer($this->userTypeWriter); 29 | $writer->writeQVariant($data); 30 | 31 | return (string)$writer; 32 | } 33 | 34 | public function parseVariantPacket($packet) 35 | { 36 | // legacy protcol always uses type prefix, so just read as variant 37 | $reader = new Reader($packet, $this->userTypeReader); 38 | $data = $reader->readQVariant(); 39 | 40 | // ping requests will actually be sent as QTime which assumes UTC timezone 41 | // times will be returned with local timezone, so account for offset to UTC 42 | if (is_array($data) && isset($data[0]) && ($data[0] === self::REQUEST_HEARTBEAT || $data[0] === self::REQUEST_HEARTBEATREPLY)) { 43 | $data[1]->modify($data[1]->getOffset() . ' seconds'); 44 | } 45 | 46 | // Don't upcast legacy InitData for "Network" to newer datagram variant. 47 | // The legacy protocol uses a rather inefficient format which repeats the 48 | // same keys for each and every object, but it's "logic" and easy to work with. 49 | // We use a similar "logic" representation on the data: 50 | // The "IrcUsersAndChannels" structure always contains the keys "Users" and "Channels" 51 | // both keys always consist of a list of objects with additional details. 52 | // https://github.com/quassel/quassel/commit/208ccb6d91ebb3c26a67c35c11411ba3ab27708a#diff-c3c5a4e63a0b757912ba28686747b040 53 | if (is_array($data) && isset($data[0]) && $data[0] === self::REQUEST_INITDATA && $data[1] === 'Network' && isset($data[3]->IrcUsersAndChannels)) { 54 | $data[3]->IrcUsersAndChannels = (object)array( 55 | 'Users' => array_values((array)$data[3]->IrcUsersAndChannels->users), 56 | 'Channels' => array_values((array)$data[3]->IrcUsersAndChannels->channels) 57 | ); 58 | } 59 | 60 | return $data; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Io/PacketSplitter.php: -------------------------------------------------------------------------------- 1 | buffer .= $chunk; 27 | 28 | while (isset($this->buffer[3])) { 29 | // buffer contains at least packet length 30 | $length = Binary::readUInt32(substr($this->buffer, 0, 4)); 31 | if ($length > self::MAX_SIZE) { 32 | throw new \OverflowException('Packet size of ' . $length . ' bytes exceeds maximum of ' . self::MAX_SIZE . ' bytes'); 33 | } 34 | 35 | // buffer contains last byte of packet 36 | if (!isset($this->buffer[3 + $length])) { 37 | return; 38 | } 39 | 40 | // parse packet and advance buffer 41 | call_user_func($fn, substr($this->buffer, 4, $length)); 42 | $this->buffer = substr($this->buffer, 4 + $length); 43 | } 44 | } 45 | 46 | /** 47 | * Checks whether there's any incomplete data in the incoming buffer 48 | * 49 | * @return bool 50 | */ 51 | public function isEmpty() 52 | { 53 | return ($this->buffer === ''); 54 | } 55 | 56 | /** 57 | * encode the given packet data to include framing (packet length) 58 | * 59 | * @param string $packet binary packet contents 60 | * @return string binary packet contents prefixed with frame length 61 | */ 62 | public function writePacket($packet) 63 | { 64 | // TODO: legacy compression / decompression 65 | // legacy protocol writes variant via DataStream to ByteArray 66 | // https://github.com/quassel/quassel/blob/master/src/common/protocols/legacy/legacypeer.cpp#L105 67 | // https://github.com/quassel/quassel/blob/master/src/common/protocols/legacy/legacypeer.cpp#L63 68 | //$data = $this->types->writeByteArray($data); 69 | 70 | // raw data is prefixed with length, then written 71 | // https://github.com/quassel/quassel/blob/master/src/common/remotepeer.cpp#L241 72 | return Binary::writeUInt32(strlen($packet)) . $packet; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Io/Prober.php: -------------------------------------------------------------------------------- 1 | write(Binary::writeUInt32($magic)); 25 | 26 | // list of supported protocol types (in order of preference) 27 | $types = array(Protocol::TYPE_DATASTREAM, Protocol::TYPE_LEGACY); 28 | 29 | // last item should get an END marker 30 | $last = array_pop($types); 31 | $types []= $last | Protocol::TYPELIST_END; 32 | 33 | foreach ($types as $type) { 34 | $stream->write(Binary::writeUInt32($type)); 35 | } 36 | 37 | $deferred = new Deferred(function ($resolve, $reject) use ($stream) { 38 | $reject(new \RuntimeException('Cancelled')); 39 | }); 40 | 41 | $buffer = ''; 42 | $fn = function ($data) use (&$buffer, &$fn, $stream, $deferred) { 43 | $buffer .= $data; 44 | 45 | if (isset($buffer[4])) { 46 | $stream->removeListener('data', $fn); 47 | $deferred->reject(new \UnexpectedValueException('Expected 4 bytes response, received more data, is this a quassel core?', Prober::ERROR_PROTOCOL)); 48 | return; 49 | } 50 | 51 | if (isset($buffer[3])) { 52 | $stream->removeListener('data', $fn); 53 | $deferred->resolve(Binary::readUInt32($buffer)); 54 | } 55 | }; 56 | $stream->on('data', $fn); 57 | 58 | $stream->on('close', function() use ($deferred) { 59 | $deferred->reject(new \RuntimeException('Stream closed, does this (old?) server support probing?', Prober::ERROR_CLOSED)); 60 | }); 61 | 62 | return $deferred->promise(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Io/Protocol.php: -------------------------------------------------------------------------------- 1 | userTypeReader = array( 49 | // All required by SessionInit 50 | 'NetworkId' => function (Reader $reader) { 51 | return $reader->readUInt(); 52 | }, 53 | 'Identity' => function (Reader $reader) { 54 | return $reader->readQVariantMap(); 55 | }, 56 | 'IdentityId' => function (Reader $reader) { 57 | return $reader->readUInt(); 58 | }, 59 | 'BufferInfo' => function (Reader $reader) { 60 | return new BufferInfo( 61 | $reader->readUInt(), 62 | $reader->readUInt(), 63 | $reader->readUShort(), 64 | $reader->readUInt(), 65 | $reader->readQByteArray() 66 | ); 67 | }, 68 | // all required by "Network" InitRequest 69 | 'Network::Server' => function (Reader $reader) { 70 | return $reader->readQVariantMap(); 71 | }, 72 | // unknown source? 73 | 'BufferId' => function(Reader $reader) { 74 | return $reader->readUInt(); 75 | }, 76 | 'Message' => function (Reader $reader) { 77 | $readTimestamp = function () use ($reader) { 78 | // create DateTime object with local time zone from unix timestamp 79 | $d = new \DateTime('@' . $reader->readUint()); 80 | $d->setTimeZone(new \DateTimeZone(date_default_timezone_get())); 81 | return $d; 82 | }; 83 | 84 | return new Message( 85 | $reader->readUInt(), 86 | $readTimestamp(), 87 | $reader->readUInt(), 88 | $reader->readUChar(), 89 | $reader->readQUserTypeByName('BufferInfo'), 90 | $reader->readQByteArray(), 91 | $reader->readQByteArray() 92 | ); 93 | }, 94 | 'MsgId' => function (Reader $reader) { 95 | return $reader->readUInt(); 96 | } 97 | ); 98 | 99 | $this->userTypeWriter = array( 100 | 'BufferInfo' => function (BufferInfo $buffer, Writer $writer) { 101 | $writer->writeUInt($buffer->id); 102 | $writer->writeUInt($buffer->networkId); 103 | $writer->writeUShort($buffer->type); 104 | $writer->writeUInt($buffer->groupId); 105 | $writer->writeQByteArray($buffer->name); 106 | }, 107 | 'BufferId' => function ($data, Writer $writer) { 108 | $writer->writeUInt($data); 109 | }, 110 | 'MsgId' => function ($data, Writer $writer) { 111 | $writer->writeUInt($data); 112 | }, 113 | 'NetworkId' => function ($data, Writer $writer) { 114 | $writer->writeUInt($data); 115 | } 116 | ); 117 | } 118 | 119 | /** 120 | * Returns whether this instance encode/decodes for the old legacy protcol 121 | * 122 | * @return boolean 123 | */ 124 | abstract public function isLegacy(); 125 | 126 | /** 127 | * encode the given list of values or map of key/value pairs to a binary packet 128 | * 129 | * @param mixed[]|array $data 130 | * @return string binary packet contents 131 | */ 132 | abstract public function serializeVariantPacket(array $data); 133 | 134 | /** 135 | * decodes the given packet contents and returns its representation in PHP 136 | * 137 | * @param string $packet binary packet contents 138 | * @return mixed[]|array list of values or map of key/value-pairs 139 | */ 140 | abstract public function parseVariantPacket($packet); 141 | } 142 | -------------------------------------------------------------------------------- /src/Models/BufferInfo.php: -------------------------------------------------------------------------------- 1 | id = $id; 52 | $this->networkId = $networkId; 53 | $this->type = $type; 54 | $this->groupId = $groupId; 55 | $this->name = $name; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Models/Message.php: -------------------------------------------------------------------------------- 1 | id = $id; 86 | $this->timestamp = $timestamp; 87 | $this->type = $type; 88 | $this->flags = $flags; 89 | $this->bufferInfo = $bufferInfo; 90 | $this->sender = $sender; 91 | $this->contents = $contents; 92 | } 93 | } 94 | --------------------------------------------------------------------------------